Skip to content

Commit 04385b0

Browse files
committed
autocomplete marker free
1 parent b090967 commit 04385b0

14 files changed

Lines changed: 241 additions & 195 deletions

File tree

src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/ast.test.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10+
import { PromQLParser } from '@elastic/esql';
1011
import { EDITOR_MARKER } from '../constants';
11-
import { correctPromqlQuerySyntax, correctQuerySyntax, getBracketsToClose } from './ast';
12+
import {
13+
addAutocompleteMarker,
14+
correctPromqlQuerySyntax,
15+
correctQuerySyntax,
16+
getBracketsToClose,
17+
removeAutocompleteMarkers,
18+
} from './ast';
1219

1320
describe('getBracketsToClose', () => {
1421
it('returns the number of brackets to close', () => {
@@ -38,19 +45,33 @@ describe('getBracketsToClose', () => {
3845
});
3946
});
4047

41-
describe('correctQuerySyntax', () => {
48+
describe('addAutocompleteMarker', () => {
4249
it('appends marker after operator', () => {
4350
const query = 'FROM foo | EVAL field > ';
44-
const result = correctQuerySyntax(query);
51+
const result = addAutocompleteMarker(query);
4552
expect(result.endsWith(EDITOR_MARKER)).toBe(true);
4653
});
4754

4855
it('appends marker after comma', () => {
4956
const query = 'FROM foo | STATS field1, ';
50-
const result = correctQuerySyntax(query);
57+
const result = addAutocompleteMarker(query);
58+
expect(result.endsWith(EDITOR_MARKER)).toBe(true);
59+
});
60+
61+
it('appends marker if all brackets are closed and ends with operator', () => {
62+
const query = 'FROM index | STATS AVG(field1) != ';
63+
const result = addAutocompleteMarker(query);
5164
expect(result.endsWith(EDITOR_MARKER)).toBe(true);
5265
});
5366

67+
it('does not append marker for inline cast type names ending with operator-like suffixes', () => {
68+
const query = 'FROM index | EVAL vec = [0.1, 0.2]::dense_vector ';
69+
const result = addAutocompleteMarker(query);
70+
expect(result).toEqual(query);
71+
});
72+
});
73+
74+
describe('correctQuerySyntax', () => {
5475
it('closes unclosed brackets', () => {
5576
const query = 'FROM foo | EVAL foo(bar[baz';
5677
const result = correctQuerySyntax(query);
@@ -65,22 +86,11 @@ describe('correctQuerySyntax', () => {
6586
expect(result).toEqual(query);
6687
});
6788

68-
it('appends marker if all brackets are closed and ends with operator', () => {
69-
const query = 'FROM index | STATS AVG(field1) != ';
70-
const result = correctQuerySyntax(query);
71-
expect(result.endsWith(EDITOR_MARKER)).toBe(true);
72-
});
73-
7489
it('handles incomplete function signature', () => {
7590
const query = 'FROM foo | EVAL foo(bar, ';
7691
const result = correctQuerySyntax(query);
77-
expect(result.endsWith(`${EDITOR_MARKER})`)).toBe(true);
78-
});
79-
80-
it('does not append marker for inline cast type names ending with operator-like suffixes', () => {
81-
const query = 'FROM index | EVAL vec = [0.1, 0.2]::dense_vector ';
82-
const result = correctQuerySyntax(query);
83-
expect(result).toEqual(query);
92+
expect(result.endsWith(')')).toBe(true);
93+
expect(result).not.toContain(EDITOR_MARKER);
8494
});
8595
});
8696

@@ -115,4 +125,12 @@ describe('correctPromqlQuerySyntax', () => {
115125
const markerMatches = result.match(new RegExp(EDITOR_MARKER, 'g')) ?? [];
116126
expect(markerMatches).toHaveLength(1);
117127
});
128+
129+
it('normalizes marker nodes from parsed PromQL autocomplete ASTs', () => {
130+
const query = correctPromqlQuerySyntax('rate(http_requests_total, ');
131+
const { root } = PromQLParser.parse(query);
132+
const normalizedRoot = removeAutocompleteMarkers(root);
133+
134+
expect(JSON.stringify(normalizedRoot)).not.toContain(EDITOR_MARKER);
135+
});
118136
});

src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/ast.ts

Lines changed: 61 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,25 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import { isColumn, isIdentifier, isList, isOptionNode, isSource, Walker } from '@elastic/esql';
10+
import { isList, isOptionNode, PromQLParser, Walker } from '@elastic/esql';
11+
import type { PromQLAstNode, PromQLAstQueryExpression } from '@elastic/esql';
1112
import type {
1213
ESQLFunction,
1314
ESQLSingleAstItem,
14-
ESQLAstExpression,
1515
ESQLAstItem,
1616
ESQLCommandOption,
17-
ESQLAstAllCommands,
1817
ESQLAstHeaderCommand,
1918
ESQLAstQueryExpression,
2019
} from '@elastic/esql/types';
2120
import { EDITOR_MARKER } from '../constants';
22-
import { endsWithComma } from './regex';
21+
import { endsWithComma, endsWithWhitespace } from './regex';
2322

24-
export function isMarkerNode(node: ESQLAstItem | undefined): boolean {
25-
if (Array.isArray(node)) {
26-
return false;
27-
}
23+
const ENDS_WITH_BINARY_OPERATOR_REGEX =
24+
/(?:\+|\/|==|>=|>|<=|<|:|%|\*|-|!=|=|\b(?:in|like|not in|not like|not rlike|rlike|and|or|not|as)\b)\s+$/i;
25+
const ENDS_WITH_CASTING_OPERATOR_REGEX = /::\s*$/i;
2826

29-
return Boolean(
30-
node &&
31-
(isColumn(node) || isIdentifier(node) || isSource(node)) &&
32-
node.name.endsWith(EDITOR_MARKER)
33-
);
27+
export function isMarkerNode(node: ESQLAstItem | PromQLAstNode | undefined): boolean {
28+
return Boolean(node && !Array.isArray(node) && node.name?.endsWith(EDITOR_MARKER));
3429
}
3530

3631
function findCommand(ast: ESQLAstQueryExpression, offset: number) {
@@ -65,81 +60,37 @@ function findHeaderCommand(
6560
return targetHeader.incomplete ? targetHeader : undefined;
6661
}
6762

68-
export function isNotMarkerNodeOrArray(arg: ESQLAstItem) {
69-
return Array.isArray(arg) || !isMarkerNode(arg);
70-
}
71-
72-
const removeMarkerNode = (node: ESQLAstExpression) => {
73-
Walker.walk(node, {
74-
visitAny: (current) => {
75-
if ('args' in current) {
76-
current.args = current.args.filter((n) => !isMarkerNode(n));
77-
} else if ('values' in current) {
78-
current.values = current.values.filter((n) => !isMarkerNode(n));
79-
}
80-
},
81-
});
82-
};
83-
84-
function removeMarkerArgFromArgsList<T extends ESQLSingleAstItem | ESQLAstAllCommands>(
85-
node: T | undefined
86-
) {
87-
if (!node) {
88-
return;
63+
// Removes parser-only autocomplete markers while preserving parser locations for cursor math.
64+
export function removeAutocompleteMarkers<T>(value: T): T {
65+
if (Array.isArray(value)) {
66+
return value
67+
.filter((item) => !isMarkerNode(item))
68+
.map((item) => removeAutocompleteMarkers(item)) as T;
8969
}
90-
if (node.type === 'command' || node.type === 'option' || node.type === 'function') {
91-
const cleanedNode = {
92-
...node,
93-
text: node.text.replace(EDITOR_MARKER, ''),
94-
args: node.args.filter(isNotMarkerNodeOrArray).map(mapToNonMarkerNode),
95-
};
96-
97-
if (cleanedNode.type === 'command' && 'expression' in cleanedNode && cleanedNode.expression) {
98-
cleanedNode.expression = isMarkerNode(cleanedNode.expression)
99-
? undefined
100-
: (mapToNonMarkerNode(cleanedNode.expression) as ESQLAstExpression);
101-
}
10270

103-
return cleanedNode;
71+
if (!value || typeof value !== 'object') {
72+
return value;
10473
}
105-
return node;
106-
}
10774

108-
export function mapToNonMarkerNode(arg: ESQLAstItem): ESQLAstItem {
109-
if (Array.isArray(arg)) {
110-
return arg.filter(isNotMarkerNodeOrArray).map(mapToNonMarkerNode);
75+
if (isMarkerNode(value as ESQLAstItem | PromQLAstNode)) {
76+
return undefined as T;
11177
}
11278

113-
if ('args' in arg) {
114-
return {
115-
...arg,
116-
text: arg.text.replace(EDITOR_MARKER, ''),
117-
args: arg.args.filter(isNotMarkerNodeOrArray).map(mapToNonMarkerNode) as typeof arg.args,
118-
} as typeof arg;
119-
}
79+
const cleanedValue = { ...value };
12080

121-
if ('values' in arg) {
122-
return {
123-
...arg,
124-
text: arg.text.replace(EDITOR_MARKER, ''),
125-
values: arg.values
126-
.filter((value) => !isMarkerNode(value))
127-
.map((value) => mapToNonMarkerNode(value) as ESQLAstExpression) as typeof arg.values,
128-
} as typeof arg;
81+
for (const [key, child] of Object.entries(value)) {
82+
cleanedValue[key as keyof typeof cleanedValue] = removeAutocompleteMarkers(child);
12983
}
13084

131-
if ('text' in arg && typeof arg.text === 'string' && arg.text.includes(EDITOR_MARKER)) {
132-
return {
133-
...arg,
134-
text: arg.text.replace(EDITOR_MARKER, ''),
135-
} as typeof arg;
85+
if (
86+
'text' in cleanedValue &&
87+
typeof cleanedValue.text === 'string' &&
88+
cleanedValue.text.includes(EDITOR_MARKER)
89+
) {
90+
cleanedValue.text = cleanedValue.text.replace(EDITOR_MARKER, '');
13691
}
13792

138-
return arg;
139-
}
140-
141-
function cleanMarkerNode(node: ESQLSingleAstItem | undefined): ESQLSingleAstItem | undefined {
142-
return isMarkerNode(node) ? undefined : node;
93+
return cleanedValue;
14394
}
14495

14596
function findOption(nodes: ESQLAstItem[], offset: number): ESQLCommandOption | undefined {
@@ -174,7 +125,7 @@ export function findAstPosition(ast: ESQLAstQueryExpression, offset: number) {
174125

175126
Walker.walk(command, {
176127
visitSource: (_node, parent, walker) => {
177-
if (_node.location.max >= offset && _node.text !== EDITOR_MARKER) {
128+
if (_node.location.max >= offset) {
178129
node = _node as ESQLSingleAstItem;
179130
walker.abort();
180131
}
@@ -188,23 +139,17 @@ export function findAstPosition(ast: ESQLAstQueryExpression, offset: number) {
188139
containingFunction = _node;
189140
}
190141

191-
if (
192-
_node.location.max >= offset &&
193-
_node.text !== EDITOR_MARKER &&
194-
(!isList(_node) || _node.subtype !== 'tuple')
195-
) {
142+
if (_node.location.max >= offset && (!isList(_node) || _node.subtype !== 'tuple')) {
196143
node = _node as ESQLSingleAstItem;
197144
}
198145
},
199146
});
200147

201-
if (node) removeMarkerNode(node);
202-
203148
return {
204-
command: removeMarkerArgFromArgsList(command)!,
205-
containingFunction: removeMarkerArgFromArgsList(containingFunction),
206-
option: removeMarkerArgFromArgsList(findOption(command.args, offset)),
207-
node: removeMarkerArgFromArgsList(cleanMarkerNode(node)),
149+
command,
150+
containingFunction,
151+
option: findOption(command.args, offset),
152+
node,
208153
};
209154
}
210155

@@ -283,6 +228,18 @@ export function getBracketsToClose(text: string) {
283228
return stack.reverse().map((bracket) => pairs[bracket]);
284229
}
285230

231+
export function addAutocompleteMarker(query: string) {
232+
if (
233+
ENDS_WITH_BINARY_OPERATOR_REGEX.test(query) ||
234+
ENDS_WITH_CASTING_OPERATOR_REGEX.test(query) ||
235+
(endsWithComma(query) && endsWithWhitespace(query))
236+
) {
237+
return `${query} ${EDITOR_MARKER}`;
238+
}
239+
240+
return query;
241+
}
242+
286243
/**
287244
* This function attempts to correct the syntax of a partial query to make it valid.
288245
*
@@ -293,27 +250,9 @@ export function getBracketsToClose(text: string) {
293250
* @param context
294251
* @returns
295252
*/
296-
export function correctQuerySyntax(_query: string) {
297-
let query = _query;
253+
export function correctQuerySyntax(query: string) {
298254
// check if all brackets are closed, otherwise close them
299-
const bracketsToAppend = getBracketsToClose(query);
300-
301-
const endsWithBinaryOperatorRegex =
302-
/(?:\+|\/|==|>=|>|<=|<|:|%|\*|-|!=|=|\b(?:in|like|not in|not like|not rlike|rlike|and|or|not|as)\b)\s+$/i;
303-
const endsWithCastingOperatorRegex = /::\s*$/i;
304-
const endsWithCommaRegex = /,\s+$/;
305-
306-
if (
307-
endsWithBinaryOperatorRegex.test(query) ||
308-
endsWithCastingOperatorRegex.test(query) ||
309-
endsWithCommaRegex.test(query)
310-
) {
311-
query += ` ${EDITOR_MARKER}`;
312-
}
313-
314-
query += bracketsToAppend.join('');
315-
316-
return query;
255+
return query + getBracketsToClose(query).join('');
317256
}
318257

319258
const PROMQL_TRAILING_COLON_REGEX = /:\s*$/;
@@ -336,6 +275,19 @@ export function correctPromqlQuerySyntax(input: string): string {
336275
return query + getPromqlBracketsToClose(query);
337276
}
338277

278+
export function parsePromqlAutocompleteQuery(query: string): {
279+
correctedQuery: string;
280+
root: PromQLAstQueryExpression;
281+
} {
282+
const correctedQuery = correctPromqlQuerySyntax(query);
283+
const { root } = PromQLParser.parse(correctedQuery);
284+
285+
return {
286+
correctedQuery,
287+
root: removeAutocompleteMarkers(root),
288+
};
289+
}
290+
339291
function getPromqlBracketsToClose(text: string): string {
340292
const esqlBrackets = getBracketsToClose(text).join('');
341293
const promqlBraces = getPromqlBracesToClose(text);

src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/autocomplete/promql/suggestion_query_engine.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import { PromQLParser } from '@elastic/esql';
1110
import type { ICommandContext, ISuggestionItem } from '../../../../registry/types';
1211
import { getQueryPosition } from './query_position';
1312
import { getPreGroupedAggregationName } from '../../promql';
13+
import { parsePromqlAutocompleteQuery } from '../../ast';
1414
import {
1515
buildNextActionsSuggestion,
1616
buildVectorSuggestions,
@@ -31,9 +31,9 @@ export function suggestForPromqlQuery(input: SuggestForPromqlQueryInput): ISugge
3131
return buildVectorSuggestions(columns, [], shouldWrap);
3232
}
3333

34-
const { root } = PromQLParser.parse(queryText);
35-
const position = getQueryPosition(root, cursorRelative, queryText);
36-
const preGroupedAgg = getPreGroupedAggregationName(queryText.slice(0, cursorRelative));
34+
const { correctedQuery, root } = parsePromqlAutocompleteQuery(queryText);
35+
const position = getQueryPosition(root, cursorRelative, correctedQuery);
36+
const preGroupedAgg = getPreGroupedAggregationName(correctedQuery.slice(0, cursorRelative));
3737

3838
if (cursorRelative > root.location.max && position.type === 'inside_query') {
3939
return buildNextActionsSuggestion({

src/platform/packages/shared/kbn-esql-language/src/commands/definitions/utils/expressions.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import type { ESQLColumnData } from '../../registry/types';
2626
import { UnmappedFieldsStrategy } from '../../registry/types';
2727
import { inOperators } from '../all_operators';
2828
import { TIME_SYSTEM_PARAMS } from './literals';
29-
import { isMarkerNode } from './ast';
3029
import { getUnmappedFieldType } from './settings';
3130
import { getPromqlFunctionDefinition } from './promql';
3231

@@ -292,15 +291,15 @@ export function getBinaryExpressionOperand(
292291
}
293292

294293
/**
295-
* Extracts a valid expression root from an assignment, handling arrays and marker nodes.
294+
* Extracts a valid expression root from an assignment, handling array operands.
296295
*/
297296
export function getAssignmentExpressionRoot(
298297
assignment: ESQLFunction
299298
): ESQLSingleAstItem | undefined {
300299
const rhs = getBinaryExpressionOperand(assignment, 'right');
301300
const root = Array.isArray(rhs) ? rhs[0] : rhs;
302301

303-
if (!root || isMarkerNode(root)) {
302+
if (!root) {
304303
return undefined;
305304
}
306305

0 commit comments

Comments
 (0)