mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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  * wildcard support  * policy spellcheck quick fix  * field/column spellcheck quick fix  * function spellcheck quick fix  * wrong quotes quick fix for literal strings  * unquoted field/column quick fix  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:
parent
e654a5be64
commit
31026a0b65
25 changed files with 879 additions and 108 deletions
|
@ -29,6 +29,7 @@ SHARED_DEPS = [
|
|||
"@npm//antlr4ts",
|
||||
"@npm//monaco-editor",
|
||||
"@npm//monaco-yaml",
|
||||
"@npm//js-levenshtein",
|
||||
]
|
||||
|
||||
webpack_cli(
|
||||
|
|
|
@ -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: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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)`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
257
packages/kbn-monaco/src/esql/lib/ast/code_actions/index.test.ts
Normal file
257
packages/kbn-monaco/src/esql/lib/ast/code_actions/index.test.ts
Normal 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`']);
|
||||
});
|
||||
});
|
383
packages/kbn-monaco/src/esql/lib/ast/code_actions/index.ts
Normal file
383
packages/kbn-monaco/src/esql/lib/ast/code_actions/index.ts
Normal 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;
|
||||
}
|
|
@ -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.',
|
||||
}),
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
|
|
|
@ -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',
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1052,4 +1052,5 @@ export const evalFunctionsDefinitions: FunctionDefinition[] = [
|
|||
...def,
|
||||
supportedCommands: ['eval', 'where', 'row'],
|
||||
supportedOptions: ['by'],
|
||||
type: 'eval',
|
||||
}));
|
||||
|
|
|
@ -99,6 +99,7 @@ export const appendSeparatorOption: CommandOptionsDefinition = {
|
|||
},
|
||||
}),
|
||||
type: 'error',
|
||||
code: 'wrongDissectOptionArgumentType',
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
54
packages/kbn-monaco/src/esql/lib/ast/shared/monaco_utils.ts
Normal file
54
packages/kbn-monaco/src/esql/lib/ast/shared/monaco_utils.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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>();
|
||||
|
|
|
@ -85,6 +85,7 @@ export interface ESQLMessage {
|
|||
type: 'error' | 'warning';
|
||||
text: string;
|
||||
location: ESQLLocation;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export type AstProviderFn = (
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue