xq-lsp 1.4.1 → 1.5.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/DrRataplan/xq-lsp/compare/lsp-v1.4.1...lsp-v1.5.0) (2026-06-18)
4
+
5
+
6
+ ### Features
7
+
8
+ * **completion:** add function param completions with proper scope isolation ([dab41a3](https://github.com/DrRataplan/xq-lsp/commit/dab41a349ae1d387f1b32b530e476b1d30da8f07))
9
+ * **diagnostics:** report undeclared variable references as XPST0008 ([6ff1fa9](https://github.com/DrRataplan/xq-lsp/commit/6ff1fa9d5a9ecfb4d440119f3efa9e3f96204e05))
10
+ * eXist-db pre-declared namespaces suppress false XQST0081 diagnostics ([cd94650](https://github.com/DrRataplan/xq-lsp/commit/cd94650db968d727717de62e6a60411481a7fb16))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * inject W3C predeclared namespaces in qt4-worker ([4c93ad6](https://github.com/DrRataplan/xq-lsp/commit/4c93ad677db0eb266e04f080ee1a1ad986dfc03a))
16
+ * **variable-diagnostics:** add explicit type annotation to fix TS build ([5867d7e](https://github.com/DrRataplan/xq-lsp/commit/5867d7ea549026cda98559b8d980f3bdf561f32f))
17
+ * **vscode:** make extension work by shimming import.meta.url ([fca848a](https://github.com/DrRataplan/xq-lsp/commit/fca848af51ee17a0dba8e7b95f4767c4d1ce974f))
18
+
3
19
  ## [1.4.1](https://github.com/DrRataplan/xq-lsp/compare/lsp-v1.4.0...lsp-v1.4.1) (2026-06-10)
4
20
 
5
21
 
package/README.md CHANGED
@@ -59,24 +59,70 @@ For files with syntax errors (common while editing), the server falls back to re
59
59
 
60
60
  Use the `lib` key to load built-in definitions for a specific runtime:
61
61
 
62
- | Value | Runtime |
63
- | --------- | ------------------------------------------------------------------- |
64
- | `"fonto"` | [Fonto XML editor](https://www.fontoxml.com/) — `fonto:*` functions |
62
+ | Value | Runtime |
63
+ | ----------- | ------------------------------------------------------------------------- |
64
+ | `"fonto"` | [Fonto XML editor](https://www.fontoxml.com/) — `fonto:*` functions |
65
+ | `"existdb"` | [eXist-db](https://exist-db.org/) — all extension module functions |
65
66
 
66
67
  ```xquery
67
- map {"glob": "src/**/*.xq", "lib": "fonto"}
68
+ map {"glob": "src/**/*.xq", "lib": "existdb"}
68
69
  ```
69
70
 
70
- Then import the namespace in your XQuery files as usual — the server resolves completions, hover, and go-to-definition against the bundled definitions:
71
+ Multiple libs use an XPath sequence:
71
72
 
72
73
  ```xquery
73
- import module namespace fonto="http://www.fontoxml.com/functions";
74
+ map {"glob": "src/**/*.xq", "lib": ("fonto", "other")}
74
75
  ```
75
76
 
76
- Multiple libs use an XPath sequence:
77
+ #### eXist-db runtime
78
+
79
+ The `"existdb"` runtime bundles stub definitions for every eXist-db extension
80
+ module (`util`, `xmldb`, `request`, `sm`, `ft`, `compression`, …).
81
+
82
+ eXist-db automatically pre-declares a large set of namespace prefixes — you can
83
+ call `util:log(…)`, `xmldb:store(…)`, `process:execute(…)`, etc. without any
84
+ `import module namespace` statement. When the `existdb` runtime is active the
85
+ server knows about these pre-declared prefixes and:
86
+
87
+ - **suppresses** false `XQST0081` ("namespace prefix not declared") diagnostics
88
+ for all pre-declared namespaces
89
+ - **provides completions, hover, and go-to-definition** for those modules
90
+ without requiring an explicit `import module namespace` in your source
91
+
92
+ If you do choose to write explicit imports (e.g. for editor portability), the
93
+ server handles those correctly too:
77
94
 
78
95
  ```xquery
79
- map {"glob": "src/**/*.xq", "lib": ("fonto", "other")}
96
+ (: explicit import — also fine :)
97
+ import module namespace util = "http://exist-db.org/xquery/util";
98
+ util:uuid()
99
+ ```
100
+
101
+ Modules that require explicit import even in eXist-db (e.g. `datetime`,
102
+ `httpclient`, `console`, `crypto`, `kwic`) still need a `import module
103
+ namespace` declaration; the server offers quick-fix code actions to insert it.
104
+
105
+ ```xquery
106
+ (: lsp-config.xq in your project root :)
107
+ map {
108
+ "glob": "src/**/*.xq",
109
+ "lib": "existdb",
110
+ "import": map {
111
+ "generateLocationHints": false()
112
+ }
113
+ }
114
+ ```
115
+
116
+ The `generateLocationHints: false()` setting prevents the server from appending
117
+ `at "…"` paths to generated imports, which eXist-db does not need (it resolves
118
+ modules by namespace URI).
119
+
120
+ #### Fonto runtime
121
+
122
+ Then import the namespace in your XQuery files as usual — the server resolves completions, hover, and go-to-definition against the bundled definitions:
123
+
124
+ ```xquery
125
+ import module namespace fonto="http://www.fontoxml.com/functions";
80
126
  ```
81
127
 
82
128
  ### Location hints
@@ -14,4 +14,4 @@ export interface CompletionEntry {
14
14
  isSnippet: boolean;
15
15
  }
16
16
  export declare function functionDoc(fn: FunctionSymbol): string;
17
- export declare function getCompletions(ctx: CompletionContext, currentAnalysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>, snippets?: boolean): CompletionEntry[];
17
+ export declare function getCompletions(ctx: CompletionContext, currentAnalysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>, snippets?: boolean, lastValidAnalysis?: FileAnalysis): CompletionEntry[];
@@ -1,5 +1,5 @@
1
1
  import { formatQName } from "./types.js";
2
- import { resolvePrefix } from "./analyzer.js";
2
+ import { resolvePrefix, nodeStackAtOffset, findAll } from "./analyzer.js";
3
3
  function parseToken(textBefore) {
4
4
  const match = textBefore.match(/\$([\w:\-]*)$|(?:([\w\-]+):)([\w\-]*)$|([\w\-]+)$/);
5
5
  if (!match)
@@ -41,6 +41,31 @@ function buildVariableEntry(v, filter) {
41
41
  return null;
42
42
  return { label, kind: "variable", insertText: label, isSnippet: false };
43
43
  }
44
+ function buildParamEntry(p, filter) {
45
+ if (filter && !p.name.toLowerCase().startsWith(filter.toLowerCase()))
46
+ return null;
47
+ return {
48
+ label: `$${p.name}`,
49
+ kind: "variable",
50
+ detail: p.type ? `as ${p.type}` : undefined,
51
+ insertText: `$${p.name}`,
52
+ isSnippet: false,
53
+ };
54
+ }
55
+ /** Find the function symbol enclosing the cursor using the AST. */
56
+ function findEnclosingFunction(ast, cursorOffset, functions) {
57
+ const stack = nodeStackAtOffset(ast, cursorOffset);
58
+ const bodyNode = stack.slice().reverse().find((n) => n.type === "FunctionBody");
59
+ if (!bodyNode || bodyNode.start === undefined || bodyNode.end == null)
60
+ return null;
61
+ const annotatedNode = stack.find((n) => n.type === "AnnotatedDecl");
62
+ if (!annotatedNode || annotatedNode.start === undefined)
63
+ return null;
64
+ const fn = functions.find((f) => f.sourceOffset === annotatedNode.start);
65
+ if (!fn)
66
+ return null;
67
+ return { fn, bodyStart: bodyNode.start, bodyEnd: bodyNode.end };
68
+ }
44
69
  function buildFunctionEntry(fn, label, snippets) {
45
70
  return {
46
71
  label,
@@ -51,23 +76,63 @@ function buildFunctionEntry(fn, label, snippets) {
51
76
  isSnippet: snippets && fn.params.length > 0,
52
77
  };
53
78
  }
54
- export function getCompletions(ctx, currentAnalysis, importedAnalyses, snippets = false) {
79
+ export function getCompletions(ctx, currentAnalysis, importedAnalyses, snippets = false, lastValidAnalysis) {
55
80
  const token = parseToken(ctx.textBeforeCursor);
56
81
  const items = [];
57
82
  if (token.kind === "variable") {
58
83
  const filter = token.prefix;
84
+ // Module-level variables are always visible
59
85
  for (const v of currentAnalysis.moduleVariables) {
60
86
  const e = buildVariableEntry(v, filter);
61
87
  if (e)
62
88
  items.push(e);
63
89
  }
64
- for (const v of currentAnalysis.localBindings) {
65
- if (v.offset < ctx.cursorOffset) {
66
- const e = buildVariableEntry(v, filter);
67
- if (e)
68
- items.push(e);
90
+ // Prefer the current AST; fall back to the last successfully-parsed one for scope analysis
91
+ const scopeAst = currentAnalysis.ast ?? lastValidAnalysis?.ast;
92
+ if (scopeAst) {
93
+ const enclosing = findEnclosingFunction(scopeAst, ctx.cursorOffset, currentAnalysis.functions);
94
+ if (enclosing) {
95
+ // Inside a function body: params + local bindings within this body before cursor
96
+ for (const p of enclosing.fn.params) {
97
+ const e = buildParamEntry(p, filter);
98
+ if (e)
99
+ items.push(e);
100
+ }
101
+ for (const v of currentAnalysis.localBindings) {
102
+ if (v.offset > enclosing.bodyStart && v.offset < ctx.cursorOffset && v.offset < enclosing.bodyEnd) {
103
+ const e = buildVariableEntry(v, filter);
104
+ if (e)
105
+ items.push(e);
106
+ }
107
+ }
108
+ }
109
+ else {
110
+ // In query body: exclude local bindings that live inside function bodies
111
+ const functionBodyRanges = findAll(scopeAst, "FunctionBody")
112
+ .filter((b) => b.start !== undefined && b.end != null)
113
+ .map((b) => ({ start: b.start, end: b.end }));
114
+ for (const v of currentAnalysis.localBindings) {
115
+ if (v.offset >= ctx.cursorOffset)
116
+ continue;
117
+ if (functionBodyRanges.some((r) => v.offset >= r.start && v.offset <= r.end))
118
+ continue;
119
+ const e = buildVariableEntry(v, filter);
120
+ if (e)
121
+ items.push(e);
122
+ }
123
+ }
124
+ }
125
+ else {
126
+ // No AST ever available: simple offset-based filter, no function-param completions
127
+ for (const v of currentAnalysis.localBindings) {
128
+ if (v.offset < ctx.cursorOffset) {
129
+ const e = buildVariableEntry(v, filter);
130
+ if (e)
131
+ items.push(e);
132
+ }
69
133
  }
70
134
  }
135
+ // Imported module-level variables are always visible
71
136
  for (const analysis of importedAnalyses.values()) {
72
137
  for (const v of analysis.moduleVariables) {
73
138
  const e = buildVariableEntry(v, filter);
@@ -1,2 +1,15 @@
1
- import type { FileAnalysis } from "./types.ts";
1
+ import type { FileAnalysis, NamespaceDecl } from "./types.ts";
2
+ /**
3
+ * Returns all namespace declarations that are in scope without any explicit
4
+ * `import module namespace` or `declare namespace` statement. Includes the
5
+ * standard XQuery 3.1 predeclared namespaces (math, map, array) and any
6
+ * runtime-specific additions (e.g. eXist-db's util, xmldb, …).
7
+ */
8
+ export declare function getRuntimePredeclaredNamespaces(runtimes: string[]): NamespaceDecl[];
9
+ /**
10
+ * Return a copy of `analysis` with the given predeclared namespace declarations
11
+ * merged into `namespaceDecls`. Prefixes already visible via imports, the
12
+ * module's own prefix, or an existing `declare namespace` are skipped.
13
+ */
14
+ export declare function withPredeclaredNs(analysis: FileAnalysis, predeclaredNs: NamespaceDecl[]): FileAnalysis;
2
15
  export declare function getRuntimeAnalyses(runtimes: string[]): FileAnalysis[];
package/dist/runtimes.js CHANGED
@@ -3,6 +3,14 @@ import * as fs from "fs";
3
3
  import { fileURLToPath } from "url";
4
4
  import { analyze } from "./analyzer.js";
5
5
  const runtimesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "runtimes");
6
+ function toNamespaceDecls(raw) {
7
+ return Object.entries(raw).map(([prefix, namespaceUri]) => ({ prefix, namespaceUri }));
8
+ }
9
+ // Lazily loaded predeclared namespace list for the existdb runtime.
10
+ let existdbPredeclared = null;
11
+ function loadExistdbPredeclared() {
12
+ return (existdbPredeclared ??= toNamespaceDecls(JSON.parse(fs.readFileSync(path.join(runtimesDir, "existdb", "predeclared-namespaces.json"), "utf-8"))));
13
+ }
6
14
  const RUNTIME_FILES = {
7
15
  fonto: [path.join(runtimesDir, "fonto", "fonto.xq")],
8
16
  existdb: [
@@ -39,6 +47,35 @@ const RUNTIME_FILES = {
39
47
  ],
40
48
  };
41
49
  const runtimeAnalysisCache = new Map();
50
+ /**
51
+ * Returns all namespace declarations that are in scope without any explicit
52
+ * `import module namespace` or `declare namespace` statement. Includes the
53
+ * standard XQuery 3.1 predeclared namespaces (math, map, array) and any
54
+ * runtime-specific additions (e.g. eXist-db's util, xmldb, …).
55
+ */
56
+ export function getRuntimePredeclaredNamespaces(runtimes) {
57
+ if (!runtimes.includes("existdb"))
58
+ return [];
59
+ return loadExistdbPredeclared();
60
+ }
61
+ /**
62
+ * Return a copy of `analysis` with the given predeclared namespace declarations
63
+ * merged into `namespaceDecls`. Prefixes already visible via imports, the
64
+ * module's own prefix, or an existing `declare namespace` are skipped.
65
+ */
66
+ export function withPredeclaredNs(analysis, predeclaredNs) {
67
+ if (predeclaredNs.length === 0)
68
+ return analysis;
69
+ const known = new Set([
70
+ ...analysis.imports.map((i) => i.prefix),
71
+ ...analysis.namespaceDecls.map((nd) => nd.prefix),
72
+ ...(analysis.modulePrefix ? [analysis.modulePrefix] : []),
73
+ ]);
74
+ const toAdd = predeclaredNs.filter((nd) => !known.has(nd.prefix));
75
+ if (toAdd.length === 0)
76
+ return analysis;
77
+ return { ...analysis, namespaceDecls: [...analysis.namespaceDecls, ...toAdd] };
78
+ }
42
79
  export function getRuntimeAnalyses(runtimes) {
43
80
  const results = [];
44
81
  for (const runtime of runtimes) {
package/dist/server.js CHANGED
@@ -14,11 +14,13 @@ import { checkFunctionCalls } from "./functioncall-diagnostics.js";
14
14
  import { findConfig, expandGlobs } from "./config.js";
15
15
  import { findUndeclaredPrefixUsages, findImportInsertPosition, findDeclareNsInsertPosition, } from "./namespace-diagnostics.js";
16
16
  import { checkUnused } from "./unused-diagnostics.js";
17
+ import { checkUndeclaredVariables } from "./variable-diagnostics.js";
17
18
  import { checkContextItemUsage } from "./context-item-diagnostics.js";
18
- import { getRuntimeAnalyses } from "./runtimes.js";
19
+ import { getRuntimeAnalyses, getRuntimePredeclaredNamespaces, withPredeclaredNs } from "./runtimes.js";
19
20
  const connection = createConnection(ProposedFeatures.all);
20
21
  const documents = new TextDocuments(TextDocument);
21
22
  const analysisCache = new Map();
23
+ const lastValidAnalysisCache = new Map(); // last AST-path analysis per URI
22
24
  const typeErrorCache = new Map();
23
25
  // Glob analyses keyed by config directory, then by module namespace URI.
24
26
  // Populated lazily on the first request for a given config root.
@@ -26,11 +28,15 @@ const globAnalysesByConfigDir = new Map();
26
28
  function analyzeDocument(doc) {
27
29
  const { analysis } = analyzeWithAst(doc.getText(), doc.uri);
28
30
  analysisCache.set(doc.uri, analysis);
31
+ if (analysis.usedAstPath)
32
+ lastValidAnalysisCache.set(doc.uri, analysis);
29
33
  return analysis;
30
34
  }
31
35
  function analyzeDocumentFull(doc) {
32
36
  const { analysis, ast, parseError } = analyzeWithAst(doc.getText(), doc.uri);
33
37
  analysisCache.set(doc.uri, analysis);
38
+ if (analysis.usedAstPath)
39
+ lastValidAnalysisCache.set(doc.uri, analysis);
34
40
  return { analysis, parseDiagnostics: buildParseDiagnostics(parseError), hasAst: ast !== null, ast };
35
41
  }
36
42
  function buildParseDiagnostics(parseError) {
@@ -105,10 +111,12 @@ function getGlobAnalyses(currentUri) {
105
111
  globAnalysesByConfigDir.set(configDir, byNamespace);
106
112
  return { byNamespace, lib };
107
113
  }
108
- function resolveImports(currentUri, analysis) {
114
+ function resolveContext(currentUri, rawAnalysis) {
109
115
  const result = new Map();
110
116
  result.set("builtin:fn", getBuiltins());
111
117
  const { byNamespace: globAnalyses, lib } = getGlobAnalyses(currentUri);
118
+ const predeclaredNs = getRuntimePredeclaredNamespaces(lib);
119
+ const analysis = withPredeclaredNs(rawAnalysis, predeclaredNs);
112
120
  const runtimeByNamespace = new Map();
113
121
  for (const runtimeAnalysis of getRuntimeAnalyses(lib)) {
114
122
  if (runtimeAnalysis.moduleNamespaceUri) {
@@ -116,7 +124,7 @@ function resolveImports(currentUri, analysis) {
116
124
  runtimeByNamespace.set(runtimeAnalysis.moduleNamespaceUri, existing ? mergeAnalyses(existing, runtimeAnalysis) : runtimeAnalysis);
117
125
  }
118
126
  }
119
- for (const imp of analysis.imports) {
127
+ for (const imp of rawAnalysis.imports) {
120
128
  if (imp.atPath) {
121
129
  const uri = resolveImportUri(currentUri, imp.atPath);
122
130
  const imported = getImportedAnalysis(uri);
@@ -130,7 +138,15 @@ function resolveImports(currentUri, analysis) {
130
138
  result.set(imp.namespaceUri, imported);
131
139
  }
132
140
  }
133
- return result;
141
+ // Add pre-declared runtime analyses directly — available without explicit import
142
+ for (const nd of predeclaredNs) {
143
+ if (!result.has(nd.namespaceUri)) {
144
+ const runtimeAnalysis = runtimeByNamespace.get(nd.namespaceUri);
145
+ if (runtimeAnalysis)
146
+ result.set(nd.namespaceUri, runtimeAnalysis);
147
+ }
148
+ }
149
+ return { analysis, imported: result };
134
150
  }
135
151
  // ── LSP lifecycle ────────────────────────────────────────────────────────────
136
152
  let snippetSupport = false;
@@ -155,12 +171,12 @@ connection.onInitialize((params) => {
155
171
  });
156
172
  documents.onDidChangeContent((change) => {
157
173
  const doc = change.document;
158
- const { analysis, parseDiagnostics: parseDiags, hasAst, ast } = analyzeDocumentFull(doc);
174
+ const { analysis: rawAnalysis, parseDiagnostics: parseDiags, hasAst, ast } = analyzeDocumentFull(doc);
175
+ const { analysis, imported: resolvedImported } = resolveContext(doc.uri, rawAnalysis);
159
176
  if (hasAst && ast !== null) {
160
- const imported = resolveImports(doc.uri, analysis);
161
177
  typeErrorCache.set(doc.uri, [
162
- ...checkTypes(ast, doc.getText(), analysis, imported),
163
- ...checkFunctionCalls(ast, analysis, imported),
178
+ ...checkTypes(ast, doc.getText(), analysis, resolvedImported),
179
+ ...checkFunctionCalls(ast, analysis, resolvedImported),
164
180
  ]);
165
181
  }
166
182
  const typeDiags = (typeErrorCache.get(doc.uri) ?? []).map((td) => ({
@@ -209,34 +225,48 @@ documents.onDidChangeContent((change) => {
209
225
  code: cd.code,
210
226
  source: "xquery-lsp",
211
227
  }));
228
+ const undeclaredVarDiagRaw = hasAst && ast !== null ? checkUndeclaredVariables(ast, analysis, resolvedImported) : [];
229
+ const undeclaredVarDiags = undeclaredVarDiagRaw.map((d) => ({
230
+ severity: DiagnosticSeverity.Error,
231
+ range: {
232
+ start: doc.positionAt(d.offset),
233
+ end: doc.positionAt(d.offset + d.length),
234
+ },
235
+ message: d.message,
236
+ code: d.code,
237
+ source: "xquery-lsp",
238
+ }));
212
239
  connection.sendDiagnostics({
213
240
  uri: doc.uri,
214
- diagnostics: [...parseDiags, ...typeDiags, ...nsDiags, ...unusedDiags, ...contextItemDiags],
241
+ diagnostics: [
242
+ ...parseDiags, ...typeDiags, ...nsDiags, ...unusedDiags, ...contextItemDiags,
243
+ ...undeclaredVarDiags,
244
+ ],
215
245
  });
216
246
  });
217
247
  connection.onCompletion((params) => {
218
248
  const doc = documents.get(params.textDocument.uri);
219
249
  if (!doc)
220
250
  return [];
221
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
222
- const imported = resolveImports(doc.uri, analysis);
251
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
252
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
223
253
  const offset = doc.offsetAt(params.position);
224
- return getCompletions({ textBeforeCursor: doc.getText().slice(0, offset), cursorOffset: offset }, analysis, imported, snippetSupport);
254
+ return getCompletions({ textBeforeCursor: doc.getText().slice(0, offset), cursorOffset: offset }, analysis, imported, snippetSupport, lastValidAnalysisCache.get(doc.uri));
225
255
  });
226
256
  connection.onHover((params) => {
227
257
  const doc = documents.get(params.textDocument.uri);
228
258
  if (!doc)
229
259
  return null;
230
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
231
- const imported = resolveImports(doc.uri, analysis);
260
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
261
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
232
262
  return getHover(doc, doc.offsetAt(params.position), analysis, imported);
233
263
  });
234
264
  connection.onSignatureHelp((params) => {
235
265
  const doc = documents.get(params.textDocument.uri);
236
266
  if (!doc)
237
267
  return null;
238
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
239
- const imported = resolveImports(doc.uri, analysis);
268
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
269
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
240
270
  return getSignatureHelp(doc, doc.offsetAt(params.position), analysis, imported);
241
271
  });
242
272
  connection.onDocumentSymbol((params) => {
@@ -250,8 +280,8 @@ connection.onDefinition((params) => {
250
280
  const doc = documents.get(params.textDocument.uri);
251
281
  if (!doc)
252
282
  return null;
253
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
254
- const imported = resolveImports(doc.uri, analysis);
283
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
284
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
255
285
  return getDefinition(doc, doc.offsetAt(params.position), analysis, imported, (atPath) => resolveImportUri(doc.uri, atPath));
256
286
  });
257
287
  connection.onCodeAction((params) => {
@@ -0,0 +1,19 @@
1
+ import type { Node } from "xq-parser";
2
+ import type { FileAnalysis } from "./types.ts";
3
+ export interface UndeclaredVariableDiagnostic {
4
+ message: string;
5
+ code: "XPST0008";
6
+ offset: number;
7
+ length: number;
8
+ }
9
+ /**
10
+ * Walk the AST and report every variable reference that cannot be resolved
11
+ * to a declared variable: function parameter, let/for binding, module-level
12
+ * `declare variable`, or an imported module variable.
13
+ *
14
+ * Variables whose namespace prefix is undeclared are skipped — XQST0081
15
+ * already fires for those, and adding XPST0008 on top would be noisy.
16
+ * Variables inside catch clauses are also skipped since the implicit
17
+ * `$err:*` bindings are not represented in the AST.
18
+ */
19
+ export declare function checkUndeclaredVariables(ast: Node, analysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>): UndeclaredVariableDiagnostic[];
@@ -0,0 +1,164 @@
1
+ import { qnameKey } from "./types.js";
2
+ import { isTerminal, directChildOf, directChildrenOf } from "./analyzer.js";
3
+ import { asFunctionDecl, asVarRef, asVarName, asBinding } from "./ast-nodes.js";
4
+ // ── Scope stack ───────────────────────────────────────────────────────────────
5
+ class ScopeStack {
6
+ frames = [];
7
+ push() { this.frames.push(new Set()); }
8
+ pop() { this.frames.pop(); }
9
+ add(key) { this.frames[this.frames.length - 1]?.add(key); }
10
+ has(key) { return this.frames.some((f) => f.has(key)); }
11
+ }
12
+ // ── Helpers ───────────────────────────────────────────────────────────────────
13
+ function isKnown(qname, scope, moduleVarKeys) {
14
+ // Variables with an undeclared-prefix URI are already caught by XQST0081; skip here.
15
+ if (qname.namespaceUri.startsWith("urn:xq-lsp:undeclared:"))
16
+ return true;
17
+ return scope.has(qnameKey(qname)) || moduleVarKeys.has(qnameKey(qname));
18
+ }
19
+ function reportVarRef(node, qname, out) {
20
+ const name = qname.prefix ? `${qname.prefix}:${qname.localName}` : qname.localName;
21
+ out.push({
22
+ message: `Variable '$${name}' is not declared`,
23
+ code: "XPST0008",
24
+ offset: node.start ?? 0,
25
+ length: name.length + 1, // +1 for "$"
26
+ });
27
+ }
28
+ function addBinding(bindingNode, scope, analysis, moduleVarKeys, out) {
29
+ const b = asBinding(bindingNode, analysis);
30
+ if (!b)
31
+ return;
32
+ if (b.initExpr)
33
+ walk(b.initExpr, scope, analysis, moduleVarKeys, out);
34
+ scope.add(qnameKey(b.qname));
35
+ if (b.positionalVar)
36
+ scope.add(qnameKey(b.positionalVar.qname));
37
+ }
38
+ // ── Scope-aware AST walker ────────────────────────────────────────────────────
39
+ function walk(node, scope, analysis, moduleVarKeys, out) {
40
+ if (isTerminal(node))
41
+ return;
42
+ const { children } = node;
43
+ switch (node.type) {
44
+ case "AnnotatedDecl": {
45
+ const fnDeclNode = directChildOf(node, "FunctionDecl");
46
+ const fn = fnDeclNode ? asFunctionDecl(fnDeclNode, analysis) : null;
47
+ if (fn) {
48
+ scope.push();
49
+ for (const p of fn.params)
50
+ scope.add(qnameKey(p.qname));
51
+ if (fn.body)
52
+ walk(fn.body, scope, analysis, moduleVarKeys, out);
53
+ scope.pop();
54
+ return;
55
+ }
56
+ // Module variable declaration: walk the initializer expression only
57
+ const varDecl = directChildOf(node, "VarDecl");
58
+ if (varDecl && !isTerminal(varDecl)) {
59
+ for (const child of varDecl.children)
60
+ walk(child, scope, analysis, moduleVarKeys, out);
61
+ }
62
+ return;
63
+ }
64
+ case "FLWORExpr": {
65
+ scope.push();
66
+ for (const child of children) {
67
+ if (isTerminal(child))
68
+ continue;
69
+ // Unwrap InitialClause/IntermediateClause wrappers (may be multiply nested)
70
+ let clause = child;
71
+ while (!isTerminal(clause) && (clause.type === "InitialClause" || clause.type === "IntermediateClause")) {
72
+ const inner = clause.children.find((c) => !isTerminal(c));
73
+ if (!inner)
74
+ break;
75
+ clause = inner;
76
+ }
77
+ switch (clause.type) {
78
+ case "ForClause":
79
+ for (const b of directChildrenOf(clause, "ForBinding"))
80
+ addBinding(b, scope, analysis, moduleVarKeys, out);
81
+ break;
82
+ case "LetClause":
83
+ for (const b of directChildrenOf(clause, "LetBinding"))
84
+ addBinding(b, scope, analysis, moduleVarKeys, out);
85
+ break;
86
+ case "CountClause":
87
+ addBinding(clause, scope, analysis, moduleVarKeys, out);
88
+ break;
89
+ case "GroupByClause": {
90
+ const specList = directChildOf(clause, "GroupingSpecList");
91
+ for (const spec of specList ? directChildrenOf(specList, "GroupingSpec") : [])
92
+ addBinding(spec, scope, analysis, moduleVarKeys, out);
93
+ break;
94
+ }
95
+ case "WindowClause": {
96
+ const inner = directChildOf(clause, "TumblingWindowClause") ??
97
+ directChildOf(clause, "SlidingWindowClause");
98
+ if (inner)
99
+ addBinding(inner, scope, analysis, moduleVarKeys, out);
100
+ for (const c of clause.children)
101
+ if (!isTerminal(c))
102
+ walk(c, scope, analysis, moduleVarKeys, out);
103
+ break;
104
+ }
105
+ default:
106
+ walk(clause, scope, analysis, moduleVarKeys, out);
107
+ }
108
+ }
109
+ scope.pop();
110
+ return;
111
+ }
112
+ case "QuantifiedExpr": {
113
+ scope.push();
114
+ for (const child of children) {
115
+ if (isTerminal(child))
116
+ continue;
117
+ if (child.type === "VarName") {
118
+ const qname = asVarName(child, analysis);
119
+ if (qname)
120
+ scope.add(qnameKey(qname));
121
+ }
122
+ else {
123
+ walk(child, scope, analysis, moduleVarKeys, out);
124
+ }
125
+ }
126
+ scope.pop();
127
+ return;
128
+ }
129
+ case "CatchClause":
130
+ // Skip: implicit $err:* variables inside catch clauses are not tracked in the AST.
131
+ return;
132
+ case "VarRef": {
133
+ const qname = asVarRef(node, analysis);
134
+ if (qname && !isKnown(qname, scope, moduleVarKeys))
135
+ reportVarRef(node, qname, out);
136
+ return;
137
+ }
138
+ default:
139
+ for (const child of children)
140
+ walk(child, scope, analysis, moduleVarKeys, out);
141
+ }
142
+ }
143
+ // ── Public entry point ────────────────────────────────────────────────────────
144
+ /**
145
+ * Walk the AST and report every variable reference that cannot be resolved
146
+ * to a declared variable: function parameter, let/for binding, module-level
147
+ * `declare variable`, or an imported module variable.
148
+ *
149
+ * Variables whose namespace prefix is undeclared are skipped — XQST0081
150
+ * already fires for those, and adding XPST0008 on top would be noisy.
151
+ * Variables inside catch clauses are also skipped since the implicit
152
+ * `$err:*` bindings are not represented in the AST.
153
+ */
154
+ export function checkUndeclaredVariables(ast, analysis, importedAnalyses) {
155
+ const moduleVarKeys = new Set();
156
+ for (const v of analysis.moduleVariables)
157
+ moduleVarKeys.add(qnameKey(v.qname));
158
+ for (const imported of importedAnalyses.values())
159
+ for (const v of imported.moduleVariables)
160
+ moduleVarKeys.add(qnameKey(v.qname));
161
+ const out = [];
162
+ walk(ast, new ScopeStack(), analysis, moduleVarKeys, out);
163
+ return out;
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xq-lsp",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "XQuery Language Server Protocol implementation",
5
5
  "license": "MIT",
6
6
  "author": "Martin Middel",
@@ -17,7 +17,7 @@
17
17
  "build": "tsc",
18
18
  "start": "node ./src/server.ts --stdio",
19
19
  "test": "node --test",
20
- "test:coverage": "mkdir -p coverage && node --experimental-test-coverage --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-coverage-exclude='src/**/*.test.ts'",
20
+ "test:coverage": "mkdir -p coverage && node --experimental-test-coverage --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='editors/**'",
21
21
  "prepublishOnly": "npm run build"
22
22
  },
23
23
  "dependencies": {
@@ -25,7 +25,7 @@
25
25
  "slimdom": "^4.3.5",
26
26
  "vscode-languageserver": "^9.0.1",
27
27
  "vscode-languageserver-textdocument": "^1.0.12",
28
- "xq-parser": "^0.4.0"
28
+ "xq-parser": "^0.5.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^25.6.0",
@@ -1,6 +1,6 @@
1
- import type { FileAnalysis, FunctionSymbol, VariableSymbol } from "./types.ts";
1
+ import type { FileAnalysis, FunctionSymbol, VariableSymbol, ParamInfo } from "./types.ts";
2
2
  import { formatQName } from "./types.ts";
3
- import { resolvePrefix } from "./analyzer.ts";
3
+ import { resolvePrefix, nodeStackAtOffset, findAll } from "./analyzer.ts";
4
4
 
5
5
  export interface CompletionContext {
6
6
  /** Text from start of document up to (and including) the cursor position */
@@ -61,6 +61,34 @@ function buildVariableEntry(v: VariableSymbol, filter: string): CompletionEntry
61
61
  return { label, kind: "variable", insertText: label, isSnippet: false };
62
62
  }
63
63
 
64
+ function buildParamEntry(p: ParamInfo, filter: string): CompletionEntry | null {
65
+ if (filter && !p.name.toLowerCase().startsWith(filter.toLowerCase())) return null;
66
+ return {
67
+ label: `$${p.name}`,
68
+ kind: "variable",
69
+ detail: p.type ? `as ${p.type}` : undefined,
70
+ insertText: `$${p.name}`,
71
+ isSnippet: false,
72
+ };
73
+ }
74
+
75
+ /** Find the function symbol enclosing the cursor using the AST. */
76
+ function findEnclosingFunction(
77
+ ast: NonNullable<FileAnalysis["ast"]>,
78
+ cursorOffset: number,
79
+ functions: FunctionSymbol[],
80
+ ): { fn: FunctionSymbol; bodyStart: number; bodyEnd: number } | null {
81
+ const stack = nodeStackAtOffset(ast, cursorOffset);
82
+ const bodyNode = stack.slice().reverse().find((n) => n.type === "FunctionBody");
83
+ if (!bodyNode || bodyNode.start === undefined || bodyNode.end == null) return null;
84
+ const annotatedNode = stack.find((n) => n.type === "AnnotatedDecl");
85
+ if (!annotatedNode || annotatedNode.start === undefined) return null;
86
+ const fn = functions.find((f) => f.sourceOffset === annotatedNode.start);
87
+ if (!fn) return null;
88
+ return { fn, bodyStart: bodyNode.start, bodyEnd: bodyNode.end };
89
+ }
90
+
91
+
64
92
  function buildFunctionEntry(fn: FunctionSymbol, label: string, snippets: boolean): CompletionEntry {
65
93
  return {
66
94
  label,
@@ -77,22 +105,59 @@ export function getCompletions(
77
105
  currentAnalysis: FileAnalysis,
78
106
  importedAnalyses: Map<string, FileAnalysis>,
79
107
  snippets = false,
108
+ lastValidAnalysis?: FileAnalysis,
80
109
  ): CompletionEntry[] {
81
110
  const token = parseToken(ctx.textBeforeCursor);
82
111
  const items: CompletionEntry[] = [];
83
112
 
84
113
  if (token.kind === "variable") {
85
114
  const filter = token.prefix;
115
+
116
+ // Module-level variables are always visible
86
117
  for (const v of currentAnalysis.moduleVariables) {
87
118
  const e = buildVariableEntry(v, filter);
88
119
  if (e) items.push(e);
89
120
  }
90
- for (const v of currentAnalysis.localBindings) {
91
- if (v.offset < ctx.cursorOffset) {
92
- const e = buildVariableEntry(v, filter);
93
- if (e) items.push(e);
121
+
122
+ // Prefer the current AST; fall back to the last successfully-parsed one for scope analysis
123
+ const scopeAst = currentAnalysis.ast ?? lastValidAnalysis?.ast;
124
+ if (scopeAst) {
125
+ const enclosing = findEnclosingFunction(scopeAst, ctx.cursorOffset, currentAnalysis.functions);
126
+ if (enclosing) {
127
+ // Inside a function body: params + local bindings within this body before cursor
128
+ for (const p of enclosing.fn.params) {
129
+ const e = buildParamEntry(p, filter);
130
+ if (e) items.push(e);
131
+ }
132
+ for (const v of currentAnalysis.localBindings) {
133
+ if (v.offset > enclosing.bodyStart && v.offset < ctx.cursorOffset && v.offset < enclosing.bodyEnd) {
134
+ const e = buildVariableEntry(v, filter);
135
+ if (e) items.push(e);
136
+ }
137
+ }
138
+ } else {
139
+ // In query body: exclude local bindings that live inside function bodies
140
+ const functionBodyRanges = findAll(scopeAst, "FunctionBody")
141
+ .filter((b) => b.start !== undefined && b.end != null)
142
+ .map((b) => ({ start: b.start as number, end: b.end as number }));
143
+ for (const v of currentAnalysis.localBindings) {
144
+ if (v.offset >= ctx.cursorOffset) continue;
145
+ if (functionBodyRanges.some((r) => v.offset >= r.start && v.offset <= r.end)) continue;
146
+ const e = buildVariableEntry(v, filter);
147
+ if (e) items.push(e);
148
+ }
149
+ }
150
+ } else {
151
+ // No AST ever available: simple offset-based filter, no function-param completions
152
+ for (const v of currentAnalysis.localBindings) {
153
+ if (v.offset < ctx.cursorOffset) {
154
+ const e = buildVariableEntry(v, filter);
155
+ if (e) items.push(e);
156
+ }
94
157
  }
95
158
  }
159
+
160
+ // Imported module-level variables are always visible
96
161
  for (const analysis of importedAnalyses.values()) {
97
162
  for (const v of analysis.moduleVariables) {
98
163
  const e = buildVariableEntry(v, filter);
@@ -0,0 +1,34 @@
1
+ {
2
+ "util": "http://exist-db.org/xquery/util",
3
+ "xmldb": "http://exist-db.org/xquery/xmldb",
4
+ "system": "http://exist-db.org/xquery/system",
5
+ "request": "http://exist-db.org/xquery/request",
6
+ "response": "http://exist-db.org/xquery/response",
7
+ "session": "http://exist-db.org/xquery/session",
8
+ "process": "http://exist-db.org/xquery/process",
9
+ "sm": "http://exist-db.org/xquery/securitymanager",
10
+ "inspect": "http://exist-db.org/xquery/inspection",
11
+ "ft": "http://exist-db.org/xquery/lucene",
12
+ "ngram": "http://exist-db.org/xquery/ngram",
13
+ "range": "http://exist-db.org/xquery/range",
14
+ "sort": "http://exist-db.org/xquery/sort",
15
+ "file": "http://exist-db.org/xquery/file",
16
+ "compression": "http://exist-db.org/xquery/compression",
17
+ "cache": "http://exist-db.org/xquery/cache",
18
+ "transform": "http://exist-db.org/xquery/transform",
19
+ "scheduler": "http://exist-db.org/xquery/scheduler",
20
+ "validation": "http://exist-db.org/xquery/validation",
21
+ "image": "http://exist-db.org/xquery/image",
22
+ "repo": "http://exist-db.org/xquery/repo",
23
+ "mail": "http://exist-db.org/xquery/mail",
24
+ "xmldiff": "http://exist-db.org/xquery/xmldiff",
25
+ "contentextraction": "http://exist-db.org/xquery/contentextraction",
26
+ "counter": "http://exist-db.org/xquery/counter",
27
+ "exist": "http://exist.sourceforge.net/NS/exist",
28
+ "xsi": "http://www.w3.org/2001/XMLSchema-instance",
29
+ "xdt": "http://www.w3.org/2003/05/xpath-datatypes",
30
+ "err": "http://www.w3.org/2005/xqt-errors",
31
+ "exerr": "http://www.exist-db.org/xqt-errors/",
32
+ "java": "http://exist.sourceforge.net/NS/exist/java-binding",
33
+ "dbgp": "http://www.xdebug.org/"
34
+ }
@@ -619,6 +619,38 @@ declare function util:is-module-registered() as xs:boolean external;
619
619
  :)
620
620
  declare function util:line-number() as xs:integer external;
621
621
 
622
+ (:~
623
+ : Logs a message to the current logger (log4j). The $priority argument
624
+ : specifies the log level, e.g. 'DEBUG', 'INFO', 'WARN', 'ERROR'.
625
+ : @param $priority The log level priority string (e.g. 'INFO', 'WARN', 'ERROR')
626
+ : @param $message The message to log
627
+ :)
628
+ declare function util:log($priority as xs:string, $message as item()*) as empty-sequence() external;
629
+
630
+ (:~
631
+ : Logs a message to a named logger.
632
+ : @param $priority The log level: 'error', 'warn', 'debug', 'info', 'trace'
633
+ : @param $logger-name The name of the logger, e.g. 'my.app.log'
634
+ : @param $message The message to log
635
+ :)
636
+ declare function util:log-app(
637
+ $priority as xs:string,
638
+ $logger-name as xs:string,
639
+ $message as item()*
640
+ ) as empty-sequence() external;
641
+
642
+ (:~
643
+ : Logs the message to System.err.
644
+ : @param $message The message to log
645
+ :)
646
+ declare function util:log-system-err($message as item()*) as empty-sequence() external;
647
+
648
+ (:~
649
+ : Logs the message to System.out.
650
+ : @param $message The message to log
651
+ :)
652
+ declare function util:log-system-out($message as item()*) as empty-sequence() external;
653
+
622
654
  (:~
623
655
  : Returns a sequence of function items for each function in the current
624
656
  : module.
package/src/runtimes.ts CHANGED
@@ -2,10 +2,24 @@ import * as path from "path";
2
2
  import * as fs from "fs";
3
3
  import { fileURLToPath } from "url";
4
4
  import { analyze } from "./analyzer.ts";
5
- import type { FileAnalysis } from "./types.ts";
5
+ import type { FileAnalysis, NamespaceDecl } from "./types.ts";
6
6
 
7
7
  const runtimesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "runtimes");
8
8
 
9
+ function toNamespaceDecls(raw: Record<string, string>): NamespaceDecl[] {
10
+ return Object.entries(raw).map(([prefix, namespaceUri]) => ({ prefix, namespaceUri }));
11
+ }
12
+
13
+ // Lazily loaded predeclared namespace list for the existdb runtime.
14
+ let existdbPredeclared: NamespaceDecl[] | null = null;
15
+ function loadExistdbPredeclared(): NamespaceDecl[] {
16
+ return (existdbPredeclared ??= toNamespaceDecls(
17
+ JSON.parse(
18
+ fs.readFileSync(path.join(runtimesDir, "existdb", "predeclared-namespaces.json"), "utf-8"),
19
+ ) as Record<string, string>,
20
+ ));
21
+ }
22
+
9
23
  const RUNTIME_FILES: Record<string, string[]> = {
10
24
  fonto: [path.join(runtimesDir, "fonto", "fonto.xq")],
11
25
  existdb: [
@@ -44,6 +58,34 @@ const RUNTIME_FILES: Record<string, string[]> = {
44
58
 
45
59
  const runtimeAnalysisCache = new Map<string, FileAnalysis>();
46
60
 
61
+ /**
62
+ * Returns all namespace declarations that are in scope without any explicit
63
+ * `import module namespace` or `declare namespace` statement. Includes the
64
+ * standard XQuery 3.1 predeclared namespaces (math, map, array) and any
65
+ * runtime-specific additions (e.g. eXist-db's util, xmldb, …).
66
+ */
67
+ export function getRuntimePredeclaredNamespaces(runtimes: string[]): NamespaceDecl[] {
68
+ if (!runtimes.includes("existdb")) return [];
69
+ return loadExistdbPredeclared();
70
+ }
71
+
72
+ /**
73
+ * Return a copy of `analysis` with the given predeclared namespace declarations
74
+ * merged into `namespaceDecls`. Prefixes already visible via imports, the
75
+ * module's own prefix, or an existing `declare namespace` are skipped.
76
+ */
77
+ export function withPredeclaredNs(analysis: FileAnalysis, predeclaredNs: NamespaceDecl[]): FileAnalysis {
78
+ if (predeclaredNs.length === 0) return analysis;
79
+ const known = new Set<string>([
80
+ ...analysis.imports.map((i) => i.prefix),
81
+ ...analysis.namespaceDecls.map((nd) => nd.prefix),
82
+ ...(analysis.modulePrefix ? [analysis.modulePrefix] : []),
83
+ ]);
84
+ const toAdd = predeclaredNs.filter((nd) => !known.has(nd.prefix));
85
+ if (toAdd.length === 0) return analysis;
86
+ return { ...analysis, namespaceDecls: [...analysis.namespaceDecls, ...toAdd] };
87
+ }
88
+
47
89
  export function getRuntimeAnalyses(runtimes: string[]): FileAnalysis[] {
48
90
  const results: FileAnalysis[] = [];
49
91
  for (const runtime of runtimes) {
package/src/server.ts CHANGED
@@ -28,13 +28,15 @@ import {
28
28
  } from "./namespace-diagnostics.ts";
29
29
  import type { NamespaceUsageKind } from "./namespace-diagnostics.ts";
30
30
  import { checkUnused } from "./unused-diagnostics.ts";
31
+ import { checkUndeclaredVariables } from "./variable-diagnostics.ts";
31
32
  import { checkContextItemUsage } from "./context-item-diagnostics.ts";
32
- import { getRuntimeAnalyses } from "./runtimes.ts";
33
+ import { getRuntimeAnalyses, getRuntimePredeclaredNamespaces, withPredeclaredNs } from "./runtimes.ts";
33
34
 
34
35
  const connection = createConnection(ProposedFeatures.all);
35
36
  const documents = new TextDocuments(TextDocument);
36
37
 
37
38
  const analysisCache = new Map<string, FileAnalysis>();
39
+ const lastValidAnalysisCache = new Map<string, FileAnalysis>(); // last AST-path analysis per URI
38
40
  const typeErrorCache = new Map<string, TypeDiagnostic[]>();
39
41
 
40
42
  // Glob analyses keyed by config directory, then by module namespace URI.
@@ -44,6 +46,7 @@ const globAnalysesByConfigDir = new Map<string, Map<string, FileAnalysis>>();
44
46
  function analyzeDocument(doc: TextDocument): FileAnalysis {
45
47
  const { analysis } = analyzeWithAst(doc.getText(), doc.uri);
46
48
  analysisCache.set(doc.uri, analysis);
49
+ if (analysis.usedAstPath) lastValidAnalysisCache.set(doc.uri, analysis);
47
50
  return analysis;
48
51
  }
49
52
 
@@ -55,6 +58,7 @@ function analyzeDocumentFull(doc: TextDocument): {
55
58
  } {
56
59
  const { analysis, ast, parseError } = analyzeWithAst(doc.getText(), doc.uri);
57
60
  analysisCache.set(doc.uri, analysis);
61
+ if (analysis.usedAstPath) lastValidAnalysisCache.set(doc.uri, analysis);
58
62
  return { analysis, parseDiagnostics: buildParseDiagnostics(parseError), hasAst: ast !== null, ast };
59
63
  }
60
64
 
@@ -133,10 +137,16 @@ function getGlobAnalyses(currentUri: string): { byNamespace: Map<string, FileAna
133
137
  return { byNamespace, lib };
134
138
  }
135
139
 
136
- function resolveImports(currentUri: string, analysis: FileAnalysis): Map<string, FileAnalysis> {
140
+ function resolveContext(
141
+ currentUri: string,
142
+ rawAnalysis: FileAnalysis,
143
+ ): { analysis: FileAnalysis; imported: Map<string, FileAnalysis> } {
137
144
  const result = new Map<string, FileAnalysis>();
138
145
  result.set("builtin:fn", getBuiltins());
139
146
  const { byNamespace: globAnalyses, lib } = getGlobAnalyses(currentUri);
147
+ const predeclaredNs = getRuntimePredeclaredNamespaces(lib);
148
+ const analysis = withPredeclaredNs(rawAnalysis, predeclaredNs);
149
+
140
150
  const runtimeByNamespace = new Map<string, FileAnalysis>();
141
151
  for (const runtimeAnalysis of getRuntimeAnalyses(lib)) {
142
152
  if (runtimeAnalysis.moduleNamespaceUri) {
@@ -147,7 +157,7 @@ function resolveImports(currentUri: string, analysis: FileAnalysis): Map<string,
147
157
  );
148
158
  }
149
159
  }
150
- for (const imp of analysis.imports) {
160
+ for (const imp of rawAnalysis.imports) {
151
161
  if (imp.atPath) {
152
162
  const uri = resolveImportUri(currentUri, imp.atPath);
153
163
  const imported = getImportedAnalysis(uri);
@@ -158,7 +168,14 @@ function resolveImports(currentUri: string, analysis: FileAnalysis): Map<string,
158
168
  if (imported) result.set(imp.namespaceUri, imported);
159
169
  }
160
170
  }
161
- return result;
171
+ // Add pre-declared runtime analyses directly — available without explicit import
172
+ for (const nd of predeclaredNs) {
173
+ if (!result.has(nd.namespaceUri)) {
174
+ const runtimeAnalysis = runtimeByNamespace.get(nd.namespaceUri);
175
+ if (runtimeAnalysis) result.set(nd.namespaceUri, runtimeAnalysis);
176
+ }
177
+ }
178
+ return { analysis, imported: result };
162
179
  }
163
180
 
164
181
  // ── LSP lifecycle ────────────────────────────────────────────────────────────
@@ -187,13 +204,13 @@ connection.onInitialize((params) => {
187
204
 
188
205
  documents.onDidChangeContent((change) => {
189
206
  const doc = change.document;
190
- const { analysis, parseDiagnostics: parseDiags, hasAst, ast } = analyzeDocumentFull(doc);
207
+ const { analysis: rawAnalysis, parseDiagnostics: parseDiags, hasAst, ast } = analyzeDocumentFull(doc);
208
+ const { analysis, imported: resolvedImported } = resolveContext(doc.uri, rawAnalysis);
191
209
 
192
210
  if (hasAst && ast !== null) {
193
- const imported = resolveImports(doc.uri, analysis);
194
211
  typeErrorCache.set(doc.uri, [
195
- ...checkTypes(ast, doc.getText(), analysis, imported),
196
- ...checkFunctionCalls(ast, analysis, imported),
212
+ ...checkTypes(ast, doc.getText(), analysis, resolvedImported),
213
+ ...checkFunctionCalls(ast, analysis, resolvedImported),
197
214
  ]);
198
215
  }
199
216
 
@@ -247,39 +264,56 @@ documents.onDidChangeContent((change) => {
247
264
  source: "xquery-lsp",
248
265
  }));
249
266
 
267
+ const undeclaredVarDiagRaw =
268
+ hasAst && ast !== null ? checkUndeclaredVariables(ast, analysis, resolvedImported) : [];
269
+ const undeclaredVarDiags = undeclaredVarDiagRaw.map((d) => ({
270
+ severity: DiagnosticSeverity.Error,
271
+ range: {
272
+ start: doc.positionAt(d.offset),
273
+ end: doc.positionAt(d.offset + d.length),
274
+ },
275
+ message: d.message,
276
+ code: d.code,
277
+ source: "xquery-lsp",
278
+ }));
279
+
250
280
  connection.sendDiagnostics({
251
281
  uri: doc.uri,
252
- diagnostics: [...parseDiags, ...typeDiags, ...nsDiags, ...unusedDiags, ...contextItemDiags],
282
+ diagnostics: [
283
+ ...parseDiags, ...typeDiags, ...nsDiags, ...unusedDiags, ...contextItemDiags,
284
+ ...undeclaredVarDiags,
285
+ ],
253
286
  });
254
287
  });
255
288
 
256
289
  connection.onCompletion((params) => {
257
290
  const doc = documents.get(params.textDocument.uri);
258
291
  if (!doc) return [];
259
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
260
- const imported = resolveImports(doc.uri, analysis);
292
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
293
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
261
294
  const offset = doc.offsetAt(params.position);
262
295
  return getCompletions(
263
296
  { textBeforeCursor: doc.getText().slice(0, offset), cursorOffset: offset },
264
297
  analysis,
265
298
  imported,
266
299
  snippetSupport,
300
+ lastValidAnalysisCache.get(doc.uri),
267
301
  );
268
302
  });
269
303
 
270
304
  connection.onHover((params) => {
271
305
  const doc = documents.get(params.textDocument.uri);
272
306
  if (!doc) return null;
273
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
274
- const imported = resolveImports(doc.uri, analysis);
307
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
308
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
275
309
  return getHover(doc, doc.offsetAt(params.position), analysis, imported);
276
310
  });
277
311
 
278
312
  connection.onSignatureHelp((params) => {
279
313
  const doc = documents.get(params.textDocument.uri);
280
314
  if (!doc) return null;
281
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
282
- const imported = resolveImports(doc.uri, analysis);
315
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
316
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
283
317
  return getSignatureHelp(doc, doc.offsetAt(params.position), analysis, imported);
284
318
  });
285
319
 
@@ -293,8 +327,8 @@ connection.onDocumentSymbol((params) => {
293
327
  connection.onDefinition((params) => {
294
328
  const doc = documents.get(params.textDocument.uri);
295
329
  if (!doc) return null;
296
- const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
297
- const imported = resolveImports(doc.uri, analysis);
330
+ const rawAnalysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
331
+ const { analysis, imported } = resolveContext(doc.uri, rawAnalysis);
298
332
  return getDefinition(doc, doc.offsetAt(params.position), analysis, imported, (atPath) =>
299
333
  resolveImportUri(doc.uri, atPath),
300
334
  );
@@ -0,0 +1,190 @@
1
+ import type { Node, NonTerminal } from "xq-parser";
2
+ import type { FileAnalysis, QName } from "./types.ts";
3
+ import { qnameKey } from "./types.ts";
4
+ import { isTerminal, directChildOf, directChildrenOf } from "./analyzer.ts";
5
+ import { asFunctionDecl, asVarRef, asVarName, asBinding } from "./ast-nodes.ts";
6
+
7
+ export interface UndeclaredVariableDiagnostic {
8
+ message: string;
9
+ code: "XPST0008";
10
+ offset: number;
11
+ length: number;
12
+ }
13
+
14
+ // ── Scope stack ───────────────────────────────────────────────────────────────
15
+
16
+ class ScopeStack {
17
+ private frames: Array<Set<string>> = [];
18
+ push(): void { this.frames.push(new Set()); }
19
+ pop(): void { this.frames.pop(); }
20
+ add(key: string): void { this.frames[this.frames.length - 1]?.add(key); }
21
+ has(key: string): boolean { return this.frames.some((f) => f.has(key)); }
22
+ }
23
+
24
+ // ── Helpers ───────────────────────────────────────────────────────────────────
25
+
26
+ function isKnown(qname: QName, scope: ScopeStack, moduleVarKeys: Set<string>): boolean {
27
+ // Variables with an undeclared-prefix URI are already caught by XQST0081; skip here.
28
+ if (qname.namespaceUri.startsWith("urn:xq-lsp:undeclared:")) return true;
29
+ return scope.has(qnameKey(qname)) || moduleVarKeys.has(qnameKey(qname));
30
+ }
31
+
32
+ function reportVarRef(node: Node, qname: QName, out: UndeclaredVariableDiagnostic[]): void {
33
+ const name = qname.prefix ? `${qname.prefix}:${qname.localName}` : qname.localName;
34
+ out.push({
35
+ message: `Variable '$${name}' is not declared`,
36
+ code: "XPST0008",
37
+ offset: node.start ?? 0,
38
+ length: name.length + 1, // +1 for "$"
39
+ });
40
+ }
41
+
42
+ function addBinding(
43
+ bindingNode: Node,
44
+ scope: ScopeStack,
45
+ analysis: FileAnalysis,
46
+ moduleVarKeys: Set<string>,
47
+ out: UndeclaredVariableDiagnostic[],
48
+ ): void {
49
+ const b = asBinding(bindingNode, analysis);
50
+ if (!b) return;
51
+ if (b.initExpr) walk(b.initExpr, scope, analysis, moduleVarKeys, out);
52
+ scope.add(qnameKey(b.qname));
53
+ if (b.positionalVar) scope.add(qnameKey(b.positionalVar.qname));
54
+ }
55
+
56
+ // ── Scope-aware AST walker ────────────────────────────────────────────────────
57
+
58
+ function walk(
59
+ node: Node,
60
+ scope: ScopeStack,
61
+ analysis: FileAnalysis,
62
+ moduleVarKeys: Set<string>,
63
+ out: UndeclaredVariableDiagnostic[],
64
+ ): void {
65
+ if (isTerminal(node)) return;
66
+ const { children } = node as NonTerminal;
67
+
68
+ switch (node.type) {
69
+ case "AnnotatedDecl": {
70
+ const fnDeclNode = directChildOf(node, "FunctionDecl");
71
+ const fn = fnDeclNode ? asFunctionDecl(fnDeclNode, analysis) : null;
72
+ if (fn) {
73
+ scope.push();
74
+ for (const p of fn.params) scope.add(qnameKey(p.qname));
75
+ if (fn.body) walk(fn.body, scope, analysis, moduleVarKeys, out);
76
+ scope.pop();
77
+ return;
78
+ }
79
+ // Module variable declaration: walk the initializer expression only
80
+ const varDecl = directChildOf(node, "VarDecl");
81
+ if (varDecl && !isTerminal(varDecl)) {
82
+ for (const child of (varDecl as NonTerminal).children)
83
+ walk(child, scope, analysis, moduleVarKeys, out);
84
+ }
85
+ return;
86
+ }
87
+
88
+ case "FLWORExpr": {
89
+ scope.push();
90
+ for (const child of children) {
91
+ if (isTerminal(child)) continue;
92
+ // Unwrap InitialClause/IntermediateClause wrappers (may be multiply nested)
93
+ let clause: Node = child;
94
+ while (!isTerminal(clause) && (clause.type === "InitialClause" || clause.type === "IntermediateClause")) {
95
+ const inner: Node | undefined = (clause as NonTerminal).children.find((c) => !isTerminal(c));
96
+ if (!inner) break;
97
+ clause = inner;
98
+ }
99
+ switch (clause.type) {
100
+ case "ForClause":
101
+ for (const b of directChildrenOf(clause, "ForBinding"))
102
+ addBinding(b, scope, analysis, moduleVarKeys, out);
103
+ break;
104
+ case "LetClause":
105
+ for (const b of directChildrenOf(clause, "LetBinding"))
106
+ addBinding(b, scope, analysis, moduleVarKeys, out);
107
+ break;
108
+ case "CountClause":
109
+ addBinding(clause, scope, analysis, moduleVarKeys, out);
110
+ break;
111
+ case "GroupByClause": {
112
+ const specList = directChildOf(clause, "GroupingSpecList");
113
+ for (const spec of specList ? directChildrenOf(specList, "GroupingSpec") : [])
114
+ addBinding(spec, scope, analysis, moduleVarKeys, out);
115
+ break;
116
+ }
117
+ case "WindowClause": {
118
+ const inner =
119
+ directChildOf(clause, "TumblingWindowClause") ??
120
+ directChildOf(clause, "SlidingWindowClause");
121
+ if (inner) addBinding(inner, scope, analysis, moduleVarKeys, out);
122
+ for (const c of (clause as NonTerminal).children)
123
+ if (!isTerminal(c)) walk(c, scope, analysis, moduleVarKeys, out);
124
+ break;
125
+ }
126
+ default:
127
+ walk(clause, scope, analysis, moduleVarKeys, out);
128
+ }
129
+ }
130
+ scope.pop();
131
+ return;
132
+ }
133
+
134
+ case "QuantifiedExpr": {
135
+ scope.push();
136
+ for (const child of children) {
137
+ if (isTerminal(child)) continue;
138
+ if (child.type === "VarName") {
139
+ const qname = asVarName(child, analysis);
140
+ if (qname) scope.add(qnameKey(qname));
141
+ } else {
142
+ walk(child, scope, analysis, moduleVarKeys, out);
143
+ }
144
+ }
145
+ scope.pop();
146
+ return;
147
+ }
148
+
149
+ case "CatchClause":
150
+ // Skip: implicit $err:* variables inside catch clauses are not tracked in the AST.
151
+ return;
152
+
153
+ case "VarRef": {
154
+ const qname = asVarRef(node, analysis);
155
+ if (qname && !isKnown(qname, scope, moduleVarKeys))
156
+ reportVarRef(node, qname, out);
157
+ return;
158
+ }
159
+
160
+ default:
161
+ for (const child of children) walk(child, scope, analysis, moduleVarKeys, out);
162
+ }
163
+ }
164
+
165
+ // ── Public entry point ────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Walk the AST and report every variable reference that cannot be resolved
169
+ * to a declared variable: function parameter, let/for binding, module-level
170
+ * `declare variable`, or an imported module variable.
171
+ *
172
+ * Variables whose namespace prefix is undeclared are skipped — XQST0081
173
+ * already fires for those, and adding XPST0008 on top would be noisy.
174
+ * Variables inside catch clauses are also skipped since the implicit
175
+ * `$err:*` bindings are not represented in the AST.
176
+ */
177
+ export function checkUndeclaredVariables(
178
+ ast: Node,
179
+ analysis: FileAnalysis,
180
+ importedAnalyses: Map<string, FileAnalysis>,
181
+ ): UndeclaredVariableDiagnostic[] {
182
+ const moduleVarKeys = new Set<string>();
183
+ for (const v of analysis.moduleVariables) moduleVarKeys.add(qnameKey(v.qname));
184
+ for (const imported of importedAnalyses.values())
185
+ for (const v of imported.moduleVariables) moduleVarKeys.add(qnameKey(v.qname));
186
+
187
+ const out: UndeclaredVariableDiagnostic[] = [];
188
+ walk(ast, new ScopeStack(), analysis, moduleVarKeys, out);
189
+ return out;
190
+ }