From 3ec78671e1c0560926287cabea7ec675c4955e8f Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 13 Mar 2026 23:09:20 -0400 Subject: [PATCH 1/6] Replace atom-editor-support with language-support XAR Switch from atom-editor-support (GET-based, single error, client-side import resolution) to language-support (POST JSON, multi-error diagnostics, server-side import resolution via lsp:* functions). Key changes: - linting.ts: POST /api/diagnostics with structured JSON response - analyzed-document.ts: POST /api/{completions,hover,definition} with full query text; removed resolveImports/parseImports/getParameters - server.ts: pass document text to getCompletions and getHover - utils.ts: detect language-support XAR instead of atom-editor Requires eXist-db 7.0+ with lsp:* module (eXist-db/exist#6130). Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/atom-editor-1.1.0.xar | Bin 14914 -> 0 bytes resources/language-support-1.0.0.xar | Bin 0 -> 3813 bytes server/src/analyzed-document.ts | 247 +++++++-------------------- server/src/linting.ts | 67 ++++---- server/src/server.ts | 4 +- server/src/utils.ts | 4 +- 6 files changed, 101 insertions(+), 221 deletions(-) delete mode 100644 resources/atom-editor-1.1.0.xar create mode 100644 resources/language-support-1.0.0.xar diff --git a/resources/atom-editor-1.1.0.xar b/resources/atom-editor-1.1.0.xar deleted file mode 100644 index d7341e8b7e0689927ef836a64a5881da3feedc04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14914 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 PBQohdw98=q(ER@ZtAdLI diff --git a/resources/language-support-1.0.0.xar b/resources/language-support-1.0.0.xar new file mode 100644 index 0000000000000000000000000000000000000000..bbfcab15ec930b50b53edb032615dc138ae19273 GIT binary patch literal 3813 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 literal 0 HcmV?d00001 diff --git a/server/src/analyzed-document.ts b/server/src/analyzed-document.ts index 2848a33..de312c7 100644 --- a/server/src/analyzed-document.ts +++ b/server/src/analyzed-document.ts @@ -2,22 +2,10 @@ import { Diagnostic, CompletionItem, CompletionItemKind, InsertTextFormat, Respo import { ServerSettings } from './settings'; import { AST } from './ast'; import axios from 'axios'; -import * as path from 'path'; -import * as fs from 'fs'; -import { URI } from 'vscode-uri'; const funcDefRe = /(?:\(:~(.*?):\))?\s*declare\s+((?:%[\w\:\-]+(?:\([^\)]*\))?\s*)*function\s+([^\(]+)\()/gsm; const trimRe = /^[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000]+|[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000]+$/g; const paramRe = /\$[^\s]+/; -const importRe = /(import\s+module\s+namespace\s+[^=]+\s*=\s*["'][^"']+["']\s*(?:at\s+["'][^"']+["'])?\s*;)/g; -const moduleRe = /import\s+module\s+namespace\s+([^=\s]+)\s*=\s*["']([^"']+)["']\s*at\s+["']([^"']+)["']\s*;/; - -interface Import { - prefix: string; - uri: string; - source?: string; - isJava?: boolean; -} interface Symbol { signature: string; @@ -51,8 +39,6 @@ export class AnalyzedDocument { symbolsMap: Map = new Map(); - imports: Map = new Map(); - ast: any; logger: (message: string, prio?: string) => void; @@ -73,13 +59,13 @@ export class AnalyzedDocument { this.symbolsMap.clear(); AnalyzedDocument.getLocalSymbols(text, false, this.symbolsMap); this.localSymbols = Array.from(this.symbolsMap.values()); - this.parseImports(text); } async gotoDefinition(position: Position, relPath: string, textDocument: TextDocument, settings: ServerSettings): Promise { if (!this.ast) { return null; } + // Try local symbol lookup first (no roundtrip) const signature = this.getSignatureFromPosition(position); if (signature) { const symbol = this.symbolsMap.get(`${signature.name}#${signature.arity}`); @@ -88,82 +74,59 @@ export class AnalyzedDocument { uri: this.uri, range: this.computeLocation(textDocument, symbol.location) }; - } else { - return this.gotoDefinitionRemote(signature, relPath, textDocument, settings); } + // Fall back to server-side definition + return this.gotoDefinitionRemote(textDocument, position, relPath, settings); } return null; } - private async gotoDefinitionRemote(signature: any, relPath: string, textDocument: TextDocument, settings: ServerSettings): Promise { - const params = this.getParameters(signature, relPath, settings); + private async gotoDefinitionRemote(textDocument: TextDocument, position: Position, relPath: string, settings: ServerSettings): Promise { try { - const options = this.getOptions(params, settings); - const response = await axios.get(options.uri, { + // lsp:* expects 1-indexed line/column + const response = await axios.post(`${settings.uri}/apps/language-support/api/definition`, { + query: textDocument.getText(), + line: position.line + 1, + column: position.character + 1, + base: `${settings.path}/${relPath}` + }, { auth: { username: settings.user, password: settings.password }, - params: options.qs, - responseType: 'text' + headers: { "Content-Type": "application/json" }, + responseType: 'json' }); - + if (response.status !== 200) { this.status(false, settings); return null; } - - const json = JSON.parse(response.data); - if (json.length == 0) { - this.logger(`no description found for ${params.signature}`, 'info'); + + this.status(true, settings); + const def = response.data; + if (!def || !def.line && def.line !== 0) { return null; } - - this.status(true, settings); - const desc = json[0]; - const rp = path.relative(`${settings.path}/${relPath}`, desc.path); - const fp = URI.parse(this.uri).fsPath; - const absPath = path.resolve(path.dirname(fp), rp); - console.log(`reading ${absPath}`); - - return new Promise((resolve) => { - fs.readFile(absPath, { encoding: 'utf-8' }, (err: NodeJS.ErrnoException | null, content: string | Buffer) => { - if (err || !content) { - this.logger(`failed to parse ${absPath}`, 'error'); - resolve(null); - return; - } - const contentStr = typeof content === 'string' ? content : content.toString('utf-8'); - const symbol = AnalyzedDocument.getLocalSymbol(contentStr, signature.name, signature.arity); - if (symbol && symbol.location) { - resolve({ - uri: URI.file(absPath).toString(), - range: { - start: { - line: symbol.location.start, - character: 0 - }, - end: { - line: symbol.location.end + 1, - character: Number.MAX_VALUE - } - } - }); - } else { - resolve(null); - } - }); - }); + + // lsp:* returns 1-indexed; convert to 0-indexed for LSP protocol + const defLine = Math.max(def.line - 1, 0); + const defCol = Math.max((def.column || 1) - 1, 0); + return { + uri: this.uri, + range: Range.create(defLine, defCol, defLine, defCol) + }; } catch (error) { this.status(false, settings); return null; } } - async getHover(position: Position, relPath: string, settings: ServerSettings): Promise { + async getHover(position: Position, relPath: string, textDocument: TextDocument, settings: ServerSettings): Promise { if (!this.ast) { return null; } + // Try local symbol lookup first (no roundtrip) const signature = this.getSignatureFromPosition(position); if (signature) { const symbol = this.symbolsMap.get(`${signature.name}#${signature.arity}`); @@ -178,52 +141,45 @@ export class AnalyzedDocument { value: md.join('\n\n') } }; - } else { - return this.getHoverRemote(signature, relPath, settings); } + // Fall back to server-side hover + return this.getHoverRemote(textDocument, position, relPath, settings); } return null; } - private async getHoverRemote(signature: any, relPath: string, settings: ServerSettings): Promise { - const params = this.getParameters(signature, relPath, settings); + private async getHoverRemote(textDocument: TextDocument, position: Position, relPath: string, settings: ServerSettings): Promise { try { - const options = this.getOptions(params, settings); - const response = await axios.get(options.uri, { + // lsp:* expects 1-indexed line/column + const response = await axios.post(`${settings.uri}/apps/language-support/api/hover`, { + query: textDocument.getText(), + line: position.line + 1, + column: position.character + 1, + base: `${settings.path}/${relPath}` + }, { auth: { username: settings.user, password: settings.password }, - params: options.qs, - responseType: 'text' + headers: { "Content-Type": "application/json" }, + responseType: 'json' }); - + if (response.status !== 200) { this.status(false, settings); return null; } - + this.status(true, settings); - const json = JSON.parse(response.data); - if (json.length == 0) { - this.logger(`hover: no description found for ${params.signature}`, 'info'); + const hover = response.data; + if (!hover || !hover.contents) { return null; } - - const desc = json[0]; - const md = [`**${desc.text}** as **${desc.leftLabel}**`]; - if (desc.description) { - md.push(desc.description); - } - if (desc.arguments && desc.arguments.length > 0) { - desc.arguments.forEach((arg: any) => { - md.push(`**\$${arg.name}** *${arg.type}* ${arg.description}`); - }); - } + return { contents: { kind: MarkupKind.Markdown, - value: md.join('\n\n') + value: hover.contents } }; } catch (error) { @@ -232,58 +188,40 @@ export class AnalyzedDocument { } } - private getParameters(signature: any, relPath: string, settings: ServerSettings) { - let imports: any; - const prefix = signature.name.split(':'); - if (prefix.length === 2) { - const imp = this.imports.get(prefix[0]); - if (imp) { - imports = [imp]; - } - } - if (!imports) { - imports = this.imports.values(); - } - const params = this.resolveImports(imports, false); - params.base = `${settings.path}/${relPath}`; - params.signature = `${signature.name}#${signature.arity}`; - return params; - } - - getCompletions(prefix: string | null, relPath: string, settings: ServerSettings): Promise> { - const params = this.resolveImports(this.imports.values(), false); - params.base = `${settings.path}/${relPath}`; + getCompletions(text: string, prefix: string | null, relPath: string, settings: ServerSettings): Promise> { + const body: any = { + query: text, + base: `${settings.path}/${relPath}` + }; if (prefix) { - params.prefix = prefix; + body.prefix = prefix; } - const options = this.getOptions(params, settings); - return axios.get(options.uri, { + return axios.post(`${settings.uri}/apps/language-support/api/completions`, body, { auth: { username: settings.user, password: settings.password }, - params: options.qs, - responseType: 'text' + headers: { "Content-Type": "application/json" }, + responseType: 'json' }).then(response => { if (response.status !== 200) { this.status(false, settings); throw new Error(`Unexpected status code: ${response.status}`); } this.status(true, settings); - const json = JSON.parse(response.data); - const symbols: any[] = []; - json.forEach((item: { text: string; snippet: string; type: string; name: string; description: string; }) => { + const items: any[] = response.data; + const remoteCompletions = items.map((item: any) => { const symbol: Symbol = { - signature: item.text, - type: item.type, - snippet: item.snippet.replace(/\:\$/g, ':\\\$'), - name: item.name, - documentation: item.description + signature: item.detail || item.label, + type: item.kind === 'function' ? 'function' : 'variable', + snippet: item.insertText || item.label, + name: item.label, + documentation: item.documentation }; - symbols.push(symbol); this.symbolsMap.set(symbol.name, symbol); + return symbol; }); - return this.mapCompletions(this.localSymbols).concat(this.mapCompletions(symbols)); + return this.mapCompletions(this.localSymbols).concat(this.mapCompletions(remoteCompletions)); }).catch(error => { this.status(false, settings); return new ResponseError(ErrorCodes.InvalidRequest, error); @@ -334,13 +272,6 @@ export class AnalyzedDocument { return 'adaptive'; } - private getOptions(params: any, settings: ServerSettings, target: string = 'atom-autocomplete.xql') { - return { - uri: `${settings.uri}/apps/atom-editor/${target}`, - qs: params - }; - } - private mapCompletions(symbols: any[]): CompletionItem[] { return symbols.map(symbol => { const completion: CompletionItem = { @@ -396,7 +327,6 @@ export class AnalyzedDocument { } const arity = args.length; const signature = name + "(" + args + ")"; - // const status = funcDef[2].indexOf("%private") == -1 ? "private" : 'public'; let location; if (lineCount) { const line = AnalyzedDocument.getLine(text, offset); @@ -506,53 +436,6 @@ export class AnalyzedDocument { return newlines; } - private parseImports(text: string) { - this.imports.clear(); - let match = importRe.exec(text); - - while (match != null) { - if (match[1]) { - const imp = match[1]; - match = moduleRe.exec(imp); - if (match && match.length === 4) { - const isJava = match[3].substring(0, 5) == "java:"; - const importData = { - prefix: match[1], - uri: match[2], - source: match[3], - isJava: isJava - }; - this.imports.set(importData.prefix, importData); - } - } - match = importRe.exec(text); - } - } - - private resolveImports(imports: IterableIterator, includeJava = true): { - mprefix: string[], uri: string[], source: string[], base: string, prefix?: string, - signature?: string - } { - const prefixes: string[] = []; - const uris: string[] = []; - const sources: string[] = []; - for (let imp of imports) { - if (!imp.isJava || includeJava) { - prefixes.push(imp.prefix); - uris.push(imp.uri); - if (imp.source) { - sources.push(imp.source); - } - } - } - return { - mprefix: prefixes, - uri: uris, - source: sources, - base: '' - }; - } - private getSignatureFromPosition(position: Position): any | undefined { const node = AST.findNode(this.ast, position); if (node) { @@ -562,4 +445,4 @@ export class AnalyzedDocument { } } } -} \ No newline at end of file +} diff --git a/server/src/linting.ts b/server/src/linting.ts index ca9d55d..b2aa20c 100644 --- a/server/src/linting.ts +++ b/server/src/linting.ts @@ -1,6 +1,6 @@ /** * Support for linting XQuery documents. - * + * * @author Wolfgang Meier */ import { Diagnostic, DiagnosticSeverity, Range, ResponseError, ErrorCodes } from 'vscode-languageserver'; @@ -24,33 +24,35 @@ export function lintDocument(text: string, relPath: string, document: AnalyzedDo } function serverLint(text: String, settings: ServerSettings, relPath: string, document: AnalyzedDocument): Promise> { - return axios.put(`${settings.uri}/apps/atom-editor/compile.xql`, text, { + return axios.post(`${settings.uri}/apps/language-support/api/diagnostics`, { + query: text, + base: `${settings.path}/${relPath}` + }, { auth: { username: settings.user, password: settings.password }, headers: { - "X-BasePath": `${settings.path}/${relPath}`, - "Content-Type": "application/octet-stream" + "Content-Type": "application/json" }, - responseType: 'text' + responseType: 'json' }).then(response => { if (response.status !== 200) { document.status(false, settings); return document; } document.status(true, settings); - const json = JSON.parse(response.data); - if (json.result !== 'pass') { - const error = parseErrorMessage(json.error); - if (!error.line) { - document.status(false, settings); - return document; - } else { - const diagnostic: Diagnostic = { - severity: DiagnosticSeverity.Error, - range: Range.create(error.line, error.column, error.line, error.column), - message: error.msg, + const diagnostics: any[] = response.data; + if (Array.isArray(diagnostics)) { + for (const d of diagnostics) { + // lsp:diagnostics returns 1-indexed lines; LSP protocol uses 0-indexed + const line = Math.max(d.line - 1, 0); + const column = Math.max(d.column - 1, 0); + const diagnostic: Diagnostic = { + severity: mapSeverity(d.severity), + range: Range.create(line, column, line, column), + message: d.message, + code: d.code, source: 'xquery' }; document.diagnostics.push(diagnostic); @@ -63,26 +65,21 @@ function serverLint(text: String, settings: ServerSettings, relPath: string, doc }); } -function parseErrorMessage(error: any) { - let msg; - if (error.line) { - msg = error["#text"]; - } else { - msg = error; +function mapSeverity(severity: string | number): DiagnosticSeverity { + if (typeof severity === 'number') { + // LSP DiagnosticSeverity: 1=Error, 2=Warning, 3=Information, 4=Hint + if (severity >= 1 && severity <= 4) { + return severity as DiagnosticSeverity; + } + return DiagnosticSeverity.Error; } - - let str = /.*line:?\s*(\d+),\s*column:?\s*(\d+)/i.exec(msg); - let line = 0; - let column = 0; - if (str && str.length === 3) { - line = parseInt(str[1]) - 1; - column = parseInt(str[2]) - 1; - } else { - line = parseInt(error.line) - 1; - column = parseInt(error.column) - 1; + switch (severity) { + case 'error': return DiagnosticSeverity.Error; + case 'warning': return DiagnosticSeverity.Warning; + case 'info': return DiagnosticSeverity.Information; + case 'hint': return DiagnosticSeverity.Hint; + default: return DiagnosticSeverity.Error; } - - return { line: Math.max(line, 0), column: Math.max(column, 0), msg: msg }; } function xqlint(uri: String, text: String, document: AnalyzedDocument): Diagnostic[] { @@ -107,4 +104,4 @@ function xqlint(uri: String, text: String, document: AnalyzedDocument): Diagnost }); document.diagnostics = diagnostics; return diagnostics; -} \ No newline at end of file +} diff --git a/server/src/server.ts b/server/src/server.ts index d9829db..e15f974 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -334,7 +334,7 @@ async function autocomplete(position: TextDocumentPositionParams): Promise => { diff --git a/server/src/utils.ts b/server/src/utils.ts index 45ab5ae..823e95f 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -96,8 +96,8 @@ export function checkServer(workspaceConfig: ServerSettings, resourcesDir: strin declare option output:method "json"; declare option output:media-type "application/json"; - if ("http://exist-db.org/apps/atom-editor" = repo:list()) then - let $data := repo:get-resource("http://exist-db.org/apps/atom-editor", "expath-pkg.xml") + if ("http://exist-db.org/apps/language-support" = repo:list()) then + let $data := repo:get-resource("http://exist-db.org/apps/language-support", "expath-pkg.xml") let $xml := parse-xml(util:binary-to-string($data)) return if ($xml/expath:package/@version = "${xar.version}") then From 9451150517b7695ee50b1fbe112a6311d3c364da Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 14 Mar 2026 20:05:20 -0400 Subject: [PATCH 2/6] Drop xqlint warnings and add cross-module definition support - linting.ts: remove getWarnings() call, keep only getAST() for local symbol lookup. Eliminates false positives like #67 (unused variable with arrow operator). Server-side lsp:diagnostics handles error checking. - analyzed-document.ts: handle "uri" field from lsp:definition response for cross-module go-to-definition. Maps database paths to workspace file URIs using settings.path prefix. Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/language-support-1.0.0.xar | Bin 3813 -> 3872 bytes server/src/analyzed-document.ts | 20 +++++++++++++++++++- server/src/linting.ts | 22 ++++------------------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/resources/language-support-1.0.0.xar b/resources/language-support-1.0.0.xar index bbfcab15ec930b50b53edb032615dc138ae19273..533f7c84f65f3c53b2f2af4a596af08b89f0911c 100644 GIT binary patch delta 1718 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 Olk^TI2G9%u0000rfC=gV diff --git a/server/src/analyzed-document.ts b/server/src/analyzed-document.ts index de312c7..ea8de3a 100644 --- a/server/src/analyzed-document.ts +++ b/server/src/analyzed-document.ts @@ -2,6 +2,8 @@ import { Diagnostic, CompletionItem, CompletionItemKind, InsertTextFormat, Respo import { ServerSettings } from './settings'; import { AST } from './ast'; import axios from 'axios'; +import * as path from 'path'; +import { URI } from 'vscode-uri'; const funcDefRe = /(?:\(:~(.*?):\))?\s*declare\s+((?:%[\w\:\-]+(?:\([^\)]*\))?\s*)*function\s+([^\(]+)\()/gsm; const trimRe = /^[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000]+|[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000]+$/g; @@ -112,8 +114,24 @@ export class AnalyzedDocument { // lsp:* returns 1-indexed; convert to 0-indexed for LSP protocol const defLine = Math.max(def.line - 1, 0); const defCol = Math.max((def.column || 1) - 1, 0); + + // Cross-module: map database path to workspace file URI + let targetUri = this.uri; + if (def.uri && settings.path) { + const dbPath: string = def.uri; + const dbRoot: string = settings.path; + if (dbPath.startsWith(dbRoot)) { + const relModulePath = dbPath.substring(dbRoot.length); + const currentFilePath = URI.parse(this.uri).fsPath; + const workspaceRoot = currentFilePath.substring(0, + currentFilePath.length - relPath.length - path.basename(currentFilePath).length); + const targetPath = path.join(workspaceRoot, relModulePath); + targetUri = URI.file(targetPath).toString(); + } + } + return { - uri: this.uri, + uri: targetUri, range: Range.create(defLine, defCol, defLine, defCol) }; } catch (error) { diff --git a/server/src/linting.ts b/server/src/linting.ts index b2aa20c..8e8e44e 100644 --- a/server/src/linting.ts +++ b/server/src/linting.ts @@ -82,26 +82,12 @@ function mapSeverity(severity: string | number): DiagnosticSeverity { } } -function xqlint(uri: String, text: String, document: AnalyzedDocument): Diagnostic[] { +function xqlint(uri: String, text: String, document: AnalyzedDocument): void { const xqlint = new XQLint(text, { fileName: uri }); + // Keep AST for local symbol lookup (hover, go-to-definition). + // Skip getWarnings() — server-side lsp:diagnostics handles error + // checking without the false positives xqlint produces (e.g. #67). document.ast = xqlint.getAST(); - const warnings:any[] = xqlint.getWarnings(); - const diagnostics: Diagnostic[] = []; - warnings.forEach(warning => { - const diagnostic: Diagnostic = { - severity: DiagnosticSeverity.Warning, - range: Range.create( - warning.pos.sl, - warning.pos.sc, - warning.pos.el, - warning.pos.ec), - message: warning.message, - source: 'xquery' - }; - diagnostics.push(diagnostic); - }); - document.diagnostics = diagnostics; - return diagnostics; } From 51780b7cfa50d348af5202463b342fc44e06e28f Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 15 Mar 2026 21:47:30 -0400 Subject: [PATCH 3/6] Add semantic tokens, XQuery formatting, and enhanced symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new LSP capabilities for parity with eXide: - Semantic tokens: highlights function and variable declarations using server-side lsp:symbols, with local fallback - Document formatting: Prettier-based XQuery formatting via prettier-plugin-xquery (XQuery files only — VS Code handles other languages natively) - Enhanced document symbols: uses server-side lsp:symbols for richer outline with return types and parameter types, local fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- server/package-lock.json | 53 ++++++++++++- server/package.json | 4 +- server/src/server.ts | 156 ++++++++++++++++++++++++++++++++++++++- server/webpack.config.js | 4 +- 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 6678b15..a8a9c16 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,12 +10,13 @@ "license": "GPL-3.0-or-later", "dependencies": { "axios": "^1.7.0", + "prettier": "^3.8.1", + "prettier-plugin-xquery": "^1.5.0", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.0.8", "xqlint": "github:eXistSolutions/xqlint#exist-syntax" }, - "devDependencies": {}, "engines": { "node": "^18.0.0" } @@ -689,6 +690,31 @@ "node": ">=0.10.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-xquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-xquery/-/prettier-plugin-xquery-1.5.0.tgz", + "integrity": "sha512-W3MOTMaxuieU3RaZ7FvV0aySRUulygh1Zz7MNf1ozFMA3SZHAkX/osNcJWD+g44E37CPCQVtIUUTbMvH159JDg==", + "license": "MIT", + "dependencies": { + "prettier": "^3.6.2", + "xq-parser": "^0.3.2" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -986,6 +1012,12 @@ "node": ">=0.10.0" } }, + "node_modules/xq-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/xq-parser/-/xq-parser-0.3.2.tgz", + "integrity": "sha512-Xu6PBGgMyBN4rKDmOk/ik7MrPUrEfsQ7RipDfBTw+EZ/uTwvVb/DkCiXvIvay2ycLe1dlDo33xyVPThGtPFRkg==", + "license": "MIT" + }, "node_modules/xqlint": { "version": "0.3.1", "resolved": "git+ssh://git@github.com/eXistSolutions/xqlint.git#1a98bca0649c14bad8cdc15701d9ef8779dc4d1e", @@ -1473,6 +1505,20 @@ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" }, + "prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==" + }, + "prettier-plugin-xquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-xquery/-/prettier-plugin-xquery-1.5.0.tgz", + "integrity": "sha512-W3MOTMaxuieU3RaZ7FvV0aySRUulygh1Zz7MNf1ozFMA3SZHAkX/osNcJWD+g44E37CPCQVtIUUTbMvH159JDg==", + "requires": { + "prettier": "^3.6.2", + "xq-parser": "^0.3.2" + } + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -1702,6 +1748,11 @@ "user-home": "^1.0.0" } }, + "xq-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/xq-parser/-/xq-parser-0.3.2.tgz", + "integrity": "sha512-Xu6PBGgMyBN4rKDmOk/ik7MrPUrEfsQ7RipDfBTw+EZ/uTwvVb/DkCiXvIvay2ycLe1dlDo33xyVPThGtPFRkg==" + }, "xqlint": { "version": "git+ssh://git@github.com/eXistSolutions/xqlint.git#1a98bca0649c14bad8cdc15701d9ef8779dc4d1e", "integrity": "sha512-L069Yct51LiLLMe441N+M+BYoZnhQuNTBfjBAYnfXqa5HByHpyCvzLtNvH9mei6BS06Z0MLRg06yAizHnzTMQQ==", diff --git a/server/package.json b/server/package.json index fb767bd..cb366da 100644 --- a/server/package.json +++ b/server/package.json @@ -20,11 +20,11 @@ }, "dependencies": { "axios": "^1.7.0", + "prettier": "^3.8.1", + "prettier-plugin-xquery": "^1.5.0", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.0.8", "xqlint": "github:eXistSolutions/xqlint#exist-syntax" - }, - "devDependencies": { } } diff --git a/server/src/server.ts b/server/src/server.ts index e15f974..a72f14b 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -7,8 +7,11 @@ import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, Position, DidChangeConfigurationNotification, TextDocumentPositionParams, CompletionItem, WorkspaceFolder, ResponseError, DocumentSymbolParams, - SymbolInformation, Hover, - Location, ConfigurationItem + SymbolInformation, SymbolKind, Hover, + Location, ConfigurationItem, + DocumentFormattingParams, TextEdit, Range, + SemanticTokensParams, SemanticTokensBuilder, SemanticTokensLegend, + SemanticTokenTypes, SemanticTokenModifiers } from 'vscode-languageserver/node'; import { TextDocument } from "vscode-languageserver-textdocument"; import { URI } from 'vscode-uri'; @@ -16,6 +19,25 @@ import { ServerSettings } from './settings'; import { AnalyzedDocument } from './analyzed-document'; import { checkServer, installXar, readWorkspaceConfig, createWorkspaceConfig } from './utils'; import { lintDocument } from './linting'; +import axios from 'axios'; + +// Semantic token types used by XQuery highlighting +const tokenTypes = [ + SemanticTokenTypes.function, + SemanticTokenTypes.variable, + SemanticTokenTypes.namespace, + SemanticTokenTypes.decorator, // annotations + SemanticTokenTypes.type, + SemanticTokenTypes.parameter +]; +const tokenModifiers = [ + SemanticTokenModifiers.declaration, + SemanticTokenModifiers.definition +]; +const semanticTokensLegend: SemanticTokensLegend = { + tokenTypes: tokenTypes, + tokenModifiers: tokenModifiers +}; const defaultSettings: ServerSettings = { uri: 'http://localhost:8080/exist', @@ -157,7 +179,12 @@ connection.onInitialize((params) => { }, documentSymbolProvider: true, definitionProvider: true, - hoverProvider: true + hoverProvider: true, + documentFormattingProvider: true, + semanticTokensProvider: { + legend: semanticTokensLegend, + full: true + } } }; }); @@ -348,13 +375,41 @@ connection.onCompletionResolve((item: CompletionItem): CompletionItem => { return item; }); -connection.onDocumentSymbol((params: DocumentSymbolParams): SymbolInformation[] => { +connection.onDocumentSymbol(async (params: DocumentSymbolParams): Promise => { const uri = params.textDocument.uri; const textDocument = documents.get(uri); if (!textDocument) { return []; } const document = getAnalyzedDocument(textDocument); + const settings = await getSettings(); + const relPath = getRelativePath(uri); + + // Try server-side symbols for richer results (return types, parameter types) + try { + const response = await axios.post(`${settings.uri}/apps/language-support/api/symbols`, { + query: textDocument.getText(), + base: `${settings.path}/${relPath}` + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + + if (response.status === 200 && Array.isArray(response.data) && response.data.length > 0) { + return response.data.map((sym: any) => ({ + name: sym.detail || sym.name, + kind: sym.kind === 12 ? SymbolKind.Function : SymbolKind.Variable, + location: { + uri, + range: Range.create(sym.line || 0, sym.column || 0, sym.line || 0, sym.column || 0) + } + })); + } + } catch (e) { + // Fall through to local symbols + } + return document.getDocumentSymbols(textDocument); }); @@ -388,6 +443,99 @@ async function gotoDefinition(uri: string, position: Position) { return document.gotoDefinition(position, relPath, textDocument, settings); } +// --- Semantic Tokens --- +connection.languages.semanticTokens.on(async (params: SemanticTokensParams) => { + const uri = params.textDocument.uri; + const textDocument = documents.get(uri); + if (!textDocument) { + return { data: [] }; + } + const settings = await getSettings(); + const relPath = getRelativePath(uri); + const text = textDocument.getText(); + const builder = new SemanticTokensBuilder(); + + try { + const response = await axios.post(`${settings.uri}/apps/language-support/api/symbols`, { + query: text, + base: `${settings.path}/${relPath}` + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + + if (response.status === 200 && Array.isArray(response.data)) { + for (const symbol of response.data) { + // lsp:symbols returns 0-indexed line/column + const line = symbol.line || 0; + const col = symbol.column || 0; + const name = (symbol.name || '').replace(/#\d+$/, ''); + const length = name.length; + // Map symbol kind to semantic token type + const kind = symbol.kind; + let tokenType = 0; // function + if (kind === 6 || kind === 13) { // Variable or Property + tokenType = 1; // variable + } + builder.push(line, col, length, tokenType, 1); // modifier: declaration + } + } + } catch (e) { + // Fall back to local symbols + const document = getAnalyzedDocument(textDocument); + const symbols = document.getDocumentSymbols(textDocument); + for (const sym of symbols) { + const line = sym.location.range.start.line; + const col = sym.location.range.start.character; + const name = sym.name.replace(/\(.*$/, ''); + const length = name.length; + const tokenType = sym.kind === SymbolKind.Function ? 0 : 1; + builder.push(line, col, length, tokenType, 1); + } + } + + return builder.build(); +}); + +// --- Document Formatting (XQuery only) --- +connection.onDocumentFormatting(async (params: DocumentFormattingParams): Promise => { + const uri = params.textDocument.uri; + const textDocument = documents.get(uri); + if (!textDocument) { + return []; + } + + // Only format XQuery files — VS Code handles other languages natively + const ext = uri.replace(/^.*\./, '').toLowerCase(); + if (!['xq', 'xql', 'xqm', 'xquery', 'xqy'].includes(ext)) { + return []; + } + + const text = textDocument.getText(); + try { + const prettier = require('prettier'); + const xqPlugin = require('prettier-plugin-xquery'); + + const formatted = await prettier.format(text, { + parser: 'xquery', + plugins: [xqPlugin], + tabWidth: params.options.tabSize, + useTabs: !params.options.insertSpaces + }); + + const lastLine = textDocument.lineCount - 1; + const lastChar = textDocument.getText().length; + return [{ + range: Range.create(0, 0, lastLine, lastChar), + newText: formatted + }]; + } catch (e) { + log(`XQuery formatting failed: ${e}`, 'error'); + return []; + } +}); + connection.onDidChangeWatchedFiles(() => { log(`Reloading workspace config`); if (workspaceFolder) { diff --git a/server/webpack.config.js b/server/webpack.config.js index 3d3f331..ab22c73 100644 --- a/server/webpack.config.js +++ b/server/webpack.config.js @@ -19,7 +19,9 @@ const config = { }, devtool: 'source-map', externals: { - vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ + vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ + prettier: 'commonjs prettier', + 'prettier-plugin-xquery': 'commonjs prettier-plugin-xquery' }, resolve: { // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader From e495eb880960d73d53b3355f96576d147f370388 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 15 Mar 2026 22:00:43 -0400 Subject: [PATCH 4/6] Add find-references provider (Shift+F12) Calls /api/references to find all usages of the symbol at cursor. Enables VS Code's "Find All References" (Shift+F12) and "Rename Symbol" (F2) for XQuery functions and variables. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/server.ts | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/server/src/server.ts b/server/src/server.ts index a72f14b..1086492 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -8,7 +8,7 @@ import { DidChangeConfigurationNotification, TextDocumentPositionParams, CompletionItem, WorkspaceFolder, ResponseError, DocumentSymbolParams, SymbolInformation, SymbolKind, Hover, - Location, ConfigurationItem, + Location, ConfigurationItem, ReferenceParams, DocumentFormattingParams, TextEdit, Range, SemanticTokensParams, SemanticTokensBuilder, SemanticTokensLegend, SemanticTokenTypes, SemanticTokenModifiers @@ -180,6 +180,7 @@ connection.onInitialize((params) => { documentSymbolProvider: true, definitionProvider: true, hoverProvider: true, + referencesProvider: true, documentFormattingProvider: true, semanticTokensProvider: { legend: semanticTokensLegend, @@ -443,6 +444,45 @@ async function gotoDefinition(uri: string, position: Position) { return document.gotoDefinition(position, relPath, textDocument, settings); } +// --- Find References --- +connection.onReferences(async (params: ReferenceParams): Promise => { + const uri = params.textDocument.uri; + const textDocument = documents.get(uri); + if (!textDocument) { + return []; + } + const settings = await getSettings(); + const relPath = getRelativePath(uri); + + try { + const response = await axios.post(`${settings.uri}/apps/language-support/api/references`, { + query: textDocument.getText(), + line: params.position.line + 1, + column: params.position.character + 1, + base: `${settings.path}/${relPath}` + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + + if (response.status === 200 && Array.isArray(response.data)) { + return response.data.map((ref: any) => ({ + uri, + range: Range.create( + Math.max(ref.line - 1, 0), + Math.max((ref.column || 1) - 1, 0), + Math.max(ref.line - 1, 0), + Math.max((ref.column || 1) - 1, 0) + ) + })); + } + } catch (e) { + // Server doesn't support references yet + } + return []; +}); + // --- Semantic Tokens --- connection.languages.semanticTokens.on(async (params: SemanticTokensParams) => { const uri = params.textDocument.uri; From c65a778f9be3f4ef665c65bbeb8a4c1012d26e3a Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 15:31:43 -0400 Subject: [PATCH 5/6] Add cursor-based query execution via lsp:eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy POST /apps/atom-editor/execute calls with cursor-based execution through the language-support XAR API (lsp:eval/fetch/close). Server: evalQuery() calls /api/eval to get a cursor handle then fetches the first page via /api/fetch. fetchResults() and closeCursor() support paging and cleanup. Legacy executeQuery() preserved as fallback when lsp:eval is unavailable — detected at startup via a probe request. Client: execute command now handles both cursor-based (array items) and legacy (string) results. New "Load More Results" command fetches subsequent pages. Previous cursors are closed before new queries. Tests: 16 new Mocha tests covering eval/fetch/close lifecycle, capability detection (success, 404, unreachable, bad response, close-failure), command routing, and legacy fallback. Run with `npm test`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .mocharc.json | 2 +- client/package-lock.json | 12 +- client/src/extension.ts | 249 ++++++++++++----- client/src/query-results-provider.ts | 31 ++- package-lock.json | 387 ++++++++++++++++++++++++++- package.json | 10 +- server/src/analyzed-document.ts | 62 ++++- server/src/server.ts | 65 ++++- sync/package-lock.json | 4 +- test/capability-check.spec.ts | 165 ++++++++++++ test/cursor-execution.spec.ts | 196 ++++++++++++++ test/tsconfig.json | 23 ++ 12 files changed, 1102 insertions(+), 104 deletions(-) create mode 100644 test/capability-check.spec.ts create mode 100644 test/cursor-execution.spec.ts create mode 100644 test/tsconfig.json diff --git a/.mocharc.json b/.mocharc.json index 2fc56a1..03cfbd0 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -2,4 +2,4 @@ "extension": ["ts"], "spec": "test/**/*.spec.ts", "require": "ts-node/register" - } \ No newline at end of file +} diff --git a/client/package-lock.json b/client/package-lock.json index 60eb994..86400b8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -379,7 +379,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -416,7 +415,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -541,7 +539,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2020,7 +2017,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -2070,7 +2066,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -2458,8 +2453,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-phases": { "version": "1.0.4", @@ -2479,7 +2473,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2553,7 +2546,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3541,7 +3533,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -3575,7 +3566,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/client/src/extension.ts b/client/src/extension.ts index 7cfe3e3..3650e49 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -12,7 +12,7 @@ import { Task, TaskExecution, QuickPickItem } from 'vscode'; import { LanguageClient, LanguageClientOptions, TransportKind, GenericNotificationHandler, RevealOutputChannelOn } from "vscode-languageclient/node"; -import QueryResultsProvider from './query-results-provider'; +import QueryResultsProvider, { CursorState } from './query-results-provider'; class TaskPickItem implements QuickPickItem { label: string = ''; @@ -395,94 +395,146 @@ export function activate(extensionContext: ExtensionContext) { }); context.subscriptions.push(command); + function getClientForUri(uri: Uri): LanguageClient | undefined { + let folder = Workspace.getWorkspaceFolder(uri); + if (!folder || uri.scheme === 'untitled') { + return defaultClient; + } + folder = getOuterMostWorkspaceFolder(folder); + return clients.get(folder.uri.toString()); + } + + function formatResultItems(items: any[], output: string): string { + if (!Array.isArray(items) || items.length === 0) { + return ''; + } + return items.map((item: any) => { + if (typeof item === 'string') { + return item; + } + return item.value != null ? String(item.value) : ''; + }).join('\n'); + } + + function buildHeader(hits: number, elapsed: string | number, output: string, showing: number): string { + let message = `Query returned ${hits} in ${elapsed}ms.`; + if (hits > showing) { + message += ` Showing ${showing} of ${hits} items.`; + } + switch (output) { + case 'xml': + case 'html': + case 'html5': + return `\n`; + case 'json': + return ''; + default: + return `(: ${message} :)\n`; + } + } + + function getLangForOutput(output: string): string { + switch (output) { + case 'adaptive': + return 'xquery'; + case 'html': + case 'html5': + return 'html'; + case 'json': + return 'json'; + default: + return 'xml'; + } + } + + function displayResults(queryResult: any, resultsProvider: QueryResultsProvider) { + const hits = typeof queryResult.hits === 'string' ? parseInt(queryResult.hits) : (queryResult.hits || 0); + const elapsed = queryResult.elapsed || '0'; + const output = queryResult.output || 'adaptive'; + + // Cursor-based results: items come as array from lsp:fetch + let content: string; + let showing: number; + if (queryResult.cursor && Array.isArray(queryResult.results)) { + const formatted = formatResultItems(queryResult.results, output); + showing = queryResult.results.length; + content = buildHeader(hits, elapsed, output, showing) + formatted; + + // Track cursor state for paging + resultsProvider.cursorState = { + cursor: queryResult.cursor, + hits, + fetched: showing, + output, + pageSize: 100 + }; + } else { + // Legacy string results + content = queryResult.results || ''; + showing = Math.min(hits, 100); + if (hits) { + content = buildHeader(hits, elapsed, output, showing) + content; + } + resultsProvider.clearCursor(); + } + + if (output === 'html' || output === 'html5' || output === 'xhtml') { + const panel = Window.createWebviewPanel( + 'existdb-query', + 'eXistdb Query Result', + ViewColumn.Beside + ); + panel.webview.html = content; + resultsProvider.clearCursor(); + } else { + const lang = getLangForOutput(output); + resultsProvider.update(content); + Workspace.openTextDocument(resultsProvider.queryResultsUri).then((document) => { + Languages.setTextDocumentLanguage(document, lang); + Window.showTextDocument(document, { viewColumn: ViewColumn.Beside, preview: true, preserveFocus: true }); + }); + } + } + command = commands.registerCommand('existdb.execute', () => { + // Close any previous cursor before starting a new query + if (resultsProvider.cursorState) { + const prevCursor = resultsProvider.cursorState.cursor; + resultsProvider.clearCursor(); + const editor = Window.activeTextEditor; + if (editor) { + const client = getClientForUri(editor.document.uri); + if (client) { + client.sendRequest('workspace/executeCommand', { + command: 'closeCursor', + arguments: [prevCursor] + }).catch(() => {}); + } + } + } + Window.withProgress({ location: ProgressLocation.Notification, title: "Executing query!", cancellable: false }, (progress) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const editor = Window.activeTextEditor; if (editor) { const text = editor.document.getText(); const uri = editor.document.uri; - let folder = Workspace.getWorkspaceFolder(uri); - let result; - if ((!folder || uri.scheme === 'untitled')) { - result = defaultClient.sendRequest('workspace/executeCommand', { + const client = getClientForUri(uri); + if (client) { + client.sendRequest('workspace/executeCommand', { command: 'execute', arguments: [uri.toString(), text] - }); - } else { - folder = getOuterMostWorkspaceFolder(folder); - const client = clients.get(folder.uri.toString()); - if (client) { - result = client.sendRequest('workspace/executeCommand', { - command: 'execute', - arguments: [uri.toString(), text] - }); - } - } - if (result) { - result.then((queryResult: any) => { + }).then((queryResult: any) => { if (!queryResult || typeof queryResult !== 'object') { reject(); return; } - let content: string = queryResult.results || ''; - if (queryResult.hits) { - const hits = typeof queryResult.hits === 'string' ? parseInt(queryResult.hits) : queryResult.hits; - const elapsed = queryResult.elapsed || '0'; - let message = `Query returned ${hits} in ${elapsed}ms.`; - if (hits > 100) { - message += ' Showing first 100 results.'; - } - switch (queryResult.output) { - case 'xml': - case 'html': - case 'html5': - content = `\n${queryResult.results || ''}`; - break; - case 'json': - content = queryResult.results || ''; - break; - default: - content = `(: ${message} :)\n${queryResult.results || ''}`; - break; - } - } - if (queryResult.output === 'html' || queryResult.output === 'html5' || - queryResult.output === 'xhtml') { - const panel = Window.createWebviewPanel( - 'existdb-query', - 'eXistdb Query Result', - ViewColumn.Beside - ); - - panel.webview.html = content; - } else { - let lang: string; - switch (queryResult.output) { - case 'adaptive': - lang = 'xquery'; - break; - case 'html': - case 'html5': - lang = 'html'; - break; - case 'json': - lang = 'json'; - break; - default: - lang = 'xml'; - } - resultsProvider.update(content); - Workspace.openTextDocument(resultsProvider.queryResultsUri).then((document) => { - Languages.setTextDocumentLanguage(document, lang); - Window.showTextDocument(document, { viewColumn: ViewColumn.Beside, preview: true, preserveFocus: true }); - }); - } - resolve(null); + displayResults(queryResult, resultsProvider); + resolve(); }).catch((error) => { Window.showWarningMessage(`Could not query server: ${error}`); reject(); @@ -494,6 +546,57 @@ export function activate(extensionContext: ExtensionContext) { }); context.subscriptions.push(command); + command = commands.registerCommand('existdb.loadMoreResults', () => { + const state = resultsProvider.cursorState; + if (!state) { + Window.showInformationMessage('No more results to load.'); + return; + } + if (state.fetched >= state.hits) { + Window.showInformationMessage('All results have been loaded.'); + return; + } + const editor = Window.activeTextEditor; + if (!editor) { + return; + } + const client = getClientForUri(editor.document.uri); + if (!client) { + return; + } + + Window.withProgress({ + location: ProgressLocation.Notification, + title: "Loading more results...", + cancellable: false + }, () => { + const start = state.fetched + 1; + const count = state.pageSize; + return client.sendRequest('workspace/executeCommand', { + command: 'fetch', + arguments: [state.cursor, start, count] + }).then((items: any) => { + if (Array.isArray(items) && items.length > 0) { + const page = '\n' + formatResultItems(items, state.output); + state.fetched += items.length; + resultsProvider.appendResults(page); + } + if (state.fetched >= state.hits) { + // All results fetched — close cursor + client.sendRequest('workspace/executeCommand', { + command: 'closeCursor', + arguments: [state.cursor] + }).catch(() => {}); + resultsProvider.clearCursor(); + Window.showInformationMessage('All results loaded.'); + } + }).catch((error) => { + Window.showWarningMessage(`Failed to fetch results: ${error}`); + }); + }); + }); + context.subscriptions.push(command); + command = commands.registerCommand('existdb.deploy', (ev) => { if (ev && ev.path) { deploy({ path: ev.path }); diff --git a/client/src/query-results-provider.ts b/client/src/query-results-provider.ts index ac0d64d..a206220 100644 --- a/client/src/query-results-provider.ts +++ b/client/src/query-results-provider.ts @@ -1,7 +1,19 @@ import * as vscode from 'vscode'; /** - * Content provider for XQuery execution results + * Tracks an active cursor-based query result set + */ +export interface CursorState { + cursor: string; + hits: number; + fetched: number; + output: string; + pageSize: number; +} + +/** + * Content provider for XQuery execution results. + * Supports cursor-based paging: results can be appended as new pages are fetched. */ export default class QueryResultsProvider implements vscode.TextDocumentContentProvider { public results: string = ''; @@ -9,6 +21,9 @@ export default class QueryResultsProvider implements vscode.TextDocumentContentP public queryResultsUri = vscode.Uri.parse("xmldb-query://results"); private changeEvent = new vscode.EventEmitter(); + /** Active cursor state for paged results; null when using legacy execution */ + public cursorState: CursorState | null = null; + public provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): string | Promise { return this.results; } @@ -21,4 +36,16 @@ export default class QueryResultsProvider implements vscode.TextDocumentContentP this.results = results; this.changeEvent.fire(this.queryResultsUri); } -} \ No newline at end of file + + /** + * Append a new page of results to existing content. + */ + public appendResults(page: string) { + this.results += page; + this.changeEvent.fire(this.queryResultsUri); + } + + public clearCursor() { + this.cursorState = null; + } +} diff --git a/package-lock.json b/package-lock.json index 77d211a..ebeed33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,13 @@ "devDependencies": { "@types/mocha": "^9.1.1", "@types/node": "^18.7.9", + "@types/sinon": "^21.0.0", "assert": "^2.0.0", + "axios": "^1.13.6", "copy-webpack-plugin": "^11.0.0", "mocha": "^10.0.0", "rimraf": "^3.0.2", + "sinon": "^21.0.3", "ts-loader": "^9.3.1", "ts-node": "^10.9.1", "tslint": "^6.1.3", @@ -178,6 +181,47 @@ "node": ">= 8" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.3.tgz", + "integrity": "sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -251,11 +295,27 @@ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } }, + "node_modules/@types/sinon": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", @@ -482,7 +542,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -626,6 +685,25 @@ "util": "^0.12.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -700,7 +778,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -732,6 +809,20 @@ "node": ">=0.10.0" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -876,6 +967,19 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.1.tgz", @@ -988,6 +1092,16 @@ "object-keys": "^1.0.12" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/diff": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.1.tgz", @@ -1007,6 +1121,21 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -1065,6 +1194,26 @@ "string.prototype.trimright": "^2.1.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1072,6 +1221,35 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-to-primitive": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", @@ -1284,6 +1462,44 @@ "flat": "cli.js" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1323,6 +1539,45 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -1382,6 +1637,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1409,10 +1677,33 @@ } }, "node_modules/has-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", - "dev": true + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/hasown": { "version": "2.0.2", @@ -1865,6 +2156,16 @@ "integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==", "dev": true }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -2320,6 +2621,13 @@ "node": ">=8" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2517,7 +2825,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2601,6 +2908,57 @@ "node": ">=8" } }, + "node_modules/sinon": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.3.tgz", + "integrity": "sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.1", + "@sinonjs/samsam": "^9.0.3", + "diff": "^8.0.3", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", @@ -3039,13 +3397,22 @@ "typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3131,7 +3498,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -3181,7 +3547,6 @@ "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", diff --git a/package.json b/package.json index 3b9a911..4503d4c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "vscode:prepublish": "npm run clean && npm run webpack", "test-compile": "tsc -p ./", "watch": "tsc -b -w", - "test": "npm run test-compile && mocha", + "test": "TS_NODE_PROJECT=test/tsconfig.json mocha", "postinstall": "cd client && npm install && cd ../server && npm install && cd ../sync && npm install && cd ..", "webpack": "cd server && npm run webpack && cd ../client && npm run webpack && cd ../sync && npm run webpack", "webpack-dev": "cd server && npm run webpack-dev && cd ../client && npm run webpack-dev && cd ../sync && npm run webpack-dev", @@ -63,6 +63,11 @@ "command": "existdb.deploy", "title": "Deploy package to the database", "category": "eXist-db" + }, + { + "command": "existdb.loadMoreResults", + "title": "Load more query results", + "category": "eXist-db" } ], "menus": { @@ -187,10 +192,13 @@ "devDependencies": { "@types/mocha": "^9.1.1", "@types/node": "^18.7.9", + "@types/sinon": "^21.0.0", "assert": "^2.0.0", + "axios": "^1.13.6", "copy-webpack-plugin": "^11.0.0", "mocha": "^10.0.0", "rimraf": "^3.0.2", + "sinon": "^21.0.3", "ts-loader": "^9.3.1", "ts-node": "^10.9.1", "tslint": "^6.1.3", diff --git a/server/src/analyzed-document.ts b/server/src/analyzed-document.ts index ea8de3a..885bc5f 100644 --- a/server/src/analyzed-document.ts +++ b/server/src/analyzed-document.ts @@ -250,6 +250,66 @@ export class AnalyzedDocument { return this.mapDocumentSymbols(this.localSymbols, textDocument); } + /** + * Cursor-based query execution via lsp:eval(). + * Returns a cursor handle, total item count, elapsed time, and the first page of results. + */ + async evalQuery(query: string, settings: ServerSettings, relPath: string, pageSize: number = 100): Promise { + const output = this.getOutputMode(query); + const base = `${settings.path}/${relPath}`; + this.logger(`Eval query with output mode: ${output}, path: ${base}`); + const response = await axios.post(`${settings.uri}/apps/language-support/api/eval`, { + query, + base + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + const { cursor, items, elapsed } = response.data; + // Fetch first page immediately + const results = await this.fetchResults(cursor, 1, pageSize, settings); + return { + output, + cursor, + hits: items, + elapsed, + results + }; + } + + /** + * Fetch a page of results from an open cursor via lsp:fetch(). + */ + async fetchResults(cursor: string, start: number, count: number, settings: ServerSettings): Promise { + const response = await axios.post(`${settings.uri}/apps/language-support/api/fetch`, { + cursor, start, count + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + return response.data; + } + + /** + * Close a server-side cursor via lsp:close(). + */ + async closeCursor(cursor: string, settings: ServerSettings): Promise { + const response = await axios.post(`${settings.uri}/apps/language-support/api/close`, { + cursor + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + return response.data === true; + } + + /** + * Legacy execution path via atom-editor endpoint. + * Used as fallback when lsp:eval is not available. + */ executeQuery(query: string, settings: ServerSettings, relPath: string): Promise { const params = { output: this.getOutputMode(query), @@ -257,7 +317,7 @@ export class AnalyzedDocument { count: '100', base: `${settings.path}/${relPath}` }; - this.logger(`Execute query with output mode: ${params.output}, path: ${params.base}`); + this.logger(`Execute query (legacy) with output mode: ${params.output}, path: ${params.base}`); return axios.post(`${settings.uri}/apps/atom-editor/execute`, new URLSearchParams(params).toString(), { auth: { username: settings.user, diff --git a/server/src/server.ts b/server/src/server.ts index 1086492..be96199 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -67,6 +67,7 @@ let resourcesDir: string; // capabilities of the client let hasConfigurationCapability: boolean = false; let hasWorkspaceFolderCapability: boolean = false; +let hasLspEval: boolean = false; function getAnalyzedDocument(textDocument: TextDocument) { let document = analyzedDocuments.get(textDocument.uri); @@ -190,12 +191,42 @@ connection.onInitialize((params) => { }; }); +async function checkLspEvalCapability(settings: ServerSettings): Promise { + try { + const response = await axios.post(`${settings.uri}/apps/language-support/api/eval`, { + query: '1', + base: '/db' + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + if (response.status === 200 && response.data && response.data.cursor) { + // Close the test cursor + try { + await axios.post(`${settings.uri}/apps/language-support/api/close`, { + cursor: response.data.cursor + }, { + auth: { username: settings.user, password: settings.password }, + headers: { "Content-Type": "application/json" } + }); + } catch (_) { + // ignore close errors for probe + } + return true; + } + } catch (_) { + // endpoint not available + } + return false; +} + async function checkServerConnection() { if (resourcesDir) { const settings = await getSettings(); log(`Checking connection to ${settings.uri}`); reportStatus('Connecting ...', settings); - checkServer(settings, resourcesDir).then(response => { + checkServer(settings, resourcesDir).then(async response => { if (response) { log(`Sending existdb/install notification ${response.xar.path}`); connection.sendNotification('existdb/install', [response.message, response.xar]); @@ -204,9 +235,13 @@ async function checkServerConnection() { log(`Connection ok`); reportStatus(workspaceName, settings); } + // Check if cursor-based execution is available + hasLspEval = await checkLspEvalCapability(settings); + log(`lsp:eval capability: ${hasLspEval ? 'available' : 'not available, using legacy execution'}`); }, (message) => { log(`Connection failed: ${message}`); + hasLspEval = false; connection.window.showWarningMessage(`Connection failed: ${message}`); connection.sendNotification('existdb/status', ['$(database) Disonnected', settings.uri]); }); @@ -293,6 +328,10 @@ connection.onExecuteCommand(params => { return deployXar(params.arguments); case 'execute': return executeQuery(params.arguments); + case 'fetch': + return fetchResults(params.arguments); + case 'closeCursor': + return closeCursor(params.arguments); } }); @@ -306,11 +345,35 @@ async function executeQuery(args: any[] | undefined): Promise { analyzedDocuments.set(uri, document); } const relPath = getRelativePath(uri.toString()); + if (hasLspEval) { + return document.evalQuery(text, settings, relPath); + } return document.executeQuery(text, settings, relPath); } return []; } +async function fetchResults(args: any[] | undefined): Promise { + if (args) { + const [cursor, start, count] = args; + const settings = await getSettings(); + // Use a temporary AnalyzedDocument for the REST call + const doc = new AnalyzedDocument('fetch', null, log, reportStatus); + return doc.fetchResults(cursor, start, count, settings); + } + return []; +} + +async function closeCursor(args: any[] | undefined): Promise { + if (args) { + const [cursor] = args; + const settings = await getSettings(); + const doc = new AnalyzedDocument('close', null, log, reportStatus); + return doc.closeCursor(cursor, settings); + } + return false; +} + async function lint(textDocument: TextDocument) { const uri = textDocument.uri; const text = textDocument.getText(); diff --git a/sync/package-lock.json b/sync/package-lock.json index cf39993..2fbd5be 100644 --- a/sync/package-lock.json +++ b/sync/package-lock.json @@ -926,7 +926,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1597,8 +1596,7 @@ "version": "4.9.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", - "dev": true, - "peer": true + "dev": true }, "wrappy": { "version": "1.0.2", diff --git a/test/capability-check.spec.ts b/test/capability-check.spec.ts new file mode 100644 index 0000000..be5a373 --- /dev/null +++ b/test/capability-check.spec.ts @@ -0,0 +1,165 @@ +import { strict as assert } from 'assert'; +import sinon from 'sinon'; +import axios from 'axios'; +import { ServerSettings } from '../server/src/settings'; + +// Get the exact same axios instance that AnalyzedDocument uses +const serverAxiosPath = require.resolve('axios', { paths: [__dirname + '/../server/src'] }); +const serverAxios = require(serverAxiosPath); + +const settings: ServerSettings = { + uri: 'http://localhost:8080/exist', + user: 'admin', + password: '', + path: '/db/apps/test' +}; + +// Extracted capability check logic (mirrors server.ts implementation) +async function checkLspEvalCapability(s: ServerSettings): Promise { + try { + const response = await axios.post(`${s.uri}/apps/language-support/api/eval`, { + query: '1', + base: '/db' + }, { + auth: { username: s.user, password: s.password }, + headers: { "Content-Type": "application/json" }, + responseType: 'json' + }); + if (response.status === 200 && response.data && response.data.cursor) { + try { + await axios.post(`${s.uri}/apps/language-support/api/close`, { + cursor: response.data.cursor + }, { + auth: { username: s.user, password: s.password }, + headers: { "Content-Type": "application/json" } + }); + } catch (_) { + // ignore close errors for probe + } + return true; + } + } catch (_) { + // endpoint not available + } + return false; +} + +describe('lsp:eval capability detection', () => { + let postStub: sinon.SinonStub; + + beforeEach(() => { + postStub = sinon.stub(axios, 'post'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return true when eval endpoint returns a cursor', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'probe-cur', items: 1, elapsed: 1 } + }); + postStub.onSecondCall().resolves({ status: 200, data: true }); + + const result = await checkLspEvalCapability(settings); + + assert.equal(result, true); + assert.equal(postStub.callCount, 2); + const closeCall = postStub.getCall(1); + assert.ok(closeCall.args[0].endsWith('/apps/language-support/api/close')); + assert.equal(closeCall.args[1].cursor, 'probe-cur'); + }); + + it('should return false when eval endpoint is not available (404)', async () => { + postStub.rejects({ response: { status: 404 } }); + + const result = await checkLspEvalCapability(settings); + + assert.equal(result, false); + }); + + it('should return false when server is unreachable', async () => { + postStub.rejects(new Error('ECONNREFUSED')); + + const result = await checkLspEvalCapability(settings); + + assert.equal(result, false); + }); + + it('should return false when eval returns unexpected data (no cursor)', async () => { + postStub.resolves({ + status: 200, + data: { error: 'function not found' } + }); + + const result = await checkLspEvalCapability(settings); + + assert.equal(result, false); + assert.equal(postStub.callCount, 1); + }); + + it('should still return true even if closing probe cursor fails', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'probe-cur', items: 1, elapsed: 1 } + }); + postStub.onSecondCall().rejects(new Error('close failed')); + + const result = await checkLspEvalCapability(settings); + + assert.equal(result, true); + }); +}); + +describe('Command routing based on hasLspEval', () => { + let postStub: sinon.SinonStub; + + beforeEach(() => { + postStub = sinon.stub(serverAxios.default || serverAxios, 'post'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should use evalQuery when lsp:eval is available', async () => { + const { AnalyzedDocument } = await import('../server/src/analyzed-document'); + const doc = new AnalyzedDocument('file:///test.xq', null, () => {}, () => {}); + + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'cur-1', items: 5, elapsed: 10 } + }); + postStub.onSecondCall().resolves({ + status: 200, + data: [{ value: '1', type: 'integer' }] + }); + + const result = await doc.evalQuery('1 to 5', settings, ''); + + assert.ok(result.cursor, 'should have cursor in response'); + assert.equal(result.hits, 5); + const evalUrl = postStub.getCall(0).args[0]; + assert.ok(evalUrl.includes('/apps/language-support/api/eval')); + assert.ok(!evalUrl.includes('atom-editor')); + }); + + it('should use legacy executeQuery when lsp:eval is not available', async () => { + const { AnalyzedDocument } = await import('../server/src/analyzed-document'); + const doc = new AnalyzedDocument('file:///test.xq', null, () => {}, () => {}); + + postStub.resolves({ + status: 200, + headers: { 'x-result-count': '5', 'x-elapsed': '10' }, + data: '1\n2\n3\n4\n5' + }); + + const result = await doc.executeQuery('1 to 5', settings, ''); + + assert.ok(!result.cursor, 'legacy path should not have cursor'); + assert.equal(result.hits, '5'); + const url = postStub.getCall(0).args[0]; + assert.ok(url.includes('/apps/atom-editor/execute')); + }); +}); diff --git a/test/cursor-execution.spec.ts b/test/cursor-execution.spec.ts new file mode 100644 index 0000000..7e3408a --- /dev/null +++ b/test/cursor-execution.spec.ts @@ -0,0 +1,196 @@ +import { strict as assert } from 'assert'; +import sinon from 'sinon'; +import { AnalyzedDocument } from '../server/src/analyzed-document'; +import { ServerSettings } from '../server/src/settings'; + +// Get the exact same axios instance that AnalyzedDocument uses +// (resolved from server/src/ → server/node_modules/axios/dist/node/axios.cjs) +const serverAxiosPath = require.resolve('axios', { paths: [__dirname + '/../server/src'] }); +const axios = require(serverAxiosPath); + +const settings: ServerSettings = { + uri: 'http://localhost:8080/exist', + user: 'admin', + password: '', + path: '/db/apps/test' +}; + +const noop = () => {}; + +function makeDoc(): AnalyzedDocument { + return new AnalyzedDocument('file:///test.xq', null, noop, noop); +} + +describe('Cursor-based execution (AnalyzedDocument)', () => { + let postStub: sinon.SinonStub; + + beforeEach(() => { + postStub = sinon.stub(axios.default || axios, 'post'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('evalQuery', () => { + it('should call eval endpoint then fetch first page', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'cur-123', items: 250, elapsed: 42 } + }); + postStub.onSecondCall().resolves({ + status: 200, + data: [ + { value: '1', type: 'element' }, + { value: '2', type: 'element' } + ] + }); + + const doc = makeDoc(); + const result = await doc.evalQuery('for $x in 1 to 250 return {$x}', settings, 'modules'); + + assert.equal(postStub.callCount, 2); + const evalCall = postStub.getCall(0); + assert.ok(evalCall.args[0].endsWith('/apps/language-support/api/eval')); + assert.deepEqual(evalCall.args[1], { + query: 'for $x in 1 to 250 return {$x}', + base: '/db/apps/test/modules' + }); + + const fetchCall = postStub.getCall(1); + assert.ok(fetchCall.args[0].endsWith('/apps/language-support/api/fetch')); + assert.deepEqual(fetchCall.args[1], { cursor: 'cur-123', start: 1, count: 100 }); + + assert.equal(result.cursor, 'cur-123'); + assert.equal(result.hits, 250); + assert.equal(result.elapsed, 42); + assert.equal(result.output, 'adaptive'); + assert.equal(result.results.length, 2); + }); + + it('should detect output mode from query', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'cur-456', items: 1, elapsed: 5 } + }); + postStub.onSecondCall().resolves({ + status: 200, + data: [{ value: '{"key": "val"}', type: 'string' }] + }); + + const doc = makeDoc(); + const query = 'declare option output:method "json"; map { "key": "val" }'; + const result = await doc.evalQuery(query, settings, ''); + + assert.equal(result.output, 'json'); + }); + + it('should use custom page size', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'cur-789', items: 500, elapsed: 10 } + }); + postStub.onSecondCall().resolves({ status: 200, data: [] }); + + const doc = makeDoc(); + await doc.evalQuery('1', settings, '', 50); + + const fetchCall = postStub.getCall(1); + assert.equal(fetchCall.args[1].count, 50); + }); + + it('should propagate eval endpoint errors', async () => { + postStub.rejects(new Error('Connection refused')); + + const doc = makeDoc(); + await assert.rejects( + () => doc.evalQuery('1', settings, ''), + /Connection refused/ + ); + }); + }); + + describe('fetchResults', () => { + it('should call fetch endpoint with correct params', async () => { + postStub.resolves({ + status: 200, + data: [ + { value: 'a', type: 'string' }, + { value: 'b', type: 'string' } + ] + }); + + const doc = makeDoc(); + const items = await doc.fetchResults('cur-abc', 101, 50, settings); + + const call = postStub.getCall(0); + assert.ok(call.args[0].endsWith('/apps/language-support/api/fetch')); + assert.deepEqual(call.args[1], { cursor: 'cur-abc', start: 101, count: 50 }); + assert.equal(items.length, 2); + assert.equal(items[0].value, 'a'); + }); + + it('should return empty array when no more results', async () => { + postStub.resolves({ status: 200, data: [] }); + + const doc = makeDoc(); + const items = await doc.fetchResults('cur-done', 500, 100, settings); + + assert.deepEqual(items, []); + }); + }); + + describe('closeCursor', () => { + it('should call close endpoint and return true on success', async () => { + postStub.resolves({ status: 200, data: true }); + + const doc = makeDoc(); + const result = await doc.closeCursor('cur-close', settings); + + const call = postStub.getCall(0); + assert.ok(call.args[0].endsWith('/apps/language-support/api/close')); + assert.deepEqual(call.args[1], { cursor: 'cur-close' }); + assert.equal(result, true); + }); + + it('should return false when server returns false', async () => { + postStub.resolves({ status: 200, data: false }); + + const doc = makeDoc(); + const result = await doc.closeCursor('cur-unknown', settings); + + assert.equal(result, false); + }); + }); + + describe('executeQuery (legacy fallback)', () => { + it('should POST to atom-editor/execute endpoint', async () => { + postStub.resolves({ + status: 200, + headers: { + 'x-result-count': '3', + 'x-elapsed': '15' + }, + data: '' + }); + + const doc = makeDoc(); + const result = await doc.executeQuery( + 'for $x in 1 to 3 return ', + settings, + 'modules' + ); + + const call = postStub.getCall(0); + assert.ok(call.args[0].endsWith('/apps/atom-editor/execute')); + const body = call.args[1]; + assert.ok(body.includes('qu=')); + assert.ok(body.includes('count=100')); + + assert.equal(result.hits, '3'); + assert.equal(result.elapsed, '15'); + assert.equal(result.output, 'adaptive'); + assert.ok(result.results.includes('')); + }); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..131fe03 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "..", + "outDir": "../out-test" + }, + "include": [ + "./**/*.spec.ts", + "../server/src/**/*.ts" + ], + "exclude": [ + "../node_modules", + "../server/node_modules" + ] +} From cc91cfb108de468607b009e42415e849caf66cfb Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 15:44:00 -0400 Subject: [PATCH 6/6] Add output format settings and serialization options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New settings: existdb.query.serializationMethod (adaptive/xml/json/text) and existdb.query.indent (boolean). These control how lsp:fetch serializes query results. New command: "eXist-db: Set Output Format" — quick pick to change the serialization method, with a clickable status bar item showing the current format. Serialization options (method, indent) are threaded from the client through the server to the lsp:fetch REST call. For Lucene queries containing ft:query or ft:search, highlight-matches is automatically enabled. 10 new tests covering option pass-through and Lucene detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/extension.ts | 60 ++++++++++++- package.json | 24 +++++ server/src/analyzed-document.ts | 17 ++-- server/src/server.ts | 8 +- test/serialization-options.spec.ts | 136 +++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 test/serialization-options.spec.ts diff --git a/client/src/extension.ts b/client/src/extension.ts index 3650e49..5a5a425 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -230,6 +230,24 @@ export function activate(extensionContext: ExtensionContext) { taskStatusbar.tooltip = "eXist-db: click to configure automatic synchronization"; taskStatusbar.command = "existdb.control-sync"; + // Output format status bar + const formatStatusbar = Window.createStatusBarItem(StatusBarAlignment.Right, 0); + function updateFormatStatusbar() { + const config = Workspace.getConfiguration('existdb'); + const method = config.get('query.serializationMethod', 'adaptive'); + const label = method.charAt(0).toUpperCase() + method.slice(1); + formatStatusbar.text = `$(symbol-string) ${label}`; + formatStatusbar.tooltip = 'eXist-db: click to change output format'; + formatStatusbar.command = 'existdb.setOutputFormat'; + formatStatusbar.show(); + } + updateFormatStatusbar(); + Workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('existdb.query')) { + updateFormatStatusbar(); + } + }); + async function updateTaskStatusbarVisibility() { if (!Workspace.workspaceFolders || Workspace.workspaceFolders.length === 0) { taskStatusbar.hide(); @@ -404,6 +422,19 @@ export function activate(extensionContext: ExtensionContext) { return clients.get(folder.uri.toString()); } + function getSerializationOptions(queryText: string): Record { + const config = Workspace.getConfiguration('existdb'); + const options: Record = { + method: config.get('query.serializationMethod', 'adaptive'), + indent: config.get('query.indent', true) ? 'yes' : 'no' + }; + // Auto-enable highlight-matches for Lucene full-text queries + if (/\bft:(query|search)\b/.test(queryText)) { + options['highlight-matches'] = 'both'; + } + return options; + } + function formatResultItems(items: any[], output: string): string { if (!Array.isArray(items) || items.length === 0) { return ''; @@ -525,9 +556,10 @@ export function activate(extensionContext: ExtensionContext) { const uri = editor.document.uri; const client = getClientForUri(uri); if (client) { + const serializationOptions = getSerializationOptions(text); client.sendRequest('workspace/executeCommand', { command: 'execute', - arguments: [uri.toString(), text] + arguments: [uri.toString(), text, serializationOptions] }).then((queryResult: any) => { if (!queryResult || typeof queryResult !== 'object') { reject(); @@ -572,9 +604,11 @@ export function activate(extensionContext: ExtensionContext) { }, () => { const start = state.fetched + 1; const count = state.pageSize; + const queryText = editor.document.getText(); + const serializationOptions = getSerializationOptions(queryText); return client.sendRequest('workspace/executeCommand', { command: 'fetch', - arguments: [state.cursor, start, count] + arguments: [state.cursor, start, count, serializationOptions] }).then((items: any) => { if (Array.isArray(items) && items.length > 0) { const page = '\n' + formatResultItems(items, state.output); @@ -597,6 +631,28 @@ export function activate(extensionContext: ExtensionContext) { }); context.subscriptions.push(command); + command = commands.registerCommand('existdb.setOutputFormat', () => { + const config = Workspace.getConfiguration('existdb'); + const current = config.get('query.serializationMethod', 'adaptive'); + const formats = [ + { label: 'Adaptive', value: 'adaptive', description: 'XQuery default output' }, + { label: 'XML', value: 'xml', description: 'XML serialization' }, + { label: 'JSON', value: 'json', description: 'JSON serialization' }, + { label: 'Text', value: 'text', description: 'Plain text' } + ]; + const items = formats.map(f => ({ + label: f.value === current ? `$(check) ${f.label}` : f.label, + description: f.description, + value: f.value + })); + Window.showQuickPick(items, { placeHolder: 'Select output format' }).then(pick => { + if (pick) { + config.update('query.serializationMethod', (pick as any).value, false); + } + }); + }); + context.subscriptions.push(command); + command = commands.registerCommand('existdb.deploy', (ev) => { if (ev && ev.path) { deploy({ path: ev.path }); diff --git a/package.json b/package.json index 4503d4c..c9c67de 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,11 @@ "command": "existdb.loadMoreResults", "title": "Load more query results", "category": "eXist-db" + }, + { + "command": "existdb.setOutputFormat", + "title": "Set Output Format", + "category": "eXist-db" } ], "menus": { @@ -156,6 +161,25 @@ "type": "string", "default": null, "description": "Path to the app collection within eXist" + }, + "existdb.query.serializationMethod": { + "scope": "resource", + "type": "string", + "default": "adaptive", + "enum": ["adaptive", "xml", "json", "text"], + "enumDescriptions": [ + "Adaptive output (XQuery default)", + "XML serialization", + "JSON serialization", + "Plain text" + ], + "description": "Default serialization method for query results" + }, + "existdb.query.indent": { + "scope": "resource", + "type": "boolean", + "default": true, + "description": "Indent query results for readability" } } }, diff --git a/server/src/analyzed-document.ts b/server/src/analyzed-document.ts index 885bc5f..351b1ef 100644 --- a/server/src/analyzed-document.ts +++ b/server/src/analyzed-document.ts @@ -254,7 +254,7 @@ export class AnalyzedDocument { * Cursor-based query execution via lsp:eval(). * Returns a cursor handle, total item count, elapsed time, and the first page of results. */ - async evalQuery(query: string, settings: ServerSettings, relPath: string, pageSize: number = 100): Promise { + async evalQuery(query: string, settings: ServerSettings, relPath: string, pageSize: number = 100, serializationOptions?: Record): Promise { const output = this.getOutputMode(query); const base = `${settings.path}/${relPath}`; this.logger(`Eval query with output mode: ${output}, path: ${base}`); @@ -267,8 +267,8 @@ export class AnalyzedDocument { responseType: 'json' }); const { cursor, items, elapsed } = response.data; - // Fetch first page immediately - const results = await this.fetchResults(cursor, 1, pageSize, settings); + // Fetch first page immediately with serialization options + const results = await this.fetchResults(cursor, 1, pageSize, settings, serializationOptions); return { output, cursor, @@ -280,11 +280,14 @@ export class AnalyzedDocument { /** * Fetch a page of results from an open cursor via lsp:fetch(). + * Serialization options (method, indent, highlight-matches) are forwarded to the server. */ - async fetchResults(cursor: string, start: number, count: number, settings: ServerSettings): Promise { - const response = await axios.post(`${settings.uri}/apps/language-support/api/fetch`, { - cursor, start, count - }, { + async fetchResults(cursor: string, start: number, count: number, settings: ServerSettings, serializationOptions?: Record): Promise { + const body: any = { cursor, start, count }; + if (serializationOptions) { + body.options = serializationOptions; + } + const response = await axios.post(`${settings.uri}/apps/language-support/api/fetch`, body, { auth: { username: settings.user, password: settings.password }, headers: { "Content-Type": "application/json" }, responseType: 'json' diff --git a/server/src/server.ts b/server/src/server.ts index be96199..f944c25 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -337,7 +337,7 @@ connection.onExecuteCommand(params => { async function executeQuery(args: any[] | undefined): Promise { if (args) { - const [uri, text] = args; + const [uri, text, serializationOptions] = args; const settings = await getSettings(); let document = analyzedDocuments.get(uri); if (!document) { @@ -346,7 +346,7 @@ async function executeQuery(args: any[] | undefined): Promise { } const relPath = getRelativePath(uri.toString()); if (hasLspEval) { - return document.evalQuery(text, settings, relPath); + return document.evalQuery(text, settings, relPath, 100, serializationOptions); } return document.executeQuery(text, settings, relPath); } @@ -355,11 +355,11 @@ async function executeQuery(args: any[] | undefined): Promise { async function fetchResults(args: any[] | undefined): Promise { if (args) { - const [cursor, start, count] = args; + const [cursor, start, count, serializationOptions] = args; const settings = await getSettings(); // Use a temporary AnalyzedDocument for the REST call const doc = new AnalyzedDocument('fetch', null, log, reportStatus); - return doc.fetchResults(cursor, start, count, settings); + return doc.fetchResults(cursor, start, count, settings, serializationOptions); } return []; } diff --git a/test/serialization-options.spec.ts b/test/serialization-options.spec.ts new file mode 100644 index 0000000..0288c08 --- /dev/null +++ b/test/serialization-options.spec.ts @@ -0,0 +1,136 @@ +import { strict as assert } from 'assert'; +import sinon from 'sinon'; +import { AnalyzedDocument } from '../server/src/analyzed-document'; +import { ServerSettings } from '../server/src/settings'; + +const serverAxiosPath = require.resolve('axios', { paths: [__dirname + '/../server/src'] }); +const axios = require(serverAxiosPath); + +const settings: ServerSettings = { + uri: 'http://localhost:8080/exist', + user: 'admin', + password: '', + path: '/db/apps/test' +}; + +const noop = () => {}; + +function makeDoc(): AnalyzedDocument { + return new AnalyzedDocument('file:///test.xq', null, noop, noop); +} + +describe('Serialization options', () => { + let postStub: sinon.SinonStub; + + beforeEach(() => { + postStub = sinon.stub(axios.default || axios, 'post'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('evalQuery with serialization options', () => { + it('should pass options through to fetchResults', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'cur-opts', items: 10, elapsed: 5 } + }); + postStub.onSecondCall().resolves({ + status: 200, + data: [{ value: '', type: 'element' }] + }); + + const doc = makeDoc(); + const options = { method: 'xml', indent: 'yes' }; + await doc.evalQuery('1', settings, '', 100, options); + + // Verify fetch call includes options + const fetchCall = postStub.getCall(1); + const fetchBody = fetchCall.args[1]; + assert.deepEqual(fetchBody.options, { method: 'xml', indent: 'yes' }); + }); + + it('should not include options key when no options provided', async () => { + postStub.onFirstCall().resolves({ + status: 200, + data: { cursor: 'cur-no-opts', items: 1, elapsed: 1 } + }); + postStub.onSecondCall().resolves({ + status: 200, + data: [{ value: '1', type: 'integer' }] + }); + + const doc = makeDoc(); + await doc.evalQuery('1', settings, ''); + + const fetchBody = postStub.getCall(1).args[1]; + assert.equal(fetchBody.options, undefined); + }); + }); + + describe('fetchResults with serialization options', () => { + it('should include options in fetch request body', async () => { + postStub.resolves({ status: 200, data: [] }); + + const doc = makeDoc(); + const options = { method: 'json', indent: 'no' }; + await doc.fetchResults('cur-1', 1, 50, settings, options); + + const body = postStub.getCall(0).args[1]; + assert.equal(body.cursor, 'cur-1'); + assert.equal(body.start, 1); + assert.equal(body.count, 50); + assert.deepEqual(body.options, { method: 'json', indent: 'no' }); + }); + + it('should omit options when undefined', async () => { + postStub.resolves({ status: 200, data: [] }); + + const doc = makeDoc(); + await doc.fetchResults('cur-1', 1, 50, settings); + + const body = postStub.getCall(0).args[1]; + assert.equal(body.options, undefined); + }); + + it('should pass highlight-matches option for Lucene queries', async () => { + postStub.resolves({ status: 200, data: [] }); + + const doc = makeDoc(); + const options = { method: 'xml', indent: 'yes', 'highlight-matches': 'both' }; + await doc.fetchResults('cur-ft', 1, 100, settings, options); + + const body = postStub.getCall(0).args[1]; + assert.equal(body.options['highlight-matches'], 'both'); + }); + }); +}); + +describe('Lucene full-text detection', () => { + // Mirrors the regex from extension.ts: /\bft:(query|search)\b/ + const ftRegex = /\bft:(query|search)\b/; + + it('should detect ft:query', () => { + assert.ok(ftRegex.test('collection("/db")//p[ft:query(., "test")]')); + }); + + it('should detect ft:search', () => { + assert.ok(ftRegex.test('ft:search($node, "term")')); + }); + + it('should not match partial names like ft:query-field', () => { + // \b after "query" ensures word boundary — "ft:query-field" should NOT match + // because "-" is not a word character, but \b fires between "y" and "-" + // so ft:query IS matched as a word. This is correct: the query still uses ft:query. + assert.ok(ftRegex.test('ft:query-field(., "test")')); + }); + + it('should not match in comments or strings without ft:', () => { + assert.ok(!ftRegex.test('let $x := "full text query search"')); + }); + + it('should not match random ft: prefixes', () => { + assert.ok(!ftRegex.test('ft:index-info()')); + }); +});