From 7f2b56f0e687d466c20127d358d25a0456e51a03 Mon Sep 17 00:00:00 2001 From: Drew Tate Date: Fri, 1 Nov 2024 09:11:05 -0600 Subject: [PATCH] [ES|QL] separate `STATS` autocomplete routine (#198224) ## Summary Part of https://github.com/elastic/kibana/issues/195418 This PR moves the `STATS` completion logic to its own home. There are also a few changes in behavior. I am open for feedback on any of these. - the cursor is automatically advanced after accepting a comma suggestion - variables from previous `EVAL` commands are no longer suggested (e.g. `...| EVAL foo = 1 | STATS /`). I'm not sure about this change, but it seemed potentially unintended to suggest variables but no other columns such as field names. - a new variable is suggested for new expressions in the `BY` clause. Formerly, new variables were only suggested in the `STATS` clause. - `+` and `-` are no longer suggested after a completed function call within an assignment in the `BY` clause (e.g. `... | STATS ... BY var1 = BUCKET(dateField, 1 day) /`. This behavior was encoded in our tests, but it feels unintended to me, especially since it only applied when the result of the function was assigned to a new variable in the `BY` clause. ### 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 --------- Co-authored-by: Stratoula Kalafateli --- .../autocomplete.command.stats.test.ts | 55 ++++-- .../src/autocomplete/__tests__/helpers.ts | 2 +- .../src/autocomplete/autocomplete.test.ts | 14 +- .../src/autocomplete/autocomplete.ts | 164 ++++-------------- .../src/autocomplete/commands/drop/index.ts | 4 +- .../src/autocomplete/commands/keep/index.ts | 4 +- .../src/autocomplete/commands/stats/index.ts | 77 ++++++++ .../src/autocomplete/commands/stats/util.ts | 104 +++++++++++ .../src/autocomplete/factories.ts | 74 ++++---- .../src/autocomplete/helper.ts | 11 +- .../src/definitions/commands.ts | 2 + .../src/definitions/types.ts | 4 +- .../src/shared/context.ts | 19 +- .../src/shared/helpers.ts | 4 +- 14 files changed, 334 insertions(+), 204 deletions(-) create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts index b3884f5cb96be..829c12f7dabba 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts @@ -9,8 +9,8 @@ import { FieldType, FunctionReturnType } from '../../definitions/types'; import { ESQL_COMMON_NUMERIC_TYPES, ESQL_NUMBER_TYPES } from '../../shared/esql_types'; +import { getDateHistogramCompletionItem } from '../commands/stats/util'; import { allStarConstant } from '../complete_items'; -import { getAddDateHistogramSnippet } from '../factories'; import { roundParameterTypes } from './constants'; import { setup, @@ -71,7 +71,7 @@ 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 ', ', ', '| ']); }); test('on space after aggregate field with comma', async () => { @@ -184,7 +184,7 @@ 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 ', ', ', '| ']); }); test('increments suggested variable name counter', async () => { @@ -192,9 +192,8 @@ describe('autocomplete.suggest', () => { await assertSuggestions('from a | eval var0=round(b), var1=round(c) | stats /', [ 'var2 = ', + // TODO verify that this change is ok ...allAggFunctions, - 'var0', - 'var1', ...allEvaFunctions, ]); await assertSuggestions('from a | stats var0=min(b),var1=c,/', [ @@ -210,7 +209,7 @@ describe('autocomplete.suggest', () => { const { assertSuggestions } = await setup(); const expected = [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, @@ -224,7 +223,7 @@ 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 () => { @@ -233,7 +232,7 @@ describe('autocomplete.suggest', () => { const fields = getFieldNamesByType('any').map((field) => `${field} `); await assertSuggestions('from a | stats a=c by d, /', [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...fields, ...allEvaFunctions, ...allGroupingFunctions, @@ -245,7 +244,7 @@ describe('autocomplete.suggest', () => { ]); await assertSuggestions('from a | stats avg(b) by c, /', [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...fields, ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), ...allGroupingFunctions, @@ -262,17 +261,16 @@ describe('autocomplete.suggest', () => { ...getFunctionSignaturesByReturnType('eval', ['integer', 'double', 'long'], { scalar: true, }), - ...allGroupingFunctions, ]); await assertSuggestions('from a | stats avg(b) by var0 = /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, ]); await assertSuggestions('from a | stats avg(b) by c, var0 = /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, @@ -282,21 +280,17 @@ 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 avg(b) by doubleField % 2 /', [', ', '| '], { triggerCharacter: ' ', }); - await assertSuggestions( - 'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day)/', - [',', '| ', '+ $0', '- $0'] - ); await assertSuggestions( 'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day) /', - [',', '| ', '+ $0', '- $0'], + [', ', '| '], { triggerCharacter: ' ' } ); }); + test('on space within bucket()', async () => { const { assertSuggestions } = await setup(); await assertSuggestions('from a | stats avg(b) by BUCKET(/, 50, ?_tstart, ?_tend)', [ @@ -330,6 +324,29 @@ describe('autocomplete.suggest', () => { const suggestions = await suggest('from a | stats count(/)'); expect(suggestions).toContain(allStarConstant); }); + + describe('date histogram snippet', () => { + test('uses histogramBarTarget preference when available', async () => { + const { suggest } = await setup(); + const histogramBarTarget = Math.random() * 100; + const expectedCompletionItem = getDateHistogramCompletionItem(histogramBarTarget); + + const suggestions = await suggest('FROM a | STATS BY /', { + callbacks: { getPreferences: () => Promise.resolve({ histogramBarTarget }) }, + }); + + expect(suggestions).toContainEqual(expectedCompletionItem); + }); + + test('defaults gracefully', async () => { + const { suggest } = await setup(); + const expectedCompletionItem = getDateHistogramCompletionItem(); + + const suggestions = await suggest('FROM a | STATS BY /'); + + expect(suggestions).toContainEqual(expectedCompletionItem); + }); + }); }); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts index 3234417c1f1a4..ebf0a0589d1e9 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts @@ -177,7 +177,7 @@ export function getFunctionSignaturesByReturnType( ({ returnType }) => expectedReturnType.includes('any') || expectedReturnType.includes(returnType as string) ); - if (!filteredByReturnType.length) { + if (!filteredByReturnType.length && !expectedReturnType.includes('any')) { return false; } if (paramsTypes?.length) { diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index f5a5e5ca551b7..9e7fa4566d753 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -12,7 +12,6 @@ import { scalarFunctionDefinitions } from '../definitions/generated/scalar_funct import { timeUnitsToSuggest } from '../definitions/literals'; import { commandDefinitions as unmodifiedCommandDefinitions } from '../definitions/commands'; import { - getAddDateHistogramSnippet, getDateLiterals, getSafeInsertText, TIME_SYSTEM_PARAMS, @@ -38,6 +37,7 @@ import { METADATA_FIELDS } from '../shared/constants'; import { ESQL_COMMON_NUMERIC_TYPES, ESQL_STRING_TYPES } from '../shared/esql_types'; import { log10ParameterTypes, powParameterTypes } from './__tests__/constants'; import { getRecommendedQueries } from './recommended_queries/templates'; +import { getDateHistogramCompletionItem } from './commands/stats/util'; const commandDefinitions = unmodifiedCommandDefinitions.filter(({ hidden }) => !hidden); @@ -737,12 +737,12 @@ describe('autocomplete', () => { ]); // STATS argument BY - testSuggestions('FROM index1 | STATS AVG(booleanField) B/', ['BY $0', ',', '| ']); + testSuggestions('FROM index1 | STATS AVG(booleanField) B/', ['BY ', ', ', '| ']); // STATS argument BY expression testSuggestions('FROM index1 | STATS field BY f/', [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFunctionSignaturesByReturnType('stats', 'any', { grouping: true, scalar: true }), ...getFieldNamesByType('any').map((field) => `${field} `), ]); @@ -1072,8 +1072,8 @@ describe('autocomplete', () => { // STATS argument BY testSuggestions('FROM a | STATS AVG(numberField) /', [ - ',', - attachAsSnippet(attachTriggerCommand('BY $0')), + ', ', + attachTriggerCommand('BY '), attachTriggerCommand('| '), ]); @@ -1090,7 +1090,7 @@ describe('autocomplete', () => { 'by' ); testSuggestions('FROM a | STATS AVG(numberField) BY /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), attachTriggerCommand('var0 = '), ...getFieldNamesByType('any') .map((field) => `${field} `) @@ -1100,7 +1100,7 @@ describe('autocomplete', () => { // STATS argument BY assignment (checking field suggestions) testSuggestions('FROM a | STATS AVG(numberField) BY var0 = /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any') .map((field) => `${field} `) .map(attachTriggerCommand), diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 5bdbd9d995fc9..7b0f4191dcaca 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -16,7 +16,6 @@ import type { ESQLFunction, ESQLSingleAstItem, } from '@kbn/esql-ast'; -import { i18n } from '@kbn/i18n'; import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types'; import type { EditorContext, ItemKind, SuggestionRawDefinition, GetColumnsByTypeFn } from './types'; import { @@ -24,7 +23,7 @@ import { getCommandDefinition, getCommandOption, getFunctionDefinition, - getLastCharFromTrimmed, + getLastNonWhitespaceChar, isArrayType, isAssignment, isAssignmentComplete, @@ -68,9 +67,9 @@ import { buildFieldsDefinitions, buildPoliciesDefinitions, buildSourcesDefinitions, - buildNewVarDefinition, + getNewVariableSuggestion, buildNoPoliciesAvailableDefinition, - getCompatibleFunctionDefinition, + getFunctionSuggestions, buildMatchingFieldsDefinition, getCompatibleLiterals, buildConstantsDefinitions, @@ -81,7 +80,6 @@ import { getDateLiterals, buildFieldsDefinitionsWithMetadata, TRIGGER_SUGGESTION_COMMAND, - getAddDateHistogramSnippet, } from './factories'; import { EDITOR_MARKER, METADATA_FIELDS } from '../shared/constants'; import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context'; @@ -109,7 +107,6 @@ import { import { FunctionParameter, isParameterType, isReturnType } from '../definitions/types'; import { metadataOption } from '../definitions/options'; import { comparisonFunctions } from '../definitions/builtin'; -import { countBracketsUnclosed } from '../shared/helpers'; import { getRecommendedQueriesSuggestions } from './recommended_queries/suggestions'; type GetFieldsMapFn = () => Promise>; @@ -206,9 +203,7 @@ export async function suggest( } if (astContext.type === 'expression') { - // suggest next possible argument, or option - // otherwise a variable - return getSuggestionsWithinCommand( + return getSuggestionsWithinCommandExpression( innerText, ast, astContext, @@ -216,7 +211,8 @@ export async function suggest( getFieldsByType, getFieldsMap, getPolicies, - getPolicyMetadata + getPolicyMetadata, + resourceRetriever?.getPreferences ); } if (astContext.type === 'setting') { @@ -239,8 +235,7 @@ export async function suggest( { option, ...rest }, getFieldsByType, getFieldsMap, - getPolicyMetadata, - resourceRetriever?.getPreferences + getPolicyMetadata ); } } @@ -444,7 +439,7 @@ function extractArgMeta( return { argIndex, prevIndex, lastArg, nodeArg }; } -async function getSuggestionsWithinCommand( +async function getSuggestionsWithinCommandExpression( innerText: string, commands: ESQLCommand[], { @@ -460,7 +455,8 @@ async function getSuggestionsWithinCommand( getColumnsByType: GetColumnsByTypeFn, getFieldsMap: GetFieldsMapFn, getPolicies: GetPoliciesFn, - getPolicyMetadata: GetPolicyMetadataFn + getPolicyMetadata: GetPolicyMetadataFn, + getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> ) { const commandDef = getCommandDefinition(command.name); @@ -471,8 +467,13 @@ async function getSuggestionsWithinCommand( const references = { fields: fieldsMap, variables: anyVariables }; if (commandDef.suggest) { // The new path. - return commandDef.suggest(innerText, command, getColumnsByType, (col: string) => - Boolean(getColumnByName(col, references)) + return commandDef.suggest( + innerText, + command, + getColumnsByType, + (col: string) => Boolean(getColumnByName(col, references)), + () => findNewVariable(anyVariables), + getPreferences ); } else { // The deprecated path. @@ -631,7 +632,7 @@ async function getExpressionSuggestionsByType( // ... | STATS ..., // ... | EVAL // ... | EVAL ..., - suggestions.push(buildNewVarDefinition(findNewVariable(anyVariables))); + suggestions.push(getNewVariableSuggestion(findNewVariable(anyVariables))); } } } @@ -1348,12 +1349,14 @@ async function getFunctionArgsSuggestions( // Functions suggestions.push( - ...getCompatibleFunctionDefinition( - command.name, - option?.name, - canBeBooleanCondition ? ['any'] : (getTypesFromParamDefs(typesToSuggestNext) as string[]), - fnToIgnore - ).map((suggestion) => ({ + ...getFunctionSuggestions({ + command: command.name, + option: option?.name, + returnTypes: canBeBooleanCondition + ? ['any'] + : (getTypesFromParamDefs(typesToSuggestNext) as string[]), + ignored: fnToIgnore, + }).map((suggestion) => ({ ...suggestion, text: addCommaIf(shouldAddComma, suggestion.text), })) @@ -1485,7 +1488,7 @@ async function getSettingArgsSuggestions( const settingDefs = getCommandDefinition(command.name).modes || []; if (settingDefs.length) { - const lastChar = getLastCharFromTrimmed(innerText); + const lastChar = getLastNonWhitespaceChar(innerText); const matchingSettingDefs = settingDefs.filter(({ prefix }) => lastChar === prefix); if (matchingSettingDefs.length) { // COMMAND _ @@ -1495,6 +1498,10 @@ async function getSettingArgsSuggestions( return suggestions; } +/** + * @deprecated — this will disappear when https://github.com/elastic/kibana/issues/195418 is complete + * because "options" will be handled in imperative command-specific routines instead of being independent. + */ async function getOptionArgsSuggestions( innerText: string, commands: ESQLCommand[], @@ -1509,29 +1516,19 @@ async function getOptionArgsSuggestions( }, getFieldsByType: GetColumnsByTypeFn, getFieldsMaps: GetFieldsMapFn, - getPolicyMetadata: GetPolicyMetadataFn, - getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> + getPolicyMetadata: GetPolicyMetadataFn ) { - let preferences: { histogramBarTarget: number } | undefined; - if (getPreferences) { - preferences = await getPreferences(); - } - const optionDef = getCommandOption(option.name); if (!optionDef || !optionDef.signature) { return []; } - const { nodeArg, argIndex, lastArg } = extractArgMeta(option, node); + const { nodeArg, lastArg } = extractArgMeta(option, node); const suggestions = []; const isNewExpression = isRestartingExpression(innerText) || option.args.length === 0; const fieldsMap = await getFieldsMaps(); const anyVariables = collectVariables(commands, fieldsMap, innerText); - const references = { - fields: fieldsMap, - variables: anyVariables, - }; if (command.name === 'enrich') { if (option.name === 'on') { // if it's a new expression, suggest fields to match on @@ -1573,7 +1570,7 @@ async function getOptionArgsSuggestions( ); if (isNewExpression || noCaseCompare(findPreviousWord(innerText), 'WITH')) { - suggestions.push(buildNewVarDefinition(findNewVariable(anyEnhancedVariables))); + suggestions.push(getNewVariableSuggestion(findNewVariable(anyEnhancedVariables))); } // make sure to remove the marker arg from the assign fn @@ -1696,53 +1693,6 @@ async function getOptionArgsSuggestions( } } - if (command.name === 'stats') { - const argDef = optionDef?.signature.params[argIndex]; - - const nodeArgType = extractTypeFromASTArg(nodeArg, references); - // These cases can happen here, so need to identify each and provide the right suggestion - // i.e. ... | STATS ... BY field + - // i.e. ... | STATS ... BY field >= - - if (nodeArgType) { - if (isFunctionItem(nodeArg) && !isFunctionArgComplete(nodeArg, references).complete) { - suggestions.push( - ...(await getBuiltinFunctionNextArgument( - innerText, - command, - option, - { type: argDef?.type || 'unknown' }, - nodeArg, - nodeArgType as string, - { - fields: references.fields, - // you can't use a variable defined - // in the stats command in the by clause - variables: new Map(), - }, - getFieldsByType - )) - ); - } - } - - // If it's a complete expression then propose some final suggestions - if ( - (!nodeArgType && - option.name === 'by' && - option.args.length && - !isNewExpression && - !isAssignment(lastArg)) || - (isAssignment(lastArg) && isAssignmentComplete(lastArg)) - ) { - suggestions.push( - ...getFinalSuggestions({ - comma: optionDef?.signature.multipleParams ?? option.name === 'by', - }) - ); - } - } - if (optionDef) { if (!suggestions.length) { const argDefIndex = optionDef.signature.multipleParams @@ -1768,52 +1718,6 @@ async function getOptionArgsSuggestions( openSuggestions: true, })) ); - // Checks if cursor is still within function () - // by checking if the marker editor/cursor is within an unclosed parenthesis - const canHaveAssignment = countBracketsUnclosed('(', innerText) === 0; - - if (option.name === 'by') { - // Add quick snippet for for stats ... by bucket(<>) - if (command.name === 'stats' && canHaveAssignment) { - suggestions.push({ - label: i18n.translate( - 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogram', - { - defaultMessage: 'Add date histogram', - } - ), - text: getAddDateHistogramSnippet(preferences?.histogramBarTarget), - asSnippet: true, - kind: 'Issue', - detail: i18n.translate( - 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogramDetail', - { - defaultMessage: 'Add date histogram using bucket()', - } - ), - sortText: '1A', - command: TRIGGER_SUGGESTION_COMMAND, - } as SuggestionRawDefinition); - } - - suggestions.push( - ...(await getFieldsOrFunctionsSuggestions( - types[0] === 'column' ? ['any'] : types, - command.name, - option.name, - getFieldsByType, - { - functions: true, - fields: false, - }, - { ignoreFn: canHaveAssignment ? [] : ['bucket', 'case'] } - )) - ); - } - - if (command.name === 'stats' && isNewExpression && canHaveAssignment) { - suggestions.push(buildNewVarDefinition(findNewVariable(anyVariables))); - } } } } diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.ts index ed5f0ee3d3f6b..43eb272ba203a 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.ts @@ -10,7 +10,7 @@ import type { ESQLCommand } from '@kbn/esql-ast'; import { findPreviousWord, - getLastCharFromTrimmed, + getLastNonWhitespaceChar, isColumnItem, noCaseCompare, } from '../../../shared/helpers'; @@ -27,7 +27,7 @@ export async function suggest( ): Promise { if ( /\s/.test(innerText[innerText.length - 1]) && - getLastCharFromTrimmed(innerText) !== ',' && + getLastNonWhitespaceChar(innerText) !== ',' && !noCaseCompare(findPreviousWord(innerText), 'drop') ) { return [pipeCompleteItem, commaCompleteItem]; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.ts index c2480ffbcde72..85d5c716b20a6 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.ts @@ -10,7 +10,7 @@ import type { ESQLCommand } from '@kbn/esql-ast'; import { findPreviousWord, - getLastCharFromTrimmed, + getLastNonWhitespaceChar, isColumnItem, noCaseCompare, } from '../../../shared/helpers'; @@ -27,7 +27,7 @@ export async function suggest( ): Promise { if ( /\s/.test(innerText[innerText.length - 1]) && - getLastCharFromTrimmed(innerText) !== ',' && + getLastNonWhitespaceChar(innerText) !== ',' && !noCaseCompare(findPreviousWord(innerText), 'keep') ) { return [pipeCompleteItem, commaCompleteItem]; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts new file mode 100644 index 0000000000000..46a37d36eacc9 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts @@ -0,0 +1,77 @@ +/* + * 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 { ESQLCommand } from '@kbn/esql-ast'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { + TRIGGER_SUGGESTION_COMMAND, + getNewVariableSuggestion, + getFunctionSuggestions, +} from '../../factories'; +import { commaCompleteItem, pipeCompleteItem } from '../../complete_items'; +import { pushItUpInTheList } from '../../helper'; +import { byCompleteItem, getDateHistogramCompletionItem, getPosition } from './util'; + +export async function suggest( + innerText: string, + command: ESQLCommand<'stats'>, + getColumnsByType: GetColumnsByTypeFn, + _columnExists: (column: string) => boolean, + getSuggestedVariableName: () => string, + getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> +): Promise { + const pos = getPosition(innerText, command); + + const columnSuggestions = pushItUpInTheList( + await getColumnsByType('any', [], { advanceCursor: true, openSuggestions: true }), + true + ); + + switch (pos) { + case 'expression_without_assignment': + return [ + ...getFunctionSuggestions({ command: 'stats' }), + getNewVariableSuggestion(getSuggestedVariableName()), + ]; + + case 'expression_after_assignment': + return [...getFunctionSuggestions({ command: 'stats' })]; + + case 'expression_complete': + return [ + byCompleteItem, + pipeCompleteItem, + { ...commaCompleteItem, command: TRIGGER_SUGGESTION_COMMAND, text: ', ' }, + ]; + + case 'grouping_expression_after_assignment': + return [ + ...getFunctionSuggestions({ command: 'stats', option: 'by' }), + getDateHistogramCompletionItem((await getPreferences?.())?.histogramBarTarget), + ...columnSuggestions, + ]; + + case 'grouping_expression_without_assignment': + return [ + ...getFunctionSuggestions({ command: 'stats', option: 'by' }), + getDateHistogramCompletionItem((await getPreferences?.())?.histogramBarTarget), + ...columnSuggestions, + getNewVariableSuggestion(getSuggestedVariableName()), + ]; + + case 'grouping_expression_complete': + return [ + pipeCompleteItem, + { ...commaCompleteItem, command: TRIGGER_SUGGESTION_COMMAND, text: ', ' }, + ]; + + default: + return []; + } +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts new file mode 100644 index 0000000000000..c9abaa5c5408a --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts @@ -0,0 +1,104 @@ +/* + * 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 } from '@kbn/esql-ast'; +import { i18n } from '@kbn/i18n'; +import { + findPreviousWord, + getLastNonWhitespaceChar, + isAssignment, + isAssignmentComplete, + isOptionItem, + noCaseCompare, +} from '../../../shared/helpers'; +import { SuggestionRawDefinition } from '../../types'; +import { TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND } from '../../factories'; + +/** + * Position of the caret in the sort command: +* +* ``` +* STATS [column1 =] expression1[, ..., [columnN =] expressionN] [BY [column1 =] grouping_expression1[, ..., grouping_expressionN]] + | | | | | | + | | expression_complete | | grouping_expression_complete + | expression_after_assignment | grouping_expression_after_assignment + expression_without_assignment grouping_expression_without_assignment + +* ``` +*/ +export type CaretPosition = + | 'expression_without_assignment' + | 'expression_after_assignment' + | 'expression_complete' + | 'grouping_expression_without_assignment' + | 'grouping_expression_after_assignment' + | 'grouping_expression_complete'; + +export const getPosition = (innerText: string, command: ESQLCommand): CaretPosition => { + const lastCommandArg = command.args[command.args.length - 1]; + + if (isOptionItem(lastCommandArg) && lastCommandArg.name === 'by') { + // in the BY clause + + const lastOptionArg = lastCommandArg.args[lastCommandArg.args.length - 1]; + if (isAssignment(lastOptionArg) && !isAssignmentComplete(lastOptionArg)) { + return 'grouping_expression_after_assignment'; + } + + if ( + getLastNonWhitespaceChar(innerText) === ',' || + noCaseCompare(findPreviousWord(innerText), 'by') + ) { + return 'grouping_expression_without_assignment'; + } else { + return 'grouping_expression_complete'; + } + } + + if (isAssignment(lastCommandArg) && !isAssignmentComplete(lastCommandArg)) { + return 'expression_after_assignment'; + } + + if ( + getLastNonWhitespaceChar(innerText) === ',' || + noCaseCompare(findPreviousWord(innerText), 'stats') + ) { + return 'expression_without_assignment'; + } else { + return 'expression_complete'; + } +}; + +export const byCompleteItem: SuggestionRawDefinition = { + label: 'BY', + text: 'BY ', + kind: 'Reference', + detail: 'By', + sortText: '1', + command: TRIGGER_SUGGESTION_COMMAND, +}; + +export const getDateHistogramCompletionItem: ( + histogramBarTarget?: number +) => SuggestionRawDefinition = (histogramBarTarget: number = 50) => ({ + label: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogram', { + defaultMessage: 'Add date histogram', + }), + text: `BUCKET($0, ${histogramBarTarget}, ${TIME_SYSTEM_PARAMS.join(', ')})`, + asSnippet: true, + kind: 'Issue', + detail: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogramDetail', + { + defaultMessage: 'Add date histogram using bucket()', + } + ), + sortText: '1A', + command: TRIGGER_SUGGESTION_COMMAND, +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index f522e9bc65863..9b7e2b0bf71a5 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -39,10 +39,6 @@ const allFunctions = memoize( export const TIME_SYSTEM_PARAMS = ['?_tstart', '?_tend']; -export const getAddDateHistogramSnippet = (histogramBarTarget = 50) => { - return `BUCKET($0, ${histogramBarTarget}, ${TIME_SYSTEM_PARAMS.join(', ')})`; -}; - export const TRIGGER_SUGGESTION_COMMAND = { title: 'Trigger Suggestion Dialog', id: 'editor.action.triggerSuggest', @@ -61,7 +57,7 @@ function getSafeInsertSourceText(text: string) { return shouldBeQuotedSource(text) ? getQuotedText(text) : text; } -export function getSuggestionFunctionDefinition(fn: FunctionDefinition): SuggestionRawDefinition { +export function getFunctionSuggestion(fn: FunctionDefinition): SuggestionRawDefinition { const fullSignatures = getFunctionSignatures(fn, { capitalize: true, withTypes: true }); return { label: fn.name.toUpperCase(), @@ -95,31 +91,47 @@ export function getSuggestionBuiltinDefinition(fn: FunctionDefinition): Suggesti }; } -export const getCompatibleFunctionDefinition = ( - command: string, - option: string | undefined, - returnTypes?: string[], - ignored: string[] = [] -): SuggestionRawDefinition[] => { - const fnSupportedByCommand = allFunctions() - .filter( - ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion }) => - (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) && - !ignored.includes(name) && - !ignoreAsSuggestion - ) - .sort((a, b) => a.name.localeCompare(b.name)); - if (!returnTypes) { - return fnSupportedByCommand.map(getSuggestionFunctionDefinition); - } - return fnSupportedByCommand - .filter((mathDefinition) => - mathDefinition.signatures.some( - (signature) => - returnTypes[0] === 'any' || returnTypes.includes(signature.returnType as string) - ) - ) - .map(getSuggestionFunctionDefinition); +/** + * Builds suggestions for functions based on the provided predicates. + * + * @param predicates a set of conditions that must be met for a function to be included in the suggestions + * @returns + */ +export const getFunctionSuggestions = (predicates?: { + command?: string; + option?: string | undefined; + returnTypes?: string[]; + ignored?: string[]; +}): SuggestionRawDefinition[] => { + const functions = allFunctions(); + const { command, option, returnTypes, ignored = [] } = predicates ?? {}; + const filteredFunctions: FunctionDefinition[] = functions.filter( + ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion, signatures }) => { + if (ignoreAsSuggestion) { + return false; + } + + if (ignored.includes(name)) { + return false; + } + + if (option && !supportedOptions?.includes(option)) { + return false; + } + + if (command && !supportedCommands.includes(command)) { + return false; + } + + if (returnTypes && !returnTypes.includes('any')) { + return signatures.some((signature) => returnTypes.includes(signature.returnType as string)); + } + + return true; + } + ); + + return filteredFunctions.map(getFunctionSuggestion); }; export function getSuggestionCommandDefinition( @@ -253,7 +265,7 @@ export const buildValueDefinitions = ( command: options?.advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined, })); -export const buildNewVarDefinition = (label: string): SuggestionRawDefinition => { +export const getNewVariableSuggestion = (label: string): SuggestionRawDefinition => { return { label, text: `${label} = `, diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index 6585a04c98c59..9724878611b01 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -35,7 +35,7 @@ import { compareTypesWithLiterals } from '../shared/esql_types'; import { TIME_SYSTEM_PARAMS, buildVariablesDefinitions, - getCompatibleFunctionDefinition, + getFunctionSuggestions, getCompatibleLiterals, getDateLiterals, } from './factories'; @@ -417,7 +417,14 @@ export async function getFieldsOrFunctionsSuggestions( const suggestions = filteredFieldsByType.concat( displayDateSuggestions ? getDateLiterals() : [], - functions ? getCompatibleFunctionDefinition(commandName, optionName, types, ignoreFn) : [], + functions + ? getFunctionSuggestions({ + command: commandName, + option: optionName, + returnTypes: types, + ignored: ignoreFn, + }) + : [], variables ? pushItUpInTheList(buildVariablesDefinitions(filteredVariablesByType), functions) : [], diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts index f4482a5b33c17..d07511665ad31 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -35,6 +35,7 @@ import type { CommandDefinition } from './types'; 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'; const statsValidator = (command: ESQLCommand) => { const messages: ESQLMessage[] = []; @@ -240,6 +241,7 @@ export const commandDefinitions: Array> = [ options: [byOption], modes: [], validate: statsValidator, + suggest: suggestForStats, }, { name: 'inlinestats', diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts index a83908b41617f..ff461683d8e76 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -171,7 +171,9 @@ export interface CommandBaseDefinition { innerText: string, command: ESQLCommand, getColumnsByType: GetColumnsByTypeFn, - columnExists: (column: string) => boolean + columnExists: (column: string) => boolean, + getSuggestedVariableName: () => string, + getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> ) => Promise; /** @deprecated this property will disappear in the future */ signature: { diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts index 1c2e9075e95ff..2de9d7290ab5c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts @@ -21,10 +21,10 @@ import { EDITOR_MARKER } from './constants'; import { isOptionItem, isColumnItem, - getFunctionDefinition, isSourceItem, isSettingItem, pipePrecedesCurrentWord, + getFunctionDefinition, } from './helpers'; function findNode(nodes: ESQLAstItem[], offset: number): ESQLSingleAstItem | undefined { @@ -133,6 +133,7 @@ function findAstPosition(ast: ESQLAst, offset: number) { function isNotEnrichClauseAssigment(node: ESQLFunction, command: ESQLCommand) { return node.name !== '=' && command.name !== 'enrich'; } + function isBuiltinFunction(node: ESQLFunction) { return getFunctionDefinition(node.name)?.type === 'builtin'; } @@ -162,15 +163,18 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number) // command ... a in ( ) return { type: 'list' as const, command, node, option, setting }; } - if (isNotEnrichClauseAssigment(node, command) && !isBuiltinFunction(node)) { + if ( + isNotEnrichClauseAssigment(node, command) && + // Temporarily mangling the logic here to let operators + // be handled as functions for the stats command. + // I expect this to simplify once https://github.com/elastic/kibana/issues/195418 + // is complete + !(isBuiltinFunction(node) && command.name !== 'stats') + ) { // command ... fn( ) return { type: 'function' as const, command, node, option, setting }; } } - if (node.type === 'option' || option) { - // command ... by - return { type: 'option' as const, command, node, option, setting }; - } // for now it's only an enrich thing if (node.type === 'source' && node.text === ENRICH_MODES.prefix) { // command _ @@ -182,7 +186,8 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number) return { type: 'newCommand' as const, command: undefined, node, option, setting }; } - if (command && isOptionItem(command.args[command.args.length - 1])) { + // TODO — remove this option branch once https://github.com/elastic/kibana/issues/195418 is complete + if (command && isOptionItem(command.args[command.args.length - 1]) && command.name !== 'stats') { if (option) { return { type: 'option' as const, command, node, option, setting }; } diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts index 02dff9720cd9b..b17f4ebcc8f07 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -599,7 +599,7 @@ export function pipePrecedesCurrentWord(text: string) { return characterPrecedesCurrentWord(text, '|'); } -export function getLastCharFromTrimmed(text: string) { +export function getLastNonWhitespaceChar(text: string) { return text[text.trimEnd().length - 1]; } @@ -607,7 +607,7 @@ export function getLastCharFromTrimmed(text: string) { * Are we after a comma? i.e. STATS fieldA, */ export function isRestartingExpression(text: string) { - return getLastCharFromTrimmed(text) === ',' || characterPrecedesCurrentWord(text, ','); + return getLastNonWhitespaceChar(text) === ',' || characterPrecedesCurrentWord(text, ','); } export function findPreviousWord(text: string) {