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 +16 -0
- package/README.md +54 -8
- package/dist/completion-core.d.ts +1 -1
- package/dist/completion-core.js +72 -7
- package/dist/runtimes.d.ts +14 -1
- package/dist/runtimes.js +37 -0
- package/dist/server.js +48 -18
- package/dist/variable-diagnostics.d.ts +19 -0
- package/dist/variable-diagnostics.js +164 -0
- package/package.json +3 -3
- package/src/completion-core.ts +71 -6
- package/src/runtimes/existdb/predeclared-namespaces.json +34 -0
- package/src/runtimes/existdb/util.xq +32 -0
- package/src/runtimes.ts +43 -1
- package/src/server.ts +51 -17
- package/src/variable-diagnostics.ts +190 -0
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
|
|
63
|
-
|
|
|
64
|
-
| `"fonto"`
|
|
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": "
|
|
68
|
+
map {"glob": "src/**/*.xq", "lib": "existdb"}
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
Multiple libs use an XPath sequence:
|
|
71
72
|
|
|
72
73
|
```xquery
|
|
73
|
-
|
|
74
|
+
map {"glob": "src/**/*.xq", "lib": ("fonto", "other")}
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
|
|
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
|
-
|
|
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[];
|
package/dist/completion-core.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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);
|
package/dist/runtimes.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
163
|
-
...checkFunctionCalls(ast, analysis,
|
|
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: [
|
|
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
|
|
222
|
-
const imported =
|
|
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
|
|
231
|
-
const imported =
|
|
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
|
|
239
|
-
const imported =
|
|
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
|
|
254
|
-
const imported =
|
|
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.
|
|
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.
|
|
28
|
+
"xq-parser": "^0.5.1"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^25.6.0",
|
package/src/completion-core.ts
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
196
|
-
...checkFunctionCalls(ast, analysis,
|
|
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: [
|
|
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
|
|
260
|
-
const imported =
|
|
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
|
|
274
|
-
const imported =
|
|
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
|
|
282
|
-
const imported =
|
|
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
|
|
297
|
-
const imported =
|
|
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
|
+
}
|