Files
dbx/apps/desktop/src/lib/redis/redisCommandDocs.ts
T

258 lines
11 KiB
TypeScript

/**
* 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));
}