feat(redis): insert command argument examples

Closes #6925
This commit is contained in:
t8y2
2026-08-22 06:04:07 +00:00
parent f274a599ed
commit a0c9a1c878
4 changed files with 95 additions and 6 deletions
@@ -799,7 +799,7 @@ describe("RedisKeyBrowser command completion", () => {
await setCommandInput("VGE");
expect(mocks.listRedisCompletionCommandDocs).toHaveBeenCalledWith("connection", "0");
expect(commandCompletionLabels()).toContain("VGETReads a vendor key.string");
expect(commandCompletionLabels()).toEqual(expect.arrayContaining([expect.stringContaining("VGET")]));
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
@@ -808,7 +808,7 @@ describe("RedisKeyBrowser command completion", () => {
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await settle();
expect(input.value).toBe("VGET ");
expect(input.value).toBe("VGET arg1");
});
it("completes known keys only at a documented key argument", async () => {
@@ -929,8 +929,35 @@ describe("RedisKeyBrowser command completion", () => {
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(input.value).toBe("SET ");
expect(commandCompletionLabels()).toContain("user:1key");
expect(input.value).toBe("SET arg1 arg2");
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
});
it("inserts documented Redis argument examples before executing on Enter", async () => {
mocks.listRedisCompletionCommandDocs.mockResolvedValueOnce([
{
name: "GETBIT",
group: "bitmap",
arity: 3,
keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }],
arguments: [
{ name: "key", type: "key" },
{ name: "offset", type: "integer" },
],
},
]);
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("GETB");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(input.value).toBe("GETBIT key offset");
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
expect(commandCompletionLabels()).toEqual([]);
});
it("waits for command metadata instead of sending a partial command on Enter", async () => {
@@ -952,7 +979,7 @@ describe("RedisKeyBrowser command completion", () => {
await settle();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(input.value).toBe("SET ");
expect(input.value).toBe("SET arg1 arg2");
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
});
@@ -1792,7 +1792,9 @@ function commandCompletionInsertion(index = commandCompletionSelectedIndex.value
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 };
const hasCommandExample = commandHead && item.apply !== undefined && item.apply !== item.label;
const replacement = `${insert}${appendSpace && !hasCommandExample ? " " : ""}`;
return { text, from, to, insert, replacement, appendSpace: appendSpace && !hasCommandExample, commandHead };
}
function selectedCompletionMatchesInput(): boolean {
@@ -2332,6 +2334,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<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 v-if="item.apply && item.apply !== item.label" class="block truncate text-[11px] text-slate-500">{{ item.apply }}</span>
</span>
<span v-if="item.detail" class="shrink-0 text-slate-400">{{ item.detail }}</span>
</button>
@@ -141,11 +141,52 @@ function buildSpecDetail(entry: CompletionCommandEntry): string {
return entry.group;
}
function argumentPlaceholder(argument: RedisCommandArgument): string {
const name = argument.name.trim().replaceAll(/[^a-zA-Z0-9_-]+/g, "_");
return name || "value";
}
function exampleArguments(arguments_: readonly RedisCommandArgument[]): string[] {
const result: string[] = [];
for (const argument of arguments_) {
if (argument.optional) continue;
if (argument.type === "oneof") {
const first = argument.arguments?.find((candidate) => !candidate.optional);
if (first) result.push(...exampleArgument(first));
continue;
}
result.push(...exampleArgument(argument));
}
return result;
}
function exampleArgument(argument: RedisCommandArgument): string[] {
if (argument.enum?.length) return [argument.enum[0]!];
const nested = argument.arguments?.length ? exampleArguments(argument.arguments) : [];
const token = argument.token?.trim().toUpperCase();
if (argument.type === "pure-token") return token ? [token] : [];
if (token && nested.length) return [token, ...nested];
if (nested.length) return nested;
if (token) return [token, argumentPlaceholder(argument)];
return [argumentPlaceholder(argument)];
}
function commandExample(entry: CompletionCommandEntry): string {
let arguments_ = entry.arguments.length ? exampleArguments(entry.arguments) : [];
if (arguments_.length === 0 && entry.arity) {
const count = entry.arity > 0 ? entry.arity - 1 : -entry.arity - 1;
arguments_ = Array.from({ length: Math.min(Math.max(count, 0), 8) }, (_, index) => `arg${index + 1}`);
}
return [entry.name, ...arguments_].join(" ");
}
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}`);
const example = commandExample(entry);
if (example !== entry.name) info.push(`Example: ${example}`);
return info.join("\n");
}
@@ -216,6 +257,7 @@ function commandItems(index: CompletionIndex, prefix: string): RedisCompletionIt
info: buildSpecInfo(entry, entry.name),
summary: entry.summary,
since: entry.since,
apply: commandExample(entry),
boost: boostFor(entry),
}));
return items.sort((a, b) => b.boost - a.boost);
@@ -231,6 +273,7 @@ function subcommandItems(index: CompletionIndex, commandPrefix: string, prefix:
info: buildSpecInfo(entry, entry.name),
summary: entry.summary,
since: entry.since,
apply: commandExample(entry).slice(commandPrefix.length + 1),
boost: boostFor(entry),
}));
return items.sort((a, b) => b.boost - a.boost);
+16
View File
@@ -111,6 +111,22 @@ test("command mode completes documented names case-insensitively", () => {
assert.ok(!labels(complete("GET")).some((name) => name.includes(" ")));
});
test("command completion inserts a server-documented argument example", () => {
const getbit: RedisCommandDocumentation = {
name: "GETBIT",
group: "bitmap",
arity: 3,
arguments: [
{ name: "key", type: "key" },
{ name: "offset", type: "integer" },
],
keySpecs: oneKey,
};
const item = buildRedisCompletionItems("GETB", 4, { commands: [getbit] })[0];
assert.equal(item?.apply, "GETBIT key offset");
assert.match(item?.info ?? "", /Example: GETBIT key offset/);
});
test("subcommand mode follows the server's command hierarchy", () => {
const names = labels(complete("XGROUP "));
assert.deepEqual(new Set(names), new Set(["CREATE", "DESTROY", "SETID"]));