[ES|QL] Stop autocomplete on non-existent function (#175675)

## Summary

This PR fixes a scenario where the user types a non-existent function
into the editor:
* before a JS error was logged into the console due to a forced case
into `isFunctionArgComplete` function
* before eval functions were suggested by default in this case, now the
service just stops with an early exit.



![fix_function_autocomplete](d551b470-fa5b-4cf1-b688-7b4ac6f75515)


### 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:
Marco Liberati 2024-01-26 16:01:12 +01:00 committed by GitHub
parent 63c3ef4808
commit 3f92578e61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 103 deletions

View file

@ -625,6 +625,19 @@ describe('autocomplete', () => {
],
'('
);
testSuggestions(
'from a | eval a=raund()', // note the typo in round
[],
'('
);
testSuggestions(
'from a | eval a=raund(', // note the typo in round
[]
);
testSuggestions(
'from a | eval raund(', // note the typo in round
[]
);
testSuggestions('from a | eval a=round(numberField) ', [
...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true }, ['number']),
'|',

View file

@ -48,7 +48,6 @@ import {
commandAutocompleteDefinitions,
getAssignmentDefinitionCompletitionItem,
getBuiltinCompatibleFunctionDefinition,
mathCommandDefinition,
pipeCompleteItem,
} from './complete_items';
import {
@ -402,7 +401,10 @@ function isFunctionArgComplete(
arg: ESQLFunction,
references: Pick<ReferenceMaps, 'fields' | 'variables'>
) {
const fnDefinition = getFunctionDefinition(arg.name)!;
const fnDefinition = getFunctionDefinition(arg.name);
if (!fnDefinition) {
return { complete: false };
}
const cleanedArgs = removeMarkerArgFromArgsList(arg)!.args;
const argLengthCheck = fnDefinition.signatures.some((def) => {
if (def.infiniteParams && cleanedArgs.length > 0) {
@ -478,6 +480,11 @@ async function getExpressionSuggestionsByType(
// * or after a comma => i.e. STATS fieldA, <here>
const isNewExpression = isRestartingExpression(innerText) || argIndex === 0;
// 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[]
@ -925,108 +932,107 @@ async function getFunctionArgsSuggestions(
getPolicyMetadata: GetPolicyMetadataFn
): Promise<AutocompleteCommandDefinition[]> {
const fnDefinition = getFunctionDefinition(node.name);
if (fnDefinition) {
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
const variablesExcludingCurrentCommandOnes = excludeVariablesFromCurrentCommand(
commands,
command,
fieldsMap
);
// pick the type of the next arg
const shouldGetNextArgument = node.text.includes(EDITOR_MARKER);
let argIndex = Math.max(node.args.length, 0);
if (!shouldGetNextArgument && argIndex) {
argIndex -= 1;
}
const types = fnDefinition.signatures.flatMap((signature) => {
if (signature.params.length > argIndex) {
return signature.params[argIndex].type;
}
if (signature.infiniteParams) {
return signature.params[0].type;
}
return [];
});
const arg = node.args[argIndex];
const hasMoreMandatoryArgs =
fnDefinition.signatures[0].params.filter(
({ optional }, index) => !optional && index > argIndex
).length > argIndex;
const suggestions = [];
const noArgDefined = !arg;
const isUnknownColumn =
arg &&
isColumnItem(arg) &&
!columnExists(arg, { fields: fieldsMap, variables: variablesExcludingCurrentCommandOnes })
.hit;
if (noArgDefined || isUnknownColumn) {
// ... | EVAL fn( <suggest>)
// ... | EVAL fn( field, <suggest>)
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
types,
command.name,
option?.name,
getFieldsByType,
{
functions: command.name !== 'stats',
fields: true,
variables: variablesExcludingCurrentCommandOnes,
},
// do not repropose the same function as arg
// i.e. avoid cases like abs(abs(abs(...))) with suggestions
{ ignoreFn: [node.name] }
))
);
}
// for eval and row commands try also to complete numeric literals with time intervals where possible
if (arg) {
if (command.name !== 'stats') {
if (isLiteralItem(arg) && arg.literalType === 'number') {
// ... | EVAL fn(2 <suggest>)
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['time_literal_unit'],
command.name,
option?.name,
getFieldsByType,
{
functions: false,
fields: false,
variables: variablesExcludingCurrentCommandOnes,
}
))
);
}
}
if (hasMoreMandatoryArgs) {
// 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,
insertText:
hasMoreMandatoryArgs && !fnDefinition.builtin
? `${suggestion.insertText},`
: suggestion.insertText,
}
: suggestion
);
}
return suggestions.map(({ insertText, ...rest }) => ({
...rest,
insertText: hasMoreMandatoryArgs && !fnDefinition.builtin ? `${insertText},` : insertText,
}));
// early exit on no hit
if (!fnDefinition) {
return [];
}
return mathCommandDefinition;
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
const variablesExcludingCurrentCommandOnes = excludeVariablesFromCurrentCommand(
commands,
command,
fieldsMap
);
// pick the type of the next arg
const shouldGetNextArgument = node.text.includes(EDITOR_MARKER);
let argIndex = Math.max(node.args.length, 0);
if (!shouldGetNextArgument && argIndex) {
argIndex -= 1;
}
const types = fnDefinition.signatures.flatMap((signature) => {
if (signature.params.length > argIndex) {
return signature.params[argIndex].type;
}
if (signature.infiniteParams) {
return signature.params[0].type;
}
return [];
});
const arg = node.args[argIndex];
const hasMoreMandatoryArgs =
fnDefinition.signatures[0].params.filter(({ optional }, index) => !optional && index > argIndex)
.length > argIndex;
const suggestions = [];
const noArgDefined = !arg;
const isUnknownColumn =
arg &&
isColumnItem(arg) &&
!columnExists(arg, { fields: fieldsMap, variables: variablesExcludingCurrentCommandOnes }).hit;
if (noArgDefined || isUnknownColumn) {
// ... | EVAL fn( <suggest>)
// ... | EVAL fn( field, <suggest>)
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
types,
command.name,
option?.name,
getFieldsByType,
{
functions: command.name !== 'stats',
fields: true,
variables: variablesExcludingCurrentCommandOnes,
},
// do not repropose the same function as arg
// i.e. avoid cases like abs(abs(abs(...))) with suggestions
{ ignoreFn: [node.name] }
))
);
}
// for eval and row commands try also to complete numeric literals with time intervals where possible
if (arg) {
if (command.name !== 'stats') {
if (isLiteralItem(arg) && arg.literalType === 'number') {
// ... | EVAL fn(2 <suggest>)
suggestions.push(
...(await getFieldsOrFunctionsSuggestions(
['time_literal_unit'],
command.name,
option?.name,
getFieldsByType,
{
functions: false,
fields: false,
variables: variablesExcludingCurrentCommandOnes,
}
))
);
}
}
if (hasMoreMandatoryArgs) {
// 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,
insertText:
hasMoreMandatoryArgs && !fnDefinition.builtin
? `${suggestion.insertText},`
: suggestion.insertText,
}
: suggestion
);
}
return suggestions.map(({ insertText, ...rest }) => ({
...rest,
insertText: hasMoreMandatoryArgs && !fnDefinition.builtin ? `${insertText},` : insertText,
}));
}
async function getSettingArgsSuggestions(