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 bfb8c8e2b16bf..42a6d4c4fd64d 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -1358,4 +1358,91 @@ describe('autocomplete', () => { ).map((s) => (s.text.toLowerCase().includes('null') ? s : attachTriggerCommand(s))) ); }); + + describe('Replacement ranges are attached when needed', () => { + testSuggestions('FROM a | WHERE doubleField IS NOT N/', [ + { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 35 } }, + { text: 'IS NULL', rangeToReplace: { start: 35, end: 35 } }, + '!= $0', + '< $0', + '<= $0', + '== $0', + '> $0', + '>= $0', + 'IN $0', + ]); + testSuggestions('FROM a | WHERE doubleField IS N/', [ + { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 31 } }, + { text: 'IS NULL', rangeToReplace: { start: 28, end: 31 } }, + { text: '!= $0', rangeToReplace: { start: 31, end: 31 } }, + '< $0', + '<= $0', + '== $0', + '> $0', + '>= $0', + 'IN $0', + ]); + testSuggestions('FROM a | EVAL doubleField IS NOT N/', [ + { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 34 } }, + 'IS NULL', + '% $0', + '* $0', + '+ $0', + '- $0', + '/ $0', + '!= $0', + '< $0', + '<= $0', + '== $0', + '> $0', + '>= $0', + 'IN $0', + ]); + testSuggestions('FROM a | SORT doubleField IS NOT N/', [ + { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 34 } }, + 'IS NULL', + '% $0', + '* $0', + '+ $0', + '- $0', + '/ $0', + '!= $0', + '< $0', + '<= $0', + '== $0', + '> $0', + '>= $0', + 'IN $0', + ]); + describe('dot-separated field names', () => { + testSuggestions( + 'FROM a | KEEP field.nam/', + [{ text: 'field.name', rangeToReplace: { start: 15, end: 23 } }], + undefined, + [[{ name: 'field.name', type: 'double' }]] + ); + // multi-line + testSuggestions( + 'FROM a\n| KEEP field.nam/', + [{ text: 'field.name', rangeToReplace: { start: 15, end: 23 } }], + undefined, + [[{ name: 'field.name', type: 'double' }]] + ); + // triple separator + testSuggestions( + 'FROM a\n| KEEP field.name.f/', + [{ text: 'field.name.foo', rangeToReplace: { start: 15, end: 26 } }], + undefined, + [[{ name: 'field.name.foo', type: 'double' }]] + ); + // whitespace — we can't support this case yet because + // we are relying on string checking instead of the AST :( + testSuggestions.skip( + 'FROM a | KEEP field . n/', + [{ text: 'field . name', rangeToReplace: { start: 15, end: 23 } }], + undefined, + [[{ name: 'field.name', type: 'double' }]] + ); + }); + }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 1ccc6d6e62bf5..650c2326fe007 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -88,6 +88,7 @@ import { import { ESQLCallbacks } from '../shared/types'; import { getFunctionsToIgnoreForStats, + getOverlapRange, getParamAtPosition, getQueryForFields, getSourcesFromCommands, @@ -656,31 +657,52 @@ async function getExpressionSuggestionsByType( } // Suggest fields or variables if (argDef.type === 'column' || argDef.type === 'any') { - // ... | if ((!nodeArg || isNewExpression) && !endsWithNot) { - suggestions.push( - ...(await getFieldsOrFunctionsSuggestions( - argDef.innerTypes ?? ['any'], - command.name, - option?.name, - getFieldsByType, - { - // TODO instead of relying on canHaveAssignments and other command name checks - // we should have a more generic way to determine if a command can have functions. - // I think it comes down to the definition of 'column' since 'any' should always - // include functions. - functions: canHaveAssignments || command.name === 'sort', - fields: !argDef.constantOnly, - variables: anyVariables, - literals: argDef.constantOnly, - }, - { - ignoreFields: isNewExpression - ? command.args.filter(isColumnItem).map(({ name }) => name) - : [], - } - )) + const fieldSuggestions = await getFieldsOrFunctionsSuggestions( + argDef.innerTypes || ['any'], + command.name, + option?.name, + getFieldsByType, + { + // TODO instead of relying on canHaveAssignments and other command name checks + // we should have a more generic way to determine if a command can have functions. + // I think it comes down to the definition of 'column' since 'any' should always + // include functions. + functions: canHaveAssignments || command.name === 'sort', + fields: !argDef.constantOnly, + variables: anyVariables, + literals: argDef.constantOnly, + }, + { + ignoreFields: isNewExpression + ? command.args.filter(isColumnItem).map(({ name }) => name) + : [], + } ); + + /** + * @TODO — this string manipulation 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]; + if (lastWord !== '') { + // ... | + suggestions.push( + ...fieldSuggestions.map((suggestion) => ({ + ...suggestion, + rangeToReplace: { + start: innerText.length - lastWord.length + 1, + end: innerText.length, + }, + })) + ); + } else { + // ... | + suggestions.push(...fieldSuggestions); + } } } if (argDef.type === 'function' || argDef.type === 'any') { @@ -784,6 +806,7 @@ async function getExpressionSuggestionsByType( const nodeArgType = extractFinalTypeFromArg(nodeArg, references); suggestions.push( ...(await getBuiltinFunctionNextArgument( + innerText, command, option, argDef, @@ -859,7 +882,7 @@ async function getExpressionSuggestionsByType( // i.e. ... | field >= // i.e. ... | field > 0 // i.e. ... | field + otherN - + // "FROM a | WHERE doubleField IS NOT N" if (nodeArgType) { if (isFunctionItem(nodeArg)) { if (nodeArg.name === 'not') { @@ -879,6 +902,7 @@ async function getExpressionSuggestionsByType( } else { suggestions.push( ...(await getBuiltinFunctionNextArgument( + innerText, command, option, argDef, @@ -988,6 +1012,7 @@ async function getExpressionSuggestionsByType( } async function getBuiltinFunctionNextArgument( + queryText: string, command: ESQLCommand, option: ESQLCommandOption | undefined, argDef: { type: string }, @@ -1072,7 +1097,16 @@ async function getBuiltinFunctionNextArgument( } } } - return suggestions; + return suggestions.map((s) => { + const overlap = getOverlapRange(queryText, s.text); + return { + ...s, + rangeToReplace: { + start: overlap.start, + end: overlap.end, + }, + }; + }); } function pushItUpInTheList(suggestions: SuggestionRawDefinition[], shouldPromote: boolean) { @@ -1636,6 +1670,7 @@ async function getOptionArgsSuggestions( if (isFunctionItem(nodeArg) && !isFunctionArgComplete(nodeArg, references).complete) { suggestions.push( ...(await getBuiltinFunctionNextArgument( + innerText, command, option, { type: argDef?.type || 'any' }, diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index 65f6601c51c13..7d4c8c9cc111e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -92,3 +92,42 @@ export function getSupportedTypesForBinaryOperators( .map(({ params }) => params[1].type) : [previousType]; } + +/** + * Checks the suggestion text for overlap with the current query. + * + * This is useful to determine the range of the existing query that should be + * replaced if the suggestion is accepted. + * + * For example + * QUERY: FROM source | WHERE field IS NO + * SUGGESTION: IS NOT NULL + * + * The overlap is "IS NO" and the range to replace is "IS NO" in the query. + * + * @param query + * @param suggestionText + * @returns + */ +export function getOverlapRange( + query: string, + suggestionText: string +): { start: number; end: number } { + let overlapLength = 0; + + // Convert both strings to lowercase for case-insensitive comparison + const lowerQuery = query.toLowerCase(); + const lowerSuggestionText = suggestionText.toLowerCase(); + + for (let i = 0; i <= lowerSuggestionText.length; i++) { + const substr = lowerSuggestionText.substring(0, i); + if (lowerQuery.endsWith(substr)) { + overlapLength = i; + } + } + + return { + start: Math.min(query.length - overlapLength + 1, query.length), + end: query.length, + }; +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts index f431eb9a27145..e132d5edb6b8f 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts @@ -57,6 +57,13 @@ export interface SuggestionRawDefinition { title: string; id: string; }; + /** + * The range that should be replaced when the suggestion is applied + */ + rangeToReplace?: { + start: number; + end: number; + }; } export interface EditorContext { diff --git a/packages/kbn-monaco/src/esql/language.ts b/packages/kbn-monaco/src/esql/language.ts index 7c49da41a996e..4c629f6b060bb 100644 --- a/packages/kbn-monaco/src/esql/language.ts +++ b/packages/kbn-monaco/src/esql/language.ts @@ -102,7 +102,8 @@ export const ESQLLang: CustomLangModuleType = { ); const suggestionEntries = await astAdapter.autocomplete(model, position, context); return { - suggestions: wrapAsMonacoSuggestions(suggestionEntries.suggestions), + // @ts-expect-error because of range typing: https://github.com/microsoft/monaco-editor/issues/4638 + suggestions: wrapAsMonacoSuggestions(suggestionEntries), }; }, }; diff --git a/packages/kbn-monaco/src/esql/lib/converters/suggestions.ts b/packages/kbn-monaco/src/esql/lib/converters/suggestions.ts index 0d02a19dda070..d26169d59647b 100644 --- a/packages/kbn-monaco/src/esql/lib/converters/suggestions.ts +++ b/packages/kbn-monaco/src/esql/lib/converters/suggestions.ts @@ -6,29 +6,34 @@ * Side Public License, v 1. */ -import type { SuggestionRawDefinition } from '@kbn/esql-validation-autocomplete'; import { monaco } from '../../../monaco_imports'; -import { MonacoAutocompleteCommandDefinition } from '../types'; +import { + MonacoAutocompleteCommandDefinition, + SuggestionRawDefinitionWithMonacoRange, +} from '../types'; export function wrapAsMonacoSuggestions( - suggestions: SuggestionRawDefinition[] + suggestions: SuggestionRawDefinitionWithMonacoRange[] ): MonacoAutocompleteCommandDefinition[] { - return suggestions.map( - ({ label, text, asSnippet, kind, detail, documentation, sortText, command }) => ({ - label, - insertText: text, - kind: - kind in monaco.languages.CompletionItemKind - ? monaco.languages.CompletionItemKind[kind] - : monaco.languages.CompletionItemKind.Method, // fallback to Method - detail, - documentation, - sortText, - command, - insertTextRules: asSnippet - ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet - : undefined, - range: undefined as unknown as monaco.IRange, - }) + return suggestions.map( + ({ label, text, asSnippet, kind, detail, documentation, sortText, command, range }) => { + const monacoSuggestion: MonacoAutocompleteCommandDefinition = { + label, + insertText: text, + kind: + kind in monaco.languages.CompletionItemKind + ? monaco.languages.CompletionItemKind[kind] + : monaco.languages.CompletionItemKind.Method, // fallback to Method + detail, + documentation, + sortText, + command, + insertTextRules: asSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range, + }; + return monacoSuggestion; + } ); } diff --git a/packages/kbn-monaco/src/esql/lib/esql_ast_provider.ts b/packages/kbn-monaco/src/esql/lib/esql_ast_provider.ts index 8aa2e16979c5b..95f31764d7a78 100644 --- a/packages/kbn-monaco/src/esql/lib/esql_ast_provider.ts +++ b/packages/kbn-monaco/src/esql/lib/esql_ast_provider.ts @@ -16,8 +16,9 @@ import { monaco } from '../../monaco_imports'; import type { ESQLWorker } from '../worker/esql_worker'; import { wrapAsMonacoMessages } from './converters/positions'; import { getHoverItem } from './hover/hover'; -import { monacoPositionToOffset } from './shared/utils'; +import { monacoPositionToOffset, offsetRangeToMonacoRange } from './shared/utils'; import { getSignatureHelp } from './signature'; +import { SuggestionRawDefinitionWithMonacoRange } from './types'; export class ESQLAstAdapter { constructor( @@ -66,17 +67,17 @@ export class ESQLAstAdapter { model: monaco.editor.ITextModel, position: monaco.Position, context: monaco.languages.CompletionContext - ) { + ): Promise { const getAstFn = await this.getAstWorker(model); const fullText = model.getValue(); const offset = monacoPositionToOffset(fullText, position); - const suggestionEntries = await suggest(fullText, offset, context, getAstFn, this.callbacks); - return { - suggestions: suggestionEntries.map((suggestion) => ({ - ...suggestion, - range: undefined as unknown as monaco.IRange, - })), - }; + const suggestions = await suggest(fullText, offset, context, getAstFn, this.callbacks); + for (const s of suggestions) { + (s as SuggestionRawDefinitionWithMonacoRange).range = s.rangeToReplace + ? offsetRangeToMonacoRange(fullText, s.rangeToReplace) + : undefined; + } + return suggestions; } async codeAction( diff --git a/packages/kbn-monaco/src/esql/lib/shared/utils.ts b/packages/kbn-monaco/src/esql/lib/shared/utils.ts index 9f2d6d4f22545..e09362cb5c83f 100644 --- a/packages/kbn-monaco/src/esql/lib/shared/utils.ts +++ b/packages/kbn-monaco/src/esql/lib/shared/utils.ts @@ -11,11 +11,68 @@ import type { monaco } from '../../../monaco_imports'; // From Monaco position to linear offset export function monacoPositionToOffset(expression: string, position: monaco.Position): number { const lines = expression.split(/\n/); - return lines - .slice(0, position.lineNumber) - .reduce( - (prev, current, index) => - prev + (index === position.lineNumber - 1 ? position.column - 1 : current.length + 1), - 0 - ); + let offset = 0; + + for (let i = 0; i < position.lineNumber - 1; i++) { + offset += lines[i].length + 1; // +1 for the newline character + } + + offset += position.column - 1; + + return offset; } + +/** + * Given an offset range, returns a monaco IRange object. + * @param expression + * @param range + * @returns + */ +export const offsetRangeToMonacoRange = ( + expression: string, + range: { start: number; end: number } +): { + startColumn: number; + endColumn: number; + startLineNumber: number; + endLineNumber: number; +} => { + let startColumn = 0; + let endColumn = 0; + let currentOffset = 0; + + let startLineNumber = 1; + let endLineNumber = 1; + let currentLine = 1; + + for (let i = 0; i < expression.length; i++) { + if (expression[i] === '\n') { + currentLine++; + currentOffset = i + 1; + } + + if (i === range.start) { + startLineNumber = currentLine; + startColumn = i - currentOffset; + } + + if (i === range.end) { + endLineNumber = currentLine; + endColumn = i - currentOffset; + break; // No need to continue once we find the end position + } + } + + // Handle the case where the end offset is at the end of the string + if (range.end === expression.length) { + endLineNumber = currentLine; + endColumn = expression.length - currentOffset; + } + + return { + startColumn, + endColumn, + startLineNumber, + endLineNumber, + }; +}; diff --git a/packages/kbn-monaco/src/esql/lib/types.ts b/packages/kbn-monaco/src/esql/lib/types.ts index 6bd51a1690067..ebe7459fef983 100644 --- a/packages/kbn-monaco/src/esql/lib/types.ts +++ b/packages/kbn-monaco/src/esql/lib/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { SuggestionRawDefinition } from '@kbn/esql-validation-autocomplete'; import { monaco } from '../../monaco_imports'; export type MonacoAutocompleteCommandDefinition = Pick< @@ -18,7 +19,13 @@ export type MonacoAutocompleteCommandDefinition = Pick< | 'sortText' | 'insertTextRules' | 'command' - | 'range' ->; +> & { range?: monaco.IRange }; export type MonacoCodeAction = monaco.languages.CodeAction; + +export type SuggestionRawDefinitionWithMonacoRange = Omit< + SuggestionRawDefinition, + 'rangeToReplace' +> & { + range?: monaco.IRange; +};