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..5a5a425 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 = ''; @@ -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(); @@ -395,94 +413,160 @@ 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 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 ''; + } + 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) { + const serializationOptions = getSerializationOptions(text); + 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) => { + arguments: [uri.toString(), text, serializationOptions] + }).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 +578,81 @@ 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; + const queryText = editor.document.getText(); + const serializationOptions = getSerializationOptions(queryText); + return client.sendRequest('workspace/executeCommand', { + command: 'fetch', + arguments: [state.cursor, start, count, serializationOptions] + }).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.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/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..c9c67de 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,16 @@ "command": "existdb.deploy", "title": "Deploy package to the database", "category": "eXist-db" + }, + { + "command": "existdb.loadMoreResults", + "title": "Load more query results", + "category": "eXist-db" + }, + { + "command": "existdb.setOutputFormat", + "title": "Set Output Format", + "category": "eXist-db" } ], "menus": { @@ -151,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" } } }, @@ -187,10 +216,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/resources/atom-editor-1.1.0.xar b/resources/atom-editor-1.1.0.xar deleted file mode 100644 index d7341e8..0000000 Binary files a/resources/atom-editor-1.1.0.xar and /dev/null differ diff --git a/resources/language-support-1.0.0.xar b/resources/language-support-1.0.0.xar new file mode 100644 index 0000000..533f7c8 Binary files /dev/null and b/resources/language-support-1.0.0.xar differ 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/analyzed-document.ts b/server/src/analyzed-document.ts index 2848a33..351b1ef 100644 --- a/server/src/analyzed-document.ts +++ b/server/src/analyzed-document.ts @@ -3,21 +3,11 @@ 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 +41,6 @@ export class AnalyzedDocument { symbolsMap: Map = new Map(); - imports: Map = new Map(); - ast: any; logger: (message: string, prio?: string) => void; @@ -73,13 +61,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 +76,75 @@ 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); + + // 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: targetUri, + 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 +159,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 +206,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); @@ -294,6 +250,69 @@ 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, serializationOptions?: Record): 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 with serialization options + const results = await this.fetchResults(cursor, 1, pageSize, settings, serializationOptions); + return { + output, + cursor, + hits: items, + elapsed, + results + }; + } + + /** + * 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, 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' + }); + 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), @@ -301,7 +320,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, @@ -334,13 +353,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 +408,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 +517,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 +526,4 @@ export class AnalyzedDocument { } } } -} \ No newline at end of file +} diff --git a/server/src/linting.ts b/server/src/linting.ts index ca9d55d..8e8e44e 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,48 +65,29 @@ 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[] { +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; -} \ No newline at end of file +} diff --git a/server/src/server.ts b/server/src/server.ts index d9829db..f944c25 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, ReferenceParams, + 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', @@ -45,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); @@ -157,17 +180,53 @@ connection.onInitialize((params) => { }, documentSymbolProvider: true, definitionProvider: true, - hoverProvider: true + hoverProvider: true, + referencesProvider: true, + documentFormattingProvider: true, + semanticTokensProvider: { + legend: semanticTokensLegend, + full: true + } } }; }); +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]); @@ -176,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]); }); @@ -265,12 +328,16 @@ 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); } }); 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) { @@ -278,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, 100, serializationOptions); + } return document.executeQuery(text, settings, relPath); } return []; } +async function fetchResults(args: any[] | undefined): Promise { + if (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, serializationOptions); + } + 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(); @@ -334,7 +425,7 @@ async function autocomplete(position: TextDocumentPositionParams): Promise { 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); }); @@ -370,7 +489,7 @@ async function hover(uri: string, position: Position) { const document = getAnalyzedDocument(textDocument); const relPath = getRelativePath(uri); const settings = await getSettings(); - return document.getHover(position, relPath, settings); + return document.getHover(position, relPath, textDocument, settings); } connection.onDefinition((params: TextDocumentPositionParams): Promise => { @@ -388,6 +507,138 @@ 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; + 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/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 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 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/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()')); + }); +}); 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" + ] +}