[ES|QL] Add quick fixes feature to query validation errors (#175553)

## Summary

This PR improves the UX of a query editing experience via the quick fix
feature, typical of a full IDE.
This is a bit of an experiment and a small enhancement already, but I'm
curious on more use cases for this quick fixes option. I've addressed
some simple ones here, but let me know if you think there are scenarios
you would like to be covered.

Spellchecks fixes are computed using a levenshtein distance of 3, same
as on the ES counterpart.
 
Current supported features:
* index spellcheck quick fix

![fix_indexes](dfd74f3d-28c2-4880-b771-42ea42e418d2)
  * wildcard support

![fix_indexes_wildcard](058c1e03-f4ae-40d2-ac73-e6da2b1ccde3)
* policy spellcheck quick fix

![fix_policies](24340f0a-d349-4db2-b009-97193025cf66)
* field/column spellcheck quick fix

![fix_fields](dda7e1bb-13bc-410f-a578-ef0feaed0a0f)
* function spellcheck quick fix

![fix_functions](40fa240a-034d-4ced-a23d-b970c9ebdc27)
* wrong quotes quick fix for literal strings

![fix_wrong_quotes](5f471b53-39a7-4b07-be2a-d0da27a17315)
* unquoted field/column quick fix

![fix_unquoted](c2ed7b58-a10e-4fd2-b51d-484b3b2a09e7)

Testing this feature I've also found a subtle bug in the autocomplete
code on unknown functions 😅 .
This feature requires some monaco additional sub-dependencies, so bundle
size can increase.

I was looking for a `Fix all` action as well, but I suspect there's
another route for that as error markers provided to the codeAction
provider are scoped by the hovered area in the editor, so multiple fixes
are only possible for the same hovered area and in this case I'm already
handling that manually for specific cases (i.e. unquoted field triggers
2 syntax + 1 validation errors).

---------

Co-authored-by: Eyo Okon Eyo <eyo.eyo@elastic.co>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@elastic.co>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Marco Liberati 2024-01-31 17:52:03 +01:00 committed by GitHub
parent e654a5be64
commit 31026a0b65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 879 additions and 108 deletions

View file

@ -29,6 +29,7 @@ SHARED_DEPS = [
"@npm//antlr4ts",
"@npm//monaco-editor",
"@npm//monaco-yaml",
"@npm//js-levenshtein",
]
webpack_cli(

View file

@ -111,4 +111,25 @@ export const ESQLLang: CustomLangModuleType<ESQLCallbacks> = {
},
};
},
getCodeActionProvider: (callbacks?: ESQLCallbacks): monaco.languages.CodeActionProvider => {
return {
async provideCodeActions(
model /** ITextModel*/,
range /** Range*/,
context /** CodeActionContext*/,
token /** CancellationToken*/
) {
const astAdapter = new ESQLAstAdapter(
(...uris) => workerProxyService.getWorker(uris),
callbacks
);
const actions = await astAdapter.codeAction(model, range, context);
return {
actions,
dispose: () => {},
};
},
};
},
};

View file

@ -121,8 +121,8 @@ function getFunctionSignaturesByReturnType(
}
return true;
})
.map(({ builtin: isBuiltinFn, name, signatures, ...defRest }) =>
isBuiltinFn ? `${name} $0` : `${name}($0)`
.map(({ type, name, signatures, ...defRest }) =>
type === 'builtin' ? `${name} $0` : `${name}($0)`
);
}

View file

@ -37,7 +37,6 @@ import {
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
import type {
AstProviderFn,
ESQLAst,
ESQLAstItem,
ESQLCommand,
ESQLCommandMode,
@ -72,6 +71,7 @@ import {
import { EDITOR_MARKER } from '../shared/constants';
import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context';
import {
buildQueryUntilPreviousCommand,
getFieldsByTypeHelper,
getPolicyHelper,
getSourcesHelper,
@ -187,7 +187,7 @@ export async function suggest(
const astContext = getAstContext(innerText, ast, offset);
// build the correct query to fetch the list of fields
const queryForFields = buildQueryForFields(ast, finalText);
const queryForFields = buildQueryUntilPreviousCommand(ast, finalText);
const { getFieldsByType, getFieldsMap } = getFieldsByTypeRetriever(
queryForFields,
resourceRetriever
@ -260,11 +260,6 @@ export async function suggest(
return [];
}
export function buildQueryForFields(ast: ESQLAst, queryString: string) {
const prevCommand = ast[Math.max(ast.length - 2, 0)];
return prevCommand ? queryString.substring(0, prevCommand.location.max + 1) : queryString;
}
function getFieldsByTypeRetriever(queryString: string, resourceRetriever?: ESQLCallbacks) {
const helpers = getFieldsByTypeHelper(queryString, resourceRetriever);
return {
@ -812,7 +807,7 @@ async function getBuiltinFunctionNextArgument(
// technically another boolean value should be suggested, but it is a better experience
// to actually suggest a wider set of fields/functions
[
finalType === 'boolean' && getFunctionDefinition(nodeArg.name)?.builtin
finalType === 'boolean' && getFunctionDefinition(nodeArg.name)?.type === 'builtin'
? 'any'
: finalType,
],
@ -1013,7 +1008,7 @@ async function getFunctionArgsSuggestions(
? {
...suggestion,
insertText:
hasMoreMandatoryArgs && !fnDefinition.builtin
hasMoreMandatoryArgs && fnDefinition.type !== 'builtin'
? `${suggestion.insertText},`
: suggestion.insertText,
}
@ -1023,7 +1018,8 @@ async function getFunctionArgsSuggestions(
return suggestions.map(({ insertText, ...rest }) => ({
...rest,
insertText: hasMoreMandatoryArgs && !fnDefinition.builtin ? `${insertText},` : insertText,
insertText:
hasMoreMandatoryArgs && fnDefinition.type !== 'builtin' ? `${insertText},` : insertText,
}));
}

View file

@ -18,7 +18,7 @@ import {
CommandOptionsDefinition,
CommandModeDefinition,
} from '../definitions/types';
import { getCommandDefinition } from '../shared/helpers';
import { getCommandDefinition, shouldBeQuotedText } from '../shared/helpers';
import { buildDocumentation, buildFunctionDocumentation } from './documentation_util';
const allFunctions = statsAggregationFunctionDefinitions.concat(evalFunctionsDefinitions);
@ -28,11 +28,8 @@ export const TRIGGER_SUGGESTION_COMMAND = {
id: 'editor.action.triggerSuggest',
};
function getSafeInsertText(text: string, { dashSupported }: { dashSupported?: boolean } = {}) {
if (dashSupported) {
return /[^a-zA-Z\d_\.@-]/.test(text) ? `\`${text}\`` : text;
}
return /[^a-zA-Z\d_\.@]/.test(text) ? `\`${text}\`` : text;
function getSafeInsertText(text: string, options: { dashSupported?: boolean } = {}) {
return shouldBeQuotedText(text, options) ? `\`${text}\`` : text;
}
export function getAutocompleteFunctionDefinition(fn: FunctionDefinition) {

View file

@ -0,0 +1,257 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { EditorError } from '../../../../types';
import { CharStreams } from 'antlr4ts';
import { getActions } from '.';
import { getParser, ROOT_STATEMENT } from '../../antlr_facade';
import { ESQLErrorListener } from '../../monaco/esql_error_listener';
import { AstListener } from '../ast_factory';
import { wrapAsMonacoMessage } from '../shared/monaco_utils';
import { ESQLAst } from '../types';
import { validateAst } from '../validation/validation';
import { monaco } from '../../../../monaco_imports';
import { getAllFunctions } from '../shared/helpers';
function getCallbackMocks() {
return {
getFieldsFor: jest.fn(async ({ query }) =>
/enrich/.test(query)
? [
{ name: 'otherField', type: 'string' },
{ name: 'yetAnotherField', type: 'number' },
]
: /unsupported_index/.test(query)
? [{ name: 'unsupported_field', type: 'unsupported' }]
: [
...['string', 'number', 'date', 'boolean', 'ip'].map((type) => ({
name: `${type}Field`,
type,
})),
{ name: 'geoPointField', type: 'geo_point' },
{ name: 'any#Char$Field', type: 'number' },
{ name: 'kubernetes.something.something', type: 'number' },
{
name: `listField`,
type: `list`,
},
{ name: '@timestamp', type: 'date' },
]
),
getSources: jest.fn(async () =>
['index', '.secretIndex', 'my-index'].map((name) => ({
name,
hidden: name.startsWith('.'),
}))
),
getPolicies: jest.fn(async () => [
{
name: 'policy',
sourceIndices: ['enrichIndex1'],
matchField: 'otherStringField',
enrichFields: ['other-field', 'yetAnotherField'],
},
{
name: 'policy[]',
sourceIndices: ['enrichIndex1'],
matchField: 'otherStringField',
enrichFields: ['other-field', 'yetAnotherField'],
},
]),
};
}
const getAstAndErrors = async (
text: string | undefined
): Promise<{
errors: EditorError[];
ast: ESQLAst;
}> => {
if (text == null) {
return { ast: [], errors: [] };
}
const errorListener = new ESQLErrorListener();
const parseListener = new AstListener();
const parser = getParser(CharStreams.fromString(text), errorListener, parseListener);
parser[ROOT_STATEMENT]();
return { ...parseListener.getAst(), errors: errorListener.getErrors() };
};
function createModelAndRange(text: string) {
return {
model: { getValue: () => text } as monaco.editor.ITextModel,
range: {} as monaco.Range,
};
}
function createMonacoContext(errors: EditorError[]): monaco.languages.CodeActionContext {
return {
markers: errors,
trigger: 1,
};
}
/**
* There are different wats to test the code here: one is a direct unit test of the feature, another is
* an integration test passing from the query statement validation. The latter is more realistic, but
* a little bit more tricky to setup. This function will encapsulate all the complexity
*/
function testQuickFixesFn(
statement: string,
expectedFixes: string[] = [],
options: { equalityCheck?: 'include' | 'equal' } = {},
{ only, skip }: { only?: boolean; skip?: boolean } = {}
) {
const testFn = only ? it.only : skip ? it.skip : it;
const { model, range } = createModelAndRange(statement);
testFn(`${statement} => ["${expectedFixes.join('","')}"]`, async () => {
const callbackMocks = getCallbackMocks();
const { errors } = await validateAst(statement, getAstAndErrors, callbackMocks);
const monacoErrors = wrapAsMonacoMessage('error', statement, errors);
const context = createMonacoContext(monacoErrors);
const actions = await getActions(model, range, context, getAstAndErrors, callbackMocks);
const edits = actions.map(
({ edit }) => (edit?.edits[0] as monaco.languages.IWorkspaceTextEdit).textEdit.text
);
expect(edits).toEqual(
!options || !options.equalityCheck || options.equalityCheck === 'equal'
? expectedFixes
: expect.arrayContaining(expectedFixes)
);
});
}
type TestArgs = [string, string[], { equalityCheck?: 'include' | 'equal' }];
// Make only and skip work with our custom wrapper
const testQuickFixes = Object.assign(testQuickFixesFn, {
skip: (...args: TestArgs) => {
const paddingArgs = ['equal'].slice(args.length - 2);
return testQuickFixesFn(...((args.length > 1 ? [...args, ...paddingArgs] : args) as TestArgs), {
skip: true,
});
},
only: (...args: TestArgs) => {
const paddingArgs = ['equal'].slice(args.length - 2);
return testQuickFixesFn(...((args.length > 1 ? [...args, ...paddingArgs] : args) as TestArgs), {
only: true,
});
},
});
describe('quick fixes logic', () => {
describe('fixing index spellchecks', () => {
// No error, no quick action
testQuickFixes('FROM index', []);
testQuickFixes('FROM index2', ['index']);
testQuickFixes('FROM myindex', ['index', 'my-index']);
// wildcards
testQuickFixes('FROM index*', []);
testQuickFixes('FROM ind*', []);
testQuickFixes('FROM end*', ['ind*']);
testQuickFixes('FROM endex*', ['index']);
// Too far for the levenstein distance and should not fix with a hidden index
testQuickFixes('FROM secretIndex', []);
testQuickFixes('FROM secretIndex2', []);
});
describe('fixing fields spellchecks', () => {
for (const command of ['KEEP', 'DROP', 'EVAL']) {
testQuickFixes(`FROM index | ${command} stringField`, []);
// strongField => stringField
testQuickFixes(`FROM index | ${command} strongField`, ['stringField']);
testQuickFixes(`FROM index | ${command} numberField, strongField`, ['stringField']);
}
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField']);
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField']);
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField']);
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField']);
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField']);
// This levarage the knowledge of the enrich policy fields to suggest the right field
testQuickFixes(`FROM index | ENRICH policy | KEEP yetAnotherField2`, ['yetAnotherField']);
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField']);
testQuickFixes(`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`, [
'yetAnotherField',
]);
});
describe('fixing policies spellchecks', () => {
testQuickFixes(`FROM index | ENRICH poli`, ['policy']);
testQuickFixes(`FROM index | ENRICH mypolicy`, ['policy']);
testQuickFixes(`FROM index | ENRICH policy[`, ['policy', 'policy[]']);
});
describe('fixing function spellchecks', () => {
function toFunctionSignature(name: string) {
return `${name}()`;
}
// it should be strange enough to make the function invalid
const BROKEN_PREFIX = 'Q';
for (const fn of getAllFunctions({ type: 'eval' })) {
// add an A to the function name to make it invalid
testQuickFixes(
`FROM index | EVAL ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
testQuickFixes(
`FROM index | EVAL var0 = ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
testQuickFixes(
`FROM index | STATS avg(${BROKEN_PREFIX}${fn.name}())`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
testQuickFixes(
`FROM index | STATS avg(numberField) BY ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
testQuickFixes(
`FROM index | STATS avg(numberField) BY var0 = ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
}
for (const fn of getAllFunctions({ type: 'agg' })) {
// add an A to the function name to make it invalid
testQuickFixes(
`FROM index | STATS ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
testQuickFixes(
`FROM index | STATS var0 = ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include' }
);
}
// it should preserve the arguments
testQuickFixes(`FROM index | EVAL rAund(numberField)`, ['round(numberField)'], {
equalityCheck: 'include',
});
testQuickFixes(`FROM index | STATS AVVG(numberField)`, ['avg(numberField)'], {
equalityCheck: 'include',
});
});
describe('fixing wrong quotes', () => {
testQuickFixes(`FROM index | WHERE stringField like 'asda'`, ['"asda"']);
testQuickFixes(`FROM index | WHERE stringField not like 'asda'`, ['"asda"']);
});
describe('fixing unquoted field names', () => {
testQuickFixes('FROM index | DROP any#Char$Field', ['`any#Char$Field`']);
testQuickFixes('FROM index | DROP numberField, any#Char$Field', ['`any#Char$Field`']);
});
});

View file

@ -0,0 +1,383 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import levenshtein from 'js-levenshtein';
import type { monaco } from '../../../../monaco_imports';
import {
getFieldsByTypeHelper,
getPolicyHelper,
getSourcesHelper,
} from '../shared/resources_helpers';
import { getAllFunctions, isSourceItem, shouldBeQuotedText } from '../shared/helpers';
import { ESQLCallbacks } from '../shared/types';
import { AstProviderFn, ESQLAst } from '../types';
import { buildQueryForFieldsFromSource } from '../validation/helpers';
type GetSourceFn = () => Promise<string[]>;
type GetFieldsByTypeFn = (type: string | string[], ignored?: string[]) => Promise<string[]>;
type GetPoliciesFn = () => Promise<string[]>;
type GetPolicyFieldsFn = (name: string) => Promise<string[]>;
interface Callbacks {
getSources: GetSourceFn;
getFieldsByType: GetFieldsByTypeFn;
getPolicies: GetPoliciesFn;
getPolicyFields: GetPolicyFieldsFn;
}
function getFieldsByTypeRetriever(queryString: string, resourceRetriever?: ESQLCallbacks) {
const helpers = getFieldsByTypeHelper(queryString, resourceRetriever);
return {
getFieldsByType: async (expectedType: string | string[] = 'any', ignored: string[] = []) => {
const fields = await helpers.getFieldsByType(expectedType, ignored);
return fields;
},
getFieldsMap: helpers.getFieldsMap,
};
}
function getPolicyRetriever(resourceRetriever?: ESQLCallbacks) {
const helpers = getPolicyHelper(resourceRetriever);
return {
getPolicies: async () => {
const policies = await helpers.getPolicies();
return policies.map(({ name }) => name);
},
getPolicyFields: async (policy: string) => {
const metadata = await helpers.getPolicyMetadata(policy);
return metadata?.enrichFields || [];
},
};
}
function getSourcesRetriever(resourceRetriever?: ESQLCallbacks) {
const helper = getSourcesHelper(resourceRetriever);
return async () => {
const list = (await helper()) || [];
// hide indexes that start with .
return list.filter(({ hidden }) => !hidden).map(({ name }) => name);
};
}
export const getCompatibleFunctionDefinitions = (
command: string,
option: string | undefined,
returnTypes?: string[],
ignored: string[] = []
): string[] => {
const fnSupportedByCommand = getAllFunctions({ type: ['eval', 'agg'] }).filter(
({ name, supportedCommands, supportedOptions }) =>
(option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) &&
!ignored.includes(name)
);
if (!returnTypes) {
return fnSupportedByCommand.map(({ name }) => name);
}
return fnSupportedByCommand
.filter((mathDefinition) =>
mathDefinition.signatures.some(
(signature) => returnTypes[0] === 'any' || returnTypes.includes(signature.returnType)
)
)
.map(({ name }) => name);
};
function createAction(
title: string,
solution: string,
error: monaco.editor.IMarkerData,
uri: monaco.Uri
) {
return {
title,
diagnostics: [error],
kind: 'quickfix',
edit: {
edits: [
{
resource: uri,
textEdit: {
range: error,
text: solution,
},
versionId: undefined,
},
],
},
isPreferred: true,
};
}
async function getSpellingPossibilities(fn: () => Promise<string[]>, errorText: string) {
const allPossibilities = await fn();
const allSolutions = allPossibilities.reduce((solutions, item) => {
const distance = levenshtein(item, errorText);
if (distance < 3) {
solutions.push(item);
}
return solutions;
}, [] as string[]);
// filter duplicates
return Array.from(new Set(allSolutions));
}
async function getSpellingActionForColumns(
error: monaco.editor.IMarkerData,
uri: monaco.Uri,
queryString: string,
ast: ESQLAst,
{ getFieldsByType, getPolicies, getPolicyFields }: Callbacks
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
// @TODO add variables support
const possibleFields = await getSpellingPossibilities(async () => {
const availableFields = await getFieldsByType('any');
const enrichPolicies = ast.filter(({ name }) => name === 'enrich');
if (enrichPolicies.length) {
const enrichPolicyNames = enrichPolicies.flatMap(({ args }) =>
args.filter(isSourceItem).map(({ name }) => name)
);
const enrichFields = await Promise.all(enrichPolicyNames.map(getPolicyFields));
availableFields.push(...enrichFields.flat());
}
return availableFields;
}, errorText);
return wrapIntoSpellingChangeAction(error, uri, possibleFields);
}
async function getQuotableActionForColumns(
error: monaco.editor.IMarkerData,
uri: monaco.Uri,
queryString: string,
ast: ESQLAst,
{ getFieldsByType }: Callbacks
) {
const commandEndIndex = ast.find((command) => command.location.max > error.endColumn)?.location
.max;
// the error received is unknwonColumn here, but look around the column to see if there's more
// which broke the grammar and the validation code couldn't identify as unquoted column
const remainingCommandText = queryString.substring(
error.endColumn - 1,
commandEndIndex ? commandEndIndex + 1 : undefined
);
const stopIndex = Math.max(
/,/.test(remainingCommandText)
? remainingCommandText.indexOf(',')
: /\s/.test(remainingCommandText)
? remainingCommandText.indexOf(' ')
: remainingCommandText.length,
0
);
const possibleUnquotedText = queryString.substring(
error.endColumn - 1,
error.endColumn + stopIndex
);
const errorText = queryString
.substring(error.startColumn - 1, error.endColumn + possibleUnquotedText.length)
.trimEnd();
const actions = [];
if (shouldBeQuotedText(errorText)) {
const availableFields = new Set(await getFieldsByType('any'));
const solution = `\`${errorText}\``;
if (availableFields.has(errorText) || availableFields.has(solution)) {
actions.push(
createAction(
i18n.translate('monaco.esql.quickfix.replaceWithSolution', {
defaultMessage: 'Did you mean {solution} ?',
values: {
solution,
},
}),
solution,
{ ...error, endColumn: error.startColumn + errorText.length }, // override the location
uri
)
);
}
}
return actions;
}
async function getSpellingActionForIndex(
error: monaco.editor.IMarkerData,
uri: monaco.Uri,
queryString: string,
ast: ESQLAst,
{ getSources }: Callbacks
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const possibleSources = await getSpellingPossibilities(async () => {
// Handle fuzzy names via truncation to test levenstein distance
const sources = await getSources();
if (errorText.endsWith('*')) {
return sources.map((source) =>
source.length > errorText.length ? source.substring(0, errorText.length - 1) + '*' : source
);
}
return sources;
}, errorText);
return wrapIntoSpellingChangeAction(error, uri, possibleSources);
}
async function getSpellingActionForPolicies(
error: monaco.editor.IMarkerData,
uri: monaco.Uri,
queryString: string,
ast: ESQLAst,
{ getPolicies }: Callbacks
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const possiblePolicies = await getSpellingPossibilities(getPolicies, errorText);
return wrapIntoSpellingChangeAction(error, uri, possiblePolicies);
}
async function getSpellingActionForFunctions(
error: monaco.editor.IMarkerData,
uri: monaco.Uri,
queryString: string,
ast: ESQLAst
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
// fallback to the last command if not found
const commandContext =
ast.find((command) => command.location.max > error.endColumn) || ast[ast.length - 1];
if (!commandContext) {
return [];
}
const possibleSolutions = await getSpellingPossibilities(
async () =>
getCompatibleFunctionDefinitions(commandContext.name, undefined).concat(
// support nested expressions in STATS
commandContext.name === 'stats' ? getCompatibleFunctionDefinitions('eval', undefined) : []
),
errorText.substring(0, errorText.lastIndexOf('(')).toLowerCase() // reduce a bit the distance check making al lowercase
);
return wrapIntoSpellingChangeAction(
error,
uri,
possibleSolutions.map((fn) => `${fn}${errorText.substring(errorText.lastIndexOf('('))}`)
);
}
function wrapIntoSpellingChangeAction(
error: monaco.editor.IMarkerData,
uri: monaco.Uri,
possibleSolution: string[]
): monaco.languages.CodeAction[] {
return possibleSolution.map((solution) =>
createAction(
// @TODO: workout why the tooltip is truncating the title here
i18n.translate('monaco.esql.quickfix.replaceWithSolution', {
defaultMessage: 'Did you mean {solution} ?',
values: {
solution,
},
}),
solution,
error,
uri
)
);
}
function inferCodeFromError(error: monaco.editor.IMarkerData & { owner?: string }) {
if (error.message.includes('missing STRING')) {
const [, value] = error.message.split('at ');
return value.startsWith("'") && value.endsWith("'") ? 'wrongQuotes' : undefined;
}
}
export async function getActions(
model: monaco.editor.ITextModel,
range: monaco.Range,
context: monaco.languages.CodeActionContext,
astProvider: AstProviderFn,
resourceRetriever?: ESQLCallbacks
): Promise<monaco.languages.CodeAction[]> {
const actions: monaco.languages.CodeAction[] = [];
if (context.markers.length === 0) {
return actions;
}
const innerText = model.getValue();
const { ast } = await astProvider(innerText);
const queryForFields = buildQueryForFieldsFromSource(innerText, ast);
const { getFieldsByType } = getFieldsByTypeRetriever(queryForFields, resourceRetriever);
const getSources = getSourcesRetriever(resourceRetriever);
const { getPolicies, getPolicyFields } = getPolicyRetriever(resourceRetriever);
const callbacks = {
getFieldsByType,
getSources,
getPolicies,
getPolicyFields,
};
// Markers are sent only on hover and are limited to the hovered area
// so unless there are multiple error/markers for the same area, there's just one
// in some cases, like syntax + semantic errors (i.e. unquoted fields eval field-1 ), there might be more than one
for (const error of context.markers) {
const code = error.code ?? inferCodeFromError(error);
switch (code) {
case 'unknownColumn':
const [columnsSpellChanges, columnsQuotedChanges] = await Promise.all([
getSpellingActionForColumns(error, model.uri, innerText, ast, callbacks),
getQuotableActionForColumns(error, model.uri, innerText, ast, callbacks),
]);
actions.push(...(columnsQuotedChanges.length ? columnsQuotedChanges : columnsSpellChanges));
break;
case 'unknownIndex':
const indexSpellChanges = await getSpellingActionForIndex(
error,
model.uri,
innerText,
ast,
callbacks
);
actions.push(...indexSpellChanges);
break;
case 'unknownPolicy':
const policySpellChanges = await getSpellingActionForPolicies(
error,
model.uri,
innerText,
ast,
callbacks
);
actions.push(...policySpellChanges);
break;
case 'unknownFunction':
const fnsSpellChanges = await getSpellingActionForFunctions(
error,
model.uri,
innerText,
ast
);
actions.push(...fnsSpellChanges);
break;
case 'wrongQuotes':
// it is a syntax error, so location won't be helpful here
const [, errorText] = error.message.split('at ');
actions.push(
createAction(
i18n.translate('monaco.esql.quickfix.replaceWithQuote', {
defaultMessage: 'Change quote to " (double)',
}),
errorText.replaceAll("'", '"'),
// override the location
{ ...error, endColumn: error.startColumn + errorText.length },
model.uri
)
);
break;
default:
break;
}
}
return actions;
}

View file

@ -21,6 +21,7 @@ function createNumericAggDefinition({
const extraParamsExample = args.length ? `, ${args.map(({ value }) => value).join(',')}` : '';
return {
name,
type: 'agg',
description,
supportedCommands: ['stats'],
signatures: [
@ -93,6 +94,7 @@ export const statsAggregationFunctionDefinitions: FunctionDefinition[] = [
.concat([
{
name: 'count',
type: 'agg',
description: i18n.translate('monaco.esql.definitions.countDoc', {
defaultMessage: 'Returns the count of the values in a field.',
}),
@ -115,6 +117,7 @@ export const statsAggregationFunctionDefinitions: FunctionDefinition[] = [
},
{
name: 'count_distinct',
type: 'agg',
description: i18n.translate('monaco.esql.definitions.countDistinctDoc', {
defaultMessage: 'Returns the count of distinct values in a field.',
}),
@ -132,6 +135,7 @@ export const statsAggregationFunctionDefinitions: FunctionDefinition[] = [
},
{
name: 'st_centroid',
type: 'agg',
description: i18n.translate('monaco.esql.definitions.stCentroidDoc', {
defaultMessage: 'Returns the count of distinct values in a field.',
}),

View file

@ -14,9 +14,9 @@ function createMathDefinition(
types: Array<string | string[]>,
description: string,
warning?: FunctionDefinition['warning']
) {
): FunctionDefinition {
return {
builtin: true,
type: 'builtin',
name,
description,
supportedCommands: ['eval', 'where', 'row'],
@ -52,9 +52,9 @@ function createComparisonDefinition(
description: string;
},
warning?: FunctionDefinition['warning']
) {
): FunctionDefinition {
return {
builtin: true,
type: 'builtin' as const,
name,
description,
supportedCommands: ['eval', 'where', 'row'],
@ -113,18 +113,28 @@ export const builtinFunctions: FunctionDefinition[] = [
i18n.translate('monaco.esql.definition.divideDoc', {
defaultMessage: 'Divide (/)',
}),
(left, right) => {
if (right.type === 'literal' && right.literalType === 'number') {
return right.value === 0
? i18n.translate('monaco.esql.divide.warning.divideByZero', {
defaultMessage: 'Cannot divide by zero: {left}/{right}',
values: {
left: left.text,
right: right.value,
},
})
: undefined;
(fnDef) => {
const [left, right] = fnDef.args;
const messages = [];
if (!Array.isArray(left) && !Array.isArray(right)) {
if (right.type === 'literal' && right.literalType === 'number') {
if (right.value === 0) {
messages.push({
type: 'warning' as const,
code: 'divideByZero',
text: i18n.translate('monaco.esql.divide.warning.divideByZero', {
defaultMessage: 'Cannot divide by zero: {left}/{right}',
values: {
left: left.text,
right: right.value,
},
}),
location: fnDef.location,
});
}
}
}
return messages;
}
),
createMathDefinition(
@ -133,18 +143,28 @@ export const builtinFunctions: FunctionDefinition[] = [
i18n.translate('monaco.esql.definition.moduleDoc', {
defaultMessage: 'Module (%)',
}),
(left, right) => {
if (right.type === 'literal' && right.literalType === 'number') {
return right.value === 0
? i18n.translate('monaco.esql.divide.warning.zeroModule', {
defaultMessage: 'Module by zero can return null value: {left}/{right}',
values: {
left: left.text,
right: right.value,
},
})
: undefined;
(fnDef) => {
const [left, right] = fnDef.args;
const messages = [];
if (!Array.isArray(left) && !Array.isArray(right)) {
if (right.type === 'literal' && right.literalType === 'number') {
if (right.value === 0) {
messages.push({
type: 'warning' as const,
code: 'moduleByZero',
text: i18n.translate('monaco.esql.divide.warning.zeroModule', {
defaultMessage: 'Module by zero can return null value: {left}/{right}',
values: {
left: left.text,
right: right.value,
},
}),
location: fnDef.location,
});
}
}
}
return messages;
}
),
...[
@ -184,7 +204,7 @@ export const builtinFunctions: FunctionDefinition[] = [
defaultMessage: 'Greater than or equal to',
}),
},
].map((op) => createComparisonDefinition(op)),
].map((op): FunctionDefinition => createComparisonDefinition(op)),
...[
// new special comparison operator for strings only
{
@ -207,8 +227,8 @@ export const builtinFunctions: FunctionDefinition[] = [
}),
},
{ name: 'not_rlike', description: '' },
].map(({ name, description }) => ({
builtin: true,
].map<FunctionDefinition>(({ name, description }) => ({
type: 'builtin' as const,
ignoreAsSuggestion: /not/.test(name),
name,
description,
@ -233,8 +253,8 @@ export const builtinFunctions: FunctionDefinition[] = [
}),
},
{ name: 'not_in', description: '' },
].map(({ name, description }) => ({
builtin: true,
].map<FunctionDefinition>(({ name, description }) => ({
type: 'builtin',
ignoreAsSuggestion: /not/.test(name),
name,
description,
@ -284,7 +304,7 @@ export const builtinFunctions: FunctionDefinition[] = [
}),
},
].map(({ name, description }) => ({
builtin: true,
type: 'builtin' as const,
name,
description,
supportedCommands: ['eval', 'where', 'row'],
@ -300,7 +320,7 @@ export const builtinFunctions: FunctionDefinition[] = [
],
})),
{
builtin: true,
type: 'builtin' as const,
name: 'not',
description: i18n.translate('monaco.esql.definition.notDoc', {
defaultMessage: 'Not',
@ -315,7 +335,7 @@ export const builtinFunctions: FunctionDefinition[] = [
],
},
{
builtin: true,
type: 'builtin' as const,
name: '=',
description: i18n.translate('monaco.esql.definition.assignDoc', {
defaultMessage: 'Assign (=)',
@ -334,6 +354,7 @@ export const builtinFunctions: FunctionDefinition[] = [
},
{
name: 'functions',
type: 'builtin',
description: i18n.translate('monaco.esql.definition.functionsDoc', {
defaultMessage: 'Show ES|QL avaialble functions with signatures',
}),
@ -347,6 +368,7 @@ export const builtinFunctions: FunctionDefinition[] = [
},
{
name: 'info',
type: 'builtin',
description: i18n.translate('monaco.esql.definition.infoDoc', {
defaultMessage: 'Show information about the current ES node',
}),

View file

@ -146,6 +146,7 @@ export const commandDefinitions: CommandDefinition[] = [
defaultMessage: 'PROJECT command is no longer supported, please use KEEP instead',
}),
type: 'warning',
code: 'projectCommandDeprecated',
});
}
return messages;
@ -174,6 +175,7 @@ export const commandDefinitions: CommandDefinition[] = [
defaultMessage: 'Removing all fields is not allowed [*]',
}),
type: 'error' as const,
code: 'dropAllColumnsError',
}))
);
}
@ -187,6 +189,7 @@ export const commandDefinitions: CommandDefinition[] = [
defaultMessage: 'Drop [@timestamp] will remove all time filters to the search results',
}),
type: 'warning',
code: 'dropTimestampWarning',
});
}
return messages;
@ -317,6 +320,7 @@ export const commandDefinitions: CommandDefinition[] = [
},
}),
type: 'warning' as const,
code: 'duplicateSettingWarning',
}))
);
}

View file

@ -1052,4 +1052,5 @@ export const evalFunctionsDefinitions: FunctionDefinition[] = [
...def,
supportedCommands: ['eval', 'where', 'row'],
supportedOptions: ['by'],
type: 'eval',
}));

View file

@ -99,6 +99,7 @@ export const appendSeparatorOption: CommandOptionsDefinition = {
},
}),
type: 'error',
code: 'wrongDissectOptionArgumentType',
});
}
return messages;

View file

@ -6,10 +6,10 @@
* Side Public License, v 1.
*/
import type { ESQLCommand, ESQLCommandOption, ESQLMessage, ESQLSingleAstItem } from '../types';
import type { ESQLCommand, ESQLCommandOption, ESQLFunction, ESQLMessage } from '../types';
export interface FunctionDefinition {
builtin?: boolean;
type: 'builtin' | 'agg' | 'eval';
ignoreAsSuggestion?: boolean;
name: string;
alias?: string[];
@ -29,7 +29,7 @@ export interface FunctionDefinition {
returnType: string;
examples?: string[];
}>;
warning?: (...args: ESQLSingleAstItem[]) => string | undefined;
warning?: (fnDef: ESQLFunction) => ESQLMessage[];
}
export interface CommandBaseDefinition {

View file

@ -132,7 +132,7 @@ function isNotEnrichClauseAssigment(node: ESQLFunction, command: ESQLCommand) {
return node.name !== '=' && command.name !== 'enrich';
}
function isBuiltinFunction(node: ESQLFunction) {
return Boolean(getFunctionDefinition(node.name)?.builtin);
return getFunctionDefinition(node.name)?.type === 'builtin';
}
export function getAstContext(innerText: string, ast: ESQLAst, offset: number) {

View file

@ -166,6 +166,17 @@ export function isSupportedFunction(
};
}
export function getAllFunctions(options?: {
type: Array<FunctionDefinition['type']> | FunctionDefinition['type'];
}) {
const fns = buildFunctionLookup();
if (!options?.type) {
return Array.from(fns.values());
}
const types = new Set(Array.isArray(options.type) ? options.type : [options.type]);
return Array.from(fns.values()).filter((fn) => types.has(fn.type));
}
export function getFunctionDefinition(name: string) {
return buildFunctionLookup().get(name.toLowerCase());
}
@ -482,3 +493,10 @@ export function getLastCharFromTrimmed(text: string) {
export function isRestartingExpression(text: string) {
return getLastCharFromTrimmed(text) === ',';
}
export function shouldBeQuotedText(
text: string,
{ dashSupported }: { dashSupported?: boolean } = {}
) {
return dashSupported ? /[^a-zA-Z\d_\.@-]/.test(text) : /[^a-zA-Z\d_\.@]/.test(text);
}

View file

@ -0,0 +1,54 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { EditorError } from '../../../../types';
import { monaco } from '../../../../monaco_imports';
import { ESQLMessage } from '../types';
// from linear offset to Monaco position
export function offsetToRowColumn(expression: string, offset: number): monaco.Position {
const lines = expression.split(/\n/);
let remainingChars = offset;
let lineNumber = 1;
for (const line of lines) {
if (line.length >= remainingChars) {
return new monaco.Position(lineNumber, remainingChars + 1);
}
remainingChars -= line.length + 1;
lineNumber++;
}
throw new Error('Algorithm failure');
}
export function wrapAsMonacoMessage(
type: 'error' | 'warning',
code: string,
messages: Array<ESQLMessage | EditorError>
): EditorError[] {
const fallbackPosition = { column: 0, lineNumber: 0 };
return messages.map((e) => {
if ('severity' in e) {
return e;
}
const startPosition = e.location ? offsetToRowColumn(code, e.location.min) : fallbackPosition;
const endPosition = e.location
? offsetToRowColumn(code, e.location.max || 0)
: fallbackPosition;
return {
message: e.text,
startColumn: startPosition.column,
startLineNumber: startPosition.lineNumber,
endColumn: endPosition.column + 1,
endLineNumber: endPosition.lineNumber,
severity: type === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
_source: 'client' as const,
code: e.code,
};
});
}

View file

@ -8,6 +8,12 @@
import type { ESQLCallbacks } from './types';
import type { ESQLRealField } from '../validation/types';
import { ESQLAst } from '../types';
export function buildQueryUntilPreviousCommand(ast: ESQLAst, queryString: string) {
const prevCommand = ast[Math.max(ast.length - 2, 0)];
return prevCommand ? queryString.substring(0, prevCommand.location.max + 1) : queryString;
}
export function getFieldsByTypeHelper(queryText: string, resourceRetriever?: ESQLCallbacks) {
const cacheFields = new Map<string, ESQLRealField>();

View file

@ -85,6 +85,7 @@ export interface ESQLMessage {
type: 'error' | 'warning';
text: string;
location: ESQLLocation;
code: string;
}
export type AstProviderFn = (

View file

@ -232,13 +232,19 @@ export function getMessageFromId<K extends ErrorTypes>({
locations: ESQLLocation;
}): ESQLMessage {
const { message, type = 'error' } = getMessageAndTypeFromId(payload);
return createMessage(type, message, locations);
return createMessage(type, message, locations, payload.messageId);
}
export function createMessage(type: 'error' | 'warning', message: string, location: ESQLLocation) {
export function createMessage(
type: 'error' | 'warning',
message: string,
location: ESQLLocation,
messageId: string
) {
return {
type,
text: message,
location,
code: messageId,
};
}

View file

@ -51,7 +51,7 @@ import type {
ESQLSingleAstItem,
ESQLSource,
} from '../types';
import { getMessageFromId, createMessage } from './errors';
import { getMessageFromId } from './errors';
import type { ESQLRealField, ESQLVariable, ReferenceMaps, ValidationResult } from './types';
import type { ESQLCallbacks } from '../shared/types';
import {
@ -325,11 +325,9 @@ function validateFunction(
}
// check if the definition has some warning to show:
if (fnDefinition.warning) {
const message = fnDefinition.warning(
...(astFunction.args.filter((arg) => !Array.isArray(arg)) as ESQLSingleAstItem[])
);
if (message) {
messages.push(createMessage('warning', message, astFunction.location));
const payloads = fnDefinition.warning(astFunction);
if (payloads.length) {
messages.push(...payloads);
}
}
// at this point we're sure that at least one signature is matching

View file

@ -6,57 +6,15 @@
* Side Public License, v 1.
*/
import type { EditorError } from '../../../types';
import type { ESQLCallbacks } from '../ast/shared/types';
import { monaco } from '../../../monaco_imports';
import type { ESQLWorker } from '../../worker/esql_worker';
import { suggest } from '../ast/autocomplete/autocomplete';
import { getHoverItem } from '../ast/hover';
import { getSignatureHelp } from '../ast/signature';
import type { ESQLMessage } from '../ast/types';
import { validateAst } from '../ast/validation/validation';
// from linear offset to Monaco position
export function offsetToRowColumn(expression: string, offset: number): monaco.Position {
const lines = expression.split(/\n/);
let remainingChars = offset;
let lineNumber = 1;
for (const line of lines) {
if (line.length >= remainingChars) {
return new monaco.Position(lineNumber, remainingChars + 1);
}
remainingChars -= line.length + 1;
lineNumber++;
}
throw new Error('Algorithm failure');
}
function wrapAsMonacoMessage(
type: 'error' | 'warning',
code: string,
messages: Array<ESQLMessage | EditorError>
): EditorError[] {
const fallbackPosition = { column: 0, lineNumber: 0 };
return messages.map((e) => {
if ('severity' in e) {
return e;
}
const startPosition = e.location ? offsetToRowColumn(code, e.location.min) : fallbackPosition;
const endPosition = e.location
? offsetToRowColumn(code, e.location.max || 0)
: fallbackPosition;
return {
message: e.text,
startColumn: startPosition.column,
startLineNumber: startPosition.lineNumber,
endColumn: endPosition.column + 1,
endLineNumber: endPosition.lineNumber,
severity: type === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
_source: 'client' as const,
};
});
}
import { getActions } from '../ast/code_actions';
import { wrapAsMonacoMessage } from '../ast/shared/monaco_utils';
export class ESQLAstAdapter {
constructor(
@ -118,4 +76,14 @@ export class ESQLAstAdapter {
})),
};
}
async codeAction(
model: monaco.editor.ITextModel,
range: monaco.Range,
context: monaco.languages.CodeActionContext
) {
const getAstFn = await this.getAstWorker(model);
const codeActions = await getActions(model, range, context, getAstFn, this.callbacks);
return codeActions;
}
}

View file

@ -23,6 +23,13 @@ import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hover.js'; // Needed f
import 'monaco-editor/esm/vs/editor/contrib/parameterHints/browser/parameterHints.js'; // Needed for signature
import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching.js'; // Needed for brackets matching highlight
import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeAction.js';
import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionCommands.js';
import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionContributions.js';
// import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionKeybindingResolver.js';
import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionMenu.js';
import 'monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionModel.js';
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js';
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js'; // Needed for basic javascript support
import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js'; // Needed for basic xml support

View file

@ -32,6 +32,7 @@ export interface LanguageProvidersModule<Deps = unknown> {
getSuggestionProvider: (callbacks?: Deps) => monaco.languages.CompletionItemProvider;
getSignatureProvider?: (callbacks?: Deps) => monaco.languages.SignatureHelpProvider;
getHoverProvider?: (callbacks?: Deps) => monaco.languages.HoverProvider;
getCodeActionProvider?: (callbacks?: Deps) => monaco.languages.CodeActionProvider;
}
export interface CustomLangModuleType<Deps = unknown>
@ -47,6 +48,7 @@ export interface EditorError {
endLineNumber: number;
endColumn: number;
message: string;
code?: string | undefined;
}
export interface LangValidation {

View file

@ -417,6 +417,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
[language, esqlCallbacks]
);
const codeActionProvider = useMemo(
() => (language === 'esql' ? ESQLLang.getCodeActionProvider?.(esqlCallbacks) : undefined),
[language, esqlCallbacks]
);
const onErrorClick = useCallback(({ startLineNumber, startColumn }: MonacoMessage) => {
if (!editor1.current) {
return;
@ -541,6 +546,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
vertical: 'auto',
},
overviewRulerBorder: false,
// this becomes confusing with multiple markers, so quick fixes
// will be proposed only within the tooltip
lightbulb: {
enabled: false,
},
readOnly:
isLoading ||
isDisabled ||
@ -776,6 +786,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
return hoverProvider?.provideHover(model, position, token);
},
}}
codeActions={codeActionProvider}
onChange={onQueryUpdate}
editorDidMount={(editor) => {
editor1.current = editor;

View file

@ -91,6 +91,13 @@ export interface CodeEditorProps {
*/
languageConfiguration?: monaco.languages.LanguageConfiguration;
/**
* CodeAction provider for code actions on markers feedback
* Documentation for the provider can be found here:
* https://microsoft.github.io/monaco-editor/docs.html#interfaces/languages.CodeActionProvider.html
*/
codeActions?: monaco.languages.CodeActionProvider;
/**
* Function called before the editor is mounted in the view
*/
@ -152,6 +159,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
hoverProvider,
placeholder,
languageConfiguration,
codeActions,
'aria-label': ariaLabel = i18n.translate('sharedUXPackages.codeEditor.ariaLabel', {
defaultMessage: 'Code Editor',
}),
@ -349,6 +357,10 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
if (languageConfiguration) {
monaco.languages.setLanguageConfiguration(languageId, languageConfiguration);
}
if (codeActions) {
monaco.languages.registerCodeActionProvider(languageId, codeActions);
}
});
// Register themes
@ -366,6 +378,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
suggestionProvider,
signatureProvider,
hoverProvider,
codeActions,
languageConfiguration,
]
);