feat(redis): add command terminal autocomplete
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user