[ES|QL] Separate EVAL autocomplete routine (#212996)

## Summary

Part of https://github.com/elastic/kibana/issues/195418

Gives `EVAL` autocomplete logic its own home 🏡


### Expression suggestions function

This PR also introduces a semi-generic function for generating
suggestions within an expression. This is so that the logic can be
shared between `EVAL` and `WHERE`. It also gets us closer to supporting
filtering in `STATS` (https://github.com/elastic/kibana/issues/195363).

To make this happen, I took stock of where we have differences in our
expression suggestions between `WHERE` and `EVAL`. In some cases, the
differences seemed important. In other cases, I felt ok removing them.

#### EVAL

| Behavior | Plan |

|--------------------------------------------------------------------------|------|
| Suggests pipe and comma after complete column names (`column/` or
`column /`)| get rid of it because an expression consisting of just a
single column name is essentially useless in `EVAL` |
| Doesn't suggest fields after an assignment | get rid of it. why act
any different than an expression not assigned an alias? |
| Suggests assignment operator after new column name (`newColumn /`) |
keep it |
| Suggests assignment snippet for empty expression | keep it |
| Suggests time literal completions after literal number in assignment
(`newColumn = 1 /`) | remove it. it doesn't feel that useful and
removing it makes it easier to have a generic expression suggestions
function. It will still be around in functions and operators (e.g. `1
day + 2 /`). |
| Supports multiple expressions | keep it |

#### WHERE

| Behavior | Plan |

|--------------------------------------------------------------------------|------|
| Suggests pipe after complete boolean expression (`foo AND bar /`) |
keep it, but outside of the expression suggestion function |
| Suggests boolean operators to make a boolean expression (`timestamp >
"2002" AND doubleField /`) | keep it... maybe we're being too smart but
we can always remove it later |

### Other changes
- the suggestions for `CASE(foo != /)` used to differ based on the
trigger kind. This seemed inadvertent so I removed the difference.
- we now add spaces after fields that are inserted in expressions. E.g.
`WHERE foo + <insert field><space>`. I'm not sure if this is best or
not...


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### Identify risks

- [ ] As with any refactor, there's a possibility this will introduce a
regression in the behavior of commands. However, all automated tests are
passing and I have tested the behavior manually and can detect no
regression.

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Drew Tate 2025-03-18 06:24:50 -06:00 committed by GitHub
parent e84f6de3f6
commit 74c31fbc86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 751 additions and 1101 deletions

View file

@ -15,6 +15,8 @@ import {
getLiteralsByType,
PartialSuggestionWithText,
getDateLiteralsByFieldType,
AssertSuggestionsFn,
fields,
} from './helpers';
import { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types';
import { scalarFunctionDefinitions } from '../../definitions/generated/scalar_functions';
@ -45,37 +47,124 @@ const getTypesFromParamDefs = (paramDefs: FunctionParameter[]): SupportedDataTyp
describe('autocomplete.suggest', () => {
describe('eval', () => {
test('suggestions', async () => {
const { assertSuggestions } = await setup();
let assertSuggestions: AssertSuggestionsFn;
beforeEach(async () => {
const setupResult = await setup();
assertSuggestions = setupResult.assertSuggestions;
});
test('empty expression', async () => {
await assertSuggestions('from a | eval /', [
'var0 = ',
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
await assertSuggestions('from a | eval doubleField/', [
'doubleField, ',
'doubleField | ',
'var0 = ',
await assertSuggestions('from a | eval col0 = /', [
...getFieldNamesByType('any').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
await assertSuggestions('from a | eval col0 = 1, /', [
'var0 = ',
...getFieldNamesByType('any').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
await assertSuggestions('from a | eval col0 = 1, col1 = /', [
...getFieldNamesByType('any').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
// Re-enable with https://github.com/elastic/kibana/issues/210639
// await assertSuggestions('from a | eval a=doubleField, /', [
// 'var0 = ',
// ...getFieldNamesByType('any').map((v) => `${v} `),
// 'a',
// ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
// ]);
await assertSuggestions(
'from a | stats avg(doubleField) by keywordField | eval /',
[
'var0 = ',
'`avg(doubleField)` ',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{
triggerCharacter: ' ',
// make aware EVAL of the previous STATS command
callbacks: createCustomCallbackMocks(
[{ name: 'avg(doubleField)', type: 'double' }],
undefined,
undefined
),
}
);
await assertSuggestions(
'from a | eval abs(doubleField) + 1 | eval /',
[
'var0 = ',
...getFieldNamesByType('any').map((v) => `${v} `),
'`abs(doubleField) + 1` ',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{
triggerCharacter: ' ',
callbacks: createCustomCallbackMocks(
[...fields, { name: 'abs(doubleField) + 1', type: 'double' }],
undefined,
undefined
),
}
);
await assertSuggestions(
'from a | stats avg(doubleField) by keywordField | eval /',
[
'var0 = ',
'`avg(doubleField)` ',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{
triggerCharacter: ' ',
callbacks: createCustomCallbackMocks(
[{ name: 'avg(doubleField)', type: 'double' }],
undefined,
undefined
),
}
);
});
test('after column', async () => {
await assertSuggestions('from a | eval doubleField /', [
...getFunctionSignaturesByReturnType('eval', 'any', { operators: true, skipAssign: true }, [
'double',
]),
',',
'| ',
]);
});
test('after NOT', async () => {
await assertSuggestions('from index | EVAL keywordField not /', [
'LIKE $0',
'RLIKE $0',
'IN $0',
]);
await assertSuggestions('from index | EVAL keywordField NOT /', [
'LIKE $0',
'RLIKE $0',
'IN $0',
]);
await assertSuggestions('from index | EVAL not /', [
...getFieldNamesByType('boolean').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }),
]);
});
test('with lists', async () => {
await assertSuggestions('from index | EVAL doubleField in /', ['( $0 )']);
await assertSuggestions(
'from index | EVAL doubleField in (/)',
@ -86,26 +175,28 @@ describe('autocomplete.suggest', () => {
{ triggerCharacter: '(' }
);
await assertSuggestions('from index | EVAL doubleField not in /', ['( $0 )']);
await assertSuggestions('from index | EVAL not /', [
...getFieldNamesByType('boolean'),
...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }),
]);
});
test('after assignment', async () => {
await assertSuggestions(
'from a | eval a=/',
[...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })],
[
...getFieldNamesByType('any').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{ triggerCharacter: '=' }
);
await assertSuggestions(
'from a | eval a=abs(doubleField), b= /',
[...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })],
[
...getFieldNamesByType('any').map((v) => `${v} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{ triggerCharacter: '=' }
);
await assertSuggestions('from a | eval a=doubleField, /', [
'var0 = ',
...getFieldNamesByType('any'),
'a',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
});
test('in and around functions', async () => {
await assertSuggestions(
'from a | eval a=round(/)',
[
@ -144,7 +235,7 @@ describe('autocomplete.suggest', () => {
{ triggerCharacter: '(' }
);
await assertSuggestions('from a | eval a=round(doubleField) /', [
',',
', ',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { operators: true, skipAssign: true }, [
'double',
@ -184,8 +275,9 @@ describe('autocomplete.suggest', () => {
);
await assertSuggestions('from a | eval a=round(doubleField),/', [
'var0 = ',
...getFieldNamesByType('any'),
'a',
...getFieldNamesByType('any').map((v) => `${v} `),
// Re-enable with https://github.com/elastic/kibana/issues/210639
// 'a',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
await assertSuggestions('from a | eval a=round(doubleField) + /', [
@ -212,64 +304,6 @@ describe('autocomplete.suggest', () => {
scalar: true,
}),
]);
await assertSuggestions(
'from a | stats avg(doubleField) by keywordField | eval /',
[
'var0 = ',
'`avg(doubleField)`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{
triggerCharacter: ' ',
// make aware EVAL of the previous STATS command
callbacks: createCustomCallbackMocks([], undefined, undefined),
}
);
await assertSuggestions(
'from a | eval abs(doubleField) + 1 | eval /',
[
'var0 = ',
...getFieldNamesByType('any'),
'`abs(doubleField) + 1`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{ triggerCharacter: ' ' }
);
await assertSuggestions(
'from a | stats avg(doubleField) by keywordField | eval /',
[
'var0 = ',
'`avg(doubleField)`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{
triggerCharacter: ' ',
callbacks: createCustomCallbackMocks(
[{ name: 'avg_doubleField_', type: 'double' }],
undefined,
undefined
),
}
// make aware EVAL of the previous STATS command with the buggy field name from expression
);
await assertSuggestions(
'from a | stats avg(doubleField), avg(kubernetes.something.something) by keywordField | eval /',
[
'var0 = ',
'`avg(doubleField)`',
'`avg(kubernetes.something.something)`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
{
triggerCharacter: ' ',
// make aware EVAL of the previous STATS command with the buggy field name from expression
callbacks: createCustomCallbackMocks(
[{ name: 'avg_doubleField_', type: 'double' }],
undefined,
undefined
),
}
);
await assertSuggestions(
'from a | eval a=round(doubleField), b=round(/)',
@ -335,11 +369,9 @@ describe('autocomplete.suggest', () => {
],
{ triggerCharacter: ' ' }
);
// test deep function nesting suggestions (and check that the same function is not suggested)
// round(round(
// round(round(round(
// etc...
});
test('deep function nesting', async () => {
for (const nesting of [1, 2, 3, 4]) {
await assertSuggestions(
`from a | eval a=${Array(nesting).fill('round(/').join('')}`,
@ -356,16 +388,18 @@ describe('autocomplete.suggest', () => {
{ triggerCharacter: '(' }
);
}
});
test('discards query after cursor', async () => {
const absParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const;
// Smoke testing for suggestions in previous position than the end of the statement
await assertSuggestions('from a | eval var0 = abs(doubleField) / | eval abs(var0)', [
',',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { operators: true, skipAssign: true }, [
'double',
]),
', ',
'| ',
]);
await assertSuggestions('from a | eval var0 = abs(b/) | eval abs(var0)', [
...getFieldNamesByType(absParameterTypes),
@ -398,9 +432,6 @@ describe('autocomplete.suggest', () => {
) {
test(`${fn.name}`, async () => {
const testedCases = new Set<string>();
const { assertSuggestions } = await setup();
for (const signature of fn.signatures) {
// @ts-expect-error Partial type
const enrichedArgs: Array<
@ -517,7 +548,6 @@ describe('autocomplete.suggest', () => {
// but currently, our autocomplete only suggests the literal suggestions
if (['date_extract', 'date_diff'].includes(fn.name)) {
test(`${fn.name}`, async () => {
const { assertSuggestions } = await setup();
const firstParam = fn.signatures[0].params[0];
const suggestedConstants = firstParam?.literalSuggestions || firstParam?.acceptedValues;
const requiresMoreArgs = true;
@ -538,7 +568,6 @@ describe('autocomplete.suggest', () => {
});
test('date math', async () => {
const { assertSuggestions } = await setup();
const dateSuggestions = timeUnitsToSuggest.map(({ name }) => name);
// Eval bucket is not a valid expression
@ -546,12 +575,10 @@ describe('autocomplete.suggest', () => {
triggerCharacter: ' ',
});
// If a literal number is detected then suggest also date period keywords
await assertSuggestions(
'from a | eval a = 1 /',
[
...dateSuggestions,
',',
', ',
'| ',
...getFunctionSignaturesByReturnType(
'eval',
@ -562,7 +589,14 @@ describe('autocomplete.suggest', () => {
],
{ triggerCharacter: ' ' }
);
await assertSuggestions('from a | eval a = 1 year /', [',', '| ', 'IS NOT NULL', 'IS NULL']);
await assertSuggestions('from a | eval a = 1 year /', [
', ',
'| ',
'+ $0',
'- $0',
'IS NOT NULL',
'IS NULL',
]);
await assertSuggestions(
'from a | eval var0=date_trunc(/)',
[
@ -579,108 +613,5 @@ describe('autocomplete.suggest', () => {
{ triggerCharacter: ' ' }
);
});
test('case', async () => {
const { assertSuggestions } = await setup();
const comparisonOperators = ['==', '!=', '>', '<', '>=', '<=']
.map((op) => `${op}`)
.concat(',');
// case( / ) suggest any field/eval function in this position as first argument
const allSuggestions = [
// With extra space after field name to open suggestions
...getFieldNamesByType('any').map((field) => `${field} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
];
await assertSuggestions('from a | eval case(/)', allSuggestions, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case(/)', allSuggestions);
// case( field /) suggest comparison operators at this point to converge to a boolean
await assertSuggestions('from a | eval case( textField /)', comparisonOperators, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case( doubleField /)', comparisonOperators, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case( booleanField /)', comparisonOperators, {
triggerCharacter: ' ',
});
// case( field > /) suggest field/function of the same type of the right hand side to complete the boolean expression
await assertSuggestions(
'from a | eval case( keywordField != /)',
[
// Notice no extra space after field name
...getFieldNamesByType(['keyword', 'text', 'boolean']).map((field) => `${field}`),
...getFunctionSignaturesByReturnType(
'eval',
['keyword', 'text', 'boolean'],
{ scalar: true },
undefined,
[]
),
],
{
triggerCharacter: ' ',
}
);
const expectedNumericSuggestions = [
// Notice no extra space after field name
...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES).map((field) => `${field}`),
...getFunctionSignaturesByReturnType(
'eval',
ESQL_COMMON_NUMERIC_TYPES,
{ scalar: true },
undefined,
[]
),
];
await assertSuggestions(
'from a | eval case( integerField != /)',
expectedNumericSuggestions,
{
triggerCharacter: ' ',
}
);
await assertSuggestions('from a | eval case( integerField != /)', [
// Notice no extra space after field name
...getFieldNamesByType('any').map((field) => `${field}`),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, []),
]);
// case( field > 0, >) suggests fields like normal
await assertSuggestions(
'from a | eval case( integerField != doubleField, /)',
[
// With extra space after field name to open suggestions
...getFieldNamesByType('any').map((field) => `${field}`),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, [
'case',
]),
],
{
triggerCharacter: ' ',
}
);
// case( multiple conditions ) suggests fields like normal
await assertSuggestions(
'from a | eval case(integerField < 0, "negative", integerField > 0, "positive", /)',
[
// With extra space after field name to open suggestions
...getFieldNamesByType('any').map((field) => `${field} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, [
'case',
]),
],
{
triggerCharacter: ' ',
}
);
});
});
});

View file

@ -257,7 +257,6 @@ describe('WHERE <expression>', () => {
'IS NULL',
'NOT',
'OR $0',
'| ',
]);
await assertSuggestions('from index | WHERE keywordField IS NOT /', [
@ -269,7 +268,6 @@ describe('WHERE <expression>', () => {
'IS NULL',
'NOT',
'OR $0',
'| ',
]);
});

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types';
import {
AssertSuggestionsFn,
getFieldNamesByType,
getFunctionSignaturesByReturnType,
setup,
} from './helpers';
describe('case', () => {
let assertSuggestions: AssertSuggestionsFn;
beforeEach(async () => {
const setupResult = await setup();
assertSuggestions = setupResult.assertSuggestions;
});
const comparisonOperators = ['==', '!=', '>', '<', '>=', '<='].map((op) => `${op}`).concat(',');
// case( / ) suggest any field/eval function in this position as first argument
const allSuggestions = [
// With extra space after field name to open suggestions
...getFieldNamesByType('any').map((field) => `${field} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
];
test('first position', async () => {
await assertSuggestions('from a | eval case(/)', allSuggestions, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case(/)', allSuggestions);
});
test('suggests comparison operators after initial column', async () => {
// case( field /) suggest comparison operators at this point to converge to a boolean
await assertSuggestions('from a | eval case( textField /)', comparisonOperators, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case( doubleField /)', comparisonOperators, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case( booleanField /)', comparisonOperators, {
triggerCharacter: ' ',
});
});
test('after comparison operator', async () => {
// case( field > /) suggest field/function of the same type of the right hand side to complete the boolean expression
await assertSuggestions(
'from a | eval case( keywordField != /)',
[
// Notice no extra space after field name
...getFieldNamesByType(['keyword', 'text', 'boolean']).map((field) => `${field}`),
...getFunctionSignaturesByReturnType(
'eval',
['keyword', 'text', 'boolean'],
{ scalar: true },
undefined,
[]
),
],
{
triggerCharacter: ' ',
}
);
const expectedNumericSuggestions = [
// Notice no extra space after field name
...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES).map((field) => `${field}`),
...getFunctionSignaturesByReturnType(
'eval',
ESQL_COMMON_NUMERIC_TYPES,
{ scalar: true },
undefined,
[]
),
];
await assertSuggestions('from a | eval case( integerField != /)', expectedNumericSuggestions, {
triggerCharacter: ' ',
});
await assertSuggestions('from a | eval case( integerField != /)', expectedNumericSuggestions);
});
test('suggestions for second position', async () => {
// case( field > 0, >) suggests fields like normal
await assertSuggestions(
'from a | eval case( integerField != doubleField, /)',
[
// With extra space after field name to open suggestions
...getFieldNamesByType('any').map((field) => `${field}`),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
],
{
triggerCharacter: ' ',
}
);
// case( multiple conditions ) suggests fields like normal
await assertSuggestions(
'from a | eval case(integerField < 0, "negative", integerField > 0, "positive", /)',
[
// With extra space after field name to open suggestions
...getFieldNamesByType('any').map((field) => `${field} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
],
{
triggerCharacter: ' ',
}
);
});
});

View file

@ -321,6 +321,12 @@ export interface SuggestOptions {
callbacks?: ESQLCallbacks;
}
export type AssertSuggestionsFn = (
query: string,
expected: Array<string | PartialSuggestionWithText>,
opts?: SuggestOptions
) => Promise<void>;
export const setup = async (caret = '/') => {
if (caret.length !== 1) {
throw new Error('Caret must be a single character');

View file

@ -327,11 +327,12 @@ describe('autocomplete', () => {
// EVAL argument
testSuggestions('FROM index1 | EVAL b/', [
'var0 = ',
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((name) => `${name} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
testSuggestions('FROM index1 | EVAL var0 = f/', [
...getFieldNamesByType('any').map((name) => `${name} `),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
@ -1008,10 +1009,6 @@ describe('autocomplete', () => {
'AND $0',
'NOT',
'OR $0',
// pipe doesn't make sense here, but Monaco will filter it out.
// see https://github.com/elastic/kibana/issues/199401 for an explanation
// of why this happens
'| ',
]);
testSuggestions('FROM a | WHERE doubleField IS N/', [
{ text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 32 } },
@ -1022,10 +1019,6 @@ describe('autocomplete', () => {
'AND $0',
'NOT',
'OR $0',
// pipe doesn't make sense here, but Monaco will filter it out.
// see https://github.com/elastic/kibana/issues/199401 for an explanation
// of why this happens
'| ',
]);
testSuggestions('FROM a | EVAL doubleField IS NOT N/', [
{ text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 35 } },

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { uniq, uniqBy } from 'lodash';
import { uniq } from 'lodash';
import {
type AstProviderFn,
type ESQLAstItem,
@ -17,56 +17,41 @@ import {
type ESQLSingleAstItem,
} from '@kbn/esql-ast';
import type { ESQLControlVariable } from '@kbn/esql-types';
import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types';
import { isNumericType } from '../shared/esql_types';
import type { EditorContext, ItemKind, SuggestionRawDefinition, GetColumnsByTypeFn } from './types';
import {
getColumnForASTNode,
getCommandDefinition,
getFunctionDefinition,
isAssignment,
isAssignmentComplete,
isColumnItem,
isFunctionItem,
isIncompleteItem,
isLiteralItem,
isOptionItem,
isRestartingExpression,
isSourceCommand,
isTimeIntervalItem,
getAllFunctions,
isSingleItem,
getColumnExists,
findPreviousWord,
correctQuerySyntax,
getColumnByName,
getAllCommands,
getExpressionType,
} from '../shared/helpers';
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
import type { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import type { ESQLRealField, ESQLVariable } from '../validation/types';
import {
allStarConstant,
commaCompleteItem,
getAssignmentDefinitionCompletitionItem,
getCommandAutocompleteDefinitions,
pipeCompleteItem,
} from './complete_items';
import {
buildPoliciesDefinitions,
getNewVariableSuggestion,
getFunctionSuggestions,
getCompatibleLiterals,
buildConstantsDefinitions,
buildOptionDefinition,
buildValueDefinitions,
getDateLiterals,
buildFieldsDefinitionsWithMetadata,
TRIGGER_SUGGESTION_COMMAND,
getOperatorSuggestions,
getSuggestionsAfterNot,
} from './factories';
import { EDITOR_MARKER, FULL_TEXT_SEARCH_FUNCTIONS } from '../shared/constants';
import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context';
import { getAstContext } from '../shared/context';
import {
buildQueryUntilPreviousCommand,
getFieldsByTypeHelper,
@ -80,16 +65,13 @@ import {
getSourcesFromCommands,
isAggFunctionUsedAlready,
getValidSignaturesAndTypesToSuggestNext,
handleFragment,
getFieldsOrFunctionsSuggestions,
pushItUpInTheList,
extractTypeFromASTArg,
getSuggestionsToRightOfOperatorExpression,
checkFunctionInvocationComplete,
} from './helper';
import {
FunctionParameter,
isParameterType,
FunctionDefinitionTypes,
GetPolicyMetadataFn,
} from '../definitions/types';
@ -99,14 +81,6 @@ import { getRecommendedQueriesSuggestions } from './recommended_queries/suggesti
type GetFieldsMapFn = () => Promise<Map<string, ESQLRealField>>;
type GetPoliciesFn = () => Promise<SuggestionRawDefinition[]>;
function getFinalSuggestions({ comma }: { comma?: boolean } = { comma: true }) {
const finalSuggestions = [pipeCompleteItem];
if (comma) {
finalSuggestions.push(commaCompleteItem);
}
return finalSuggestions;
}
export async function suggest(
fullText: string,
offset: number,
@ -255,77 +229,14 @@ function findNewVariable(variables: Map<string, ESQLVariable[]>) {
return name;
}
function workoutBuiltinOptions(
nodeArg: ESQLAstItem,
references: Pick<ReferenceMaps, 'fields' | 'variables'>
): { ignored?: string[] } {
// skip assign operator if it's a function or an existing field to avoid promoting shadowing
return {
ignored: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references))
? ['=']
: undefined,
};
}
function areCurrentArgsValid(
command: ESQLCommand,
node: ESQLAstItem,
references: Pick<ReferenceMaps, 'fields' | 'variables'>
) {
// unfortunately here we need to bake some command-specific logic
if (command.name === 'eval') {
if (node) {
if (isFunctionItem(node)) {
if (isAssignment(node)) {
return isAssignmentComplete(node);
} else {
return checkFunctionInvocationComplete(node, (expression) =>
getExpressionType(expression, references.fields, references.variables)
).complete;
}
}
}
}
if (command.name === 'rename') {
if (node) {
if (isColumnItem(node)) {
return true;
}
}
}
return true;
}
function extractArgMeta(
commandOrOption: ESQLCommand | ESQLCommandOption,
node: ESQLSingleAstItem | undefined
) {
let argIndex = commandOrOption.args.length;
const prevIndex = Math.max(argIndex - 1, 0);
const lastArg = removeMarkerArgFromArgsList(commandOrOption)!.args[prevIndex];
if (isIncompleteItem(lastArg)) {
argIndex = prevIndex;
}
// if a node is not specified use the lastArg
// mind to give priority to node as lastArg might be a function root
// => "a > b and c == d" gets translated into and( gt(a, b) , eq(c, d) ) => hence "and" is lastArg
const nodeArg = node || lastArg;
return { argIndex, prevIndex, lastArg, nodeArg };
}
async function getSuggestionsWithinCommandExpression(
innerText: string,
commands: ESQLCommand[],
{
command,
option,
node,
}: {
astContext: {
command: ESQLCommand;
option: ESQLCommandOption | undefined;
node: ESQLSingleAstItem | undefined;
node?: ESQLAstItem;
option?: ESQLCommandOption;
containingFunction?: ESQLFunction;
},
getSources: () => Promise<ESQLSourceResult[]>,
getColumnsByType: GetColumnsByTypeFn,
@ -337,533 +248,67 @@ async function getSuggestionsWithinCommandExpression(
callbacks?: ESQLCallbacks,
supportsControls?: boolean
) {
const commandDef = getCommandDefinition(command.name);
const commandDef = getCommandDefinition(astContext.command.name);
// collect all fields + variables to suggest
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
const anyVariables = collectVariables(commands, fieldsMap, innerText);
const references = { fields: fieldsMap, variables: anyVariables };
if (commandDef.suggest) {
// The new path.
return commandDef.suggest({
innerText,
command,
getColumnsByType,
getAllColumnNames: () => Array.from(fieldsMap.keys()),
columnExists: (col: string) => Boolean(getColumnByName(col, references)),
getSuggestedVariableName: (extraFieldNames?: string[]) => {
if (!extraFieldNames?.length) {
return findNewVariable(anyVariables);
}
const augmentedFieldsMap = new Map(fieldsMap);
extraFieldNames.forEach((name) => {
augmentedFieldsMap.set(name, { name, type: 'double' });
});
return findNewVariable(collectVariables(commands, augmentedFieldsMap, innerText));
},
getExpressionType: (expression: ESQLAstItem | undefined) =>
getExpressionType(expression, references.fields, references.variables),
getPreferences,
definition: commandDef,
getSources,
getRecommendedQueriesSuggestions: (prefix) =>
getRecommendedQueriesSuggestions(getColumnsByType, prefix),
getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type),
previousCommands: commands,
callbacks,
getVariables,
supportsControls,
getPolicies,
getPolicyMetadata,
});
} else {
// The deprecated path.
return getExpressionSuggestionsByType(
innerText,
commands,
{ command, option, node },
getSources,
getColumnsByType,
getFieldsMap,
getPolicies
);
}
}
/**
* @deprecated this generic logic will be replaced with the command-specific suggest functions
* from each command definition.
*/
async function getExpressionSuggestionsByType(
innerText: string,
commands: ESQLCommand[],
{
command,
option,
node,
}: {
command: ESQLCommand;
option: ESQLCommandOption | undefined;
node: ESQLSingleAstItem | undefined;
},
getSources: () => Promise<ESQLSourceResult[]>,
getFieldsByType: GetColumnsByTypeFn,
getFieldsMap: GetFieldsMapFn,
getPolicies: GetPoliciesFn
) {
const commandDef = getCommandDefinition(command.name);
const { argIndex, prevIndex, lastArg, nodeArg } = extractArgMeta(command, node);
// collect all fields + variables to suggest
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
const anyVariables = collectVariables(commands, fieldsMap, innerText);
const references = { fields: fieldsMap, variables: anyVariables };
if (!commandDef.signature || !commandDef.options) {
return [];
}
// A new expression is considered either
// * just after a command name => i.e. ... | STATS <here>
// * or after a comma => i.e. STATS fieldA, <here>
const isNewExpression =
isRestartingExpression(innerText) ||
(argIndex === 0 && (!isFunctionItem(nodeArg) || !nodeArg?.args.length));
// the not function is a special operator that can be used in different ways,
// and not all these are mapped within the AST data structure: in particular
// <COMMAND> <field> NOT <here>
// is an incomplete statement and it results in a missing AST node, so we need to detect
// from the query string itself
const endsWithNot =
/ not$/i.test(innerText.trimEnd()) &&
!command.args.some((arg) => isFunctionItem(arg) && arg.name === 'not');
// early exit in case of a missing function
if (isFunctionItem(lastArg) && !getFunctionDefinition(lastArg.name)) {
return [];
}
// Are options already declared? This is useful to suggest only new ones
const optionsAlreadyDeclared = (
command.args.filter((arg) => isOptionItem(arg)) as ESQLCommandOption[]
).map(({ name }) => ({
name,
index: commandDef.options!.findIndex(({ name: defName }) => defName === name),
}));
const optionsAvailable = commandDef.options.filter(({ name }, index) => {
const optArg = optionsAlreadyDeclared.find(({ name: optionName }) => optionName === name);
return (!optArg && !optionsAlreadyDeclared.length) || (optArg && index > optArg.index);
});
// get the next definition for the given command
let argDef = commandDef.signature.params[argIndex];
// tune it for the variadic case
if (!argDef) {
// this is the case of a comma argument
if (commandDef.signature.multipleParams) {
if (isNewExpression || (isAssignment(lastArg) && !isAssignmentComplete(lastArg))) {
// i.e. ... | <COMMAND> a, <here>
// i.e. ... | <COMMAND> a = ..., b = <here>
argDef = commandDef.signature.params[0];
}
}
// this is the case where there's an argument, but it's of the wrong type
// i.e. ... | WHERE numberField <here> (WHERE wants a boolean expression!)
// i.e. ... | STATS numberfield <here> (STATS wants a function expression!)
if (!isNewExpression && nodeArg && !Array.isArray(nodeArg)) {
const prevArg = commandDef.signature.params[prevIndex];
// in some cases we do not want to go back as the command only accepts a literal
// i.e. LIMIT 5 <suggest> -> that's it, so no argDef should be assigned
// make an exception for STATS (STATS is the only command who accept a function type as arg)
if (
prevArg &&
(prevArg.type === 'function' || (!Array.isArray(nodeArg) && prevArg.type !== nodeArg.type))
) {
if (!isLiteralItem(nodeArg) || !prevArg.constantOnly) {
argDef = prevArg;
}
}
}
}
const previousWord = findPreviousWord(innerText);
// enrich with assignment has some special rules who are handled somewhere else
const canHaveAssignments =
['eval', 'stats', 'row'].includes(command.name) &&
!comparisonFunctions.map((fn) => fn.name).includes(previousWord);
const suggestions: SuggestionRawDefinition[] = [];
// When user types and accepts autocomplete suggestion, and cursor is placed at the end of a valid field
// we should not show irrelevant functions that might have words matching
const columnWithActiveCursor = commands.find(
(c) =>
c.name === command.name &&
command.name === 'eval' &&
c.args.some((arg) => isColumnItem(arg) && arg.name.includes(EDITOR_MARKER))
);
const shouldShowFunctions = !columnWithActiveCursor;
// in this flow there's a clear plan here from argument definitions so try to follow it
if (argDef) {
if (argDef.type === 'column' || argDef.type === 'any' || argDef.type === 'function') {
if (isNewExpression && canHaveAssignments) {
if (endsWithNot) {
// i.e.
// ... | ROW field NOT <suggest>
// ... | EVAL field NOT <suggest>
// there's not way to know the type of the field here, so suggest anything
suggestions.push(...getSuggestionsAfterNot());
} else {
// i.e.
// ... | ROW <suggest>
// ... | STATS <suggest>
// ... | STATS ..., <suggest>
// ... | EVAL <suggest>
// ... | EVAL ..., <suggest>
suggestions.push(getNewVariableSuggestion(findNewVariable(anyVariables)));
}
}
}
// Suggest fields or variables
if (argDef.type === 'column' || argDef.type === 'any') {
if ((!nodeArg || isNewExpression) && !endsWithNot) {
const fieldSuggestions = await getFieldsOrFunctionsSuggestions(
argDef.innerTypes || ['any'],
command.name,
option?.name,
getFieldsByType,
{
// TODO instead of relying on canHaveAssignments and other command name checks
// we should have a more generic way to determine if a command can have functions.
// I think it comes down to the definition of 'column' since 'any' should always
// include functions.
functions: canHaveAssignments || command.name === 'sort',
fields: !argDef.constantOnly,
variables: anyVariables,
literals: argDef.constantOnly,
},
{
ignoreColumns: isNewExpression
? command.args.filter(isColumnItem).map(({ name }) => name)
: [],
}
);
const fieldFragmentSuggestions = await handleFragment(
innerText,
(fragment) => Boolean(getColumnByName(fragment, references)),
(_fragment: string, rangeToReplace?: { start: number; end: number }) => {
// COMMAND fie<suggest>
return fieldSuggestions.map((suggestion) => {
// if there is already a command, we don't want to override it
if (suggestion.command) return suggestion;
return {
...suggestion,
text: suggestion.text + (['grok', 'dissect'].includes(command.name) ? ' ' : ''),
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace,
};
});
},
(fragment: string, rangeToReplace: { start: number; end: number }) => {
// COMMAND field<suggest>
if (['grok', 'dissect'].includes(command.name)) {
return fieldSuggestions.map((suggestion) => {
// if there is already a command, we don't want to override it
if (suggestion.command) return suggestion;
return {
...suggestion,
text: suggestion.text + ' ',
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace,
};
});
}
const finalSuggestions = [{ ...pipeCompleteItem, text: ' | ' }];
if (fieldSuggestions.length > 1)
// when we fix the editor marker, this should probably be checked against 0 instead of 1
// this is because the last field in the AST is currently getting removed (because it contains
// the editor marker) so it is not included in the ignored list which is used to filter out
// existing fields above.
finalSuggestions.push({ ...commaCompleteItem, text: ', ' });
return finalSuggestions.map<SuggestionRawDefinition>((s) => ({
...s,
filterText: fragment,
text: fragment + s.text,
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace,
}));
}
);
suggestions.push(...fieldFragmentSuggestions);
}
}
if (argDef.type === 'function' || argDef.type === 'any') {
if (isColumnItem(nodeArg)) {
// ... | STATS a <suggest>
// ... | EVAL a <suggest>
const nodeArgType = extractTypeFromASTArg(nodeArg, references);
if (isParameterType(nodeArgType)) {
suggestions.push(
...getOperatorSuggestions({
command: command.name,
leftParamType: nodeArgType,
ignored: workoutBuiltinOptions(nodeArg, references).ignored,
})
);
} else {
suggestions.push(getAssignmentDefinitionCompletitionItem());
}
}
if (
(isNewExpression && !endsWithNot) ||
(isAssignment(nodeArg) && !isAssignmentComplete(nodeArg))
) {
// ... | STATS a = <suggest>
// ... | EVAL a = <suggest>
// ... | STATS a = ..., <suggest>
// ... | EVAL a = ..., <suggest>
// ... | STATS a = ..., b = <suggest>
// ... | EVAL a = ..., b = <suggest>
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['any'],
command.name,
option?.name,
getFieldsByType,
{
functions: shouldShowFunctions,
fields: false,
variables: nodeArg ? undefined : anyVariables,
literals: argDef.constantOnly,
}
))
);
if (['show', 'meta'].includes(command.name)) {
suggestions.push(...getOperatorSuggestions({ command: command.name }));
}
}
}
if (argDef.type === 'any') {
// ... | EVAL var = field <suggest>
// ... | EVAL var = fn(field) <suggest>
// make sure we're still in the same assignment context and there's no comma (newExpression ensures that)
if (!isNewExpression) {
if (isAssignment(nodeArg) && isAssignmentComplete(nodeArg)) {
const [rightArg] = nodeArg.args[1] as [ESQLSingleAstItem];
const nodeArgType = extractTypeFromASTArg(rightArg, references);
suggestions.push(
...getOperatorSuggestions({
command: command.name,
leftParamType: isParameterType(nodeArgType) ? nodeArgType : 'any',
ignored: workoutBuiltinOptions(nodeArg, references).ignored,
})
);
if (isNumericType(nodeArgType) && isLiteralItem(rightArg)) {
// ... EVAL var = 1 <suggest>
suggestions.push(...getCompatibleLiterals(['time_literal_unit']));
}
if (isFunctionItem(rightArg)) {
if (rightArg.args.some(isTimeIntervalItem)) {
const lastFnArg = rightArg.args[rightArg.args.length - 1];
const lastFnArgType = extractTypeFromASTArg(lastFnArg, references);
if (isNumericType(lastFnArgType) && isLiteralItem(lastFnArg))
// ... EVAL var = 1 year + 2 <suggest>
suggestions.push(...getCompatibleLiterals(['time_literal_unit']));
}
}
} else {
if (isFunctionItem(nodeArg)) {
if (nodeArg.name === 'not') {
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['boolean'],
command.name,
option?.name,
getFieldsByType,
{
functions: true,
fields: true,
variables: anyVariables,
}
))
);
} else {
suggestions.push(
...(await getSuggestionsToRightOfOperatorExpression({
queryText: innerText,
commandName: command.name,
optionName: option?.name,
rootOperator: nodeArg,
getExpressionType: (expression) =>
getExpressionType(expression, references.fields, references.variables),
getColumnsByType: getFieldsByType,
}))
);
if (nodeArg.args.some(isTimeIntervalItem)) {
const lastFnArg = nodeArg.args[nodeArg.args.length - 1];
const lastFnArgType = extractTypeFromASTArg(lastFnArg, references);
if (isNumericType(lastFnArgType) && isLiteralItem(lastFnArg))
// ... EVAL var = 1 year + 2 <suggest>
suggestions.push(...getCompatibleLiterals(['time_literal_unit']));
}
}
}
}
}
}
// if the definition includes a list of constants, suggest them
if (argDef.values) {
// ... | <COMMAND> ... <suggest enums>
suggestions.push(
...buildConstantsDefinitions(argDef.values, undefined, undefined, {
advanceCursorAndOpenSuggestions: true,
})
);
}
// If the type is specified try to dig deeper in the definition to suggest the best candidate
if (
['string', 'text', 'keyword', 'boolean', ...ESQL_NUMBER_TYPES].includes(argDef.type) &&
!argDef.values
) {
// it can be just literal values (i.e. "string")
if (argDef.constantOnly) {
// ... | <COMMAND> ... <suggest>
suggestions.push(...getCompatibleLiterals([argDef.type]));
} else {
// or it can be anything else as long as it is of the right type and the end (i.e. column or function)
if (!nodeArg) {
if (endsWithNot) {
// i.e.
// ... | WHERE field NOT <suggest>
// there's not way to know the type of the field here, so suggest anything
suggestions.push(...getSuggestionsAfterNot());
} else {
// ... | <COMMAND> <suggest>
// In this case start suggesting something not strictly based on type
suggestions.push(
...(await getFieldsByType('any', [], { advanceCursor: true, openSuggestions: true })),
...(await getFieldsOrFunctionsSuggestions(
['any'],
command.name,
option?.name,
getFieldsByType,
{
functions: true,
fields: false,
variables: anyVariables,
}
))
);
}
} else {
// if something is already present, leverage its type to suggest something in context
const nodeArgType = extractTypeFromASTArg(nodeArg, references);
// These cases can happen here, so need to identify each and provide the right suggestion
// i.e. ... | <COMMAND> field <suggest>
// i.e. ... | <COMMAND> field + <suggest>
// i.e. ... | <COMMAND> field >= <suggest>
// i.e. ... | <COMMAND> field > 0 <suggest>
// i.e. ... | <COMMAND> field + otherN <suggest>
// "FROM a | WHERE doubleField IS NOT N"
if (nodeArgType) {
if (isFunctionItem(nodeArg)) {
if (nodeArg.name === 'not') {
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['boolean'],
command.name,
option?.name,
getFieldsByType,
{
functions: true,
fields: true,
variables: anyVariables,
}
))
);
} else {
suggestions.push(
...(await getSuggestionsToRightOfOperatorExpression({
queryText: innerText,
commandName: command.name,
optionName: option?.name,
rootOperator: nodeArg,
getExpressionType: (expression) =>
getExpressionType(expression, references.fields, references.variables),
getColumnsByType: getFieldsByType,
}))
);
}
} else if (isParameterType(nodeArgType)) {
// i.e. ... | <COMMAND> field <suggest>
suggestions.push(
...getOperatorSuggestions({
command: command.name,
leftParamType: nodeArgType,
ignored: workoutBuiltinOptions(nodeArg, references).ignored,
})
);
}
}
}
}
}
}
const nonOptionArgs = command.args.filter(
(arg) => !isOptionItem(arg) && !Array.isArray(arg) && !arg.incomplete
);
// Perform some checks on mandatory arguments
const mandatoryArgsAlreadyPresent =
(commandDef.signature.multipleParams && nonOptionArgs.length > 1) ||
nonOptionArgs.length >=
commandDef.signature.params.filter(({ optional }) => !optional).length ||
argDef?.type === 'function';
// check if declared args are fully valid for the given command
const currentArgsAreValidForCommand = areCurrentArgsValid(command, nodeArg, references);
// latest suggestions: options and final ones
// For now, we don't suggest for expressions within any function besides CASE
// e.g. CASE(field != /)
//
// So, it is handled as a special branch...
if (
(!isNewExpression && mandatoryArgsAlreadyPresent && currentArgsAreValidForCommand) ||
optionsAlreadyDeclared.length
astContext.containingFunction?.name === 'case' &&
!Array.isArray(astContext.node) &&
astContext.node?.type === 'function' &&
astContext.node?.subtype === 'binary-expression'
) {
// suggest some command options
if (optionsAvailable.length) {
suggestions.push(
...optionsAvailable.map((opt) => buildOptionDefinition(opt, command.name === 'dissect'))
);
}
if (!optionsAvailable.length || optionsAvailable.every(({ optional }) => optional)) {
const shouldPushItDown = command.name === 'eval' && !command.args.some(isFunctionItem);
// now suggest pipe or comma
const finalSuggestions = getFinalSuggestions({
comma:
commandDef.signature.multipleParams &&
optionsAvailable.length === commandDef.options.length,
}).map(({ sortText, ...rest }) => ({
...rest,
sortText: shouldPushItDown ? `Z${sortText}` : sortText,
}));
suggestions.push(...finalSuggestions);
}
return await getSuggestionsToRightOfOperatorExpression({
queryText: innerText,
commandName: astContext.command.name,
optionName: astContext.option?.name,
rootOperator: astContext.node,
getExpressionType: (expression) =>
getExpressionType(expression, references.fields, references.variables),
getColumnsByType,
});
}
// Due to some logic overlapping functions can be repeated
// so dedupe here based on text string (it can differ from name)
return uniqBy(suggestions, (suggestion) => suggestion.text);
return commandDef.suggest({
innerText,
command: astContext.command,
getColumnsByType,
getAllColumnNames: () => Array.from(fieldsMap.keys()),
columnExists: (col: string) => Boolean(getColumnByName(col, references)),
getSuggestedVariableName: (extraFieldNames?: string[]) => {
if (!extraFieldNames?.length) {
return findNewVariable(anyVariables);
}
const augmentedFieldsMap = new Map(fieldsMap);
extraFieldNames.forEach((name) => {
augmentedFieldsMap.set(name, { name, type: 'double' });
});
return findNewVariable(collectVariables(commands, augmentedFieldsMap, innerText));
},
getExpressionType: (expression: ESQLAstItem | undefined) =>
getExpressionType(expression, references.fields, references.variables),
getPreferences,
definition: commandDef,
getSources,
getRecommendedQueriesSuggestions: (prefix) =>
getRecommendedQueriesSuggestions(getColumnsByType, prefix),
getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type),
previousCommands: commands,
callbacks,
getVariables,
supportsControls,
getPolicies,
getPolicyMetadata,
});
}
const addCommaIf = (condition: boolean, text: string) => (condition ? `${text},` : text);

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ESQLSingleAstItem } from '@kbn/esql-ast';
import { isMarkerNode } from '../../../shared/context';
import { isAssignment, isColumnItem } from '../../../..';
import { CommandSuggestParams } from '../../../definitions/types';
import type { SuggestionRawDefinition } from '../../types';
import { getNewVariableSuggestion } from '../../factories';
import { commaCompleteItem, pipeCompleteItem } from '../../complete_items';
import { buildPartialMatcher, getExpressionPosition, suggestForExpression } from '../../helper';
const isNullMatcher = buildPartialMatcher('is nul');
const isNotNullMatcher = buildPartialMatcher('is not nul');
export async function suggest(
params: CommandSuggestParams<'eval'>
): Promise<SuggestionRawDefinition[]> {
let expressionRoot = /,\s*$/.test(params.innerText)
? undefined
: (params.command.args[params.command.args.length - 1] as ESQLSingleAstItem | undefined);
let insideAssignment = false;
if (expressionRoot && isAssignment(expressionRoot)) {
// EVAL foo = <use this as the expression root>
expressionRoot = (expressionRoot.args[1] as ESQLSingleAstItem[])[0] as ESQLSingleAstItem;
insideAssignment = true;
if (isMarkerNode(expressionRoot)) {
expressionRoot = undefined;
}
}
const suggestions = await suggestForExpression({
...params,
expressionRoot,
commandName: 'eval',
});
const positionInExpression = getExpressionPosition(params.innerText, expressionRoot);
if (positionInExpression === 'empty_expression' && !insideAssignment) {
suggestions.push(getNewVariableSuggestion(params.getSuggestedVariableName()));
}
const isExpressionComplete =
params.getExpressionType(expressionRoot) !== 'unknown' &&
// see https://github.com/elastic/kibana/issues/199401
// for the reason we need this string check.
!(isNullMatcher.test(params.innerText) || isNotNullMatcher.test(params.innerText));
if (
// don't suggest finishing characters if incomplete expression
isExpressionComplete &&
// don't suggest finishing characters if the expression is a column
// because "EVAL columnName" is a useless expression
expressionRoot &&
!isColumnItem(expressionRoot)
) {
suggestions.push(pipeCompleteItem, { ...commaCompleteItem, text: ', ' });
}
return suggestions;
}

View file

@ -12,8 +12,8 @@ import { ESQLCommand, mutate, LeafPrinter } from '@kbn/esql-ast';
import type { ESQLAstJoinCommand } from '@kbn/esql-ast';
import type { ESQLCallbacks } from '../../../shared/types';
import {
CommandBaseDefinition,
CommandDefinition,
CommandSuggestFunction,
CommandSuggestParams,
CommandTypeDefinition,
} from '../../../definitions/types';
@ -96,7 +96,7 @@ const suggestFields = async (
return [...intersection, ...union];
};
export const suggest: CommandBaseDefinition<'join'>['suggest'] = async ({
export const suggest: CommandSuggestFunction<'join'> = async ({
innerText,
command,
getColumnsByType,

View file

@ -7,214 +7,37 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Walker, type ESQLSingleAstItem, type ESQLFunction } from '@kbn/esql-ast';
import { logicalOperators } from '../../../definitions/all_operators';
import { CommandSuggestParams, isParameterType } from '../../../definitions/types';
import { isFunctionItem } from '../../../shared/helpers';
import { type ESQLSingleAstItem } from '@kbn/esql-ast';
import { CommandSuggestParams } from '../../../definitions/types';
import type { SuggestionRawDefinition } from '../../types';
import {
getFunctionSuggestions,
getOperatorSuggestion,
getOperatorSuggestions,
getSuggestionsAfterNot,
} from '../../factories';
import { getOverlapRange, getSuggestionsToRightOfOperatorExpression } from '../../helper';
import { getPosition } from './util';
import { pipeCompleteItem } from '../../complete_items';
import {
EDITOR_MARKER,
UNSUPPORTED_COMMANDS_BEFORE_MATCH,
UNSUPPORTED_COMMANDS_BEFORE_QSTR,
} from '../../../shared/constants';
import { buildPartialMatcher, suggestForExpression } from '../../helper';
export async function suggest({
innerText,
command,
getColumnsByType,
getExpressionType,
previousCommands,
}: CommandSuggestParams<'where'>): Promise<SuggestionRawDefinition[]> {
const suggestions: SuggestionRawDefinition[] = [];
const isNullMatcher = buildPartialMatcher('is nul');
const isNotNullMatcher = buildPartialMatcher('is not nul');
/**
* The logic for WHERE suggestions is basically the logic for expression suggestions.
* I assume we will eventually extract much of this to be a shared function among WHERE and EVAL
* and anywhere else the user can enter a generic expression.
*/
const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined;
export async function suggest(
params: CommandSuggestParams<'where'>
): Promise<SuggestionRawDefinition[]> {
const expressionRoot = params.command.args[0] as ESQLSingleAstItem | undefined;
const suggestions = await suggestForExpression({
...params,
expressionRoot,
commandName: 'where',
});
const position = getPosition(innerText, command);
switch (position) {
/**
* After a column name
*/
case 'after_column':
const columnType = getExpressionType(expressionRoot);
const isExpressionComplete =
expressionRoot &&
params.getExpressionType(expressionRoot) === 'boolean' &&
// see https://github.com/elastic/kibana/issues/199401
// for the reason we need this string check.
!(isNullMatcher.test(params.innerText) || isNotNullMatcher.test(params.innerText));
if (!isParameterType(columnType)) {
break;
}
suggestions.push(
...getOperatorSuggestions({
command: 'where',
leftParamType: columnType,
// no assignments allowed in WHERE
ignored: ['='],
})
);
break;
/**
* After a complete (non-operator) function call
*/
case 'after_function':
const returnType = getExpressionType(expressionRoot);
if (!isParameterType(returnType)) {
break;
}
suggestions.push(
...getOperatorSuggestions({
command: 'where',
leftParamType: returnType,
ignored: ['='],
})
);
break;
/**
* After a NOT keyword
*
* the NOT function is a special operator that can be used in different ways,
* and not all these are mapped within the AST data structure: in particular
* <COMMAND> <field> NOT <here>
* is an incomplete statement and it results in a missing AST node, so we need to detect
* from the query string itself
*
* (this comment was copied but seems to still apply)
*/
case 'after_not':
if (expressionRoot && isFunctionItem(expressionRoot) && expressionRoot.name === 'not') {
suggestions.push(
...getFunctionSuggestions({ command: 'where', returnTypes: ['boolean'] }),
...(await getColumnsByType('boolean', [], { advanceCursor: true, openSuggestions: true }))
);
} else {
suggestions.push(...getSuggestionsAfterNot());
}
break;
/**
* After an operator (e.g. AND, OR, IS NULL, +, etc.)
*/
case 'after_operator':
if (!expressionRoot) {
break;
}
if (!isFunctionItem(expressionRoot) || expressionRoot.subtype === 'variadic-call') {
// this is already guaranteed in the getPosition function, but TypeScript doesn't know
break;
}
let rightmostOperator = expressionRoot;
// get rightmost function
const walker = new Walker({
visitFunction: (fn: ESQLFunction) => {
if (fn.location.min > rightmostOperator.location.min && fn.subtype !== 'variadic-call')
rightmostOperator = fn;
},
});
walker.walkFunction(expressionRoot);
// See https://github.com/elastic/kibana/issues/199401 for an explanation of
// why this check has to be so convoluted
if (rightmostOperator.text.toLowerCase().trim().endsWith('null')) {
suggestions.push(...logicalOperators.map(getOperatorSuggestion));
break;
}
suggestions.push(
...(await getSuggestionsToRightOfOperatorExpression({
queryText: innerText,
commandName: 'where',
rootOperator: rightmostOperator,
preferredExpressionType: 'boolean',
getExpressionType,
getColumnsByType,
}))
);
break;
case 'empty_expression':
// Don't suggest MATCH, QSTR or KQL after unsupported commands
const priorCommands = previousCommands?.map((a) => a.name) ?? [];
const ignored = [];
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_MATCH.has(c))) {
ignored.push('match');
}
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_QSTR.has(c))) {
ignored.push('kql', 'qstr');
}
const last = previousCommands?.[previousCommands.length - 1];
let columnSuggestions: SuggestionRawDefinition[] = [];
if (!last?.text?.endsWith(`:${EDITOR_MARKER}`)) {
columnSuggestions = await getColumnsByType('any', [], {
advanceCursor: true,
openSuggestions: true,
});
}
suggestions.push(
...columnSuggestions,
...getFunctionSuggestions({ command: 'where', ignored })
);
break;
}
// Is this a complete expression of the right type?
// Is this a complete boolean expression?
// If so, we can call it done and suggest a pipe
if (getExpressionType(expressionRoot) === 'boolean') {
if (isExpressionComplete) {
suggestions.push(pipeCompleteItem);
}
/**
* Attach replacement ranges if there's a prefix.
*
* Can't rely on Monaco because
* - it counts "." as a word separator
* - it doesn't handle multi-word completions (like "is null")
*
* TODO - think about how to generalize this.
*/
const hasNonWhitespacePrefix = !/\s/.test(innerText[innerText.length - 1]);
if (hasNonWhitespacePrefix) {
// get index of first char of final word
const lastWhitespaceIndex = innerText.search(/\S(?=\S*$)/);
suggestions.forEach((s) => {
if (['IS NULL', 'IS NOT NULL'].includes(s.text)) {
// this suggestion has spaces in it (e.g. "IS NOT NULL")
// so we need to see if there's an overlap
const overlap = getOverlapRange(innerText, s.text);
if (overlap.start < overlap.end) {
// there's an overlap so use that
s.rangeToReplace = overlap;
return;
}
}
// no overlap, so just replace from the last whitespace
s.rangeToReplace = {
start: lastWhitespaceIndex + 1,
end: innerText.length,
};
});
}
return suggestions;
}

View file

@ -1,52 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ESQLCommand, ESQLSingleAstItem } from '@kbn/esql-ast';
import { isColumnItem, isFunctionItem } from '../../../shared/helpers';
export type CaretPosition =
| 'after_column'
| 'after_function'
| 'after_not'
| 'after_operator'
| 'empty_expression';
export const getPosition = (innerText: string, command: ESQLCommand): CaretPosition => {
const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined;
const endsWithNot = / not$/i.test(innerText.trimEnd());
if (
endsWithNot &&
!(
expressionRoot &&
isFunctionItem(expressionRoot) &&
// See https://github.com/elastic/kibana/issues/199401
// for more information on this check...
['is null', 'is not null'].includes(expressionRoot.name)
)
) {
return 'after_not';
}
if (expressionRoot) {
if (isColumnItem(expressionRoot)) {
return 'after_column';
}
if (isFunctionItem(expressionRoot) && expressionRoot.subtype === 'variadic-call') {
return 'after_function';
}
if (isFunctionItem(expressionRoot) && expressionRoot.subtype !== 'variadic-call') {
return 'after_operator';
}
}
return 'empty_expression';
};

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getOverlapRange } from './helper';
import { buildPartialMatcher, getOverlapRange } from './helper';
describe('getOverlapRange', () => {
it('should return the overlap range', () => {
@ -22,3 +22,18 @@ describe('getOverlapRange', () => {
});
});
});
describe('buildPartialMatcher', () => {
it('should build a partial matcher', () => {
const str = 'is NoT nulL';
const matcher = buildPartialMatcher(str);
for (let i = 0; i < str.length; i++) {
expect(matcher.test(str.slice(0, i + 1))).toEqual(true);
}
expect(matcher.test('not')).toEqual(false);
expect(matcher.test('is null')).toEqual(false);
expect(matcher.test('is not nullz')).toEqual(false);
});
});

View file

@ -14,6 +14,8 @@ import {
type ESQLFunction,
type ESQLLiteral,
type ESQLSource,
ESQLSingleAstItem,
Walker,
} from '@kbn/esql-ast';
import { uniqBy } from 'lodash';
import { ESQLVariableType } from '@kbn/esql-types';
@ -24,6 +26,7 @@ import {
type SupportedDataType,
isReturnType,
FunctionDefinitionTypes,
CommandSuggestParams,
} from '../definitions/types';
import {
findFinalWord,
@ -45,11 +48,18 @@ import {
getCompatibleLiterals,
getDateLiterals,
getOperatorSuggestions,
getSuggestionsAfterNot,
getOperatorSuggestion,
} from './factories';
import { EDITOR_MARKER } from '../shared/constants';
import {
EDITOR_MARKER,
UNSUPPORTED_COMMANDS_BEFORE_MATCH,
UNSUPPORTED_COMMANDS_BEFORE_QSTR,
} from '../shared/constants';
import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import { listCompleteItem } from './complete_items';
import { removeMarkerArgFromArgsList } from '../shared/context';
import { logicalOperators } from '../definitions/all_operators';
function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] {
return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem);
@ -532,7 +542,8 @@ export function checkFunctionInvocationComplete(
const hasCorrectTypes = fnDefinition.signatures.some((def) => {
return func.args.every((a, index) => {
return (
(fnDefinition.name.endsWith('null') && def.params[index].type === 'any') ||
fnDefinition.name.endsWith('null') ||
def.params[index].type === 'any' ||
def.params[index].type === getExpressionType(a)
);
});
@ -680,3 +691,271 @@ export async function getSuggestionsToRightOfOperatorExpression({
};
});
}
/**
* The position of the cursor within an expression.
*/
type ExpressionPosition =
| 'after_column'
| 'after_function'
| 'after_not'
| 'after_operator'
| 'after_literal'
| 'empty_expression';
/**
* Determines the position of the cursor within an expression.
* @param innerText
* @param expressionRoot
* @returns
*/
export const getExpressionPosition = (
innerText: string,
expressionRoot: ESQLSingleAstItem | undefined
): ExpressionPosition => {
const endsWithNot = / not$/i.test(innerText.trimEnd());
if (
endsWithNot &&
!(
expressionRoot &&
isFunctionItem(expressionRoot) &&
// See https://github.com/elastic/kibana/issues/199401
// for more information on this check...
['is null', 'is not null'].includes(expressionRoot.name)
)
) {
return 'after_not';
}
if (expressionRoot) {
if (isColumnItem(expressionRoot)) {
return 'after_column';
}
if (isFunctionItem(expressionRoot) && expressionRoot.subtype === 'variadic-call') {
return 'after_function';
}
if (isFunctionItem(expressionRoot) && expressionRoot.subtype !== 'variadic-call') {
return 'after_operator';
}
if (isLiteralItem(expressionRoot) || isTimeIntervalItem(expressionRoot)) {
return 'after_literal';
}
}
return 'empty_expression';
};
/**
* Creates suggestion within an expression.
*
* TODO should this function know about the command context
* or would we prefer a set of generic configuration options?
*
* @param param0
* @returns
*/
export async function suggestForExpression({
expressionRoot,
innerText,
getExpressionType,
getColumnsByType,
previousCommands,
commandName,
}: {
expressionRoot: ESQLSingleAstItem | undefined;
commandName: string;
} & Pick<
CommandSuggestParams<string>,
'innerText' | 'getExpressionType' | 'getColumnsByType' | 'previousCommands'
>): Promise<SuggestionRawDefinition[]> {
const suggestions: SuggestionRawDefinition[] = [];
const position = getExpressionPosition(innerText, expressionRoot);
switch (position) {
/**
* After a literal, column, or complete (non-operator) function call
*/
case 'after_literal':
case 'after_column':
case 'after_function':
const expressionType = getExpressionType(expressionRoot);
if (!isParameterType(expressionType)) {
break;
}
suggestions.push(
...getOperatorSuggestions({
command: commandName,
leftParamType: expressionType,
ignored: ['='],
})
);
break;
/**
* After a NOT keyword
*
* the NOT function is a special operator that can be used in different ways,
* and not all these are mapped within the AST data structure: in particular
* <COMMAND> <field> NOT <here>
* is an incomplete statement and it results in a missing AST node, so we need to detect
* from the query string itself
*
* (this comment was copied but seems to still apply)
*/
case 'after_not':
if (expressionRoot && isFunctionItem(expressionRoot) && expressionRoot.name === 'not') {
suggestions.push(
...getFunctionSuggestions({ command: commandName, returnTypes: ['boolean'] }),
...(await getColumnsByType('boolean', [], { advanceCursor: true, openSuggestions: true }))
);
} else {
suggestions.push(...getSuggestionsAfterNot());
}
break;
/**
* After an operator (e.g. AND, OR, IS NULL, +, etc.)
*/
case 'after_operator':
if (!expressionRoot) {
break;
}
if (!isFunctionItem(expressionRoot) || expressionRoot.subtype === 'variadic-call') {
// this is already guaranteed in the getPosition function, but TypeScript doesn't know
break;
}
let rightmostOperator = expressionRoot;
// get rightmost function
const walker = new Walker({
visitFunction: (fn: ESQLFunction) => {
if (fn.location.min > rightmostOperator.location.min && fn.subtype !== 'variadic-call')
rightmostOperator = fn;
},
});
walker.walkFunction(expressionRoot);
// See https://github.com/elastic/kibana/issues/199401 for an explanation of
// why this check has to be so convoluted
if (rightmostOperator.text.toLowerCase().trim().endsWith('null')) {
suggestions.push(...logicalOperators.map(getOperatorSuggestion));
break;
}
suggestions.push(
...(await getSuggestionsToRightOfOperatorExpression({
queryText: innerText,
commandName,
rootOperator: rightmostOperator,
preferredExpressionType: commandName === 'where' ? 'boolean' : undefined,
getExpressionType,
getColumnsByType,
}))
);
break;
case 'empty_expression':
// Don't suggest MATCH, QSTR or KQL after unsupported commands
const priorCommands = previousCommands?.map((a) => a.name) ?? [];
const ignored = [];
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_MATCH.has(c))) {
ignored.push('match');
}
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_QSTR.has(c))) {
ignored.push('kql', 'qstr');
}
const last = previousCommands?.[previousCommands.length - 1];
let columnSuggestions: SuggestionRawDefinition[] = [];
if (!last?.text?.endsWith(`:${EDITOR_MARKER}`)) {
columnSuggestions = await getColumnsByType('any', [], {
advanceCursor: true,
openSuggestions: true,
});
}
suggestions.push(
...pushItUpInTheList(columnSuggestions, true),
...getFunctionSuggestions({ command: commandName, ignored })
);
break;
}
/**
* Attach replacement ranges if there's a prefix.
*
* Can't rely on Monaco because
* - it counts "." as a word separator
* - it doesn't handle multi-word completions (like "is null")
*
* TODO - think about how to generalize this issue: https://github.com/elastic/kibana/issues/209905
*/
const hasNonWhitespacePrefix = !/\s/.test(innerText[innerText.length - 1]);
if (hasNonWhitespacePrefix) {
// get index of first char of final word
const lastWhitespaceIndex = innerText.search(/\S(?=\S*$)/);
suggestions.forEach((s) => {
if (['IS NULL', 'IS NOT NULL'].includes(s.text)) {
// this suggestion has spaces in it (e.g. "IS NOT NULL")
// so we need to see if there's an overlap
const overlap = getOverlapRange(innerText, s.text);
if (overlap.start < overlap.end) {
// there's an overlap so use that
s.rangeToReplace = overlap;
return;
}
}
// no overlap, so just replace from the last whitespace
s.rangeToReplace = {
start: lastWhitespaceIndex + 1,
end: innerText.length,
};
});
}
return suggestions;
}
/**
* Builds a regex that matches partial strings starting
* from the beginning of the string.
*
* Example:
* "is null" -> /^i(?:s(?:\s+(?:n(?:u(?:l(?:l)?)?)?)?)?)?$/i
*/
export function buildPartialMatcher(str: string) {
// Split the string into characters
const chars = str.split('');
// Initialize the regex pattern
let pattern = '';
// Iterate through the characters and build the pattern
chars.forEach((char, index) => {
if (char === ' ') {
pattern += '\\s+';
} else {
pattern += char;
}
if (index < chars.length - 1) {
pattern += '(?:';
}
});
// Close the non-capturing groups
for (let i = 0; i < chars.length - 1; i++) {
pattern += ')?';
}
// Return the final regex pattern
return new RegExp(pattern + '$', 'i');
}

View file

@ -30,21 +30,22 @@ import { ENRICH_MODES } from './settings';
import { type CommandDefinition } from './types';
import { checkAggExistence, checkFunctionContent } from './commands_helpers';
import { suggest as suggestForSort } from '../autocomplete/commands/sort';
import { suggest as suggestForKeep } from '../autocomplete/commands/keep';
import { suggest as suggestForDrop } from '../autocomplete/commands/drop';
import { suggest as suggestForStats } from '../autocomplete/commands/stats';
import { suggest as suggestForWhere } from '../autocomplete/commands/where';
import { suggest as suggestForJoin } from '../autocomplete/commands/join';
import { suggest as suggestForFrom } from '../autocomplete/commands/from';
import { suggest as suggestForRow } from '../autocomplete/commands/row';
import { suggest as suggestForShow } from '../autocomplete/commands/show';
import { suggest as suggestForGrok } from '../autocomplete/commands/grok';
import { suggest as suggestForDissect } from '../autocomplete/commands/dissect';
import { suggest as suggestForDrop } from '../autocomplete/commands/drop';
import { suggest as suggestForEnrich } from '../autocomplete/commands/enrich';
import { suggest as suggestForRename } from '../autocomplete/commands/rename';
import { suggest as suggestForEval } from '../autocomplete/commands/eval';
import { suggest as suggestForFrom } from '../autocomplete/commands/from';
import { suggest as suggestForGrok } from '../autocomplete/commands/grok';
import { suggest as suggestForJoin } from '../autocomplete/commands/join';
import { suggest as suggestForKeep } from '../autocomplete/commands/keep';
import { suggest as suggestForLimit } from '../autocomplete/commands/limit';
import { suggest as suggestForMvExpand } from '../autocomplete/commands/mv_expand';
import { suggest as suggestForRename } from '../autocomplete/commands/rename';
import { suggest as suggestForRow } from '../autocomplete/commands/row';
import { suggest as suggestForShow } from '../autocomplete/commands/show';
import { suggest as suggestForSort } from '../autocomplete/commands/sort';
import { suggest as suggestForStats } from '../autocomplete/commands/stats';
import { suggest as suggestForWhere } from '../autocomplete/commands/where';
const statsValidator = (command: ESQLCommand) => {
const messages: ESQLMessage[] = [];
@ -207,6 +208,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
{ name: 'expression', type: 'function', optional: true },
],
},
suggest: () => [],
},
{
name: 'stats',
@ -243,6 +245,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
modes: [],
// Reusing the same validation logic as stats command
validate: statsValidator,
suggest: () => [],
},
{
@ -263,6 +266,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
},
options: [],
modes: [],
suggest: suggestForEval,
},
{
name: 'rename',
@ -476,6 +480,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
params: [],
multipleParams: false,
},
suggest: () => [],
},
{
name: 'join',

View file

@ -280,6 +280,9 @@ export type CommandSuggestFunction<CommandName extends string> = (
params: CommandSuggestParams<CommandName>
) => Promise<SuggestionRawDefinition[]> | SuggestionRawDefinition[];
/**
* @deprecated use CommandDefinition instead
*/
export interface CommandBaseDefinition<CommandName extends string> {
name: CommandName;
@ -298,7 +301,6 @@ export interface CommandBaseDefinition<CommandName extends string> {
* Whether to show or hide in autocomplete suggestion list
*/
hidden?: boolean;
suggest?: CommandSuggestFunction<CommandName>;
/** @deprecated this property will disappear in the future */
signature: {
multipleParams: boolean;
@ -322,6 +324,9 @@ export interface CommandTypeDefinition {
description?: string;
}
/**
* @deprecated options are going away
*/
export interface CommandOptionsDefinition<CommandName extends string = string>
extends CommandBaseDefinition<CommandName> {
wrapped?: string[];
@ -345,6 +350,7 @@ export interface CommandDefinition<CommandName extends string>
extends CommandBaseDefinition<CommandName> {
examples: string[];
validate?: (option: ESQLCommand) => ESQLMessage[];
suggest: CommandSuggestFunction<CommandName>;
/** @deprecated this property will disappear in the future */
modes: CommandModeDefinition[];
/** @deprecated this property will disappear in the future */

View file

@ -121,8 +121,19 @@ function findAstPosition(ast: ESQLAst, offset: number) {
if (!command) {
return { command: undefined, node: undefined };
}
const containingFunction = Walker.findAll(
command,
(node) =>
node.type === 'function' &&
node.subtype === 'variadic-call' &&
node.location?.min <= offset &&
node.location?.max >= offset
).pop() as ESQLFunction | undefined;
return {
command: removeMarkerArgFromArgsList(command)!,
containingFunction: removeMarkerArgFromArgsList(containingFunction),
option: removeMarkerArgFromArgsList(findOption(command.args, offset)),
node: removeMarkerArgFromArgsList(cleanMarkerNode(findNode(command.args, offset))),
};
@ -162,16 +173,16 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
};
}
const { command, option, node } = findAstPosition(ast, offset);
const { command, option, node, containingFunction } = findAstPosition(ast, offset);
if (node) {
if (node.type === 'literal' && node.literalType === 'keyword') {
// command ... "<here>"
return { type: 'value' as const, command, node, option };
return { type: 'value' as const, command, node, option, containingFunction };
}
if (node.type === 'function') {
if (['in', 'not_in'].includes(node.name) && Array.isArray(node.args[1])) {
// command ... a in ( <here> )
return { type: 'list' as const, command, node, option };
return { type: 'list' as const, command, node, option, containingFunction };
}
if (
isNotEnrichClauseAssigment(node, command) &&
@ -182,19 +193,20 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
!(isOperator(node) && command.name !== 'stats')
) {
// command ... fn( <here> )
return { type: 'function' as const, command, node, option };
return { type: 'function' as const, command, node, option, containingFunction };
}
}
}
if (!command || (queryString.length <= offset && pipePrecedesCurrentWord(queryString))) {
// // ... | <here>
return { type: 'newCommand' as const, command: undefined, node, option };
return { type: 'newCommand' as const, command: undefined, node, option, containingFunction };
}
// command a ... <here> OR command a = ... <here>
return {
type: 'expression' as const,
command,
containingFunction,
option,
node,
};

View file

@ -111,7 +111,7 @@ export function isIncompleteItem(arg: ESQLAstItem): boolean {
return !arg || (!Array.isArray(arg) && arg.incomplete);
}
export function isMathFunction(query: string, offset: number) {
function isMathFunction(query: string) {
const queryTrimmed = query.trimEnd();
// try to get the full operation token (e.g. "+", "in", "like", etc...) but it requires the token
// to be spaced out from a field/function (e.g. "field + ") so it is subject to issues
@ -764,8 +764,8 @@ export function correctQuerySyntax(_query: string, context: EditorContext) {
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
(context.triggerKind === 0 && unclosedRoundBracketCount === 0) ||
(context.triggerCharacter === ' ' && isMathFunction(query, query.length)) ||
isComma(query.trimEnd()[query.trimEnd().length - 1])
isMathFunction(query) ||
/,\s+$/.test(query)
) {
query += EDITOR_MARKER;
}