diff --git a/packages/kbn-esql-ast/src/ast_helpers.ts b/packages/kbn-esql-ast/src/ast_helpers.ts index 7dff8cda3566a4..76f576f1ec019a 100644 --- a/packages/kbn-esql-ast/src/ast_helpers.ts +++ b/packages/kbn-esql-ast/src/ast_helpers.ts @@ -405,9 +405,9 @@ export function createSource( index, name: text, sourceType: type, - text, location: getPosition(ctx.start, ctx.stop), incomplete: Boolean(ctx.exception || text === ''), + text: ctx?.getText(), }; } diff --git a/packages/kbn-esql-utils/src/utils/query_parsing_helpers.ts b/packages/kbn-esql-utils/src/utils/query_parsing_helpers.ts index 734acac70fd7d0..53ce6e06bb5363 100644 --- a/packages/kbn-esql-utils/src/utils/query_parsing_helpers.ts +++ b/packages/kbn-esql-utils/src/utils/query_parsing_helpers.ts @@ -24,7 +24,7 @@ export function getIndexPatternFromESQLQuery(esql?: string) { const sourceCommand = ast.find(({ name }) => ['from', 'metrics'].includes(name)); const args = (sourceCommand?.args ?? []) as ESQLSource[]; const indices = args.filter((arg) => arg.sourceType === 'index'); - return indices?.map((index) => index.text).join(','); + return indices?.map((index) => index.name).join(','); } // For ES|QL we consider stats and keep transformational command diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts index 4d37ca078dd88c..fa2a969384a09c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts @@ -15,9 +15,6 @@ 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', () => { @@ -37,17 +34,17 @@ describe('autocomplete.suggest', () => { test('suggests visible indices on space', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('from /', addTrailingSpace(visibleIndices)); - await assertSuggestions('FROM /', addTrailingSpace(visibleIndices)); - await assertSuggestions('from /index', addTrailingSpace(visibleIndices)); + await assertSuggestions('from /', visibleIndices); + await assertSuggestions('FROM /', visibleIndices); + await assertSuggestions('from /index', visibleIndices); }); test('suggests visible indices on comma', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('FROM a,/', addTrailingSpace(visibleIndices)); - await assertSuggestions('FROM a, /', addTrailingSpace(visibleIndices)); - await assertSuggestions('from *,/', addTrailingSpace(visibleIndices)); + await assertSuggestions('FROM a,/', visibleIndices); + await assertSuggestions('FROM a, /', visibleIndices); + await assertSuggestions('from *,/', visibleIndices); }); test('can suggest integration data sources', async () => { @@ -56,10 +53,7 @@ describe('autocomplete.suggest', () => { .filter(({ hidden }) => !hidden) .map(({ name, suggestedAs }) => suggestedAs || name) .sort(); - const expectedSuggestions = addTrailingSpace( - visibleDataSources, - (s) => !integrations.find(({ name }) => name === s) - ); + const expectedSuggestions = visibleDataSources; const { assertSuggestions, callbacks } = await setup(); const cb = { ...callbacks, @@ -75,7 +69,7 @@ describe('autocomplete.suggest', () => { }); describe('... METADATA ', () => { - const metadataFieldsSandIndex = metadataFields.filter((field) => field !== '_index'); + const metadataFieldsAndIndex = metadataFields.filter((field) => field !== '_index'); test('on SPACE without comma ",", suggests adding metadata', async () => { const { assertSuggestions } = await setup(); @@ -103,8 +97,8 @@ describe('autocomplete.suggest', () => { test('filters out already used metadata fields', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('from a, b [metadata _index, /]', metadataFieldsSandIndex); - await assertSuggestions('from a, b metadata _index, /', metadataFieldsSandIndex); + await assertSuggestions('from a, b [metadata _index, /]', metadataFieldsAndIndex); + await assertSuggestions('from a, b metadata _index, /', metadataFieldsAndIndex); }); }); }); 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 70b1d717f6e4e1..7d09f2c82c4b76 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts @@ -17,7 +17,7 @@ import { groupingFunctionDefinitions } from '../../definitions/grouping'; import * as autocomplete from '../autocomplete'; import type { ESQLCallbacks } from '../../shared/types'; import type { EditorContext, SuggestionRawDefinition } from '../types'; -import { TIME_SYSTEM_PARAMS } from '../factories'; +import { TIME_SYSTEM_PARAMS, getSafeInsertText } from '../factories'; import { getFunctionSignatures } from '../../definitions/helpers'; import { ESQLRealField } from '../../validation/types'; import { @@ -280,10 +280,7 @@ export function createCompletionContext(triggerCharacter?: string) { export function getPolicyFields(policyName: string) { return policies .filter(({ name }) => name === policyName) - .flatMap(({ enrichFields }) => - // ok, this is a bit of cheating as it's using the same logic as in the helper - enrichFields.map((field) => (/[^a-zA-Z\d_\.@]/.test(field) ? `\`${field}\`` : field)) - ); + .flatMap(({ enrichFields }) => enrichFields.map((field) => getSafeInsertText(field))); } export interface SuggestOptions { 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 80ff7148f86233..a58a55f124c4ee 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -392,27 +392,44 @@ describe('autocomplete', () => { describe('enrich', () => { const modes = ['any', 'coordinator', 'remote']; - const policyNames = policies.map(({ name, suggestedAs }) => suggestedAs || name); + const expectedPolicyNameSuggestions = policies + .map(({ name, suggestedAs }) => suggestedAs || name) + .map((name) => `${name} `); for (const prevCommand of [ '', // '| enrich other-policy ', // '| enrich other-policy on b ', // '| enrich other-policy with c ', ]) { - testSuggestions(`from a ${prevCommand}| enrich /`, policyNames); + testSuggestions(`from a ${prevCommand}| enrich /`, expectedPolicyNameSuggestions); testSuggestions( `from a ${prevCommand}| enrich _/`, modes.map((mode) => `_${mode}:$0`), '_' ); for (const mode of modes) { - testSuggestions(`from a ${prevCommand}| enrich _${mode}:/`, policyNames, ':'); - testSuggestions(`from a ${prevCommand}| enrich _${mode.toUpperCase()}:/`, policyNames, ':'); - testSuggestions(`from a ${prevCommand}| enrich _${camelCase(mode)}:/`, policyNames, ':'); + testSuggestions( + `from a ${prevCommand}| enrich _${mode}:/`, + expectedPolicyNameSuggestions, + ':' + ); + testSuggestions( + `from a ${prevCommand}| enrich _${mode.toUpperCase()}:/`, + expectedPolicyNameSuggestions, + ':' + ); + testSuggestions( + `from a ${prevCommand}| enrich _${camelCase(mode)}:/`, + expectedPolicyNameSuggestions, + ':' + ); } testSuggestions(`from a ${prevCommand}| enrich policy /`, ['ON $0', 'WITH $0', '| ']); - testSuggestions(`from a ${prevCommand}| enrich policy on /`, getFieldNamesByType('any')); - testSuggestions(`from a ${prevCommand}| enrich policy on b /`, ['WITH $0', ',', '| ']); + testSuggestions( + `from a ${prevCommand}| enrich policy on /`, + getFieldNamesByType('any').map((v) => `${v} `) + ); + testSuggestions(`from a ${prevCommand}| enrich policy on b /`, ['WITH $0', '| ']); testSuggestions( `from a ${prevCommand}| enrich policy on b with /`, ['var0 = ', ...getPolicyFields('policy')], @@ -453,14 +470,8 @@ describe('autocomplete', () => { // @TODO: get updated eval block from main describe('values suggestions', () => { - testSuggestions('FROM "a/"', ['a ', 'b '], undefined, [ - , - [ - { name: 'a', hidden: false }, - { name: 'b', hidden: false }, - ], - ]); - testSuggestions('FROM " /"', [], ' '); + testSuggestions('FROM "i/"', ['index'], undefined, [, [{ name: 'index', hidden: false }]]); + testSuggestions('FROM "index/"', ['index'], undefined, [, [{ name: 'index', hidden: false }]]); // TODO — re-enable these tests when we can support this case testSuggestions.skip('FROM " a/"', []); testSuggestions.skip('FROM "foo b/"', []); @@ -564,7 +575,7 @@ describe('autocomplete', () => { }); // FROM source - testSuggestions('FROM k/', ['index1 ', 'index2 '], undefined, [ + testSuggestions('FROM k/', ['index1', 'index2'], undefined, [ , [ { name: 'index1', hidden: false }, @@ -604,14 +615,17 @@ describe('autocomplete', () => { // ENRICH policy testSuggestions( 'FROM index1 | ENRICH p/', - policies.map(({ name }) => getSafeInsertText(name)) + policies.map(({ name }) => getSafeInsertText(name) + ' ') ); // ENRICH policy ON testSuggestions('FROM index1 | ENRICH policy O/', ['ON $0', 'WITH $0', '| ']); // ENRICH policy ON field - testSuggestions('FROM index1 | ENRICH policy ON f/', getFieldNamesByType('any')); + testSuggestions( + 'FROM index1 | ENRICH policy ON f/', + getFieldNamesByType('any').map((name) => `${name} `) + ); // ENRICH policy WITH policyfield testSuggestions('FROM index1 | ENRICH policy WITH v/', [ @@ -714,6 +728,10 @@ describe('autocomplete', () => { }); describe('advancing the cursor and opening the suggestion menu automatically ✨', () => { + /** + * NOTE: Monaco uses an Invoke trigger kind when the show suggestions action is triggered (e.g. accepting the "FROM" suggestion) + */ + const attachTriggerCommand = ( s: string | PartialSuggestionWithText ): PartialSuggestionWithText => @@ -819,24 +837,122 @@ describe('autocomplete', () => { ]); // 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, - [ - , + describe('sources', () => { + testSuggestions( + 'FROM /', [ - { name: 'index1', hidden: false }, - { name: 'index2', hidden: false }, + { text: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, ], - ] - ); + undefined, + [ + , + [ + { name: 'index1', hidden: false }, + { name: 'index2', hidden: false }, + ], + ] + ); + + testSuggestions( + 'FROM index/', + [ + { text: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, + ], + undefined, + [ + , + [ + { name: 'index1', hidden: false }, + { name: 'index2', hidden: false }, + ], + ] + ); + + testSuggestions( + 'FROM index1/', + [ + { text: 'index1 | ', filterText: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'index1, ', filterText: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'index1 METADATA ', filterText: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, + ], + undefined, + [ + , + [ + { name: 'index1', hidden: false }, + { name: 'index2', hidden: false }, + ], + ] + ); + + testSuggestions( + 'FROM index1, index2/', + [ + { text: 'index2 | ', filterText: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'index2, ', filterText: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'index2 METADATA ', filterText: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, + ], + undefined, + [ + , + [ + { name: 'index1', hidden: false }, + { name: 'index2', hidden: false }, + ], + ] + ); + + // This is a source name that contains a special character + // meaning that Monaco by default will only set the replacement + // range to cover "bar" and not "foo$bar". We have to make sure + // we're setting it ourselves. + testSuggestions( + 'FROM foo$bar/', + [ + { + text: 'foo$bar | ', + filterText: 'foo$bar', + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace: { start: 6, end: 13 }, + }, + { + text: 'foo$bar, ', + filterText: 'foo$bar', + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace: { start: 6, end: 13 }, + }, + { + text: 'foo$bar METADATA ', + filterText: 'foo$bar', + asSnippet: false, // important because the text includes "$" + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace: { start: 6, end: 13 }, + }, + ], + undefined, + [, [{ name: 'foo$bar', hidden: false }]] + ); + + // This is an identifier that matches multiple sources + testSuggestions( + 'FROM i*/', + [ + { text: 'i* | ', filterText: 'i*', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'i*, ', filterText: 'i*', command: TRIGGER_SUGGESTION_COMMAND }, + { text: 'i* METADATA ', filterText: 'i*', command: TRIGGER_SUGGESTION_COMMAND }, + ], + undefined, + [ + , + [ + { name: 'index1', hidden: false }, + { name: 'index2', hidden: false }, + ], + ] + ); + }); // FROM source METADATA testSuggestions('FROM index1 M/', [ @@ -845,6 +961,57 @@ describe('autocomplete', () => { '| ', ]); + describe('ENRICH', () => { + testSuggestions( + 'FROM a | ENRICH /', + policies.map((p) => `${getSafeInsertText(p.name)} `).map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a | ENRICH pol/', + policies + .map((p) => `${getSafeInsertText(p.name)} `) + .map(attachTriggerCommand) + .map((s) => ({ ...s, rangeToReplace: { start: 17, end: 20 } })) + ); + testSuggestions( + 'FROM a | ENRICH policy /', + ['ON $0', 'WITH $0', '| '].map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a | ENRICH policy ON /', + getFieldNamesByType('any') + .map((name) => `${name} `) + .map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a | ENRICH policy ON @timestamp /', + ['WITH $0', '| '].map(attachTriggerCommand) + ); + // nothing fancy with this field list + testSuggestions('FROM a | ENRICH policy ON @timestamp WITH /', [ + 'var0 = ', + ...getPolicyFields('policy').map((name) => ({ text: name, command: undefined })), + ]); + describe('replacement range', () => { + testSuggestions('FROM a | ENRICH policy ON @timestamp WITH othe/', [ + 'var0 = ', + ...getPolicyFields('policy').map((name) => ({ + text: name, + command: undefined, + rangeToReplace: { start: 43, end: 47 }, + })), + ]); + testSuggestions( + 'FROM a | ENRICH policy ON @timestamp WITH var0 = othe/', + getPolicyFields('policy').map((name) => ({ + text: name, + command: undefined, + rangeToReplace: { start: 50, end: 54 }, + })) + ); + }); + }); + // LIMIT number testSuggestions('FROM a | LIMIT /', ['10 ', '100 ', '1000 '].map(attachTriggerCommand)); @@ -937,80 +1104,126 @@ describe('autocomplete', () => { ['keyword'] ).map((s) => (s.text.toLowerCase().includes('null') ? s : attachTriggerCommand(s))) ); - describe('field lists', () => { - // KEEP field - testSuggestions('FROM a | KEEP /', getFieldNamesByType('any').map(attachTriggerCommand)); - testSuggestions( - 'FROM a | KEEP d/', - getFieldNamesByType('any') - .map((text) => ({ - text, - rangeToReplace: { start: 15, end: 16 }, - })) - .map(attachTriggerCommand) - ); - testSuggestions( - 'FROM a | KEEP doubleFiel/', - getFieldNamesByType('any').map(attachTriggerCommand) - ); - testSuggestions( - 'FROM a | KEEP doubleField/', - ['doubleField, ', 'doubleField | '] - .map((text) => ({ - text, - filterText: 'doubleField', - rangeToReplace: { start: 15, end: 26 }, - })) - .map(attachTriggerCommand) - ); - testSuggestions('FROM a | KEEP doubleField /', ['| ', ',']); - // Let's get funky with the field names - testSuggestions( - 'FROM a | KEEP @timestamp/', - ['@timestamp, ', '@timestamp | '] - .map((text) => ({ - text, - filterText: '@timestamp', - rangeToReplace: { start: 15, end: 25 }, - })) - .map(attachTriggerCommand), - undefined, - [[{ name: '@timestamp', type: 'date' }]] - ); - testSuggestions( - 'FROM a | KEEP foo.bar/', - ['foo.bar, ', 'foo.bar | '] - .map((text) => ({ - text, - filterText: 'foo.bar', - rangeToReplace: { start: 15, end: 22 }, - })) - .map(attachTriggerCommand), - undefined, - [[{ name: 'foo.bar', type: 'double' }]] - ); - - // @todo re-enable these tests when we can use AST to support this case - testSuggestions.skip('FROM a | KEEP `foo.bar`/', ['foo.bar, ', 'foo.bar | '], undefined, [ - [{ name: 'foo.bar', type: 'double' }], - ]); - testSuggestions.skip('FROM a | KEEP `foo`.`bar`/', ['foo.bar, ', 'foo.bar | '], undefined, [ - [{ name: 'foo.bar', type: 'double' }], - ]); - testSuggestions.skip('FROM a | KEEP `any#Char$Field`/', [ - '`any#Char$Field`, ', - '`any#Char$Field` | ', - ]); + describe('field lists', () => { + describe('METADATA ', () => { + // METADATA field + testSuggestions('FROM a METADATA /', METADATA_FIELDS.map(attachTriggerCommand)); + testSuggestions('FROM a METADATA _i/', METADATA_FIELDS.map(attachTriggerCommand)); + testSuggestions( + 'FROM a METADATA _id/', + [ + { filterText: '_id', text: '_id, ' }, + { filterText: '_id', text: '_id | ' }, + ].map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a METADATA _id, /', + METADATA_FIELDS.filter((field) => field !== '_id').map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a METADATA _id, _ignored/', + [ + { filterText: '_ignored', text: '_ignored, ' }, + { filterText: '_ignored', text: '_ignored | ' }, + ].map(attachTriggerCommand) + ); + // comma if there's even one more field + testSuggestions('FROM a METADATA _id, _ignored, _index, _source/', [ + { filterText: '_source', text: '_source | ', command: TRIGGER_SUGGESTION_COMMAND }, + { filterText: '_source', text: '_source, ', command: TRIGGER_SUGGESTION_COMMAND }, + ]); + // no comma if there are no more fields + testSuggestions('FROM a METADATA _id, _ignored, _index, _source, _version/', [ + { filterText: '_version', text: '_version | ', command: TRIGGER_SUGGESTION_COMMAND }, + ]); + }); - // Subsequent fields - testSuggestions( - 'FROM a | KEEP doubleField, dateFiel/', - getFieldNamesByType('any') - .filter((s) => s !== 'doubleField') - .map(attachTriggerCommand) - ); - testSuggestions('FROM a | KEEP doubleField, dateField/', ['dateField, ', 'dateField | ']); + describe('KEEP ', () => { + // KEEP field + testSuggestions('FROM a | KEEP /', getFieldNamesByType('any').map(attachTriggerCommand)); + testSuggestions( + 'FROM a | KEEP d/', + getFieldNamesByType('any') + .map((text) => ({ + text, + rangeToReplace: { start: 15, end: 16 }, + })) + .map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a | KEEP doubleFiel/', + getFieldNamesByType('any').map(attachTriggerCommand) + ); + testSuggestions( + 'FROM a | KEEP doubleField/', + ['doubleField, ', 'doubleField | '] + .map((text) => ({ + text, + filterText: 'doubleField', + rangeToReplace: { start: 15, end: 26 }, + })) + .map(attachTriggerCommand) + ); + testSuggestions('FROM a | KEEP doubleField /', ['| ', ',']); + + // Let's get funky with the field names + testSuggestions( + 'FROM a | KEEP @timestamp/', + ['@timestamp, ', '@timestamp | '] + .map((text) => ({ + text, + filterText: '@timestamp', + rangeToReplace: { start: 15, end: 25 }, + })) + .map(attachTriggerCommand), + undefined, + [[{ name: '@timestamp', type: 'date' }]] + ); + testSuggestions( + 'FROM a | KEEP foo.bar/', + ['foo.bar, ', 'foo.bar | '] + .map((text) => ({ + text, + filterText: 'foo.bar', + rangeToReplace: { start: 15, end: 22 }, + })) + .map(attachTriggerCommand), + undefined, + [[{ name: 'foo.bar', type: 'double' }]] + ); + + describe('escaped field names', () => { + // This isn't actually the behavior we want, but this test is here + // to make sure no weird suggestions start cropping up in this case. + testSuggestions('FROM a | KEEP `foo.bar`/', ['foo.bar'], undefined, [ + [{ name: 'foo.bar', type: 'double' }], + ]); + // @todo re-enable these tests when we can use AST to support this case + testSuggestions.skip('FROM a | KEEP `foo.bar`/', ['foo.bar, ', 'foo.bar | '], undefined, [ + [{ name: 'foo.bar', type: 'double' }], + ]); + testSuggestions.skip( + 'FROM a | KEEP `foo`.`bar`/', + ['foo.bar, ', 'foo.bar | '], + undefined, + [[{ name: 'foo.bar', type: 'double' }]] + ); + testSuggestions.skip('FROM a | KEEP `any#Char$Field`/', [ + '`any#Char$Field`, ', + '`any#Char$Field` | ', + ]); + }); + + // Subsequent fields + testSuggestions( + 'FROM a | KEEP doubleField, dateFiel/', + getFieldNamesByType('any') + .filter((s) => s !== 'doubleField') + .map(attachTriggerCommand) + ); + testSuggestions('FROM a | KEEP doubleField, dateField/', ['dateField, ', 'dateField | ']); + }); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index f80ebf862c0cfc..a1be88b0b1436e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -47,6 +47,8 @@ import { noCaseCompare, correctQuerySyntax, getColumnByName, + sourceExists, + findFinalWord, } from '../shared/helpers'; import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables'; import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; @@ -89,7 +91,7 @@ import { getPolicyHelper, getSourcesHelper, } from '../shared/resources_helpers'; -import { ESQLCallbacks } from '../shared/types'; +import { ESQLCallbacks, ESQLSourceResult } from '../shared/types'; import { getFunctionsToIgnoreForStats, getOverlapRange, @@ -107,12 +109,9 @@ import { isParameterType, isReturnType, } from '../definitions/types'; +import { metadataOption } from '../definitions/options'; import { comparisonFunctions } from '../definitions/builtin'; -type GetSourceFn = () => Promise; -type GetDataStreamsForIntegrationFn = ( - sourceName: string -) => Promise | undefined>; type GetFieldsByTypeFn = ( type: string | string[], ignored?: string[], @@ -178,8 +177,7 @@ export async function suggest( queryForFields, resourceRetriever ); - const getSources = getSourcesRetriever(resourceRetriever); - const getDatastreamsForIntegration = getDatastreamsForIntegrationRetriever(resourceRetriever); + const getSources = getSourcesHelper(resourceRetriever); const { getPolicies, getPolicyMetadata } = getPolicyRetriever(resourceRetriever); if (astContext.type === 'newCommand') { @@ -201,7 +199,6 @@ export async function suggest( ast, astContext, getSources, - getDatastreamsForIntegration, getFieldsByType, getFieldsMap, getPolicies, @@ -287,29 +284,15 @@ function getPolicyRetriever(resourceRetriever?: ESQLCallbacks) { }; } -function getSourcesRetriever(resourceRetriever?: ESQLCallbacks) { - const helper = getSourcesHelper(resourceRetriever); - return async () => { - const list = (await helper()) || []; - // hide indexes that start with . - return buildSourcesDefinitions( - list - .filter(({ hidden }) => !hidden) - .map(({ name, dataStreams, title, type }) => { - return { name, isIntegration: Boolean(dataStreams && dataStreams.length), title, type }; - }) - ); - }; -} - -function getDatastreamsForIntegrationRetriever( - resourceRetriever?: ESQLCallbacks -): GetDataStreamsForIntegrationFn { - const helper = getSourcesHelper(resourceRetriever); - return async (sourceName: string) => { - const list = (await helper()) || []; - return list.find(({ name }) => name === sourceName)?.dataStreams; - }; +function getSourceSuggestions(sources: ESQLSourceResult[]) { + // hide indexes that start with . + return buildSourcesDefinitions( + sources + .filter(({ hidden }) => !hidden) + .map(({ name, dataStreams, title, type }) => { + return { name, isIntegration: Boolean(dataStreams && dataStreams.length), title, type }; + }) + ); } function findNewVariable(variables: Map) { @@ -487,8 +470,7 @@ async function getExpressionSuggestionsByType( option: ESQLCommandOption | undefined; node: ESQLSingleAstItem | undefined; }, - getSources: GetSourceFn, - getDatastreamsForIntegration: GetDataStreamsForIntegrationFn, + getSources: () => Promise, getFieldsByType: GetFieldsByTypeFn, getFieldsMap: GetFieldsMapFn, getPolicies: GetPoliciesFn, @@ -626,13 +608,12 @@ async function getExpressionSuggestionsByType( ); /** - * @TODO — this string manipulation is crude and can't support all cases + * @TODO — this string scanning is crude and can't support all cases * Checking for a partial word and computing the replacement range should * really be done using the AST node, but we'll have to refactor further upstream * to make that available. This is a quick fix to support the most common case. */ - const words = innerText.split(/\s+/); - const lastWord = words[words.length - 1]; + const lastWord = findFinalWord(innerText); if (lastWord !== '') { // ... | @@ -649,16 +630,13 @@ async function getExpressionSuggestionsByType( } // now we know that the user has already entered a column, // so suggest comma and pipe - // const NON_ALPHANUMERIC_REGEXP = /[^a-zA-Z\d]/g; - // const textToUse = lastWord.replace(NON_ALPHANUMERIC_REGEXP, ''); - const textToUse = lastWord; return [ { ...pipeCompleteItem, text: ' | ' }, { ...commaCompleteItem, text: ', ' }, ].map((s) => ({ ...s, - filterText: textToUse, - text: textToUse + s.text, + filterText: lastWord, + text: lastWord + s.text, command: TRIGGER_SUGGESTION_COMMAND, rangeToReplace, })); @@ -912,9 +890,22 @@ async function getExpressionSuggestionsByType( if (argDef.innerTypes?.includes('policy')) { // ... | ENRICH const policies = await getPolicies(); + const lastWord = findFinalWord(innerText); + if (lastWord !== '') { + policies.forEach((suggestion) => { + suggestions.push({ + ...suggestion, + rangeToReplace: { + start: innerText.length - lastWord.length + 1, + end: innerText.length + 1, + }, + }); + }); + } suggestions.push(...(policies.length ? policies : [buildNoPoliciesAvailableDefinition()])); } else { - const index = getSourcesFromCommands(commands, 'index'); + const indexes = getSourcesFromCommands(commands, 'index'); + const lastIndex = indexes[indexes.length - 1]; const canRemoveQuote = isNewExpression && innerText.includes('"'); // Function to add suggestions based on canRemoveQuote const addSuggestionsBasedOnQuote = async (definitions: SuggestionRawDefinition[]) => { @@ -923,24 +914,58 @@ async function getExpressionSuggestionsByType( ); }; - if (index && index.text && index.text !== EDITOR_MARKER) { - const source = index.text.replace(EDITOR_MARKER, ''); - const dataStreams = await getDatastreamsForIntegration(source); + if (lastIndex && lastIndex.text && lastIndex.text !== EDITOR_MARKER) { + const sources = await getSources(); + const sourceIdentifier = lastIndex.text.replace(EDITOR_MARKER, ''); + if (sourceExists(sourceIdentifier, new Set(sources.map(({ name }) => name)))) { + const exactMatch = sources.find(({ name: _name }) => _name === sourceIdentifier); + if (exactMatch?.dataStreams) { + // this is an integration name, suggest the datastreams + addSuggestionsBasedOnQuote( + buildSourcesDefinitions( + exactMatch.dataStreams.map(({ name }) => ({ name, isIntegration: false })) + ) + ); + } else { + // this is a complete source name + const rangeToReplace = { + start: innerText.length - sourceIdentifier.length + 1, + end: innerText.length + 1, + }; - if (dataStreams) { - // Integration name, suggest the datastreams - await addSuggestionsBasedOnQuote( - buildSourcesDefinitions( - dataStreams.map(({ name }) => ({ name, isIntegration: false })) - ) - ); + const suggestionsToAdd: SuggestionRawDefinition[] = [ + { + ...pipeCompleteItem, + filterText: sourceIdentifier, + text: sourceIdentifier + ' | ', + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + }, + { + ...commaCompleteItem, + filterText: sourceIdentifier, + text: sourceIdentifier + ', ', + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + }, + { + ...buildOptionDefinition(metadataOption), + filterText: sourceIdentifier, + text: sourceIdentifier + ' METADATA ', + asSnippet: false, // turn this off because $ could be contained within the source name + rangeToReplace, + }, + ]; + + addSuggestionsBasedOnQuote(suggestionsToAdd); + } } else { - // Not an integration, just a partial source name - await addSuggestionsBasedOnQuote(await getSources()); + // Just a partial source name + await addSuggestionsBasedOnQuote(getSourceSuggestions(sources)); } } else { // FROM or no index/text - await addSuggestionsBasedOnQuote(await getSources()); + await addSuggestionsBasedOnQuote(getSourceSuggestions(await getSources())); } } } @@ -1541,7 +1566,7 @@ async function getOptionArgsSuggestions( // if it's a new expression, suggest fields to match on if ( isNewExpression || - findPreviousWord(innerText) === 'ON' || + noCaseCompare(findPreviousWord(innerText), 'ON') || (option && isAssignment(option.args[0]) && !option.args[1]) ) { const policyName = isSourceItem(command.args[0]) ? command.args[0].name : undefined; @@ -1561,7 +1586,7 @@ async function getOptionArgsSuggestions( suggestions.push( buildOptionDefinition(getCommandOption('with')!), ...getFinalSuggestions({ - comma: true, + comma: false, }) ); } @@ -1588,7 +1613,23 @@ async function getOptionArgsSuggestions( if (policyMetadata) { if (isNewExpression || (assignFn && !isAssignmentComplete(assignFn))) { // ... | ENRICH ... WITH a = - suggestions.push(...buildFieldsDefinitions(policyMetadata.enrichFields)); + // ... | ENRICH ... WITH b + const fieldSuggestions = buildFieldsDefinitions(policyMetadata.enrichFields); + // in this case, we don't want to open the suggestions menu when the field is accepted + // because we're keeping the suggestions simple here for now. Could always revisit. + fieldSuggestions.forEach((s) => (s.command = undefined)); + + // attach the replacement range if needed + const lastWord = findFinalWord(innerText); + if (lastWord) { + // ENRICH ... WITH a + const rangeToReplace = { + start: innerText.length - lastWord.length + 1, + end: innerText.length + 1, + }; + fieldSuggestions.forEach((s) => (s.rangeToReplace = rangeToReplace)); + } + suggestions.push(...fieldSuggestions); } } @@ -1640,13 +1681,41 @@ async function getOptionArgsSuggestions( if (option.name === 'metadata') { const existingFields = new Set(option.args.filter(isColumnItem).map(({ name }) => name)); const filteredMetaFields = METADATA_FIELDS.filter((name) => !existingFields.has(name)); - if (isNewExpression) { + const lastWord = findFinalWord(innerText); + if (lastWord) { + // METADATA something + const isField = METADATA_FIELDS.includes(lastWord); + if (isField) { + // METADATA field + suggestions.push({ + ...pipeCompleteItem, + text: lastWord + ' | ', + filterText: lastWord, + command: TRIGGER_SUGGESTION_COMMAND, + }); + if (filteredMetaFields.length > 1) { + suggestions.push({ + ...commaCompleteItem, + text: lastWord + ', ', + filterText: lastWord, + command: TRIGGER_SUGGESTION_COMMAND, + }); + } + } else { + suggestions.push(...buildFieldsDefinitions(filteredMetaFields)); + } + } else if (isNewExpression) { + // METADATA + // METADATA field, suggestions.push(...buildFieldsDefinitions(filteredMetaFields)); - } else if (existingFields.size > 0) { - if (filteredMetaFields.length > 0) { - suggestions.push(commaCompleteItem); + } else { + if (existingFields.size > 0) { + // METADATA field + if (filteredMetaFields.length > 0) { + suggestions.push(commaCompleteItem); + } + suggestions.push(pipeCompleteItem); } - suggestions.push(pipeCompleteItem); } } diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index d085588c934e72..b33d705711f05e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -175,6 +175,7 @@ export const buildFieldsDefinitions = (fields: string[]): SuggestionRawDefinitio defaultMessage: `Field specified by the input table`, }), sortText: 'D', + command: TRIGGER_SUGGESTION_COMMAND, })); }; export const buildVariablesDefinitions = (variables: string[]): SuggestionRawDefinition[] => @@ -196,7 +197,7 @@ export const buildSourcesDefinitions = ( ): SuggestionRawDefinition[] => sources.map(({ name, isIntegration, title, type }) => ({ label: title ?? name, - text: getSafeInsertSourceText(name) + (!isIntegration ? ' ' : ''), + text: getSafeInsertSourceText(name), isSnippet: isIntegration, kind: isIntegration ? 'Class' : 'Issue', detail: isIntegration @@ -272,7 +273,7 @@ export const buildPoliciesDefinitions = ( ): SuggestionRawDefinition[] => policies.map(({ name: label, sourceIndices }) => ({ label, - text: getSafeInsertText(label, { dashSupported: true }), + text: getSafeInsertText(label, { dashSupported: true }) + ' ', kind: 'Class', detail: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.policyDefinition', { defaultMessage: `Policy defined on {count, plural, one {index} other {indices}}: {indices}`, @@ -282,6 +283,7 @@ export const buildPoliciesDefinitions = ( }, }), sortText: 'D', + command: TRIGGER_SUGGESTION_COMMAND, })); export const buildMatchingFieldsDefinition = ( @@ -290,7 +292,7 @@ export const buildMatchingFieldsDefinition = ( ): SuggestionRawDefinition[] => fields.map((label) => ({ label, - text: getSafeInsertText(label), + text: getSafeInsertText(label) + ' ', kind: 'Variable', detail: i18n.translate( 'kbn-esql-validation-autocomplete.esql.autocomplete.matchingFieldDefinition', @@ -302,6 +304,7 @@ export const buildMatchingFieldsDefinition = ( } ), sortText: 'D', + command: TRIGGER_SUGGESTION_COMMAND, })); export const buildOptionDefinition = ( diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index deba0b045563d2..f42d7de5a38ab2 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -94,9 +94,7 @@ export function getQueryForFields(queryString: string, commands: ESQLCommand[]) export function getSourcesFromCommands(commands: ESQLCommand[], sourceType: 'index' | 'policy') { const fromCommand = commands.find(({ name }) => name === 'from'); const args = (fromCommand?.args ?? []) as ESQLSource[]; - const sources = args.filter((arg) => arg.sourceType === sourceType); - - return sources.length === 1 ? sources[0] : undefined; + return args.filter((arg) => arg.sourceType === sourceType); } export function removeQuoteForSuggestedSources(suggestions: SuggestionRawDefinition[]) { diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts index eb51bfa79227ba..d58101e9ff8ebe 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -640,6 +640,16 @@ export function findPreviousWord(text: string) { return words[words.length - 2]; } +/** + * Returns the word at the end of the text if there is one. + * @param text + * @returns + */ +export function findFinalWord(text: string) { + const words = text.split(/\s+/); + return words[words.length - 1]; +} + export function shouldBeQuotedSource(text: string) { // Based on lexer `fragment UNQUOTED_SOURCE_PART` return /[:"=|,[\]\/ \t\r\n]/.test(text); diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/types.ts b/packages/kbn-esql-validation-autocomplete/src/shared/types.ts index 45f1fc0e9311dc..d27b84dc4cb274 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/types.ts @@ -13,17 +13,16 @@ import type { ESQLRealField } from '../validation/types'; type CallbackFn = (ctx?: Options) => Result[] | Promise; /** @public **/ +export interface ESQLSourceResult { + name: string; + hidden: boolean; + title?: string; + dataStreams?: Array<{ name: string; title?: string }>; + type?: string; +} + export interface ESQLCallbacks { - getSources?: CallbackFn< - {}, - { - name: string; - hidden: boolean; - title?: string; - dataStreams?: Array<{ name: string; title?: string }>; - type?: string; - } - >; + getSources?: CallbackFn<{}, ESQLSourceResult>; getFieldsFor?: CallbackFn<{ query: string }, ESQLRealField>; getPolicies?: CallbackFn< {},