[ES|QL] Make getActions more resilient to lack of callbacks (#180260)

## Summary

Now `getActions` provides some more fixes in case of lack of callabacks,
like with unquoted fields.
The feature is still experimental and applies only to unquoted fields
(disabling the existence check on quoted fields).

### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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:
Marco Liberati 2024-04-08 16:07:54 +02:00 committed by GitHub
parent 069d814fe4
commit c2adb13ee9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 476 additions and 164 deletions

View file

@ -25,8 +25,8 @@ export class ESQLErrorListener extends ErrorListener<any> {
const textMessage = `SyntaxError: ${message}`;
const tokenPosition = getPosition(offendingSymbol);
const startColumn = tokenPosition?.min + 1 || column;
const endColumn = tokenPosition?.max + 1 || column + 1;
const startColumn = offendingSymbol && tokenPosition ? tokenPosition.min + 1 : column + 1;
const endColumn = offendingSymbol && tokenPosition ? tokenPosition.max + 1 : column + 2;
this.errors.push({
startLineNumber: line,

View file

@ -116,6 +116,7 @@ const {title, edits} = await getActions(
queryString,
errors,
getAstAndSyntaxErrors,
undefined,
myCallbacks
);
@ -124,6 +125,33 @@ const {title, edits} = await getActions(
console.log({ title, edits });
```
Like with validation also `getActions` can 'relax' its internal checks when no callbacks, either all or specific ones, are passed.
```js
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
import { validateQuery, getActions } from '@kbn/esql-validation-autocomplete';
const queryString = "from index2 | keep unquoted-field"
const myCallbacks = {
getSources: async () => [{name: 'index', hidden: false}],
...
};
const { errors, warnings } = await validateQuery(queryString, getAstAndSyntaxErrors, undefined, myCallbacks);
const {title, edits} = await getActions(
queryString,
errors,
getAstAndSyntaxErrors,
{ relaxOnMissingCallbacks: true },
myCallbacks
);
console.log(edits[0].text); // => `unquoted-field`
```
**Note**: this behaviour is still experimental, and applied for few error types, like the unquoted fields case.
### getAstContext
This is an important function in order to build more features on top of the existing one.

View file

@ -10,6 +10,7 @@ import { getActions } from './actions';
import { validateQuery } from '../validation/validation';
import { getAllFunctions } from '../shared/helpers';
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
import { CodeActionOptions } from './types';
function getCallbackMocks() {
return {
@ -60,6 +61,8 @@ function getCallbackMocks() {
};
}
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
/**
* 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
@ -68,27 +71,41 @@ function getCallbackMocks() {
function testQuickFixesFn(
statement: string,
expectedFixes: string[] = [],
options: { equalityCheck?: 'include' | 'equal' } = {},
options: Simplify<{ equalityCheck?: 'include' | 'equal' } & CodeActionOptions> = {},
{ only, skip }: { only?: boolean; skip?: boolean } = {}
) {
const testFn = only ? it.only : skip ? it.skip : it;
testFn(`${statement} => ["${expectedFixes.join('","')}"]`, async () => {
const callbackMocks = getCallbackMocks();
const { errors } = await validateQuery(
statement,
getAstAndSyntaxErrors,
undefined,
callbackMocks
);
testFn(
`${statement} => ["${expectedFixes.join('","')}"]${
options.relaxOnMissingCallbacks != null
? ` (Relaxed = ${options.relaxOnMissingCallbacks})`
: ''
} `,
async () => {
const callbackMocks = getCallbackMocks();
const { errors } = await validateQuery(
statement,
getAstAndSyntaxErrors,
undefined,
callbackMocks
);
const { equalityCheck, ...fnOptions } = options || {};
const actions = await getActions(statement, errors, getAstAndSyntaxErrors, callbackMocks);
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
expect(edits).toEqual(
!options || !options.equalityCheck || options.equalityCheck === 'equal'
? expectedFixes
: expect.arrayContaining(expectedFixes)
);
});
const actions = await getActions(
statement,
errors,
getAstAndSyntaxErrors,
fnOptions,
callbackMocks
);
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
expect(edits).toEqual(
!equalityCheck || equalityCheck === 'equal'
? expectedFixes
: expect.arrayContaining(expectedFixes)
);
}
);
}
type TestArgs = [string, string[], { equalityCheck?: 'include' | 'equal' }?];
@ -111,84 +128,128 @@ const testQuickFixes = Object.assign(testQuickFixesFn, {
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', []);
for (const options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
// No error, no quick action
testQuickFixes('FROM index', [], options);
testQuickFixes('FROM index2', ['index'], options);
testQuickFixes('FROM myindex', ['index', 'my-index'], options);
// wildcards
testQuickFixes('FROM index*', [], options);
testQuickFixes('FROM ind*', [], options);
testQuickFixes('FROM end*', ['ind*']);
testQuickFixes('FROM endex*', ['index'], options);
// Too far for the levenstein distance and should not fix with a hidden index
testQuickFixes('FROM secretIndex', [], options);
testQuickFixes('FROM secretIndex2', [], options);
}
});
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('metafields spellchecks', () => {
for (const isWrapped of [true, false]) {
function setWrapping(text: string) {
return isWrapped ? `[${text}]` : text;
}
testQuickFixes(`FROM index ${setWrapping('metadata _i_ndex')}`, ['_index']);
testQuickFixes(`FROM index ${setWrapping('metadata _id, _i_ndex')}`, ['_index']);
testQuickFixes(`FROM index ${setWrapping('METADATA _id, _i_ndex')}`, ['_index']);
for (const options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
for (const command of ['KEEP', 'DROP', 'EVAL']) {
testQuickFixes(`FROM index | ${command} stringField`, [], options);
// strongField => stringField
testQuickFixes(`FROM index | ${command} strongField`, ['stringField'], options);
testQuickFixes(
`FROM index | ${command} numberField, strongField`,
['stringField'],
options
);
}
});
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField'], options);
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField'], options);
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField'], options);
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField'], options);
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField'], options);
// This levarage the knowledge of the enrich policy fields to suggest the right field
testQuickFixes(
`FROM index | ENRICH policy | KEEP yetAnotherField2`,
['yetAnotherField'],
options
);
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField'], options);
testQuickFixes(
`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`,
['yetAnotherField'],
options
);
describe('metafields spellchecks', () => {
for (const isWrapped of [true, false]) {
function setWrapping(text: string) {
return isWrapped ? `[${text}]` : text;
}
testQuickFixes(`FROM index ${setWrapping('metadata _i_ndex')}`, ['_index'], options);
testQuickFixes(`FROM index ${setWrapping('metadata _id, _i_ndex')}`, ['_index'], options);
testQuickFixes(`FROM index ${setWrapping('METADATA _id, _i_ndex')}`, ['_index'], options);
}
});
}
});
describe('fixing meta 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']);
for (const options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
for (const command of ['KEEP', 'DROP', 'EVAL']) {
testQuickFixes(`FROM index | ${command} stringField`, [], options);
// strongField => stringField
testQuickFixes(`FROM index | ${command} strongField`, ['stringField'], options);
testQuickFixes(
`FROM index | ${command} numberField, strongField`,
['stringField'],
options
);
}
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField'], options);
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField'], options);
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField'], options);
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField'], options);
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField'], options);
// This levarage the knowledge of the enrich policy fields to suggest the right field
testQuickFixes(
`FROM index | ENRICH policy | KEEP yetAnotherField2`,
['yetAnotherField'],
options
);
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField'], options);
testQuickFixes(
`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`,
['yetAnotherField'],
options
);
}
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[]']);
for (const options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
testQuickFixes(`FROM index | ENRICH poli`, ['policy'], options);
testQuickFixes(`FROM index | ENRICH mypolicy`, ['policy'], options);
testQuickFixes(`FROM index | ENRICH policy[`, ['policy', 'policy[]'], options);
describe('modes', () => {
testQuickFixes(`FROM index | ENRICH _ann:policy`, ['_any']);
const modes = ['_any', '_coordinator', '_remote'];
for (const mode of modes) {
testQuickFixes(`FROM index | ENRICH ${mode.replace('_', '@')}:policy`, [mode]);
}
testQuickFixes(`FROM index | ENRICH unknown:policy`, modes);
});
describe('modes', () => {
testQuickFixes(`FROM index | ENRICH _ann:policy`, ['_any'], options);
const modes = ['_any', '_coordinator', '_remote'];
for (const mode of modes) {
testQuickFixes(`FROM index | ENRICH ${mode.replace('_', '@')}:policy`, [mode], options);
}
testQuickFixes(`FROM index | ENRICH unknown:policy`, modes, options);
});
}
});
describe('fixing function spellchecks', () => {
@ -197,68 +258,147 @@ describe('quick fixes logic', () => {
}
// 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 options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
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', ...options }
);
testQuickFixes(
`FROM index | EVAL var0 = ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include', ...options }
);
testQuickFixes(
`FROM index | STATS avg(${BROKEN_PREFIX}${fn.name}())`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include', ...options }
);
testQuickFixes(
`FROM index | STATS avg(numberField) BY ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include', ...options }
);
testQuickFixes(
`FROM index | STATS avg(numberField) BY var0 = ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include', ...options }
);
}
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', ...options }
);
testQuickFixes(
`FROM index | STATS var0 = ${BROKEN_PREFIX}${fn.name}()`,
[fn.name].map(toFunctionSignature),
{ equalityCheck: 'include', ...options }
);
}
// it should preserve the arguments
testQuickFixes(`FROM index | EVAL rAund(numberField)`, ['round(numberField)'], {
equalityCheck: 'include',
...options,
});
testQuickFixes(`FROM index | STATS AVVG(numberField)`, ['avg(numberField)'], {
equalityCheck: 'include',
...options,
});
}
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"']);
for (const options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
testQuickFixes(`FROM index | WHERE stringField like 'asda'`, ['"asda"'], options);
testQuickFixes(`FROM index | WHERE stringField not like 'asda'`, ['"asda"'], options);
}
});
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`']);
for (const options of [
undefined,
{ relaxOnMissingCallbacks: false },
{ relaxOnMissingCallbacks: false },
]) {
testQuickFixes('FROM index | DROP any#Char$Field', ['`any#Char$Field`'], options);
testQuickFixes(
'FROM index | DROP numberField, any#Char$Field',
['`any#Char$Field`'],
options
);
}
describe('with no callbacks', () => {
describe('with no relaxed option', () => {
it('return no result without callbacks and relaxed option', async () => {
const statement = `FROM index | DROP any#Char$Field`;
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors);
const edits = await getActions(statement, errors, getAstAndSyntaxErrors);
expect(edits.length).toBe(0);
});
it('return no result without specific callback and relaxed option', async () => {
const callbackMocks = getCallbackMocks();
const statement = `FROM index | DROP any#Char$Field`;
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors, undefined, {
...callbackMocks,
getFieldsFor: undefined,
});
const edits = await getActions(statement, errors, getAstAndSyntaxErrors, undefined, {
...callbackMocks,
getFieldsFor: undefined,
});
expect(edits.length).toBe(0);
});
});
describe('with relaxed option', () => {
it('return a result without callbacks and relaxed option', async () => {
const statement = `FROM index | DROP any#Char$Field`;
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors);
const actions = await getActions(statement, errors, getAstAndSyntaxErrors, {
relaxOnMissingCallbacks: true,
});
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
expect(edits).toEqual(['`any#Char$Field`']);
});
it('return a result without specific callback and relaxed option', async () => {
const callbackMocks = getCallbackMocks();
const statement = `FROM index | DROP any#Char$Field`;
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors, undefined, {
...callbackMocks,
getFieldsFor: undefined,
});
const actions = await getActions(
statement,
errors,
getAstAndSyntaxErrors,
{
relaxOnMissingCallbacks: true,
},
{ ...callbackMocks, getFieldsFor: undefined }
);
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
expect(edits).toEqual(['`any#Char$Field`']);
});
});
});
});
describe('callbacks', () => {
it('should not crash if callback functions are not passed', async () => {
it('should not crash if specific callback functions are not passed', async () => {
const callbackMocks = getCallbackMocks();
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
const { errors } = await validateQuery(
@ -268,7 +408,7 @@ describe('quick fixes logic', () => {
callbackMocks
);
try {
await getActions(statement, errors, getAstAndSyntaxErrors, {
await getActions(statement, errors, getAstAndSyntaxErrors, undefined, {
getFieldsFor: undefined,
getSources: undefined,
getPolicies: undefined,
@ -279,6 +419,33 @@ describe('quick fixes logic', () => {
}
});
it('should not crash if specific callback functions are not passed with relaxed option', async () => {
const callbackMocks = getCallbackMocks();
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
const { errors } = await validateQuery(
statement,
getAstAndSyntaxErrors,
undefined,
callbackMocks
);
try {
await getActions(
statement,
errors,
getAstAndSyntaxErrors,
{ relaxOnMissingCallbacks: true },
{
getFieldsFor: undefined,
getSources: undefined,
getPolicies: undefined,
getMetaFields: undefined,
}
);
} catch {
fail('Should not throw');
}
});
it('should not crash no callbacks are passed', async () => {
const callbackMocks = getCallbackMocks();
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
@ -289,7 +456,25 @@ describe('quick fixes logic', () => {
callbackMocks
);
try {
await getActions(statement, errors, getAstAndSyntaxErrors, undefined);
await getActions(statement, errors, getAstAndSyntaxErrors);
} catch {
fail('Should not throw');
}
});
it('should not crash no callbacks are passed with relaxed option', async () => {
const callbackMocks = getCallbackMocks();
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
const { errors } = await validateQuery(
statement,
getAstAndSyntaxErrors,
undefined,
callbackMocks
);
try {
await getActions(statement, errors, getAstAndSyntaxErrors, {
relaxOnMissingCallbacks: true,
});
} catch {
fail('Should not throw');
}

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import levenshtein from 'js-levenshtein';
import type { AstProviderFn, ESQLAst, ESQLCommand, EditorError, ESQLMessage } from '@kbn/esql-ast';
import { uniqBy } from 'lodash';
import {
getFieldsByTypeHelper,
getPolicyHelper,
@ -16,13 +17,15 @@ import {
import {
getAllFunctions,
getCommandDefinition,
isColumnItem,
isSourceItem,
shouldBeQuotedText,
} from '../shared/helpers';
import { ESQLCallbacks } from '../shared/types';
import { buildQueryForFieldsFromSource } from '../validation/helpers';
import { DOUBLE_BACKTICK, SINGLE_TICK_REGEX } from '../shared/constants';
import type { CodeAction, Callbacks } from './types';
import type { CodeAction, Callbacks, CodeActionOptions } from './types';
import { getAstContext } from '../shared/context';
import { wrapAsEditorMessage } from './utils';
function getFieldsByTypeRetriever(queryString: string, resourceRetriever?: ESQLCallbacks) {
@ -114,9 +117,13 @@ async function getSpellingActionForColumns(
error: EditorError,
queryString: string,
ast: ESQLAst,
{ getFieldsByType, getPolicies, getPolicyFields }: Callbacks
options: CodeActionOptions,
{ getFieldsByType, getPolicies, getPolicyFields }: Partial<Callbacks>
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
if (!getFieldsByType || !getPolicyFields) {
return [];
}
// @TODO add variables support
const possibleFields = await getSpellingPossibilities(async () => {
const availableFields = await getFieldsByType('any');
@ -133,11 +140,32 @@ async function getSpellingActionForColumns(
return wrapIntoSpellingChangeAction(error, possibleFields);
}
function extractUnquotedFieldText(
query: string,
errorType: string,
ast: ESQLAst,
possibleStart: number,
end: number
) {
if (errorType === 'syntaxError') {
// scope it down to column items for now
const { node } = getAstContext(query, ast, possibleStart - 1);
if (node && isColumnItem(node)) {
return {
start: node.location.min + 1,
name: query.substring(node.location.min, end).trimEnd(),
};
}
}
return { start: possibleStart + 1, name: query.substring(possibleStart, end).trimEnd() };
}
async function getQuotableActionForColumns(
error: EditorError,
queryString: string,
ast: ESQLAst,
{ getFieldsByType }: Callbacks
options: CodeActionOptions,
{ getFieldsByType }: Partial<Callbacks>
): Promise<CodeAction[]> {
const commandEndIndex = ast.find((command) => command.location.max > error.endColumn)?.location
.max;
@ -159,14 +187,20 @@ async function getQuotableActionForColumns(
error.endColumn - 1,
error.endColumn + stopIndex
);
const errorText = queryString
.substring(error.startColumn - 1, error.endColumn + possibleUnquotedText.length)
.trimEnd();
const { start, name: errorText } = extractUnquotedFieldText(
queryString,
error.code || 'syntaxError',
ast,
error.startColumn - 1,
error.endColumn + possibleUnquotedText.length
);
const actions: CodeAction[] = [];
if (shouldBeQuotedText(errorText)) {
const availableFields = new Set(await getFieldsByType('any'));
const solution = `\`${errorText.replace(SINGLE_TICK_REGEX, DOUBLE_BACKTICK)}\``;
if (availableFields.has(errorText) || availableFields.has(solution)) {
if (!getFieldsByType) {
if (!options.relaxOnMissingCallbacks) {
return [];
}
actions.push(
createAction(
i18n.translate('kbn-esql-validation-autocomplete.esql.quickfix.replaceWithSolution', {
@ -176,9 +210,25 @@ async function getQuotableActionForColumns(
},
}),
solution,
{ ...error, endColumn: error.startColumn + errorText.length } // override the location
{ ...error, startColumn: start, endColumn: start + errorText.length } // override the location
)
);
} else {
const availableFields = new Set(await getFieldsByType('any'));
if (availableFields.has(errorText) || availableFields.has(solution)) {
actions.push(
createAction(
i18n.translate('kbn-esql-validation-autocomplete.esql.quickfix.replaceWithSolution', {
defaultMessage: 'Did you mean {solution} ?',
values: {
solution,
},
}),
solution,
{ ...error, startColumn: start, endColumn: start + errorText.length } // override the location
)
);
}
}
}
return actions;
@ -188,8 +238,12 @@ async function getSpellingActionForIndex(
error: EditorError,
queryString: string,
ast: ESQLAst,
{ getSources }: Callbacks
options: CodeActionOptions,
{ getSources }: Partial<Callbacks>
) {
if (!getSources) {
return [];
}
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const possibleSources = await getSpellingPossibilities(async () => {
// Handle fuzzy names via truncation to test levenstein distance
@ -208,8 +262,12 @@ async function getSpellingActionForPolicies(
error: EditorError,
queryString: string,
ast: ESQLAst,
{ getPolicies }: Callbacks
options: CodeActionOptions,
{ getPolicies }: Partial<Callbacks>
) {
if (!getPolicies) {
return [];
}
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const possiblePolicies = await getSpellingPossibilities(getPolicies, errorText);
return wrapIntoSpellingChangeAction(error, possiblePolicies);
@ -245,8 +303,12 @@ async function getSpellingActionForMetadata(
error: EditorError,
queryString: string,
ast: ESQLAst,
{ getMetaFields }: Callbacks
options: CodeActionOptions,
{ getMetaFields }: Partial<Callbacks>
) {
if (!getMetaFields) {
return [];
}
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const possibleMetafields = await getSpellingPossibilities(getMetaFields, errorText);
return wrapIntoSpellingChangeAction(error, possibleMetafields);
@ -256,7 +318,8 @@ async function getSpellingActionForEnrichMode(
error: EditorError,
queryString: string,
ast: ESQLAst,
_callbacks: Callbacks
options: CodeActionOptions,
_callbacks: Partial<Callbacks>
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const commandContext =
@ -300,17 +363,27 @@ function extractQuotedText(rawText: string, error: EditorError) {
return rawText.substring(error.startColumn - 2, error.endColumn);
}
function inferCodeFromError(error: EditorError & { owner?: string }, rawText: string) {
function inferCodeFromError(
error: EditorError & { owner?: string },
ast: ESQLAst,
rawText: string
) {
if (error.message.endsWith('expecting QUOTED_STRING')) {
const value = extractQuotedText(rawText, error);
return /^'(.)*'$/.test(value) ? 'wrongQuotes' : undefined;
}
if (error.message.startsWith('SyntaxError: token recognition error at:')) {
// scope it down to column items for now
const { node } = getAstContext(rawText, ast, error.startColumn - 2);
return node && isColumnItem(node) ? 'quotableFields' : undefined;
}
}
export async function getActions(
innerText: string,
markers: Array<ESQLMessage | EditorError>,
astProvider: AstProviderFn,
options: CodeActionOptions = {},
resourceRetriever?: ESQLCallbacks
): Promise<CodeAction[]> {
const actions: CodeAction[] = [];
@ -327,28 +400,46 @@ export async function getActions(
const getMetaFields = getMetaFieldsRetriever(innerText, ast, resourceRetriever);
const callbacks = {
getFieldsByType,
getSources,
getPolicies,
getPolicyFields,
getMetaFields,
getFieldsByType: resourceRetriever?.getFieldsFor ? getFieldsByType : undefined,
getSources: resourceRetriever?.getSources ? getSources : undefined,
getPolicies: resourceRetriever?.getPolicies ? getPolicies : undefined,
getPolicyFields: resourceRetriever?.getPolicies ? getPolicyFields : undefined,
getMetaFields: resourceRetriever?.getMetaFields ? getMetaFields : undefined,
};
// 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 editorMarkers) {
const code = error.code ?? inferCodeFromError(error, innerText);
const code = error.code ?? inferCodeFromError(error, ast, innerText);
switch (code) {
case 'unknownColumn':
case 'unknownColumn': {
const [columnsSpellChanges, columnsQuotedChanges] = await Promise.all([
getSpellingActionForColumns(error, innerText, ast, callbacks),
getQuotableActionForColumns(error, innerText, ast, callbacks),
getSpellingActionForColumns(error, innerText, ast, options, callbacks),
getQuotableActionForColumns(error, innerText, ast, options, callbacks),
]);
actions.push(...(columnsQuotedChanges.length ? columnsQuotedChanges : columnsSpellChanges));
break;
}
case 'quotableFields': {
const columnsQuotedChanges = await getQuotableActionForColumns(
error,
innerText,
ast,
options,
callbacks
);
actions.push(...columnsQuotedChanges);
break;
}
case 'unknownIndex':
const indexSpellChanges = await getSpellingActionForIndex(error, innerText, ast, callbacks);
const indexSpellChanges = await getSpellingActionForIndex(
error,
innerText,
ast,
options,
callbacks
);
actions.push(...indexSpellChanges);
break;
case 'unknownPolicy':
@ -356,6 +447,7 @@ export async function getActions(
error,
innerText,
ast,
options,
callbacks
);
actions.push(...policySpellChanges);
@ -369,6 +461,7 @@ export async function getActions(
error,
innerText,
ast,
options,
callbacks
);
actions.push(...metadataSpellChanges);
@ -396,6 +489,7 @@ export async function getActions(
error,
innerText,
ast,
options,
callbacks
);
actions.push(...enrichModeSpellChanges);
@ -404,5 +498,5 @@ export async function getActions(
break;
}
}
return actions;
return uniqBy(actions, ({ edits }) => edits[0].text);
}

View file

@ -31,3 +31,7 @@ export interface CodeAction {
text: string;
}>;
}
export interface CodeActionOptions {
relaxOnMissingCallbacks?: boolean;
}

View file

@ -89,6 +89,7 @@ export class ESQLAstAdapter {
model.getValue(),
context.markers as EditorError[],
getAstFn,
undefined,
this.callbacks
);
return codeActions;