xq-lsp 1.8.3-next.430 → 1.8.3-next.439

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,3 +17,13 @@ export interface ModuleImportDiagnostic extends TypeDiagnostic {
17
17
  * checked here.
18
18
  */
19
19
  export declare function checkModuleImportTargets(analysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>): ModuleImportDiagnostic[];
20
+ export interface UnresolvedImportDiagnostic extends TypeDiagnostic {
21
+ code: "xq-lsp:unresolved-import";
22
+ }
23
+ /**
24
+ * Report imports whose module could not be loaded. Variable and function
25
+ * checks stay silent for such namespaces, so this is where the user learns
26
+ * why. Reported as a warning (not XQST0059) because the module may
27
+ * legitimately live outside the workspace or be generated at install time.
28
+ */
29
+ export declare function checkUnresolvedImports(analysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>): UnresolvedImportDiagnostic[];
@@ -33,3 +33,24 @@ export function checkModuleImportTargets(analysis, importedAnalyses) {
33
33
  }
34
34
  return out;
35
35
  }
36
+ /**
37
+ * Report imports whose module could not be loaded. Variable and function
38
+ * checks stay silent for such namespaces, so this is where the user learns
39
+ * why. Reported as a warning (not XQST0059) because the module may
40
+ * legitimately live outside the workspace or be generated at install time.
41
+ */
42
+ export function checkUnresolvedImports(analysis, importedAnalyses) {
43
+ const out = [];
44
+ for (const imp of analysis.imports) {
45
+ if (importedAnalyses.has(imp.atPath ?? imp.namespaceUri) || importedAnalyses.has(imp.namespaceUri))
46
+ continue;
47
+ const location = imp.atPath ? `at '${imp.atPath}'` : `for namespace '${imp.namespaceUri}'`;
48
+ out.push({
49
+ message: `Module ${location} could not be resolved; its functions and variables are not checked`,
50
+ code: "xq-lsp:unresolved-import",
51
+ offset: imp.offset,
52
+ length: imp.prefix.length,
53
+ });
54
+ }
55
+ return out;
56
+ }
@@ -3,12 +3,27 @@ import type { FileAnalysis } from "./types.ts";
3
3
  export type NamespaceUsageKind = "function" | "variable" | "element";
4
4
  export interface NamespaceDiagnostic {
5
5
  message: string;
6
- code: "XQST0081";
6
+ code: "XQST0081" | "FONS0004";
7
7
  offset: number;
8
8
  length: number;
9
9
  prefix: string;
10
10
  usageKind: NamespaceUsageKind;
11
11
  }
12
+ export interface DynamicPrefixUse {
13
+ /** The prefix text. */
14
+ prefix: string;
15
+ /** Source offset of the prefix (inside the string literal). */
16
+ offset: number;
17
+ /** True when an unbound prefix is an error (xs:QName / xs:NOTATION); false for namespace-uri-for-prefix, which just returns (). */
18
+ required: boolean;
19
+ }
20
+ /**
21
+ * Prefixes taken from a string literal and resolved against the static
22
+ * in-scope namespaces at runtime: `xs:QName("p:l")`, `"p:l" cast as xs:QName`
23
+ * (also `castable as`, `xs:NOTATION`) and `fn:namespace-uri-for-prefix("p", …)`.
24
+ * Only literal operands are detected.
25
+ */
26
+ export declare function findDynamicPrefixUses(ast: Node, analysis: FileAnalysis): DynamicPrefixUse[];
12
27
  /**
13
28
  * Walk the AST and report every prefixed name reference whose prefix is not
14
29
  * declared in `analysis` (via import module namespace, declare namespace,
@@ -1,4 +1,70 @@
1
- import { isTerminal, directChildOf, firstTerminalValue, resolvePrefix } from "./analyzer.js";
1
+ import { XMLNS_FN, XMLNS_XS, isTerminal, directChildOf, findAll, firstTerminalValue, parseEQName, resolvePrefix, } from "./analyzer.js";
2
+ import { asFunctionCall } from "./ast-nodes.js";
3
+ /** Unwraps single-child grammar wrappers down to a StringLiteral terminal; null for anything more complex. */
4
+ function asStringLiteral(node) {
5
+ let cur = node;
6
+ while (cur && !isTerminal(cur)) {
7
+ const children = cur.children;
8
+ if (children.length !== 1)
9
+ return null;
10
+ cur = children[0];
11
+ }
12
+ return cur && cur.type === "StringLiteral" ? cur : null;
13
+ }
14
+ /**
15
+ * Prefixes taken from a string literal and resolved against the static
16
+ * in-scope namespaces at runtime: `xs:QName("p:l")`, `"p:l" cast as xs:QName`
17
+ * (also `castable as`, `xs:NOTATION`) and `fn:namespace-uri-for-prefix("p", …)`.
18
+ * Only literal operands are detected.
19
+ */
20
+ export function findDynamicPrefixUses(ast, analysis) {
21
+ const out = [];
22
+ const fromQNameLiteral = (lit, required) => {
23
+ if (!lit)
24
+ return;
25
+ const text = lit.value.slice(1, -1);
26
+ const trimmedStart = text.length - text.trimStart().length;
27
+ const t = text.trim();
28
+ const c = t.indexOf(":");
29
+ if (c <= 0)
30
+ return;
31
+ out.push({ prefix: t.slice(0, c), offset: lit.start + 1 + trimmedStart, required });
32
+ };
33
+ for (const call of findAll(ast, "FunctionCall")) {
34
+ const fc = asFunctionCall(call, analysis);
35
+ if (!fc)
36
+ continue;
37
+ const { namespaceUri, localName } = fc.qname;
38
+ const lit = asStringLiteral(fc.args[0]);
39
+ if (!lit)
40
+ continue;
41
+ if (namespaceUri === XMLNS_XS && (localName === "QName" || localName === "NOTATION")) {
42
+ fromQNameLiteral(lit, true);
43
+ }
44
+ else if (namespaceUri === XMLNS_FN && localName === "namespace-uri-for-prefix") {
45
+ const prefix = lit.value.slice(1, -1);
46
+ if (prefix.length > 0)
47
+ out.push({ prefix, offset: lit.start + 1, required: false });
48
+ }
49
+ }
50
+ // castable as never raises an error for an unbound prefix; it just yields false.
51
+ for (const type of ["CastExpr", "CastableExpr"]) {
52
+ for (const node of findAll(ast, type)) {
53
+ const single = directChildOf(node, "SingleType");
54
+ const typeNode = single && findAll(single, "QName")[0];
55
+ const typeName = typeNode && firstTerminalValue(typeNode);
56
+ if (!typeName)
57
+ continue;
58
+ const { prefix, localName, uri } = parseEQName(typeName);
59
+ if ((uri ?? resolvePrefix(prefix, analysis)) !== XMLNS_XS)
60
+ continue;
61
+ if (localName !== "QName" && localName !== "NOTATION")
62
+ continue;
63
+ fromQNameLiteral(asStringLiteral(node.children[0]), type === "CastExpr");
64
+ }
65
+ }
66
+ return out;
67
+ }
2
68
  function checkQName(node, kind, analysis, inlineStack, out) {
3
69
  if (!node)
4
70
  return;
@@ -23,6 +89,20 @@ function checkQName(node, kind, analysis, inlineStack, out) {
23
89
  });
24
90
  }
25
91
  }
92
+ function inlineXmlnsPrefixes(elem) {
93
+ const local = new Set();
94
+ const attrList = directChildOf(elem, "DirAttributeList");
95
+ if (attrList && !isTerminal(attrList)) {
96
+ for (const c of attrList.children) {
97
+ if (c.type !== "QName")
98
+ continue;
99
+ const attrName = firstTerminalValue(c);
100
+ if (attrName?.startsWith("xmlns:"))
101
+ local.add(attrName.slice(6));
102
+ }
103
+ }
104
+ return local;
105
+ }
26
106
  function walk(node, analysis, inlineStack, out) {
27
107
  if (isTerminal(node))
28
108
  return;
@@ -30,17 +110,7 @@ function walk(node, analysis, inlineStack, out) {
30
110
  if (node.type === "DirElemConstructor") {
31
111
  // Collect xmlns:prefix declarations from this element's attribute list,
32
112
  // then push them onto the scope stack for the duration of this subtree.
33
- const local = new Set();
34
- const attrList = directChildOf(node, "DirAttributeList");
35
- if (attrList && !isTerminal(attrList)) {
36
- for (const c of attrList.children) {
37
- if (c.type !== "QName")
38
- continue;
39
- const attrName = firstTerminalValue(c);
40
- if (attrName?.startsWith("xmlns:"))
41
- local.add(attrName.slice(6));
42
- }
43
- }
113
+ const local = inlineXmlnsPrefixes(node);
44
114
  inlineStack.push(local);
45
115
  checkQName(directChildOf(node, "QName"), "element", analysis, inlineStack, out);
46
116
  for (const c of children)
@@ -77,6 +147,29 @@ export function findUndeclaredPrefixUsages(ast, analysis) {
77
147
  return [];
78
148
  const out = [];
79
149
  walk(ast, analysis, [], out);
150
+ // Prefixes inside string literals resolve against the in-scope namespaces, which
151
+ // include xmlns:prefix declarations of enclosing direct element constructors.
152
+ const dirElems = findAll(ast, "DirElemConstructor").map((el) => ({
153
+ start: el.start ?? 0,
154
+ end: el.end ?? 0,
155
+ prefixes: inlineXmlnsPrefixes(el),
156
+ }));
157
+ for (const use of findDynamicPrefixUses(ast, analysis)) {
158
+ if (!use.required)
159
+ continue;
160
+ if (dirElems.some((el) => el.start <= use.offset && use.offset < el.end && el.prefixes.has(use.prefix)))
161
+ continue;
162
+ if (!resolvePrefix(use.prefix, analysis).startsWith("urn:xq-lsp:undeclared:"))
163
+ continue;
164
+ out.push({
165
+ message: `Namespace prefix '${use.prefix}' is not declared`,
166
+ code: "FONS0004",
167
+ offset: use.offset,
168
+ length: use.prefix.length,
169
+ prefix: use.prefix,
170
+ usageKind: "element",
171
+ });
172
+ }
80
173
  return out;
81
174
  }
82
175
  // ── Insertion-position helpers (used by the code action handler) ─────────────
package/dist/server.js CHANGED
@@ -18,6 +18,7 @@ import { findConfig, expandGlobs } from "./config.js";
18
18
  import { findImportInsertPosition, findDeclareNsInsertPosition, computeRelativePath } from "./namespace-diagnostics.js";
19
19
  import { findUndeclaredPrefixUsages } from "./namespace-diagnostics.js";
20
20
  import { runDiagnostics, runHints } from "./diagnostics.js";
21
+ import { checkUnresolvedImports } from "./module-import-diagnostics.js";
21
22
  import { getRuntimeAnalyses, getRuntimePredeclaredNamespaces, withPredeclaredNs, getRuntimePredeclaredVariables, withPredeclaredVariables, } from "./runtimes.js";
22
23
  import { buildOrganizeImportsEdit, buildExtractVariableEdit, buildExtractFunctionEdit } from "./refactor-actions.js";
23
24
  import { getWorkspaceSymbols } from "./workspace-symbol.js";
@@ -279,6 +280,7 @@ function computeDiagnostics(doc) {
279
280
  const nsDiagRaw = findUndeclaredPrefixUsages(ast, analysis);
280
281
  const errorDiagRaw = hasAst && ast !== null ? runDiagnostics(ast, doc.getText(), analysis, resolvedImported) : [];
281
282
  const hintDiagRaw = hasAst && ast !== null ? runHints(ast, analysis) : [];
283
+ const unresolvedImportRaw = hasAst ? checkUnresolvedImports(analysis, resolvedImported) : [];
282
284
  function toLsp(d, severity) {
283
285
  return {
284
286
  severity,
@@ -295,7 +297,8 @@ function computeDiagnostics(doc) {
295
297
  // nsDiags already covers XQST0081 (with the code-action data field), so exclude those here.
296
298
  const errorDiags = errorDiagRaw.filter((d) => d.code !== "XQST0081").map((d) => toLsp(d, DiagnosticSeverity.Error));
297
299
  const hintDiags = hintDiagRaw.map((d) => toLsp(d, DiagnosticSeverity.Hint));
298
- return [...parseDiags, ...nsDiags, ...errorDiags, ...hintDiags];
300
+ const unresolvedImportDiags = unresolvedImportRaw.map((d) => toLsp(d, DiagnosticSeverity.Warning));
301
+ return [...parseDiags, ...nsDiags, ...errorDiags, ...unresolvedImportDiags, ...hintDiags];
299
302
  }
300
303
  documents.onDidChangeContent((change) => {
301
304
  connection.sendDiagnostics({ uri: change.document.uri, diagnostics: computeDiagnostics(change.document) });
@@ -524,7 +527,7 @@ connection.onCodeAction((params) => {
524
527
  const actions = [];
525
528
  const only = params.context.only;
526
529
  for (const diag of params.context.diagnostics) {
527
- if (diag.code !== "XQST0081")
530
+ if (diag.code !== "XQST0081" && diag.code !== "FONS0004")
528
531
  continue;
529
532
  const data = diag.data;
530
533
  if (!data?.prefix)
@@ -1,4 +1,5 @@
1
1
  import { qnameKey, formatQName } from "./types.js";
2
+ import { findDynamicPrefixUses } from "./namespace-diagnostics.js";
2
3
  import { isTerminal, directChildOf, directChildrenOf, findAll, firstTerminalValue, parseEQName, resolvePrefix } from "./analyzer.js";
3
4
  import { asFunctionCall, asNamedFunctionRef, asVarRef, asVarName, asFunctionDecl, asFunctionDeclaration, asVariableDeclaration, asBinding, asCatchClause, } from "./ast-nodes.js";
4
5
  // ── Scope stack ───────────────────────────────────────────────────────────────
@@ -170,7 +171,7 @@ function walk(node, scope, analysis, usedFns, usedVars) {
170
171
  * two terminal types catches every usage site without enumerating each
171
172
  * grammar production that can carry a prefix.
172
173
  */
173
- function collectUsedPrefixes(ast) {
174
+ function collectUsedPrefixes(ast, analysis) {
174
175
  const used = new Set();
175
176
  for (const node of findAll(ast, "QName")) {
176
177
  const value = firstTerminalValue(node);
@@ -191,8 +192,13 @@ function collectUsedPrefixes(ast) {
191
192
  if (prefix !== "*")
192
193
  used.add(prefix);
193
194
  }
195
+ collectDynamicPrefixUses(ast, analysis, used);
194
196
  return used;
195
197
  }
198
+ function collectDynamicPrefixUses(ast, analysis, used) {
199
+ for (const use of findDynamicPrefixUses(ast, analysis))
200
+ used.add(use.prefix);
201
+ }
196
202
  // ── Public entry point ────────────────────────────────────────────────────────
197
203
  /**
198
204
  * Walk the AST and report declared functions and module-level variables that
@@ -248,7 +254,7 @@ export function checkUnused(ast, analysis) {
248
254
  });
249
255
  }
250
256
  // ── Check imports and namespace declarations ─────────────────────────────
251
- const usedPrefixes = collectUsedPrefixes(ast);
257
+ const usedPrefixes = collectUsedPrefixes(ast, analysis);
252
258
  for (const imp of analysis.imports) {
253
259
  if (usedPrefixes.has(imp.prefix))
254
260
  continue;
@@ -11,6 +11,8 @@ export interface UndeclaredVariableDiagnostic {
11
11
  * to a declared variable: function parameter, let/for binding, module-level
12
12
  * `declare variable`, or an imported module variable.
13
13
  *
14
+ * Variables whose prefix is bound by an import that did not resolve to an
15
+ * analysed module are skipped too (XQST0059 reports the import itself).
14
16
  * Variables whose namespace prefix is undeclared are skipped — XQST0081
15
17
  * already fires for those, and adding XPST0008 on top would be noisy.
16
18
  * Variables inside catch clauses are also skipped since the implicit
@@ -10,10 +10,14 @@ class ScopeStack {
10
10
  has(key) { return this.frames.some((f) => f.has(key)); }
11
11
  }
12
12
  // ── Helpers ───────────────────────────────────────────────────────────────────
13
- function isKnown(qname, scope, moduleVarKeys) {
13
+ function isKnown(qname, scope, moduleVarKeys, unresolvedImportUris) {
14
14
  // Variables with an undeclared-prefix URI are already caught by XQST0081; skip here.
15
15
  if (qname.namespaceUri.startsWith("urn:xq-lsp:undeclared:"))
16
16
  return true;
17
+ // The import exists but its module was not analysed (outside the workspace,
18
+ // generated at install time, …): we cannot know what it declares, so stay silent.
19
+ if (unresolvedImportUris.has(qname.namespaceUri))
20
+ return true;
17
21
  return scope.has(qnameKey(qname)) || moduleVarKeys.has(qnameKey(qname));
18
22
  }
19
23
  function reportVarRef(node, qname, out) {
@@ -25,18 +29,18 @@ function reportVarRef(node, qname, out) {
25
29
  length: name.length + 1, // +1 for "$"
26
30
  });
27
31
  }
28
- function addBinding(bindingNode, scope, analysis, moduleVarKeys, out) {
32
+ function addBinding(bindingNode, scope, analysis, moduleVarKeys, unresolvedImportUris, out) {
29
33
  const b = asBinding(bindingNode, analysis);
30
34
  if (!b)
31
35
  return;
32
36
  if (b.initExpr)
33
- walk(b.initExpr, scope, analysis, moduleVarKeys, out);
37
+ walk(b.initExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
34
38
  scope.add(qnameKey(b.qname));
35
39
  if (b.positionalVar)
36
40
  scope.add(qnameKey(b.positionalVar.qname));
37
41
  }
38
42
  // ── Scope-aware AST walker ────────────────────────────────────────────────────
39
- function walk(node, scope, analysis, moduleVarKeys, out) {
43
+ function walk(node, scope, analysis, moduleVarKeys, unresolvedImportUris, out) {
40
44
  if (isTerminal(node))
41
45
  return;
42
46
  const { children } = node;
@@ -49,7 +53,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
49
53
  for (const p of fn.params)
50
54
  scope.add(qnameKey(p.qname));
51
55
  if (fn.body)
52
- walk(fn.body, scope, analysis, moduleVarKeys, out);
56
+ walk(fn.body, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
53
57
  scope.pop();
54
58
  return;
55
59
  }
@@ -57,7 +61,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
57
61
  const varDecl = directChildOf(node, "VarDecl");
58
62
  if (varDecl && !isTerminal(varDecl)) {
59
63
  for (const child of varDecl.children)
60
- walk(child, scope, analysis, moduleVarKeys, out);
64
+ walk(child, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
61
65
  }
62
66
  return;
63
67
  }
@@ -77,19 +81,19 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
77
81
  switch (clause.type) {
78
82
  case "ForClause":
79
83
  for (const b of directChildrenOf(clause, "ForBinding"))
80
- addBinding(b, scope, analysis, moduleVarKeys, out);
84
+ addBinding(b, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
81
85
  break;
82
86
  case "LetClause":
83
87
  for (const b of directChildrenOf(clause, "LetBinding"))
84
- addBinding(b, scope, analysis, moduleVarKeys, out);
88
+ addBinding(b, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
85
89
  break;
86
90
  case "CountClause":
87
- addBinding(clause, scope, analysis, moduleVarKeys, out);
91
+ addBinding(clause, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
88
92
  break;
89
93
  case "GroupByClause": {
90
94
  const specList = directChildOf(clause, "GroupingSpecList");
91
95
  for (const spec of specList ? directChildrenOf(specList, "GroupingSpec") : [])
92
- addBinding(spec, scope, analysis, moduleVarKeys, out);
96
+ addBinding(spec, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
93
97
  break;
94
98
  }
95
99
  case "WindowClause": {
@@ -99,7 +103,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
99
103
  if (b) {
100
104
  // The "in" source expr is evaluated in the outer scope, before $w exists.
101
105
  if (b.initExpr)
102
- walk(b.initExpr, scope, analysis, moduleVarKeys, out);
106
+ walk(b.initExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
103
107
  // Start/end condition vars ($current/$positional/$previous/$next) are visible
104
108
  // across both conditions and the return clause — but $w itself is not yet bound
105
109
  // while its own boundary conditions are being evaluated.
@@ -121,14 +125,14 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
121
125
  }
122
126
  const whenExpr = directChildOf(cond, "ExprSingle");
123
127
  if (whenExpr)
124
- walk(whenExpr, scope, analysis, moduleVarKeys, out);
128
+ walk(whenExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
125
129
  }
126
130
  scope.add(qnameKey(b.qname));
127
131
  }
128
132
  break;
129
133
  }
130
134
  default:
131
- walk(clause, scope, analysis, moduleVarKeys, out);
135
+ walk(clause, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
132
136
  }
133
137
  }
134
138
  scope.pop();
@@ -145,7 +149,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
145
149
  scope.add(qnameKey(qname));
146
150
  }
147
151
  else {
148
- walk(child, scope, analysis, moduleVarKeys, out);
152
+ walk(child, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
149
153
  }
150
154
  }
151
155
  scope.pop();
@@ -159,7 +163,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
159
163
  for (const p of fn.params)
160
164
  scope.add(qnameKey(p.qname));
161
165
  if (fn.body)
162
- walk(fn.body, scope, analysis, moduleVarKeys, out);
166
+ walk(fn.body, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
163
167
  scope.pop();
164
168
  return;
165
169
  }
@@ -169,7 +173,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
169
173
  case "TypeswitchExpr": {
170
174
  const operand = directChildOf(node, "Expr");
171
175
  if (operand)
172
- walk(operand, scope, analysis, moduleVarKeys, out);
176
+ walk(operand, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
173
177
  for (const caseClause of directChildrenOf(node, "CaseClause")) {
174
178
  scope.push();
175
179
  const varNameNode = directChildOf(caseClause, "VarName");
@@ -178,7 +182,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
178
182
  scope.add(qnameKey(qname));
179
183
  const returnExpr = directChildOf(caseClause, "ExprSingle");
180
184
  if (returnExpr)
181
- walk(returnExpr, scope, analysis, moduleVarKeys, out);
185
+ walk(returnExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
182
186
  scope.pop();
183
187
  }
184
188
  // `default $v return ...` — $v (optional) and the return expr are direct
@@ -190,7 +194,7 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
190
194
  scope.add(qnameKey(defaultQname));
191
195
  const defaultExpr = directChildOf(node, "ExprSingle");
192
196
  if (defaultExpr)
193
- walk(defaultExpr, scope, analysis, moduleVarKeys, out);
197
+ walk(defaultExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
194
198
  scope.pop();
195
199
  return;
196
200
  }
@@ -200,25 +204,25 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
200
204
  break;
201
205
  scope.push();
202
206
  for (const b of tx.copyBindings) {
203
- walk(b.initExpr, scope, analysis, moduleVarKeys, out);
207
+ walk(b.initExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
204
208
  scope.add(qnameKey(b.qname));
205
209
  }
206
210
  if (tx.modifyExpr)
207
- walk(tx.modifyExpr, scope, analysis, moduleVarKeys, out);
211
+ walk(tx.modifyExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
208
212
  if (tx.returnExpr)
209
- walk(tx.returnExpr, scope, analysis, moduleVarKeys, out);
213
+ walk(tx.returnExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
210
214
  scope.pop();
211
215
  return;
212
216
  }
213
217
  case "VarRef": {
214
218
  const qname = asVarRef(node, analysis);
215
- if (qname && !isKnown(qname, scope, moduleVarKeys))
219
+ if (qname && !isKnown(qname, scope, moduleVarKeys, unresolvedImportUris))
216
220
  reportVarRef(node, qname, out);
217
221
  return;
218
222
  }
219
223
  default:
220
224
  for (const child of children)
221
- walk(child, scope, analysis, moduleVarKeys, out);
225
+ walk(child, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
222
226
  }
223
227
  }
224
228
  // ── Public entry point ────────────────────────────────────────────────────────
@@ -227,6 +231,8 @@ function walk(node, scope, analysis, moduleVarKeys, out) {
227
231
  * to a declared variable: function parameter, let/for binding, module-level
228
232
  * `declare variable`, or an imported module variable.
229
233
  *
234
+ * Variables whose prefix is bound by an import that did not resolve to an
235
+ * analysed module are skipped too (XQST0059 reports the import itself).
230
236
  * Variables whose namespace prefix is undeclared are skipped — XQST0081
231
237
  * already fires for those, and adding XPST0008 on top would be noisy.
232
238
  * Variables inside catch clauses are also skipped since the implicit
@@ -239,7 +245,9 @@ export function checkUndeclaredVariables(ast, analysis, importedAnalyses) {
239
245
  for (const imported of importedAnalyses.values())
240
246
  for (const v of imported.moduleVariables)
241
247
  moduleVarKeys.add(qnameKey(v.qname));
248
+ // Imports whose module was not analysed: their variables are unknowable.
249
+ const unresolvedImportUris = new Set(analysis.imports.map((i) => i.namespaceUri).filter((u) => !importedAnalyses.has(u)));
242
250
  const out = [];
243
- walk(ast, new ScopeStack(), analysis, moduleVarKeys, out);
251
+ walk(ast, new ScopeStack(), analysis, moduleVarKeys, unresolvedImportUris, out);
244
252
  return out;
245
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xq-lsp",
3
- "version": "1.8.3-next.430",
3
+ "version": "1.8.3-next.439",
4
4
  "description": "XQuery Language Server Protocol implementation",
5
5
  "license": "MIT",
6
6
  "author": "Martin Middel",
@@ -41,3 +41,31 @@ export function checkModuleImportTargets(
41
41
  }
42
42
  return out;
43
43
  }
44
+
45
+ export interface UnresolvedImportDiagnostic extends TypeDiagnostic {
46
+ code: "xq-lsp:unresolved-import";
47
+ }
48
+
49
+ /**
50
+ * Report imports whose module could not be loaded. Variable and function
51
+ * checks stay silent for such namespaces, so this is where the user learns
52
+ * why. Reported as a warning (not XQST0059) because the module may
53
+ * legitimately live outside the workspace or be generated at install time.
54
+ */
55
+ export function checkUnresolvedImports(
56
+ analysis: FileAnalysis,
57
+ importedAnalyses: Map<string, FileAnalysis>,
58
+ ): UnresolvedImportDiagnostic[] {
59
+ const out: UnresolvedImportDiagnostic[] = [];
60
+ for (const imp of analysis.imports) {
61
+ if (importedAnalyses.has(imp.atPath ?? imp.namespaceUri) || importedAnalyses.has(imp.namespaceUri)) continue;
62
+ const location = imp.atPath ? `at '${imp.atPath}'` : `for namespace '${imp.namespaceUri}'`;
63
+ out.push({
64
+ message: `Module ${location} could not be resolved; its functions and variables are not checked`,
65
+ code: "xq-lsp:unresolved-import",
66
+ offset: imp.offset,
67
+ length: imp.prefix.length,
68
+ });
69
+ }
70
+ return out;
71
+ }
@@ -1,18 +1,96 @@
1
- import type { Node } from "xq-parser";
1
+ import type { Node, NonTerminal, Terminal } from "xq-parser";
2
2
  import type { FileAnalysis } from "./types.ts";
3
- import { isTerminal, directChildOf, firstTerminalValue, resolvePrefix } from "./analyzer.ts";
3
+ import {
4
+ XMLNS_FN,
5
+ XMLNS_XS,
6
+ isTerminal,
7
+ directChildOf,
8
+ findAll,
9
+ firstTerminalValue,
10
+ parseEQName,
11
+ resolvePrefix,
12
+ } from "./analyzer.ts";
13
+ import { asFunctionCall } from "./ast-nodes.ts";
4
14
 
5
15
  export type NamespaceUsageKind = "function" | "variable" | "element";
6
16
 
7
17
  export interface NamespaceDiagnostic {
8
18
  message: string;
9
- code: "XQST0081";
19
+ code: "XQST0081" | "FONS0004";
10
20
  offset: number; // offset of the prefix start in source
11
21
  length: number; // length of the prefix (not including the colon)
12
22
  prefix: string;
13
23
  usageKind: NamespaceUsageKind;
14
24
  }
15
25
 
26
+ /** Unwraps single-child grammar wrappers down to a StringLiteral terminal; null for anything more complex. */
27
+ function asStringLiteral(node: Node | undefined): Terminal | null {
28
+ let cur: Node | undefined = node;
29
+ while (cur && !isTerminal(cur)) {
30
+ const children = (cur as NonTerminal).children;
31
+ if (children.length !== 1) return null;
32
+ cur = children[0];
33
+ }
34
+ return cur && cur.type === "StringLiteral" ? (cur as Terminal) : null;
35
+ }
36
+
37
+ export interface DynamicPrefixUse {
38
+ /** The prefix text. */
39
+ prefix: string;
40
+ /** Source offset of the prefix (inside the string literal). */
41
+ offset: number;
42
+ /** True when an unbound prefix is an error (xs:QName / xs:NOTATION); false for namespace-uri-for-prefix, which just returns (). */
43
+ required: boolean;
44
+ }
45
+
46
+ /**
47
+ * Prefixes taken from a string literal and resolved against the static
48
+ * in-scope namespaces at runtime: `xs:QName("p:l")`, `"p:l" cast as xs:QName`
49
+ * (also `castable as`, `xs:NOTATION`) and `fn:namespace-uri-for-prefix("p", …)`.
50
+ * Only literal operands are detected.
51
+ */
52
+ export function findDynamicPrefixUses(ast: Node, analysis: FileAnalysis): DynamicPrefixUse[] {
53
+ const out: DynamicPrefixUse[] = [];
54
+ const fromQNameLiteral = (lit: Terminal | null, required: boolean): void => {
55
+ if (!lit) return;
56
+ const text = lit.value.slice(1, -1);
57
+ const trimmedStart = text.length - text.trimStart().length;
58
+ const t = text.trim();
59
+ const c = t.indexOf(":");
60
+ if (c <= 0) return;
61
+ out.push({ prefix: t.slice(0, c), offset: lit.start + 1 + trimmedStart, required });
62
+ };
63
+
64
+ for (const call of findAll(ast, "FunctionCall")) {
65
+ const fc = asFunctionCall(call, analysis);
66
+ if (!fc) continue;
67
+ const { namespaceUri, localName } = fc.qname;
68
+ const lit = asStringLiteral(fc.args[0]);
69
+ if (!lit) continue;
70
+ if (namespaceUri === XMLNS_XS && (localName === "QName" || localName === "NOTATION")) {
71
+ fromQNameLiteral(lit, true);
72
+ } else if (namespaceUri === XMLNS_FN && localName === "namespace-uri-for-prefix") {
73
+ const prefix = lit.value.slice(1, -1);
74
+ if (prefix.length > 0) out.push({ prefix, offset: lit.start + 1, required: false });
75
+ }
76
+ }
77
+
78
+ // castable as never raises an error for an unbound prefix; it just yields false.
79
+ for (const type of ["CastExpr", "CastableExpr"]) {
80
+ for (const node of findAll(ast, type)) {
81
+ const single = directChildOf(node, "SingleType");
82
+ const typeNode = single && findAll(single, "QName")[0];
83
+ const typeName = typeNode && firstTerminalValue(typeNode);
84
+ if (!typeName) continue;
85
+ const { prefix, localName, uri } = parseEQName(typeName);
86
+ if ((uri ?? resolvePrefix(prefix, analysis)) !== XMLNS_XS) continue;
87
+ if (localName !== "QName" && localName !== "NOTATION") continue;
88
+ fromQNameLiteral(asStringLiteral((node as NonTerminal).children[0]), type === "CastExpr");
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
16
94
  function checkQName(
17
95
  node: Node | undefined,
18
96
  kind: NamespaceUsageKind,
@@ -40,6 +118,19 @@ function checkQName(
40
118
  }
41
119
  }
42
120
 
121
+ function inlineXmlnsPrefixes(elem: Node): Set<string> {
122
+ const local = new Set<string>();
123
+ const attrList = directChildOf(elem, "DirAttributeList");
124
+ if (attrList && !isTerminal(attrList)) {
125
+ for (const c of (attrList as { children: Node[] }).children) {
126
+ if (c.type !== "QName") continue;
127
+ const attrName = firstTerminalValue(c);
128
+ if (attrName?.startsWith("xmlns:")) local.add(attrName.slice(6));
129
+ }
130
+ }
131
+ return local;
132
+ }
133
+
43
134
  function walk(
44
135
  node: Node,
45
136
  analysis: FileAnalysis,
@@ -52,15 +143,7 @@ function walk(
52
143
  if (node.type === "DirElemConstructor") {
53
144
  // Collect xmlns:prefix declarations from this element's attribute list,
54
145
  // then push them onto the scope stack for the duration of this subtree.
55
- const local = new Set<string>();
56
- const attrList = directChildOf(node, "DirAttributeList");
57
- if (attrList && !isTerminal(attrList)) {
58
- for (const c of (attrList as { children: Node[] }).children) {
59
- if (c.type !== "QName") continue;
60
- const attrName = firstTerminalValue(c);
61
- if (attrName?.startsWith("xmlns:")) local.add(attrName.slice(6));
62
- }
63
- }
146
+ const local = inlineXmlnsPrefixes(node);
64
147
  inlineStack.push(local);
65
148
  checkQName(directChildOf(node, "QName"), "element", analysis, inlineStack, out);
66
149
  for (const c of children) walk(c, analysis, inlineStack, out);
@@ -100,6 +183,27 @@ export function findUndeclaredPrefixUsages(
100
183
  if (!ast) return [];
101
184
  const out: NamespaceDiagnostic[] = [];
102
185
  walk(ast, analysis, [], out);
186
+
187
+ // Prefixes inside string literals resolve against the in-scope namespaces, which
188
+ // include xmlns:prefix declarations of enclosing direct element constructors.
189
+ const dirElems = findAll(ast, "DirElemConstructor").map((el) => ({
190
+ start: el.start ?? 0,
191
+ end: el.end ?? 0,
192
+ prefixes: inlineXmlnsPrefixes(el),
193
+ }));
194
+ for (const use of findDynamicPrefixUses(ast, analysis)) {
195
+ if (!use.required) continue;
196
+ if (dirElems.some((el) => el.start <= use.offset && use.offset < el.end && el.prefixes.has(use.prefix))) continue;
197
+ if (!resolvePrefix(use.prefix, analysis).startsWith("urn:xq-lsp:undeclared:")) continue;
198
+ out.push({
199
+ message: `Namespace prefix '${use.prefix}' is not declared`,
200
+ code: "FONS0004",
201
+ offset: use.offset,
202
+ length: use.prefix.length,
203
+ prefix: use.prefix,
204
+ usageKind: "element",
205
+ });
206
+ }
103
207
  return out;
104
208
  }
105
209
 
package/src/server.ts CHANGED
@@ -35,6 +35,7 @@ import { findImportInsertPosition, findDeclareNsInsertPosition, computeRelativeP
35
35
  import type { NamespaceUsageKind } from "./namespace-diagnostics.ts";
36
36
  import { findUndeclaredPrefixUsages } from "./namespace-diagnostics.ts";
37
37
  import { runDiagnostics, runHints } from "./diagnostics.ts";
38
+ import { checkUnresolvedImports } from "./module-import-diagnostics.ts";
38
39
  import {
39
40
  getRuntimeAnalyses,
40
41
  getRuntimePredeclaredNamespaces,
@@ -327,6 +328,8 @@ function computeDiagnostics(doc: TextDocument) {
327
328
  const errorDiagRaw = hasAst && ast !== null ? runDiagnostics(ast, doc.getText(), analysis, resolvedImported) : [];
328
329
  const hintDiagRaw = hasAst && ast !== null ? runHints(ast, analysis) : [];
329
330
 
331
+ const unresolvedImportRaw = hasAst ? checkUnresolvedImports(analysis, resolvedImported) : [];
332
+
330
333
  function toLsp(d: TypeDiagnostic, severity: DiagnosticSeverity) {
331
334
  return {
332
335
  severity,
@@ -345,7 +348,9 @@ function computeDiagnostics(doc: TextDocument) {
345
348
  const errorDiags = errorDiagRaw.filter((d) => d.code !== "XQST0081").map((d) => toLsp(d, DiagnosticSeverity.Error));
346
349
  const hintDiags = hintDiagRaw.map((d) => toLsp(d, DiagnosticSeverity.Hint));
347
350
 
348
- return [...parseDiags, ...nsDiags, ...errorDiags, ...hintDiags];
351
+ const unresolvedImportDiags = unresolvedImportRaw.map((d) => toLsp(d, DiagnosticSeverity.Warning));
352
+
353
+ return [...parseDiags, ...nsDiags, ...errorDiags, ...unresolvedImportDiags, ...hintDiags];
349
354
  }
350
355
 
351
356
  documents.onDidChangeContent((change) => {
@@ -588,7 +593,7 @@ connection.onCodeAction((params) => {
588
593
  const only = params.context.only as CodeActionKind[] | undefined;
589
594
 
590
595
  for (const diag of params.context.diagnostics) {
591
- if (diag.code !== "XQST0081") continue;
596
+ if (diag.code !== "XQST0081" && diag.code !== "FONS0004") continue;
592
597
  const data = diag.data as { prefix: string; usageKind: NamespaceUsageKind } | undefined;
593
598
  if (!data?.prefix) continue;
594
599
 
@@ -1,6 +1,7 @@
1
1
  import type { Node, NonTerminal } from "xq-parser";
2
2
  import type { FileAnalysis } from "./types.ts";
3
3
  import { qnameKey, formatQName } from "./types.ts";
4
+ import { findDynamicPrefixUses } from "./namespace-diagnostics.ts";
4
5
  import { isTerminal, directChildOf, directChildrenOf, findAll, firstTerminalValue, parseEQName, resolvePrefix } from "./analyzer.ts";
5
6
  import {
6
7
  asFunctionCall,
@@ -198,7 +199,7 @@ function walk(
198
199
  * two terminal types catches every usage site without enumerating each
199
200
  * grammar production that can carry a prefix.
200
201
  */
201
- function collectUsedPrefixes(ast: Node): Set<string> {
202
+ function collectUsedPrefixes(ast: Node, analysis: FileAnalysis): Set<string> {
202
203
  const used = new Set<string>();
203
204
 
204
205
  for (const node of findAll(ast, "QName")) {
@@ -217,9 +218,15 @@ function collectUsedPrefixes(ast: Node): Set<string> {
217
218
  if (prefix !== "*") used.add(prefix);
218
219
  }
219
220
 
221
+ collectDynamicPrefixUses(ast, analysis, used);
222
+
220
223
  return used;
221
224
  }
222
225
 
226
+ function collectDynamicPrefixUses(ast: Node, analysis: FileAnalysis, used: Set<string>): void {
227
+ for (const use of findDynamicPrefixUses(ast, analysis)) used.add(use.prefix);
228
+ }
229
+
223
230
  // ── Public entry point ────────────────────────────────────────────────────────
224
231
 
225
232
  /**
@@ -278,7 +285,7 @@ export function checkUnused(ast: Node, analysis: FileAnalysis): UnusedDiagnostic
278
285
 
279
286
  // ── Check imports and namespace declarations ─────────────────────────────
280
287
 
281
- const usedPrefixes = collectUsedPrefixes(ast);
288
+ const usedPrefixes = collectUsedPrefixes(ast, analysis);
282
289
 
283
290
  for (const imp of analysis.imports) {
284
291
  if (usedPrefixes.has(imp.prefix)) continue;
@@ -23,9 +23,12 @@ class ScopeStack {
23
23
 
24
24
  // ── Helpers ───────────────────────────────────────────────────────────────────
25
25
 
26
- function isKnown(qname: QName, scope: ScopeStack, moduleVarKeys: Set<string>): boolean {
26
+ function isKnown(qname: QName, scope: ScopeStack, moduleVarKeys: Set<string>, unresolvedImportUris: Set<string>): boolean {
27
27
  // Variables with an undeclared-prefix URI are already caught by XQST0081; skip here.
28
28
  if (qname.namespaceUri.startsWith("urn:xq-lsp:undeclared:")) return true;
29
+ // The import exists but its module was not analysed (outside the workspace,
30
+ // generated at install time, …): we cannot know what it declares, so stay silent.
31
+ if (unresolvedImportUris.has(qname.namespaceUri)) return true;
29
32
  return scope.has(qnameKey(qname)) || moduleVarKeys.has(qnameKey(qname));
30
33
  }
31
34
 
@@ -44,11 +47,12 @@ function addBinding(
44
47
  scope: ScopeStack,
45
48
  analysis: FileAnalysis,
46
49
  moduleVarKeys: Set<string>,
50
+ unresolvedImportUris: Set<string>,
47
51
  out: UndeclaredVariableDiagnostic[],
48
52
  ): void {
49
53
  const b = asBinding(bindingNode, analysis);
50
54
  if (!b) return;
51
- if (b.initExpr) walk(b.initExpr, scope, analysis, moduleVarKeys, out);
55
+ if (b.initExpr) walk(b.initExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
52
56
  scope.add(qnameKey(b.qname));
53
57
  if (b.positionalVar) scope.add(qnameKey(b.positionalVar.qname));
54
58
  }
@@ -60,6 +64,7 @@ function walk(
60
64
  scope: ScopeStack,
61
65
  analysis: FileAnalysis,
62
66
  moduleVarKeys: Set<string>,
67
+ unresolvedImportUris: Set<string>,
63
68
  out: UndeclaredVariableDiagnostic[],
64
69
  ): void {
65
70
  if (isTerminal(node)) return;
@@ -72,7 +77,7 @@ function walk(
72
77
  if (fn) {
73
78
  scope.push();
74
79
  for (const p of fn.params) scope.add(qnameKey(p.qname));
75
- if (fn.body) walk(fn.body, scope, analysis, moduleVarKeys, out);
80
+ if (fn.body) walk(fn.body, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
76
81
  scope.pop();
77
82
  return;
78
83
  }
@@ -80,7 +85,7 @@ function walk(
80
85
  const varDecl = directChildOf(node, "VarDecl");
81
86
  if (varDecl && !isTerminal(varDecl)) {
82
87
  for (const child of (varDecl as NonTerminal).children)
83
- walk(child, scope, analysis, moduleVarKeys, out);
88
+ walk(child, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
84
89
  }
85
90
  return;
86
91
  }
@@ -99,19 +104,19 @@ function walk(
99
104
  switch (clause.type) {
100
105
  case "ForClause":
101
106
  for (const b of directChildrenOf(clause, "ForBinding"))
102
- addBinding(b, scope, analysis, moduleVarKeys, out);
107
+ addBinding(b, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
103
108
  break;
104
109
  case "LetClause":
105
110
  for (const b of directChildrenOf(clause, "LetBinding"))
106
- addBinding(b, scope, analysis, moduleVarKeys, out);
111
+ addBinding(b, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
107
112
  break;
108
113
  case "CountClause":
109
- addBinding(clause, scope, analysis, moduleVarKeys, out);
114
+ addBinding(clause, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
110
115
  break;
111
116
  case "GroupByClause": {
112
117
  const specList = directChildOf(clause, "GroupingSpecList");
113
118
  for (const spec of specList ? directChildrenOf(specList, "GroupingSpec") : [])
114
- addBinding(spec, scope, analysis, moduleVarKeys, out);
119
+ addBinding(spec, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
115
120
  break;
116
121
  }
117
122
  case "WindowClause": {
@@ -121,7 +126,7 @@ function walk(
121
126
  const b = inner ? asBinding(inner, analysis) : null;
122
127
  if (b) {
123
128
  // The "in" source expr is evaluated in the outer scope, before $w exists.
124
- if (b.initExpr) walk(b.initExpr, scope, analysis, moduleVarKeys, out);
129
+ if (b.initExpr) walk(b.initExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
125
130
  // Start/end condition vars ($current/$positional/$previous/$next) are visible
126
131
  // across both conditions and the return clause — but $w itself is not yet bound
127
132
  // while its own boundary conditions are being evaluated.
@@ -137,14 +142,14 @@ function walk(
137
142
  if (wv.nextItem) scope.add(qnameKey(wv.nextItem));
138
143
  }
139
144
  const whenExpr = directChildOf(cond, "ExprSingle");
140
- if (whenExpr) walk(whenExpr, scope, analysis, moduleVarKeys, out);
145
+ if (whenExpr) walk(whenExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
141
146
  }
142
147
  scope.add(qnameKey(b.qname));
143
148
  }
144
149
  break;
145
150
  }
146
151
  default:
147
- walk(clause, scope, analysis, moduleVarKeys, out);
152
+ walk(clause, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
148
153
  }
149
154
  }
150
155
  scope.pop();
@@ -159,7 +164,7 @@ function walk(
159
164
  const qname = asVarName(child, analysis);
160
165
  if (qname) scope.add(qnameKey(qname));
161
166
  } else {
162
- walk(child, scope, analysis, moduleVarKeys, out);
167
+ walk(child, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
163
168
  }
164
169
  }
165
170
  scope.pop();
@@ -171,7 +176,7 @@ function walk(
171
176
  if (!fn) break;
172
177
  scope.push();
173
178
  for (const p of fn.params) scope.add(qnameKey(p.qname));
174
- if (fn.body) walk(fn.body, scope, analysis, moduleVarKeys, out);
179
+ if (fn.body) walk(fn.body, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
175
180
  scope.pop();
176
181
  return;
177
182
  }
@@ -182,14 +187,14 @@ function walk(
182
187
 
183
188
  case "TypeswitchExpr": {
184
189
  const operand = directChildOf(node, "Expr");
185
- if (operand) walk(operand, scope, analysis, moduleVarKeys, out);
190
+ if (operand) walk(operand, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
186
191
  for (const caseClause of directChildrenOf(node, "CaseClause")) {
187
192
  scope.push();
188
193
  const varNameNode = directChildOf(caseClause, "VarName");
189
194
  const qname = varNameNode ? asVarName(varNameNode, analysis) : null;
190
195
  if (qname) scope.add(qnameKey(qname));
191
196
  const returnExpr = directChildOf(caseClause, "ExprSingle");
192
- if (returnExpr) walk(returnExpr, scope, analysis, moduleVarKeys, out);
197
+ if (returnExpr) walk(returnExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
193
198
  scope.pop();
194
199
  }
195
200
  // `default $v return ...` — $v (optional) and the return expr are direct
@@ -199,7 +204,7 @@ function walk(
199
204
  const defaultQname = defaultVarNode ? asVarName(defaultVarNode, analysis) : null;
200
205
  if (defaultQname) scope.add(qnameKey(defaultQname));
201
206
  const defaultExpr = directChildOf(node, "ExprSingle");
202
- if (defaultExpr) walk(defaultExpr, scope, analysis, moduleVarKeys, out);
207
+ if (defaultExpr) walk(defaultExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
203
208
  scope.pop();
204
209
  return;
205
210
  }
@@ -209,24 +214,24 @@ function walk(
209
214
  if (!tx) break;
210
215
  scope.push();
211
216
  for (const b of tx.copyBindings) {
212
- walk(b.initExpr, scope, analysis, moduleVarKeys, out);
217
+ walk(b.initExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
213
218
  scope.add(qnameKey(b.qname));
214
219
  }
215
- if (tx.modifyExpr) walk(tx.modifyExpr, scope, analysis, moduleVarKeys, out);
216
- if (tx.returnExpr) walk(tx.returnExpr, scope, analysis, moduleVarKeys, out);
220
+ if (tx.modifyExpr) walk(tx.modifyExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
221
+ if (tx.returnExpr) walk(tx.returnExpr, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
217
222
  scope.pop();
218
223
  return;
219
224
  }
220
225
 
221
226
  case "VarRef": {
222
227
  const qname = asVarRef(node, analysis);
223
- if (qname && !isKnown(qname, scope, moduleVarKeys))
228
+ if (qname && !isKnown(qname, scope, moduleVarKeys, unresolvedImportUris))
224
229
  reportVarRef(node, qname, out);
225
230
  return;
226
231
  }
227
232
 
228
233
  default:
229
- for (const child of children) walk(child, scope, analysis, moduleVarKeys, out);
234
+ for (const child of children) walk(child, scope, analysis, moduleVarKeys, unresolvedImportUris, out);
230
235
  }
231
236
  }
232
237
 
@@ -237,6 +242,8 @@ function walk(
237
242
  * to a declared variable: function parameter, let/for binding, module-level
238
243
  * `declare variable`, or an imported module variable.
239
244
  *
245
+ * Variables whose prefix is bound by an import that did not resolve to an
246
+ * analysed module are skipped too (XQST0059 reports the import itself).
240
247
  * Variables whose namespace prefix is undeclared are skipped — XQST0081
241
248
  * already fires for those, and adding XPST0008 on top would be noisy.
242
249
  * Variables inside catch clauses are also skipped since the implicit
@@ -252,7 +259,12 @@ export function checkUndeclaredVariables(
252
259
  for (const imported of importedAnalyses.values())
253
260
  for (const v of imported.moduleVariables) moduleVarKeys.add(qnameKey(v.qname));
254
261
 
262
+ // Imports whose module was not analysed: their variables are unknowable.
263
+ const unresolvedImportUris = new Set(
264
+ analysis.imports.map((i) => i.namespaceUri).filter((u) => !importedAnalyses.has(u)),
265
+ );
266
+
255
267
  const out: UndeclaredVariableDiagnostic[] = [];
256
- walk(ast, new ScopeStack(), analysis, moduleVarKeys, out);
268
+ walk(ast, new ScopeStack(), analysis, moduleVarKeys, unresolvedImportUris, out);
257
269
  return out;
258
270
  }