feat(redis): add command terminal autocomplete

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-12 00:41:31 +08:00
1 parent 80bd8dac06
commit dbe3375739
13 files changed
+1865 -337

No files matched your search

@@ -0,0 +1,257 @@
/**
* Normalizes `COMMAND DOCS` maps emitted by the Rust bridge. RESP3 maps are
* serialized as `{ key, value }` entries; RESP2 maps are alternating arrays.
*/
export interface RedisCommandDocumentation {
name: string;
summary?: string;
since?: string;
group?: string;
arity?: number;
keySpecs: RedisCommandKeySpec[];
arguments?: RedisCommandArgument[];
}
/** The recursive command grammar Redis publishes through `COMMAND DOCS`. */
export interface RedisCommandArgument {
name: string;
token?: string;
type?: string;
summary?: string;
since?: string;
optional?: boolean;
multiple?: boolean;
multipleToken?: boolean;
enum?: string[];
arguments?: RedisCommandArgument[];
}
/** The key-position information Redis publishes through `COMMAND DOCS`. */
export interface RedisCommandKeySpec {
beginSearch: { type: "index"; index: number } | { type: "keyword"; keyword: string; startFrom: number };
findKeys: { type: "range"; lastKey: number; keyStep: number; limit: number } | { type: "keynum"; keyNumIndex: number; firstKey: number; keyStep: number };
}
type RedisRecord = Record<string, unknown>;
function mapEntries(value: unknown): Array<[string, unknown]> {
if (Array.isArray(value)) {
// RESP2 represents a map as an alternating key/value array, while RESP3
// reaches the bridge as `{ key, value }` entries.
if (value.length > 0 && value.length % 2 === 0 && value.every((item, index) => index % 2 !== 0 || typeof item === "string")) {
const entries: Array<[string, unknown]> = [];
for (let index = 0; index < value.length; index += 2) {
entries.push([value[index] as string, value[index + 1]]);
}
return entries;
}
const entries: Array<[string, unknown]> = [];
for (const item of value) {
if (Array.isArray(item) && item.length >= 2 && typeof item[0] === "string") {
entries.push([item[0], item[1]]);
continue;
}
if (item && typeof item === "object" && "key" in item && "value" in item && typeof item.key === "string") {
entries.push([item.key, item.value]);
}
}
return entries;
}
if (value && typeof value === "object") return Object.entries(value);
return [];
}
function recordFromMap(value: unknown): RedisRecord {
return Object.fromEntries(mapEntries(value));
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function optionalNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function enabledFlag(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function argumentFlags(value: unknown): Set<string> {
return new Set(
(Array.isArray(value) ? value : [])
.filter((flag): flag is string => typeof flag === "string")
.map((flag) => flag.trim().toLowerCase().replaceAll("-", "_"))
.filter(Boolean),
);
}
function normalizeCommandName(value: string): string {
// Redis represents subcommands as `parent|child` in COMMAND metadata.
return value.trim().replaceAll("|", " ").toUpperCase();
}
function commandArguments(value: unknown): RedisCommandArgument[] {
const arguments_: RedisCommandArgument[] = [];
for (const rawArgument of Array.isArray(value) ? value : []) {
const argument = recordFromMap(rawArgument);
const name = optionalString(argument.name);
if (!name) continue;
const token = optionalString(argument.token);
const type = optionalString(argument.type);
const summary = optionalString(argument.summary);
const since = optionalString(argument.since);
const nested = commandArguments(argument.arguments);
const enumValues = (Array.isArray(argument.enum) ? argument.enum : []).filter((item): item is string => typeof item === "string" && item.length > 0);
const flags = argumentFlags(argument.flags);
arguments_.push({
name,
...(token ? { token: token.toUpperCase() } : {}),
...(type ? { type } : {}),
...(summary ? { summary } : {}),
...(since ? { since } : {}),
...(enabledFlag(argument.optional) || flags.has("optional") ? { optional: true } : {}),
...(enabledFlag(argument.multiple) || flags.has("multiple") ? { multiple: true } : {}),
...(enabledFlag(argument.multiple_token) || flags.has("multiple_token") ? { multipleToken: true } : {}),
...(enumValues.length ? { enum: enumValues } : {}),
...(nested.length ? { arguments: nested } : {}),
});
}
return arguments_;
}
function commandKeySpecs(value: unknown): RedisCommandKeySpec[] {
const specs: RedisCommandKeySpec[] = [];
for (const rawSpec of Array.isArray(value) ? value : []) {
const spec = recordFromMap(rawSpec);
const beginSearch = recordFromMap(spec.begin_search);
const findKeys = recordFromMap(spec.find_keys);
const beginSpec = recordFromMap(beginSearch.spec);
const findSpec = recordFromMap(findKeys.spec);
const beginType = optionalString(beginSearch.type);
const findType = optionalString(findKeys.type);
const keyStep = optionalNumber(findSpec.keystep);
if (!beginType || !findType || !keyStep || keyStep < 1) continue;
const begin = beginType === "index" ? optionalNumber(beginSpec.index) : beginType === "keyword" ? optionalString(beginSpec.keyword) : undefined;
const normalizedBegin = typeof begin === "number" && begin > 0 ? { type: "index" as const, index: begin } : typeof begin === "string" ? { type: "keyword" as const, keyword: begin.toUpperCase(), startFrom: optionalNumber(beginSpec.startfrom) ?? 1 } : undefined;
if (!normalizedBegin) continue;
if (findType === "range") {
const lastKey = optionalNumber(findSpec.lastkey);
if (lastKey == null) continue;
const limit = optionalNumber(findSpec.limit) ?? 0;
if (limit < 0) continue;
specs.push({ beginSearch: normalizedBegin, findKeys: { type: "range", lastKey, keyStep, limit } });
continue;
}
if (findType === "keynum") {
const keyNumIndex = optionalNumber(findSpec.keynumidx);
const firstKey = optionalNumber(findSpec.firstkey);
if (keyNumIndex == null || firstKey == null) continue;
specs.push({ beginSearch: normalizedBegin, findKeys: { type: "keynum", keyNumIndex, firstKey, keyStep } });
}
}
return specs;
}
function legacyCommandKeySpecs(value: readonly unknown[]): RedisCommandKeySpec[] {
const firstKey = optionalNumber(value[3]);
const lastKey = optionalNumber(value[4]);
const keyStep = optionalNumber(value[5]);
if (firstKey == null || firstKey < 1 || lastKey == null || keyStep == null || keyStep < 1) return [];
// `COMMAND` reports an absolute last-key position, while `COMMAND DOCS`
// range specs express it relative to the begin-search position.
return [{ beginSearch: { type: "index", index: firstKey }, findKeys: { type: "range", lastKey: lastKey < 0 ? lastKey : Math.max(0, lastKey - firstKey), keyStep, limit: 0 } }];
}
function isCommandInfo(value: unknown): value is unknown[] {
return Array.isArray(value) && typeof value[0] === "string" && optionalNumber(value[1]) !== undefined && Array.isArray(value[2]) && optionalNumber(value[3]) !== undefined;
}
/** Extract the completion-relevant subset of Redis' official `COMMAND DOCS` reply. */
export function parseRedisCommandDocumentation(value: unknown): RedisCommandDocumentation[] {
const docs = new Map<string, RedisCommandDocumentation>();
const collect = (rawDocs: unknown) => {
for (const [rawName, rawDoc] of mapEntries(rawDocs)) {
const name = normalizeCommandName(rawName);
if (!name) continue;
const doc = recordFromMap(rawDoc);
const arguments_ = commandArguments(doc.arguments);
docs.set(name, {
name,
summary: optionalString(doc.summary),
since: optionalString(doc.since),
group: optionalString(doc.group),
arity: optionalNumber(doc.arity),
keySpecs: commandKeySpecs(doc.key_specs),
...(arguments_.length ? { arguments: arguments_ } : {}),
});
// COMMAND DOCS returns only command families at the top level; their
// concrete subcommands are nested in a map keyed as `parent|child`.
if (doc.subcommands) collect(doc.subcommands);
}
};
collect(value);
return [...docs.values()].sort((left, right) => left.name.localeCompare(right.name));
}
/**
* Extract command names from the legacy `COMMAND` reply, used on Redis before
* 7.0 where `COMMAND DOCS` is unavailable. Its name and key-position fields
* are stable across Redis 2.8+, and modern replies also include subcommands.
*/
export function parseRedisCommandCatalog(value: unknown): RedisCommandDocumentation[] {
const docs = new Map<string, RedisCommandDocumentation>();
const collect = (rawValue: unknown) => {
if (isCommandInfo(rawValue)) {
const rawName = rawValue[0];
if (typeof rawName !== "string") return;
const name = normalizeCommandName(rawName);
if (name) {
docs.set(name, {
name,
summary: undefined,
since: undefined,
group: undefined,
arity: optionalNumber(rawValue[1]),
keySpecs: legacyCommandKeySpecs(rawValue),
});
}
// Redis 7+ appends subcommand command-info replies in slot 10.
collect(rawValue[9]);
return;
}
if (Array.isArray(rawValue)) {
for (const entry of rawValue) collect(entry);
return;
}
if (rawValue && typeof rawValue === "object") {
// A cluster client can return a node-to-reply map through the bridge.
if ("value" in rawValue) {
collect(rawValue.value);
} else {
for (const entry of Object.values(rawValue)) collect(entry);
}
}
};
collect(value);
return [...docs.values()].sort((left, right) => left.name.localeCompare(right.name));
}
export function mergeRedisCommandDocumentation(documentation: readonly RedisCommandDocumentation[], catalog: readonly RedisCommandDocumentation[]): RedisCommandDocumentation[] {
const merged = new Map(catalog.map((command) => [command.name, command]));
for (const command of documentation) {
const catalogCommand = merged.get(command.name);
merged.set(command.name, {
...catalogCommand,
...command,
arity: command.arity ?? catalogCommand?.arity,
keySpecs: command.keySpecs.length ? command.keySpecs : (catalogCommand?.keySpecs ?? []),
});
}
return [...merged.values()].sort((left, right) => left.name.localeCompare(right.name));
}
@@ -0,0 +1,79 @@
/** Tokenizes Redis command text with the same quoting and escape rules as the backend. */
export interface RedisArgvToken {
value: string;
/** 1-based start/end character columns within the source line. */
startColumn: number;
endColumn: number;
}
export interface RedisArgvResult {
argv: RedisArgvToken[];
unclosedQuote: boolean;
unclosedQuoteStart?: number;
}
function escapedCharacter(value: string): string {
return value === "n" ? "\n" : value === "r" ? "\r" : value === "t" ? "\t" : value;
}
export function tokenizeRedisLine(line: string): RedisArgvResult {
let end = line.length;
while (end > 0 && /\s/.test(line[end - 1]!)) end--;
while (end > 0 && line[end - 1] === ";") end--;
const argv: RedisArgvToken[] = [];
let value = "";
let startColumn: number | undefined;
let quote: string | undefined;
let unclosedQuoteStart: number | undefined;
let escaping = false;
const pushToken = (endColumn: number) => {
if (startColumn != null) {
argv.push({ value, startColumn, endColumn });
}
value = "";
startColumn = undefined;
};
for (let index = 0; index < end; index++) {
const character = line[index]!;
if (startColumn == null) {
if (/\s/.test(character)) continue;
startColumn = index + 1;
}
if (escaping) {
value += escapedCharacter(character);
escaping = false;
continue;
}
if (character === "\\") {
escaping = true;
continue;
}
if (quote) {
if (character === quote) quote = undefined;
else value += character;
continue;
}
if (character === '"' || character === "'") {
quote = character;
unclosedQuoteStart = index + 1;
continue;
}
if (/\s/.test(character)) {
pushToken(index + 1);
continue;
}
value += character;
}
if (escaping) value += "\\";
if (quote) {
pushToken(end + 1);
return { argv, unclosedQuote: true, unclosedQuoteStart };
}
pushToken(end + 1);
return { argv, unclosedQuote: false };
}
+337 -104
View File
@@ -1,22 +1,24 @@
/**
* Redis command autocompletion for the query editor. Mirrors the shape of
* `elasticsearchCompletion.ts` so the editor's completion pipeline (the single
* `autocompletion({ override })` in QueryEditor.vue) can dispatch to it.
* Redis command autocompletion shared by the query editor and terminal input.
* It mirrors the shape of `elasticsearchCompletion.ts` so the editor's
* completion pipeline can dispatch to it.
*
* Data source: the static `REDIS_COMMAND_TABLE` (built for diagnostics). Keys
* are UPPER CASE command names; two-token subcommands are keyed as `"MAIN SUB"`
* (e.g. `"XGROUP CREATE"`). We split those into a main-name index + subcommand
* index here.
* Candidates and their displayed metadata come exclusively from the connected
* server's `COMMAND DOCS` (or legacy `COMMAND`) response. Safety enforcement
* remains in the command execution path and is not inferred for completion.
*/
import { REDIS_COMMAND_TABLE } from "@/lib/redis/redisCommandTable";
import type { RedisCommandSpec } from "@/lib/redis/redisCommandTable";
import type { RedisCommandArgument, RedisCommandDocumentation, RedisCommandKeySpec } from "@/lib/redis/redisCommandDocs";
import { tokenizeRedisLine } from "@/lib/redis/redisCommandTokenizer";
export interface RedisCompletionItem {
label: string;
type: "keyword" | "text"; // command/subcommand=keyword, key name=text
detail?: string; // single-line, e.g. "string · confirm"
info?: string; // multi-line: Group / Arity / Safety
summary?: string;
since?: string;
apply?: string;
appendSpace?: boolean;
boost: number;
}
@@ -27,53 +29,38 @@ export interface RedisCompletionContext {
from: number;
/** Upper-cased main command already typed, when known. */
mainCommand?: string;
/** Resolved command head, including a subcommand when applicable. */
commandName?: string;
/** In argument mode: 0-based index of the argument position (after the command head). */
argumentIndex?: number;
/** Completed argument values before the current cursor position. */
argumentValues?: string[];
}
export interface RedisCompletionInput {
keys?: string[];
/** Commands reported by the connected Redis server through `COMMAND DOCS` or `COMMAND`. */
commands: readonly RedisCommandDocumentation[];
}
// ---- Static indexes derived from the command table ----
// ---- Server-reported command index ----
interface MainCommandEntry {
interface CompletionCommandEntry {
name: string;
spec: RedisCommandSpec;
arity: number;
group: string;
summary?: string;
since?: string;
keySpecs: readonly RedisCommandKeySpec[];
arguments: readonly RedisCommandArgument[];
}
interface SubcommandEntry {
main: string;
sub: string;
spec: RedisCommandSpec;
interface CompletionIndex {
commands: Map<string, CompletionCommandEntry>;
mainCommands: CompletionCommandEntry[];
subcommands: Map<string, CompletionCommandEntry[]>;
}
const MAIN_COMMANDS: MainCommandEntry[] = [];
const SUBCOMMANDS: SubcommandEntry[] = [];
const SUBCOMMAND_MAINS = new Set<string>();
for (const [key, spec] of Object.entries(REDIS_COMMAND_TABLE)) {
const space = key.indexOf(" ");
if (space < 0) {
MAIN_COMMANDS.push({ name: key, spec });
} else {
const main = key.slice(0, space);
const sub = key.slice(space + 1);
SUBCOMMANDS.push({ main, sub, spec });
SUBCOMMAND_MAINS.add(main);
// A main command that only has subcommand forms still needs a main-name
// entry so the user can complete the main token first.
if (!REDIS_COMMAND_TABLE[main]) MAIN_COMMANDS.push({ name: main, spec });
}
}
// Groups whose first argument is a key name (enable key completion there).
const KEY_ARGUMENT_GROUPS = new Set(["string", "generic", "list", "hash", "set", "zset", "bitmap", "hyperloglog", "geo", "stream"]);
// Commands that accept a variadic list of keys (keep suggesting keys beyond the
// first argument slot). Most key commands take exactly one key; these keep going.
const MULTI_KEY_COMMANDS = new Set(["DEL", "UNLINK", "EXISTS", "TOUCH", "MGET"]);
// Boost tuning: common groups surface higher.
const GROUP_BOOST: Record<string, number> = {
string: 110,
@@ -94,38 +81,92 @@ function describeArity(arity: number): string {
return "variable arguments";
}
function createCompletionIndex(commandDocs: readonly RedisCommandDocumentation[]): CompletionIndex {
const commands = new Map<string, CompletionCommandEntry>();
for (const doc of commandDocs) {
const name = doc.name.trim().toUpperCase();
if (!name) continue;
commands.set(name, {
name,
arity: doc.arity ?? 0,
group: doc.group ?? "unknown",
summary: doc.summary,
since: doc.since,
keySpecs: doc.keySpecs,
arguments: doc.arguments ?? [],
});
}
const mainCommands = new Map<string, CompletionCommandEntry>();
const subcommands = new Map<string, CompletionCommandEntry[]>();
for (const entry of commands.values()) {
const tokens = entry.name.split(" ");
const main = tokens[0]!;
if (!mainCommands.has(main)) {
mainCommands.set(main, commands.get(main) ?? { ...entry, name: main, summary: undefined, since: undefined });
}
for (let tokenIndex = 1; tokenIndex < tokens.length; tokenIndex++) {
const parent = tokens.slice(0, tokenIndex).join(" ");
const childName = tokens.slice(0, tokenIndex + 1).join(" ");
const entries = subcommands.get(parent) ?? [];
if (!entries.some((candidate) => candidate.name === childName)) {
entries.push(commands.get(childName) ?? { ...entry, name: childName, summary: undefined, since: undefined });
}
subcommands.set(parent, entries);
}
}
return {
commands,
mainCommands: [...mainCommands.values()],
subcommands,
};
}
const completionIndexes = new WeakMap<ReadonlyArray<RedisCommandDocumentation>, CompletionIndex>();
function completionIndex(input: Pick<RedisCompletionInput, "commands">): CompletionIndex {
const cached = completionIndexes.get(input.commands);
if (cached) return cached;
const index = createCompletionIndex(input.commands);
completionIndexes.set(input.commands, index);
return index;
}
function matchesPrefix(value: string, prefix: string): boolean {
return value.toLowerCase().startsWith(prefix.toLowerCase());
}
function buildSpecDetail(spec: RedisCommandSpec): string {
return spec.safety === "allowed" ? spec.group : `${spec.group} · ${spec.safety}`;
function buildSpecDetail(entry: CompletionCommandEntry): string {
return entry.group;
}
function buildSpecInfo(spec: RedisCommandSpec, label: string): string {
return [`Command: ${label}`, `Group: ${spec.group}`, `Arity: ${describeArity(spec.arity)}`, `Safety: ${spec.safety}`].join("\n");
function buildSpecInfo(entry: CompletionCommandEntry, label: string): string {
const info = [];
if (entry.summary) info.push(entry.summary);
info.push(`Command: ${label}`, `Group: ${entry.group}`, `Arity: ${describeArity(entry.arity)}`);
if (entry.since) info.push(`Since: ${entry.since}`);
return info.join("\n");
}
function boostFor(spec: RedisCommandSpec): number {
return GROUP_BOOST[spec.group] ?? 90;
function boostFor(entry: CompletionCommandEntry): number {
return GROUP_BOOST[entry.group] ?? 90;
}
// ---- Context parsing ----
export function getRedisCompletionContext(text: string, cursor: number): RedisCompletionContext {
export function getRedisCompletionContext(text: string, cursor: number, input: Pick<RedisCompletionInput, "commands">): RedisCompletionContext {
const index = completionIndex(input);
const safeCursor = Math.max(0, Math.min(cursor, text.length));
const lineStart = text.lastIndexOf("\n", safeCursor - 1) + 1;
const beforeCursor = text.slice(lineStart, safeCursor);
// Tokenize the part before the cursor by whitespace.
const tokens = beforeCursor.trimStart().length === 0 ? [] : beforeCursor.trim().split(/\s+/);
const tokenized = tokenizeRedisLine(beforeCursor);
const endsWithSpace = beforeCursor.length > 0 && /\s$/.test(beforeCursor);
// Current word being typed (no trailing space yet).
const currentWord = endsWithSpace ? "" : (tokens[tokens.length - 1] ?? "");
const wordStartFromEnd = currentWord.length;
const from = safeCursor - wordStartFromEnd;
const currentToken = endsWithSpace ? undefined : tokenized.argv[tokenized.argv.length - 1];
const currentWord = currentToken?.value ?? "";
const from = currentToken ? lineStart + currentToken.startColumn - 1 : safeCursor;
const tokens = tokenized.argv.map((token) => token.value);
const typedTokens = endsWithSpace ? tokens : tokens.slice(0, -1);
// No command yet (or typing the very first token).
@@ -133,45 +174,73 @@ export function getRedisCompletionContext(text: string, cursor: number): RedisCo
return { mode: "command", prefix: currentWord, from };
}
const main = typedTokens[0]!.toUpperCase();
const normalizedTokens = typedTokens.map((token) => token.toUpperCase());
const main = normalizedTokens[0]!;
const commandPrefix = normalizedTokens.join(" ");
// First token done + space → maybe a subcommand of a command that has them.
if (typedTokens.length === 1 && SUBCOMMAND_MAINS.has(main)) {
return { mode: "subcommand", prefix: currentWord, from, mainCommand: main };
// Command docs can nest subcommands more than one level deep. Treat every
// documented command prefix as a potential next-token completion context.
if (index.subcommands.has(commandPrefix)) {
return { mode: "subcommand", prefix: currentWord, from, mainCommand: main, commandName: commandPrefix };
}
// Past the command (and any subcommand slot) → an argument. Track which
// argument position the cursor is at so we only suggest key names for the
// first key argument (e.g. GET <key>, not after the key is filled in).
const commandHeadTokens = typedTokens.length >= 2 && REDIS_COMMAND_TABLE[`${main} ${typedTokens[1]!.toUpperCase()}`] ? 2 : 1;
let commandName: string | undefined;
for (let tokenCount = normalizedTokens.length; tokenCount > 0; tokenCount--) {
const candidate = normalizedTokens.slice(0, tokenCount).join(" ");
if (!index.commands.has(candidate)) continue;
commandName = candidate;
break;
}
const commandHeadTokens = commandName ? commandName.split(" ").length : normalizedTokens.length;
const argumentIndex = Math.max(typedTokens.length - commandHeadTokens, 0);
return { mode: "argument", prefix: currentWord, from, mainCommand: main, argumentIndex };
return {
mode: "argument",
prefix: currentWord,
from,
mainCommand: main,
commandName,
argumentIndex,
argumentValues: typedTokens.slice(commandHeadTokens),
};
}
// ---- Item builders ----
function commandItems(prefix: string): RedisCompletionItem[] {
const items = MAIN_COMMANDS.filter((entry) => matchesPrefix(entry.name, prefix)).map((entry) => ({
label: entry.name,
type: "keyword" as const,
detail: buildSpecDetail(entry.spec),
info: buildSpecInfo(entry.spec, entry.name),
boost: boostFor(entry.spec),
}));
function commandItems(index: CompletionIndex, prefix: string): RedisCompletionItem[] {
const items = index.mainCommands
.filter((entry) => matchesPrefix(entry.name, prefix))
.map((entry) => ({
label: entry.name,
type: "keyword" as const,
detail: buildSpecDetail(entry),
info: buildSpecInfo(entry, entry.name),
summary: entry.summary,
since: entry.since,
boost: boostFor(entry),
}));
return items.sort((a, b) => b.boost - a.boost);
}
function subcommandItems(main: string, prefix: string): RedisCompletionItem[] {
const items = SUBCOMMANDS.filter((entry) => entry.main === main && matchesPrefix(entry.sub, prefix)).map((entry) => ({
label: entry.sub,
type: "keyword" as const,
detail: buildSpecDetail(entry.spec),
info: buildSpecInfo(entry.spec, `${main} ${entry.sub}`),
boost: boostFor(entry.spec),
}));
function subcommandItems(index: CompletionIndex, commandPrefix: string, prefix: string): RedisCompletionItem[] {
const items = (index.subcommands.get(commandPrefix) ?? [])
.filter((entry) => matchesPrefix(entry.name.slice(commandPrefix.length + 1), prefix))
.map((entry) => ({
label: entry.name.slice(commandPrefix.length + 1),
type: "keyword" as const,
detail: buildSpecDetail(entry),
info: buildSpecInfo(entry, entry.name),
summary: entry.summary,
since: entry.since,
boost: boostFor(entry),
}));
return items.sort((a, b) => b.boost - a.boost);
}
function redisArgumentApply(value: string): string {
if (value && !/[\s"']/.test(value) && !value.endsWith(";")) return value;
return `"${value.replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t").replaceAll('"', '\\"')}"`;
}
function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] {
if (!prefix) {
// No partial key typed yet: offer a bounded sample (sorted) so the menu isn't empty.
@@ -179,6 +248,7 @@ function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] {
label: key,
type: "text" as const,
detail: "key",
apply: redisArgumentApply(key),
boost: 60,
}));
}
@@ -189,42 +259,205 @@ function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] {
label: key,
type: "text" as const,
detail: "key",
apply: redisArgumentApply(key),
boost: key.toLowerCase().startsWith(prefix.toLowerCase()) ? 70 : 55,
}));
}
export function buildRedisCompletionItemsFromContext(context: RedisCompletionContext, input: RedisCompletionInput = {}): RedisCompletionItem[] {
if (context.mode === "command") return commandItems(context.prefix);
if (context.mode === "subcommand" && context.mainCommand) {
return subcommandItems(context.mainCommand, context.prefix);
interface ArgumentTransition {
to: number;
token?: string;
argument?: RedisCommandArgument;
}
interface ArgumentGrammar {
start: number;
transitions: Map<number, ArgumentTransition[]>;
}
const argumentGrammars = new WeakMap<ReadonlyArray<RedisCommandArgument>, ArgumentGrammar>();
function argumentGrammar(arguments_: readonly RedisCommandArgument[]): ArgumentGrammar {
const cached = argumentGrammars.get(arguments_);
if (cached) return cached;
const transitions = new Map<number, ArgumentTransition[]>();
let nextState = 0;
const state = () => nextState++;
const add = (from: number, transition: ArgumentTransition) => transitions.set(from, [...(transitions.get(from) ?? []), transition]);
const epsilon = (from: number, to: number) => add(from, { to });
const compileSequence = (arguments_: readonly RedisCommandArgument[], start: number, end: number) => {
let from = start;
arguments_.forEach((argument, index) => {
const to = index === arguments_.length - 1 ? end : state();
compileArgument(argument, from, to);
from = to;
});
if (arguments_.length === 0) epsilon(start, end);
};
const compileContent = (argument: RedisCommandArgument, start: number, end: number) => {
if (argument.type === "pure-token") {
epsilon(start, end);
} else if (argument.type === "block") {
compileSequence(argument.arguments ?? [], start, end);
} else if (argument.type === "oneof") {
for (const child of argument.arguments ?? []) compileArgument(child, start, end);
} else if (argument.enum?.length) {
for (const value of argument.enum) add(start, { to: end, token: value.toUpperCase(), argument });
} else {
add(start, { to: end, argument });
}
};
const compileCore = (argument: RedisCommandArgument, start: number, end: number) => {
if (!argument.token) return compileContent(argument, start, end);
const contentStart = state();
add(start, { to: contentStart, token: argument.token.toUpperCase(), argument });
compileContent(argument, contentStart, end);
};
function compileArgument(argument: RedisCommandArgument, start: number, end: number) {
if (argument.optional) epsilon(start, end);
if (!argument.multiple) {
compileCore(argument, start, end);
return;
}
if (argument.token && !argument.multipleToken && argument.type !== "pure-token") {
const contentStart = state();
const repeated = state();
add(start, { to: contentStart, token: argument.token.toUpperCase(), argument });
compileContent(argument, contentStart, repeated);
epsilon(repeated, end);
epsilon(repeated, contentStart);
return;
}
const repeated = state();
compileCore(argument, start, repeated);
epsilon(repeated, end);
epsilon(repeated, start);
}
// argument mode: offer key names at the key-argument slot only. Most key
// commands take a single key (first slot); variadic key-list commands
// (DEL/UNLINK/EXISTS/...) keep suggesting at every slot.
if (context.mode === "argument" && takesKeyArgument(context.mainCommand) && shouldSuggestKeyAt(context.mainCommand, context.argumentIndex)) {
const start = state();
const end = state();
compileSequence(arguments_, start, end);
const grammar = { start, transitions };
argumentGrammars.set(arguments_, grammar);
return grammar;
}
function argumentTokenItems(entry: CompletionCommandEntry, values: readonly string[], prefix: string): RedisCompletionItem[] {
if (entry.arguments.length === 0) return [];
const { start, transitions } = argumentGrammar(entry.arguments);
const closure = (states: ReadonlySet<number>) => {
const result = new Set(states);
const pending = [...states];
while (pending.length) {
for (const transition of transitions.get(pending.pop()!) ?? []) {
if (transition.token || transition.argument || result.has(transition.to)) continue;
result.add(transition.to);
pending.push(transition.to);
}
}
return result;
};
let active = closure(new Set([start]));
for (const value of values) {
const next = new Set<number>();
for (const current of active) {
for (const transition of transitions.get(current) ?? []) {
if (transition.token ? transition.token === value.toUpperCase() : transition.argument) next.add(transition.to);
}
}
active = closure(next);
if (active.size === 0) return [];
}
const items = new Map<string, RedisCompletionItem>();
for (const current of active) {
for (const transition of transitions.get(current) ?? []) {
if (!transition.token || !matchesPrefix(transition.token, prefix)) continue;
const argument = transition.argument;
const info = [argument?.summary, entry.summary, argument?.since ? `Since: ${argument.since}` : undefined].filter(Boolean).join("\n");
items.set(transition.token, {
label: transition.token,
type: "keyword",
detail: [argument?.name, argument?.type].filter(Boolean).join(" · "),
info: info || undefined,
apply: transition.token,
appendSpace: true,
boost: 80,
});
}
}
return [...items.values()];
}
export function buildRedisCompletionItemsFromContext(context: RedisCompletionContext, input: RedisCompletionInput): RedisCompletionItem[] {
const index = completionIndex(input);
if (context.mode === "command") return commandItems(index, context.prefix);
if (context.mode === "subcommand" && context.commandName) {
return subcommandItems(index, context.commandName, context.prefix);
}
if (context.mode === "argument" && takesKeyArgument(context.commandName, input, context.argumentIndex, context.argumentValues)) {
return keyItems(context.prefix, input.keys ?? []);
}
if (context.mode === "argument" && context.commandName) {
const entry = index.commands.get(context.commandName);
if (entry) return argumentTokenItems(entry, context.argumentValues ?? [], context.prefix);
}
return [];
}
function shouldSuggestKeyAt(mainCommand: string | undefined, argumentIndex: number | undefined): boolean {
if (argumentIndex == null) return false;
if (mainCommand && MULTI_KEY_COMMANDS.has(mainCommand)) return true;
return argumentIndex === 0;
function keySpecStart(spec: RedisCommandKeySpec, argumentIndex: number, argumentsBeforeCursor: readonly string[]): number | undefined {
const beginSearch = spec.beginSearch;
if (beginSearch.type === "index") return beginSearch.index - 1;
const searchStart = beginSearch.startFrom >= 0 ? Math.max(0, beginSearch.startFrom - 1) : Math.max(0, Math.max(argumentsBeforeCursor.length, argumentIndex + 1) + beginSearch.startFrom);
if (beginSearch.startFrom < 0) {
for (let index = Math.min(searchStart, argumentsBeforeCursor.length - 1); index >= 0; index--) {
if (argumentsBeforeCursor[index]?.toUpperCase() === beginSearch.keyword) return index + 1;
}
return undefined;
}
const keywordIndex = argumentsBeforeCursor.findIndex((argument, index) => index >= searchStart && argument.toUpperCase() === beginSearch.keyword);
return keywordIndex < 0 ? undefined : keywordIndex + 1;
}
export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput = {}): RedisCompletionItem[] {
return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor), input);
function keySpecMatchesArgument(spec: RedisCommandKeySpec, argumentIndex: number, argumentsBeforeCursor: readonly string[]): boolean {
const start = keySpecStart(spec, argumentIndex, argumentsBeforeCursor);
if (start == null || argumentIndex < start) return false;
if (spec.findKeys.type === "range") {
// Redis uses negative lastkey values relative to the final argv position.
// The active argument is included so a completed tail argument is never
// offered as a key (for example, BLPOP's timeout).
const argumentCount = Math.max(argumentsBeforeCursor.length, argumentIndex + 1);
let last = spec.findKeys.lastKey < 0 ? argumentCount + spec.findKeys.lastKey : start + spec.findKeys.lastKey;
if (argumentIndex === start && argumentsBeforeCursor.length === start) last = start;
if (spec.findKeys.lastKey === -1 && spec.findKeys.limit > 1) {
const completedAfterStart = Math.max(0, argumentsBeforeCursor.length - start);
// With no key entered yet, the first key is unambiguous. Once arguments
// exist, `limit` separates the key list from its equally sized tail.
last = completedAfterStart === 0 ? start : start + (Math.ceil(completedAfterStart / spec.findKeys.limit) - 1) * spec.findKeys.keyStep;
}
return argumentIndex <= last && (argumentIndex - start) % spec.findKeys.keyStep === 0;
}
const keyCountArgument = argumentsBeforeCursor[start + spec.findKeys.keyNumIndex];
if (!keyCountArgument || !/^\d+$/.test(keyCountArgument)) return false;
const keyCount = Number(keyCountArgument);
const firstKey = start + spec.findKeys.firstKey;
const lastKey = firstKey + Math.max(0, keyCount - 1) * spec.findKeys.keyStep;
return argumentIndex >= firstKey && argumentIndex <= lastKey && (argumentIndex - firstKey) % spec.findKeys.keyStep === 0;
}
/** True when the main command's first argument is a key (by group heuristic). */
export function takesKeyArgument(mainCommand?: string): boolean {
if (!mainCommand) return false;
const spec = REDIS_COMMAND_TABLE[mainCommand];
if (spec) return KEY_ARGUMENT_GROUPS.has(spec.group);
// A main that only exists via subcommands (e.g. XGROUP, CONFIG): treat stream/cluster
// subcommand roots as key-taking only for the stream group, conservatively.
return false;
export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput): RedisCompletionItem[] {
return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor, input), input);
}
/** True when the server's key specs identify the active argument as a key. */
export function takesKeyArgument(commandName: string | undefined, input: Pick<RedisCompletionInput, "commands">, argumentIndex = 0, argumentsBeforeCursor: readonly string[] = []): boolean {
if (!commandName || argumentIndex < 0) return false;
const command = completionIndex(input).commands.get(commandName.toUpperCase());
return command?.keySpecs.some((spec) => keySpecMatchesArgument(spec, argumentIndex, argumentsBeforeCursor)) ?? false;
}
export function shouldAutoOpenRedisCompletion(text: string, cursor: number): boolean {
@@ -9,108 +9,9 @@
import type { SqlTextSpan } from "@/types/database";
import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
import { resolveRedisCommandSpec } from "@/lib/redis/redisCommandTable";
import { tokenizeRedisLine } from "@/lib/redis/redisCommandTokenizer";
export interface RedisArgvToken {
/** Token text (raw, case preserved). */
value: string;
/** 1-based start/end character columns within the source line. */
startColumn: number;
endColumn: number;
}
export interface RedisArgvResult {
argv: RedisArgvToken[];
/** True when a quoted string was not closed before end-of-line. */
unclosedQuote: boolean;
/** Column where an unclosed quote started (1-based), if any. */
unclosedQuoteStart?: number;
}
/**
* Tokenize a single Redis command line into argv, mirroring the server-side
* `parse_command_argv` rules (whitespace separation, single/double quotes,
* backslash escapes). Trailing `;` is stripped. Each token records its column
* span so diagnostics can underline the offending token.
*/
export function tokenizeRedisLine(line: string): RedisArgvResult {
const argv: RedisArgvToken[] = [];
let i = 0;
const n = line.length;
let unclosedQuote = false;
let unclosedQuoteStart: number | undefined;
while (i < n) {
// Skip whitespace.
while (i < n && (line[i] === " " || line[i] === "\t")) i++;
if (i >= n) break;
const startColumn = i + 1; // 1-based
let value = "";
let closed = false;
const ch = line[i];
if (ch === '"' || ch === "'") {
if (unclosedQuoteStart === undefined) unclosedQuoteStart = startColumn;
const quote = ch;
i++; // consume opening quote
let escaping = false;
while (i < n) {
const c = line[i];
if (escaping) {
value += c;
escaping = false;
i++;
continue;
}
if (c === "\\") {
escaping = true;
i++;
continue;
}
if (c === quote) {
i++; // consume closing quote
closed = true;
break;
}
value += c;
i++;
}
if (!closed && i >= n) {
unclosedQuote = true;
} else {
// If the quote was closed, allow trailing chars until whitespace as part
// of the same token (e.g. `"a"b` → `ab`) to match common tokenizers.
while (i < n && line[i] !== " " && line[i] !== "\t") {
value += line[i];
i++;
}
}
} else {
closed = true; // unquoted tokens are always complete
while (i < n && line[i] !== " " && line[i] !== "\t") {
if (line[i] === "\\") {
i++;
if (i < n) {
value += line[i];
i++;
}
continue;
}
value += line[i];
i++;
}
}
// Strip a single trailing semicolon (line-level `;` terminator).
if (value.endsWith(";")) value = value.slice(0, -1);
if (value.length > 0 || !closed) {
argv.push({ value, startColumn, endColumn: i + 1 });
}
}
return { argv, unclosedQuote, unclosedQuoteStart };
}
export { tokenizeRedisLine, type RedisArgvResult, type RedisArgvToken } from "@/lib/redis/redisCommandTokenizer";
function aritySatisfied(arity: number, tokenCount: number): boolean {
if (arity > 0) return tokenCount === arity;