From 8772f10aa4905a48e61214fc91c682064b038d12 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 2 Sep 2026 14:01:25 +0800 Subject: [PATCH] fix(elasticsearch): add dsl query snippets to completion --- .../elasticsearch/elasticsearchCompletion.ts | 38 ++++++++++++++++++- .../app-tests/elasticsearchCompletion.test.ts | 15 ++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/elasticsearch/elasticsearchCompletion.ts b/apps/desktop/src/lib/elasticsearch/elasticsearchCompletion.ts index 2575f3d2af..3d3d2161bb 100644 --- a/apps/desktop/src/lib/elasticsearch/elasticsearchCompletion.ts +++ b/apps/desktop/src/lib/elasticsearch/elasticsearchCompletion.ts @@ -27,6 +27,7 @@ export interface ElasticsearchCompletionContext { replaceClosingQuote?: '"'; jsonSlot?: ElasticsearchJsonSlot; hasKeySeparator?: boolean; + jsonIndent?: string; } export interface ElasticsearchCompletionInput { @@ -88,6 +89,16 @@ const JSON_SNIPPETS = [ apply: '"match_all": {}', detail: "Match all documents", }, + { + label: "match", + apply: '"match": {\n "${field}": "${value}"\n}', + detail: "Match query", + }, + { + label: "term", + apply: '"term": {\n "${field}": "${value}"\n}', + detail: "Term query", + }, { label: "bool", apply: '"bool": {\n "must": [\n {}\n ],\n "filter": []\n}', @@ -98,6 +109,11 @@ const JSON_SNIPPETS = [ apply: '"range": {\n "${field}": {\n "gte": "${value}"\n }\n}', detail: "Range query", }, + { + label: "exists", + apply: '"exists": {\n "field": "${field}"\n}', + detail: "Exists query", + }, { label: "terms", apply: '"terms": {\n "${field}": []\n}', @@ -117,7 +133,10 @@ export function getElasticsearchCompletionContext(text: string, cursor: number): // Check the current line before falling back to JSON mode so completion also // works after leading comments and on subsequent REST requests. const methodMatch = /^\s*([A-Za-z]*)$/.exec(beforeCursorOnLine); - if (methodMatch) { + // A bare word on a JSON body line is a DSL key, not another HTTP method. + // Without this guard, typing `term` after an opening `{` incorrectly enters + // method mode and produces no JSON suggestions until a quote is added. + if (methodMatch && (lineStart === 0 || !hasOpenJsonContainer(text, safeCursor))) { const leadingWhitespace = beforeCursorOnLine.length - beforeCursorOnLine.trimStart().length; return { mode: "method", @@ -158,6 +177,7 @@ export function getElasticsearchCompletionContext(text: string, cursor: number): replaceClosingQuote, jsonSlot: readJsonSlot(text, jsonPrefix.from), hasKeySeparator: hasJsonKeySeparator(text, safeCursor), + jsonIndent: beforeCursorOnLine.match(/^\s*/)?.[0] ?? "", }; } @@ -275,7 +295,7 @@ function jsonItems(context: ElasticsearchCompletionContext): ElasticsearchComple label: snippet.label, type: "snippet" as const, detail: snippet.detail, - apply: snippet.apply, + apply: indentJsonSnippet(snippet.apply, context.jsonIndent ?? ""), // 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, @@ -286,6 +306,11 @@ function jsonItems(context: ElasticsearchCompletionContext): ElasticsearchComple return dedupeAndSort([...snippetItems, ...keyItems]); } +function indentJsonSnippet(snippet: string, baseIndent: string): string { + if (!baseIndent) return snippet; + return snippet.replace(/\n/g, `\n${baseIndent}`); +} + // 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 { @@ -294,6 +319,15 @@ function readRequestBody(text: string, from: number): string { return before.slice(requestLine?.[0].length ?? 0).replace(/"(?:\\.|[^"\\])*"|(?:#|\/\/)[^\n]*/g, ""); } +function hasOpenJsonContainer(text: string, cursor: number): boolean { + const stack: string[] = []; + for (const char of readRequestBody(text, cursor)) { + if (char === "{" || char === "[") stack.push(char); + else if (char === "}" || char === "]") stack.pop(); + } + return stack.length > 0; +} + // 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 { diff --git a/packages/app-tests/elasticsearchCompletion.test.ts b/packages/app-tests/elasticsearchCompletion.test.ts index 20e855383e..1455090472 100644 --- a/packages/app-tests/elasticsearchCompletion.test.ts +++ b/packages/app-tests/elasticsearchCompletion.test.ts @@ -102,6 +102,21 @@ test("Elasticsearch JSON keys bring their value brackets along", () => { assert.equal(applyCompletion(scalarKey, scalarKey.length, '"size"'), 'GET /orders/_search\n{\n "size": '); }); +test("Elasticsearch query snippets provide term field and value placeholders", () => { + const text = 'GET /orders/_search\n{\n te'; + assert.equal(getElasticsearchCompletionContext(text, text.length).mode, "json"); + const term = buildElasticsearchCompletionItems(text, text.length).find((item) => item.label === "term"); + + assert.ok(term); + assert.equal(term?.type, "snippet"); + assert.equal( + applyCompletion(text, text.length, "term"), + 'GET /orders/_search\n{\n "term": {\n "field": "value"\n }', + ); + assert.match(term?.apply ?? "", /\$\{field\}/); + assert.match(term?.apply ?? "", /\$\{value\}/); +}); + 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;