[ES|QL] open the suggestion menu automatically in more places (#189585)

## Summary

Closes https://github.com/elastic/kibana/issues/189662

### Before

https://github.com/user-attachments/assets/9f4e0fc6-1399-4b17-a151-753178e5ec7f

### After

https://github.com/user-attachments/assets/45d59b63-3f18-457e-b4b9-1d98fa0ad8b1



| | Before | After |

|------------------------------|------------------------------------------|-----------------------|
| Source command |  |  |
| Pipe command |  |  |
| Function argument (when the minimum args aren't met) |  |  |
| Pipe `\|` |  |  |
| Assignment `var0 =` |   |    |
| FROM source |  |  |
| FROM source METADATA |  |  |
| FROM source METADATA field |  |  |
| FROM source METADATA field, |  |  |
| EVAL argument |  |  |
| DISSECT field |  |  |
| DISSECT field pattern |  |  |
| DISSECT field pattern APPEND_SEPARATOR |  |  |
| DISSECT field pattern APPEND_SEPARATOR = separator |  |  |
| DROP field |  |  |
| DROP field1, field2 |  |  |
| ENRICH policy |  |  |
| ENRICH policy ON |  |  |
| ENRICH policy ON field |  |  |
| ENRICH policy WITH |  |  |
| ENRICH policy WITH field |  |  |
| GROK field |  |  |
| GROK field pattern |  |  |
| KEEP field |  |  |
| KEEP field1, field2 |  |  |
| LIMIT number |  |  |
| MV_EXPAND field |  |  |
| RENAME field |  |  |
| RENAME field AS |  | |
| RENAME field AS var0 |  |  |
| SORT field |  |  |
| SORT field order |  |  |
| SORT field order nulls-order |  |  |
| STATS argument |  |  |
| STATS argument BY |  |  |
| STATS argument BY expression |  |  |
| WHERE argument |  |  |
| WHERE argument comparison    |   |                      |    
| WHERE argument comparison argument |  |  |

Also made a couple of improvements
- Added support for Invoke completion triggers for subsequent function
arguments (e.g. `date_diff("day", <cursor here>)`)
- When you select a date in the date picker, your cursor is now advanced
past the inserted date and the editor is refocused.

### 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
This commit is contained in:
Drew Tate 2024-08-07 07:31:18 -06:00 committed by GitHub
parent 358e104ebc
commit 44c5f8ca80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 654 additions and 269 deletions

View file

@ -14,6 +14,9 @@ const visibleIndices = indexes
.map(({ name, suggestedAs }) => suggestedAs || name)
.sort();
const addTrailingSpace = (strings: string[], predicate: (s: string) => boolean = (_s) => true) =>
strings.map((string) => (predicate(string) ? `${string} ` : string));
const metadataFields = [...METADATA_FIELDS].sort();
describe('autocomplete.suggest', () => {
@ -33,17 +36,17 @@ describe('autocomplete.suggest', () => {
test('suggests visible indices on space', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from /', visibleIndices);
await assertSuggestions('FROM /', visibleIndices);
await assertSuggestions('from /index', visibleIndices);
await assertSuggestions('from /', addTrailingSpace(visibleIndices));
await assertSuggestions('FROM /', addTrailingSpace(visibleIndices));
await assertSuggestions('from /index', addTrailingSpace(visibleIndices));
});
test('suggests visible indices on comma', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('FROM a,/', visibleIndices);
await assertSuggestions('FROM a, /', visibleIndices);
await assertSuggestions('from *,/', visibleIndices);
await assertSuggestions('FROM a,/', addTrailingSpace(visibleIndices));
await assertSuggestions('FROM a, /', addTrailingSpace(visibleIndices));
await assertSuggestions('from *,/', addTrailingSpace(visibleIndices));
});
test('can suggest integration data sources', async () => {
@ -52,17 +55,21 @@ describe('autocomplete.suggest', () => {
.filter(({ hidden }) => !hidden)
.map(({ name, suggestedAs }) => suggestedAs || name)
.sort();
const expectedSuggestions = addTrailingSpace(
visibleDataSources,
(s) => !integrations.find(({ name }) => name === s)
);
const { assertSuggestions, callbacks } = await setup();
const cb = {
...callbacks,
getSources: jest.fn().mockResolvedValue(dataSources),
};
assertSuggestions('from /', visibleDataSources, { callbacks: cb });
assertSuggestions('FROM /', visibleDataSources, { callbacks: cb });
assertSuggestions('FROM a,/', visibleDataSources, { callbacks: cb });
assertSuggestions('from a, /', visibleDataSources, { callbacks: cb });
assertSuggestions('from *,/', visibleDataSources, { callbacks: cb });
await assertSuggestions('from /', expectedSuggestions, { callbacks: cb });
await assertSuggestions('FROM /', expectedSuggestions, { callbacks: cb });
await assertSuggestions('FROM a,/', expectedSuggestions, { callbacks: cb });
await assertSuggestions('from a, /', expectedSuggestions, { callbacks: cb });
await assertSuggestions('from *,/', expectedSuggestions, { callbacks: cb });
});
});
@ -71,7 +78,7 @@ describe('autocomplete.suggest', () => {
test('on <kbd>SPACE</kbd> without comma ",", suggests adding metadata', async () => {
const { assertSuggestions } = await setup();
const expected = ['METADATA $0', ',', '|'].sort();
const expected = ['METADATA $0', ',', '| '].sort();
await assertSuggestions('from a, b /', expected);
});
@ -86,10 +93,10 @@ describe('autocomplete.suggest', () => {
test('on <kbd>SPACE</kbd> after "METADATA" column suggests command and pipe operators', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a, b [metadata _index /]', [',', '|']);
await assertSuggestions('from a, b metadata _index /', [',', '|']);
await assertSuggestions('from a, b metadata _index, _source /', [',', '|']);
await assertSuggestions(`from a, b metadata ${METADATA_FIELDS.join(', ')} /`, ['|']);
await assertSuggestions('from a, b [metadata _index /]', [',', '| ']);
await assertSuggestions('from a, b metadata _index /', [',', '| ']);
await assertSuggestions('from a, b metadata _index, _source /', [',', '| ']);
await assertSuggestions(`from a, b metadata ${METADATA_FIELDS.join(', ')} /`, ['| ']);
});
test('filters out already used metadata fields', async () => {

View file

@ -45,7 +45,7 @@ describe('autocomplete.suggest', () => {
describe('... <aggregates> ...', () => {
test('lists possible aggregations on space after command', async () => {
const { assertSuggestions } = await setup();
const expected = ['var0 =', ...allAggFunctions, ...allEvaFunctions];
const expected = ['var0 = ', ...allAggFunctions, ...allEvaFunctions];
await assertSuggestions('from a | stats /', expected);
await assertSuggestions('FROM a | STATS /', expected);
@ -60,14 +60,14 @@ describe('autocomplete.suggest', () => {
test('on space after aggregate field', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | stats a=min(b) /', ['BY $0', ',', '|']);
await assertSuggestions('from a | stats a=min(b) /', ['BY $0', ',', '| ']);
});
test('on space after aggregate field with comma', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | stats a=max(b), /', [
'var0 =',
'var0 = ',
...allAggFunctions,
...allEvaFunctions,
]);
@ -78,7 +78,7 @@ describe('autocomplete.suggest', () => {
await assertSuggestions('from a | stats by bucket(/', [
...getFieldNamesByType([...ESQL_COMMON_NUMERIC_TYPES, 'date']).map(
(field) => `${field},`
(field) => `${field}, `
),
...getFunctionSignaturesByReturnType('eval', ['date', ...ESQL_COMMON_NUMERIC_TYPES], {
scalar: true,
@ -172,21 +172,21 @@ describe('autocomplete.suggest', () => {
test('when typing right paren', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | stats a = min(b)/ | sort b', ['BY $0', ',', '|']);
await assertSuggestions('from a | stats a = min(b)/ | sort b', ['BY $0', ',', '| ']);
});
test('increments suggested variable name counter', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | eval var0=round(b), var1=round(c) | stats /', [
'var2 =',
'var2 = ',
...allAggFunctions,
'var0',
'var1',
...allEvaFunctions,
]);
await assertSuggestions('from a | stats var0=min(b),var1=c,/', [
'var2 =',
'var2 = ',
...allAggFunctions,
...allEvaFunctions,
]);
@ -197,8 +197,8 @@ describe('autocomplete.suggest', () => {
test('on space after "BY" keyword', async () => {
const { assertSuggestions } = await setup();
const expected = [
'var0 =',
...getFieldNamesByType('any'),
'var0 = ',
...getFieldNamesByType('any').map((field) => `${field} `),
...allEvaFunctions,
...allGroupingFunctions,
];
@ -211,26 +211,27 @@ describe('autocomplete.suggest', () => {
test('on space after grouping field', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | stats a=c by d /', [',', '|']);
await assertSuggestions('from a | stats a=c by d /', [',', '| ']);
});
test('after comma "," in grouping fields', async () => {
const { assertSuggestions } = await setup();
const fields = getFieldNamesByType('any').map((field) => `${field} `);
await assertSuggestions('from a | stats a=c by d, /', [
'var0 =',
...getFieldNamesByType('any'),
'var0 = ',
...fields,
...allEvaFunctions,
...allGroupingFunctions,
]);
await assertSuggestions('from a | stats a=min(b),/', [
'var0 =',
'var0 = ',
...allAggFunctions,
...allEvaFunctions,
]);
await assertSuggestions('from a | stats avg(b) by c, /', [
'var0 =',
...getFieldNamesByType('any'),
'var0 = ',
...fields,
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
...allGroupingFunctions,
]);
@ -251,12 +252,12 @@ describe('autocomplete.suggest', () => {
...allGroupingFunctions,
]);
await assertSuggestions('from a | stats avg(b) by var0 = /', [
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((field) => `${field} `),
...allEvaFunctions,
...allGroupingFunctions,
]);
await assertSuggestions('from a | stats avg(b) by c, var0 = /', [
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((field) => `${field} `),
...allEvaFunctions,
...allGroupingFunctions,
]);
@ -265,11 +266,11 @@ describe('autocomplete.suggest', () => {
test('on space after expression right hand side operand', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [',', '|']);
await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [',', '| ']);
await assertSuggestions(
'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day)/',
[',', '|', '+ $0', '- $0']
[',', '| ', '+ $0', '- $0']
);
});
});

View file

@ -41,6 +41,7 @@ export const triggerCharacters = [',', '(', '=', ' '];
export const fields: Array<{ name: string; type: string; suggestedAs?: string }> = [
...[
'string',
'keyword',
'double',
'date',
'boolean',

View file

@ -10,7 +10,12 @@ import { suggest } from './autocomplete';
import { evalFunctionDefinitions } from '../definitions/functions';
import { timeUnitsToSuggest } from '../definitions/literals';
import { commandDefinitions } from '../definitions/commands';
import { getSafeInsertText, getUnitDuration, TRIGGER_SUGGESTION_COMMAND } from './factories';
import {
getSafeInsertText,
getUnitDuration,
TIME_SYSTEM_PARAMS,
TRIGGER_SUGGESTION_COMMAND,
} from './factories';
import { camelCase, partition } from 'lodash';
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
import { FunctionParameter, FunctionReturnType } from '../definitions/types';
@ -26,6 +31,7 @@ import {
createCompletionContext,
getPolicyFields,
PartialSuggestionWithText,
TIME_PICKER_SUGGESTION,
} from './__tests__/helpers';
import { METADATA_FIELDS } from '../shared/constants';
import {
@ -143,7 +149,7 @@ describe('autocomplete', () => {
describe('show', () => {
testSuggestions('show ', ['INFO']);
for (const fn of ['info']) {
testSuggestions(`show ${fn} `, ['|']);
testSuggestions(`show ${fn} `, ['| ']);
}
});
@ -151,9 +157,12 @@ describe('autocomplete', () => {
const allEvalFns = getFunctionSignaturesByReturnType('where', 'any', {
scalar: true,
});
testSuggestions('from a | where ', [...getFieldNamesByType('any'), ...allEvalFns]);
testSuggestions('from a | where ', [
...getFieldNamesByType('any').map((field) => `${field} `),
...allEvalFns,
]);
testSuggestions('from a | eval var0 = 1 | where ', [
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((name) => `${name} `),
'var0',
...allEvalFns,
]);
@ -172,6 +181,14 @@ describe('autocomplete', () => {
...getFieldNamesByType('any'),
...getFunctionSignaturesByReturnType('where', ['any'], { scalar: true }),
]);
testSuggestions('from a | where dateField >= ', [
TIME_PICKER_SUGGESTION,
...TIME_SYSTEM_PARAMS,
...getFieldNamesByType('date'),
...getFunctionSignaturesByReturnType('where', ['date'], { scalar: true }),
]);
// Skip these tests until the insensitive case equality gets restored back
testSuggestions.skip('from a | where stringField =~ ', [
...getFieldNamesByType('string'),
@ -182,7 +199,7 @@ describe('autocomplete', () => {
...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }),
]);
testSuggestions.skip('from a | where stringField =~ stringField ', [
'|',
'| ',
...getFunctionSignaturesByReturnType(
'where',
'boolean',
@ -307,7 +324,7 @@ describe('autocomplete', () => {
for (const subExpression of subExpressions) {
testSuggestions(`from a | ${subExpression} grok `, getFieldNamesByType('string'));
testSuggestions(`from a | ${subExpression} grok stringField `, [constantPattern], ' ');
testSuggestions(`from a | ${subExpression} grok stringField ${constantPattern} `, ['|']);
testSuggestions(`from a | ${subExpression} grok stringField ${constantPattern} `, ['| ']);
}
});
@ -324,7 +341,7 @@ describe('autocomplete', () => {
testSuggestions(`from a | ${subExpression} dissect stringField `, [constantPattern], ' ');
testSuggestions(
`from a | ${subExpression} dissect stringField ${constantPattern} `,
['APPEND_SEPARATOR = $0', '|'],
['APPEND_SEPARATOR = $0', '| '],
' '
);
testSuggestions(
@ -333,30 +350,30 @@ describe('autocomplete', () => {
);
testSuggestions(
`from a | ${subExpression} dissect stringField ${constantPattern} append_separator = ":" `,
['|']
['| ']
);
}
});
describe('sort', () => {
testSuggestions('from a | sort ', [
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((name) => `${name} `),
...getFunctionSignaturesByReturnType('sort', 'any', { scalar: true }),
]);
testSuggestions('from a | sort stringField ', ['ASC', 'DESC', ',', '|']);
testSuggestions('from a | sort stringField desc ', ['NULLS FIRST', 'NULLS LAST', ',', '|']);
testSuggestions('from a | sort stringField ', ['ASC ', 'DESC ', ',', '| ']);
testSuggestions('from a | sort stringField desc ', ['NULLS FIRST ', 'NULLS LAST ', ',', '| ']);
// @TODO: improve here
// testSuggestions('from a | sort stringField desc ', ['first', 'last']);
});
describe('limit', () => {
testSuggestions('from a | limit ', ['10', '100', '1000']);
testSuggestions('from a | limit 4 ', ['|']);
testSuggestions('from a | limit ', ['10 ', '100 ', '1000 ']);
testSuggestions('from a | limit 4 ', ['| ']);
});
describe('mv_expand', () => {
testSuggestions('from a | mv_expand ', getFieldNamesByType('any'));
testSuggestions('from a | mv_expand a ', ['|']);
testSuggestions('from a | mv_expand a ', ['| ']);
});
describe('rename', () => {
@ -413,8 +430,9 @@ describe('autocomplete', () => {
testSuggestions(`from a ${prevCommand}| enrich _${mode.toUpperCase()}:`, policyNames, ':');
testSuggestions(`from a ${prevCommand}| enrich _${camelCase(mode)}:`, policyNames, ':');
}
testSuggestions(`from a ${prevCommand}| enrich policy `, ['ON $0', 'WITH $0', '|']);
testSuggestions(`from a ${prevCommand}| enrich policy `, ['ON $0', 'WITH $0', '| ']);
testSuggestions(`from a ${prevCommand}| enrich policy on `, [
'keywordField',
'stringField',
'doubleField',
'dateField',
@ -427,28 +445,28 @@ describe('autocomplete', () => {
'`any#Char$Field`',
'kubernetes.something.something',
]);
testSuggestions(`from a ${prevCommand}| enrich policy on b `, ['WITH $0', ',', '|']);
testSuggestions(`from a ${prevCommand}| enrich policy on b `, ['WITH $0', ',', '| ']);
testSuggestions(
`from a ${prevCommand}| enrich policy on b with `,
['var0 =', ...getPolicyFields('policy')],
['var0 = ', ...getPolicyFields('policy')],
' '
);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 `, ['= $0', ',', '|']);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 `, ['= $0', ',', '| ']);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = `, [
...getPolicyFields('policy'),
]);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = stringField `, [
',',
'|',
'| ',
]);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = stringField, `, [
'var1 =',
'var1 = ',
...getPolicyFields('policy'),
]);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = stringField, var1 `, [
'= $0',
',',
'|',
'| ',
]);
testSuggestions(
`from a ${prevCommand}| enrich policy on b with var0 = stringField, var1 = `,
@ -456,16 +474,20 @@ describe('autocomplete', () => {
);
testSuggestions(
`from a ${prevCommand}| enrich policy with `,
['var0 =', ...getPolicyFields('policy')],
['var0 = ', ...getPolicyFields('policy')],
' '
);
testSuggestions(`from a ${prevCommand}| enrich policy with stringField `, ['= $0', ',', '|']);
testSuggestions(`from a ${prevCommand}| enrich policy with stringField `, [
'= $0',
',',
'| ',
]);
}
});
describe('eval', () => {
testSuggestions('from a | eval ', [
'var0 =',
'var0 = ',
...getFieldNamesByType('any'),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
@ -474,7 +496,7 @@ describe('autocomplete', () => {
'double',
]),
',',
'|',
'| ',
]);
testSuggestions('from index | EVAL stringField not ', ['LIKE $0', 'RLIKE $0', 'IN $0']);
testSuggestions('from index | EVAL stringField NOT ', ['LIKE $0', 'RLIKE $0', 'IN $0']);
@ -499,7 +521,7 @@ describe('autocomplete', () => {
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
]);
testSuggestions('from a | eval a=doubleField, ', [
'var0 =',
'var0 = ',
...getFieldNamesByType('any'),
'a',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
@ -548,7 +570,7 @@ describe('autocomplete', () => {
);
testSuggestions('from a | eval a=round(doubleField) ', [
',',
'|',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [
'double',
]),
@ -573,7 +595,7 @@ describe('autocomplete', () => {
' '
);
testSuggestions('from a | eval a=round(doubleField),', [
'var0 =',
'var0 = ',
...getFieldNamesByType('any'),
'a',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
@ -597,7 +619,7 @@ describe('autocomplete', () => {
testSuggestions(
'from a | stats avg(doubleField) by stringField | eval ',
[
'var0 =',
'var0 = ',
'`avg(doubleField)`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
@ -609,7 +631,7 @@ describe('autocomplete', () => {
testSuggestions(
'from a | eval abs(doubleField) + 1 | eval ',
[
'var0 =',
'var0 = ',
...getFieldNamesByType('any'),
'`abs(doubleField) + 1`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
@ -619,7 +641,7 @@ describe('autocomplete', () => {
testSuggestions(
'from a | stats avg(doubleField) by stringField | eval ',
[
'var0 =',
'var0 = ',
'`avg(doubleField)`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
@ -631,7 +653,7 @@ describe('autocomplete', () => {
testSuggestions(
'from a | stats avg(doubleField), avg(kubernetes.something.something) by stringField | eval ',
[
'var0 =',
'var0 = ',
'`avg(doubleField)`',
'`avg(kubernetes.something.something)`',
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
@ -664,7 +686,7 @@ describe('autocomplete', () => {
);
// test that comma is correctly added to the suggestions if minParams is not reached yet
testSuggestions('from a | eval a=concat( ', [
...getFieldNamesByType(['text', 'keyword']).map((v) => `${v},`),
...getFieldNamesByType(['text', 'keyword']).map((v) => `${v}, `),
...getFunctionSignaturesByReturnType(
'eval',
['text', 'keyword'],
@ -691,7 +713,7 @@ describe('autocomplete', () => {
testSuggestions(
'from a | eval a=cidr_match(ipField, textField, ',
[
...getFieldNamesByType('text'),
...getFieldNamesByType('keyword'),
...getFunctionSignaturesByReturnType(
'eval',
['text', 'keyword'],
@ -704,7 +726,7 @@ describe('autocomplete', () => {
);
// test that comma is correctly added to the suggestions if minParams is not reached yet
testSuggestions('from a | eval a=cidr_match( ', [
...getFieldNamesByType('ip').map((v) => `${v},`),
...getFieldNamesByType('ip').map((v) => `${v}, `),
...getFunctionSignaturesByReturnType('eval', 'ip', { scalar: true }, undefined, [
'cidr_match',
]).map((v) => ({ ...v, text: `${v.text},` })),
@ -749,7 +771,7 @@ describe('autocomplete', () => {
'from a | eval var0 = abs(doubleField) | eval abs(var0)',
[
',',
'|',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [
'double',
]),
@ -807,13 +829,13 @@ describe('autocomplete', () => {
if (!requiresMoreArgs || s === '' || (typeof s === 'object' && s.text === '')) {
return s;
}
return typeof s === 'string' ? `${s},` : { ...s, text: `${s.text},` };
return typeof s === 'string' ? `${s}, ` : { ...s, text: `${s.text},` };
};
testSuggestions(
`from a | eval ${fn.name}(${Array(i).fill('field').join(', ')}${i ? ',' : ''} )`,
suggestedConstants?.length
? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ',' : ''}`)
? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ', ' : ''}`)
: [
...getDateLiteralsByFieldType(getTypesFromParamDefs(acceptsFieldParamDefs)),
...getFieldNamesByType(getTypesFromParamDefs(acceptsFieldParamDefs)),
@ -833,7 +855,7 @@ describe('autocomplete', () => {
i ? ',' : ''
} )`,
suggestedConstants?.length
? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ',' : ''}`)
? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ', ' : ''}`)
: [
...getDateLiteralsByFieldType(getTypesFromParamDefs(acceptsFieldParamDefs)),
...getFieldNamesByType(getTypesFromParamDefs(acceptsFieldParamDefs)),
@ -865,7 +887,7 @@ describe('autocomplete', () => {
testSuggestions(
`from a | eval ${fn.name}(`,
suggestedConstants?.length
? [...suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ',' : ''}`)]
? [...suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ', ' : ''}`)]
: []
);
}
@ -881,7 +903,7 @@ describe('autocomplete', () => {
[
...dateSuggestions,
',',
'|',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [
'integer',
]),
@ -890,12 +912,12 @@ describe('autocomplete', () => {
);
testSuggestions('from a | eval a = 1 year ', [
',',
'|',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [
'time_interval',
]),
]);
testSuggestions('from a | eval a = 1 day + 2 ', [',', '|']);
testSuggestions('from a | eval a = 1 day + 2 ', [',', '| ']);
testSuggestions(
'from a | eval 1 day + 2 ',
[
@ -908,19 +930,19 @@ describe('autocomplete', () => {
);
testSuggestions(
'from a | eval var0=date_trunc()',
[...getLiteralsByType('time_literal').map((t) => `${t},`)],
getLiteralsByType('time_literal').map((t) => `${t}, `),
'('
);
testSuggestions(
'from a | eval var0=date_trunc(2 )',
[...dateSuggestions.map((t) => `${t},`), ','],
[...dateSuggestions.map((t) => `${t}, `), ','],
' '
);
});
});
describe('values suggestions', () => {
testSuggestions('FROM "a"', ['a', 'b'], undefined, 7, [
testSuggestions('FROM "a"', ['a ', 'b '], undefined, 7, [
,
[
{ name: 'a', hidden: false },
@ -975,50 +997,6 @@ describe('autocomplete', () => {
});
});
describe('auto triggers', () => {
function getSuggestionsFor(statement: string) {
const callbackMocks = createCustomCallbackMocks(undefined, undefined, undefined);
const triggerOffset = statement.lastIndexOf(' ') + 1; // drop <here>
const context = createCompletionContext(statement[triggerOffset]);
return suggest(
statement,
triggerOffset + 1,
context,
async (text) => (text ? getAstAndSyntaxErrors(text) : { ast: [], errors: [] }),
callbackMocks
);
}
it('should trigger further suggestions for functions', async () => {
const suggestions = await getSuggestionsFor('from a | eval ');
// test that all functions will retrigger suggestions
expect(
suggestions
.filter(({ kind }) => kind === 'Function')
.every(({ command }) => command === TRIGGER_SUGGESTION_COMMAND)
).toBeTruthy();
// now test that non-function won't retrigger
expect(
suggestions
.filter(({ kind }) => kind !== 'Function')
.every(({ command }) => command == null)
).toBeTruthy();
});
it('should trigger further suggestions for commands', async () => {
const suggestions = await getSuggestionsFor('from a | ');
// test that all commands will retrigger suggestions
expect(
suggestions.every(({ command }) => command === TRIGGER_SUGGESTION_COMMAND)
).toBeTruthy();
});
it('should trigger further suggestions after enrich mode', async () => {
const suggestions = await getSuggestionsFor('from a | enrich _any:');
// test that all commands will retrigger suggestions
expect(
suggestions.every(({ command }) => command === TRIGGER_SUGGESTION_COMMAND)
).toBeTruthy();
});
});
/**
* Monaco asks for suggestions in at least two different scenarios.
* 1. When the user types a non-whitespace character (e.g. 'FROM k') - this is the Invoke trigger kind
@ -1049,24 +1027,47 @@ describe('autocomplete', () => {
10
);
// function argument
testSuggestions(
'FROM kibana_sample_data_logs | EVAL TRIM(e)',
[
...getFunctionSignaturesByReturnType(
'eval',
['text', 'keyword'],
{ scalar: true },
undefined,
['trim']
),
],
undefined,
42
);
describe('function arguments', () => {
// function argument
testSuggestions(
'FROM kibana_sample_data_logs | EVAL TRIM(e)',
[
...getFieldNamesByType(['text', 'keyword']),
...getFunctionSignaturesByReturnType(
'eval',
['text', 'keyword'],
{ scalar: true },
undefined,
['trim']
),
],
undefined,
42
);
// subsequent function argument
const expectedDateDiff2ndArgSuggestions = [
TIME_PICKER_SUGGESTION,
...TIME_SYSTEM_PARAMS.map((t) => `${t}, `),
...getFieldNamesByType('date').map((name) => `${name}, `),
...getFunctionSignaturesByReturnType('eval', 'date', { scalar: true }).map((s) => ({
...s,
text: `${s.text},`,
})),
];
testSuggestions(
'FROM a | EVAL DATE_DIFF("day", )',
expectedDateDiff2ndArgSuggestions,
undefined,
31
);
// trigger character case for comparison
testSuggestions('FROM a | EVAL DATE_DIFF("day", )', expectedDateDiff2ndArgSuggestions, ' ');
});
// FROM source
testSuggestions('FROM k', ['index1', 'index2'], undefined, 6, [
testSuggestions('FROM k', ['index1 ', 'index2 '], undefined, 6, [
,
[
{ name: 'index1', hidden: false },
@ -1075,7 +1076,7 @@ describe('autocomplete', () => {
]);
// FROM source METADATA
testSuggestions('FROM index1 M', [',', 'METADATA $0', '|'], undefined, 13);
testSuggestions('FROM index1 M', [',', 'METADATA $0', '| '], undefined, 13);
// FROM source METADATA field
testSuggestions('FROM index1 METADATA _', METADATA_FIELDS, undefined, 22);
@ -1084,7 +1085,7 @@ describe('autocomplete', () => {
testSuggestions(
'FROM index1 | EVAL b',
[
'var0 =',
'var0 = ',
...getFieldNamesByType('any'),
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
],
@ -1117,7 +1118,7 @@ describe('autocomplete', () => {
);
// ENRICH policy ON
testSuggestions('FROM index1 | ENRICH policy O', ['ON $0', 'WITH $0', '|'], undefined, 29);
testSuggestions('FROM index1 | ENRICH policy O', ['ON $0', 'WITH $0', '| '], undefined, 29);
// ENRICH policy ON field
testSuggestions('FROM index1 | ENRICH policy ON f', getFieldNamesByType('any'), undefined, 32);
@ -1125,14 +1126,14 @@ describe('autocomplete', () => {
// ENRICH policy WITH policyfield
testSuggestions(
'FROM index1 | ENRICH policy WITH v',
['var0 =', ...getPolicyFields('policy')],
['var0 = ', ...getPolicyFields('policy')],
undefined,
34
);
testSuggestions(
'FROM index1 | ENRICH policy WITH \tv',
['var0 =', ...getPolicyFields('policy')],
['var0 = ', ...getPolicyFields('policy')],
undefined,
34
);
@ -1154,7 +1155,7 @@ describe('autocomplete', () => {
// LIMIT argument
// Here we actually test that the invoke trigger kind does not work
// because it isn't very useful to see literal suggestions when typing a number
testSuggestions('FROM a | LIMIT 1', ['|'], undefined, 16);
testSuggestions('FROM a | LIMIT 1', ['| '], undefined, 16);
// MV_EXPAND field
testSuggestions('FROM index1 | MV_EXPAND f', getFieldNamesByType('any'), undefined, 25);
@ -1173,19 +1174,24 @@ describe('autocomplete', () => {
'FROM index1 | SORT f',
[
...getFunctionSignaturesByReturnType('sort', 'any', { scalar: true }),
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((field) => `${field} `),
],
undefined,
20
);
// SORT field order
testSuggestions('FROM index1 | SORT stringField a', ['ASC', 'DESC', ',', '|'], undefined, 32);
testSuggestions(
'FROM index1 | SORT stringField a',
['ASC ', 'DESC ', ',', '| '],
undefined,
32
);
// SORT field order nulls
testSuggestions(
'FROM index1 | SORT stringField ASC n',
['NULLS FIRST', 'NULLS LAST', ',', '|'],
['NULLS FIRST ', 'NULLS LAST ', ',', '| '],
undefined,
36
);
@ -1193,21 +1199,24 @@ describe('autocomplete', () => {
// STATS argument
testSuggestions(
'FROM index1 | STATS f',
['var0 =', ...getFunctionSignaturesByReturnType('stats', 'any', { scalar: true, agg: true })],
[
'var0 = ',
...getFunctionSignaturesByReturnType('stats', 'any', { scalar: true, agg: true }),
],
undefined,
21
);
// STATS argument BY
testSuggestions('FROM index1 | STATS AVG(booleanField) B', ['BY $0', ',', '|'], undefined, 39);
testSuggestions('FROM index1 | STATS AVG(booleanField) B', ['BY $0', ',', '| '], undefined, 39);
// STATS argument BY expression
testSuggestions(
'FROM index1 | STATS field BY f',
[
'var0 =',
'var0 = ',
...getFunctionSignaturesByReturnType('stats', 'any', { grouping: true, scalar: true }),
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((field) => `${field} `),
],
undefined,
30
@ -1217,7 +1226,7 @@ describe('autocomplete', () => {
testSuggestions(
'FROM index1 | WHERE f',
[
...getFieldNamesByType('any'),
...getFieldNamesByType('any').map((field) => `${field} `),
...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }),
],
undefined,
@ -1239,4 +1248,264 @@ describe('autocomplete', () => {
33
);
});
describe('advancing the cursor and opening the suggestion menu automatically ✨', () => {
const attachTriggerCommand = (
s: string | PartialSuggestionWithText
): PartialSuggestionWithText =>
typeof s === 'string'
? {
text: s,
command: TRIGGER_SUGGESTION_COMMAND,
}
: { ...s, command: TRIGGER_SUGGESTION_COMMAND };
const attachAsSnippet = (s: PartialSuggestionWithText): PartialSuggestionWithText => ({
...s,
asSnippet: true,
});
// Source command
testSuggestions(
'F',
['FROM $0', 'ROW $0', 'SHOW $0'].map(attachTriggerCommand).map(attachAsSnippet),
undefined,
1
);
// Pipe command
testSuggestions(
'FROM a | E',
commandDefinitions
.filter(({ name }) => !sourceCommands.includes(name))
.map(({ name }) => attachTriggerCommand(name.toUpperCase() + ' $0'))
.map(attachAsSnippet), // TODO consider making this check more fundamental
undefined,
10
);
describe('function arguments', () => {
// literalSuggestions parameter
const dateDiffFirstParamSuggestions =
evalFunctionDefinitions.find(({ name }) => name === 'date_diff')?.signatures[0].params?.[0]
.literalSuggestions ?? [];
testSuggestions(
'FROM a | EVAL DATE_DIFF()',
dateDiffFirstParamSuggestions.map((s) => `"${s}", `).map(attachTriggerCommand),
undefined,
24
);
// field parameter
const expectedStringSuggestionsWhenMoreArgsAreNeeded = [
...getFieldNamesByType('keyword')
.map((field) => `${field}, `)
.map(attachTriggerCommand),
...getFunctionSignaturesByReturnType('eval', 'keyword', { scalar: true }, undefined, [
'replace',
]).map((s) => ({
...s,
text: `${s.text},`,
})),
];
testSuggestions(
'FROM a | EVAL REPLACE()',
expectedStringSuggestionsWhenMoreArgsAreNeeded,
undefined,
22
);
// subsequent parameter
testSuggestions(
'FROM a | EVAL REPLACE(stringField, )',
expectedStringSuggestionsWhenMoreArgsAreNeeded,
undefined,
35
);
// final parameter — should not advance!
testSuggestions(
'FROM a | EVAL REPLACE(stringField, stringField, )',
[
...getFieldNamesByType('keyword').map((field) => ({ text: field, command: undefined })),
...getFunctionSignaturesByReturnType('eval', 'keyword', { scalar: true }, undefined, [
'replace',
]),
],
undefined,
48
);
// Trigger character because this is how it will actually be... the user will press
// space-bar... this may change if we fix the tokenization of timespan literals
// such that "2 days" is a single monaco token
testSuggestions(
'FROM a | EVAL DATE_TRUNC(2 )',
[...timeUnitsToSuggest.map((s) => `${s.name}, `).map(attachTriggerCommand), ','],
' '
);
});
// PIPE (|)
testSuggestions(
'FROM a ',
[attachTriggerCommand('| '), ',', attachAsSnippet(attachTriggerCommand('METADATA $0'))],
undefined,
7
);
// Assignment
testSuggestions(`FROM a | ENRICH policy on b with `, [
attachTriggerCommand('var0 = '),
...getPolicyFields('policy'),
]);
// FROM source
//
// Using an Invoke trigger kind here because that's what Monaco uses when the show suggestions
// action is triggered (e.g. accepting the "FROM" suggestion)
testSuggestions(
'FROM ',
[
{ text: 'index1 ', command: TRIGGER_SUGGESTION_COMMAND },
{ text: 'index2 ', command: TRIGGER_SUGGESTION_COMMAND },
],
undefined,
5,
[
,
[
{ name: 'index1', hidden: false },
{ name: 'index2', hidden: false },
],
]
);
// FROM source METADATA
testSuggestions(
'FROM index1 M',
[',', attachAsSnippet(attachTriggerCommand('METADATA $0')), '| '],
undefined,
13
);
// LIMIT number
testSuggestions('FROM a | LIMIT ', ['10 ', '100 ', '1000 '].map(attachTriggerCommand));
// SORT field
testSuggestions(
'FROM a | SORT ',
[
...getFieldNamesByType('any').map((field) => `${field} `),
...getFunctionSignaturesByReturnType('sort', 'any', { scalar: true }),
].map(attachTriggerCommand),
undefined,
14
);
// SORT field order
testSuggestions(
'FROM a | SORT field ',
[',', ...['ASC ', 'DESC ', '| '].map(attachTriggerCommand)],
undefined,
20
);
// SORT field order nulls
testSuggestions(
'FROM a | SORT field ASC ',
[',', ...['NULLS FIRST ', 'NULLS LAST ', '| '].map(attachTriggerCommand)],
undefined,
24
);
// STATS argument
testSuggestions(
'FROM a | STATS ',
[
'var0 = ',
...getFunctionSignaturesByReturnType('stats', 'any', { scalar: true, agg: true }).map(
attachAsSnippet
),
].map(attachTriggerCommand),
undefined,
15
);
// STATS argument BY
testSuggestions(
'FROM a | STATS AVG(numberField) ',
[',', attachAsSnippet(attachTriggerCommand('BY $0')), attachTriggerCommand('| ')],
undefined,
32
);
// STATS argument BY field
const allByCompatibleFunctions = getFunctionSignaturesByReturnType(
'stats',
'any',
{
scalar: true,
grouping: true,
},
undefined,
undefined,
'by'
);
testSuggestions(
'FROM a | STATS AVG(numberField) BY ',
[
attachTriggerCommand('var0 = '),
...getFieldNamesByType('any')
.map((field) => `${field} `)
.map(attachTriggerCommand),
...allByCompatibleFunctions,
],
undefined,
35
);
// STATS argument BY assignment (checking field suggestions)
testSuggestions(
'FROM a | STATS AVG(numberField) BY var0 = ',
[
...getFieldNamesByType('any')
.map((field) => `${field} `)
.map(attachTriggerCommand),
...allByCompatibleFunctions,
],
undefined,
41
);
// WHERE argument (field suggestions)
testSuggestions(
'FROM a | WHERE ',
[
...getFieldNamesByType('any')
.map((field) => `${field} `)
.map(attachTriggerCommand),
...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }).map(attachAsSnippet),
],
undefined,
15
);
// WHERE argument comparison
testSuggestions(
'FROM a | WHERE stringField ',
getFunctionSignaturesByReturnType(
'where',
'boolean',
{
builtin: true,
},
['string']
).map((s) => (s.text.toLowerCase().includes('null') ? s : attachTriggerCommand(s))),
undefined,
27
);
});
});

View file

@ -13,10 +13,11 @@ import type {
ESQLCommand,
ESQLCommandOption,
ESQLFunction,
ESQLLiteral,
ESQLSingleAstItem,
} from '@kbn/esql-ast';
import { partition } from 'lodash';
import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types';
import { ESQL_NUMBER_TYPES, compareTypesWithLiterals, isNumericType } from '../shared/esql_types';
import type { EditorContext, SuggestionRawDefinition } from './types';
import {
lookupColumn,
@ -44,6 +45,7 @@ import {
nonNullable,
getColumnExists,
findPreviousWord,
noCaseCompare,
} from '../shared/helpers';
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
@ -93,7 +95,7 @@ import {
isAggFunctionUsedAlready,
removeQuoteForSuggestedSources,
} from './helper';
import { FunctionParameter } from '../definitions/types';
import { FunctionParameter, FunctionReturnType, SupportedFieldType } from '../definitions/types';
type GetSourceFn = () => Promise<SuggestionRawDefinition[]>;
type GetDataStreamsForIntegrationFn = (
@ -101,7 +103,8 @@ type GetDataStreamsForIntegrationFn = (
) => Promise<Array<{ name: string; title?: string }> | undefined>;
type GetFieldsByTypeFn = (
type: string | string[],
ignored?: string[]
ignored?: string[],
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
) => Promise<SuggestionRawDefinition[]>;
type GetFieldsMapFn = () => Promise<Map<string, ESQLRealField>>;
type GetPoliciesFn = () => Promise<SuggestionRawDefinition[]>;
@ -185,8 +188,8 @@ function correctQuerySyntax(_query: string, context: EditorContext) {
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
(context.triggerKind === 0 && unclosedRoundBrackets === 0) ||
(context.triggerCharacter === ' ' &&
(isMathFunction(query, query.length) || isComma(query.trimEnd()[query.trimEnd().length - 1])))
(context.triggerCharacter === ' ' && isMathFunction(query, query.length)) ||
isComma(query.trimEnd()[query.trimEnd().length - 1])
) {
query += EDITOR_MARKER;
}
@ -308,12 +311,19 @@ export async function suggest(
return [];
}
function getFieldsByTypeRetriever(queryString: string, resourceRetriever?: ESQLCallbacks) {
function getFieldsByTypeRetriever(
queryString: string,
resourceRetriever?: ESQLCallbacks
): { getFieldsByType: GetFieldsByTypeFn; getFieldsMap: GetFieldsMapFn } {
const helpers = getFieldsByTypeHelper(queryString, resourceRetriever);
return {
getFieldsByType: async (expectedType: string | string[] = 'any', ignored: string[] = []) => {
getFieldsByType: async (
expectedType: string | string[] = 'any',
ignored: string[] = [],
options
) => {
const fields = await helpers.getFieldsByType(expectedType, ignored);
return buildFieldsDefinitionsWithMetadata(fields);
return buildFieldsDefinitionsWithMetadata(fields, options);
},
getFieldsMap: helpers.getFieldsMap,
};
@ -429,7 +439,13 @@ function areCurrentArgsValid(
function extractFinalTypeFromArg(
arg: ESQLAstItem,
references: Pick<ReferenceMaps, 'fields' | 'variables'>
): string | undefined {
):
| ESQLLiteral['literalType']
| SupportedFieldType
| FunctionReturnType
| 'timeInterval'
| string // @TODO remove this
| undefined {
if (Array.isArray(arg)) {
return extractFinalTypeFromArg(arg[0], references);
}
@ -792,7 +808,11 @@ async function getExpressionSuggestionsByType(
// if the definition includes a list of constants, suggest them
if (argDef.values) {
// ... | <COMMAND> ... <suggest enums>
suggestions.push(...buildConstantsDefinitions(argDef.values));
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 (
@ -815,6 +835,7 @@ async function getExpressionSuggestionsByType(
// ... | <COMMAND> <suggest>
// In this case start suggesting something not strictly based on type
suggestions.push(
...(await getFieldsByType('any', [], { advanceCursorAndOpenSuggestions: true })),
...(await getFieldsOrFunctionsSuggestions(
['any'],
command.name,
@ -822,7 +843,7 @@ async function getExpressionSuggestionsByType(
getFieldsByType,
{
functions: true,
fields: true,
fields: false,
variables: anyVariables,
}
))
@ -1087,7 +1108,11 @@ async function getFieldsOrFunctionsSuggestions(
} = {}
): Promise<SuggestionRawDefinition[]> {
const filteredFieldsByType = pushItUpInTheList(
(await (fields ? getFieldsByType(types, ignoreFields) : [])) as SuggestionRawDefinition[],
(await (fields
? getFieldsByType(types, ignoreFields, {
advanceCursorAndOpenSuggestions: commandName === 'sort',
})
: [])) as SuggestionRawDefinition[],
functions
);
@ -1187,6 +1212,8 @@ async function getFunctionArgsSuggestions(
? refSignature.minParams - 1 > argIndex
: false);
const shouldAddComma = hasMoreMandatoryArgs && fnDefinition.type !== 'builtin';
const suggestedConstants = Array.from(
new Set(
fnDefinition.signatures.reduce<string[]>((acc, signature) => {
@ -1207,13 +1234,13 @@ async function getFunctionArgsSuggestions(
);
if (suggestedConstants.length) {
return buildValueDefinitions(suggestedConstants).map((suggestion) => ({
...suggestion,
text: addCommaIf(hasMoreMandatoryArgs && fnDefinition.type !== 'builtin', suggestion.text),
}));
return buildValueDefinitions(suggestedConstants, {
addComma: hasMoreMandatoryArgs,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
});
}
const suggestions = [];
const suggestions: SuggestionRawDefinition[] = [];
const noArgDefined = !arg;
const isUnknownColumn =
arg &&
@ -1267,7 +1294,9 @@ async function getFunctionArgsSuggestions(
// if existing arguments are preset already, use them to filter out incompatible signatures
.filter((signature) => {
if (existingTypes.length) {
return existingTypes.every((type, index) => signature.params[index].type === type);
return existingTypes.every((type, index) =>
compareTypesWithLiterals(signature.params[index].type, type)
);
}
return true;
});
@ -1299,28 +1328,51 @@ async function getFunctionArgsSuggestions(
return Array.from(new Set(paramDefs.map(({ type }) => type)));
};
// Literals
suggestions.push(
...getCompatibleLiterals(command.name, getTypesFromParamDefs(constantOnlyParamDefs))
...getCompatibleLiterals(
command.name,
getTypesFromParamDefs(constantOnlyParamDefs),
undefined,
{ addComma: shouldAddComma, advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs }
)
);
// Fields
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
getTypesFromParamDefs(paramDefsWhichSupportFields),
...pushItUpInTheList(
await getFieldsByType(getTypesFromParamDefs(paramDefsWhichSupportFields), [], {
addComma: shouldAddComma,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
}),
true
)
);
// Functions
suggestions.push(
...getCompatibleFunctionDefinition(
command.name,
option?.name,
getFieldsByType,
{
functions: true,
fields: true,
variables: variablesExcludingCurrentCommandOnes,
},
// do not repropose the same function as arg
// i.e. avoid cases like abs(abs(abs(...))) with suggestions
{
ignoreFn: fnToIgnore,
}
))
getTypesFromParamDefs(paramDefsWhichSupportFields),
fnToIgnore
).map((suggestion) => ({
...suggestion,
text: addCommaIf(shouldAddComma, suggestion.text),
}))
);
// could also be in stats (bucket) but our autocomplete is not great yet
if (
getTypesFromParamDefs(paramDefsWhichSupportFields).includes('date') &&
['where', 'eval'].includes(command.name)
)
suggestions.push(
...getDateLiterals({
addComma: shouldAddComma,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
})
);
}
// for eval and row commands try also to complete numeric literals with time intervals where possible
@ -1329,18 +1381,10 @@ async function getFunctionArgsSuggestions(
if (isLiteralItem(arg) && isNumericType(arg.literalType)) {
// ... | EVAL fn(2 <suggest>)
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['time_literal_unit'],
command.name,
option?.name,
getFieldsByType,
{
functions: false,
fields: false,
variables: variablesExcludingCurrentCommandOnes,
literals: true,
}
))
...getCompatibleLiterals(command.name, ['time_literal_unit'], undefined, {
addComma: shouldAddComma,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
})
);
}
}
@ -1349,24 +1393,9 @@ async function getFunctionArgsSuggestions(
// suggest a comma if there's another argument for the function
suggestions.push(commaCompleteItem);
}
// if there are other arguments in the function, inject automatically a comma after each suggestion
return suggestions.map((suggestion) =>
suggestion !== commaCompleteItem
? {
...suggestion,
text:
hasMoreMandatoryArgs && fnDefinition.type !== 'builtin'
? `${suggestion.text},`
: suggestion.text,
}
: suggestion
);
}
return suggestions.map(({ text, ...rest }) => ({
...rest,
text: addCommaIf(hasMoreMandatoryArgs && fnDefinition.type !== 'builtin' && text !== '', text),
}));
return suggestions;
}
async function getListArgsSuggestions(
@ -1521,7 +1550,7 @@ async function getOptionArgsSuggestions(
innerText
);
if (isNewExpression || findPreviousWord(innerText) === 'WITH') {
if (isNewExpression || noCaseCompare(findPreviousWord(innerText), 'WITH')) {
suggestions.push(buildNewVarDefinition(findNewVariable(anyEnhancedVariables)));
}
@ -1595,19 +1624,6 @@ async function getOptionArgsSuggestions(
}
if (command.name === 'stats') {
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['column'],
command.name,
option.name,
getFieldsByType,
{
functions: false,
fields: true,
}
))
);
const argDef = optionDef?.signature.params[argIndex];
const nodeArgType = extractFinalTypeFromArg(nodeArg, references);
@ -1667,20 +1683,27 @@ async function getOptionArgsSuggestions(
})
);
} else if (isNewExpression || (isAssignment(nodeArg) && !isAssignmentComplete(nodeArg))) {
// Otherwise try to complete the expression suggesting some columns
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
types[0] === 'column' ? ['any'] : types,
command.name,
option.name,
getFieldsByType,
{
functions: option.name === 'by',
fields: true,
}
))
...(await getFieldsByType(types[0] === 'column' ? ['any'] : types, [], {
advanceCursorAndOpenSuggestions: true,
}))
);
if (option.name === 'by') {
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
types[0] === 'column' ? ['any'] : types,
command.name,
option.name,
getFieldsByType,
{
functions: true,
fields: false,
}
))
);
}
if (command.name === 'stats' && isNewExpression) {
suggestions.push(buildNewVarDefinition(findNewVariable(anyVariables)));
}

View file

@ -98,13 +98,16 @@ function buildCharCompleteItem(
sortText,
};
}
export const pipeCompleteItem = buildCharCompleteItem(
'|',
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.pipeDoc', {
export const pipeCompleteItem: SuggestionRawDefinition = {
label: '|',
text: '| ',
kind: 'Keyword',
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.pipeDoc', {
defaultMessage: 'Pipe (|)',
}),
{ sortText: 'C', quoted: false }
);
sortText: 'C',
command: TRIGGER_SUGGESTION_COMMAND,
};
export const commaCompleteItem = buildCharCompleteItem(
',',

View file

@ -130,7 +130,8 @@ export function getSuggestionCommandDefinition(
}
export const buildFieldsDefinitionsWithMetadata = (
fields: ESQLRealField[]
fields: ESQLRealField[],
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
): SuggestionRawDefinition[] => {
return fields.map((field) => {
const description = field.metadata?.description;
@ -138,7 +139,10 @@ export const buildFieldsDefinitionsWithMetadata = (
const titleCaseType = field.type.charAt(0).toUpperCase() + field.type.slice(1);
return {
label: field.name,
text: getSafeInsertText(field.name),
text:
getSafeInsertText(field.name) +
(options?.addComma ? ',' : '') +
(options?.advanceCursorAndOpenSuggestions ? ' ' : ''),
kind: 'Variable',
detail: titleCaseType,
documentation: description
@ -151,6 +155,7 @@ ${description}`,
: undefined,
// If there is a description, it is a field from ECS, so it should be sorted to the top
sortText: description ? '1D' : 'D',
command: options?.advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
};
});
};
@ -185,9 +190,8 @@ export const buildSourcesDefinitions = (
): SuggestionRawDefinition[] =>
sources.map(({ name, isIntegration, title }) => ({
label: title ?? name,
text: getSafeInsertSourceText(name),
text: getSafeInsertSourceText(name) + (!isIntegration ? ' ' : ''),
isSnippet: isIntegration,
...(isIntegration && { command: TRIGGER_SUGGESTION_COMMAND }),
kind: isIntegration ? 'Class' : 'Issue',
detail: isIntegration
? i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.integrationDefinition', {
@ -197,16 +201,24 @@ export const buildSourcesDefinitions = (
defaultMessage: `Index`,
}),
sortText: 'A',
command: TRIGGER_SUGGESTION_COMMAND,
}));
export const buildConstantsDefinitions = (
userConstants: string[],
detail?: string,
sortText?: string
sortText?: string,
/**
* Whether or not to advance the cursor and open the suggestions dialog after inserting the constant.
*/
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
): SuggestionRawDefinition[] =>
userConstants.map((label) => ({
label,
text: label,
text:
label +
(options?.addComma ? ',' : '') +
(options?.advanceCursorAndOpenSuggestions ? ' ' : ''),
kind: 'Constant',
detail:
detail ??
@ -214,32 +226,35 @@ export const buildConstantsDefinitions = (
defaultMessage: `Constant`,
}),
sortText: sortText ?? 'A',
command: options?.advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
}));
export const buildValueDefinitions = (
values: string[],
detail?: string
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
): SuggestionRawDefinition[] =>
values.map((value) => ({
label: `"${value}"`,
text: `"${value}"`,
detail:
detail ??
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.valueDefinition', {
defaultMessage: 'Literal value',
}),
text: `"${value}"${options?.addComma ? ',' : ''}${
options?.advanceCursorAndOpenSuggestions ? ' ' : ''
}`,
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.valueDefinition', {
defaultMessage: 'Literal value',
}),
kind: 'Value',
command: options?.advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
}));
export const buildNewVarDefinition = (label: string): SuggestionRawDefinition => {
return {
label,
text: `${label} =`,
text: `${label} = `,
kind: 'Variable',
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.newVarDoc', {
defaultMessage: 'Define a new variable',
}),
sortText: '1',
command: TRIGGER_SUGGESTION_COMMAND,
};
};
@ -358,21 +373,39 @@ export function getUnitDuration(unit: number = 1) {
* "magical" logic. Maybe this is really the same thing as the literalOptions parameter
* definition property...
*/
export function getCompatibleLiterals(commandName: string, types: string[], names?: string[]) {
export function getCompatibleLiterals(
commandName: string,
types: string[],
names?: string[],
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
) {
const suggestions: SuggestionRawDefinition[] = [];
if (types.some(isNumericType)) {
if (commandName === 'limit') {
// suggest 10/100/1000 for limit
suggestions.push(...buildConstantsDefinitions(['10', '100', '1000'], ''));
suggestions.push(
...buildConstantsDefinitions(['10', '100', '1000'], '', undefined, {
advanceCursorAndOpenSuggestions: true,
})
);
}
}
if (types.includes('time_literal')) {
// filter plural for now and suggest only unit + singular
suggestions.push(...buildConstantsDefinitions(getUnitDuration(1))); // i.e. 1 year
suggestions.push(
...buildConstantsDefinitions(getUnitDuration(1), undefined, undefined, options)
); // i.e. 1 year
}
// this is a special type built from the suggestion system, not inherited from the AST
if (types.includes('time_literal_unit')) {
suggestions.push(...buildConstantsDefinitions(timeUnitsToSuggest.map(({ name }) => name))); // i.e. year, month, ...
suggestions.push(
...buildConstantsDefinitions(
timeUnitsToSuggest.map(({ name }) => name),
undefined,
undefined,
options
)
); // i.e. year, month, ...
}
if (types.includes('string')) {
if (names) {
@ -383,25 +416,31 @@ export function getCompatibleLiterals(commandName: string, types: string[], name
[commandName === 'grok' ? '"%{WORD:firstWord}"' : '"%{firstWord}"'],
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.aPatternString', {
defaultMessage: 'A pattern string',
})
}),
undefined,
options
)
);
} else {
suggestions.push(...buildConstantsDefinitions(['string'], ''));
suggestions.push(...buildConstantsDefinitions(['string'], '', undefined, options));
}
}
}
return suggestions;
}
export function getDateLiterals() {
export function getDateLiterals(options?: {
advanceCursorAndOpenSuggestions?: boolean;
addComma?: boolean;
}) {
return [
...buildConstantsDefinitions(
TIME_SYSTEM_PARAMS,
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.namedParamDefinition', {
defaultMessage: 'Named parameter',
}),
'1A'
'1A',
options
),
{
label: i18n.translate(

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { ESQLDecimalLiteral, ESQLNumericLiteralType } from '@kbn/esql-ast/src/types';
import { ESQLDecimalLiteral, ESQLLiteral, ESQLNumericLiteralType } from '@kbn/esql-ast/src/types';
import { FunctionParameterType } from '../definitions/types';
export const ESQL_COMMON_NUMERIC_TYPES = ['double', 'long', 'integer'] as const;
export const ESQL_NUMERIC_DECIMAL_TYPES = [
@ -47,3 +48,29 @@ export function isNumericDecimalType(type: unknown): type is ESQLDecimalLiteral
ESQL_NUMERIC_DECIMAL_TYPES.includes(type as (typeof ESQL_NUMERIC_DECIMAL_TYPES)[number])
);
}
/**
* Compares two types, taking into account literal types
* @TODO strengthen typing here (remove `string`)
*/
export const compareTypesWithLiterals = (
a: ESQLLiteral['literalType'] | FunctionParameterType | string,
b: ESQLLiteral['literalType'] | FunctionParameterType | string
) => {
if (a === b) {
return true;
}
if (a === 'decimal') {
return isNumericDecimalType(b);
}
if (b === 'decimal') {
return isNumericDecimalType(a);
}
if (a === 'string') {
return isStringType(b);
}
if (b === 'string') {
return isStringType(a);
}
return false;
};

View file

@ -645,3 +645,8 @@ export const isParam = (x: unknown): x is ESQLParamLiteral =>
typeof x === 'object' &&
(x as ESQLParamLiteral).type === 'literal' &&
(x as ESQLParamLiteral).literalType === 'param';
/**
* Compares two strings in a case-insensitive manner
*/
export const noCaseCompare = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();

View file

@ -763,8 +763,18 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
forceMoveMarkers: true,
},
]);
setPopoverPosition({});
datePickerOpenStatusRef.current = false;
// move the cursor past the date we just inserted
editor1.current?.setPosition({
lineNumber: currentCursorPosition?.lineNumber ?? 0,
column: (currentCursorPosition?.column ?? 0) + addition.length - 1,
});
// restore focus to the editor
editor1.current?.focus();
}
}}
inline