fix(elasticsearch): complete json keys without stray quote

This commit is contained in:
onenewcode
2026-09-01 01:17:59 +08:00
committed by GitHub
parent b41dac4617
commit cb3de0ef49
3 changed files with 165 additions and 14 deletions
@@ -3819,6 +3819,13 @@ function shouldInsertSqlCompletionSpace(): boolean {
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics";
}
// Snippet expansion normally follows from the item type; a provider can also
// opt a differently-typed item in so its `${}` fields still expand on accept.
function shouldApplyCompletionAsSnippet(item: QueryCompletionItem): boolean {
if ("applyAsSnippet" in item && item.applyAsSnippet === true) return true;
return item.type === "snippet" || item.type === "function";
}
function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectionActionItem) {
const filterText = "filterText" in item && typeof item.filterText === "string" ? item.filterText : undefined;
const labelPresentation = completionLabelPresentation(item.label, filterText);
@@ -3837,7 +3844,7 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio
recordCompletionSelection(item.label, item.type);
};
const batchColumnSelection = batchColumnSelectionMarkerForItem(item);
if ((item.type === "snippet" || item.type === "function") && item.apply) {
if (shouldApplyCompletionAsSnippet(item) && item.apply) {
const completion = codeMirrorSnippetCompletion(item.apply, {
...labelPresentation,
type: item.type,
@@ -1,11 +1,19 @@
export type ElasticsearchCompletionMode = "method" | "path" | "json";
export type ElasticsearchJsonSlot = "key" | "value";
export interface ElasticsearchCompletionItem {
label: string;
type: "keyword" | "property" | "text" | "snippet";
detail?: string;
info?: string;
apply?: string;
filterText?: string;
// Set when `apply` carries snippet fields so the editor expands it instead of
// inserting the raw `${}` markers.
applyAsSnippet?: boolean;
// The auto-closed quote sitting at the cursor, which the applied text replaces.
replaceClosingQuote?: '"';
boost: number;
}
@@ -16,6 +24,9 @@ export interface ElasticsearchCompletionContext {
method?: string;
path?: string;
segmentIndex?: number;
replaceClosingQuote?: '"';
jsonSlot?: ElasticsearchJsonSlot;
hasKeySeparator?: boolean;
}
export interface ElasticsearchCompletionInput {
@@ -61,6 +72,16 @@ const INDEX_ENDPOINTS = [
const JSON_KEYWORDS = ["query", "bool", "must", "should", "must_not", "filter", "match", "match_all", "term", "terms", "range", "exists", "sort", "aggs", "aggregations", "size", "from", "_source", "fields", "track_total_hits"];
const JSON_ARRAY_KEYS = new Set(["must", "should", "must_not", "filter", "sort", "_source", "fields"]);
const JSON_SCALAR_KEYS = new Set(["size", "from", "track_total_hits"]);
// Value scaffold inserted after a completed key so the matching brackets come
// along with it, with the cursor parked where the value goes.
function jsonKeyValueTemplate(key: string): string {
if (JSON_SCALAR_KEYS.has(key)) return "${}";
return JSON_ARRAY_KEYS.has(key) ? "[${}]" : "{${}}";
}
const JSON_SNIPPETS = [
{
label: "match_all",
@@ -129,7 +150,15 @@ export function getElasticsearchCompletionContext(text: string, cursor: number):
if (lineStart > 0 || safeCursor > firstLineLimit || looksLikeJsonBody(text, safeCursor)) {
const jsonPrefix = readJsonPrefix(text, safeCursor);
return { mode: "json", prefix: jsonPrefix.prefix, from: jsonPrefix.from };
const replaceClosingQuote = jsonPrefix.prefix.startsWith('"') && text[safeCursor] === '"' ? ('"' as const) : undefined;
return {
mode: "json",
prefix: jsonPrefix.prefix,
from: jsonPrefix.from,
replaceClosingQuote,
jsonSlot: readJsonSlot(text, jsonPrefix.from),
hasKeySeparator: hasJsonKeySeparator(text, safeCursor),
};
}
const prefix = readWordPrefix(text, safeCursor);
@@ -143,7 +172,7 @@ export function buildElasticsearchCompletionItems(text: string, cursor: number,
export function buildElasticsearchCompletionItemsFromContext(context: ElasticsearchCompletionContext, input: ElasticsearchCompletionInput = {}): ElasticsearchCompletionItem[] {
if (context.mode === "method") return methodItems(context.prefix);
if (context.mode === "json") return jsonItems(context.prefix);
if (context.mode === "json") return jsonItems(context);
return pathItems(context, input.indices ?? []);
}
@@ -225,25 +254,70 @@ function indexEndpointItems(prefix: string): ElasticsearchCompletionItem[] {
}));
}
function jsonItems(prefix: string): ElasticsearchCompletionItem[] {
const normalizedPrefix = prefix.replace(/^"/, "");
function jsonItems(context: ElasticsearchCompletionContext): ElasticsearchCompletionItem[] {
const quoted = context.prefix.startsWith('"');
const normalizedPrefix = context.prefix.replace(/^"/, "");
// Only an object key without its own `:` yet may pull the value scaffold in;
// anywhere else the extra `": {}"` would produce invalid JSON.
const withValueTemplate = context.jsonSlot === "key" && !context.hasKeySeparator;
const keyItems = JSON_KEYWORDS.filter((key) => matchesFuzzyPrefix(key, normalizedPrefix)).map((key) => ({
label: `"${key}"`,
type: "property" as const,
detail: "Query DSL field",
apply: `"${key}"`,
apply: withValueTemplate ? `"${key}": ${jsonKeyValueTemplate(key)}` : `"${key}"`,
applyAsSnippet: withValueTemplate,
replaceClosingQuote: context.replaceClosingQuote,
boost: key.startsWith(normalizedPrefix) ? 95 : 70,
}));
const snippetItems = JSON_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, normalizedPrefix)).map((snippet) => ({
label: snippet.label,
type: "snippet" as const,
detail: snippet.detail,
apply: snippet.apply,
boost: 105,
}));
// Snippets already spell out `"key": value`, so they only fit a key slot.
const snippetItems = withValueTemplate
? JSON_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, normalizedPrefix)).map((snippet) => ({
label: snippet.label,
type: "snippet" as const,
detail: snippet.detail,
apply: snippet.apply,
// Bare snippet labels cannot match a typed opening quote, so let the
// editor filter them on the quoted form it sees in the document.
filterText: quoted ? `"${snippet.label}"` : undefined,
replaceClosingQuote: context.replaceClosingQuote,
boost: 105,
}))
: [];
return dedupeAndSort([...snippetItems, ...keyItems]);
}
// Reads the body of the request the cursor sits in, with strings and comments
// blanked out so their quotes and braces cannot be mistaken for structure.
function readRequestBody(text: string, from: number): string {
const before = text.slice(0, from);
const requestLine = /[\s\S]*(?:^|\n)[ \t]*(?:GET|POST|PUT|DELETE|HEAD)[ \t][^\n]*/i.exec(before);
return before.slice(requestLine?.[0].length ?? 0).replace(/"(?:\\.|[^"\\])*"|(?:#|\/\/)[^\n]*/g, "");
}
// Tracks open brackets so an array element or a value after `:` is never
// mistaken for an object key.
function readJsonSlot(text: string, from: number): ElasticsearchJsonSlot {
const stack: string[] = [];
let last = "";
for (const char of readRequestBody(text, from)) {
if (/\s/.test(char)) continue;
if (char === "{" || char === "[") stack.push(char);
else if (char === "}" || char === "]") stack.pop();
last = char;
}
return stack[stack.length - 1] === "{" && (last === "{" || last === ",") ? "key" : "value";
}
function hasJsonKeySeparator(text: string, cursor: number): boolean {
let index = cursor;
// The caret may sit inside the key, so step over the rest of the token and
// its closing quote before looking for the separator.
while (index < text.length && /[\w_]/.test(text[index] ?? "")) index++;
if (text[index] === '"') index++;
while (text[index] === " " || text[index] === "\t") index++;
return text[index] === ":";
}
function looksLikeJsonBody(text: string, cursor: number): boolean {
const before = text.slice(0, cursor);
return before.includes("\n") || before.lastIndexOf("{") > before.lastIndexOf("\n");
+71 -1
View File
@@ -9,7 +9,11 @@ function applyCompletion(text: string, cursor: number, label: string): string {
const context = getElasticsearchCompletionContext(text, cursor);
const item = buildElasticsearchCompletionItems(text, cursor, { indices }).find((candidate) => candidate.label === label);
assert.ok(item, `Expected completion item ${label}`);
return `${text.slice(0, context.from)}${item.apply ?? item.label}${text.slice(cursor)}`;
// Mirrors the editor: the auto-closed quote at the cursor is part of the
// replaced range, and snippet fields expand to their placeholder text.
const to = item.replaceClosingQuote && text[cursor] === item.replaceClosingQuote ? cursor + 1 : cursor;
const insert = (item.apply ?? item.label).replace(/\$\{([^}]*)\}/g, "$1");
return `${text.slice(0, context.from)}${insert}${text.slice(to)}`;
}
test("suggests Elasticsearch HTTP methods for empty and prefix input", () => {
@@ -73,6 +77,72 @@ test("suggests Elasticsearch JSON DSL keys and snippets", () => {
assert.doesNotThrow(() => JSON.parse(`{${matchAll?.apply}}`));
});
test("Elasticsearch JSON key completion replaces the auto-closed quote", () => {
const text = 'GET /orders/_search\n{\n "query": {\n "bool":{\n "f"\n }\n }\n}';
const cursor = text.indexOf('"f"') + 2;
const context = getElasticsearchCompletionContext(text, cursor);
assert.equal(context.replaceClosingQuote, '"');
assert.equal(buildElasticsearchCompletionItems(text, cursor).find((item) => item.label === '"fields"')?.replaceClosingQuote, '"');
const applied = applyCompletion(text, cursor, '"fields"');
assert.ok(applied.includes('"fields": []'));
assert.equal(applied.includes('""'), false);
});
test("Elasticsearch JSON keys bring their value brackets along", () => {
const objectKey = 'GET /orders/_search\n{\n "qu';
assert.equal(applyCompletion(objectKey, objectKey.length, '"query"'), 'GET /orders/_search\n{\n "query": {}');
assert.equal(buildElasticsearchCompletionItems(objectKey, objectKey.length).find((item) => item.label === '"query"')?.applyAsSnippet, true);
const arrayKey = 'GET /orders/_search\n{\n "query": {\n "bool": {\n "mu';
assert.ok(applyCompletion(arrayKey, arrayKey.length, '"must"').endsWith('"must": []'));
const scalarKey = 'GET /orders/_search\n{\n "si';
assert.equal(applyCompletion(scalarKey, scalarKey.length, '"size"'), 'GET /orders/_search\n{\n "size": ');
});
test("Elasticsearch JSON keys keep the scaffold out when a separator already exists", () => {
const text = 'GET /orders/_search\n{\n "qu": {}';
const cursor = 'GET /orders/_search\n{\n "qu'.length;
assert.equal(getElasticsearchCompletionContext(text, cursor).hasKeySeparator, true);
assert.equal(applyCompletion(text, cursor, '"query"'), 'GET /orders/_search\n{\n "query": {}');
// The caret may also sit inside an existing key, past its own separator.
const inside = 'GET /orders/_search\n{\n "size": 10\n}';
assert.equal(getElasticsearchCompletionContext(inside, inside.indexOf('"size"') + 3).hasKeySeparator, true);
});
test("Elasticsearch JSON slot survives comments and earlier requests", () => {
const commented = 'GET /orders/_search\n# the "orders body\n{\n "bo';
assert.equal(getElasticsearchCompletionContext(commented, commented.length).jsonSlot, "key");
const secondRequest = 'GET /orders/_search\n{\n "query": {\n\nGET /users/_search\n{\n "bo';
assert.equal(getElasticsearchCompletionContext(secondRequest, secondRequest.length).jsonSlot, "key");
});
test("Elasticsearch JSON value slots are not completed as keys", () => {
const text = 'GET /orders/_search\n{\n "sort": [\n "fi';
const items = buildElasticsearchCompletionItems(text, text.length);
assert.equal(getElasticsearchCompletionContext(text, text.length).jsonSlot, "value");
assert.equal(items.find((item) => item.label === '"fields"')?.apply, '"fields"');
assert.equal(
items.some((item) => item.label === "bool"),
false,
);
});
test("Elasticsearch JSON snippets stay reachable after an opening quote", () => {
const quoted = 'GET /orders/_search\n{\n "bo';
assert.equal(buildElasticsearchCompletionItems(quoted, quoted.length).find((item) => item.label === "bool")?.filterText, '"bool"');
const bare = 'GET /orders/_search\n{\n "query": {bo';
assert.equal(getElasticsearchCompletionContext(bare, bare.length).mode, "json");
assert.equal(buildElasticsearchCompletionItems(bare, bare.length).find((item) => item.label === "bool")?.filterText, undefined);
});
test("Elasticsearch completion auto trigger ignores structural JSON punctuation", () => {
assert.equal(shouldAutoOpenElasticsearchCompletion("GET /", "GET /".length), true);
assert.equal(shouldAutoOpenElasticsearchCompletion("GET /_", "GET /_".length), true);