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,260 @@
1
+ import {
2
+ SignatureInformation,
3
+ ParameterInformation,
4
+ SymbolKind,
5
+ Location,
6
+ MarkupKind,
7
+ } from "vscode-languageserver/node.js";
8
+ import type { Hover, SignatureHelp, DocumentSymbol, Range, Position } from "vscode-languageserver/node.js";
9
+ import type { TextDocument } from "vscode-languageserver-textdocument";
10
+ import * as fs from "fs";
11
+ import { fileURLToPath } from "url";
12
+ import type { FileAnalysis, FunctionSymbol } from "./types.ts";
13
+ import { resolvePrefix } from "./analyzer.ts";
14
+
15
+ // ── Shared helpers ───────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Resolve a qualified-name word (e.g. "fn:true", "m:sin", "true") to a FunctionSymbol
19
+ * using proper namespace URI resolution against the current file's namespace context.
20
+ */
21
+ function resolveFunction(
22
+ word: string,
23
+ currentAnalysis: FileAnalysis,
24
+ fns: FunctionSymbol[],
25
+ ): FunctionSymbol | undefined {
26
+ const colonIdx = word.indexOf(":");
27
+ const prefix = colonIdx >= 0 ? word.slice(0, colonIdx) : "";
28
+ const localName = colonIdx >= 0 ? word.slice(colonIdx + 1) : word;
29
+ const uri = resolvePrefix(prefix, currentAnalysis);
30
+ return fns.find((f) => f.namespaceUri === uri && f.localName === localName);
31
+ }
32
+
33
+ function wordAt(text: string, offset: number): { word: string; start: number; end: number } {
34
+ let start = offset;
35
+ let end = offset;
36
+ while (start > 0 && /[\w:\-]/.test(text[start - 1])) start--;
37
+ while (end < text.length && /[\w:\-]/.test(text[end])) end++;
38
+ return { word: text.slice(start, end), start, end };
39
+ }
40
+
41
+ function functionSignature(fn: FunctionSymbol): string {
42
+ const params = fn.params.map((p) => `$${p.name}${p.type ? " as " + p.type : ""}`).join(", ");
43
+ const ret = fn.returnType ? ` as ${fn.returnType}` : "";
44
+ return `declare function ${fn.name}(${params})${ret}`;
45
+ }
46
+
47
+ function allFunctions(current: FileAnalysis, imported: Map<string, FileAnalysis>): FunctionSymbol[] {
48
+ const fns = [...current.functions];
49
+ for (const a of imported.values()) fns.push(...a.functions);
50
+ return fns;
51
+ }
52
+
53
+ function positionInText(text: string, offset: number): Position {
54
+ const before = text.slice(0, offset);
55
+ const lines = before.split("\n");
56
+ return { line: lines.length - 1, character: lines[lines.length - 1].length };
57
+ }
58
+
59
+ function rangeFromOffset(doc: TextDocument, start: number, end: number): Range {
60
+ return { start: doc.positionAt(start), end: doc.positionAt(end) };
61
+ }
62
+
63
+ // ── Hover ────────────────────────────────────────────────────────────────────
64
+
65
+ export function getHover(
66
+ doc: TextDocument,
67
+ offset: number,
68
+ current: FileAnalysis,
69
+ imported: Map<string, FileAnalysis>,
70
+ ): Hover | null {
71
+ const text = doc.getText();
72
+ const { word, start, end } = wordAt(text, offset);
73
+ if (!word) return null;
74
+
75
+ // Check if hovering over a $ variable
76
+ const hasDollar = start > 0 && text[start - 1] === "$";
77
+ if (hasDollar) {
78
+ const varName = word;
79
+ const allVars = [
80
+ ...current.moduleVariables,
81
+ ...current.localBindings,
82
+ ...[...imported.values()].flatMap((a) => a.moduleVariables),
83
+ ];
84
+ const v = allVars.find((v) => v.name === varName);
85
+ if (v) {
86
+ return {
87
+ contents: { kind: MarkupKind.Markdown, value: `\`$${v.name}\`` },
88
+ range: rangeFromOffset(doc, start - 1, end),
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ // Check if hovering over a function name
95
+ const fn = resolveFunction(word, current, allFunctions(current, imported));
96
+ if (fn) {
97
+ return {
98
+ contents: {
99
+ kind: MarkupKind.Markdown,
100
+ value: "```xquery\n" + functionSignature(fn) + "\n```",
101
+ },
102
+ range: rangeFromOffset(doc, start, end),
103
+ };
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ // ── Signature help ───────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Scan left from cursor to find the function call we're inside.
113
+ * Returns { name, activeParam } or null.
114
+ */
115
+ function findEnclosingCall(text: string, offset: number): { name: string; activeParam: number } | null {
116
+ let depth = 0;
117
+ let commas = 0;
118
+
119
+ for (let i = offset - 1; i >= 0; i--) {
120
+ const ch = text[i];
121
+ if (ch === ")") {
122
+ depth++;
123
+ continue;
124
+ }
125
+ if (ch === "(") {
126
+ if (depth > 0) {
127
+ depth--;
128
+ continue;
129
+ }
130
+ // Found the opening paren of our call — scan left for the function name
131
+ let nameEnd = i;
132
+ while (nameEnd > 0 && /\s/.test(text[nameEnd - 1])) nameEnd--;
133
+ const { word, start } = wordAt(text, nameEnd - 1);
134
+ if (!word) return null;
135
+ return { name: word, activeParam: commas };
136
+ }
137
+ if (ch === "," && depth === 0) commas++;
138
+ // Stop at statement boundaries
139
+ if ((ch === ";" || ch === "{") && depth === 0) return null;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ export function getSignatureHelp(
145
+ doc: TextDocument,
146
+ offset: number,
147
+ current: FileAnalysis,
148
+ imported: Map<string, FileAnalysis>,
149
+ ): SignatureHelp | null {
150
+ const text = doc.getText();
151
+ const call = findEnclosingCall(text, offset);
152
+ if (!call) return null;
153
+
154
+ const fn = resolveFunction(call.name, current, allFunctions(current, imported));
155
+ if (!fn) return null;
156
+
157
+ const sig: SignatureInformation = {
158
+ label: functionSignature(fn),
159
+ parameters: fn.params.map((p) => {
160
+ const label = `$${p.name}${p.type ? " as " + p.type : ""}`;
161
+ return ParameterInformation.create(label);
162
+ }),
163
+ };
164
+
165
+ return {
166
+ signatures: [sig],
167
+ activeSignature: 0,
168
+ activeParameter: Math.min(call.activeParam, fn.params.length - 1),
169
+ };
170
+ }
171
+
172
+ // ── Document symbols ─────────────────────────────────────────────────────────
173
+
174
+ export function getDocumentSymbols(doc: TextDocument, current: FileAnalysis): DocumentSymbol[] {
175
+ const symbols: DocumentSymbol[] = [];
176
+
177
+ for (const fn of current.functions) {
178
+ const pos = doc.positionAt(fn.sourceOffset ?? 0);
179
+ const range: Range = { start: pos, end: pos };
180
+ symbols.push({
181
+ name: fn.name,
182
+ kind: SymbolKind.Function,
183
+ range,
184
+ selectionRange: range,
185
+ detail: `#${fn.arity}`,
186
+ });
187
+ }
188
+
189
+ for (const v of current.moduleVariables) {
190
+ const pos = doc.positionAt(v.offset);
191
+ const range: Range = { start: pos, end: pos };
192
+ symbols.push({
193
+ name: `$${v.name}`,
194
+ kind: SymbolKind.Variable,
195
+ range,
196
+ selectionRange: range,
197
+ });
198
+ }
199
+
200
+ return symbols;
201
+ }
202
+
203
+ // ── Go to definition ─────────────────────────────────────────────────────────
204
+
205
+ export function getDefinition(
206
+ doc: TextDocument,
207
+ offset: number,
208
+ current: FileAnalysis,
209
+ imported: Map<string, FileAnalysis>,
210
+ resolveUri: (atPath: string) => string,
211
+ ): Location | null {
212
+ const text = doc.getText();
213
+ const { word, start } = wordAt(text, offset);
214
+ if (!word) return null;
215
+
216
+ const hasDollar = start > 0 && text[start - 1] === "$";
217
+
218
+ if (hasDollar) {
219
+ // Variable definition — search current file only
220
+ const allVars = [...current.moduleVariables, ...current.localBindings];
221
+ const v = allVars.find((v) => v.name === word);
222
+ if (!v) return null;
223
+ const pos = doc.positionAt(v.offset);
224
+ return Location.create(doc.uri, { start: pos, end: pos });
225
+ }
226
+
227
+ // Function definition — resolve by namespace URI, search current file then all imports
228
+ const colonIdx = word.indexOf(":");
229
+ const wordPrefix = colonIdx >= 0 ? word.slice(0, colonIdx) : "";
230
+ const wordLocalName = colonIdx >= 0 ? word.slice(colonIdx + 1) : word;
231
+ const targetUri = resolvePrefix(wordPrefix, current);
232
+
233
+ const localFn = current.functions.find((f) => f.namespaceUri === targetUri && f.localName === wordLocalName);
234
+ if (localFn) {
235
+ const pos = doc.positionAt(localFn.sourceOffset ?? 0);
236
+ return Location.create(doc.uri, { start: pos, end: pos });
237
+ }
238
+
239
+ // Search imported files: find which atPath contains the function, then navigate there
240
+ for (const imp of current.imports) {
241
+ const analysis = imported.get(imp.atPath);
242
+ if (!analysis) continue;
243
+ const fn = analysis.functions.find((f) => f.namespaceUri === targetUri && f.localName === wordLocalName);
244
+ if (!fn) continue;
245
+ const uri = resolveUri(imp.atPath);
246
+ let pos: Position = { line: 0, character: 0 };
247
+ if (fn.sourceOffset !== undefined) {
248
+ try {
249
+ const filePath = fileURLToPath(uri);
250
+ const srcText = fs.readFileSync(filePath, "utf-8");
251
+ pos = positionInText(srcText, fn.sourceOffset);
252
+ } catch {
253
+ /* file not readable, use line 0 */
254
+ }
255
+ }
256
+ return Location.create(uri, { start: pos, end: pos });
257
+ }
258
+
259
+ return null;
260
+ }
package/src/server.ts ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createConnection,
4
+ TextDocuments,
5
+ ProposedFeatures,
6
+ TextDocumentSyncKind,
7
+ DiagnosticSeverity,
8
+ } from 'vscode-languageserver/node.js';
9
+ import { TextDocument } from 'vscode-languageserver-textdocument';
10
+ import * as path from 'path';
11
+ import * as fs from 'fs';
12
+ import { fileURLToPath, pathToFileURL } from 'url';
13
+ import { XQuery31Full } from 'xq-parser';
14
+ import { analyze } from './analyzer.ts';
15
+ import { getBuiltins } from './builtins.ts';
16
+ import { getCompletions } from './completion.ts';
17
+ import { getHover, getSignatureHelp, getDocumentSymbols, getDefinition } from './features.ts';
18
+ import type { FileAnalysis } from './types.ts';
19
+
20
+ const connection = createConnection(ProposedFeatures.all);
21
+ const documents = new TextDocuments(TextDocument);
22
+
23
+ const analysisCache = new Map<string, FileAnalysis>();
24
+
25
+ function analyzeDocument(doc: TextDocument): FileAnalysis {
26
+ const analysis = analyze(doc.getText(), doc.uri);
27
+ analysisCache.set(doc.uri, analysis);
28
+ return analysis;
29
+ }
30
+
31
+ function uriToPath(uri: string): string {
32
+ try {
33
+ return fileURLToPath(uri);
34
+ } catch {
35
+ return uri.replace(/^file:\/\//, '');
36
+ }
37
+ }
38
+
39
+ function resolveImportUri(currentUri: string, atPath: string): string {
40
+ const currentPath = uriToPath(currentUri);
41
+ const dir = path.dirname(currentPath);
42
+ const resolved = path.resolve(dir, atPath);
43
+ return pathToFileURL(resolved).toString();
44
+ }
45
+
46
+ function getImportedAnalysis(importUri: string): FileAnalysis | null {
47
+ if (analysisCache.has(importUri)) return analysisCache.get(importUri)!;
48
+ try {
49
+ const text = fs.readFileSync(uriToPath(importUri), 'utf-8');
50
+ const analysis = analyze(text, importUri);
51
+ analysisCache.set(importUri, analysis);
52
+ return analysis;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function resolveImports(currentUri: string, analysis: FileAnalysis): Map<string, FileAnalysis> {
59
+ const result = new Map<string, FileAnalysis>();
60
+ result.set('builtin:fn', getBuiltins());
61
+ for (const imp of analysis.imports) {
62
+ const uri = resolveImportUri(currentUri, imp.atPath);
63
+ const imported = getImportedAnalysis(uri);
64
+ if (imported) result.set(imp.atPath, imported);
65
+ }
66
+ return result;
67
+ }
68
+
69
+ function parseDiagnostics(text: string, uri: string) {
70
+ try {
71
+ XQuery31Full(text);
72
+ return [];
73
+ } catch (e: unknown) {
74
+ const msg = e instanceof Error ? e.message : String(e);
75
+ // "at line N, column M:" in the parser's error messages
76
+ const loc = msg.match(/at line (\d+), column (\d+)/);
77
+ const line = loc ? parseInt(loc[1]) - 1 : 0;
78
+ const col = loc ? parseInt(loc[2]) - 1 : 0;
79
+ return [{
80
+ severity: DiagnosticSeverity.Error,
81
+ range: { start: { line, character: col }, end: { line, character: col + 1 } },
82
+ message: msg.split('\n')[0],
83
+ source: 'xquery-lsp',
84
+ }];
85
+ }
86
+ }
87
+
88
+ // ── LSP lifecycle ────────────────────────────────────────────────────────────
89
+
90
+ let snippetSupport = false;
91
+
92
+ connection.onInitialize((params) => {
93
+ snippetSupport =
94
+ params.capabilities.textDocument?.completion?.completionItem?.snippetSupport ?? false;
95
+ return {
96
+ capabilities: {
97
+ textDocumentSync: TextDocumentSyncKind.Incremental,
98
+ completionProvider: {
99
+ triggerCharacters: ['$', ':'],
100
+ resolveProvider: false,
101
+ },
102
+ hoverProvider: true,
103
+ signatureHelpProvider: {
104
+ triggerCharacters: ['(', ','],
105
+ },
106
+ documentSymbolProvider: true,
107
+ definitionProvider: true,
108
+ },
109
+ }});
110
+
111
+ documents.onDidChangeContent(change => {
112
+ const doc = change.document;
113
+ analyzeDocument(doc);
114
+ const diagnostics = parseDiagnostics(doc.getText(), doc.uri);
115
+ connection.sendDiagnostics({ uri: doc.uri, diagnostics });
116
+ });
117
+
118
+ connection.onCompletion(params => {
119
+ const doc = documents.get(params.textDocument.uri);
120
+ if (!doc) return [];
121
+ const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
122
+ const imported = resolveImports(doc.uri, analysis);
123
+ const offset = doc.offsetAt(params.position);
124
+ return getCompletions({ textBeforeCursor: doc.getText().slice(0, offset), cursorOffset: offset }, analysis, imported, snippetSupport);
125
+ });
126
+
127
+ connection.onHover(params => {
128
+ const doc = documents.get(params.textDocument.uri);
129
+ if (!doc) return null;
130
+ const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
131
+ const imported = resolveImports(doc.uri, analysis);
132
+ return getHover(doc, doc.offsetAt(params.position), analysis, imported);
133
+ });
134
+
135
+ connection.onSignatureHelp(params => {
136
+ const doc = documents.get(params.textDocument.uri);
137
+ if (!doc) return null;
138
+ const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
139
+ const imported = resolveImports(doc.uri, analysis);
140
+ return getSignatureHelp(doc, doc.offsetAt(params.position), analysis, imported);
141
+ });
142
+
143
+ connection.onDocumentSymbol(params => {
144
+ const doc = documents.get(params.textDocument.uri);
145
+ if (!doc) return [];
146
+ const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
147
+ return getDocumentSymbols(doc, analysis);
148
+ });
149
+
150
+ connection.onDefinition(params => {
151
+ const doc = documents.get(params.textDocument.uri);
152
+ if (!doc) return null;
153
+ const analysis = analysisCache.get(doc.uri) ?? analyzeDocument(doc);
154
+ const imported = resolveImports(doc.uri, analysis);
155
+ return getDefinition(
156
+ doc,
157
+ doc.offsetAt(params.position),
158
+ analysis,
159
+ imported,
160
+ atPath => resolveImportUri(doc.uri, atPath),
161
+ );
162
+ });
163
+
164
+ documents.listen(connection);
165
+ connection.listen();
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ export interface ParamInfo {
2
+ name: string;
3
+ type?: string;
4
+ description?: string;
5
+ }
6
+
7
+ export interface DocComment {
8
+ description: string;
9
+ params: Record<string, string>; // param name (without $) → description
10
+ returns?: string;
11
+ }
12
+
13
+ export interface FunctionSymbol {
14
+ name: string; // full qualified name as declared, e.g. "local:add"
15
+ prefix: string; // namespace prefix as declared, e.g. "local"
16
+ localName: string; // local part, e.g. "add"
17
+ namespaceUri: string; // resolved namespace URI, e.g. "http://www.w3.org/2005/xquery-local-functions"
18
+ arity: number;
19
+ params: ParamInfo[];
20
+ returnType?: string;
21
+ doc?: DocComment;
22
+ sourceUri: string;
23
+ sourceOffset?: number; // char offset of the declare keyword
24
+ }
25
+
26
+ export interface VariableSymbol {
27
+ name: string; // without $, e.g. "sum" or "local:count"
28
+ offset: number; // char offset in source where it's defined
29
+ isModuleLevel: boolean;
30
+ sourceUri: string;
31
+ }
32
+
33
+ export interface ImportInfo {
34
+ prefix: string;
35
+ namespaceUri: string;
36
+ atPath: string; // as written in source, e.g. "./other.xq"
37
+ }
38
+
39
+ export interface FileAnalysis {
40
+ functions: FunctionSymbol[];
41
+ moduleVariables: VariableSymbol[]; // declare variable
42
+ localBindings: VariableSymbol[]; // let/for bindings
43
+ imports: ImportInfo[];
44
+ defaultFunctionNamespace: string; // from 'declare default function namespace', else XMLNS_FN
45
+ moduleNamespaceUri?: string; // from 'module namespace prefix="uri"'
46
+ modulePrefix?: string; // from 'module namespace prefix="uri"'
47
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "preserve",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "erasableSyntaxOnly": true,
8
+ "verbatimModuleSyntax": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "rewriteRelativeImportExtensions": true,
12
+ "declaration": true,
13
+ "skipLibCheck": true,
14
+ "types": ["node"]
15
+ },
16
+ "paths": {
17
+ "vscode-languageserver/node.js": ["./node_modules/vscode-languageserver/node.d.ts"]
18
+ },
19
+ "include": [
20
+ "./src/*"
21
+ ]
22
+ }