xq-lsp 1.8.2 → 1.8.3-next.430

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/dist/types.d.ts CHANGED
@@ -26,6 +26,7 @@ export interface FunctionSymbol {
26
26
  doc?: DocComment;
27
27
  sourceUri: string;
28
28
  sourceOffset?: number;
29
+ visibility?: "public" | "private";
29
30
  }
30
31
  export interface VariableSymbol {
31
32
  qname: QName;
@@ -33,6 +34,7 @@ export interface VariableSymbol {
33
34
  isModuleLevel: boolean;
34
35
  sourceUri: string;
35
36
  doc?: string;
37
+ type?: string;
36
38
  }
37
39
  export interface ImportInfo {
38
40
  prefix: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xq-lsp",
3
- "version": "1.8.2",
3
+ "version": "1.8.3-next.430",
4
4
  "description": "XQuery Language Server Protocol implementation",
5
5
  "license": "MIT",
6
6
  "author": "Martin Middel",
package/src/analyzer.ts CHANGED
@@ -26,7 +26,7 @@ export const XMLNS_ARRAY = "http://www.w3.org/2005/xpath-functions/array";
26
26
  // context, independent of any runtime/library configuration. math/map/array
27
27
  // aren't spec-mandated but are universally available in practice (they back
28
28
  // the 3.1 map/array/math core function libraries), so they're predeclared here too.
29
- const BUILTIN_PREFIXES: Record<string, string> = {
29
+ export const BUILTIN_PREFIXES: Record<string, string> = {
30
30
  fn: XMLNS_FN,
31
31
  local: XMLNS_LOCAL,
32
32
  xs: XMLNS_XS,
@@ -176,7 +176,10 @@ function findPrecedingDoc(comments: Terminal[], text: string, offset: number): D
176
176
  // ── Namespace helpers ────────────────────────────────────────────────────────
177
177
 
178
178
  function extractModuleNamespace(text: string): { prefix: string; uri: string } | undefined {
179
- const m = text.match(/module\s+namespace\s+([\w\-]+)\s*=\s*["']([^"']*)["']/);
179
+ // The negative lookbehind excludes `import module namespace ...` — a module
180
+ // import, not this file's own `module namespace` declaration — which would
181
+ // otherwise match the same pattern and be mistaken for it.
182
+ const m = text.match(/(?<!import\s+)\bmodule\s+namespace\s+([\w\-]+)\s*=\s*["']([^"']*)["']/);
180
183
  return m ? { prefix: m[1], uri: m[2] } : undefined;
181
184
  }
182
185
 
@@ -201,6 +204,25 @@ function resolveNamespaceUri(prefix: string, prefixMap: Map<string, string>): st
201
204
  return prefixMap.get(prefix) ?? `urn:xq-lsp:undeclared:${prefix}`;
202
205
  }
203
206
 
207
+ const PREDEFINED_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
208
+
209
+ /**
210
+ * A BracedURILiteral's content may contain PredefinedEntityRef (&amp; &lt; &gt;
211
+ * &quot; &apos;) and CharRef (&#DDDD; / &#xHHHH;) escapes, same as string content —
212
+ * e.g. `Q{http:&#x2F;&#x2F;example.com}` names the URI "http://example.com". The
213
+ * parser's terminal value is the raw, un-decoded source slice, so this decoding
214
+ * has to happen here rather than being assumed already done.
215
+ */
216
+ function decodeCharRefs(text: string): string {
217
+ return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, ref: string) => {
218
+ if (ref[0] === "#") {
219
+ const codePoint = ref[1] === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
220
+ return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint);
221
+ }
222
+ return PREDEFINED_ENTITIES[ref] ?? match;
223
+ });
224
+ }
225
+
204
226
  /**
205
227
  * Per the BracedURILiteral grammar, whitespace inside `Q{...}` is insignificant:
206
228
  * leading/trailing whitespace is trimmed and internal runs are collapsed to a
@@ -208,7 +230,7 @@ function resolveNamespaceUri(prefix: string, prefixMap: Map<string, string>): st
208
230
  * This does not decode percent-escapes — `Q{foo%20bar}` stays distinct from `Q{foo bar}`.
209
231
  */
210
232
  function normalizeBracedUri(uri: string): string {
211
- return uri.trim().replace(/\s+/g, " ");
233
+ return decodeCharRefs(uri).trim().replace(/\s+/g, " ");
212
234
  }
213
235
 
214
236
  /**
@@ -244,6 +266,19 @@ export function sequenceTypeText(text: string, node: Node): string | undefined {
244
266
  return text.slice(node.start, node.end ?? undefined).trim() || undefined;
245
267
  }
246
268
 
269
+ // A %public/%private annotation on an AnnotatedDecl controls whether the
270
+ // declaration is visible to modules that import it (XQuery 3.0+). Returns
271
+ // undefined when neither annotation is present; callers treat that as public,
272
+ // same as FunctionSymbol.visibility's own default.
273
+ function extractVisibility(annotated: Node): "public" | "private" | undefined {
274
+ for (const ann of directChildrenOf(annotated, "Annotation")) {
275
+ const eqname = directChildOf(ann, "EQName");
276
+ const raw = eqname ? firstTerminalValue(eqname) : null;
277
+ if (raw === "private" || raw === "public") return raw;
278
+ }
279
+ return undefined;
280
+ }
281
+
247
282
  function extractFunctions(
248
283
  ast: Node,
249
284
  sourceUri: string,
@@ -296,6 +331,7 @@ function extractFunctions(
296
331
  doc,
297
332
  sourceUri,
298
333
  sourceOffset: annotated.start,
334
+ visibility: extractVisibility(annotated),
299
335
  });
300
336
  }
301
337
  return results;
@@ -318,12 +354,15 @@ function extractModuleVariables(
318
354
  const name = varName ? firstTerminalValue(varName) : null;
319
355
  if (!name) continue;
320
356
  const doc = findPrecedingDoc(comments, text, annotated.start);
357
+ const typeDecl = directChildOf(varDecl, "TypeDeclaration");
358
+ const seqType = typeDecl ? directChildOf(typeDecl, "SequenceType") : undefined;
321
359
  results.push({
322
360
  qname: makeVarQName(name, prefixMap),
323
361
  offset: varDecl.start,
324
362
  isModuleLevel: true,
325
363
  sourceUri,
326
364
  doc: doc?.description || undefined,
365
+ type: seqType ? sequenceTypeText(text, seqType) : undefined,
327
366
  });
328
367
  }
329
368
  return results;
@@ -466,12 +505,15 @@ function analyzeRegex(text: string, sourceUri: string): FileAnalysis {
466
505
  RE_VAR_DECL.lastIndex = 0;
467
506
  while ((m = RE_VAR_DECL.exec(text)) !== null) {
468
507
  const doc = findPrecedingDocInText(text, m.index);
508
+ // `declare variable $x as T := …` / `… as T external` — T runs up to `:=` or `external`.
509
+ const typeMatch = /^\s+as\s+([^;]{1,200}?)\s*(?::=|\bexternal\b)/.exec(text.slice(m.index + m[0].length));
469
510
  moduleVariables.push({
470
511
  qname: makeVarQName(m[1], prefixMap),
471
512
  offset: m.index,
472
513
  isModuleLevel: true,
473
514
  sourceUri,
474
515
  doc: doc?.description || undefined,
516
+ type: typeMatch?.[1],
475
517
  });
476
518
  }
477
519
 
package/src/ast-nodes.ts CHANGED
@@ -145,6 +145,63 @@ export function asFunctionDecl(node: Node, analysis: FileAnalysis): FunctionDecl
145
145
  return { nameNode, qname: { prefix, localName, namespaceUri }, params, body: directChildOf(node, "FunctionBody") ?? null };
146
146
  }
147
147
 
148
+ // ── Prolog declarations ───────────────────────────────────────────────────────
149
+
150
+ /** The version string of `xquery version "x"` (quotes stripped) and its literal node; null when only an encoding is given. */
151
+ export function asVersionDecl(node: Node): { version: string; versionNode: Node } | null {
152
+ if (node.type !== "VersionDecl" || !directChildOf(node, "'version'")) return null;
153
+ const versionNode = directChildOf(node, "StringLiteral");
154
+ const raw = versionNode ? firstTerminalValue(versionNode) : null;
155
+ if (!versionNode || !raw) return null;
156
+ // String literals may contain character references, e.g. '1&#x2e;0'
157
+ const version = raw
158
+ .slice(1, -1)
159
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
160
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
161
+ return { version, versionNode };
162
+ }
163
+
164
+ /** Resolved QName and name node of a `declare option` OptionDecl. */
165
+ export function asOptionDecl(node: Node, analysis: FileAnalysis): { qname: QName; nameNode: Node } | null {
166
+ if (node.type !== "OptionDecl") return null;
167
+ const nameNode = directChildOf(node, "EQName");
168
+ const qname = nameNode ? qnameFromRawTerminal(nameNode, analysis) : null;
169
+ return nameNode && qname ? { qname, nameNode } : null;
170
+ }
171
+
172
+ /** Prefix bound by a `declare namespace p = "uri"` NamespaceDecl. */
173
+ export function asNamespaceDecl(node: Node): { prefix: string } | null {
174
+ if (node.type !== "NamespaceDecl") return null;
175
+ const prefixNode = directChildOf(node, "NCName");
176
+ const prefix = prefixNode ? firstTerminalValue(prefixNode) : null;
177
+ return prefix === null ? null : { prefix };
178
+ }
179
+
180
+ /** Which default namespace a `declare default element|function namespace` DefaultNamespaceDecl sets. */
181
+ export function asDefaultNamespaceDecl(node: Node): { kind: "element" | "function" } | null {
182
+ if (node.type !== "DefaultNamespaceDecl") return null;
183
+ if (directChildOf(node, "'element'")) return { kind: "element" };
184
+ if (directChildOf(node, "'function'")) return { kind: "function" };
185
+ return null;
186
+ }
187
+
188
+ export interface DecimalFormatDeclShape {
189
+ /** Expanded name of the format; "" for the default decimal format. */
190
+ key: string;
191
+ properties: Array<{ nameNode: Node; name: string }>;
192
+ }
193
+
194
+ export function asDecimalFormatDecl(node: Node, analysis: FileAnalysis): DecimalFormatDeclShape | null {
195
+ if (node.type !== "DecimalFormatDecl") return null;
196
+ const eqname = directChildOf(node, "EQName");
197
+ const qname = eqname ? qnameFromRawTerminal(eqname, analysis) : null;
198
+ const properties = directChildrenOf(node, "DFPropertyName").flatMap((nameNode) => {
199
+ const name = firstTerminalValue(nameNode);
200
+ return name ? [{ nameNode, name }] : [];
201
+ });
202
+ return { key: qname ? `${qname.namespaceUri}#${qname.localName}` : "", properties };
203
+ }
204
+
148
205
  // ── InlineFunctionExpr ────────────────────────────────────────────────────────
149
206
 
150
207
  export interface InlineFunctionExprShape {
@@ -426,3 +483,31 @@ export function argExpr(node: Node): Node | null {
426
483
  if (node.type !== "Argument") return null;
427
484
  return directChildOf(node, "ExprSingle") ?? null;
428
485
  }
486
+
487
+ // ── Arithmetic expressions ────────────────────────────────────────────────────
488
+
489
+ export interface ArithmeticExprShape {
490
+ /** Operand expressions in source order (always at least two). */
491
+ operands: Node[];
492
+ /**
493
+ * Operators in source order; `operators[i]` sits between `operands[i]` and `operands[i + 1]`.
494
+ * Invariant: `operators.length === operands.length - 1`, so `operands[i + 1]` is always
495
+ * defined for every index into `operators`.
496
+ */
497
+ operators: string[];
498
+ }
499
+
500
+ // The grammar produces a flat, left-associative chain `operand (op operand)*`. Every expression
501
+ // passes through these node types, so a chain with a single operand is not arithmetic → null.
502
+ export function asArithmeticExpr(node: Node): ArithmeticExprShape | null {
503
+ if (node.type !== "AdditiveExpr" && node.type !== "MultiplicativeExpr") return null;
504
+ if (isTerminal(node)) return null;
505
+ const operands: Node[] = [];
506
+ const operators: string[] = [];
507
+ for (const c of (node as import("xq-parser").NonTerminal).children) {
508
+ if (isTerminal(c)) operators.push(c.value);
509
+ else operands.push(c);
510
+ }
511
+ // Enforces the invariant documented on ArithmeticExprShape; a malformed chain is not arithmetic.
512
+ return operands.length > 1 && operators.length === operands.length - 1 ? { operands, operators } : null;
513
+ }
@@ -12,6 +12,7 @@ import { findUndeclaredPrefixUsages } from "./namespace-diagnostics.ts";
12
12
  import { checkContextItemUsage } from "./context-item-diagnostics.ts";
13
13
  import { checkUndeclaredVariables } from "./variable-diagnostics.ts";
14
14
  import { checkDuplicateFunctions } from "./duplicate-function-diagnostics.ts";
15
+ import { checkDuplicatePrologDecls } from "./prolog-diagnostics.ts";
15
16
  import { checkModuleImportTargets } from "./module-import-diagnostics.ts";
16
17
  import { checkUnused } from "./unused-diagnostics.ts";
17
18
  import { checkBracedUriWhitespace } from "./braced-uri-diagnostics.ts";
@@ -35,6 +36,7 @@ export function runDiagnostics(
35
36
  ...checkContextItemUsage(ast),
36
37
  ...checkUndeclaredVariables(ast, analysis, imports),
37
38
  ...checkDuplicateFunctions(ast, analysis, imports),
39
+ ...checkDuplicatePrologDecls(ast, analysis),
38
40
  ...checkModuleImportTargets(analysis, imports),
39
41
  ];
40
42
  }
@@ -1,13 +1,85 @@
1
1
  import type { Node, NonTerminal } from "xq-parser";
2
2
  import type { TypeDiagnostic, FileAnalysis, FunctionSymbol, QName } from "./types.ts";
3
3
  import { formatQName } from "./types.ts";
4
- import { findAll, isTerminal, directChildrenOf, firstTerminalValue, parseEQName, resolvePrefix } from "./analyzer.ts";
4
+ import {
5
+ findAll,
6
+ isTerminal,
7
+ directChildrenOf,
8
+ firstTerminalValue,
9
+ parseEQName,
10
+ resolvePrefix,
11
+ XMLNS_LOCAL,
12
+ XMLNS_FN,
13
+ XMLNS_XS,
14
+ XMLNS_MATH,
15
+ XMLNS_MAP,
16
+ XMLNS_ARRAY,
17
+ } from "./analyzer.ts";
5
18
  import { asFunctionCall, asNamedFunctionRef } from "./ast-nodes.ts";
6
19
 
7
- function allFunctionsFlat(analysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>): FunctionSymbol[] {
20
+ // Namespaces that are always in scope without an explicit import: the
21
+ // spec-mandated predeclared ones, plus whatever this file declares of its own
22
+ // (local: functions, its own module namespace, and its default function
23
+ // namespace) — we have complete, closed-world knowledge of all of these.
24
+ function alwaysAccessibleNamespaceUris(analysis: FileAnalysis): Set<string> {
25
+ const uris = new Set([XMLNS_LOCAL, XMLNS_FN, XMLNS_XS, XMLNS_MATH, XMLNS_MAP, XMLNS_ARRAY, analysis.defaultFunctionNamespace]);
26
+ if (analysis.moduleNamespaceUri) uris.add(analysis.moduleNamespaceUri);
27
+ return uris;
28
+ }
29
+
30
+ /**
31
+ * Gather every function symbol this file can legally call, plus the set of
32
+ * imported namespace URIs we couldn't resolve to any analysis (unresolvable
33
+ * "at" path, or an unresolvable bare import — genuinely unknown, so calls
34
+ * into them are left unchecked to avoid false positives).
35
+ *
36
+ * A namespace not covered by any of the above (not declared, not imported,
37
+ * not predeclared) has zero accessible functions by construction: nothing in
38
+ * this module's static scope could have brought a function from it into
39
+ * view, regardless of what happens to be sitting in `importedAnalyses` (the
40
+ * QT4 harness, for instance, loads catalog modules that were never actually
41
+ * imported by the query under test, specifically to verify this isn't enough
42
+ * to make their functions callable).
43
+ */
44
+ function accessibleFunctions(
45
+ analysis: FileAnalysis,
46
+ importedAnalyses: Map<string, FileAnalysis>,
47
+ ): { fns: FunctionSymbol[]; unresolvedImportUris: Set<string> } {
8
48
  const fns = [...analysis.functions];
9
- for (const a of importedAnalyses.values()) fns.push(...a.functions);
10
- return fns;
49
+ const alwaysOpen = alwaysAccessibleNamespaceUris(analysis);
50
+ const importedNamespaceUris = new Set(analysis.imports.map((i) => i.namespaceUri));
51
+ // Runtime-predeclared namespaces (e.g. eXist-db's util:, xmldb:) are merged into
52
+ // namespaceDecls with offset -1 — see withPredeclaredNs — marking them as
53
+ // implicitly available without a written import. The QT4 harness uses the same
54
+ // offset -1 convention for <environment><namespace> bindings, which name a
55
+ // namespace without providing (or requiring) any declaration of what's in it.
56
+ const predeclaredUris = new Set(analysis.namespaceDecls.filter((nd) => nd.offset === -1).map((nd) => nd.namespaceUri));
57
+ // Namespaces we might have real knowledge of — but only once we find a matching
58
+ // entry in importedAnalyses below. Until then, stay silent about them rather
59
+ // than treating "no analysis available" as "no such function". Namespaces
60
+ // already in alwaysOpen are excluded: they're already guaranteed accessible
61
+ // (e.g. builtins arrive under a "builtin:" map key, not one matching their own
62
+ // namespace URI), so redundantly re-declaring one — as the QT4 harness's
63
+ // <environment><namespace prefix="map" uri=".../map"/> does — must not make it
64
+ // look unresolved.
65
+ const candidateUris = new Set([...importedNamespaceUris, ...predeclaredUris].filter((u) => !alwaysOpen.has(u)));
66
+ const resolvedUris = new Set<string>();
67
+
68
+ for (const [key, a] of importedAnalyses) {
69
+ if (key.startsWith("builtin:") || alwaysOpen.has(key)) {
70
+ fns.push(...a.functions);
71
+ continue;
72
+ }
73
+ if (candidateUris.has(key)) {
74
+ // A %private function/variable is only visible within its own declaring
75
+ // module — filter it out for anything reached through an import.
76
+ fns.push(...a.functions.filter((f) => f.visibility !== "private"));
77
+ resolvedUris.add(key);
78
+ }
79
+ }
80
+
81
+ const unresolvedImportUris = new Set([...candidateUris].filter((u) => !resolvedUris.has(u)));
82
+ return { fns, unresolvedImportUris };
11
83
  }
12
84
 
13
85
  function arityDescription(overloads: FunctionSymbol[]): string {
@@ -20,15 +92,20 @@ function checkArity(
20
92
  arity: number,
21
93
  nodeStart: number,
22
94
  allFns: FunctionSymbol[],
23
- knownNamespaceUris: Set<string>,
95
+ unresolvedImportUris: Set<string>,
24
96
  errors: TypeDiagnostic[],
25
97
  ): void {
26
98
  const { namespaceUri, localName } = qname;
99
+ // A prefix that resolves to nothing at all is a separate diagnostic's
100
+ // concern (findUndeclaredPrefixUsages) — don't pile on here.
101
+ if (namespaceUri.startsWith("urn:xq-lsp:undeclared:")) return;
102
+ // An import we couldn't resolve to any analysis: genuinely unknown, stay silent.
103
+ if (unresolvedImportUris.has(namespaceUri)) return;
104
+
27
105
  const overloads = allFns.filter((f) => f.qname.namespaceUri === namespaceUri && f.qname.localName === localName);
28
106
  const name = formatQName(qname);
29
107
 
30
108
  if (overloads.length === 0) {
31
- if (!knownNamespaceUris.has(namespaceUri)) return;
32
109
  errors.push({ message: `${name} is not declared`, code: "XPST0017", offset: nodeStart, length: name.length });
33
110
  return;
34
111
  }
@@ -48,10 +125,13 @@ function checkArity(
48
125
  * Walk all FunctionCall, NamedFunctionRef, and ArrowExpr nodes in the AST
49
126
  * and report XPST0017 for any call where the argument count does not match
50
127
  * any known overload, or where the function name is not declared in a
51
- * namespace we have analysis for.
128
+ * namespace this file can call into.
52
129
  *
53
- * If the function's namespace is entirely unknown (no analysis loaded for it),
54
- * the call is silently skipped — it could be a runtime-only library.
130
+ * A namespace is only skipped (left unchecked) when it's the target of an
131
+ * import we couldn't resolve to any analysis, or when the prefix itself is
132
+ * undeclared (that's findUndeclaredPrefixUsages's concern) — see
133
+ * accessibleFunctions and checkArity. Every other namespace is closed-world:
134
+ * not finding a matching declaration there means one doesn't exist.
55
135
  */
56
136
  export function checkFunctionCalls(
57
137
  ast: Node,
@@ -59,16 +139,14 @@ export function checkFunctionCalls(
59
139
  importedAnalyses: Map<string, FileAnalysis>,
60
140
  ): TypeDiagnostic[] {
61
141
  const errors: TypeDiagnostic[] = [];
62
- const allFns = allFunctionsFlat(analysis, importedAnalyses);
63
- // Namespaces we have at least one function declaration for.
64
- const knownNamespaceUris = new Set(allFns.map((f) => f.qname.namespaceUri));
142
+ const { fns: allFns, unresolvedImportUris } = accessibleFunctions(analysis, importedAnalyses);
65
143
 
66
144
  // ── FunctionCall ─────────────────────────────────────────────────────────────
67
145
 
68
146
  for (const callNode of findAll(ast, "FunctionCall")) {
69
147
  const call = asFunctionCall(callNode, analysis);
70
148
  if (!call) continue;
71
- checkArity(call.qname, call.args.length, callNode.start, allFns, knownNamespaceUris, errors);
149
+ checkArity(call.qname, call.args.length, callNode.start, allFns, unresolvedImportUris, errors);
72
150
  }
73
151
 
74
152
  // ── NamedFunctionRef ─────────────────────────────────────────────────────────
@@ -76,7 +154,7 @@ export function checkFunctionCalls(
76
154
  for (const refNode of findAll(ast, "NamedFunctionRef")) {
77
155
  const ref = asNamedFunctionRef(refNode, analysis);
78
156
  if (!ref) continue;
79
- checkArity(ref.qname, ref.arity, refNode.start, allFns, knownNamespaceUris, errors);
157
+ checkArity(ref.qname, ref.arity, refNode.start, allFns, unresolvedImportUris, errors);
80
158
  }
81
159
 
82
160
  // ── ArrowExpr ────────────────────────────────────────────────────────────────
@@ -102,7 +180,7 @@ export function checkFunctionCalls(
102
180
  const namespaceUri = uri ?? resolvePrefix(prefix, analysis);
103
181
  const explicitArgs = directChildrenOf(argList, "Argument").length;
104
182
 
105
- checkArity({ prefix, localName, namespaceUri }, 1 + explicitArgs, arrowNode.start, allFns, knownNamespaceUris, errors);
183
+ checkArity({ prefix, localName, namespaceUri }, 1 + explicitArgs, arrowNode.start, allFns, unresolvedImportUris, errors);
106
184
  }
107
185
  }
108
186
 
@@ -1,12 +1,17 @@
1
1
  import { InlayHintKind } from "vscode-languageserver/node.js";
2
2
  import type { InlayHint, Range } from "vscode-languageserver/node.js";
3
3
  import type { TextDocument } from "vscode-languageserver-textdocument";
4
- import type { Node, NonTerminal } from "xq-parser";
4
+ import type { Node } from "xq-parser";
5
5
  import type { FileAnalysis, FunctionSymbol, XQueryType } from "./types.ts";
6
- import { qnameKey } from "./types.ts";
7
- import { findAll, isTerminal, directChildOf } from "./analyzer.ts";
8
- import { asFunctionCall, asBinding, asTypedBinding } from "./ast-nodes.ts";
9
- import { parseType, inferExprType, formatType } from "./typechecker.ts";
6
+ import { findAll } from "./analyzer.ts";
7
+ import { asFunctionCall } from "./ast-nodes.ts";
8
+ import {
9
+ externalVariableTypes,
10
+ formatType,
11
+ resolveFunction,
12
+ walkModuleScopes,
13
+ withInferredReturnTypes,
14
+ } from "./typechecker.ts";
10
15
 
11
16
  function allFunctionsFlat(analysis: FileAnalysis, importedAnalyses: Map<string, FileAnalysis>): FunctionSymbol[] {
12
17
  const fns = [...analysis.functions];
@@ -14,28 +19,10 @@ function allFunctionsFlat(analysis: FileAnalysis, importedAnalyses: Map<string,
14
19
  return fns;
15
20
  }
16
21
 
17
- function resolveCallee(
18
- call: { qname: { namespaceUri: string; localName: string } },
19
- argCount: number,
20
- allFns: FunctionSymbol[],
21
- ): FunctionSymbol | undefined {
22
- const overloads = allFns.filter(
23
- (f) => f.qname.namespaceUri === call.qname.namespaceUri && f.qname.localName === call.qname.localName,
24
- );
25
- return overloads.find((f) => (f.variadic ? argCount >= f.arity : f.arity === argCount));
26
- }
27
-
28
22
  function inRange(offset: number, range: { start: number; end: number }): boolean {
29
23
  return offset >= range.start && offset <= range.end;
30
24
  }
31
25
 
32
- // Narrows a sequence's type to its item type (occurrence ""), for `for` bindings
33
- // where the bound variable holds one item per iteration, not the whole sequence.
34
- function itemTypeOf(t: XQueryType): XQueryType {
35
- if (t.kind === "unknown" || t.kind === "empty") return t;
36
- return t.occurrence === "" ? t : { ...t, occurrence: "" };
37
- }
38
-
39
26
  // ── Parameter name hints ─────────────────────────────────────────────────────
40
27
 
41
28
  function collectParameterHints(
@@ -49,7 +36,7 @@ function collectParameterHints(
49
36
  for (const callNode of findAll(ast, "FunctionCall")) {
50
37
  const call = asFunctionCall(callNode, analysis);
51
38
  if (!call || call.args.length === 0) continue;
52
- const fn = resolveCallee(call, call.args.length, allFns);
39
+ const fn = resolveFunction(call.qname, call.args.length, allFns);
53
40
  if (!fn) continue;
54
41
 
55
42
  for (let i = 0; i < call.args.length && i < fn.params.length; i++) {
@@ -65,78 +52,40 @@ function collectParameterHints(
65
52
  }
66
53
  }
67
54
 
68
- // ── Inferred type hints on untyped let/for bindings ──────────────────────────
55
+ // ── Inferred type hints on untyped bindings ──────────────────────────────────
69
56
 
70
- function collectParams(paramList: Node | undefined, text: string, analysis: FileAnalysis, scope: Map<string, XQueryType>): void {
71
- if (!paramList) return;
72
- for (const param of findAll(paramList, "Param")) {
73
- const binding = asTypedBinding(param, text, analysis);
74
- if (binding?.typeStr) scope.set(qnameKey(binding.qname), parseType(binding.typeStr));
75
- }
76
- }
77
-
78
- function buildModuleVarTypes(ast: Node, text: string, analysis: FileAnalysis): Map<string, XQueryType> {
79
- const types = new Map<string, XQueryType>();
80
- for (const node of findAll(ast, "VarDecl")) {
81
- const binding = asTypedBinding(node, text, analysis);
82
- if (binding?.typeStr) types.set(qnameKey(binding.qname), parseType(binding.typeStr));
83
- }
84
- return types;
85
- }
86
-
87
- // Walks the tree tracking in-scope variable types, mirroring typechecker.ts's scope walk,
88
- // but also records the inferred type of untyped let/for bindings so later bindings see it.
89
- function walkForTypeHints(
90
- node: Node,
91
- scopeTypes: Map<string, XQueryType>,
92
- moduleTypes: Map<string, XQueryType>,
93
- text: string,
57
+ // Shows the inferred type after the name of every binding without a declared type:
58
+ // let/for/group-by variables and module-level `declare variable`s. The scope walk is the
59
+ // same one the type checker uses, so hints reflect exactly what diagnostics see.
60
+ function collectTypeHints(
61
+ ast: Node,
94
62
  analysis: FileAnalysis,
95
63
  allFns: FunctionSymbol[],
96
64
  range: { start: number; end: number },
97
65
  hints: InlayHint[],
98
66
  doc: TextDocument,
67
+ outerScope: Map<string, XQueryType>,
99
68
  ): void {
100
- if (isTerminal(node)) return;
101
- const nt = node as NonTerminal;
102
-
103
- if (node.type === "InlineFunctionExpr") {
104
- const innerScope = new Map(moduleTypes);
105
- collectParams(directChildOf(node, "ParamList"), text, analysis, innerScope);
106
- const body = directChildOf(node, "FunctionBody");
107
- if (body) walkForTypeHints(body, innerScope, moduleTypes, text, analysis, allFns, range, hints, doc);
108
- return;
109
- }
110
-
111
- if (node.type === "LetBinding" || node.type === "ForBinding") {
112
- const typed = asTypedBinding(node, text, analysis);
113
- const binding = asBinding(node, analysis);
114
- if (typed && binding) {
115
- if (typed.typeStr) {
116
- scopeTypes.set(qnameKey(typed.qname), parseType(typed.typeStr));
117
- } else if (binding.initExpr) {
118
- const inferredSeq = inferExprType(binding.initExpr, scopeTypes, analysis, allFns);
119
- // A `for` binding iterates the sequence one item at a time, so $x's type
120
- // is the *item* type (occurrence narrowed to singular) — not the type of
121
- // the whole iterated sequence, which is what inferExprType returns.
122
- const inferred = node.type === "ForBinding" ? itemTypeOf(inferredSeq) : inferredSeq;
123
- scopeTypes.set(qnameKey(typed.qname), inferred);
124
- const nameEnd = binding.nameNode.end ?? binding.nameNode.start;
125
- if (inferred.kind !== "unknown" && inRange(nameEnd, range)) {
126
- hints.push({
127
- position: doc.positionAt(nameEnd),
128
- label: `: ${formatType(inferred)}`,
129
- kind: InlayHintKind.Type,
130
- paddingLeft: true,
131
- });
132
- }
133
- }
134
- }
135
- }
136
-
137
- for (const child of nt.children) {
138
- walkForTypeHints(child, scopeTypes, moduleTypes, text, analysis, allFns, range, hints, doc);
139
- }
69
+ const seen = new Set<number>();
70
+ walkModuleScopes(
71
+ ast,
72
+ analysis,
73
+ allFns,
74
+ {
75
+ onBinding: (nameNode, type) => {
76
+ const nameEnd = nameNode.end ?? nameNode.start;
77
+ if (type.kind === "unknown" || !inRange(nameEnd, range) || seen.has(nameEnd)) return;
78
+ seen.add(nameEnd);
79
+ hints.push({
80
+ position: doc.positionAt(nameEnd),
81
+ label: `: ${formatType(type)}`,
82
+ kind: InlayHintKind.Type,
83
+ paddingLeft: true,
84
+ });
85
+ },
86
+ },
87
+ outerScope,
88
+ );
140
89
  }
141
90
 
142
91
  // ── Entry point ───────────────────────────────────────────────────────────────
@@ -148,15 +97,15 @@ export function getInlayHints(
148
97
  imported: Map<string, FileAnalysis>,
149
98
  range: Range,
150
99
  ): InlayHint[] {
151
- const text = doc.getText();
152
100
  const offsetRange = { start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) };
153
- const allFns = allFunctionsFlat(analysis, imported);
101
+ const declaredFns = allFunctionsFlat(analysis, imported);
102
+ const outerScope = externalVariableTypes(analysis, imported.values(), declaredFns);
103
+ const allFns = withInferredReturnTypes(ast, analysis, declaredFns, outerScope);
154
104
  const hints: InlayHint[] = [];
155
105
 
156
106
  collectParameterHints(ast, analysis, allFns, offsetRange, hints, doc);
157
107
 
158
- const moduleTypes = buildModuleVarTypes(ast, text, analysis);
159
- walkForTypeHints(ast, new Map(moduleTypes), moduleTypes, text, analysis, allFns, offsetRange, hints, doc);
108
+ collectTypeHints(ast, analysis, allFns, offsetRange, hints, doc, outerScope);
160
109
 
161
110
  return hints;
162
111
  }
@@ -0,0 +1,17 @@
1
+ import type { FileAnalysis } from "./types.ts";
2
+
3
+ /**
4
+ * The declarations that *other* files contribute to a module namespace. XQuery lets one module be
5
+ * implemented by several module files sharing a target namespace, and some runtimes (e.g. Fonto)
6
+ * merge them, so a file can call a function declared in a sibling without importing it.
7
+ *
8
+ * `merged` is the glob index entry for the namespace, which may include the current file's own
9
+ * on-disk declarations. Those are dropped so they aren't reported as duplicates of the (possibly
10
+ * edited) in-memory versions, nor resolved to stale positions. Returns null when nothing is left.
11
+ */
12
+ export function siblingModuleAnalysis(merged: FileAnalysis, currentUri: string): FileAnalysis | null {
13
+ const functions = merged.functions.filter((f) => f.sourceUri !== currentUri);
14
+ const moduleVariables = merged.moduleVariables.filter((v) => v.sourceUri !== currentUri);
15
+ if (functions.length === 0 && moduleVariables.length === 0) return null;
16
+ return { ...merged, functions, moduleVariables };
17
+ }
@@ -133,6 +133,7 @@ export function getRuntimePredeclaredVariables(runtimes: string[], sourceUri: st
133
133
  isModuleLevel: true,
134
134
  sourceUri,
135
135
  doc,
136
+ type: "xs:string", // all three are file system / collection paths
136
137
  }));
137
138
  }
138
139