mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(elasticsearch): complete json keys without stray quote
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user