diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 34ba6f1ca..b11dfa36f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -3114,7 +3114,7 @@ function completionOptionForItem(item: QueryCompletionItem) { markCompletionAccepted(item); const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to; const insert = appendSqlCompletionSpace(item.apply ?? item.label, { - enabled: shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion, + enabled: ("appendSpace" in item && item.appendSpace === true) || (shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion), itemType: item.type, nextCharacter: view.state.sliceDoc(replaceTo, replaceTo + 1), }); @@ -3157,11 +3157,22 @@ async function provideRedisCompletions(currentState: import("@codemirror/state") const fullDoc = currentState.doc.toString(); if (!explicit && !shouldAutoOpenRedisCompletion(fullDoc, position)) return null; - const completionContext = getRedisCompletionContext(fullDoc, position); + let commands; + try { + commands = await connectionStore.listRedisCompletionCommandDocs(props.connectionId, props.database ?? "0"); + } catch { + // Completion is deliberately instance-driven: do not substitute a bundled + // command list when the server does not expose command metadata. + return null; + } + if (epoch !== completionEpoch) return null; + + const completionInput = { commands }; + const completionContext = getRedisCompletionContext(fullDoc, position, completionInput); // Key-name completion needs a reliable db index; props.database may briefly be "" on // the New Query path before the active db resolves, and only key-argument commands warrant it. let keys: string[] = []; - if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.mainCommand)) { + if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.commandName, completionInput, completionContext.argumentIndex, completionContext.argumentValues)) { try { keys = await connectionStore.listRedisCompletionKeys(props.connectionId, props.database); } catch { @@ -3172,6 +3183,7 @@ async function provideRedisCompletions(currentState: import("@codemirror/state") const items = buildRedisCompletionItemsFromContext(completionContext, { keys, + commands, }); if (items.length === 0) return null; // Use the built-in filter (the default) so typing narrows the list and moves diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 3ce41146b..8560f3d2d 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -22,9 +22,13 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisExecuteCommand: vi.fn(), + saveHistory: vi.fn(), canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000), toast: vi.fn(), updateRedisDbKeyStats: vi.fn(), + listRedisCompletionCommandDocs: vi.fn(), + listRedisCompletionKeys: vi.fn(), redisScanPageSize: 100, infiniteScroll: false, queryResultMaxRowsEnabled: true, @@ -46,6 +50,8 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisExecuteCommand: mocks.redisExecuteCommand, + saveHistory: mocks.saveHistory, })); vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => { @@ -58,6 +64,8 @@ vi.mock("@/stores/connectionStore", () => ({ ensureConnected: vi.fn().mockResolvedValue(undefined), getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }), updateRedisDbKeyStats: mocks.updateRedisDbKeyStats, + listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs, + listRedisCompletionKeys: mocks.listRedisCompletionKeys, invalidateCompletionCache: vi.fn(), refreshRedisDbKeyCounts: vi.fn(), }), @@ -346,6 +354,15 @@ function redisKeyInfo(keyType = "json") { return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" }; } +const completionCommands = [ + { name: "GET", group: "string", arity: 2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] }, + { name: "GETEX", group: "string", arity: -2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] }, + { name: "GETSET", group: "string", arity: 3, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] }, + { name: "PING", group: "connection", arity: -1, keySpecs: [] }, + { name: "SET", group: "string", arity: -3, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] }, + { name: "VGET", group: "string", arity: 2, summary: "Reads a vendor key.", keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] }, +]; + function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -376,6 +393,10 @@ function resetApiMocks() { mocks.redisCheckJsonModule.mockResolvedValue(true); mocks.redisDeleteKey.mockResolvedValue(undefined); mocks.redisDeleteKeys.mockResolvedValue(0); + mocks.redisExecuteCommand.mockResolvedValue({ value: "OK" }); + mocks.saveHistory.mockResolvedValue(undefined); + mocks.listRedisCompletionCommandDocs.mockResolvedValue(completionCommands); + mocks.listRedisCompletionKeys.mockResolvedValue(["user:1"]); mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000); } @@ -386,6 +407,7 @@ function mountBrowser() { app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); app.mount(host); mountedApps.push({ unmount: () => app.unmount(), host }); + return host; } function mountScopedBrowser() { @@ -502,6 +524,21 @@ async function openCreateDialog() { await setInput('input[placeholder="redis.createKeyNamePlaceholder"]', KEY_NAME); } +async function openCommandPanel() { + const trigger = document.querySelector('[value="command"]'); + expect(trigger, "redis.commandLine trigger").toBeDefined(); + trigger!.click(); + await settle(); +} + +function commandCompletionLabels(): string[] { + return Array.from(document.querySelectorAll('[role="option"]')).map((item) => item.textContent?.trim() ?? ""); +} + +async function setCommandInput(value: string) { + await setInput("[data-redis-command-input]", value); +} + async function fillCreateValue(type: CreateType) { if (type === "string") { const textarea = requiredElement("textarea"); @@ -597,6 +634,186 @@ describe("RedisKeyBrowser scope changes", () => { }); }); +describe("RedisKeyBrowser command completion", () => { + it("uses the connected server's module command docs and accepts the selection with Tab", async () => { + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("VGE"); + + expect(mocks.listRedisCompletionCommandDocs).toHaveBeenCalledWith("connection", "0"); + expect(commandCompletionLabels()).toContain("VGETReads a vendor key.string"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); + await settle(); + expect(input.value).toBe("VGE"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + await settle(); + expect(input.value).toBe("VGET "); + }); + + it("completes known keys only at a documented key argument", async () => { + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("VGET "); + + expect(mocks.listRedisCompletionKeys).toHaveBeenCalledWith("connection", "0"); + expect(commandCompletionLabels()).toContain("user:1key"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + await settle(); + expect(input.value).toBe("VGET user:1"); + }); + + it("replaces an incomplete quoted key with the executable completion text", async () => { + mocks.listRedisCompletionKeys.mockResolvedValueOnce(["user name"]); + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput('VGET "user'); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + await settle(); + + expect(input.value).toBe('VGET "user name"'); + }); + + it("does not guess command candidates when server metadata is unavailable", async () => { + mocks.listRedisCompletionCommandDocs.mockRejectedValueOnce(new Error("unknown subcommand 'DOCS'")); + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("GE"); + + expect(commandCompletionLabels()).toEqual([]); + }); + + it("keeps one selected completion while pointer and keyboard move it", async () => { + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("GE"); + + const options = Array.from(document.querySelectorAll('[role="option"]')); + const input = requiredElement("[data-redis-command-input]"); + const listbox = requiredElement('[role="listbox"]'); + expect(options.length).toBeGreaterThan(1); + expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[0]]); + expect(options.every((option) => !option.hasAttribute("title"))).toBe(true); + expect(input.getAttribute("aria-controls")).toBe(listbox.id); + expect(input.getAttribute("aria-activedescendant")).toBe(options[0]!.id); + + options[1]!.dispatchEvent(new Event("pointerenter", { bubbles: true })); + await settle(); + expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[1]]); + expect(input.getAttribute("aria-activedescendant")).toBe(options[1]!.id); + + listbox.scrollTop = 20; + vi.spyOn(listbox, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 200 } as DOMRect); + vi.spyOn(options[2]!, "getBoundingClientRect").mockReturnValue({ top: 180, bottom: 224 } as DOMRect); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await settle(); + expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[2]]); + expect(listbox.scrollTop).toBe(44); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await settle(); + expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[2]]); + + vi.spyOn(options[1]!, "getBoundingClientRect").mockReturnValue({ top: 76, bottom: 120 } as DOMRect); + vi.spyOn(options[0]!, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 144 } as DOMRect); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + await settle(); + expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[0]]); + expect(listbox.scrollTop).toBe(20); + }); + + it("accepts a documented argument keyword and advances to its value", async () => { + mocks.listRedisCompletionCommandDocs.mockResolvedValueOnce([ + ...completionCommands, + { + name: "XREAD", + group: "stream", + arity: -4, + keySpecs: [], + arguments: [ + { name: "count", token: "COUNT", type: "integer", optional: true }, + { name: "streams", token: "STREAMS", type: "block", arguments: [{ name: "key", type: "key", multiple: true }] }, + ], + }, + ]); + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("XREAD C"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + await settle(); + + expect(input.value).toBe("XREAD COUNT "); + expect(commandCompletionLabels()).toEqual([]); + }); + + it("accepts the selected completion before executing on Enter", async () => { + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("SE"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + + expect(input.value).toBe("SET "); + expect(commandCompletionLabels()).toContain("user:1key"); + }); + + it("waits for command metadata instead of sending a partial command on Enter", async () => { + const docs = deferred(); + mocks.listRedisCompletionCommandDocs.mockReturnValueOnce(docs.promise); + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("SE"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + + expect(input.value).toBe("SE"); + expect(mocks.redisExecuteCommand).not.toHaveBeenCalled(); + + docs.resolve(completionCommands); + await settle(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + expect(input.value).toBe("SET "); + expect(mocks.redisExecuteCommand).not.toHaveBeenCalled(); + }); + + it("executes an exact command instead of completing it a second time", async () => { + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("PING"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + + expect(mocks.redisExecuteCommand).toHaveBeenCalledWith("connection", 0, "PING", true); + expect(input.value).toBe(""); + }); +}); + describe("RedisKeyBrowser expiry creation", () => { it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => { mountBrowser(); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index a8f8194fb..a27ad0dbf 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -43,6 +43,8 @@ import { import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety"; import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable"; import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession"; +import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion"; +import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs"; import { formatRedisConsoleValue, redisValuePreview, redisValueSize } from "@/lib/redis/redisValuePresentation"; import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { copyToClipboard } from "@/lib/common/clipboard"; @@ -119,6 +121,15 @@ const commandRunning = ref(false); const commandDb = ref(props.db); const commandHistory = ref([]); const commandHistoryIndex = ref(-1); +const commandCompletionItems = ref([]); +const commandCompletionSelectedIndex = ref(0); +const commandCompletionLoading = ref(false); +const commandCompletionListboxId = `redis-command-completions-${uuid()}`; +const commandCompletionSelectedItem = computed(() => commandCompletionItems.value[commandCompletionSelectedIndex.value]); +const commandCompletionActiveDescendant = computed(() => (commandCompletionSelectedItem.value ? `${commandCompletionListboxId}-option-${commandCompletionSelectedIndex.value}` : undefined)); +const commandDocumentationLoading = ref(false); +const commandCompletionOpen = computed(() => commandDocumentationLoading.value || commandCompletionLoading.value || commandCompletionItems.value.length > 0); +const commandDocumentation = shallowRef([]); const activeSidePanel = ref("detail"); const showCreateKeyDialog = ref(false); const creatingKey = ref(false); @@ -152,6 +163,10 @@ let redisDbFlushedListenerRegistered = false; let redisInfiniteScrollFrame = 0; const loadedKeyRaws = new Set(); let treeIndex: RedisKeyTreeIndex | null = null; +const REDIS_COMMAND_COMPLETION_MENU_LIMIT = 12; +let commandCompletionRequestId = 0; +let commandDocumentationConnectionId: string | null = null; +let commandDocumentationRequestId = 0; const valueQuery = computed(() => searchPattern.value.trim()); const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all"); @@ -909,6 +924,7 @@ async function openCommandPanel() { activeSidePanel.value = "command"; await nextTick(); getCommandInput()?.focus(); + requestCommandDocumentation(); } function makeEntry(): CreateKeyEntry { @@ -1209,6 +1225,7 @@ async function createRedisKey() { async function executeCommand() { const command = commandText.value.trim(); + dismissCommandCompletions(); if (!command) { // 空命令显示提示但不记入历史 appendCommandOutput({ @@ -1345,6 +1362,147 @@ function getCommandInput(): HTMLInputElement | null { return rootRef.value?.querySelector("[data-redis-command-input]") ?? null; } +function resetCommandDocumentation() { + commandDocumentationRequestId++; + commandDocumentationConnectionId = null; + commandDocumentationLoading.value = false; + commandDocumentation.value = []; +} + +function requestCommandDocumentation() { + if (commandDocumentationLoading.value || commandDocumentationConnectionId === props.connectionId) return; + const requestId = ++commandDocumentationRequestId; + const connectionId = props.connectionId; + const database = String(commandDb.value); + commandDocumentationLoading.value = true; + void connectionStore + .listRedisCompletionCommandDocs(connectionId, database) + .then((docs) => { + if (requestId !== commandDocumentationRequestId || connectionId !== props.connectionId) return; + commandDocumentation.value = docs; + commandDocumentationConnectionId = connectionId; + if (commandText.value) void refreshCommandCompletions(); + }) + .catch(() => { + if (requestId !== commandDocumentationRequestId || connectionId !== props.connectionId) return; + // Do not offer guessed commands when the instance's metadata is unavailable. + commandDocumentation.value = []; + commandDocumentationConnectionId = connectionId; + }) + .finally(() => { + if (requestId === commandDocumentationRequestId) commandDocumentationLoading.value = false; + }); +} + +function dismissCommandCompletions() { + commandCompletionRequestId++; + commandCompletionItems.value = []; + commandCompletionSelectedIndex.value = 0; + commandCompletionLoading.value = false; +} + +async function refreshCommandCompletions(options: { force?: boolean } = {}) { + const input = getCommandInput(); + const text = commandText.value; + if (!options.force && !text) { + dismissCommandCompletions(); + return; + } + + const cursor = input?.selectionStart ?? text.length; + requestCommandDocumentation(); + const completionInput = { commands: commandDocumentation.value }; + const context = getRedisCompletionContext(text, cursor, completionInput); + const requestId = ++commandCompletionRequestId; + commandCompletionItems.value = []; + commandCompletionSelectedIndex.value = 0; + + let keys: string[] = []; + const needsKeys = context.mode === "argument" && takesKeyArgument(context.commandName, completionInput, context.argumentIndex, context.argumentValues); + commandCompletionLoading.value = needsKeys; + if (needsKeys) { + try { + keys = await connectionStore.listRedisCompletionKeys(props.connectionId, String(commandDb.value)); + } catch { + keys = []; + } + } + + if (requestId !== commandCompletionRequestId) return; + commandCompletionItems.value = buildRedisCompletionItemsFromContext(context, { keys, ...completionInput }).slice(0, REDIS_COMMAND_COMPLETION_MENU_LIMIT); + commandCompletionLoading.value = false; +} + +function onCommandInput() { + void refreshCommandCompletions(); +} + +function onCommandInputClick() { + void refreshCommandCompletions(); +} + +function selectCommandCompletion(index: number) { + if (index < 0 || index >= commandCompletionItems.value.length) return; + commandCompletionSelectedIndex.value = index; + void nextTick(() => { + const listbox = document.getElementById(commandCompletionListboxId); + const option = document.getElementById(`${commandCompletionListboxId}-option-${index}`); + if (!listbox || !option) return; + const listboxRect = listbox.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + if (optionRect.top < listboxRect.top) listbox.scrollTop -= listboxRect.top - optionRect.top; + else if (optionRect.bottom > listboxRect.bottom) listbox.scrollTop += optionRect.bottom - listboxRect.bottom; + }); +} + +function moveCommandCompletionSelection(direction: 1 | -1): boolean { + const count = commandCompletionItems.value.length; + if (count === 0) return false; + const nextIndex = Math.min(Math.max(commandCompletionSelectedIndex.value + direction, 0), count - 1); + if (nextIndex !== commandCompletionSelectedIndex.value) selectCommandCompletion(nextIndex); + return true; +} + +function commandCompletionInsertion(index = commandCompletionSelectedIndex.value) { + const item = commandCompletionItems.value[index]; + const input = getCommandInput(); + if (!item || !input) return null; + + const text = commandText.value; + const context = getRedisCompletionContext(text, input.selectionStart ?? text.length, { commands: commandDocumentation.value }); + const from = context.from; + const to = input.selectionEnd ?? text.length; + const insert = item.apply ?? item.label; + const commandHead = context.mode === "command" || context.mode === "subcommand"; + const appendSpace = (commandHead || item.appendSpace === true) && !/^\s/.test(text.slice(to)); + return { text, from, to, insert, replacement: `${insert}${appendSpace ? " " : ""}`, appendSpace, commandHead }; +} + +function selectedCompletionMatchesInput(): boolean { + const completion = commandCompletionInsertion(); + if (!completion) return false; + const current = completion.text.slice(completion.from, completion.to); + return completion.commandHead ? current.toUpperCase() === completion.insert.toUpperCase() : current === completion.insert; +} + +function acceptCommandCompletion(index = commandCompletionSelectedIndex.value): boolean { + const completion = commandCompletionInsertion(index); + if (!completion) return false; + + commandText.value = `${completion.text.slice(0, completion.from)}${completion.replacement}${completion.text.slice(completion.to)}`; + dismissCommandCompletions(); + + void nextTick(() => { + const nextInput = getCommandInput(); + if (!nextInput) return; + const cursor = completion.from + completion.replacement.length; + nextInput.focus(); + nextInput.setSelectionRange(cursor, cursor); + if (completion.appendSpace) void refreshCommandCompletions({ force: true }); + }); + return true; +} + function focusSearch(): boolean { if (activeSidePanel.value === "detail" && valueViewerRef.value?.focusSearch()) { return true; @@ -1425,6 +1583,38 @@ function onCommandAreaClick() { } function onCommandInputKeydown(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.code === "Space") { + event.preventDefault(); + void refreshCommandCompletions({ force: true }); + return; + } + if (event.key === "Tab" && !event.shiftKey && acceptCommandCompletion()) { + event.preventDefault(); + return; + } + if (event.key === "Escape" && commandCompletionOpen.value) { + event.preventDefault(); + dismissCommandCompletions(); + return; + } + if (event.key === "ArrowUp" && moveCommandCompletionSelection(-1)) { + event.preventDefault(); + return; + } + if (event.key === "ArrowDown" && moveCommandCompletionSelection(1)) { + event.preventDefault(); + return; + } + // Do not execute a partial command before instance metadata can resolve it. + if (event.key === "Enter" && commandDocumentationLoading.value) { + event.preventDefault(); + return; + } + if (event.key === "Enter" && !selectedCompletionMatchesInput() && acceptCommandCompletion()) { + event.preventDefault(); + return; + } + // 上下键切换历史命令 if (event.key === "ArrowUp") { event.preventDefault(); @@ -1438,6 +1628,7 @@ function onCommandInputKeydown(event: KeyboardEvent) { commandHistoryIndex.value--; } commandText.value = commandHistory.value[commandHistoryIndex.value].command; + dismissCommandCompletions(); } else if (event.key === "ArrowDown") { event.preventDefault(); if (commandHistoryIndex.value === -1) return; @@ -1451,9 +1642,10 @@ function onCommandInputKeydown(event: KeyboardEvent) { commandHistoryIndex.value = -1; commandText.value = ""; } + dismissCommandCompletions(); } else if (event.key === "Enter") { event.preventDefault(); - executeCommand(); + void executeCommand(); } } @@ -1499,6 +1691,7 @@ watch( // ContentArea remounts this browser for scope changes; keep embedded uses // in sync as well so an old scan cannot populate the new scope. commandDb.value = db; + resetCommandDocumentation(); resetLoadedKeys(); try { await connectionStore.ensureConnected(connectionId); @@ -1737,17 +1930,53 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
{{ commandPrompt }} - +
+
+
+ +
+
+ +
+
+ +
diff --git a/apps/desktop/src/lib/redis/redisCommandDocs.ts b/apps/desktop/src/lib/redis/redisCommandDocs.ts new file mode 100644 index 000000000..02dd7b8ff --- /dev/null +++ b/apps/desktop/src/lib/redis/redisCommandDocs.ts @@ -0,0 +1,257 @@ +/** + * Normalizes `COMMAND DOCS` maps emitted by the Rust bridge. RESP3 maps are + * serialized as `{ key, value }` entries; RESP2 maps are alternating arrays. + */ +export interface RedisCommandDocumentation { + name: string; + summary?: string; + since?: string; + group?: string; + arity?: number; + keySpecs: RedisCommandKeySpec[]; + arguments?: RedisCommandArgument[]; +} + +/** The recursive command grammar Redis publishes through `COMMAND DOCS`. */ +export interface RedisCommandArgument { + name: string; + token?: string; + type?: string; + summary?: string; + since?: string; + optional?: boolean; + multiple?: boolean; + multipleToken?: boolean; + enum?: string[]; + arguments?: RedisCommandArgument[]; +} + +/** The key-position information Redis publishes through `COMMAND DOCS`. */ +export interface RedisCommandKeySpec { + beginSearch: { type: "index"; index: number } | { type: "keyword"; keyword: string; startFrom: number }; + findKeys: { type: "range"; lastKey: number; keyStep: number; limit: number } | { type: "keynum"; keyNumIndex: number; firstKey: number; keyStep: number }; +} + +type RedisRecord = Record; + +function mapEntries(value: unknown): Array<[string, unknown]> { + if (Array.isArray(value)) { + // RESP2 represents a map as an alternating key/value array, while RESP3 + // reaches the bridge as `{ key, value }` entries. + if (value.length > 0 && value.length % 2 === 0 && value.every((item, index) => index % 2 !== 0 || typeof item === "string")) { + const entries: Array<[string, unknown]> = []; + for (let index = 0; index < value.length; index += 2) { + entries.push([value[index] as string, value[index + 1]]); + } + return entries; + } + const entries: Array<[string, unknown]> = []; + for (const item of value) { + if (Array.isArray(item) && item.length >= 2 && typeof item[0] === "string") { + entries.push([item[0], item[1]]); + continue; + } + if (item && typeof item === "object" && "key" in item && "value" in item && typeof item.key === "string") { + entries.push([item.key, item.value]); + } + } + return entries; + } + if (value && typeof value === "object") return Object.entries(value); + return []; +} + +function recordFromMap(value: unknown): RedisRecord { + return Object.fromEntries(mapEntries(value)); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function optionalNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function enabledFlag(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +function argumentFlags(value: unknown): Set { + return new Set( + (Array.isArray(value) ? value : []) + .filter((flag): flag is string => typeof flag === "string") + .map((flag) => flag.trim().toLowerCase().replaceAll("-", "_")) + .filter(Boolean), + ); +} + +function normalizeCommandName(value: string): string { + // Redis represents subcommands as `parent|child` in COMMAND metadata. + return value.trim().replaceAll("|", " ").toUpperCase(); +} + +function commandArguments(value: unknown): RedisCommandArgument[] { + const arguments_: RedisCommandArgument[] = []; + for (const rawArgument of Array.isArray(value) ? value : []) { + const argument = recordFromMap(rawArgument); + const name = optionalString(argument.name); + if (!name) continue; + const token = optionalString(argument.token); + const type = optionalString(argument.type); + const summary = optionalString(argument.summary); + const since = optionalString(argument.since); + const nested = commandArguments(argument.arguments); + const enumValues = (Array.isArray(argument.enum) ? argument.enum : []).filter((item): item is string => typeof item === "string" && item.length > 0); + const flags = argumentFlags(argument.flags); + arguments_.push({ + name, + ...(token ? { token: token.toUpperCase() } : {}), + ...(type ? { type } : {}), + ...(summary ? { summary } : {}), + ...(since ? { since } : {}), + ...(enabledFlag(argument.optional) || flags.has("optional") ? { optional: true } : {}), + ...(enabledFlag(argument.multiple) || flags.has("multiple") ? { multiple: true } : {}), + ...(enabledFlag(argument.multiple_token) || flags.has("multiple_token") ? { multipleToken: true } : {}), + ...(enumValues.length ? { enum: enumValues } : {}), + ...(nested.length ? { arguments: nested } : {}), + }); + } + return arguments_; +} + +function commandKeySpecs(value: unknown): RedisCommandKeySpec[] { + const specs: RedisCommandKeySpec[] = []; + for (const rawSpec of Array.isArray(value) ? value : []) { + const spec = recordFromMap(rawSpec); + const beginSearch = recordFromMap(spec.begin_search); + const findKeys = recordFromMap(spec.find_keys); + const beginSpec = recordFromMap(beginSearch.spec); + const findSpec = recordFromMap(findKeys.spec); + const beginType = optionalString(beginSearch.type); + const findType = optionalString(findKeys.type); + const keyStep = optionalNumber(findSpec.keystep); + if (!beginType || !findType || !keyStep || keyStep < 1) continue; + + const begin = beginType === "index" ? optionalNumber(beginSpec.index) : beginType === "keyword" ? optionalString(beginSpec.keyword) : undefined; + const normalizedBegin = typeof begin === "number" && begin > 0 ? { type: "index" as const, index: begin } : typeof begin === "string" ? { type: "keyword" as const, keyword: begin.toUpperCase(), startFrom: optionalNumber(beginSpec.startfrom) ?? 1 } : undefined; + if (!normalizedBegin) continue; + + if (findType === "range") { + const lastKey = optionalNumber(findSpec.lastkey); + if (lastKey == null) continue; + const limit = optionalNumber(findSpec.limit) ?? 0; + if (limit < 0) continue; + specs.push({ beginSearch: normalizedBegin, findKeys: { type: "range", lastKey, keyStep, limit } }); + continue; + } + if (findType === "keynum") { + const keyNumIndex = optionalNumber(findSpec.keynumidx); + const firstKey = optionalNumber(findSpec.firstkey); + if (keyNumIndex == null || firstKey == null) continue; + specs.push({ beginSearch: normalizedBegin, findKeys: { type: "keynum", keyNumIndex, firstKey, keyStep } }); + } + } + return specs; +} + +function legacyCommandKeySpecs(value: readonly unknown[]): RedisCommandKeySpec[] { + const firstKey = optionalNumber(value[3]); + const lastKey = optionalNumber(value[4]); + const keyStep = optionalNumber(value[5]); + if (firstKey == null || firstKey < 1 || lastKey == null || keyStep == null || keyStep < 1) return []; + // `COMMAND` reports an absolute last-key position, while `COMMAND DOCS` + // range specs express it relative to the begin-search position. + return [{ beginSearch: { type: "index", index: firstKey }, findKeys: { type: "range", lastKey: lastKey < 0 ? lastKey : Math.max(0, lastKey - firstKey), keyStep, limit: 0 } }]; +} + +function isCommandInfo(value: unknown): value is unknown[] { + return Array.isArray(value) && typeof value[0] === "string" && optionalNumber(value[1]) !== undefined && Array.isArray(value[2]) && optionalNumber(value[3]) !== undefined; +} + +/** Extract the completion-relevant subset of Redis' official `COMMAND DOCS` reply. */ +export function parseRedisCommandDocumentation(value: unknown): RedisCommandDocumentation[] { + const docs = new Map(); + const collect = (rawDocs: unknown) => { + for (const [rawName, rawDoc] of mapEntries(rawDocs)) { + const name = normalizeCommandName(rawName); + if (!name) continue; + const doc = recordFromMap(rawDoc); + const arguments_ = commandArguments(doc.arguments); + docs.set(name, { + name, + summary: optionalString(doc.summary), + since: optionalString(doc.since), + group: optionalString(doc.group), + arity: optionalNumber(doc.arity), + keySpecs: commandKeySpecs(doc.key_specs), + ...(arguments_.length ? { arguments: arguments_ } : {}), + }); + // COMMAND DOCS returns only command families at the top level; their + // concrete subcommands are nested in a map keyed as `parent|child`. + if (doc.subcommands) collect(doc.subcommands); + } + }; + collect(value); + return [...docs.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Extract command names from the legacy `COMMAND` reply, used on Redis before + * 7.0 where `COMMAND DOCS` is unavailable. Its name and key-position fields + * are stable across Redis 2.8+, and modern replies also include subcommands. + */ +export function parseRedisCommandCatalog(value: unknown): RedisCommandDocumentation[] { + const docs = new Map(); + const collect = (rawValue: unknown) => { + if (isCommandInfo(rawValue)) { + const rawName = rawValue[0]; + if (typeof rawName !== "string") return; + const name = normalizeCommandName(rawName); + if (name) { + docs.set(name, { + name, + summary: undefined, + since: undefined, + group: undefined, + arity: optionalNumber(rawValue[1]), + keySpecs: legacyCommandKeySpecs(rawValue), + }); + } + // Redis 7+ appends subcommand command-info replies in slot 10. + collect(rawValue[9]); + return; + } + if (Array.isArray(rawValue)) { + for (const entry of rawValue) collect(entry); + return; + } + if (rawValue && typeof rawValue === "object") { + // A cluster client can return a node-to-reply map through the bridge. + if ("value" in rawValue) { + collect(rawValue.value); + } else { + for (const entry of Object.values(rawValue)) collect(entry); + } + } + }; + collect(value); + return [...docs.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +export function mergeRedisCommandDocumentation(documentation: readonly RedisCommandDocumentation[], catalog: readonly RedisCommandDocumentation[]): RedisCommandDocumentation[] { + const merged = new Map(catalog.map((command) => [command.name, command])); + for (const command of documentation) { + const catalogCommand = merged.get(command.name); + merged.set(command.name, { + ...catalogCommand, + ...command, + arity: command.arity ?? catalogCommand?.arity, + keySpecs: command.keySpecs.length ? command.keySpecs : (catalogCommand?.keySpecs ?? []), + }); + } + return [...merged.values()].sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/apps/desktop/src/lib/redis/redisCommandTokenizer.ts b/apps/desktop/src/lib/redis/redisCommandTokenizer.ts new file mode 100644 index 000000000..5cc86ce30 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisCommandTokenizer.ts @@ -0,0 +1,79 @@ +/** Tokenizes Redis command text with the same quoting and escape rules as the backend. */ +export interface RedisArgvToken { + value: string; + /** 1-based start/end character columns within the source line. */ + startColumn: number; + endColumn: number; +} + +export interface RedisArgvResult { + argv: RedisArgvToken[]; + unclosedQuote: boolean; + unclosedQuoteStart?: number; +} + +function escapedCharacter(value: string): string { + return value === "n" ? "\n" : value === "r" ? "\r" : value === "t" ? "\t" : value; +} + +export function tokenizeRedisLine(line: string): RedisArgvResult { + let end = line.length; + while (end > 0 && /\s/.test(line[end - 1]!)) end--; + while (end > 0 && line[end - 1] === ";") end--; + + const argv: RedisArgvToken[] = []; + let value = ""; + let startColumn: number | undefined; + let quote: string | undefined; + let unclosedQuoteStart: number | undefined; + let escaping = false; + + const pushToken = (endColumn: number) => { + if (startColumn != null) { + argv.push({ value, startColumn, endColumn }); + } + value = ""; + startColumn = undefined; + }; + + for (let index = 0; index < end; index++) { + const character = line[index]!; + if (startColumn == null) { + if (/\s/.test(character)) continue; + startColumn = index + 1; + } + + if (escaping) { + value += escapedCharacter(character); + escaping = false; + continue; + } + if (character === "\\") { + escaping = true; + continue; + } + if (quote) { + if (character === quote) quote = undefined; + else value += character; + continue; + } + if (character === '"' || character === "'") { + quote = character; + unclosedQuoteStart = index + 1; + continue; + } + if (/\s/.test(character)) { + pushToken(index + 1); + continue; + } + value += character; + } + + if (escaping) value += "\\"; + if (quote) { + pushToken(end + 1); + return { argv, unclosedQuote: true, unclosedQuoteStart }; + } + pushToken(end + 1); + return { argv, unclosedQuote: false }; +} diff --git a/apps/desktop/src/lib/redis/redisCompletion.ts b/apps/desktop/src/lib/redis/redisCompletion.ts index d7622bef3..7fdc26884 100644 --- a/apps/desktop/src/lib/redis/redisCompletion.ts +++ b/apps/desktop/src/lib/redis/redisCompletion.ts @@ -1,22 +1,24 @@ /** - * Redis command autocompletion for the query editor. Mirrors the shape of - * `elasticsearchCompletion.ts` so the editor's completion pipeline (the single - * `autocompletion({ override })` in QueryEditor.vue) can dispatch to it. + * Redis command autocompletion shared by the query editor and terminal input. + * It mirrors the shape of `elasticsearchCompletion.ts` so the editor's + * completion pipeline can dispatch to it. * - * Data source: the static `REDIS_COMMAND_TABLE` (built for diagnostics). Keys - * are UPPER CASE command names; two-token subcommands are keyed as `"MAIN SUB"` - * (e.g. `"XGROUP CREATE"`). We split those into a main-name index + subcommand - * index here. + * Candidates and their displayed metadata come exclusively from the connected + * server's `COMMAND DOCS` (or legacy `COMMAND`) response. Safety enforcement + * remains in the command execution path and is not inferred for completion. */ -import { REDIS_COMMAND_TABLE } from "@/lib/redis/redisCommandTable"; -import type { RedisCommandSpec } from "@/lib/redis/redisCommandTable"; +import type { RedisCommandArgument, RedisCommandDocumentation, RedisCommandKeySpec } from "@/lib/redis/redisCommandDocs"; +import { tokenizeRedisLine } from "@/lib/redis/redisCommandTokenizer"; export interface RedisCompletionItem { label: string; type: "keyword" | "text"; // command/subcommand=keyword, key name=text detail?: string; // single-line, e.g. "string · confirm" info?: string; // multi-line: Group / Arity / Safety + summary?: string; + since?: string; apply?: string; + appendSpace?: boolean; boost: number; } @@ -27,53 +29,38 @@ export interface RedisCompletionContext { from: number; /** Upper-cased main command already typed, when known. */ mainCommand?: string; + /** Resolved command head, including a subcommand when applicable. */ + commandName?: string; /** In argument mode: 0-based index of the argument position (after the command head). */ argumentIndex?: number; + /** Completed argument values before the current cursor position. */ + argumentValues?: string[]; } export interface RedisCompletionInput { keys?: string[]; + /** Commands reported by the connected Redis server through `COMMAND DOCS` or `COMMAND`. */ + commands: readonly RedisCommandDocumentation[]; } -// ---- Static indexes derived from the command table ---- +// ---- Server-reported command index ---- -interface MainCommandEntry { +interface CompletionCommandEntry { name: string; - spec: RedisCommandSpec; + arity: number; + group: string; + summary?: string; + since?: string; + keySpecs: readonly RedisCommandKeySpec[]; + arguments: readonly RedisCommandArgument[]; } -interface SubcommandEntry { - main: string; - sub: string; - spec: RedisCommandSpec; +interface CompletionIndex { + commands: Map; + mainCommands: CompletionCommandEntry[]; + subcommands: Map; } -const MAIN_COMMANDS: MainCommandEntry[] = []; -const SUBCOMMANDS: SubcommandEntry[] = []; -const SUBCOMMAND_MAINS = new Set(); - -for (const [key, spec] of Object.entries(REDIS_COMMAND_TABLE)) { - const space = key.indexOf(" "); - if (space < 0) { - MAIN_COMMANDS.push({ name: key, spec }); - } else { - const main = key.slice(0, space); - const sub = key.slice(space + 1); - SUBCOMMANDS.push({ main, sub, spec }); - SUBCOMMAND_MAINS.add(main); - // A main command that only has subcommand forms still needs a main-name - // entry so the user can complete the main token first. - if (!REDIS_COMMAND_TABLE[main]) MAIN_COMMANDS.push({ name: main, spec }); - } -} - -// Groups whose first argument is a key name (enable key completion there). -const KEY_ARGUMENT_GROUPS = new Set(["string", "generic", "list", "hash", "set", "zset", "bitmap", "hyperloglog", "geo", "stream"]); - -// Commands that accept a variadic list of keys (keep suggesting keys beyond the -// first argument slot). Most key commands take exactly one key; these keep going. -const MULTI_KEY_COMMANDS = new Set(["DEL", "UNLINK", "EXISTS", "TOUCH", "MGET"]); - // Boost tuning: common groups surface higher. const GROUP_BOOST: Record = { string: 110, @@ -94,38 +81,92 @@ function describeArity(arity: number): string { return "variable arguments"; } +function createCompletionIndex(commandDocs: readonly RedisCommandDocumentation[]): CompletionIndex { + const commands = new Map(); + for (const doc of commandDocs) { + const name = doc.name.trim().toUpperCase(); + if (!name) continue; + commands.set(name, { + name, + arity: doc.arity ?? 0, + group: doc.group ?? "unknown", + summary: doc.summary, + since: doc.since, + keySpecs: doc.keySpecs, + arguments: doc.arguments ?? [], + }); + } + + const mainCommands = new Map(); + const subcommands = new Map(); + for (const entry of commands.values()) { + const tokens = entry.name.split(" "); + const main = tokens[0]!; + if (!mainCommands.has(main)) { + mainCommands.set(main, commands.get(main) ?? { ...entry, name: main, summary: undefined, since: undefined }); + } + for (let tokenIndex = 1; tokenIndex < tokens.length; tokenIndex++) { + const parent = tokens.slice(0, tokenIndex).join(" "); + const childName = tokens.slice(0, tokenIndex + 1).join(" "); + const entries = subcommands.get(parent) ?? []; + if (!entries.some((candidate) => candidate.name === childName)) { + entries.push(commands.get(childName) ?? { ...entry, name: childName, summary: undefined, since: undefined }); + } + subcommands.set(parent, entries); + } + } + + return { + commands, + mainCommands: [...mainCommands.values()], + subcommands, + }; +} + +const completionIndexes = new WeakMap, CompletionIndex>(); + +function completionIndex(input: Pick): CompletionIndex { + const cached = completionIndexes.get(input.commands); + if (cached) return cached; + const index = createCompletionIndex(input.commands); + completionIndexes.set(input.commands, index); + return index; +} + function matchesPrefix(value: string, prefix: string): boolean { return value.toLowerCase().startsWith(prefix.toLowerCase()); } -function buildSpecDetail(spec: RedisCommandSpec): string { - return spec.safety === "allowed" ? spec.group : `${spec.group} · ${spec.safety}`; +function buildSpecDetail(entry: CompletionCommandEntry): string { + return entry.group; } -function buildSpecInfo(spec: RedisCommandSpec, label: string): string { - return [`Command: ${label}`, `Group: ${spec.group}`, `Arity: ${describeArity(spec.arity)}`, `Safety: ${spec.safety}`].join("\n"); +function buildSpecInfo(entry: CompletionCommandEntry, label: string): string { + const info = []; + if (entry.summary) info.push(entry.summary); + info.push(`Command: ${label}`, `Group: ${entry.group}`, `Arity: ${describeArity(entry.arity)}`); + if (entry.since) info.push(`Since: ${entry.since}`); + return info.join("\n"); } -function boostFor(spec: RedisCommandSpec): number { - return GROUP_BOOST[spec.group] ?? 90; +function boostFor(entry: CompletionCommandEntry): number { + return GROUP_BOOST[entry.group] ?? 90; } // ---- Context parsing ---- -export function getRedisCompletionContext(text: string, cursor: number): RedisCompletionContext { +export function getRedisCompletionContext(text: string, cursor: number, input: Pick): RedisCompletionContext { + const index = completionIndex(input); const safeCursor = Math.max(0, Math.min(cursor, text.length)); const lineStart = text.lastIndexOf("\n", safeCursor - 1) + 1; const beforeCursor = text.slice(lineStart, safeCursor); - // Tokenize the part before the cursor by whitespace. - const tokens = beforeCursor.trimStart().length === 0 ? [] : beforeCursor.trim().split(/\s+/); + const tokenized = tokenizeRedisLine(beforeCursor); const endsWithSpace = beforeCursor.length > 0 && /\s$/.test(beforeCursor); - - // Current word being typed (no trailing space yet). - const currentWord = endsWithSpace ? "" : (tokens[tokens.length - 1] ?? ""); - const wordStartFromEnd = currentWord.length; - const from = safeCursor - wordStartFromEnd; - + const currentToken = endsWithSpace ? undefined : tokenized.argv[tokenized.argv.length - 1]; + const currentWord = currentToken?.value ?? ""; + const from = currentToken ? lineStart + currentToken.startColumn - 1 : safeCursor; + const tokens = tokenized.argv.map((token) => token.value); const typedTokens = endsWithSpace ? tokens : tokens.slice(0, -1); // No command yet (or typing the very first token). @@ -133,45 +174,73 @@ export function getRedisCompletionContext(text: string, cursor: number): RedisCo return { mode: "command", prefix: currentWord, from }; } - const main = typedTokens[0]!.toUpperCase(); + const normalizedTokens = typedTokens.map((token) => token.toUpperCase()); + const main = normalizedTokens[0]!; + const commandPrefix = normalizedTokens.join(" "); - // First token done + space → maybe a subcommand of a command that has them. - if (typedTokens.length === 1 && SUBCOMMAND_MAINS.has(main)) { - return { mode: "subcommand", prefix: currentWord, from, mainCommand: main }; + // Command docs can nest subcommands more than one level deep. Treat every + // documented command prefix as a potential next-token completion context. + if (index.subcommands.has(commandPrefix)) { + return { mode: "subcommand", prefix: currentWord, from, mainCommand: main, commandName: commandPrefix }; } - // Past the command (and any subcommand slot) → an argument. Track which - // argument position the cursor is at so we only suggest key names for the - // first key argument (e.g. GET , not after the key is filled in). - const commandHeadTokens = typedTokens.length >= 2 && REDIS_COMMAND_TABLE[`${main} ${typedTokens[1]!.toUpperCase()}`] ? 2 : 1; + let commandName: string | undefined; + for (let tokenCount = normalizedTokens.length; tokenCount > 0; tokenCount--) { + const candidate = normalizedTokens.slice(0, tokenCount).join(" "); + if (!index.commands.has(candidate)) continue; + commandName = candidate; + break; + } + const commandHeadTokens = commandName ? commandName.split(" ").length : normalizedTokens.length; const argumentIndex = Math.max(typedTokens.length - commandHeadTokens, 0); - return { mode: "argument", prefix: currentWord, from, mainCommand: main, argumentIndex }; + return { + mode: "argument", + prefix: currentWord, + from, + mainCommand: main, + commandName, + argumentIndex, + argumentValues: typedTokens.slice(commandHeadTokens), + }; } // ---- Item builders ---- -function commandItems(prefix: string): RedisCompletionItem[] { - const items = MAIN_COMMANDS.filter((entry) => matchesPrefix(entry.name, prefix)).map((entry) => ({ - label: entry.name, - type: "keyword" as const, - detail: buildSpecDetail(entry.spec), - info: buildSpecInfo(entry.spec, entry.name), - boost: boostFor(entry.spec), - })); +function commandItems(index: CompletionIndex, prefix: string): RedisCompletionItem[] { + const items = index.mainCommands + .filter((entry) => matchesPrefix(entry.name, prefix)) + .map((entry) => ({ + label: entry.name, + type: "keyword" as const, + detail: buildSpecDetail(entry), + info: buildSpecInfo(entry, entry.name), + summary: entry.summary, + since: entry.since, + boost: boostFor(entry), + })); return items.sort((a, b) => b.boost - a.boost); } -function subcommandItems(main: string, prefix: string): RedisCompletionItem[] { - const items = SUBCOMMANDS.filter((entry) => entry.main === main && matchesPrefix(entry.sub, prefix)).map((entry) => ({ - label: entry.sub, - type: "keyword" as const, - detail: buildSpecDetail(entry.spec), - info: buildSpecInfo(entry.spec, `${main} ${entry.sub}`), - boost: boostFor(entry.spec), - })); +function subcommandItems(index: CompletionIndex, commandPrefix: string, prefix: string): RedisCompletionItem[] { + const items = (index.subcommands.get(commandPrefix) ?? []) + .filter((entry) => matchesPrefix(entry.name.slice(commandPrefix.length + 1), prefix)) + .map((entry) => ({ + label: entry.name.slice(commandPrefix.length + 1), + type: "keyword" as const, + detail: buildSpecDetail(entry), + info: buildSpecInfo(entry, entry.name), + summary: entry.summary, + since: entry.since, + boost: boostFor(entry), + })); return items.sort((a, b) => b.boost - a.boost); } +function redisArgumentApply(value: string): string { + if (value && !/[\s"']/.test(value) && !value.endsWith(";")) return value; + return `"${value.replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t").replaceAll('"', '\\"')}"`; +} + function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] { if (!prefix) { // No partial key typed yet: offer a bounded sample (sorted) so the menu isn't empty. @@ -179,6 +248,7 @@ function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] { label: key, type: "text" as const, detail: "key", + apply: redisArgumentApply(key), boost: 60, })); } @@ -189,42 +259,205 @@ function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] { label: key, type: "text" as const, detail: "key", + apply: redisArgumentApply(key), boost: key.toLowerCase().startsWith(prefix.toLowerCase()) ? 70 : 55, })); } -export function buildRedisCompletionItemsFromContext(context: RedisCompletionContext, input: RedisCompletionInput = {}): RedisCompletionItem[] { - if (context.mode === "command") return commandItems(context.prefix); - if (context.mode === "subcommand" && context.mainCommand) { - return subcommandItems(context.mainCommand, context.prefix); +interface ArgumentTransition { + to: number; + token?: string; + argument?: RedisCommandArgument; +} + +interface ArgumentGrammar { + start: number; + transitions: Map; +} + +const argumentGrammars = new WeakMap, ArgumentGrammar>(); + +function argumentGrammar(arguments_: readonly RedisCommandArgument[]): ArgumentGrammar { + const cached = argumentGrammars.get(arguments_); + if (cached) return cached; + const transitions = new Map(); + let nextState = 0; + const state = () => nextState++; + const add = (from: number, transition: ArgumentTransition) => transitions.set(from, [...(transitions.get(from) ?? []), transition]); + const epsilon = (from: number, to: number) => add(from, { to }); + + const compileSequence = (arguments_: readonly RedisCommandArgument[], start: number, end: number) => { + let from = start; + arguments_.forEach((argument, index) => { + const to = index === arguments_.length - 1 ? end : state(); + compileArgument(argument, from, to); + from = to; + }); + if (arguments_.length === 0) epsilon(start, end); + }; + const compileContent = (argument: RedisCommandArgument, start: number, end: number) => { + if (argument.type === "pure-token") { + epsilon(start, end); + } else if (argument.type === "block") { + compileSequence(argument.arguments ?? [], start, end); + } else if (argument.type === "oneof") { + for (const child of argument.arguments ?? []) compileArgument(child, start, end); + } else if (argument.enum?.length) { + for (const value of argument.enum) add(start, { to: end, token: value.toUpperCase(), argument }); + } else { + add(start, { to: end, argument }); + } + }; + const compileCore = (argument: RedisCommandArgument, start: number, end: number) => { + if (!argument.token) return compileContent(argument, start, end); + const contentStart = state(); + add(start, { to: contentStart, token: argument.token.toUpperCase(), argument }); + compileContent(argument, contentStart, end); + }; + function compileArgument(argument: RedisCommandArgument, start: number, end: number) { + if (argument.optional) epsilon(start, end); + if (!argument.multiple) { + compileCore(argument, start, end); + return; + } + if (argument.token && !argument.multipleToken && argument.type !== "pure-token") { + const contentStart = state(); + const repeated = state(); + add(start, { to: contentStart, token: argument.token.toUpperCase(), argument }); + compileContent(argument, contentStart, repeated); + epsilon(repeated, end); + epsilon(repeated, contentStart); + return; + } + const repeated = state(); + compileCore(argument, start, repeated); + epsilon(repeated, end); + epsilon(repeated, start); } - // argument mode: offer key names at the key-argument slot only. Most key - // commands take a single key (first slot); variadic key-list commands - // (DEL/UNLINK/EXISTS/...) keep suggesting at every slot. - if (context.mode === "argument" && takesKeyArgument(context.mainCommand) && shouldSuggestKeyAt(context.mainCommand, context.argumentIndex)) { + + const start = state(); + const end = state(); + compileSequence(arguments_, start, end); + const grammar = { start, transitions }; + argumentGrammars.set(arguments_, grammar); + return grammar; +} + +function argumentTokenItems(entry: CompletionCommandEntry, values: readonly string[], prefix: string): RedisCompletionItem[] { + if (entry.arguments.length === 0) return []; + const { start, transitions } = argumentGrammar(entry.arguments); + const closure = (states: ReadonlySet) => { + const result = new Set(states); + const pending = [...states]; + while (pending.length) { + for (const transition of transitions.get(pending.pop()!) ?? []) { + if (transition.token || transition.argument || result.has(transition.to)) continue; + result.add(transition.to); + pending.push(transition.to); + } + } + return result; + }; + + let active = closure(new Set([start])); + for (const value of values) { + const next = new Set(); + for (const current of active) { + for (const transition of transitions.get(current) ?? []) { + if (transition.token ? transition.token === value.toUpperCase() : transition.argument) next.add(transition.to); + } + } + active = closure(next); + if (active.size === 0) return []; + } + + const items = new Map(); + for (const current of active) { + for (const transition of transitions.get(current) ?? []) { + if (!transition.token || !matchesPrefix(transition.token, prefix)) continue; + const argument = transition.argument; + const info = [argument?.summary, entry.summary, argument?.since ? `Since: ${argument.since}` : undefined].filter(Boolean).join("\n"); + items.set(transition.token, { + label: transition.token, + type: "keyword", + detail: [argument?.name, argument?.type].filter(Boolean).join(" · "), + info: info || undefined, + apply: transition.token, + appendSpace: true, + boost: 80, + }); + } + } + return [...items.values()]; +} + +export function buildRedisCompletionItemsFromContext(context: RedisCompletionContext, input: RedisCompletionInput): RedisCompletionItem[] { + const index = completionIndex(input); + if (context.mode === "command") return commandItems(index, context.prefix); + if (context.mode === "subcommand" && context.commandName) { + return subcommandItems(index, context.commandName, context.prefix); + } + if (context.mode === "argument" && takesKeyArgument(context.commandName, input, context.argumentIndex, context.argumentValues)) { return keyItems(context.prefix, input.keys ?? []); } + if (context.mode === "argument" && context.commandName) { + const entry = index.commands.get(context.commandName); + if (entry) return argumentTokenItems(entry, context.argumentValues ?? [], context.prefix); + } return []; } -function shouldSuggestKeyAt(mainCommand: string | undefined, argumentIndex: number | undefined): boolean { - if (argumentIndex == null) return false; - if (mainCommand && MULTI_KEY_COMMANDS.has(mainCommand)) return true; - return argumentIndex === 0; +function keySpecStart(spec: RedisCommandKeySpec, argumentIndex: number, argumentsBeforeCursor: readonly string[]): number | undefined { + const beginSearch = spec.beginSearch; + if (beginSearch.type === "index") return beginSearch.index - 1; + const searchStart = beginSearch.startFrom >= 0 ? Math.max(0, beginSearch.startFrom - 1) : Math.max(0, Math.max(argumentsBeforeCursor.length, argumentIndex + 1) + beginSearch.startFrom); + if (beginSearch.startFrom < 0) { + for (let index = Math.min(searchStart, argumentsBeforeCursor.length - 1); index >= 0; index--) { + if (argumentsBeforeCursor[index]?.toUpperCase() === beginSearch.keyword) return index + 1; + } + return undefined; + } + const keywordIndex = argumentsBeforeCursor.findIndex((argument, index) => index >= searchStart && argument.toUpperCase() === beginSearch.keyword); + return keywordIndex < 0 ? undefined : keywordIndex + 1; } -export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput = {}): RedisCompletionItem[] { - return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor), input); +function keySpecMatchesArgument(spec: RedisCommandKeySpec, argumentIndex: number, argumentsBeforeCursor: readonly string[]): boolean { + const start = keySpecStart(spec, argumentIndex, argumentsBeforeCursor); + if (start == null || argumentIndex < start) return false; + + if (spec.findKeys.type === "range") { + // Redis uses negative lastkey values relative to the final argv position. + // The active argument is included so a completed tail argument is never + // offered as a key (for example, BLPOP's timeout). + const argumentCount = Math.max(argumentsBeforeCursor.length, argumentIndex + 1); + let last = spec.findKeys.lastKey < 0 ? argumentCount + spec.findKeys.lastKey : start + spec.findKeys.lastKey; + if (argumentIndex === start && argumentsBeforeCursor.length === start) last = start; + if (spec.findKeys.lastKey === -1 && spec.findKeys.limit > 1) { + const completedAfterStart = Math.max(0, argumentsBeforeCursor.length - start); + // With no key entered yet, the first key is unambiguous. Once arguments + // exist, `limit` separates the key list from its equally sized tail. + last = completedAfterStart === 0 ? start : start + (Math.ceil(completedAfterStart / spec.findKeys.limit) - 1) * spec.findKeys.keyStep; + } + return argumentIndex <= last && (argumentIndex - start) % spec.findKeys.keyStep === 0; + } + + const keyCountArgument = argumentsBeforeCursor[start + spec.findKeys.keyNumIndex]; + if (!keyCountArgument || !/^\d+$/.test(keyCountArgument)) return false; + const keyCount = Number(keyCountArgument); + const firstKey = start + spec.findKeys.firstKey; + const lastKey = firstKey + Math.max(0, keyCount - 1) * spec.findKeys.keyStep; + return argumentIndex >= firstKey && argumentIndex <= lastKey && (argumentIndex - firstKey) % spec.findKeys.keyStep === 0; } -/** True when the main command's first argument is a key (by group heuristic). */ -export function takesKeyArgument(mainCommand?: string): boolean { - if (!mainCommand) return false; - const spec = REDIS_COMMAND_TABLE[mainCommand]; - if (spec) return KEY_ARGUMENT_GROUPS.has(spec.group); - // A main that only exists via subcommands (e.g. XGROUP, CONFIG): treat stream/cluster - // subcommand roots as key-taking only for the stream group, conservatively. - return false; +export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput): RedisCompletionItem[] { + return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor, input), input); +} + +/** True when the server's key specs identify the active argument as a key. */ +export function takesKeyArgument(commandName: string | undefined, input: Pick, argumentIndex = 0, argumentsBeforeCursor: readonly string[] = []): boolean { + if (!commandName || argumentIndex < 0) return false; + const command = completionIndex(input).commands.get(commandName.toUpperCase()); + return command?.keySpecs.some((spec) => keySpecMatchesArgument(spec, argumentIndex, argumentsBeforeCursor)) ?? false; } export function shouldAutoOpenRedisCompletion(text: string, cursor: number): boolean { diff --git a/apps/desktop/src/lib/redis/redisSyntaxDiagnostics.ts b/apps/desktop/src/lib/redis/redisSyntaxDiagnostics.ts index 688e5cbc5..1468f2d81 100644 --- a/apps/desktop/src/lib/redis/redisSyntaxDiagnostics.ts +++ b/apps/desktop/src/lib/redis/redisSyntaxDiagnostics.ts @@ -9,108 +9,9 @@ import type { SqlTextSpan } from "@/types/database"; import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics"; import { resolveRedisCommandSpec } from "@/lib/redis/redisCommandTable"; +import { tokenizeRedisLine } from "@/lib/redis/redisCommandTokenizer"; -export interface RedisArgvToken { - /** Token text (raw, case preserved). */ - value: string; - /** 1-based start/end character columns within the source line. */ - startColumn: number; - endColumn: number; -} - -export interface RedisArgvResult { - argv: RedisArgvToken[]; - /** True when a quoted string was not closed before end-of-line. */ - unclosedQuote: boolean; - /** Column where an unclosed quote started (1-based), if any. */ - unclosedQuoteStart?: number; -} - -/** - * Tokenize a single Redis command line into argv, mirroring the server-side - * `parse_command_argv` rules (whitespace separation, single/double quotes, - * backslash escapes). Trailing `;` is stripped. Each token records its column - * span so diagnostics can underline the offending token. - */ -export function tokenizeRedisLine(line: string): RedisArgvResult { - const argv: RedisArgvToken[] = []; - let i = 0; - const n = line.length; - let unclosedQuote = false; - let unclosedQuoteStart: number | undefined; - - while (i < n) { - // Skip whitespace. - while (i < n && (line[i] === " " || line[i] === "\t")) i++; - if (i >= n) break; - - const startColumn = i + 1; // 1-based - let value = ""; - let closed = false; - const ch = line[i]; - - if (ch === '"' || ch === "'") { - if (unclosedQuoteStart === undefined) unclosedQuoteStart = startColumn; - const quote = ch; - i++; // consume opening quote - let escaping = false; - while (i < n) { - const c = line[i]; - if (escaping) { - value += c; - escaping = false; - i++; - continue; - } - if (c === "\\") { - escaping = true; - i++; - continue; - } - if (c === quote) { - i++; // consume closing quote - closed = true; - break; - } - value += c; - i++; - } - if (!closed && i >= n) { - unclosedQuote = true; - } else { - // If the quote was closed, allow trailing chars until whitespace as part - // of the same token (e.g. `"a"b` → `ab`) to match common tokenizers. - while (i < n && line[i] !== " " && line[i] !== "\t") { - value += line[i]; - i++; - } - } - } else { - closed = true; // unquoted tokens are always complete - while (i < n && line[i] !== " " && line[i] !== "\t") { - if (line[i] === "\\") { - i++; - if (i < n) { - value += line[i]; - i++; - } - continue; - } - value += line[i]; - i++; - } - } - - // Strip a single trailing semicolon (line-level `;` terminator). - if (value.endsWith(";")) value = value.slice(0, -1); - - if (value.length > 0 || !closed) { - argv.push({ value, startColumn, endColumn: i + 1 }); - } - } - - return { argv, unclosedQuote, unclosedQuoteStart }; -} +export { tokenizeRedisLine, type RedisArgvResult, type RedisArgvToken } from "@/lib/redis/redisCommandTokenizer"; function aritySatisfied(arity: number, tokenCount: number): boolean { if (arity > 0) return tokenCount === arity; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index ae1a87fe4..ed062fc58 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -36,6 +36,17 @@ function mysqlConnection(): ConnectionConfig { } as ConnectionConfig; } +function redisConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "redis-1", + name: "Redis", + db_type: "redis", + port: 6379, + database: "0", + } as ConnectionConfig; +} + function oracleConnection(): ConnectionConfig { return { ...postgresConnection(), @@ -153,6 +164,145 @@ describe("connectionStore completion assistant", () => { expect(tables).toEqual([{ name: "users", schema: "public", type: "table" }]); }); + it("falls back to the server COMMAND catalog when COMMAND DOCS is unsupported", async () => { + const redisExecuteCommand = vi + .fn() + .mockRejectedValueOnce(new Error("unknown subcommand 'DOCS'")) + .mockResolvedValueOnce({ + command: "COMMAND", + safety: "allowed", + value: [["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []]], + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + redisExecuteCommand, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [redisConnection()]; + store.connectedIds.add("redis-1"); + + const docs = await store.listRedisCompletionCommandDocs("redis-1", "0"); + const cached = await store.listRedisCompletionCommandDocs("redis-1", "0"); + + expect(redisExecuteCommand).toHaveBeenNthCalledWith(1, "redis-1", 0, "COMMAND DOCS"); + expect(redisExecuteCommand).toHaveBeenNthCalledWith(2, "redis-1", 0, "COMMAND"); + expect(redisExecuteCommand).toHaveBeenCalledTimes(2); + expect(docs).toEqual([ + { + name: "GET", + summary: undefined, + since: undefined, + group: undefined, + arity: 2, + keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }], + }, + ]); + expect(cached).toEqual(docs); + }); + + it("merges COMMAND key positions into COMMAND DOCS metadata", async () => { + const redisExecuteCommand = vi + .fn() + .mockResolvedValueOnce({ + command: "COMMAND DOCS", + safety: "allowed", + value: { get: { summary: "Returns a value.", arguments: [{ name: "key", type: "key" }] } }, + }) + .mockResolvedValueOnce({ + command: "COMMAND", + safety: "allowed", + value: [["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []]], + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + redisExecuteCommand, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [redisConnection()]; + store.connectedIds.add("redis-1"); + + const docs = await store.listRedisCompletionCommandDocs("redis-1", "0"); + + expect(docs[0]).toMatchObject({ + name: "GET", + arity: 2, + keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }], + }); + }); + + it("does not let an invalidated Redis metadata request overwrite fresh cache", async () => { + const staleDocs = deferred<{ command: string; safety: string; value: unknown }>(); + const freshDocs = deferred<{ command: string; safety: string; value: unknown }>(); + let docsRequestCount = 0; + const redisExecuteCommand = vi.fn((_connectionId: string, _database: number, command: string) => { + if (command === "COMMAND DOCS") { + docsRequestCount += 1; + return docsRequestCount === 1 ? staleDocs.promise : freshDocs.promise; + } + return Promise.resolve({ + command: "COMMAND", + safety: "allowed", + value: [["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []]], + }); + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + redisExecuteCommand, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [redisConnection()]; + store.connectedIds.add("redis-1"); + + const staleRequest = store.listRedisCompletionCommandDocs("redis-1", "0"); + await vi.waitFor(() => expect(redisExecuteCommand).toHaveBeenCalledTimes(1)); + store.invalidateCompletionCache("redis-1"); + const freshRequest = store.listRedisCompletionCommandDocs("redis-1", "0"); + freshDocs.resolve({ command: "COMMAND DOCS", safety: "allowed", value: { get: { summary: "fresh" } } }); + await expect(freshRequest).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: "GET", summary: "fresh" })])); + + staleDocs.resolve({ command: "COMMAND DOCS", safety: "allowed", value: { get: { summary: "stale" } } }); + await staleRequest; + + await expect(store.listRedisCompletionCommandDocs("redis-1", "0")).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: "GET", summary: "fresh" })])); + }); + + it("does not suggest binary Redis key displays that the command input cannot execute", async () => { + const redisScanKeysBatch = vi.fn().mockResolvedValue({ + cursor: 0, + keys: [ + { key_display: "plain", key_raw: "cGxhaW4=", key_type: "string", ttl: -1 }, + { key_display: String.raw`literal\\xAC`, key_raw: "bGl0ZXJhbFx4QUM=", key_type: "string", ttl: -1 }, + { key_display: "\\xac", key_raw: "rA==", key_type: "string", ttl: -1 }, + ], + total_keys: 3, + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + redisScanKeysBatch, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [redisConnection()]; + store.connectedIds.add("redis-1"); + + await expect(store.listRedisCompletionKeys("redis-1", "0")).resolves.toEqual(["plain", String.raw`literal\\xAC`]); + }); + it("preserves TDengine stable type in completion metadata", async () => { const listTables = vi.fn().mockResolvedValue([ { name: "test_tb", table_type: "STABLE", comment: null }, diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index ab71a51cb..3e31a7359 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -98,6 +98,7 @@ import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/sch import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering"; import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; +import { mergeRedisCommandDocumentation, parseRedisCommandCatalog, parseRedisCommandDocumentation, type RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs"; import { useSettingsStore } from "@/stores/settingsStore"; import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers"; import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion"; @@ -355,6 +356,8 @@ export const useConnectionStore = defineStore("connection", () => { const sqlServerCompletionContextCache = ref>({}); const elasticsearchCompletionIndicesCache = ref>({}); const redisCompletionKeysCache = ref>({}); + const redisCommandDocsCache = ref>({}); + const redisCommandDocsCacheGeneration = new Map(); const mongoCompletionCollectionsCache = ref>({}); const mongoCompletionFieldsCache = ref>({}); const schemaListCache = ref>({}); @@ -2378,6 +2381,10 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of Object.keys(redisCompletionKeysCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key]; } + if (database == null) { + delete redisCommandDocsCache.value[connectionId]; + redisCommandDocsCacheGeneration.set(connectionId, (redisCommandDocsCacheGeneration.get(connectionId) ?? 0) + 1); + } for (const key of Object.keys(mongoCompletionCollectionsCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionCollectionsCache.value[key]; } @@ -6214,6 +6221,8 @@ export const useConnectionStore = defineStore("connection", () => { // Upper bound on cached key names per db, to keep completion memory bounded // (Redis can hold far more keys than we ever want resident for autocomplete). const REDIS_COMPLETION_KEYS_MAX = 1000; + // `\\xNN` is binary only when it has an even number of preceding slashes. + const BINARY_REDIS_KEY_ESCAPE = /(^|[^\\])(?:\\\\)*\\x[0-9a-f]{2}/i; async function listRedisCompletionKeys(connectionId: string, database: string): Promise { if (!database) return []; @@ -6225,13 +6234,51 @@ export const useConnectionStore = defineStore("connection", () => { const pageSize = getConfig(connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT; // Bounded multi-round SCAN: trade coverage for latency/memory safety. const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6, false); - const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX); + const keys = result.keys + .map((key) => key.key_display) + .filter((key) => !BINARY_REDIS_KEY_ESCAPE.test(key)) + .slice(0, REDIS_COMPLETION_KEYS_MAX); redisCompletionKeysCache.value[cacheKey] = keys; evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX); return keys; }); } + async function listRedisCompletionCommandDocs(connectionId: string, database: string): Promise { + const cached = redisCommandDocsCache.value[connectionId]; + if (cached) return cached; + return withCompletionInFlight(`${connectionId}:redis-command-docs`, async () => { + const generation = redisCommandDocsCacheGeneration.get(connectionId) ?? 0; + await ensureConnected(connectionId); + const db = Number.parseInt(database, 10) || 0; + let docs: RedisCommandDocumentation[]; + try { + // Redis recommends COMMAND DOCS for complete, version-aware client metadata. + const docsResult = await api.redisExecuteCommand(connectionId, db, "COMMAND DOCS"); + docs = parseRedisCommandDocumentation(docsResult.value); + try { + const catalogResult = await api.redisExecuteCommand(connectionId, db, "COMMAND"); + docs = mergeRedisCommandDocumentation(docs, parseRedisCommandCatalog(catalogResult.value)); + } catch { + // Documentation still provides useful grammar when COMMAND is restricted. + } + } catch (docsError) { + // Redis before 7.0 lacks COMMAND DOCS; COMMAND still reports its actual command inventory. + try { + const result = await api.redisExecuteCommand(connectionId, db, "COMMAND"); + docs = parseRedisCommandCatalog(result.value); + } catch { + throw docsError; + } + } + if ((redisCommandDocsCacheGeneration.get(connectionId) ?? 0) === generation) { + redisCommandDocsCache.value[connectionId] = docs; + evictOldestCacheEntries(redisCommandDocsCache.value, COMPLETION_CACHE_MAX); + } + return docs; + }); + } + async function listMongoCompletionCollections(connectionId: string, database: string): Promise { if (!database) return []; const cacheKey = `${connectionId}:${database}`; @@ -7436,6 +7483,7 @@ export const useConnectionStore = defineStore("connection", () => { refreshCompletionDatabases, listElasticsearchCompletionIndices, listRedisCompletionKeys, + listRedisCompletionCommandDocs, listMongoCompletionCollections, listMongoCompletionFields, invalidateCompletionCache, diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index bc1f9a95d..e47342651 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -1561,9 +1561,11 @@ pub fn parse_command_argv(command_text: &str) -> Result, String> { let mut chars = command_text.chars().peekable(); let mut quote: Option = None; let mut escaping = false; + let mut token_started = false; while let Some(ch) = chars.next() { if escaping { + token_started = true; current.push(match ch { 'n' => '\n', 'r' => '\r', @@ -1575,6 +1577,7 @@ pub fn parse_command_argv(command_text: &str) -> Result, String> { } if ch == '\\' { + token_started = true; escaping = true; continue; } @@ -1589,20 +1592,23 @@ pub fn parse_command_argv(command_text: &str) -> Result, String> { } if ch == '"' || ch == '\'' { + token_started = true; quote = Some(ch); continue; } if ch.is_whitespace() { - if !current.is_empty() { + if token_started { argv.push(std::mem::take(&mut current)); } + token_started = false; while matches!(chars.peek(), Some(next) if next.is_whitespace()) { chars.next(); } continue; } + token_started = true; current.push(ch); } @@ -1612,10 +1618,10 @@ pub fn parse_command_argv(command_text: &str) -> Result, String> { if quote.is_some() { return Err("Redis command has an unterminated quote".to_string()); } - if !current.is_empty() { + if token_started { argv.push(current); } - if argv.is_empty() { + if argv.first().is_none_or(String::is_empty) { return Err("Redis command is empty".to_string()); } Ok(argv) @@ -5052,6 +5058,13 @@ mod tests { assert_eq!(argv, vec!["SET", "user:1", "Ada \"Lovelace\""]); } + #[test] + fn parses_empty_quoted_arguments() { + let argv = parse_command_argv(r#"SET "" """#).unwrap(); + + assert_eq!(argv, vec!["SET", "", ""]); + } + #[test] fn parses_quoted_and_escaped_command_names_before_classification() { for command_text in [r#""JSON.SET" user:1 $ {}"#, r#"JSON\.SET user:1 $ {}"#] { @@ -5062,7 +5075,9 @@ mod tests { #[test] fn rejects_empty_command_text() { - assert_eq!(parse_command_argv(" ").unwrap_err(), "Redis command is empty"); + for command_text in [" ", "\"\""] { + assert_eq!(parse_command_argv(command_text).unwrap_err(), "Redis command is empty"); + } } #[tokio::test] diff --git a/packages/app-tests/redisCommandDocs.test.ts b/packages/app-tests/redisCommandDocs.test.ts new file mode 100644 index 000000000..e0e1ac96a --- /dev/null +++ b/packages/app-tests/redisCommandDocs.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { mergeRedisCommandDocumentation, parseRedisCommandCatalog, parseRedisCommandDocumentation } from "../../apps/desktop/src/lib/redis/redisCommandDocs.ts"; + +test("parses COMMAND DOCS maps emitted by the Redis bridge", () => { + const docs = parseRedisCommandDocumentation([ + { + key: "get", + value: [ + { key: "summary", value: "Returns the string value of a key." }, + { key: "since", value: "1.0.0" }, + { key: "group", value: "string" }, + { key: "arity", value: 2 }, + { + key: "key_specs", + value: [ + [ + { + key: "begin_search", + value: [ + { key: "type", value: "index" }, + { key: "spec", value: [{ key: "index", value: 1 }] }, + ], + }, + { + key: "find_keys", + value: [ + { key: "type", value: "range" }, + { + key: "spec", + value: [ + { key: "lastkey", value: 0 }, + { key: "keystep", value: 1 }, + ], + }, + ], + }, + ], + ], + }, + ], + }, + { + key: "acl cat", + value: [ + { key: "summary", value: "Lists ACL categories." }, + { key: "group", value: "server" }, + { key: "arity", value: -2 }, + ], + }, + ]); + + assert.deepEqual(docs, [ + { name: "ACL CAT", summary: "Lists ACL categories.", since: undefined, group: "server", arity: -2, keySpecs: [] }, + { + name: "GET", + summary: "Returns the string value of a key.", + since: "1.0.0", + group: "string", + arity: 2, + keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }], + }, + ]); +}); + +test("parses nested subcommands from a COMMAND DOCS response", () => { + const docs = parseRedisCommandDocumentation([ + { + key: "acl", + value: [ + { key: "summary", value: "A container for Access List Control commands." }, + { key: "group", value: "server" }, + { key: "arity", value: -2 }, + { + key: "subcommands", + value: [ + { + key: "acl|cat", + value: [ + { key: "summary", value: "Lists ACL categories." }, + { key: "group", value: "server" }, + { key: "arity", value: -2 }, + ], + }, + ], + }, + ], + }, + ]); + + assert.deepEqual(docs, [ + { name: "ACL", summary: "A container for Access List Control commands.", since: undefined, group: "server", arity: -2, keySpecs: [] }, + { name: "ACL CAT", summary: "Lists ACL categories.", since: undefined, group: "server", arity: -2, keySpecs: [] }, + ]); +}); + +test("parses COMMAND DOCS maps returned through RESP2", () => { + const docs = parseRedisCommandDocumentation(["get", ["summary", "Returns the string value of a key.", "since", "1.0.0", "group", "string", "arity", 2, "key_specs", [["begin_search", ["type", "index", "spec", ["index", 1]], "find_keys", ["type", "range", "spec", ["lastkey", 0, "keystep", 1]]]]]]); + + assert.deepEqual(docs, [ + { + name: "GET", + summary: "Returns the string value of a key.", + since: "1.0.0", + group: "string", + arity: 2, + keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }], + }, + ]); +}); + +test("parses keyword and key-count specs from COMMAND DOCS", () => { + const docs = parseRedisCommandDocumentation({ + xread: { + key_specs: [ + { + begin_search: { type: "keyword", spec: { keyword: "STREAMS", startfrom: 1 } }, + find_keys: { type: "range", spec: { lastkey: -1, keystep: 1, limit: 2 } }, + }, + ], + }, + eval: { + key_specs: [ + { + begin_search: { type: "index", spec: { index: 2 } }, + find_keys: { type: "keynum", spec: { keynumidx: 0, firstkey: 1, keystep: 1 } }, + }, + ], + }, + migrate: { + key_specs: [ + { + begin_search: { type: "keyword", spec: { keyword: "KEYS", startfrom: -2 } }, + find_keys: { type: "range", spec: { lastkey: -1, keystep: 1, limit: 0 } }, + }, + ], + }, + }); + + assert.deepEqual(docs, [ + { name: "EVAL", summary: undefined, since: undefined, group: undefined, arity: undefined, keySpecs: [{ beginSearch: { type: "index", index: 2 }, findKeys: { type: "keynum", keyNumIndex: 0, firstKey: 1, keyStep: 1 } }] }, + { name: "MIGRATE", summary: undefined, since: undefined, group: undefined, arity: undefined, keySpecs: [{ beginSearch: { type: "keyword", keyword: "KEYS", startFrom: -2 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 0 } }] }, + { name: "XREAD", summary: undefined, since: undefined, group: undefined, arity: undefined, keySpecs: [{ beginSearch: { type: "keyword", keyword: "STREAMS", startFrom: 1 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 2 } }] }, + ]); +}); + +test("parses the recursive argument grammar from COMMAND DOCS", () => { + const [xread] = parseRedisCommandDocumentation({ + xread: { + arguments: [ + { name: "count", token: "count", type: "integer", summary: "Limits the number of entries.", since: "5.0.0", optional: 1 }, + { + name: "streams", + token: "STREAMS", + type: "block", + arguments: [ + { name: "key", type: "key", multiple: 1, multiple_token: 1 }, + { name: "ID", type: "string", multiple: true }, + ], + }, + { + name: "condition", + type: "oneof", + optional: "1", + arguments: [ + { name: "nx", token: "NX", type: "pure-token" }, + { name: "xx", token: "XX", type: "pure-token" }, + ], + }, + ], + }, + }); + + assert.deepEqual(xread?.arguments, [ + { name: "count", token: "COUNT", type: "integer", summary: "Limits the number of entries.", since: "5.0.0", optional: true }, + { + name: "streams", + token: "STREAMS", + type: "block", + arguments: [ + { name: "key", type: "key", multiple: true, multipleToken: true }, + { name: "ID", type: "string", multiple: true }, + ], + }, + { + name: "condition", + type: "oneof", + optional: true, + arguments: [ + { name: "nx", token: "NX", type: "pure-token" }, + { name: "xx", token: "XX", type: "pure-token" }, + ], + }, + ]); +}); + +test("parses argument behavior from Redis flags arrays", () => { + const [command] = parseRedisCommandDocumentation({ + example: { + arguments: [ + { name: "key", type: "key", flags: ["optional", "multiple"] }, + { name: "value", type: "string", flags: ["multiple-token"] }, + ], + }, + }); + + assert.deepEqual(command?.arguments, [ + { name: "key", type: "key", optional: true, multiple: true }, + { name: "value", type: "string", multipleToken: true }, + ]); +}); + +test("parses the legacy COMMAND catalog including nested subcommands", () => { + const docs = parseRedisCommandCatalog([ + ["get", 2, ["readonly", "fast"], 1, 1, 1, ["@read"], [], [], []], + ["blpop", -3, ["write"], 1, -2, 1, ["@write"], [], [], []], + ["acl", -2, ["admin"], 0, 0, 0, ["@admin"], [], [], [["acl|cat", -2, ["readonly"], 0, 0, 0, ["@read"], [], [], []]]], + ]); + + assert.deepEqual(docs, [ + { name: "ACL", summary: undefined, since: undefined, group: undefined, arity: -2, keySpecs: [] }, + { name: "ACL CAT", summary: undefined, since: undefined, group: undefined, arity: -2, keySpecs: [] }, + { name: "BLPOP", summary: undefined, since: undefined, group: undefined, arity: -3, keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: -2, keyStep: 1, limit: 0 } }] }, + { name: "GET", summary: undefined, since: undefined, group: undefined, arity: 2, keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }] }, + ]); +}); + +test("fills COMMAND DOCS key positions and arity from COMMAND", () => { + const docs = parseRedisCommandDocumentation({ + get: { summary: "Returns a value.", arguments: [{ name: "key", type: "key" }] }, + }); + const catalog = parseRedisCommandCatalog([ + ["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []], + ]); + + assert.deepEqual(mergeRedisCommandDocumentation(docs, catalog), [ + { + name: "GET", + summary: "Returns a value.", + since: undefined, + group: undefined, + arity: 2, + keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }], + arguments: [{ name: "key", type: "key" }], + }, + ]); +}); diff --git a/packages/app-tests/redisCompletion.test.ts b/packages/app-tests/redisCompletion.test.ts index 3341f5b4f..9f885518b 100644 --- a/packages/app-tests/redisCompletion.test.ts +++ b/packages/app-tests/redisCompletion.test.ts @@ -1,149 +1,274 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { buildRedisCompletionItems, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument } from "../../apps/desktop/src/lib/redis/redisCompletion.ts"; +import { buildRedisCompletionItems, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionInput } from "../../apps/desktop/src/lib/redis/redisCompletion.ts"; +import type { RedisCommandArgument, RedisCommandDocumentation, RedisCommandKeySpec } from "../../apps/desktop/src/lib/redis/redisCommandDocs.ts"; +import { tokenizeRedisLine } from "../../apps/desktop/src/lib/redis/redisCommandTokenizer.ts"; + +const oneKey: RedisCommandKeySpec[] = [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }]; +const allRemainingKeys: RedisCommandKeySpec[] = [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 0 } }]; +const xreadArguments: RedisCommandArgument[] = [ + { name: "count", token: "COUNT", type: "integer", optional: true }, + { name: "maxcount", token: "MAXCOUNT", type: "integer", optional: true }, + { name: "maxsize", token: "MAXSIZE", type: "integer", optional: true }, + { name: "milliseconds", token: "BLOCK", type: "integer", optional: true }, + { + name: "streams", + token: "STREAMS", + type: "block", + arguments: [ + { name: "key", type: "key", multiple: true }, + { name: "ID", type: "string", multiple: true }, + ], + }, +]; + +const commands: RedisCommandDocumentation[] = [ + { name: "ACL", group: "server", arity: -2, summary: "Access control commands.", keySpecs: [] }, + { name: "ACL CAT", group: "server", arity: -2, summary: "Lists ACL categories.", keySpecs: [] }, + { + name: "BITOP", + group: "bitmap", + arity: -4, + keySpecs: [ + { beginSearch: { type: "index", index: 2 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }, + { beginSearch: { type: "index", index: 3 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 0 } }, + ], + }, + { name: "BLPOP", group: "list", arity: -3, keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: -2, keyStep: 1, limit: 0 } }] }, + { name: "DEL", group: "generic", arity: -2, keySpecs: allRemainingKeys }, + { name: "EVAL", group: "scripting", arity: -3, keySpecs: [{ beginSearch: { type: "index", index: 2 }, findKeys: { type: "keynum", keyNumIndex: 0, firstKey: 1, keyStep: 1 } }] }, + { name: "EXISTS", group: "generic", arity: -2, keySpecs: allRemainingKeys }, + { name: "FLUSHDB", group: "server", arity: -1, keySpecs: [] }, + { name: "GET", group: "string", arity: 2, keySpecs: oneKey }, + { name: "GETRANGE", group: "string", arity: 4, keySpecs: oneKey }, + { name: "GETSET", group: "string", arity: 3, keySpecs: oneKey }, + { name: "HSET", group: "hash", arity: -4, keySpecs: oneKey }, + { + name: "MIGRATE", + group: "generic", + arity: -6, + keySpecs: [ + { beginSearch: { type: "index", index: 3 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }, + { beginSearch: { type: "keyword", keyword: "KEYS", startFrom: -2 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 0 } }, + ], + }, + { name: "OBJECT", group: "generic", arity: -2, keySpecs: [] }, + { name: "OBJECT ENCODING", group: "generic", arity: 3, keySpecs: oneKey }, + { name: "OBJECT FREQ", group: "generic", arity: 3, keySpecs: oneKey }, + { name: "OBJECT HELP", group: "generic", arity: 2, keySpecs: [] }, + { name: "OBJECT IDLETIME", group: "generic", arity: 3, keySpecs: oneKey }, + { name: "OBJECT REFCOUNT", group: "generic", arity: 3, keySpecs: oneKey }, + { name: "SELECT", group: "connection", arity: 2, keySpecs: [] }, + { name: "XGROUP", group: "stream", arity: -2, keySpecs: [] }, + { name: "XGROUP CREATE", group: "stream", arity: -5, keySpecs: oneKey }, + { name: "XGROUP DESTROY", group: "stream", arity: 4, keySpecs: oneKey }, + { name: "XGROUP SETID", group: "stream", arity: -4, keySpecs: oneKey }, + { name: "XREAD", group: "stream", arity: -4, arguments: xreadArguments, keySpecs: [{ beginSearch: { type: "keyword", keyword: "STREAMS", startFrom: 1 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 2 } }] }, + { + name: "XREADGROUP", + group: "stream", + arity: -7, + arguments: [ + { + name: "group-block", + token: "GROUP", + type: "block", + arguments: [ + { name: "group", type: "string" }, + { name: "consumer", type: "string" }, + ], + }, + { name: "count", token: "COUNT", type: "integer", optional: true }, + { name: "milliseconds", token: "BLOCK", type: "integer", optional: true }, + { name: "noack", token: "NOACK", type: "pure-token", optional: true }, + xreadArguments[4]!, + ], + keySpecs: [{ beginSearch: { type: "keyword", keyword: "STREAMS", startFrom: 4 }, findKeys: { type: "range", lastKey: -1, keyStep: 1, limit: 2 } }], + }, +]; + +function input(keys: string[] = []): RedisCompletionInput { + return { commands, keys }; +} + +function complete(text: string, cursor = text.length, keys: string[] = []) { + return buildRedisCompletionItems(text, cursor, input(keys)); +} function labels(items: { label: string }[]): string[] { return items.map((item) => item.label); } -test("command mode: completes command names by prefix (case-insensitive)", () => { - const items = buildRedisCompletionItems("GE", 2); - const names = labels(items); - assert.ok(names.includes("GET")); - assert.ok(names.includes("GETSET")); - assert.ok(names.includes("GETRANGE")); - // no subcommand forms leak in - assert.ok(!names.some((n) => n.includes(" "))); - // lowercase prefix also works - assert.ok(labels(buildRedisCompletionItems("ge", 2)).includes("GET")); +test("command candidates come only from the connected Redis instance", () => { + const names = labels(complete("GE")); + assert.deepEqual(names, ["GET", "GETRANGE", "GETSET"]); + assert.ok(!names.includes("SET")); + assert.deepEqual(buildRedisCompletionItems("GE", 2, { commands: [] }), []); }); -test("command mode: does not surface bare subcommand labels", () => { - const items = buildRedisCompletionItems("XGROUP", 6); - const names = labels(items); - assert.ok(names.includes("XGROUP")); - // "CREATE" alone is not a top-level command - assert.ok(!names.includes("CREATE")); +test("command mode completes documented names case-insensitively", () => { + assert.ok(labels(complete("ge")).includes("GET")); + assert.ok(!labels(complete("GET")).some((name) => name.includes(" "))); }); -test("command mode: detail/info carry group + arity + safety", () => { - const items = buildRedisCompletionItems("FLUSHALL", 9); - const flush = items.find((item) => item.label === "FLUSHALL"); - assert.ok(flush); - assert.match(flush!.detail ?? "", /server.*blocked/i); - assert.match(flush!.info ?? "", /Group:\s*server/i); - assert.match(flush!.info ?? "", /Arity:/i); - assert.match(flush!.info ?? "", /Safety:\s*blocked/i); -}); - -test("subcommand mode: after 'XGROUP ' suggests its subcommands", () => { - const items = buildRedisCompletionItems("XGROUP ", 7); - const names = labels(items); - assert.ok(names.includes("CREATE")); - assert.ok(names.includes("DESTROY")); - assert.ok(names.includes("SETID")); - // unrelated commands absent +test("subcommand mode follows the server's command hierarchy", () => { + const names = labels(complete("XGROUP ")); + assert.deepEqual(new Set(names), new Set(["CREATE", "DESTROY", "SETID"])); assert.ok(!names.includes("GET")); + assert.deepEqual(labels(complete("XGROUP C")), ["CREATE"]); + + const objectNames = labels(complete("OBJECT ")); + assert.deepEqual(new Set(objectNames), new Set(["ENCODING", "FREQ", "HELP", "IDLETIME", "REFCOUNT"])); }); -test("subcommand mode: filters subcommands by prefix", () => { - const items = buildRedisCompletionItems("XGROUP C", 8); - const names = labels(items); - assert.ok(names.includes("CREATE")); - assert.ok(names.includes("CREATECONSUMER")); - assert.ok(!names.includes("DESTROY")); +test("server metadata supplies module commands, summaries, and key completion", () => { + const moduleCommands: RedisCommandDocumentation[] = [ + { name: "MODULE", group: "module", arity: -2, keySpecs: [] }, + { name: "MODULE SEARCH", group: "module", arity: -2, summary: "Searches module data.", keySpecs: oneKey }, + ]; + const moduleInput = { commands: moduleCommands, keys: ["session:1"] }; + assert.ok(labels(buildRedisCompletionItems("MO", 2, moduleInput)).includes("MODULE")); + const search = buildRedisCompletionItems("MODULE S", 8, moduleInput).find((item) => item.label === "SEARCH"); + assert.equal(search?.summary, "Searches module data."); + assert.ok(labels(buildRedisCompletionItems("MODULE SEARCH ", 14, moduleInput)).includes("session:1")); }); -test("OBJECT completion uses Redis space-delimited subcommands", () => { - const commandNames = labels(buildRedisCompletionItems("OBJECT", 6)); - assert.ok(commandNames.includes("OBJECT")); - assert.ok(!commandNames.some((name) => name.startsWith("OBJECT_"))); +test("argument keyword candidates follow the server's recursive command grammar", () => { + assert.deepEqual(labels(complete("XREAD ")), ["COUNT", "MAXCOUNT", "MAXSIZE", "BLOCK", "STREAMS"]); + assert.deepEqual(labels(complete("XREAD C")), ["COUNT"]); + assert.deepEqual(complete("XREAD COUNT "), []); + assert.deepEqual(labels(complete("XREAD COUNT 2 ")), ["MAXCOUNT", "MAXSIZE", "BLOCK", "STREAMS"]); + assert.deepEqual(labels(complete("XREADGROUP ")), ["GROUP"]); + assert.deepEqual(complete("XREADGROUP GROUP "), []); + assert.deepEqual(complete("XREADGROUP GROUP workers "), []); + assert.deepEqual(labels(complete("XREADGROUP GROUP workers consumer-1 ")), ["COUNT", "BLOCK", "NOACK", "STREAMS"]); + assert.equal( + complete("XREADGROUP GROUP workers consumer-1 NOACK ").some((item) => item.label === "NOACK"), + false, + ); + assert.equal(complete("XREAD COUNT 2 ")[0]?.appendSpace, true); - const subcommands = labels(buildRedisCompletionItems("OBJECT ", 7)); - assert.deepEqual(new Set(subcommands), new Set(["ENCODING", "FREQ", "IDLETIME", "REFCOUNT", "HELP"])); - assert.ok(!subcommands.some((name) => name.includes("_"))); + const setInput: RedisCompletionInput = { + keys: [], + commands: [ + { + name: "SET", + keySpecs: oneKey, + arguments: [ + { name: "key", type: "key" }, + { name: "value", type: "string" }, + { + name: "condition", + type: "oneof", + optional: true, + arguments: [ + { name: "nx", token: "NX", type: "pure-token" }, + { name: "xx", token: "XX", type: "pure-token" }, + ], + }, + ], + }, + ], + }; + assert.deepEqual(labels(buildRedisCompletionItems("SET account value ", 18, setInput)), ["NX", "XX"]); + + const repeatInput: RedisCompletionInput = { + commands: [ + { + name: "SORT", + keySpecs: oneKey, + arguments: [ + { name: "key", type: "key" }, + { name: "pattern", token: "GET", type: "string", optional: true, multiple: true, multipleToken: true }, + { + name: "order", + type: "oneof", + optional: true, + arguments: [ + { name: "asc", token: "ASC", type: "pure-token" }, + { name: "desc", token: "DESC", type: "pure-token" }, + ], + }, + ], + }, + ], + }; + assert.deepEqual(new Set(labels(buildRedisCompletionItems("SORT users GET profile:* ", 25, repeatInput))), new Set(["GET", "ASC", "DESC"])); }); -test("argument mode: offers key names for key-taking commands", () => { - const items = buildRedisCompletionItems("GET ", 4, { keys: ["user:1", "user:2", "config:db"] }); - const names = labels(items); - assert.ok(names.includes("user:1")); - assert.ok(names.includes("user:2")); +test("key candidates follow documented range, key-count, and keyword key specs", () => { + const keys = ["dest", "source:1", "source:2"]; + assert.ok(labels(complete("BITOP AND ", 10, keys)).includes("dest")); + assert.ok(labels(complete("BITOP AND dest ", 15, keys)).includes("source:1")); + assert.ok(labels(complete("EVAL script 2 ", 14, keys)).includes("dest")); + assert.ok(labels(complete("EVAL script 2 dest ", 19, keys)).includes("source:1")); + assert.ok(labels(complete('EVAL "return 1" 2 ', undefined, keys)).includes("dest")); + assert.equal(complete("EVAL script 2 dest source:1 ", 29, keys).length, 0); + assert.ok(labels(complete("XREAD STREAMS ", 14, keys)).includes("dest")); + assert.equal(complete("XREAD STREAMS dest 0-0 ", 25, keys).length, 0); + assert.ok(labels(complete("BLPOP ", 6, keys)).includes("dest")); + assert.equal(complete("BLPOP dest ", 11, keys).length, 0); + assert.ok(labels(complete("MIGRATE host 6379 source 0 100 KEYS ", 39, keys)).includes("dest")); }); -test("argument mode: filters keys by substring prefix", () => { - const items = buildRedisCompletionItems("GET user", 8, { keys: ["user:1", "user:2", "config:db"] }); - const names = labels(items); - assert.ok(names.includes("user:1")); - assert.ok(names.includes("user:2")); - assert.ok(!names.includes("config:db")); +test("completion parses quoted arguments and safely applies special key names", () => { + const completionInput = input(["user name", 'quote"key', "plain", "path\\\\name"]); + const quotedScript = getRedisCompletionContext('EVAL "return 1" 2 ', 18, completionInput); + assert.deepEqual(quotedScript.argumentValues, ["return 1", "2"]); + assert.equal(quotedScript.argumentIndex, 2); + + const keyItems = buildRedisCompletionItems('GET "user', 9, completionInput); + assert.equal(keyItems.find((item) => item.label === "user name")?.apply, '"user name"'); + assert.equal(buildRedisCompletionItems("GET quote", 9, completionInput).find((item) => item.label === 'quote"key')?.apply, '"quote\\"key"'); + assert.equal(buildRedisCompletionItems("GET pl", 6, completionInput).find((item) => item.label === "plain")?.apply, "plain"); + const escapedKey = buildRedisCompletionItems("GET path", 8, completionInput).find((item) => item.label === "path\\\\name")?.apply; + assert.equal(escapedKey, "path\\\\name"); + assert.deepEqual( + tokenizeRedisLine(`GET ${escapedKey}`).argv.map((token) => token.value), + ["GET", "path\\name"], + ); }); -test("argument mode: empty keys yields no key items", () => { - const items = buildRedisCompletionItems("GET ", 4, { keys: [] }); - assert.equal(items.length, 0); +test("non-key arguments and unknown subcommands never produce key candidates", () => { + const keys = ["user:1"]; + assert.equal(complete("GET key ", 8, keys).length, 0); + assert.equal(complete("HSET key ", 9, keys).length, 0); + assert.equal(complete("FLUSHDB ", 8, keys).length, 0); + assert.equal(complete("OBJECT UNKNOWN ", 15, keys).length, 0); }); -test("argument mode: does not suggest keys once the key argument is filled in", () => { - // GET takes exactly one key argument; after "GET key " the next slot is out of range. - assert.equal(buildRedisCompletionItems("GET key ", 8, { keys: ["user:1"] }).length, 0); - assert.equal(buildRedisCompletionItems("GET key extra ", 14, { keys: ["user:1"] }).length, 0); - // HSET takes key then field/value; after the key only non-key args remain. - assert.equal(buildRedisCompletionItems("HSET k ", 7, { keys: ["user:1"] }).length, 0); +test("takesKeyArgument evaluates the active server-documented argument", () => { + const completionInput = input(); + assert.equal(takesKeyArgument("GET", completionInput), true); + assert.equal(takesKeyArgument("BITOP", completionInput, 0, []), false); + assert.equal(takesKeyArgument("BITOP", completionInput, 1, ["AND"]), true); + assert.equal(takesKeyArgument("EVAL", completionInput, 2, ["script", "1"]), true); + assert.equal(takesKeyArgument("EVAL", completionInput, 3, ["script", "1", "key"]), false); + assert.equal(takesKeyArgument("XREAD", completionInput, 3, ["STREAMS", "key", "0-0"]), false); + assert.equal(takesKeyArgument("MIGRATE", completionInput, 6, ["host", "6379", "source", "0", "100", "KEYS"]), true); + assert.equal(takesKeyArgument("FLUSHDB", completionInput), false); + assert.equal(takesKeyArgument(undefined, completionInput), false); }); -test("argument mode: variadic key-list commands keep suggesting keys", () => { - // DEL accepts multiple keys — keep suggesting after the first one. - assert.ok(labels(buildRedisCompletionItems("DEL k1 ", 7, { keys: ["user:1"] })).includes("user:1")); - assert.ok(labels(buildRedisCompletionItems("EXISTS k1 k2 ", 13, { keys: ["user:1"] })).includes("user:1")); +test("context parsing resolves server subcommands and argument positions", () => { + const completionInput = input(); + assert.deepEqual(getRedisCompletionContext("", 0, completionInput), { mode: "command", prefix: "", from: 0 }); + assert.deepEqual(getRedisCompletionContext("XGROUP ", 7, completionInput), { mode: "subcommand", prefix: "", from: 7, mainCommand: "XGROUP", commandName: "XGROUP" }); + const context = getRedisCompletionContext("EVAL script 2 ", 14, completionInput); + assert.equal(context.mode, "argument"); + assert.equal(context.commandName, "EVAL"); + assert.equal(context.argumentIndex, 2); + assert.deepEqual(context.argumentValues, ["script", "2"]); }); -test("argument mode: still suggests keys at the first argument slot", () => { - // GET — first argument position - const items = buildRedisCompletionItems("GET ", 4, { keys: ["user:1", "user:2"] }); - assert.ok(labels(items).includes("user:1")); -}); - -test("argument mode: non-key commands (FLUSHDB) do not suggest keys", () => { - const items = buildRedisCompletionItems("FLUSHDB ", 8, { keys: ["user:1", "user:2"] }); - assert.equal(items.length, 0); -}); - -test("takesKeyArgument reflects command group", () => { - assert.equal(takesKeyArgument("GET"), true); // string - assert.equal(takesKeyArgument("HSET"), true); // hash - assert.equal(takesKeyArgument("DEL"), true); // generic - assert.equal(takesKeyArgument("FLUSHDB"), false); // server - assert.equal(takesKeyArgument("SELECT"), false); // connection - assert.equal(takesKeyArgument(undefined), false); -}); - -test("context parsing: empty line → command mode", () => { - const ctx = getRedisCompletionContext("", 0); - assert.equal(ctx.mode, "command"); - assert.equal(ctx.prefix, ""); -}); - -test("context parsing: command + space → subcommand mode when the command has subcommands", () => { - const ctx = getRedisCompletionContext("XGROUP ", 7); - assert.equal(ctx.mode, "subcommand"); - assert.equal(ctx.mainCommand, "XGROUP"); -}); - -test("context parsing: GET + space → argument mode", () => { - const ctx = getRedisCompletionContext("GET ", 4); - assert.equal(ctx.mode, "argument"); - assert.equal(ctx.mainCommand, "GET"); -}); - -test("shouldAutoOpenRedisCompletion opens on word/space chars, not newline", () => { +test("automatic completion opens on command and key characters, not a newline", () => { assert.equal(shouldAutoOpenRedisCompletion("GET", 3), true); - assert.equal(shouldAutoOpenRedisCompletion("GET ", 4), true); // space → offer keys/subcommands + assert.equal(shouldAutoOpenRedisCompletion("GET ", 4), true); assert.equal(shouldAutoOpenRedisCompletion("GET\n", 4), false); assert.equal(shouldAutoOpenRedisCompletion("user:", 5), true); }); -test("result validFor covers command-name and key-name characters", () => { +test("completion reuse accepts command-name and key-name characters", () => { const re = getRedisCompletionResultValidFor(); assert.equal(re.test("GET"), true); assert.equal(re.test("user:1"), true); diff --git a/packages/app-tests/redisSyntaxDiagnostics.test.ts b/packages/app-tests/redisSyntaxDiagnostics.test.ts index f5ca958cd..6db9107f3 100644 --- a/packages/app-tests/redisSyntaxDiagnostics.test.ts +++ b/packages/app-tests/redisSyntaxDiagnostics.test.ts @@ -162,3 +162,18 @@ test("tokenizeRedisLine handles backslash escapes", () => { ["SET", "k", "a b"], ); }); + +test("tokenizeRedisLine follows Redis escapes and quotes anywhere in an argument", () => { + const { argv } = tokenizeRedisLine('SET pre" has space"post line\\nnext;'); + assert.deepEqual( + argv.map((token) => token.value), + ["SET", "pre has spacepost", "line\nnext"], + ); +}); + +test("tokenizeRedisLine preserves empty quoted arguments", () => { + assert.deepEqual( + tokenizeRedisLine('SET "" ""').argv.map((token) => token.value), + ["SET", "", ""], + ); +});