[ES|QL] Auto open suggestions for field lists (#190466)

## Summary

Part of https://github.com/elastic/kibana/issues/189662

## Suggests comma and pipe


https://github.com/user-attachments/assets/c09bc6fd-20a6-4f42-a871-c70e68e6d81a

## Doesn't suggest comma when there are no more fields


https://github.com/user-attachments/assets/29fce13e-e58b-4d93-bce5-6b1f913b4d92

## Doesn't work for escaped columns :(


https://github.com/user-attachments/assets/3d65f3b9-923d-4c0e-9c50-51dd83115c8b

As part of this effort I discovered
https://github.com/elastic/kibana/issues/191100 and
https://github.com/elastic/kibana/issues/191105, as well as a problem
with column name validation (see
https://github.com/elastic/kibana/issues/177699)

I think we can revisit column escaping and probably resolve all of these
issues (issue [here](https://github.com/elastic/kibana/issues/191111)).


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Drew Tate 2024-08-23 11:17:03 -06:00 committed by GitHub
parent bb7466e443
commit 15ef37f2fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 240 additions and 77 deletions

View file

@ -49,7 +49,7 @@ export {
getCommandDefinition,
getAllCommands,
getCommandOption,
lookupColumn,
getColumnForASTNode as lookupColumn,
shouldBeQuotedText,
printFunctionSignature,
checkFunctionArgMatchesDefinition as isEqualType,

View file

@ -23,6 +23,7 @@ import {
dataTypes,
fieldTypes,
isFieldType,
FunctionParameter,
} from '../src/definitions/types';
import { FUNCTION_DESCRIBE_BLOCK_NAME } from '../src/validation/function_describe_block_name';
import { getMaxMinNumberOfParams } from '../src/validation/helpers';
@ -1155,7 +1156,7 @@ function generateIncorrectlyTypedParameters(
signatures: FunctionDefinition['signatures'],
currentParams: FunctionDefinition['signatures'][number]['params'],
availableFields: Array<{ name: string; type: SupportedDataType }>
) {
): { wrongFieldMapping: FunctionParameter[]; expectedErrors: string[] } {
const literalValues = {
string: `"a"`,
number: '5',
@ -1260,7 +1261,7 @@ function generateIncorrectlyTypedParameters(
})
.filter(nonNullable);
return { wrongFieldMapping, expectedErrors };
return { wrongFieldMapping: wrongFieldMapping as FunctionParameter[], expectedErrors };
}
/**

View file

@ -20,7 +20,9 @@ import { camelCase, partition } from 'lodash';
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
import {
FunctionParameter,
FunctionReturnType,
isFieldType,
isReturnType,
isSupportedDataType,
SupportedDataType,
} from '../definitions/types';
@ -753,7 +755,9 @@ describe('autocomplete', () => {
);
const getTypesFromParamDefs = (paramDefs: FunctionParameter[]): SupportedDataType[] =>
Array.from(new Set(paramDefs.map((p) => p.type))).filter(isSupportedDataType);
Array.from(new Set(paramDefs.map((p) => p.type))).filter(
isSupportedDataType
) as SupportedDataType[];
const suggestedConstants = param.literalSuggestions || param.literalOptions;
@ -778,7 +782,9 @@ describe('autocomplete', () => {
),
...getFunctionSignaturesByReturnType(
'eval',
getTypesFromParamDefs(acceptsFieldParamDefs),
getTypesFromParamDefs(acceptsFieldParamDefs).filter(
isReturnType
) as FunctionReturnType[],
{ scalar: true },
undefined,
[fn.name]
@ -802,7 +808,7 @@ describe('autocomplete', () => {
),
...getFunctionSignaturesByReturnType(
'eval',
getTypesFromParamDefs(acceptsFieldParamDefs),
getTypesFromParamDefs(acceptsFieldParamDefs) as FunctionReturnType[],
{ scalar: true },
undefined,
[fn.name]
@ -851,13 +857,7 @@ describe('autocomplete', () => {
],
' '
);
testSuggestions('from a | eval a = 1 year /', [
',',
'| ',
...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [
'time_interval',
]),
]);
testSuggestions('from a | eval a = 1 year /', [',', '| ', 'IS NOT NULL', 'IS NULL']);
testSuggestions('from a | eval a = 1 day + 2 /', [',', '| ']);
testSuggestions(
'from a | eval 1 day + 2 /',
@ -1357,6 +1357,81 @@ 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<PartialSuggestionWithText>((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` | ',
]);
// 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('Replacement ranges are attached when needed', () => {
@ -1417,21 +1492,21 @@ describe('autocomplete', () => {
describe('dot-separated field names', () => {
testSuggestions(
'FROM a | KEEP field.nam/',
[{ text: 'field.name', rangeToReplace: { start: 15, end: 23 } }],
[{ text: 'field.name', rangeToReplace: { start: 15, end: 24 } }],
undefined,
[[{ name: 'field.name', type: 'double' }]]
);
// multi-line
testSuggestions(
'FROM a\n| KEEP field.nam/',
[{ text: 'field.name', rangeToReplace: { start: 15, end: 23 } }],
[{ text: 'field.name', rangeToReplace: { start: 15, end: 24 } }],
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 } }],
[{ text: 'field.name.foo', rangeToReplace: { start: 15, end: 27 } }],
undefined,
[[{ name: 'field.name.foo', type: 'double' }]]
);

View file

@ -20,7 +20,7 @@ import { partition } from 'lodash';
import { ESQL_NUMBER_TYPES, compareTypesWithLiterals, isNumericType } from '../shared/esql_types';
import type { EditorContext, SuggestionRawDefinition } from './types';
import {
lookupColumn,
getColumnForASTNode,
getCommandDefinition,
getCommandOption,
getFunctionDefinition,
@ -46,6 +46,7 @@ import {
getColumnExists,
findPreviousWord,
noCaseCompare,
getColumnByName,
} from '../shared/helpers';
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
@ -76,6 +77,7 @@ import {
buildValueDefinitions,
getDateLiterals,
buildFieldsDefinitionsWithMetadata,
TRIGGER_SUGGESTION_COMMAND,
} from './factories';
import { EDITOR_MARKER, SINGLE_BACKTICK, METADATA_FIELDS } from '../shared/constants';
import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context';
@ -96,7 +98,13 @@ import {
isAggFunctionUsedAlready,
removeQuoteForSuggestedSources,
} from './helper';
import { FunctionParameter, FunctionReturnType, SupportedDataType } from '../definitions/types';
import {
FunctionParameter,
FunctionReturnType,
SupportedDataType,
isParameterType,
isReturnType,
} from '../definitions/types';
type GetSourceFn = () => Promise<SuggestionRawDefinition[]>;
type GetDataStreamsForIntegrationFn = (
@ -105,7 +113,7 @@ type GetDataStreamsForIntegrationFn = (
type GetFieldsByTypeFn = (
type: string | string[],
ignored?: string[],
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean }
) => Promise<SuggestionRawDefinition[]>;
type GetFieldsMapFn = () => Promise<Map<string, ESQLRealField>>;
type GetPoliciesFn = () => Promise<SuggestionRawDefinition[]>;
@ -382,7 +390,7 @@ function workoutBuiltinOptions(
): { skipAssign: boolean } {
// skip assign operator if it's a function or an existing field to avoid promoting shadowing
return {
skipAssign: Boolean(!isColumnItem(nodeArg) || lookupColumn(nodeArg, references)),
skipAssign: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references)),
};
}
@ -456,7 +464,7 @@ function extractFinalTypeFromArg(
return arg.literalType;
}
if (isColumnItem(arg)) {
const hit = lookupColumn(arg, references);
const hit = getColumnForASTNode(arg, references);
if (hit) {
return hit.type;
}
@ -690,18 +698,47 @@ async function getExpressionSuggestionsByType(
const lastWord = words[words.length - 1];
if (lastWord !== '') {
// ... | <COMMAND> <word><suggest>
const rangeToReplace = {
start: innerText.length - lastWord.length + 1,
end: innerText.length + 1,
};
// check if lastWord is an existing field
const column = getColumnByName(lastWord, references);
if (column) {
// 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<SuggestionRawDefinition>((s) => ({
...s,
filterText: textToUse,
text: textToUse + s.text,
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace,
}));
} else {
suggestions.push(
...fieldSuggestions.map((suggestion) => ({
...suggestion,
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace,
}))
);
}
} else {
// ... | <COMMAND> <suggest>
suggestions.push(
...fieldSuggestions.map((suggestion) => ({
...suggestion,
rangeToReplace: {
start: innerText.length - lastWord.length + 1,
end: innerText.length,
},
command: TRIGGER_SUGGESTION_COMMAND,
}))
);
} else {
// ... | <COMMAND> <suggest>
suggestions.push(...fieldSuggestions);
}
}
}
@ -710,7 +747,7 @@ async function getExpressionSuggestionsByType(
// ... | STATS a <suggest>
// ... | EVAL a <suggest>
const nodeArgType = extractFinalTypeFromArg(nodeArg, references);
if (nodeArgType) {
if (isParameterType(nodeArgType)) {
suggestions.push(
...getBuiltinCompatibleFunctionDefinition(
command.name,
@ -768,7 +805,7 @@ async function getExpressionSuggestionsByType(
...getBuiltinCompatibleFunctionDefinition(
command.name,
undefined,
nodeArgType || 'any',
isParameterType(nodeArgType) ? nodeArgType : 'any',
undefined,
workoutBuiltinOptions(rightArg, references)
)
@ -859,7 +896,7 @@ async function getExpressionSuggestionsByType(
// ... | <COMMAND> <suggest>
// In this case start suggesting something not strictly based on type
suggestions.push(
...(await getFieldsByType('any', [], { advanceCursorAndOpenSuggestions: true })),
...(await getFieldsByType('any', [], { advanceCursor: true, openSuggestions: true })),
...(await getFieldsOrFunctionsSuggestions(
['any'],
command.name,
@ -913,7 +950,7 @@ async function getExpressionSuggestionsByType(
))
);
}
} else {
} else if (isParameterType(nodeArgType)) {
// i.e. ... | <COMMAND> field <suggest>
suggestions.push(
...getBuiltinCompatibleFunctionDefinition(
@ -1031,7 +1068,7 @@ async function getBuiltinFunctionNextArgument(
...getBuiltinCompatibleFunctionDefinition(
command.name,
option?.name,
nodeArgType || 'any',
isParameterType(nodeArgType) ? nodeArgType : 'any',
undefined,
workoutBuiltinOptions(nodeArg, references)
)
@ -1083,7 +1120,11 @@ async function getBuiltinFunctionNextArgument(
if (isFnComplete.reason === 'wrongTypes') {
if (nestedType) {
// suggest something to complete the builtin function
if (nestedType !== argDef.type) {
if (
nestedType !== argDef.type &&
isParameterType(nestedType) &&
isReturnType(argDef.type)
) {
suggestions.push(
...getBuiltinCompatibleFunctionDefinition(
command.name,
@ -1149,7 +1190,8 @@ async function getFieldsOrFunctionsSuggestions(
const filteredFieldsByType = pushItUpInTheList(
(await (fields
? getFieldsByType(types, ignoreFields, {
advanceCursorAndOpenSuggestions: commandName === 'sort',
advanceCursor: commandName === 'sort',
openSuggestions: commandName === 'sort',
})
: [])) as SuggestionRawDefinition[],
functions
@ -1378,7 +1420,8 @@ async function getFunctionArgsSuggestions(
...pushItUpInTheList(
await getFieldsByType(getTypesFromParamDefs(paramDefsWhichSupportFields) as string[], [], {
addComma: shouldAddComma,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
advanceCursor: hasMoreMandatoryArgs,
openSuggestions: hasMoreMandatoryArgs,
}),
true
)
@ -1721,7 +1764,8 @@ async function getOptionArgsSuggestions(
} else if (isNewExpression || (isAssignment(nodeArg) && !isAssignmentComplete(nodeArg))) {
suggestions.push(
...(await getFieldsByType(types[0] === 'column' ? ['any'] : types, [], {
advanceCursorAndOpenSuggestions: true,
advanceCursor: true,
openSuggestions: true,
}))
);

View file

@ -132,7 +132,7 @@ export function getSuggestionCommandDefinition(
export const buildFieldsDefinitionsWithMetadata = (
fields: ESQLRealField[],
options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean }
options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean }
): SuggestionRawDefinition[] => {
return fields.map((field) => {
const description = field.metadata?.description;
@ -143,7 +143,7 @@ export const buildFieldsDefinitionsWithMetadata = (
text:
getSafeInsertText(field.name) +
(options?.addComma ? ',' : '') +
(options?.advanceCursorAndOpenSuggestions ? ' ' : ''),
(options?.advanceCursor ? ' ' : ''),
kind: 'Variable',
detail: titleCaseType,
documentation: description
@ -156,7 +156,7 @@ ${description}`,
: undefined,
// If there is a description, it is a field from ECS, so it should be sorted to the top
sortText: description ? '1D' : 'D',
command: options?.advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
command: options?.openSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
};
});
};

View file

@ -27,6 +27,8 @@ export interface SuggestionRawDefinition {
label: string;
/* The actual text to insert into the editor */
text: string;
/* Text to use for filtering the suggestions */
filterText?: string;
/**
* Should the text be inserted as a snippet?
* That is usually used for special behaviour like moving the cursor in a specific position

View file

@ -41,7 +41,7 @@ function handleAdditionalArgs(
criteria: boolean,
additionalArgs: Array<{
name: string;
type: string | string[];
type: FunctionParameterType | FunctionParameterType[];
optional?: boolean;
reference?: string;
}>,

View file

@ -69,30 +69,42 @@ export const isSupportedDataType = (
*
* The fate of these is uncertain. They may be removed in the future.
*/
type ArrayType =
| 'double[]'
| 'unsigned_long[]'
| 'long[]'
| 'integer[]'
| 'counter_integer[]'
| 'counter_long[]'
| 'counter_double[]'
| 'keyword[]'
| 'text[]'
| 'boolean[]'
| 'any[]'
| 'date[]'
| 'date_period[]';
const arrayTypes = [
'double[]',
'unsigned_long[]',
'long[]',
'integer[]',
'counter_integer[]',
'counter_long[]',
'counter_double[]',
'keyword[]',
'text[]',
'boolean[]',
'any[]',
'date[]',
'date_period[]',
] as const;
export type ArrayType = (typeof arrayTypes)[number];
/**
* This is the type of a parameter in a function definition.
*/
export type FunctionParameterType = Omit<SupportedDataType, 'unsupported'> | ArrayType | 'any';
export type FunctionParameterType = Exclude<SupportedDataType, 'unsupported'> | ArrayType | 'any';
export const isParameterType = (str: string | undefined): str is FunctionParameterType =>
typeof str !== undefined &&
str !== 'unsupported' &&
([...dataTypes, ...arrayTypes, 'any'] as string[]).includes(str as string);
/**
* This is the return type of a function definition.
*/
export type FunctionReturnType = Omit<SupportedDataType, 'unsupported'> | 'any' | 'void';
export type FunctionReturnType = Exclude<SupportedDataType, 'unsupported'> | 'any' | 'void';
export const isReturnType = (str: string | FunctionParameterType): str is FunctionReturnType =>
str !== 'unsupported' &&
(dataTypes.includes(str as SupportedDataType) || str === 'any' || str === 'void');
export interface FunctionDefinition {
type: 'builtin' | 'agg' | 'eval';

View file

@ -40,6 +40,7 @@ import {
FunctionDefinition,
FunctionParameterType,
FunctionReturnType,
ArrayType,
} from '../definitions/types';
import type { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import { removeMarkerArgFromArgsList } from './context';
@ -248,28 +249,36 @@ function compareLiteralType(argType: string, item: ESQLLiteral) {
/**
* This function returns the variable or field matching a column
*/
export function lookupColumn(
export function getColumnForASTNode(
column: ESQLColumn,
{ fields, variables }: Pick<ReferenceMaps, 'fields' | 'variables'>
): ESQLRealField | ESQLVariable | undefined {
const columnName = getQuotedColumnName(column);
return (
fields.get(columnName) ||
variables.get(columnName)?.[0] ||
getColumnByName(columnName, { fields, variables }) ||
// It's possible columnName has backticks "`fieldName`"
// so we need to access the original name as well
fields.get(column.name) ||
variables.get(column.name)?.[0]
getColumnByName(column.name, { fields, variables })
);
}
/**
* This function returns the variable or field matching a column
*/
export function getColumnByName(
columnName: string,
{ fields, variables }: Pick<ReferenceMaps, 'fields' | 'variables'>
): ESQLRealField | ESQLVariable | undefined {
return fields.get(columnName) || variables.get(columnName)?.[0];
}
const ARRAY_REGEXP = /\[\]$/;
export function isArrayType(type: string) {
export function isArrayType(type: string): type is ArrayType {
return ARRAY_REGEXP.test(type);
}
const arrayToSingularMap: Map<FunctionParameterType, FunctionParameterType> = new Map([
const arrayToSingularMap: Map<ArrayType, FunctionParameterType> = new Map([
['double[]', 'double'],
['unsigned_long[]', 'unsigned_long'],
['long[]', 'long'],
@ -279,7 +288,7 @@ const arrayToSingularMap: Map<FunctionParameterType, FunctionParameterType> = ne
['counter_double[]', 'counter_double'],
['keyword[]', 'keyword'],
['text[]', 'text'],
['datetime[]', 'date'],
['date[]', 'date'],
['date_period[]', 'date_period'],
['boolean[]', 'boolean'],
['any[]', 'any'],
@ -289,7 +298,7 @@ const arrayToSingularMap: Map<FunctionParameterType, FunctionParameterType> = ne
* Given an array type for example `string[]` it will return `string`
*/
export function extractSingularType(type: FunctionParameterType): FunctionParameterType {
return arrayToSingularMap.get(type) ?? type;
return isArrayType(type) ? arrayToSingularMap.get(type)! : type;
}
export function createMapFromList<T extends { name: string }>(arr: T[]): Map<string, T> {
@ -378,7 +387,7 @@ export function getAllArrayTypes(
types.push(subArg.literalType);
}
if (subArg.type === 'column') {
const hit = lookupColumn(subArg, references);
const hit = getColumnForASTNode(subArg, references);
types.push(hit?.type || 'unsupported');
}
if (subArg.type === 'timeInterval') {
@ -445,7 +454,7 @@ export function checkFunctionArgMatchesDefinition(
return argType === 'time_literal' && inKnownTimeInterval(arg);
}
if (arg.type === 'column') {
const hit = lookupColumn(arg, references);
const hit = getColumnForASTNode(arg, references);
const validHit = hit;
if (!validHit) {
return false;

View file

@ -30,7 +30,7 @@ import {
import {
areFieldAndVariableTypesCompatible,
extractSingularType,
lookupColumn,
getColumnForASTNode,
getCommandDefinition,
getFunctionDefinition,
isArrayType,
@ -295,7 +295,7 @@ function validateFunctionColumnArg(
if (
!checkFunctionArgMatchesDefinition(actualArg, parameterDefinition, references, parentCommand)
) {
const columnHit = lookupColumn(actualArg, references);
const columnHit = getColumnForASTNode(actualArg, references);
messages.push(
getMessageFromId({
messageId: 'wrongArgumentType',
@ -876,7 +876,7 @@ function validateColumnForCommand(
({ type, innerTypes }) => type === 'column' && innerTypes
);
// this should be guaranteed by the columnCheck above
const columnRef = lookupColumn(column, references)!;
const columnRef = getColumnForASTNode(column, references)!;
if (columnParamsWithInnerTypes.length) {
const hasSomeWrongInnerTypes = columnParamsWithInnerTypes.every(({ innerTypes }) => {

View file

@ -100,10 +100,10 @@ export const ESQLLang: CustomLangModuleType<ESQLCallbacks> = {
(...uris) => workerProxyService.getWorker(uris),
callbacks
);
const suggestionEntries = await astAdapter.autocomplete(model, position, context);
const suggestions = await astAdapter.autocomplete(model, position, context);
return {
// @ts-expect-error because of range typing: https://github.com/microsoft/monaco-editor/issues/4638
suggestions: wrapAsMonacoSuggestions(suggestionEntries),
suggestions: wrapAsMonacoSuggestions(suggestions),
};
},
};

View file

@ -16,10 +16,22 @@ export function wrapAsMonacoSuggestions(
suggestions: SuggestionRawDefinitionWithMonacoRange[]
): MonacoAutocompleteCommandDefinition[] {
return suggestions.map<MonacoAutocompleteCommandDefinition>(
({ label, text, asSnippet, kind, detail, documentation, sortText, command, range }) => {
({
label,
text,
asSnippet,
kind,
detail,
documentation,
sortText,
filterText,
command,
range,
}) => {
const monacoSuggestion: MonacoAutocompleteCommandDefinition = {
label,
insertText: text,
filterText,
kind:
kind in monaco.languages.CompletionItemKind
? monaco.languages.CompletionItemKind[kind]

View file

@ -39,6 +39,7 @@ export const offsetRangeToMonacoRange = (
} => {
let startColumn = 0;
let endColumn = 0;
// How far we are past the last newline character
let currentOffset = 0;
let startLineNumber = 1;
@ -63,10 +64,16 @@ export const offsetRangeToMonacoRange = (
}
}
// Handle the case where the end offset is at the end of the string
if (range.end === expression.length) {
// Handle the case where the start offset is past the end of the string
if (range.start >= expression.length) {
startLineNumber = currentLine;
startColumn = range.start - currentOffset;
}
// Handle the case where the end offset is at the end or past the end of the string
if (range.end >= expression.length) {
endLineNumber = currentLine;
endColumn = expression.length - currentOffset;
endColumn = range.end - currentOffset;
}
return {

View file

@ -13,6 +13,7 @@ export type MonacoAutocompleteCommandDefinition = Pick<
monaco.languages.CompletionItem,
| 'label'
| 'insertText'
| 'filterText'
| 'kind'
| 'detail'
| 'documentation'