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

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.
@@ -3,12 +3,27 @@ import type { FileAnalysis } from "./types.ts";
3
3
  export type NamespaceUsageKind = "function" | "variable" | "element";
4
4
  export interface NamespaceDiagnostic {
5
5
  message: string;
6
- code: "XQST0081";
6
+ code: "XQST0081" | "FONS0004";
7
7
  offset: number;
8
8
  length: number;
9
9
  prefix: string;
10
10
  usageKind: NamespaceUsageKind;
11
11
  }
12
+ export interface DynamicPrefixUse {
13
+ /** The prefix text. */
14
+ prefix: string;
15
+ /** Source offset of the prefix (inside the string literal). */
16
+ offset: number;
17
+ /** True when an unbound prefix is an error (xs:QName / xs:NOTATION); false for namespace-uri-for-prefix, which just returns (). */
18
+ required: boolean;
19
+ }
20
+ /**
21
+ * Prefixes taken from a string literal and resolved against the static
22
+ * in-scope namespaces at runtime: `xs:QName("p:l")`, `"p:l" cast as xs:QName`
23
+ * (also `castable as`, `xs:NOTATION`) and `fn:namespace-uri-for-prefix("p", …)`.
24
+ * Only literal operands are detected.
25
+ */
26
+ export declare function findDynamicPrefixUses(ast: Node, analysis: FileAnalysis): DynamicPrefixUse[];
12
27
  /**
13
28
  * Walk the AST and report every prefixed name reference whose prefix is not
14
29
  * declared in `analysis` (via import module namespace, declare namespace,
@@ -1,4 +1,70 @@
1
- import { isTerminal, directChildOf, firstTerminalValue, resolvePrefix } from "./analyzer.js";
1
+ import { XMLNS_FN, XMLNS_XS, isTerminal, directChildOf, findAll, firstTerminalValue, parseEQName, resolvePrefix, } from "./analyzer.js";
2
+ import { asFunctionCall } from "./ast-nodes.js";
3
+ /** Unwraps single-child grammar wrappers down to a StringLiteral terminal; null for anything more complex. */
4
+ function asStringLiteral(node) {
5
+ let cur = node;
6
+ while (cur && !isTerminal(cur)) {
7
+ const children = cur.children;
8
+ if (children.length !== 1)
9
+ return null;
10
+ cur = children[0];
11
+ }
12
+ return cur && cur.type === "StringLiteral" ? cur : null;
13
+ }
14
+ /**
15
+ * Prefixes taken from a string literal and resolved against the static
16
+ * in-scope namespaces at runtime: `xs:QName("p:l")`, `"p:l" cast as xs:QName`
17
+ * (also `castable as`, `xs:NOTATION`) and `fn:namespace-uri-for-prefix("p", …)`.
18
+ * Only literal operands are detected.
19
+ */
20
+ export function findDynamicPrefixUses(ast, analysis) {
21
+ const out = [];
22
+ const fromQNameLiteral = (lit, required) => {
23
+ if (!lit)
24
+ return;
25
+ const text = lit.value.slice(1, -1);
26
+ const trimmedStart = text.length - text.trimStart().length;
27
+ const t = text.trim();
28
+ const c = t.indexOf(":");
29
+ if (c <= 0)
30
+ return;
31
+ out.push({ prefix: t.slice(0, c), offset: lit.start + 1 + trimmedStart, required });
32
+ };
33
+ for (const call of findAll(ast, "FunctionCall")) {
34
+ const fc = asFunctionCall(call, analysis);
35
+ if (!fc)
36
+ continue;
37
+ const { namespaceUri, localName } = fc.qname;
38
+ const lit = asStringLiteral(fc.args[0]);
39
+ if (!lit)
40
+ continue;
41
+ if (namespaceUri === XMLNS_XS && (localName === "QName" || localName === "NOTATION")) {
42
+ fromQNameLiteral(lit, true);
43
+ }
44
+ else if (namespaceUri === XMLNS_FN && localName === "namespace-uri-for-prefix") {
45
+ const prefix = lit.value.slice(1, -1);
46
+ if (prefix.length > 0)
47
+ out.push({ prefix, offset: lit.start + 1, required: false });
48
+ }
49
+ }
50
+ // castable as never raises an error for an unbound prefix; it just yields false.
51
+ for (const type of ["CastExpr", "CastableExpr"]) {
52
+ for (const node of findAll(ast, type)) {
53
+ const single = directChildOf(node, "SingleType");
54
+ const typeNode = single && findAll(single, "QName")[0];
55
+ const typeName = typeNode && firstTerminalValue(typeNode);
56
+ if (!typeName)
57
+ continue;
58
+ const { prefix, localName, uri } = parseEQName(typeName);
59
+ if ((uri ?? resolvePrefix(prefix, analysis)) !== XMLNS_XS)
60
+ continue;
61
+ if (localName !== "QName" && localName !== "NOTATION")
62
+ continue;
63
+ fromQNameLiteral(asStringLiteral(node.children[0]), type === "CastExpr");
64
+ }
65
+ }
66
+ return out;
67
+ }
2
68
  function checkQName(node, kind, analysis, inlineStack, out) {
3
69
  if (!node)
4
70
  return;
@@ -23,6 +89,20 @@ function checkQName(node, kind, analysis, inlineStack, out) {
23
89
  });
24
90
  }
25
91
  }
92
+ function inlineXmlnsPrefixes(elem) {
93
+ const local = new Set();
94
+ const attrList = directChildOf(elem, "DirAttributeList");
95
+ if (attrList && !isTerminal(attrList)) {
96
+ for (const c of attrList.children) {
97
+ if (c.type !== "QName")
98
+ continue;
99
+ const attrName = firstTerminalValue(c);
100
+ if (attrName?.startsWith("xmlns:"))
101
+ local.add(attrName.slice(6));
102
+ }
103
+ }
104
+ return local;
105
+ }
26
106
  function walk(node, analysis, inlineStack, out) {
27
107
  if (isTerminal(node))
28
108
  return;
@@ -30,17 +110,7 @@ function walk(node, analysis, inlineStack, out) {
30
110
  if (node.type === "DirElemConstructor") {
31
111
  // Collect xmlns:prefix declarations from this element's attribute list,
32
112
  // then push them onto the scope stack for the duration of this subtree.
33
- const local = new Set();
34
- const attrList = directChildOf(node, "DirAttributeList");
35
- if (attrList && !isTerminal(attrList)) {
36
- for (const c of attrList.children) {
37
- if (c.type !== "QName")
38
- continue;
39
- const attrName = firstTerminalValue(c);
40
- if (attrName?.startsWith("xmlns:"))
41
- local.add(attrName.slice(6));
42
- }
43
- }
113
+ const local = inlineXmlnsPrefixes(node);
44
114
  inlineStack.push(local);
45
115
  checkQName(directChildOf(node, "QName"), "element", analysis, inlineStack, out);
46
116
  for (const c of children)
@@ -77,6 +147,29 @@ export function findUndeclaredPrefixUsages(ast, analysis) {
77
147
  return [];
78
148
  const out = [];
79
149
  walk(ast, analysis, [], out);
150
+ // Prefixes inside string literals resolve against the in-scope namespaces, which
151
+ // include xmlns:prefix declarations of enclosing direct element constructors.
152
+ const dirElems = findAll(ast, "DirElemConstructor").map((el) => ({
153
+ start: el.start ?? 0,
154
+ end: el.end ?? 0,
155
+ prefixes: inlineXmlnsPrefixes(el),
156
+ }));
157
+ for (const use of findDynamicPrefixUses(ast, analysis)) {
158
+ if (!use.required)
159
+ continue;
160
+ if (dirElems.some((el) => el.start <= use.offset && use.offset < el.end && el.prefixes.has(use.prefix)))
161
+ continue;
162
+ if (!resolvePrefix(use.prefix, analysis).startsWith("urn:xq-lsp:undeclared:"))
163
+ continue;
164
+ out.push({
165
+ message: `Namespace prefix '${use.prefix}' is not declared`,
166
+ code: "FONS0004",
167
+ offset: use.offset,
168
+ length: use.prefix.length,
169
+ prefix: use.prefix,
170
+ usageKind: "element",
171
+ });
172
+ }
80
173
  return out;
81
174
  }
82
175
  // ── Insertion-position helpers (used by the code action handler) ─────────────
package/dist/server.js CHANGED
@@ -524,7 +524,7 @@ connection.onCodeAction((params) => {
524
524
  const actions = [];
525
525
  const only = params.context.only;
526
526
  for (const diag of params.context.diagnostics) {
527
- if (diag.code !== "XQST0081")
527
+ if (diag.code !== "XQST0081" && diag.code !== "FONS0004")
528
528
  continue;
529
529
  const data = diag.data;
530
530
  if (!data?.prefix)
@@ -1,4 +1,5 @@
1
1
  import { qnameKey, formatQName } from "./types.js";
2
+ import { findDynamicPrefixUses } from "./namespace-diagnostics.js";
2
3
  import { isTerminal, directChildOf, directChildrenOf, findAll, firstTerminalValue, parseEQName, resolvePrefix } from "./analyzer.js";
3
4
  import { asFunctionCall, asNamedFunctionRef, asVarRef, asVarName, asFunctionDecl, asFunctionDeclaration, asVariableDeclaration, asBinding, asCatchClause, } from "./ast-nodes.js";
4
5
  // ── Scope stack ───────────────────────────────────────────────────────────────
@@ -170,7 +171,7 @@ function walk(node, scope, analysis, usedFns, usedVars) {
170
171
  * two terminal types catches every usage site without enumerating each
171
172
  * grammar production that can carry a prefix.
172
173
  */
173
- function collectUsedPrefixes(ast) {
174
+ function collectUsedPrefixes(ast, analysis) {
174
175
  const used = new Set();
175
176
  for (const node of findAll(ast, "QName")) {
176
177
  const value = firstTerminalValue(node);
@@ -191,8 +192,13 @@ function collectUsedPrefixes(ast) {
191
192
  if (prefix !== "*")
192
193
  used.add(prefix);
193
194
  }
195
+ collectDynamicPrefixUses(ast, analysis, used);
194
196
  return used;
195
197
  }
198
+ function collectDynamicPrefixUses(ast, analysis, used) {
199
+ for (const use of findDynamicPrefixUses(ast, analysis))
200
+ used.add(use.prefix);
201
+ }
196
202
  // ── Public entry point ────────────────────────────────────────────────────────
197
203
  /**
198
204
  * Walk the AST and report declared functions and module-level variables that
@@ -248,7 +254,7 @@ export function checkUnused(ast, analysis) {
248
254
  });
249
255
  }
250
256
  // ── Check imports and namespace declarations ─────────────────────────────
251
- const usedPrefixes = collectUsedPrefixes(ast);
257
+ const usedPrefixes = collectUsedPrefixes(ast, analysis);
252
258
  for (const imp of analysis.imports) {
253
259
  if (usedPrefixes.has(imp.prefix))
254
260
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xq-lsp",
3
- "version": "1.8.3-next.430",
3
+ "version": "1.8.3-next.437",
4
4
  "description": "XQuery Language Server Protocol implementation",
5
5
  "license": "MIT",
6
6
  "author": "Martin Middel",
@@ -1,18 +1,96 @@
1
- import type { Node } from "xq-parser";
1
+ import type { Node, NonTerminal, Terminal } from "xq-parser";
2
2
  import type { FileAnalysis } from "./types.ts";
3
- import { isTerminal, directChildOf, firstTerminalValue, resolvePrefix } from "./analyzer.ts";
3
+ import {
4
+ XMLNS_FN,
5
+ XMLNS_XS,
6
+ isTerminal,
7
+ directChildOf,
8
+ findAll,
9
+ firstTerminalValue,
10
+ parseEQName,
11
+ resolvePrefix,
12
+ } from "./analyzer.ts";
13
+ import { asFunctionCall } from "./ast-nodes.ts";
4
14
 
5
15
  export type NamespaceUsageKind = "function" | "variable" | "element";
6
16
 
7
17
  export interface NamespaceDiagnostic {
8
18
  message: string;
9
- code: "XQST0081";
19
+ code: "XQST0081" | "FONS0004";
10
20
  offset: number; // offset of the prefix start in source
11
21
  length: number; // length of the prefix (not including the colon)
12
22
  prefix: string;
13
23
  usageKind: NamespaceUsageKind;
14
24
  }
15
25
 
26
+ /** Unwraps single-child grammar wrappers down to a StringLiteral terminal; null for anything more complex. */
27
+ function asStringLiteral(node: Node | undefined): Terminal | null {
28
+ let cur: Node | undefined = node;
29
+ while (cur && !isTerminal(cur)) {
30
+ const children = (cur as NonTerminal).children;
31
+ if (children.length !== 1) return null;
32
+ cur = children[0];
33
+ }
34
+ return cur && cur.type === "StringLiteral" ? (cur as Terminal) : null;
35
+ }
36
+
37
+ export interface DynamicPrefixUse {
38
+ /** The prefix text. */
39
+ prefix: string;
40
+ /** Source offset of the prefix (inside the string literal). */
41
+ offset: number;
42
+ /** True when an unbound prefix is an error (xs:QName / xs:NOTATION); false for namespace-uri-for-prefix, which just returns (). */
43
+ required: boolean;
44
+ }
45
+
46
+ /**
47
+ * Prefixes taken from a string literal and resolved against the static
48
+ * in-scope namespaces at runtime: `xs:QName("p:l")`, `"p:l" cast as xs:QName`
49
+ * (also `castable as`, `xs:NOTATION`) and `fn:namespace-uri-for-prefix("p", …)`.
50
+ * Only literal operands are detected.
51
+ */
52
+ export function findDynamicPrefixUses(ast: Node, analysis: FileAnalysis): DynamicPrefixUse[] {
53
+ const out: DynamicPrefixUse[] = [];
54
+ const fromQNameLiteral = (lit: Terminal | null, required: boolean): void => {
55
+ if (!lit) return;
56
+ const text = lit.value.slice(1, -1);
57
+ const trimmedStart = text.length - text.trimStart().length;
58
+ const t = text.trim();
59
+ const c = t.indexOf(":");
60
+ if (c <= 0) return;
61
+ out.push({ prefix: t.slice(0, c), offset: lit.start + 1 + trimmedStart, required });
62
+ };
63
+
64
+ for (const call of findAll(ast, "FunctionCall")) {
65
+ const fc = asFunctionCall(call, analysis);
66
+ if (!fc) continue;
67
+ const { namespaceUri, localName } = fc.qname;
68
+ const lit = asStringLiteral(fc.args[0]);
69
+ if (!lit) continue;
70
+ if (namespaceUri === XMLNS_XS && (localName === "QName" || localName === "NOTATION")) {
71
+ fromQNameLiteral(lit, true);
72
+ } else if (namespaceUri === XMLNS_FN && localName === "namespace-uri-for-prefix") {
73
+ const prefix = lit.value.slice(1, -1);
74
+ if (prefix.length > 0) out.push({ prefix, offset: lit.start + 1, required: false });
75
+ }
76
+ }
77
+
78
+ // castable as never raises an error for an unbound prefix; it just yields false.
79
+ for (const type of ["CastExpr", "CastableExpr"]) {
80
+ for (const node of findAll(ast, type)) {
81
+ const single = directChildOf(node, "SingleType");
82
+ const typeNode = single && findAll(single, "QName")[0];
83
+ const typeName = typeNode && firstTerminalValue(typeNode);
84
+ if (!typeName) continue;
85
+ const { prefix, localName, uri } = parseEQName(typeName);
86
+ if ((uri ?? resolvePrefix(prefix, analysis)) !== XMLNS_XS) continue;
87
+ if (localName !== "QName" && localName !== "NOTATION") continue;
88
+ fromQNameLiteral(asStringLiteral((node as NonTerminal).children[0]), type === "CastExpr");
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
16
94
  function checkQName(
17
95
  node: Node | undefined,
18
96
  kind: NamespaceUsageKind,
@@ -40,6 +118,19 @@ function checkQName(
40
118
  }
41
119
  }
42
120
 
121
+ function inlineXmlnsPrefixes(elem: Node): Set<string> {
122
+ const local = new Set<string>();
123
+ const attrList = directChildOf(elem, "DirAttributeList");
124
+ if (attrList && !isTerminal(attrList)) {
125
+ for (const c of (attrList as { children: Node[] }).children) {
126
+ if (c.type !== "QName") continue;
127
+ const attrName = firstTerminalValue(c);
128
+ if (attrName?.startsWith("xmlns:")) local.add(attrName.slice(6));
129
+ }
130
+ }
131
+ return local;
132
+ }
133
+
43
134
  function walk(
44
135
  node: Node,
45
136
  analysis: FileAnalysis,
@@ -52,15 +143,7 @@ function walk(
52
143
  if (node.type === "DirElemConstructor") {
53
144
  // Collect xmlns:prefix declarations from this element's attribute list,
54
145
  // then push them onto the scope stack for the duration of this subtree.
55
- const local = new Set<string>();
56
- const attrList = directChildOf(node, "DirAttributeList");
57
- if (attrList && !isTerminal(attrList)) {
58
- for (const c of (attrList as { children: Node[] }).children) {
59
- if (c.type !== "QName") continue;
60
- const attrName = firstTerminalValue(c);
61
- if (attrName?.startsWith("xmlns:")) local.add(attrName.slice(6));
62
- }
63
- }
146
+ const local = inlineXmlnsPrefixes(node);
64
147
  inlineStack.push(local);
65
148
  checkQName(directChildOf(node, "QName"), "element", analysis, inlineStack, out);
66
149
  for (const c of children) walk(c, analysis, inlineStack, out);
@@ -100,6 +183,27 @@ export function findUndeclaredPrefixUsages(
100
183
  if (!ast) return [];
101
184
  const out: NamespaceDiagnostic[] = [];
102
185
  walk(ast, analysis, [], out);
186
+
187
+ // Prefixes inside string literals resolve against the in-scope namespaces, which
188
+ // include xmlns:prefix declarations of enclosing direct element constructors.
189
+ const dirElems = findAll(ast, "DirElemConstructor").map((el) => ({
190
+ start: el.start ?? 0,
191
+ end: el.end ?? 0,
192
+ prefixes: inlineXmlnsPrefixes(el),
193
+ }));
194
+ for (const use of findDynamicPrefixUses(ast, analysis)) {
195
+ if (!use.required) continue;
196
+ if (dirElems.some((el) => el.start <= use.offset && use.offset < el.end && el.prefixes.has(use.prefix))) continue;
197
+ if (!resolvePrefix(use.prefix, analysis).startsWith("urn:xq-lsp:undeclared:")) continue;
198
+ out.push({
199
+ message: `Namespace prefix '${use.prefix}' is not declared`,
200
+ code: "FONS0004",
201
+ offset: use.offset,
202
+ length: use.prefix.length,
203
+ prefix: use.prefix,
204
+ usageKind: "element",
205
+ });
206
+ }
103
207
  return out;
104
208
  }
105
209
 
package/src/server.ts CHANGED
@@ -588,7 +588,7 @@ connection.onCodeAction((params) => {
588
588
  const only = params.context.only as CodeActionKind[] | undefined;
589
589
 
590
590
  for (const diag of params.context.diagnostics) {
591
- if (diag.code !== "XQST0081") continue;
591
+ if (diag.code !== "XQST0081" && diag.code !== "FONS0004") continue;
592
592
  const data = diag.data as { prefix: string; usageKind: NamespaceUsageKind } | undefined;
593
593
  if (!data?.prefix) continue;
594
594
 
@@ -1,6 +1,7 @@
1
1
  import type { Node, NonTerminal } from "xq-parser";
2
2
  import type { FileAnalysis } from "./types.ts";
3
3
  import { qnameKey, formatQName } from "./types.ts";
4
+ import { findDynamicPrefixUses } from "./namespace-diagnostics.ts";
4
5
  import { isTerminal, directChildOf, directChildrenOf, findAll, firstTerminalValue, parseEQName, resolvePrefix } from "./analyzer.ts";
5
6
  import {
6
7
  asFunctionCall,
@@ -198,7 +199,7 @@ function walk(
198
199
  * two terminal types catches every usage site without enumerating each
199
200
  * grammar production that can carry a prefix.
200
201
  */
201
- function collectUsedPrefixes(ast: Node): Set<string> {
202
+ function collectUsedPrefixes(ast: Node, analysis: FileAnalysis): Set<string> {
202
203
  const used = new Set<string>();
203
204
 
204
205
  for (const node of findAll(ast, "QName")) {
@@ -217,9 +218,15 @@ function collectUsedPrefixes(ast: Node): Set<string> {
217
218
  if (prefix !== "*") used.add(prefix);
218
219
  }
219
220
 
221
+ collectDynamicPrefixUses(ast, analysis, used);
222
+
220
223
  return used;
221
224
  }
222
225
 
226
+ function collectDynamicPrefixUses(ast: Node, analysis: FileAnalysis, used: Set<string>): void {
227
+ for (const use of findDynamicPrefixUses(ast, analysis)) used.add(use.prefix);
228
+ }
229
+
223
230
  // ── Public entry point ────────────────────────────────────────────────────────
224
231
 
225
232
  /**
@@ -278,7 +285,7 @@ export function checkUnused(ast: Node, analysis: FileAnalysis): UnusedDiagnostic
278
285
 
279
286
  // ── Check imports and namespace declarations ─────────────────────────────
280
287
 
281
- const usedPrefixes = collectUsedPrefixes(ast);
288
+ const usedPrefixes = collectUsedPrefixes(ast, analysis);
282
289
 
283
290
  for (const imp of analysis.imports) {
284
291
  if (usedPrefixes.has(imp.prefix)) continue;