diff --git a/package.json b/package.json index 1820cd40..39c69050 100644 --- a/package.json +++ b/package.json @@ -385,6 +385,11 @@ "command": "kdb.execute.block", "title": "KX: Execute Current q Block", "icon": "$(run-above)" + }, + { + "category": "KX", + "command": "kdb.toggleParameterCache", + "title": "KX: Toggle parameter cache" } ], "keybindings": [ @@ -422,6 +427,12 @@ "key": "ctrl+shift+e", "mac": "cmd+shift+e", "when": "editorLangId == q && !(resourceFilename =~ /.kdb.q/)" + }, + { + "command": "kdb.toggleParameterCache", + "key": "ctrl+shift+y", + "mac": "cmd+shift+y", + "when": "editorLangId == q && !(resourceFilename =~ /.kdb.q/)" } ], "snippets": [ diff --git a/server/src/qLangServer.ts b/server/src/qLangServer.ts index 28123662..1e6e81dc 100644 --- a/server/src/qLangServer.ts +++ b/server/src/qLangServer.ts @@ -58,6 +58,7 @@ import { EndOfLine, SemiColon, WhiteSpace, + RCurly, } from "./parser"; import { lint } from "./linter"; @@ -94,6 +95,10 @@ export default class QLangServer { "kdb.qls.expressionRange", this.onExpressionRange.bind(this), ); + this.connection.onRequest( + "kdb.qls.parameterCache", + this.onParameterCache.bind(this), + ); } public capabilities(): ServerCapabilities { @@ -233,6 +238,48 @@ export default class QLangServer { return expressionToRange(tokens, source.exprs); } + public onParameterCache({ + textDocument, + position, + }: TextDocumentPositionParams) { + const tokens = this.parse(textDocument); + const source = positionToToken(tokens, position); + if (!source) { + return null; + } + const lambda = inLambda(source); + if (!lambda) { + return null; + } + const scoped = tokens.filter((token) => inLambda(token) === lambda); + if (scoped.length === 0) { + return null; + } + const curly = scoped[scoped.length - 1]; + if (!curly || curly.tokenType !== RCurly) { + return null; + } + const params = scoped.filter((token) => inParam(token)); + if (params.length === 0) { + return null; + } + const bracket = params[params.length - 1]; + if (!bracket) { + return null; + } + const args = params + .filter((token) => assigned(token)) + .map((token) => token.image); + if (args.length === 0) { + return null; + } + return { + params: args, + start: rangeFromToken(bracket).end, + end: rangeFromToken(curly).start, + }; + } + private parse(textDocument: TextDocumentIdentifier): Token[] { const document = this.documents.get(textDocument.uri); if (!document) { diff --git a/src/commands/clientCommands.ts b/src/commands/clientCommands.ts new file mode 100644 index 00000000..475e56cc --- /dev/null +++ b/src/commands/clientCommands.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 1998-2023 Kx Systems Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +import { + EndOfLine, + ExtensionContext, + Position, + Range, + Selection, + WorkspaceEdit, + commands, + workspace, +} from "vscode"; +import { LanguageClient } from "vscode-languageclient/node"; +import { ext } from "../extensionVariables"; +import { runActiveEditor } from "./workspaceCommand"; +import { ExecutionTypes } from "../models/execution"; +import crypto from "crypto"; + +async function executeBlock(client: LanguageClient) { + if (ext.activeTextEditor) { + const range = await client.sendRequest("kdb.qls.expressionRange", { + textDocument: { uri: `${ext.activeTextEditor.document.uri}` }, + position: ext.activeTextEditor.selection.active, + }); + if (range) { + ext.activeTextEditor.selection = new Selection( + range.start.line, + range.start.character, + range.end.line, + range.end.character, + ); + await runActiveEditor(ExecutionTypes.QuerySelection); + } + } +} + +async function toggleParameterCache(client: LanguageClient) { + if (ext.activeTextEditor) { + const doc = ext.activeTextEditor.document; + const res = await client.sendRequest<{ + params: string[]; + start: Position; + end: Position; + }>("kdb.qls.parameterCache", { + textDocument: { uri: `${doc.uri}` }, + position: ext.activeTextEditor.selection.active, + }); + if (res) { + const edit = new WorkspaceEdit(); + const start = new Position(res.start.line, res.start.character); + const end = new Position(res.end.line, res.end.character); + const text = doc.getText(new Range(start, end)); + const match = + /\s*\.axdebug\.temp([A-F0-9]{6}).*?\.axdebug\.temp\1\s*;/s.exec(text); + if (match) { + const offset = doc.offsetAt(start); + edit.delete( + doc.uri, + new Range( + doc.positionAt(offset + match.index), + doc.positionAt(offset + match.index + match[0].length), + ), + ); + } else { + const hash = crypto.randomBytes(3).toString("hex").toUpperCase(); + const expr1 = `.axdebug.temp${hash}: ${res.params.length === 1 ? res.params[0] : `(${res.params.join(";")})`};`; + const expr2 = `${res.params.map((param) => `\`${param}`).join("")} set${res.params.length === 1 ? "" : "'"} .axdebug.temp${hash};`; + + if (start.line === end.line) { + edit.insert(doc.uri, start, " "); + edit.insert(doc.uri, start, expr1); + edit.insert(doc.uri, start, expr2); + } else { + const line = doc.getText( + new Range(end.line, 0, end.line, end.character), + ); + const match = /^[ \t]*/.exec(line); + if (match) { + const eol = doc.eol === EndOfLine.CRLF ? "\r\n" : "\n"; + edit.insert(doc.uri, start, eol); + edit.insert(doc.uri, start, match[0]); + edit.insert(doc.uri, start, expr1); + edit.insert(doc.uri, start, eol); + edit.insert(doc.uri, start, match[0]); + edit.insert(doc.uri, start, expr2); + } + } + } + await workspace.applyEdit(edit); + } + } +} + +export function connectClientCommands( + context: ExtensionContext, + client: LanguageClient, +) { + let mutex = false; + + context.subscriptions.push( + commands.registerCommand("kdb.execute.block", async () => { + if (!mutex) { + mutex = true; + try { + await executeBlock(client); + } finally { + mutex = false; + } + } + }), + commands.registerCommand("kdb.toggleParameterCache", async () => { + if (!mutex) { + mutex = true; + try { + await toggleParameterCache(client); + } finally { + mutex = false; + } + } + }), + ); +} diff --git a/src/extension.ts b/src/extension.ts index 929157b9..1f60a08a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,10 +17,7 @@ import { ConfigurationTarget, EventEmitter, ExtensionContext, - Position, Range, - Selection, - TextDocument, TextDocumentContentProvider, Uri, WorkspaceEdit, @@ -99,6 +96,7 @@ import { createDefaultDataSourceFile } from "./models/dataSource"; import { connectBuildTools, lintCommand } from "./commands/buildToolsCommand"; import { CompletionProvider } from "./services/completionProvider"; import { QuickFixProvider } from "./services/quickFixProvider"; +import { connectClientCommands } from "./commands/clientCommands"; let client: LanguageClient; @@ -398,22 +396,6 @@ export async function activate(context: ExtensionContext) { await commands.executeCommand("deleteFile"); } }), - commands.registerCommand("kdb.execute.block", async () => { - if (ext.activeTextEditor) { - const range = await commands.executeCommand( - "kdb.qls.expressionRange", - ext.activeTextEditor.document, - ext.activeTextEditor.selection.active, - ); - if (range) { - ext.activeTextEditor.selection = new Selection( - range.start, - range.end, - ); - await runActiveEditor(ExecutionTypes.QuerySelection); - } - } - }), DataSourceEditorProvider.register(context), @@ -496,16 +478,7 @@ export async function activate(context: ExtensionContext) { await client.start(); - context.subscriptions.push( - commands.registerCommand( - "kdb.qls.expressionRange", - (document: TextDocument, position: Position) => - client.sendRequest("kdb.qls.expressionRange", { - textDocument: { uri: `${document.uri}` }, - position: { line: position.line, character: position.character }, - }), - ), - ); + connectClientCommands(context, client); Telemetry.sendEvent("Extension.Activated"); const yamlExtension = extensions.getExtension("redhat.vscode-yaml"); diff --git a/test/suite/commands.test.ts b/test/suite/commands.test.ts index 005d47a0..94cd12c0 100644 --- a/test/suite/commands.test.ts +++ b/test/suite/commands.test.ts @@ -51,7 +51,8 @@ import * as workspaceCommand from "../../src/commands/workspaceCommand"; import { MetaObject } from "../../src/models/meta"; import { WorkspaceTreeProvider } from "../../src/services/workspaceTreeProvider"; import { GetDataError } from "../../src/models/data"; -import { arrayBuffer } from "stream/consumers"; +import * as clientCommand from "../../src/commands/clientCommands"; +import { LanguageClient } from "vscode-languageclient/node"; describe("dataSourceCommand", () => { afterEach(() => { @@ -2014,3 +2015,66 @@ describe("workspaceCommand", () => { }); }); }); + +describe("clientCommands", () => { + const client = sinon.createStubInstance(LanguageClient); + let executeBlock; + let toggleParameterCache; + + beforeEach(() => { + const context = { subscriptions: [] }; + sinon.stub(vscode.commands, "registerCommand").value((a, b) => b); + clientCommand.connectClientCommands(context, client); + executeBlock = context.subscriptions[0]; + toggleParameterCache = context.subscriptions[1]; + ext.activeTextEditor = { + options: { insertSpaces: true, indentSize: 4 }, + selection: { active: new vscode.Position(0, 0) }, + document: { + uri: vscode.Uri.file("/tmp/some.q"), + getText: () => "", + }, + }; + }); + afterEach(() => { + sinon.restore(); + ext.activeTextEditor = undefined; + }); + describe("executeBlock", () => { + it("should execute current block", async () => { + sinon + .stub(client, "sendRequest") + .value(async () => new vscode.Range(0, 0, 1, 1)); + sinon.stub(workspaceCommand, "runActiveEditor").value(() => {}); + await executeBlock(client); + assert.deepEqual( + ext.activeTextEditor.selection, + new vscode.Selection(0, 0, 1, 1), + ); + }); + }); + describe("kdb.toggleParameterCache", () => { + it("should add parameter cache for single line functions", async () => { + let edit: vscode.WorkspaceEdit; + sinon.stub(client, "sendRequest").value(async () => ({ + params: ["a"], + start: new vscode.Position(0, 0), + end: new vscode.Position(0, 10), + })); + sinon.stub(vscode.workspace, "applyEdit").value(async (a) => (edit = a)); + await toggleParameterCache(client); + assert.strictEqual(edit.size, 1); + }); + it("should add parameter cache for multi line functions", async () => { + let edit: vscode.WorkspaceEdit; + sinon.stub(client, "sendRequest").value(async () => ({ + params: ["a"], + start: new vscode.Position(0, 0), + end: new vscode.Position(1, 10), + })); + sinon.stub(vscode.workspace, "applyEdit").value(async (a) => (edit = a)); + await toggleParameterCache(client); + assert.strictEqual(edit.size, 1); + }); + }); +}); diff --git a/test/suite/qLangServer.test.ts b/test/suite/qLangServer.test.ts index daa7e3af..1d37281b 100644 --- a/test/suite/qLangServer.test.ts +++ b/test/suite/qLangServer.test.ts @@ -28,10 +28,10 @@ const context = { includeDeclaration: true }; describe("qLangServer", () => { let server: QLangServer; - function createDocument(content: string) { + function createDocument(content: string, offset?: number) { content = content.trim(); const document = TextDocument.create("test.q", "q", 1, content); - const position = document.positionAt(content.length); + const position = document.positionAt(offset || content.length); const textDocument = TextDocumentIdentifier.create("test.q"); sinon.stub(server.documents, "get").value(() => document); return { @@ -242,4 +242,77 @@ describe("qLangServer", () => { assert.strictEqual(result, null); }); }); + + describe("onExpressionRange", () => { + it("should return range for simple expression", () => { + const params = createDocument("a:1;"); + const result = server.onExpressionRange(params); + assert.ok(result); + assert.strictEqual(result.start.line, 0); + assert.strictEqual(result.start.character, 0); + assert.strictEqual(result.end.line, 0); + assert.strictEqual(result.end.character, 3); + }); + it("should return range for lambda", () => { + const params = createDocument("a:{b:1;b};"); + const result = server.onExpressionRange(params); + assert.ok(result); + assert.strictEqual(result.start.line, 0); + assert.strictEqual(result.start.character, 0); + assert.strictEqual(result.end.line, 0); + assert.strictEqual(result.end.character, 9); + }); + it("should skip comments", () => { + const params = createDocument("a:1 /comment", 1); + const result = server.onExpressionRange(params); + assert.ok(result); + assert.strictEqual(result.start.line, 0); + assert.strictEqual(result.start.character, 0); + assert.strictEqual(result.end.line, 0); + assert.strictEqual(result.end.character, 3); + }); + }); + + describe("omParameterCache", () => { + it("should cache paramater", () => { + const params = createDocument("{[a;b]}"); + const result = server.onParameterCache(params); + assert.ok(result); + assert.deepEqual(result.params, ["a", "b"]); + assert.strictEqual(result.start.line, 0); + assert.strictEqual(result.start.character, 6); + assert.strictEqual(result.end.line, 0); + assert.strictEqual(result.end.character, 6); + }); + it("should cache paramater", () => { + const params = createDocument("{[a;b]\n }"); + const result = server.onParameterCache(params); + assert.ok(result); + assert.deepEqual(result.params, ["a", "b"]); + assert.strictEqual(result.start.line, 0); + assert.strictEqual(result.start.character, 6); + assert.strictEqual(result.end.line, 1); + assert.strictEqual(result.end.character, 1); + }); + it("should return null", () => { + const params = createDocument("{[]}"); + const result = server.onParameterCache(params); + assert.strictEqual(result, null); + }); + it("should return null", () => { + const params = createDocument("{}"); + const result = server.onParameterCache(params); + assert.strictEqual(result, null); + }); + it("should return null", () => { + const params = createDocument("a:1;"); + const result = server.onParameterCache(params); + assert.strictEqual(result, null); + }); + it("should return null", () => { + const params = createDocument(""); + const result = server.onParameterCache(params); + assert.strictEqual(result, null); + }); + }); });