feat(redis): add command terminal autocomplete

This commit is contained in:
onenewcode
2026-08-12 00:41:31 +08:00
committed by GitHub
parent 80bd8dac06
commit dbe3375739
13 changed files with 1865 additions and 337 deletions
@@ -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
@@ -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<T>() {
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<HTMLElement>('[value="command"]');
expect(trigger, "redis.commandLine trigger").toBeDefined();
trigger!.click();
await settle();
}
function commandCompletionLabels(): string[] {
return Array.from(document.querySelectorAll<HTMLButtonElement>('[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<HTMLTextAreaElement>("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<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLButtonElement>('[role="option"]'));
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
const listbox = requiredElement<HTMLElement>('[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<HTMLInputElement>("[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<HTMLInputElement>("[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<typeof completionCommands>();
mocks.listRedisCompletionCommandDocs.mockReturnValueOnce(docs.promise);
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("SE");
const input = requiredElement<HTMLInputElement>("[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<HTMLInputElement>("[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();
@@ -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<RedisCommandHistoryEntry[]>([]);
const commandHistoryIndex = ref(-1);
const commandCompletionItems = ref<RedisCompletionItem[]>([]);
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<RedisCommandDocumentation[]>([]);
const activeSidePanel = ref<RedisSidePanel>("detail");
const showCreateKeyDialog = ref(false);
const creatingKey = ref(false);
@@ -152,6 +163,10 @@ let redisDbFlushedListenerRegistered = false;
let redisInfiniteScrollFrame = 0;
const loadedKeyRaws = new Set<string>();
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<HTMLInputElement>("[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 });
<form class="flex shrink-0 items-center gap-2 border-t border-white/10 bg-[#171b21] px-4 py-2" @submit.prevent="executeCommand">
<span class="shrink-0 text-[#d7ba7d]">{{ commandPrompt }}</span>
<input
v-model="commandText"
data-redis-command-input
class="dbx-editor-font-family min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
:class="{ 'opacity-50': commandRunning }"
:readonly="commandRunning"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
@keydown="onCommandInputKeydown"
/>
<div class="relative min-w-0 flex-1">
<div v-if="commandCompletionOpen" class="absolute bottom-[calc(100%+0.5rem)] left-0 z-20 w-full overflow-hidden rounded-md border border-white/15 bg-[#20262f] py-1 shadow-xl">
<div v-if="commandDocumentationLoading || commandCompletionLoading" class="flex items-center justify-center px-3 py-2 text-slate-400">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
</div>
<div v-else :id="commandCompletionListboxId" role="listbox" aria-label="Redis command completions" class="max-h-60 overflow-y-auto">
<button
v-for="(item, index) in commandCompletionItems"
:id="`${commandCompletionListboxId}-option-${index}`"
:key="`${item.type}:${item.label}:${index}`"
type="button"
role="option"
class="flex w-full items-center gap-3 px-3 py-1.5 text-left text-xs transition-colors"
:class="commandCompletionSelectedIndex === index ? 'bg-[#2b3440] text-white' : 'text-slate-200 hover:text-white'"
:aria-selected="commandCompletionSelectedIndex === index"
:aria-description="item.info"
@pointerenter="selectCommandCompletion(index)"
@mousedown.prevent
@click.stop="acceptCommandCompletion(index)"
>
<span class="min-w-0 flex-1">
<span class="block truncate font-mono">{{ item.label }}</span>
<span v-if="item.summary" class="block truncate text-[11px] text-slate-400">{{ item.summary }}</span>
</span>
<span v-if="item.detail" class="shrink-0 text-slate-400">{{ item.detail }}</span>
</button>
</div>
</div>
<input
v-model="commandText"
data-redis-command-input
class="dbx-editor-font-family min-w-0 w-full border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
:class="{ 'opacity-50': commandRunning }"
:readonly="commandRunning"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
aria-autocomplete="list"
aria-haspopup="listbox"
:aria-controls="commandCompletionOpen ? commandCompletionListboxId : undefined"
:aria-activedescendant="commandCompletionOpen ? commandCompletionActiveDescendant : undefined"
:aria-expanded="commandCompletionOpen"
@click.stop="onCommandInputClick"
@input="onCommandInput"
@keydown="onCommandInputKeydown"
/>
</div>
<Loader2 v-if="commandRunning" class="h-3.5 w-3.5 shrink-0 animate-spin text-slate-500" />
</form>
</div>
@@ -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<string, unknown>;
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<string> {
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<string, RedisCommandDocumentation>();
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<string, RedisCommandDocumentation>();
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));
}
@@ -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 };
}
+337 -104
View File
@@ -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<string, CompletionCommandEntry>;
mainCommands: CompletionCommandEntry[];
subcommands: Map<string, CompletionCommandEntry[]>;
}
const MAIN_COMMANDS: MainCommandEntry[] = [];
const SUBCOMMANDS: SubcommandEntry[] = [];
const SUBCOMMAND_MAINS = new Set<string>();
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, number> = {
string: 110,
@@ -94,38 +81,92 @@ function describeArity(arity: number): string {
return "variable arguments";
}
function createCompletionIndex(commandDocs: readonly RedisCommandDocumentation[]): CompletionIndex {
const commands = new Map<string, CompletionCommandEntry>();
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<string, CompletionCommandEntry>();
const subcommands = new Map<string, CompletionCommandEntry[]>();
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<ReadonlyArray<RedisCommandDocumentation>, CompletionIndex>();
function completionIndex(input: Pick<RedisCompletionInput, "commands">): 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<RedisCompletionInput, "commands">): 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 <key>, 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<number, ArgumentTransition[]>;
}
const argumentGrammars = new WeakMap<ReadonlyArray<RedisCommandArgument>, ArgumentGrammar>();
function argumentGrammar(arguments_: readonly RedisCommandArgument[]): ArgumentGrammar {
const cached = argumentGrammars.get(arguments_);
if (cached) return cached;
const transitions = new Map<number, ArgumentTransition[]>();
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<number>) => {
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<number>();
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<string, RedisCompletionItem>();
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<RedisCompletionInput, "commands">, 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 {
@@ -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;
@@ -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 },
+49 -1
View File
@@ -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<Record<string, SqlServerCompletionContext>>({});
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
const redisCommandDocsCache = ref<Record<string, RedisCommandDocumentation[]>>({});
const redisCommandDocsCacheGeneration = new Map<string, number>();
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
@@ -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<string[]> {
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<RedisCommandDocumentation[]> {
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<string[]> {
if (!database) return [];
const cacheKey = `${connectionId}:${database}`;
@@ -7436,6 +7483,7 @@ export const useConnectionStore = defineStore("connection", () => {
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
listRedisCompletionKeys,
listRedisCompletionCommandDocs,
listMongoCompletionCollections,
listMongoCompletionFields,
invalidateCompletionCache,
+19 -4
View File
@@ -1561,9 +1561,11 @@ pub fn parse_command_argv(command_text: &str) -> Result<Vec<String>, String> {
let mut chars = command_text.chars().peekable();
let mut quote: Option<char> = 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<Vec<String>, String> {
}
if ch == '\\' {
token_started = true;
escaping = true;
continue;
}
@@ -1589,20 +1592,23 @@ pub fn parse_command_argv(command_text: &str) -> Result<Vec<String>, 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<Vec<String>, 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]
+247
View File
@@ -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" }],
},
]);
});
+237 -112
View File
@@ -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 <here> — 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);
+15
View File
@@ -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", "", ""],
);
});