Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ES|QL] Support replacement range #190465

Merged
merged 9 commits into from
Aug 19, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing this behavior in the editor with from kibana_sample_data_flights | where AvgTicketPrice is n. Makes sense that it suggests IS NOT NULL and IS NULL where the cursor is but why the rest?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a choice: make it so that the range only gets added for IS NOT NULL and IS NULL or just add it in a generic way for all.

I opted for the latter because

  • including an empty range is valid and does no harm
  • it saves us adding a special case
    • if new operators with spaces are ever added, it will work automatically

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Thanks for the context!

'< $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' }]]
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
import { ESQLCallbacks } from '../shared/types';
import {
getFunctionsToIgnoreForStats,
getOverlapRange,
getParamAtPosition,
getQueryForFields,
getSourcesFromCommands,
Expand Down Expand Up @@ -656,31 +657,52 @@ async function getExpressionSuggestionsByType(
}
// Suggest fields or variables
if (argDef.type === 'column' || argDef.type === 'any') {
// ... | <COMMAND> <suggest>
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make an issue for this todo to keep track or is that already existing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. There is no issue. But, using the AST more is a key tenant of my vision for a re-architecture of this code. In other words, I am tracking this but I think an RFC/architecture document is the best first step here. From that, we can create more specific issues.

* 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 !== '') {
// ... | <COMMAND> <word><suggest>
suggestions.push(
...fieldSuggestions.map((suggestion) => ({
...suggestion,
rangeToReplace: {
start: innerText.length - lastWord.length + 1,
end: innerText.length,
},
}))
);
} else {
// ... | <COMMAND> <suggest>
suggestions.push(...fieldSuggestions);
}
}
}
if (argDef.type === 'function' || argDef.type === 'any') {
Expand Down Expand Up @@ -784,6 +806,7 @@ async function getExpressionSuggestionsByType(
const nodeArgType = extractFinalTypeFromArg(nodeArg, references);
suggestions.push(
...(await getBuiltinFunctionNextArgument(
innerText,
command,
option,
argDef,
Expand Down Expand Up @@ -859,7 +882,7 @@ async function getExpressionSuggestionsByType(
// i.e. ... | <COMMAND> field >= <suggest>
// i.e. ... | <COMMAND> field > 0 <suggest>
// i.e. ... | <COMMAND> field + otherN <suggest>

// "FROM a | WHERE doubleField IS NOT N"
if (nodeArgType) {
if (isFunctionItem(nodeArg)) {
if (nodeArg.name === 'not') {
Expand All @@ -879,6 +902,7 @@ async function getExpressionSuggestionsByType(
} else {
suggestions.push(
...(await getBuiltinFunctionNextArgument(
innerText,
command,
option,
argDef,
Expand Down Expand Up @@ -988,6 +1012,7 @@ async function getExpressionSuggestionsByType(
}

async function getBuiltinFunctionNextArgument(
queryText: string,
command: ESQLCommand,
option: ESQLCommandOption | undefined,
argDef: { type: string },
Expand Down Expand Up @@ -1072,7 +1097,16 @@ async function getBuiltinFunctionNextArgument(
}
}
}
return suggestions;
return suggestions.map<SuggestionRawDefinition>((s) => {
const overlap = getOverlapRange(queryText, s.text);
return {
...s,
rangeToReplace: {
start: overlap.start,
end: overlap.end,
},
};
});
}

function pushItUpInTheList(suggestions: SuggestionRawDefinition[], shouldPromote: boolean) {
Expand Down Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/kbn-monaco/src/esql/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ export const ESQLLang: CustomLangModuleType<ESQLCallbacks> = {
);
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),
};
},
};
Expand Down
45 changes: 25 additions & 20 deletions packages/kbn-monaco/src/esql/lib/converters/suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MonacoAutocompleteCommandDefinition>(
({ 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;
}
);
}
19 changes: 10 additions & 9 deletions packages/kbn-monaco/src/esql/lib/esql_ast_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -66,17 +67,17 @@ export class ESQLAstAdapter {
model: monaco.editor.ITextModel,
position: monaco.Position,
context: monaco.languages.CompletionContext
) {
): Promise<SuggestionRawDefinitionWithMonacoRange[]> {
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(
Expand Down
Loading