diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 30c5fef5e3..5a255be2dd 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -38,6 +38,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport"; import * as api from "@/lib/api"; import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics"; import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redisSyntaxDiagnostics"; +import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redisCompletion"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sqlCompletion"; import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; @@ -874,7 +875,7 @@ function unregisterTableReferenceDropListener() { let completionEpoch = 0; let completionDebounceTimer: ReturnType | null = null; -type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem; +type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem; function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) { if (items.length === 0) return null; @@ -969,6 +970,29 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor()); } +async function provideRedisCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) { + if (!props.connectionId) return null; + const epoch = ++completionEpoch; + const fullDoc = currentState.doc.toString(); + if (!explicit && !shouldAutoOpenRedisCompletion(fullDoc, position)) return null; + + const completionContext = getRedisCompletionContext(fullDoc, position); + // Key-name completion needs a reliable db index; props.database may briefly be "" on + // the New Query path before the active db resolves, and only key-argument commands warrant it. + let keys: string[] = []; + if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.mainCommand)) { + try { + keys = await connectionStore.listRedisCompletionKeys(props.connectionId, props.database); + } catch { + keys = []; + } + } + if (epoch !== completionEpoch) return null; + + const items = buildRedisCompletionItemsFromContext(completionContext, { keys }); + return buildCompletionResult(items, completionContext.from, getRedisCompletionResultValidFor()); +} + async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) { if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null; if (!props.connectionId) return null; @@ -978,6 +1002,9 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E return provideElasticsearchCompletions(currentState, position, explicit); } } + if (props.databaseType === "redis") { + return provideRedisCompletions(currentState, position, explicit); + } const hasDatabase = props.database != null; const epoch = ++completionEpoch; diff --git a/apps/desktop/src/lib/redisCompletion.ts b/apps/desktop/src/lib/redisCompletion.ts new file mode 100644 index 0000000000..2b897072f6 --- /dev/null +++ b/apps/desktop/src/lib/redisCompletion.ts @@ -0,0 +1,243 @@ +/** + * 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. + * + * 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. + */ +import { REDIS_COMMAND_TABLE } from "@/lib/redisCommandTable"; +import type { RedisCommandSpec } from "@/lib/redisCommandTable"; + +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 + apply?: string; + boost: number; +} + +export interface RedisCompletionContext { + mode: "command" | "subcommand" | "argument"; + prefix: string; + /** Absolute document offset where the completion starts. */ + from: number; + /** Upper-cased main command already typed, when known. */ + mainCommand?: string; + /** In argument mode: 0-based index of the argument position (after the command head). */ + argumentIndex?: number; +} + +export interface RedisCompletionInput { + keys?: string[]; +} + +// ---- Static indexes derived from the command table ---- + +interface MainCommandEntry { + name: string; + spec: RedisCommandSpec; +} + +interface SubcommandEntry { + main: string; + sub: string; + spec: RedisCommandSpec; +} + +const MAIN_COMMANDS: MainCommandEntry[] = []; +const SUBCOMMANDS: SubcommandEntry[] = []; +const SUBCOMMAND_MAINS = new Set(); + +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: 110, + generic: 108, + connection: 100, + server: 96, +}; + +function describeArity(arity: number): string { + if (arity > 0) { + const n = arity - 1; + return `exactly ${n} argument${n === 1 ? "" : "s"}`; + } + if (arity < 0) { + const n = -arity - 1; + return `at least ${n} argument${n === 1 ? "" : "s"}`; + } + return "variable arguments"; +} + +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 buildSpecInfo(spec: RedisCommandSpec, label: string): string { + return [`Command: ${label}`, `Group: ${spec.group}`, `Arity: ${describeArity(spec.arity)}`, `Safety: ${spec.safety}`].join("\n"); +} + +function boostFor(spec: RedisCommandSpec): number { + return GROUP_BOOST[spec.group] ?? 90; +} + +// ---- Context parsing ---- + +export function getRedisCompletionContext(text: string, cursor: number): RedisCompletionContext { + 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 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 typedTokens = endsWithSpace ? tokens : tokens.slice(0, -1); + + // No command yet (or typing the very first token). + if (typedTokens.length === 0) { + return { mode: "command", prefix: currentWord, from }; + } + + const main = typedTokens[0]!.toUpperCase(); + + // 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 }; + } + + // 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 , not after the key is filled in). + const commandHeadTokens = typedTokens.length >= 2 && REDIS_COMMAND_TABLE[`${main} ${typedTokens[1]!.toUpperCase()}`] ? 2 : 1; + const argumentIndex = Math.max(typedTokens.length - commandHeadTokens, 0); + return { mode: "argument", prefix: currentWord, from, mainCommand: main, argumentIndex }; +} + +// ---- 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), + })); + 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), + })); + return items.sort((a, b) => b.boost - a.boost); +} + +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. + return keys.slice(0, 100).map((key) => ({ + label: key, + type: "text" as const, + detail: "key", + boost: 60, + })); + } + return keys + .filter((key) => key.toLowerCase().includes(prefix.toLowerCase())) + .slice(0, 100) + .map((key) => ({ + label: key, + type: "text" as const, + detail: "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); + } + // 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)) { + return keyItems(context.prefix, input.keys ?? []); + } + 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; +} + +export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput = {}): RedisCompletionItem[] { + return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor), input); +} + +/** 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 shouldAutoOpenRedisCompletion(text: string, cursor: number): boolean { + const previousChar = text[cursor - 1]; + if (!previousChar) return false; + if (/[\n\r]/.test(previousChar)) return false; + // Open while typing command names or key names (letters/digits/_/:/./-). + if (/[\w:*.-]/.test(previousChar)) return true; + // Just typed a space after a command → open to suggest subcommands / keys. + if (/\s/.test(previousChar)) return true; + return false; +} + +export function getRedisCompletionResultValidFor(): RegExp { + return /[\w:*.-]*$/; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 4dfbd01e86..4ded0cfe4f 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -108,6 +108,7 @@ export const useConnectionStore = defineStore("connection", () => { const completionForeignKeysCache = ref>({}); const completionDatabasesCache = ref>({}); const elasticsearchCompletionIndicesCache = ref>({}); + const redisCompletionKeysCache = ref>({}); const schemaListCache = ref>({}); const sidebarSearchQuery = ref(""); const completionTableIndex = new Map(); @@ -603,6 +604,9 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key]; } + for (const key of Object.keys(redisCompletionKeysCache.value)) { + if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key]; + } for (const key of completionTableIndex.keys()) { if (key.startsWith(cachePrefix)) completionTableIndex.delete(key); } @@ -1970,6 +1974,27 @@ export const useConnectionStore = defineStore("connection", () => { return elasticsearchCompletionIndicesCache.value[cacheKey]; } + // Upper bound on cached key names per db, to keep completion memory bounded + // (Redis can hold far more keys than we ever want resident for autocomplete). + const REDIS_COMPLETION_KEYS_MAX = 1000; + + async function listRedisCompletionKeys(connectionId: string, database: string): Promise { + if (!database) return []; + const cacheKey = `${connectionId}:${database}`; + const cached = redisCompletionKeysCache.value[cacheKey]; + if (cached) return cached; + return withCompletionInFlight(`${cacheKey}:redis-keys`, async () => { + await ensureConnected(connectionId); + const pageSize = settingsStore.editorSettings.redisScanPageSize; + // Bounded multi-round SCAN: trade coverage for latency/memory safety. + const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6); + const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX); + redisCompletionKeysCache.value[cacheKey] = keys; + evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX); + return keys; + }); + } + async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise { const normalizedFilter = filter.trim().toLowerCase(); const relaxedFilter = relaxedCompletionTableFilter(normalizedFilter); @@ -2765,6 +2790,7 @@ export const useConnectionStore = defineStore("connection", () => { refreshCompletionSchemas, refreshCompletionDatabases, listElasticsearchCompletionIndices, + listRedisCompletionKeys, exportConnectionsToFile, readImportFile, importConnectionsFromFile, diff --git a/packages/app-tests/redisCompletion.test.ts b/packages/app-tests/redisCompletion.test.ts new file mode 100644 index 0000000000..8e477e0948 --- /dev/null +++ b/packages/app-tests/redisCompletion.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + buildRedisCompletionItems, + getRedisCompletionContext, + getRedisCompletionResultValidFor, + shouldAutoOpenRedisCompletion, + takesKeyArgument, +} from "../../apps/desktop/src/lib/redisCompletion.ts"; + +function labels(items: { label: string }[]): string[] { + return items.map((item) => item.label); +} + +test("command mode: completes command names by prefix (case-insensitive)", () => { + const items = buildRedisCompletionItems("GE", 2); + const names = labels(items); + assert.ok(names.includes("GET")); + assert.ok(names.includes("GETSET")); + assert.ok(names.includes("GETRANGE")); + // no subcommand forms leak in + assert.ok(!names.some((n) => n.includes(" "))); + // lowercase prefix also works + assert.ok(labels(buildRedisCompletionItems("ge", 2)).includes("GET")); +}); + +test("command mode: does not surface bare subcommand labels", () => { + const items = buildRedisCompletionItems("XGROUP", 6); + const names = labels(items); + assert.ok(names.includes("XGROUP")); + // "CREATE" alone is not a top-level command + assert.ok(!names.includes("CREATE")); +}); + +test("command mode: detail/info carry group + arity + safety", () => { + const items = buildRedisCompletionItems("FLUSHALL", 9); + const flush = items.find((item) => item.label === "FLUSHALL"); + assert.ok(flush); + assert.match(flush!.detail ?? "", /server.*blocked/i); + assert.match(flush!.info ?? "", /Group:\s*server/i); + assert.match(flush!.info ?? "", /Arity:/i); + assert.match(flush!.info ?? "", /Safety:\s*blocked/i); +}); + +test("subcommand mode: after 'XGROUP ' suggests its subcommands", () => { + const items = buildRedisCompletionItems("XGROUP ", 7); + const names = labels(items); + assert.ok(names.includes("CREATE")); + assert.ok(names.includes("DESTROY")); + assert.ok(names.includes("SETID")); + // unrelated commands absent + assert.ok(!names.includes("GET")); +}); + +test("subcommand mode: filters subcommands by prefix", () => { + const items = buildRedisCompletionItems("XGROUP C", 8); + const names = labels(items); + assert.ok(names.includes("CREATE")); + assert.ok(names.includes("CREATECONSUMER")); + assert.ok(!names.includes("DESTROY")); +}); + +test("argument mode: offers key names for key-taking commands", () => { + const items = buildRedisCompletionItems("GET ", 4, { keys: ["user:1", "user:2", "config:db"] }); + const names = labels(items); + assert.ok(names.includes("user:1")); + assert.ok(names.includes("user:2")); +}); + +test("argument mode: filters keys by substring prefix", () => { + const items = buildRedisCompletionItems("GET user", 8, { keys: ["user:1", "user:2", "config:db"] }); + const names = labels(items); + assert.ok(names.includes("user:1")); + assert.ok(names.includes("user:2")); + assert.ok(!names.includes("config:db")); +}); + +test("argument mode: empty keys yields no key items", () => { + const items = buildRedisCompletionItems("GET ", 4, { keys: [] }); + assert.equal(items.length, 0); +}); + +test("argument mode: does not suggest keys once the key argument is filled in", () => { + // GET takes exactly one key argument; after "GET key " the next slot is out of range. + assert.equal(buildRedisCompletionItems("GET key ", 8, { keys: ["user:1"] }).length, 0); + assert.equal(buildRedisCompletionItems("GET key extra ", 14, { keys: ["user:1"] }).length, 0); + // HSET takes key then field/value; after the key only non-key args remain. + assert.equal(buildRedisCompletionItems("HSET k ", 7, { keys: ["user:1"] }).length, 0); +}); + +test("argument mode: variadic key-list commands keep suggesting keys", () => { + // DEL accepts multiple keys — keep suggesting after the first one. + assert.ok(labels(buildRedisCompletionItems("DEL k1 ", 7, { keys: ["user:1"] })).includes("user:1")); + assert.ok(labels(buildRedisCompletionItems("EXISTS k1 k2 ", 13, { keys: ["user:1"] })).includes("user:1")); +}); + +test("argument mode: still suggests keys at the first argument slot", () => { + // GET — first argument position + const items = buildRedisCompletionItems("GET ", 4, { keys: ["user:1", "user:2"] }); + assert.ok(labels(items).includes("user:1")); +}); + +test("argument mode: non-key commands (FLUSHDB) do not suggest keys", () => { + const items = buildRedisCompletionItems("FLUSHDB ", 8, { keys: ["user:1", "user:2"] }); + assert.equal(items.length, 0); +}); + +test("takesKeyArgument reflects command group", () => { + assert.equal(takesKeyArgument("GET"), true); // string + assert.equal(takesKeyArgument("HSET"), true); // hash + assert.equal(takesKeyArgument("DEL"), true); // generic + assert.equal(takesKeyArgument("FLUSHDB"), false); // server + assert.equal(takesKeyArgument("SELECT"), false); // connection + assert.equal(takesKeyArgument(undefined), false); +}); + +test("context parsing: empty line → command mode", () => { + const ctx = getRedisCompletionContext("", 0); + assert.equal(ctx.mode, "command"); + assert.equal(ctx.prefix, ""); +}); + +test("context parsing: command + space → subcommand mode when the command has subcommands", () => { + const ctx = getRedisCompletionContext("XGROUP ", 7); + assert.equal(ctx.mode, "subcommand"); + assert.equal(ctx.mainCommand, "XGROUP"); +}); + +test("context parsing: GET + space → argument mode", () => { + const ctx = getRedisCompletionContext("GET ", 4); + assert.equal(ctx.mode, "argument"); + assert.equal(ctx.mainCommand, "GET"); +}); + +test("shouldAutoOpenRedisCompletion opens on word/space chars, not newline", () => { + assert.equal(shouldAutoOpenRedisCompletion("GET", 3), true); + assert.equal(shouldAutoOpenRedisCompletion("GET ", 4), true); // space → offer keys/subcommands + assert.equal(shouldAutoOpenRedisCompletion("GET\n", 4), false); + assert.equal(shouldAutoOpenRedisCompletion("user:", 5), true); +}); + +test("result validFor covers command-name and key-name characters", () => { + const re = getRedisCompletionResultValidFor(); + assert.equal(re.test("GET"), true); + assert.equal(re.test("user:1"), true); + assert.equal(re.test("a.b-c"), true); +});