xq-lsp 1.0.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.
@@ -0,0 +1,402 @@
1
+ import { XQuery31Full } from "xq-parser";
2
+ import type { Node, NonTerminal, Terminal } from "xq-parser";
3
+ import type { FileAnalysis, FunctionSymbol, VariableSymbol, ImportInfo, ParamInfo, DocComment } from "./types.ts";
4
+
5
+ // ── Well-known namespace URIs ────────────────────────────────────────────────
6
+
7
+ export const XMLNS_FN = "http://www.w3.org/2005/xpath-functions";
8
+ export const XMLNS_LOCAL = "http://www.w3.org/2005/xquery-local-functions";
9
+ export const XMLNS_XS = "http://www.w3.org/2001/XMLSchema";
10
+ export const XMLNS_MATH = "http://www.w3.org/2005/xpath-functions/math";
11
+ export const XMLNS_MAP = "http://www.w3.org/2005/xpath-functions/map";
12
+ export const XMLNS_ARRAY = "http://www.w3.org/2005/xpath-functions/array";
13
+
14
+ const BUILTIN_PREFIXES: Record<string, string> = {
15
+ fn: XMLNS_FN,
16
+ local: XMLNS_LOCAL,
17
+ xs: XMLNS_XS,
18
+ math: XMLNS_MATH,
19
+ map: XMLNS_MAP,
20
+ array: XMLNS_ARRAY,
21
+ };
22
+
23
+ /**
24
+ * Resolve a namespace prefix to its URI given the namespace context of a file.
25
+ * Unprefixed calls (prefix="") resolve to the file's default function namespace.
26
+ * Unknown prefixes fall back to a stable synthetic URI so within-file matches still work.
27
+ */
28
+ export function resolvePrefix(prefix: string, analysis: FileAnalysis): string {
29
+ if (!prefix) return analysis.defaultFunctionNamespace;
30
+ const imp = analysis.imports.find((i) => i.prefix === prefix);
31
+ if (imp) return imp.namespaceUri;
32
+ if (prefix === analysis.modulePrefix && analysis.moduleNamespaceUri) return analysis.moduleNamespaceUri;
33
+ return BUILTIN_PREFIXES[prefix] ?? `urn:xq-lsp:undeclared:${prefix}`;
34
+ }
35
+
36
+ // ── AST helpers ─────────────────────────────────────────────────────────────
37
+
38
+ function isTerminal(node: Node): node is Terminal {
39
+ return node.isTerminal;
40
+ }
41
+
42
+ function asNonTerminal(node: Node): NonTerminal {
43
+ return node as NonTerminal;
44
+ }
45
+
46
+ function firstTerminalValue(node: Node): string | null {
47
+ if (isTerminal(node)) return node.value;
48
+ for (const child of asNonTerminal(node).children) {
49
+ const v = firstTerminalValue(child);
50
+ if (v !== null) return v;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function findAll(node: Node, type: string, out: Node[] = []): Node[] {
56
+ if (node.type === type) out.push(node);
57
+ if (!isTerminal(node)) {
58
+ for (const child of asNonTerminal(node).children) {
59
+ findAll(child, type, out);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function directChildOf(node: Node, type: string): Node | undefined {
66
+ if (isTerminal(node)) return undefined;
67
+ return asNonTerminal(node).children.find((c) => c.type === type);
68
+ }
69
+
70
+ function directChildrenOf(node: Node, type: string): Node[] {
71
+ if (isTerminal(node)) return [];
72
+ return asNonTerminal(node).children.filter((c) => c.type === type);
73
+ }
74
+
75
+ // ── Doc comment parsing ──────────────────────────────────────────────────────
76
+
77
+ function parseDocComment(raw: string): DocComment {
78
+ // Strip (: / (:~ opening and :) closing, then strip leading " : " per line
79
+ const inner = raw
80
+ .replace(/^\(:~?/, "")
81
+ .replace(/:\)$/, "")
82
+ .split("\n")
83
+ .map((l) => l.replace(/^\s*:?\s?/, ""))
84
+ .join("\n")
85
+ .trim();
86
+
87
+ const params: Record<string, string> = {};
88
+ let returns: string | undefined;
89
+ const descLines: string[] = [];
90
+ let inDescription = true;
91
+
92
+ for (const line of inner.split("\n")) {
93
+ const paramMatch = line.match(/^@param\s+\$?([\w:\-]+)\s*(.*)/);
94
+ const returnMatch = line.match(/^@returns?\s+(.*)/);
95
+ if (paramMatch) {
96
+ inDescription = false;
97
+ params[paramMatch[1]] = paramMatch[2].trim();
98
+ } else if (returnMatch) {
99
+ inDescription = false;
100
+ returns = returnMatch[1].trim();
101
+ } else if (inDescription) {
102
+ descLines.push(line);
103
+ }
104
+ }
105
+
106
+ return { description: descLines.join("\n").trim(), params, returns };
107
+ }
108
+
109
+ /** Find a doc comment immediately preceding `offset` in raw text (regex fallback path). */
110
+ function findPrecedingDocInText(text: string, offset: number): DocComment | undefined {
111
+ const before = text.slice(0, offset);
112
+ const match = before.match(/\(:~?[\s\S]*?:\)\s*$/);
113
+ if (!match) return undefined;
114
+ return parseDocComment(match[0]);
115
+ }
116
+
117
+ /** Find the xqDoc comment immediately preceding `offset` (only whitespace between). */
118
+ function findPrecedingDoc(comments: Terminal[], text: string, offset: number): DocComment | undefined {
119
+ // Walk backwards through comments sorted by end position
120
+ const before = comments.filter((c) => c.end! <= offset).sort((a, b) => b.end! - a.end!);
121
+
122
+ for (const c of before) {
123
+ const between = text.slice(c.end!, offset);
124
+ if (!/^\s*$/.test(between)) break; // something other than whitespace — stop
125
+ if (c.value.startsWith("(:~") || c.value.startsWith("(:")) {
126
+ return parseDocComment(c.value);
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ // ── Namespace helpers ────────────────────────────────────────────────────────
133
+
134
+ function extractModuleNamespace(text: string): { prefix: string; uri: string } | undefined {
135
+ const m = text.match(/module\s+namespace\s+([\w\-]+)\s*=\s*["']([^"']*)["']/);
136
+ return m ? { prefix: m[1], uri: m[2] } : undefined;
137
+ }
138
+
139
+ function extractDefaultFunctionNamespace(text: string): string | undefined {
140
+ const m = text.match(/declare\s+default\s+function\s+namespace\s+["']([^"']*)["']/);
141
+ return m ? m[1] : undefined;
142
+ }
143
+
144
+ function buildPrefixMap(
145
+ moduleNs: { prefix: string; uri: string } | undefined,
146
+ imports: ImportInfo[],
147
+ ): Map<string, string> {
148
+ const map = new Map(Object.entries(BUILTIN_PREFIXES));
149
+ if (moduleNs) map.set(moduleNs.prefix, moduleNs.uri);
150
+ for (const imp of imports) map.set(imp.prefix, imp.namespaceUri);
151
+ return map;
152
+ }
153
+
154
+ function resolveNamespaceUri(prefix: string, prefixMap: Map<string, string>): string {
155
+ return prefixMap.get(prefix) ?? `urn:xq-lsp:undeclared:${prefix}`;
156
+ }
157
+
158
+ // ── Extract from valid AST ───────────────────────────────────────────────────
159
+
160
+ function sequenceTypeText(text: string, node: Node): string | undefined {
161
+ if (node.start === undefined || node.end === null) return undefined;
162
+ return text.slice(node.start, node.end ?? undefined).trim() || undefined;
163
+ }
164
+
165
+ function extractFunctions(
166
+ ast: Node,
167
+ sourceUri: string,
168
+ comments: Terminal[],
169
+ text: string,
170
+ prefixMap: Map<string, string>,
171
+ ): FunctionSymbol[] {
172
+ const results: FunctionSymbol[] = [];
173
+ // Use AnnotatedDecl (starts at 'declare') so the comment lookup clears the 'declare' keyword
174
+ for (const annotated of findAll(ast, "AnnotatedDecl")) {
175
+ const decl = directChildOf(annotated, "FunctionDecl");
176
+ if (!decl) continue;
177
+
178
+ const eqname = directChildOf(decl, "EQName");
179
+ const name = eqname ? firstTerminalValue(eqname) : null;
180
+ if (!name) continue;
181
+
182
+ const colonIdx = name.indexOf(":");
183
+ const prefix = colonIdx >= 0 ? name.slice(0, colonIdx) : "";
184
+ const localName = colonIdx >= 0 ? name.slice(colonIdx + 1) : name;
185
+
186
+ const doc = findPrecedingDoc(comments, text, annotated.start);
187
+
188
+ const params: ParamInfo[] = [];
189
+ const paramList = directChildOf(decl, "ParamList");
190
+ if (paramList) {
191
+ for (const param of findAll(paramList, "Param")) {
192
+ const paramEqname = directChildOf(param, "EQName");
193
+ const paramName = paramEqname ? firstTerminalValue(paramEqname) : null;
194
+ if (!paramName) continue;
195
+ const typeDecl = directChildOf(param, "TypeDeclaration");
196
+ const seqTypeNode = typeDecl && directChildOf(typeDecl, "SequenceType");
197
+ const paramType = seqTypeNode ? sequenceTypeText(text, seqTypeNode) : undefined;
198
+ params.push({
199
+ name: paramName,
200
+ type: paramType,
201
+ description: doc?.params[paramName],
202
+ });
203
+ }
204
+ }
205
+
206
+ // Return type: 'as' followed by SequenceType (direct child of FunctionDecl)
207
+ const seqType = directChildOf(decl, "SequenceType");
208
+ const returnType = seqType ? sequenceTypeText(text, seqType) : undefined;
209
+
210
+ results.push({
211
+ name,
212
+ prefix,
213
+ localName,
214
+ namespaceUri: resolveNamespaceUri(prefix, prefixMap),
215
+ arity: params.length,
216
+ params,
217
+ returnType,
218
+ doc,
219
+ sourceUri,
220
+ sourceOffset: annotated.start,
221
+ });
222
+ }
223
+ return results;
224
+ }
225
+
226
+ function extractModuleVariables(ast: Node, sourceUri: string): VariableSymbol[] {
227
+ const results: VariableSymbol[] = [];
228
+ for (const varDecl of findAll(ast, "VarDecl")) {
229
+ const varName = directChildOf(varDecl, "VarName");
230
+ const name = varName ? firstTerminalValue(varName) : null;
231
+ if (!name) continue;
232
+ results.push({
233
+ name,
234
+ offset: varDecl.start,
235
+ isModuleLevel: true,
236
+ sourceUri,
237
+ });
238
+ }
239
+ return results;
240
+ }
241
+
242
+ function extractLocalBindings(ast: Node, sourceUri: string): VariableSymbol[] {
243
+ const results: VariableSymbol[] = [];
244
+ for (const binding of [...findAll(ast, "LetBinding"), ...findAll(ast, "ForBinding")]) {
245
+ const varName = directChildOf(binding, "VarName");
246
+ const name = varName ? firstTerminalValue(varName) : null;
247
+ if (!name) continue;
248
+ results.push({
249
+ name,
250
+ offset: binding.start,
251
+ isModuleLevel: false,
252
+ sourceUri,
253
+ });
254
+ }
255
+ return results;
256
+ }
257
+
258
+ function extractImports(ast: Node): ImportInfo[] {
259
+ const results: ImportInfo[] = [];
260
+ for (const mi of findAll(ast, "ModuleImport")) {
261
+ const ncname = directChildOf(mi, "NCName");
262
+ const prefix = ncname ? firstTerminalValue(ncname) : null;
263
+ if (!prefix) continue;
264
+ const uris = directChildrenOf(mi, "URILiteral");
265
+ if (uris.length < 2) continue;
266
+ const namespaceUri = stripQuotes(firstTerminalValue(uris[0]) ?? "");
267
+ const atPath = stripQuotes(firstTerminalValue(uris[1]) ?? "");
268
+ results.push({ prefix, namespaceUri, atPath });
269
+ }
270
+ return results;
271
+ }
272
+
273
+ function stripQuotes(s: string): string {
274
+ return s.replace(/^["']|["']$/g, "");
275
+ }
276
+
277
+ function analyzeAst(ast: Node, comments: Terminal[], text: string, sourceUri: string): FileAnalysis {
278
+ const imports = extractImports(ast);
279
+ const moduleNs = extractModuleNamespace(text);
280
+ const defaultFunctionNamespace = extractDefaultFunctionNamespace(text) ?? XMLNS_FN;
281
+ const prefixMap = buildPrefixMap(moduleNs, imports);
282
+ return {
283
+ functions: extractFunctions(ast, sourceUri, comments, text, prefixMap),
284
+ moduleVariables: extractModuleVariables(ast, sourceUri),
285
+ localBindings: extractLocalBindings(ast, sourceUri),
286
+ imports,
287
+ defaultFunctionNamespace,
288
+ moduleNamespaceUri: moduleNs?.uri,
289
+ modulePrefix: moduleNs?.prefix,
290
+ };
291
+ }
292
+
293
+ // ── Regex fallback for syntactically invalid / partial XQuery ────────────────
294
+
295
+ const RE_FUNC = /declare\s+(?:%[\w:\-]+(?:\([^)]*\))?\s+)*function\s+([\w:\-]+)\s*\(([^)]*)\)/g;
296
+ const RE_VAR_DECL = /declare\s+(?:%[\w:\-]+(?:\([^)]*\))?\s+)*variable\s+\$([\w:\-]+)/g;
297
+ const RE_LET = /\blet\s+\$([\w:\-]+)\s*:=/g;
298
+ const RE_FOR = /\bfor\s+\$([\w:\-]+)\s+in\b/g;
299
+ const RE_IMPORT = /import\s+module\s+namespace\s+([\w\-]+)\s*=\s*["']([^"']*)["']\s+at\s+["']([^"']*)["']/g;
300
+
301
+ function analyzeRegex(text: string, sourceUri: string): FileAnalysis {
302
+ const moduleVariables: VariableSymbol[] = [];
303
+ const localBindings: VariableSymbol[] = [];
304
+ const moduleNs = extractModuleNamespace(text);
305
+ const defaultFunctionNamespace = extractDefaultFunctionNamespace(text) ?? XMLNS_FN;
306
+
307
+ let m: RegExpExecArray | null;
308
+
309
+ // Collect imports first so we can build the prefix map before resolving functions.
310
+ const imports: ImportInfo[] = [];
311
+ RE_IMPORT.lastIndex = 0;
312
+ while ((m = RE_IMPORT.exec(text)) !== null) {
313
+ imports.push({ prefix: m[1], namespaceUri: m[2], atPath: m[3] });
314
+ }
315
+
316
+ const prefixMap = buildPrefixMap(moduleNs, imports);
317
+
318
+ const functions: FunctionSymbol[] = [];
319
+ RE_FUNC.lastIndex = 0;
320
+ while ((m = RE_FUNC.exec(text)) !== null) {
321
+ const name = m[1];
322
+ const colonIdx = name.indexOf(":");
323
+ const prefix = colonIdx >= 0 ? name.slice(0, colonIdx) : "";
324
+ const localName = colonIdx >= 0 ? name.slice(colonIdx + 1) : name;
325
+ const doc = findPrecedingDocInText(text, m.index);
326
+ const rawParams = m[2].trim();
327
+ const params: ParamInfo[] = rawParams
328
+ ? rawParams.split(",").map((p) => {
329
+ const varMatch = p.match(/\$([\w:\-]+)/);
330
+ const typeMatch = p.match(/as\s+([\w:\-]+(?:\(.*?\))?)/);
331
+ const paramName = varMatch ? varMatch[1] : p.trim();
332
+ return {
333
+ name: paramName,
334
+ type: typeMatch ? typeMatch[1] : undefined,
335
+ description: doc?.params[paramName],
336
+ };
337
+ })
338
+ : [];
339
+ functions.push({
340
+ name,
341
+ prefix,
342
+ localName,
343
+ namespaceUri: resolveNamespaceUri(prefix, prefixMap),
344
+ arity: params.length,
345
+ params,
346
+ doc,
347
+ sourceUri,
348
+ sourceOffset: m.index,
349
+ });
350
+ }
351
+
352
+ RE_VAR_DECL.lastIndex = 0;
353
+ while ((m = RE_VAR_DECL.exec(text)) !== null) {
354
+ moduleVariables.push({
355
+ name: m[1],
356
+ offset: m.index,
357
+ isModuleLevel: true,
358
+ sourceUri,
359
+ });
360
+ }
361
+
362
+ RE_LET.lastIndex = 0;
363
+ while ((m = RE_LET.exec(text)) !== null) {
364
+ localBindings.push({
365
+ name: m[1],
366
+ offset: m.index,
367
+ isModuleLevel: false,
368
+ sourceUri,
369
+ });
370
+ }
371
+
372
+ RE_FOR.lastIndex = 0;
373
+ while ((m = RE_FOR.exec(text)) !== null) {
374
+ localBindings.push({
375
+ name: m[1],
376
+ offset: m.index,
377
+ isModuleLevel: false,
378
+ sourceUri,
379
+ });
380
+ }
381
+
382
+ return {
383
+ functions,
384
+ moduleVariables,
385
+ localBindings,
386
+ imports,
387
+ defaultFunctionNamespace,
388
+ moduleNamespaceUri: moduleNs?.uri,
389
+ modulePrefix: moduleNs?.prefix,
390
+ };
391
+ }
392
+
393
+ // Testing
394
+
395
+ export function analyze(text: string, sourceUri: string): FileAnalysis {
396
+ try {
397
+ const { ast, comments } = XQuery31Full(text);
398
+ return analyzeAst(ast, comments, text, sourceUri);
399
+ } catch {
400
+ return analyzeRegex(text, sourceUri);
401
+ }
402
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from "fs";
2
+ import { fileURLToPath } from "url";
3
+ import { join, dirname } from "path";
4
+ import { analyze, XMLNS_FN } from "./analyzer.ts";
5
+ import type { FileAnalysis } from "./types.ts";
6
+
7
+ const baseDir = dirname(fileURLToPath(import.meta.url));
8
+
9
+ const BUILTIN_FILES = [
10
+ "builtins/builtins-fn.xq",
11
+ "builtins/builtins-math.xq",
12
+ "builtins/builtins-map.xq",
13
+ "builtins/builtins-array.xq",
14
+ ];
15
+
16
+ let _builtins: FileAnalysis | null = null;
17
+
18
+ export function getBuiltins(): FileAnalysis {
19
+ if (!_builtins) {
20
+ const analyses = BUILTIN_FILES.map((f) => {
21
+ const text = readFileSync(join(baseDir, "..", f), "utf-8");
22
+ return analyze(text, `builtin:${f}`);
23
+ });
24
+ _builtins = {
25
+ functions: analyses.flatMap((a) => a.functions),
26
+ moduleVariables: [],
27
+ localBindings: [],
28
+ imports: [],
29
+ defaultFunctionNamespace: XMLNS_FN,
30
+ };
31
+ }
32
+ return _builtins;
33
+ }
@@ -0,0 +1,179 @@
1
+ import { CompletionItem, CompletionItemKind, InsertTextFormat, MarkupKind } from "vscode-languageserver/node.js";
2
+ import type { FileAnalysis, FunctionSymbol, VariableSymbol } from "./types.ts";
3
+ import { resolvePrefix } from "./analyzer.ts";
4
+
5
+ export interface CompletionContext {
6
+ /** Text from start of document up to (and including) the cursor position */
7
+ textBeforeCursor: string;
8
+ /** Character offset of the cursor in the document */
9
+ cursorOffset: number;
10
+ }
11
+
12
+ interface TokenInfo {
13
+ kind: "variable" | "qualified-name" | "name";
14
+ /** Prefix typed so far — used to filter completions */
15
+ prefix: string;
16
+ /** For qualified names, the namespace prefix before the colon */
17
+ nsPrefix?: string;
18
+ }
19
+
20
+ function parseToken(textBefore: string): TokenInfo {
21
+ const match = textBefore.match(/\$([\w:\-]*)$|(?:([\w\-]+):)([\w\-]*)$|([\w\-]+)$/);
22
+ if (!match) return { kind: "name", prefix: "" };
23
+
24
+ if (match[0].startsWith("$")) {
25
+ return { kind: "variable", prefix: match[1] ?? "" };
26
+ }
27
+ if (match[2] !== undefined) {
28
+ return { kind: "qualified-name", nsPrefix: match[2], prefix: match[3] ?? "" };
29
+ }
30
+ return { kind: "name", prefix: match[4] ?? "" };
31
+ }
32
+
33
+ function functionDoc(fn: FunctionSymbol): string {
34
+ const paramStr = fn.params.map((p) => `$${p.name}${p.type ? " as " + p.type : ""}`).join(", ");
35
+ const ret = fn.returnType ? ` as ${fn.returnType}` : "";
36
+ const sig = `\`\`\`xquery\ndeclare function ${fn.name}(${paramStr})${ret}\n\`\`\``;
37
+
38
+ if (!fn.doc) return sig;
39
+
40
+ const parts: string[] = [sig];
41
+ if (fn.doc.description) parts.push(fn.doc.description);
42
+
43
+ const paramDocs = fn.params.filter((p) => p.description);
44
+ if (paramDocs.length > 0) {
45
+ parts.push(paramDocs.map((p) => `- \`$${p.name}\` — ${p.description}`).join("\n"));
46
+ }
47
+ if (fn.doc.returns) parts.push(`**Returns:** ${fn.doc.returns}`);
48
+
49
+ return parts.join("\n\n");
50
+ }
51
+
52
+ function localInsertText(fn: FunctionSymbol, snippets: boolean): string {
53
+ if (fn.params.length === 0) return fn.localName + "()";
54
+ const args = snippets
55
+ ? fn.params.map((p, i) => `\${${i + 1}:\\$${p.name}}`).join(", ")
56
+ : fn.params.map((p) => `$${p.name}`).join(", ");
57
+ return `${fn.localName}(${args})`;
58
+ }
59
+
60
+ function buildVariableItem(v: VariableSymbol, filter: string): CompletionItem | null {
61
+ if (filter && !v.name.toLowerCase().startsWith(filter.toLowerCase())) return null;
62
+ return {
63
+ label: `$${v.name}`,
64
+ kind: CompletionItemKind.Variable,
65
+ insertText: `$${v.name}`,
66
+ insertTextFormat: InsertTextFormat.PlainText,
67
+ };
68
+ }
69
+
70
+ export function getCompletions(
71
+ ctx: CompletionContext,
72
+ currentAnalysis: FileAnalysis,
73
+ importedAnalyses: Map<string, FileAnalysis>,
74
+ snippets = false,
75
+ ): CompletionItem[] {
76
+ const token = parseToken(ctx.textBeforeCursor);
77
+ const items: CompletionItem[] = [];
78
+
79
+ // ── Variable completions ─────────────────────────────────────────────────
80
+
81
+ if (token.kind === "variable") {
82
+ for (const v of currentAnalysis.moduleVariables) {
83
+ const item = buildVariableItem(v, token.prefix);
84
+ if (item) items.push(item);
85
+ }
86
+ for (const v of currentAnalysis.localBindings) {
87
+ if (v.offset < ctx.cursorOffset) {
88
+ const item = buildVariableItem(v, token.prefix);
89
+ if (item) items.push(item);
90
+ }
91
+ }
92
+ for (const analysis of importedAnalyses.values()) {
93
+ for (const v of analysis.moduleVariables) {
94
+ const item = buildVariableItem(v, token.prefix);
95
+ if (item) items.push(item);
96
+ }
97
+ }
98
+ return items;
99
+ }
100
+
101
+ // ── Qualified-name completions (user typed "ns:partial") ─────────────────
102
+
103
+ if (token.kind === "qualified-name" && token.nsPrefix) {
104
+ const targetUri = resolvePrefix(token.nsPrefix, currentAnalysis);
105
+ const filter = token.prefix.toLowerCase();
106
+
107
+ const allFns = [...currentAnalysis.functions, ...[...importedAnalyses.values()].flatMap((a) => a.functions)];
108
+
109
+ for (const fn of allFns) {
110
+ if (fn.namespaceUri !== targetUri) continue;
111
+ if (filter && !fn.localName.toLowerCase().includes(filter)) continue;
112
+ items.push({
113
+ label: fn.localName,
114
+ kind: CompletionItemKind.Function,
115
+ detail: `${fn.localName}#${fn.arity}`,
116
+ documentation: { kind: MarkupKind.Markdown, value: functionDoc(fn) },
117
+ insertText: localInsertText(fn, snippets),
118
+ insertTextFormat: snippets ? InsertTextFormat.Snippet : InsertTextFormat.PlainText,
119
+ });
120
+ }
121
+ return items;
122
+ }
123
+
124
+ // ── Plain-name completions ───────────────────────────────────────────────
125
+
126
+ const filter = token.prefix.toLowerCase();
127
+ const defaultUri = currentAnalysis.defaultFunctionNamespace;
128
+
129
+ // Functions declared in the current file (full qualified name)
130
+ for (const fn of currentAnalysis.functions) {
131
+ if (filter && !fn.name.toLowerCase().includes(filter)) continue;
132
+ items.push({
133
+ label: fn.name,
134
+ kind: CompletionItemKind.Function,
135
+ detail: `${fn.name}#${fn.arity}`,
136
+ documentation: { kind: MarkupKind.Markdown, value: functionDoc(fn) },
137
+ insertText: snippets
138
+ ? fn.params.length === 0
139
+ ? fn.name + "()"
140
+ : fn.name + "(" + fn.params.map((p, i) => `\${${i + 1}:\\$${p.name}}`).join(", ") + ")"
141
+ : fn.name + "(" + fn.params.map((p) => `$${p.name}`).join(", ") + ")",
142
+ insertTextFormat: snippets ? InsertTextFormat.Snippet : InsertTextFormat.PlainText,
143
+ });
144
+ }
145
+
146
+ // Imported functions: those in the default function namespace are offered
147
+ // without prefix (since they can be called unqualified); others with full name.
148
+ for (const analysis of importedAnalyses.values()) {
149
+ for (const fn of analysis.functions) {
150
+ if (fn.namespaceUri === defaultUri) {
151
+ if (filter && !fn.localName.toLowerCase().includes(filter)) continue;
152
+ items.push({
153
+ label: fn.localName,
154
+ kind: CompletionItemKind.Function,
155
+ detail: `${fn.localName}#${fn.arity}`,
156
+ documentation: { kind: MarkupKind.Markdown, value: functionDoc(fn) },
157
+ insertText: localInsertText(fn, snippets),
158
+ insertTextFormat: snippets ? InsertTextFormat.Snippet : InsertTextFormat.PlainText,
159
+ });
160
+ } else {
161
+ if (filter && !fn.name.toLowerCase().includes(filter)) continue;
162
+ items.push({
163
+ label: fn.name,
164
+ kind: CompletionItemKind.Function,
165
+ detail: `${fn.name}#${fn.arity}`,
166
+ documentation: { kind: MarkupKind.Markdown, value: functionDoc(fn) },
167
+ insertText: snippets
168
+ ? fn.params.length === 0
169
+ ? fn.name + "()"
170
+ : fn.name + "(" + fn.params.map((p, i) => `\${${i + 1}:\\$${p.name}}`).join(", ") + ")"
171
+ : fn.name + "(" + fn.params.map((p) => `$${p.name}`).join(", ") + ")",
172
+ insertTextFormat: snippets ? InsertTextFormat.Snippet : InsertTextFormat.PlainText,
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ return items;
179
+ }