mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(redis): add command terminal autocomplete
This commit is contained in:
@@ -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>
|
||||
<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 flex-1 border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
|
||||
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 };
|
||||
}
|
||||
@@ -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) => ({
|
||||
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.spec),
|
||||
info: buildSpecInfo(entry.spec, entry.name),
|
||||
boost: boostFor(entry.spec),
|
||||
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,
|
||||
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.spec),
|
||||
info: buildSpecInfo(entry.spec, `${main} ${entry.sub}`),
|
||||
boost: boostFor(entry.spec),
|
||||
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;
|
||||
}
|
||||
// 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)) {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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;
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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", "", ""],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user