mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[ES|QL] Add hints upon hover for function argument types and time system types (#191881)
## Summary This PR adds hints list of acceptable functional argument types when user hovers over text or the empty space before cursor. https://github.com/user-attachments/assets/280598bb-f848-4cb8-b1e5-afd6509bab56 In addition, it also adds some helpful hints to guide towards named system params: - If argument can be a constant date, it will suggest ?t_start and ?t_end - If user hovers over ?t_start and ?t_end params, and the cursor is positioned to be at least 3 characters long (e.g. if it can guess whether the text is start or end) then it will show an explanation what the params mean. This is helpful for some pre-populated queries or snippet. <img width="1144" alt="image" src="https://github.com/user-attachments/assets/b39a2c44-abc8-4008-978c-41fc8fd4cc34"> ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
6d0970bc75
commit
9b61a1ecd0
9 changed files with 339 additions and 142 deletions
|
@ -114,6 +114,17 @@ export interface ESQLFunction<
|
|||
args: ESQLAstItem[];
|
||||
}
|
||||
|
||||
const isESQLAstBaseItem = (node: unknown): node is ESQLAstBaseItem =>
|
||||
typeof node === 'object' &&
|
||||
node !== null &&
|
||||
Object.hasOwn(node, 'name') &&
|
||||
Object.hasOwn(node, 'text');
|
||||
|
||||
export const isESQLFunction = (node: unknown): node is ESQLFunction =>
|
||||
isESQLAstBaseItem(node) &&
|
||||
Object.hasOwn(node, 'type') &&
|
||||
(node as ESQLFunction).type === 'function';
|
||||
|
||||
export interface ESQLFunctionCallExpression extends ESQLFunction<'variadic-call'> {
|
||||
subtype: 'variadic-call';
|
||||
args: ESQLAstItem[];
|
||||
|
@ -293,6 +304,10 @@ export interface ESQLNamedParamLiteral extends ESQLParamLiteral<'named'> {
|
|||
value: string;
|
||||
}
|
||||
|
||||
export const isESQLNamedParamLiteral = (node: ESQLAstItem): node is ESQLNamedParamLiteral =>
|
||||
isESQLAstBaseItem(node) &&
|
||||
(node as ESQLNamedParamLiteral).literalType === 'param' &&
|
||||
(node as ESQLNamedParamLiteral).paramType === 'named';
|
||||
/**
|
||||
* *Positional* parameter is a question mark followed by a number "?1".
|
||||
*
|
||||
|
|
|
@ -30,11 +30,9 @@ import {
|
|||
isAssignment,
|
||||
isAssignmentComplete,
|
||||
isColumnItem,
|
||||
isComma,
|
||||
isFunctionItem,
|
||||
isIncompleteItem,
|
||||
isLiteralItem,
|
||||
isMathFunction,
|
||||
isOptionItem,
|
||||
isRestartingExpression,
|
||||
isSourceCommand,
|
||||
|
@ -47,6 +45,7 @@ import {
|
|||
getColumnExists,
|
||||
findPreviousWord,
|
||||
noCaseCompare,
|
||||
correctQuerySyntax,
|
||||
getColumnByName,
|
||||
} from '../shared/helpers';
|
||||
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
|
||||
|
@ -98,11 +97,8 @@ import {
|
|||
getSourcesFromCommands,
|
||||
getSupportedTypesForBinaryOperators,
|
||||
isAggFunctionUsedAlready,
|
||||
getCompatibleTypesToSuggestNext,
|
||||
removeQuoteForSuggestedSources,
|
||||
getValidFunctionSignaturesForPreviousArgs,
|
||||
strictlyGetParamAtPosition,
|
||||
isLiteralDateItem,
|
||||
getValidSignaturesAndTypesToSuggestNext,
|
||||
} from './helper';
|
||||
import {
|
||||
FunctionParameter,
|
||||
|
@ -158,76 +154,6 @@ function getFinalSuggestions({ comma }: { comma?: boolean } = { comma: true }) {
|
|||
return finalSuggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function count the number of unclosed brackets in order to
|
||||
* locally fix the queryString to generate a valid AST
|
||||
* A known limitation of this is that is not aware of commas "," or pipes "|"
|
||||
* so it is not yet helpful on a multiple commands errors (a workaround it to pass each command here...)
|
||||
* @param bracketType
|
||||
* @param text
|
||||
* @returns
|
||||
*/
|
||||
function countBracketsUnclosed(bracketType: '(' | '[' | '"' | '"""', text: string) {
|
||||
const stack = [];
|
||||
const closingBrackets = { '(': ')', '[': ']', '"': '"', '"""': '"""' };
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const substr = text.substring(i, i + bracketType.length);
|
||||
if (substr === closingBrackets[bracketType] && stack.length) {
|
||||
stack.pop();
|
||||
} else if (substr === bracketType) {
|
||||
stack.push(bracketType);
|
||||
}
|
||||
}
|
||||
return stack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function attempts to correct the syntax of a partial query to make it valid.
|
||||
*
|
||||
* This is important because a syntactically-invalid query will not generate a good AST.
|
||||
*
|
||||
* @param _query
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
function correctQuerySyntax(_query: string, context: EditorContext) {
|
||||
let query = _query;
|
||||
// check if all brackets are closed, otherwise close them
|
||||
const unclosedRoundBrackets = countBracketsUnclosed('(', query);
|
||||
const unclosedSquaredBrackets = countBracketsUnclosed('[', query);
|
||||
const unclosedQuotes = countBracketsUnclosed('"', query);
|
||||
const unclosedTripleQuotes = countBracketsUnclosed('"""', query);
|
||||
// if it's a comma by the user or a forced trigger by a function argument suggestion
|
||||
// add a marker to make the expression still valid
|
||||
const charThatNeedMarkers = [',', ':'];
|
||||
if (
|
||||
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
|
||||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
|
||||
(context.triggerKind === 0 && unclosedRoundBrackets === 0) ||
|
||||
(context.triggerCharacter === ' ' && isMathFunction(query, query.length)) ||
|
||||
isComma(query.trimEnd()[query.trimEnd().length - 1])
|
||||
) {
|
||||
query += EDITOR_MARKER;
|
||||
}
|
||||
|
||||
// if there are unclosed brackets, close them
|
||||
if (unclosedRoundBrackets || unclosedSquaredBrackets || unclosedQuotes) {
|
||||
for (const [char, count] of [
|
||||
['"""', unclosedTripleQuotes],
|
||||
['"', unclosedQuotes],
|
||||
[')', unclosedRoundBrackets],
|
||||
[']', unclosedSquaredBrackets],
|
||||
]) {
|
||||
if (count) {
|
||||
// inject the closing brackets
|
||||
query += Array(count).fill(char).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export async function suggest(
|
||||
fullText: string,
|
||||
offset: number,
|
||||
|
@ -332,7 +258,7 @@ export async function suggest(
|
|||
return [];
|
||||
}
|
||||
|
||||
function getFieldsByTypeRetriever(
|
||||
export function getFieldsByTypeRetriever(
|
||||
queryString: string,
|
||||
resourceRetriever?: ESQLCallbacks
|
||||
): { getFieldsByType: GetFieldsByTypeFn; getFieldsMap: GetFieldsMapFn } {
|
||||
|
@ -1290,18 +1216,6 @@ async function getFunctionArgsSuggestions(
|
|||
fields: fieldsMap,
|
||||
variables: anyVariables,
|
||||
};
|
||||
|
||||
const enrichedArgs = node.args.map((nodeArg) => {
|
||||
let dataType = extractTypeFromASTArg(nodeArg, references);
|
||||
|
||||
// For named system time parameters ?start and ?end, make sure it's compatiable
|
||||
if (isLiteralDateItem(nodeArg)) {
|
||||
dataType = 'date';
|
||||
}
|
||||
|
||||
return { ...nodeArg, dataType } as ESQLAstItem & { dataType: string };
|
||||
});
|
||||
|
||||
const variablesExcludingCurrentCommandOnes = excludeVariablesFromCurrentCommand(
|
||||
commands,
|
||||
command,
|
||||
|
@ -1309,30 +1223,10 @@ async function getFunctionArgsSuggestions(
|
|||
innerText
|
||||
);
|
||||
|
||||
// pick the type of the next arg
|
||||
const shouldGetNextArgument = node.text.includes(EDITOR_MARKER);
|
||||
let argIndex = Math.max(node.args.length, 0);
|
||||
if (!shouldGetNextArgument && argIndex) {
|
||||
argIndex -= 1;
|
||||
}
|
||||
|
||||
const { typesToSuggestNext, hasMoreMandatoryArgs, enrichedArgs, argIndex } =
|
||||
getValidSignaturesAndTypesToSuggestNext(node, references, fnDefinition, fullText, offset);
|
||||
const arg: ESQLAstItem = enrichedArgs[argIndex];
|
||||
|
||||
const validSignatures = getValidFunctionSignaturesForPreviousArgs(
|
||||
fnDefinition,
|
||||
enrichedArgs,
|
||||
argIndex
|
||||
);
|
||||
// Retrieve unique of types that are compatiable for the current arg
|
||||
const typesToSuggestNext = getCompatibleTypesToSuggestNext(fnDefinition, enrichedArgs, argIndex);
|
||||
const hasMoreMandatoryArgs = !validSignatures
|
||||
// Types available to suggest next after this argument is completed
|
||||
.map((signature) => strictlyGetParamAtPosition(signature, argIndex + 1))
|
||||
// when a param is null, it means param is optional
|
||||
// If there's at least one param that is optional, then
|
||||
// no need to suggest comma
|
||||
.some((p) => p === null || p?.optional === true);
|
||||
|
||||
// Whether to prepend comma to suggestion string
|
||||
// E.g. if true, "fieldName" -> "fieldName, "
|
||||
const alreadyHasComma = fullText ? fullText[offset] === ',' : false;
|
||||
|
|
|
@ -438,6 +438,20 @@ export function getCompatibleLiterals(
|
|||
return suggestions;
|
||||
}
|
||||
|
||||
export const TIME_SYSTEM_DESCRIPTIONS = {
|
||||
'?t_start': i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.autocomplete.timeSystemParamStart',
|
||||
{
|
||||
defaultMessage: 'The start time from the date picker',
|
||||
}
|
||||
),
|
||||
'?t_end': i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.autocomplete.timeSystemParamEnd',
|
||||
{
|
||||
defaultMessage: 'The end time from the date picker',
|
||||
}
|
||||
),
|
||||
};
|
||||
export function getDateLiterals(options?: {
|
||||
advanceCursorAndOpenSuggestions?: boolean;
|
||||
addComma?: boolean;
|
||||
|
|
|
@ -19,6 +19,9 @@ import {
|
|||
import type { SuggestionRawDefinition } from './types';
|
||||
import { compareTypesWithLiterals } from '../shared/esql_types';
|
||||
import { TIME_SYSTEM_PARAMS } from './factories';
|
||||
import { EDITOR_MARKER } from '../shared/constants';
|
||||
import { extractTypeFromASTArg } from './autocomplete';
|
||||
import { ESQLRealField, ESQLVariable } from '../validation/types';
|
||||
|
||||
function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] {
|
||||
return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem);
|
||||
|
@ -212,7 +215,8 @@ export function getOverlapRange(
|
|||
};
|
||||
}
|
||||
|
||||
function isValidDateString(dateString: string): boolean {
|
||||
function isValidDateString(dateString: unknown): boolean {
|
||||
if (typeof dateString !== 'string') return false;
|
||||
const timestamp = Date.parse(dateString.replace(/\"/g, ''));
|
||||
return !isNaN(timestamp);
|
||||
}
|
||||
|
@ -229,6 +233,63 @@ export function isLiteralDateItem(nodeArg: ESQLAstItem): boolean {
|
|||
// If text is ?start or ?end, it's a system time parameter
|
||||
(TIME_SYSTEM_PARAMS.includes(nodeArg.text) ||
|
||||
// Or if it's a string generated by date picker
|
||||
isValidDateString(nodeArg.text))
|
||||
isValidDateString(nodeArg.value))
|
||||
);
|
||||
}
|
||||
|
||||
export function getValidSignaturesAndTypesToSuggestNext(
|
||||
node: ESQLFunction,
|
||||
references: { fields: Map<string, ESQLRealField>; variables: Map<string, ESQLVariable[]> },
|
||||
fnDefinition: FunctionDefinition,
|
||||
fullText: string,
|
||||
offset: number
|
||||
) {
|
||||
const enrichedArgs = node.args.map((nodeArg) => {
|
||||
let dataType = extractTypeFromASTArg(nodeArg, references);
|
||||
|
||||
// For named system time parameters ?start and ?end, make sure it's compatiable
|
||||
if (isLiteralDateItem(nodeArg)) {
|
||||
dataType = 'date';
|
||||
}
|
||||
|
||||
return { ...nodeArg, dataType } as ESQLAstItem & { dataType: string };
|
||||
});
|
||||
|
||||
// pick the type of the next arg
|
||||
const shouldGetNextArgument = node.text.includes(EDITOR_MARKER);
|
||||
let argIndex = Math.max(node.args.length, 0);
|
||||
if (!shouldGetNextArgument && argIndex) {
|
||||
argIndex -= 1;
|
||||
}
|
||||
|
||||
const validSignatures = getValidFunctionSignaturesForPreviousArgs(
|
||||
fnDefinition,
|
||||
enrichedArgs,
|
||||
argIndex
|
||||
);
|
||||
// Retrieve unique of types that are compatiable for the current arg
|
||||
const typesToSuggestNext = getCompatibleTypesToSuggestNext(fnDefinition, enrichedArgs, argIndex);
|
||||
const hasMoreMandatoryArgs = !validSignatures
|
||||
// Types available to suggest next after this argument is completed
|
||||
.map((signature) => strictlyGetParamAtPosition(signature, argIndex + 1))
|
||||
// when a param is null, it means param is optional
|
||||
// If there's at least one param that is optional, then
|
||||
// no need to suggest comma
|
||||
.some((p) => p === null || p?.optional === true);
|
||||
|
||||
// Whether to prepend comma to suggestion string
|
||||
// E.g. if true, "fieldName" -> "fieldName, "
|
||||
const alreadyHasComma = fullText ? fullText[offset] === ',' : false;
|
||||
const shouldAddComma =
|
||||
hasMoreMandatoryArgs && fnDefinition.type !== 'builtin' && !alreadyHasComma;
|
||||
const currentArg = enrichedArgs[argIndex];
|
||||
return {
|
||||
shouldAddComma,
|
||||
typesToSuggestNext,
|
||||
validSignatures,
|
||||
hasMoreMandatoryArgs,
|
||||
enrichedArgs,
|
||||
argIndex,
|
||||
currentArg,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -79,8 +79,10 @@ export const compareTypesWithLiterals = (
|
|||
// date_period is day/week/month/year interval
|
||||
// time_literal includes time_duration and date_period
|
||||
// So they are equivalent AST's 'timeInterval' (a date unit constant: e.g. 1 year, 15 month)
|
||||
if (a === 'time_literal' || a === 'time_duration') return b === 'timeInterval';
|
||||
if (b === 'time_literal' || b === 'time_duration') return a === 'timeInterval';
|
||||
if (a === 'time_literal' || a === 'time_duration' || a === 'date_period')
|
||||
return b === 'timeInterval';
|
||||
if (b === 'time_literal' || b === 'time_duration' || b === 'date_period')
|
||||
return a === 'timeInterval';
|
||||
if (a === 'time_literal') return b === 'time_duration';
|
||||
if (b === 'time_literal') return a === 'time_duration';
|
||||
|
||||
|
|
|
@ -48,6 +48,8 @@ import type { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/t
|
|||
import { removeMarkerArgFromArgsList } from './context';
|
||||
import { isNumericDecimalType } from './esql_types';
|
||||
import type { ReasonTypes } from './types';
|
||||
import { EDITOR_MARKER } from './constants';
|
||||
import type { EditorContext } from '../autocomplete/types';
|
||||
|
||||
export function nonNullable<T>(v: T): v is NonNullable<T> {
|
||||
return v != null;
|
||||
|
@ -662,3 +664,73 @@ export const isParam = (x: unknown): x is ESQLParamLiteral =>
|
|||
* Compares two strings in a case-insensitive manner
|
||||
*/
|
||||
export const noCaseCompare = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
/**
|
||||
* This function count the number of unclosed brackets in order to
|
||||
* locally fix the queryString to generate a valid AST
|
||||
* A known limitation of this is that is not aware of commas "," or pipes "|"
|
||||
* so it is not yet helpful on a multiple commands errors (a workaround it to pass each command here...)
|
||||
* @param bracketType
|
||||
* @param text
|
||||
* @returns
|
||||
*/
|
||||
export function countBracketsUnclosed(bracketType: '(' | '[' | '"' | '"""', text: string) {
|
||||
const stack = [];
|
||||
const closingBrackets = { '(': ')', '[': ']', '"': '"', '"""': '"""' };
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const substr = text.substring(i, i + bracketType.length);
|
||||
if (substr === closingBrackets[bracketType] && stack.length) {
|
||||
stack.pop();
|
||||
} else if (substr === bracketType) {
|
||||
stack.push(bracketType);
|
||||
}
|
||||
}
|
||||
return stack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function attempts to correct the syntax of a partial query to make it valid.
|
||||
*
|
||||
* This is important because a syntactically-invalid query will not generate a good AST.
|
||||
*
|
||||
* @param _query
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
export function correctQuerySyntax(_query: string, context: EditorContext) {
|
||||
let query = _query;
|
||||
// check if all brackets are closed, otherwise close them
|
||||
const unclosedRoundBrackets = countBracketsUnclosed('(', query);
|
||||
const unclosedSquaredBrackets = countBracketsUnclosed('[', query);
|
||||
const unclosedQuotes = countBracketsUnclosed('"', query);
|
||||
const unclosedTripleQuotes = countBracketsUnclosed('"""', query);
|
||||
// if it's a comma by the user or a forced trigger by a function argument suggestion
|
||||
// add a marker to make the expression still valid
|
||||
const charThatNeedMarkers = [',', ':'];
|
||||
if (
|
||||
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
|
||||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
|
||||
(context.triggerKind === 0 && unclosedRoundBrackets === 0) ||
|
||||
(context.triggerCharacter === ' ' && isMathFunction(query, query.length)) ||
|
||||
isComma(query.trimEnd()[query.trimEnd().length - 1])
|
||||
) {
|
||||
query += EDITOR_MARKER;
|
||||
}
|
||||
|
||||
// if there are unclosed brackets, close them
|
||||
if (unclosedRoundBrackets || unclosedSquaredBrackets || unclosedQuotes) {
|
||||
for (const [char, count] of [
|
||||
['"""', unclosedTripleQuotes],
|
||||
['"', unclosedQuotes],
|
||||
[')', unclosedRoundBrackets],
|
||||
[']', unclosedSquaredBrackets],
|
||||
]) {
|
||||
if (count) {
|
||||
// inject the closing brackets
|
||||
query += Array(count).fill(char).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
|
|
@ -84,7 +84,11 @@ function createCustomCallbackMocks(
|
|||
|
||||
function createModelAndPosition(text: string, string: string) {
|
||||
return {
|
||||
model: { getValue: () => text } as monaco.editor.ITextModel,
|
||||
model: {
|
||||
getValue: () => text,
|
||||
getLineCount: () => text.split('\n').length,
|
||||
getLineMaxColumn: (lineNumber: number) => text.split('\n')[lineNumber - 1].length,
|
||||
} as unknown as monaco.editor.ITextModel,
|
||||
// bumo the column by one as the internal logic has a -1 offset when converting frmo monaco
|
||||
position: { lineNumber: 1, column: text.lastIndexOf(string) + 1 } as monaco.Position,
|
||||
};
|
||||
|
@ -206,13 +210,17 @@ describe('hover', () => {
|
|||
'nonExistentFn',
|
||||
createFunctionContent
|
||||
);
|
||||
testSuggestions(`from a | stats avg(round(numberField))`, 'round', createFunctionContent);
|
||||
testSuggestions(`from a | stats avg(round(numberField))`, 'round', () => {
|
||||
return [
|
||||
'**Acceptable types**: **double** | **integer** | **long**',
|
||||
...createFunctionContent('round'),
|
||||
];
|
||||
});
|
||||
testSuggestions(`from a | stats avg(round(numberField))`, 'avg', createFunctionContent);
|
||||
testSuggestions(
|
||||
`from a | stats avg(nonExistentFn(numberField))`,
|
||||
'nonExistentFn',
|
||||
createFunctionContent
|
||||
);
|
||||
testSuggestions(`from a | stats avg(nonExistentFn(numberField))`, 'nonExistentFn', () => [
|
||||
'**Acceptable types**: **double** | **integer** | **long**',
|
||||
...createFunctionContent('nonExistentFn'),
|
||||
]);
|
||||
testSuggestions(`from a | where round(numberField) > 0`, 'round', createFunctionContent);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { AstProviderFn } from '@kbn/esql-ast';
|
||||
import type { AstProviderFn, ESQLAstItem } from '@kbn/esql-ast';
|
||||
import {
|
||||
getAstContext,
|
||||
getFunctionDefinition,
|
||||
|
@ -18,9 +18,123 @@ import {
|
|||
getCommandDefinition,
|
||||
type ESQLCallbacks,
|
||||
getPolicyHelper,
|
||||
collectVariables,
|
||||
ESQLRealField,
|
||||
} from '@kbn/esql-validation-autocomplete';
|
||||
import type { monaco } from '../../../monaco_imports';
|
||||
import { correctQuerySyntax } from '@kbn/esql-validation-autocomplete/src/shared/helpers';
|
||||
import type { EditorContext } from '@kbn/esql-validation-autocomplete/src/autocomplete/types';
|
||||
import {
|
||||
getQueryForFields,
|
||||
getValidSignaturesAndTypesToSuggestNext,
|
||||
} from '@kbn/esql-validation-autocomplete/src/autocomplete/helper';
|
||||
import { buildQueryUntilPreviousCommand } from '@kbn/esql-validation-autocomplete/src/shared/resources_helpers';
|
||||
import { getFieldsByTypeRetriever } from '@kbn/esql-validation-autocomplete/src/autocomplete/autocomplete';
|
||||
import {
|
||||
TIME_SYSTEM_DESCRIPTIONS,
|
||||
TIME_SYSTEM_PARAMS,
|
||||
} from '@kbn/esql-validation-autocomplete/src/autocomplete/factories';
|
||||
import { isESQLFunction, isESQLNamedParamLiteral } from '@kbn/esql-ast/src/types';
|
||||
import { monacoPositionToOffset } from '../shared/utils';
|
||||
import { monaco } from '../../../monaco_imports';
|
||||
|
||||
const ACCEPTABLE_TYPES_HOVER = i18n.translate('monaco.esql.hover.acceptableTypes', {
|
||||
defaultMessage: 'Acceptable types',
|
||||
});
|
||||
|
||||
async function getHoverItemForFunction(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
token: monaco.CancellationToken,
|
||||
astProvider: AstProviderFn,
|
||||
resourceRetriever?: ESQLCallbacks
|
||||
) {
|
||||
const context: EditorContext = {
|
||||
triggerCharacter: ' ',
|
||||
triggerKind: 1,
|
||||
};
|
||||
|
||||
const fullText = model.getValue();
|
||||
const offset = monacoPositionToOffset(fullText, position);
|
||||
const innerText = fullText.substring(0, offset);
|
||||
|
||||
const correctedQuery = correctQuerySyntax(innerText, context);
|
||||
const { ast } = await astProvider(correctedQuery);
|
||||
const astContext = getAstContext(innerText, ast, offset);
|
||||
|
||||
const { node } = astContext;
|
||||
const commands = ast;
|
||||
|
||||
if (isESQLFunction(node) && astContext.type === 'function') {
|
||||
const queryForFields = getQueryForFields(
|
||||
buildQueryUntilPreviousCommand(ast, correctedQuery),
|
||||
ast
|
||||
);
|
||||
const { getFieldsMap } = getFieldsByTypeRetriever(queryForFields, resourceRetriever);
|
||||
|
||||
const fnDefinition = getFunctionDefinition(node.name);
|
||||
// early exit on no hit
|
||||
if (!fnDefinition) {
|
||||
return undefined;
|
||||
}
|
||||
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
|
||||
const anyVariables = collectVariables(commands, fieldsMap, innerText);
|
||||
|
||||
const references = {
|
||||
fields: fieldsMap,
|
||||
variables: anyVariables,
|
||||
};
|
||||
|
||||
const { typesToSuggestNext, enrichedArgs } = getValidSignaturesAndTypesToSuggestNext(
|
||||
node,
|
||||
references,
|
||||
fnDefinition,
|
||||
fullText,
|
||||
offset
|
||||
);
|
||||
|
||||
const hoveredArg: ESQLAstItem & {
|
||||
dataType: string;
|
||||
} = enrichedArgs[enrichedArgs.length - 1];
|
||||
const contents = [];
|
||||
if (hoveredArg && isESQLNamedParamLiteral(hoveredArg)) {
|
||||
const bestMatch = TIME_SYSTEM_PARAMS.find((p) => p.startsWith(hoveredArg.text));
|
||||
// We only know if it's start or end after first 3 characters (?t_s or ?t_e)
|
||||
if (hoveredArg.text.length > 3 && bestMatch) {
|
||||
Object.entries(TIME_SYSTEM_DESCRIPTIONS).forEach(([key, value]) => {
|
||||
contents.push({
|
||||
value: `**${key}**: ${value}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typesToSuggestNext.length > 0) {
|
||||
contents.push({
|
||||
value: `**${ACCEPTABLE_TYPES_HOVER}**: ${typesToSuggestNext
|
||||
.map(
|
||||
({ type, constantOnly }) =>
|
||||
`${constantOnly ? '_constant_ ' : ''}**${type}**` +
|
||||
// If function arg is a constant date, helpfully suggest named time system params
|
||||
(constantOnly && type === 'date' ? ` | ${TIME_SYSTEM_PARAMS.join(' | ')}` : '')
|
||||
)
|
||||
.join(' | ')}`,
|
||||
});
|
||||
}
|
||||
const hints =
|
||||
contents.length > 0
|
||||
? {
|
||||
range: new monaco.Range(
|
||||
1,
|
||||
1,
|
||||
model.getLineCount(),
|
||||
model.getLineMaxColumn(model.getLineCount())
|
||||
),
|
||||
contents,
|
||||
}
|
||||
: undefined;
|
||||
return hints;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHoverItem(
|
||||
model: monaco.editor.ITextModel,
|
||||
|
@ -29,13 +143,28 @@ export async function getHoverItem(
|
|||
astProvider: AstProviderFn,
|
||||
resourceRetriever?: ESQLCallbacks
|
||||
) {
|
||||
const innerText = model.getValue();
|
||||
const offset = monacoPositionToOffset(innerText, position);
|
||||
const fullText = model.getValue();
|
||||
const offset = monacoPositionToOffset(fullText, position);
|
||||
|
||||
const { ast } = await astProvider(fullText);
|
||||
const astContext = getAstContext(fullText, ast, offset);
|
||||
|
||||
const { ast } = await astProvider(innerText);
|
||||
const astContext = getAstContext(innerText, ast, offset);
|
||||
const { getPolicyMetadata } = getPolicyHelper(resourceRetriever);
|
||||
|
||||
let hoverContent: monaco.languages.Hover = {
|
||||
contents: [],
|
||||
};
|
||||
const hoverItemsForFunction = await getHoverItemForFunction(
|
||||
model,
|
||||
position,
|
||||
token,
|
||||
astProvider,
|
||||
resourceRetriever
|
||||
);
|
||||
if (hoverItemsForFunction) {
|
||||
hoverContent = hoverItemsForFunction;
|
||||
}
|
||||
|
||||
if (['newCommand', 'list'].includes(astContext.type)) {
|
||||
return { contents: [] };
|
||||
}
|
||||
|
@ -44,12 +173,12 @@ export async function getHoverItem(
|
|||
const fnDefinition = getFunctionDefinition(astContext.node.name);
|
||||
|
||||
if (fnDefinition) {
|
||||
return {
|
||||
contents: [
|
||||
hoverContent.contents.push(
|
||||
...[
|
||||
{ value: getFunctionSignatures(fnDefinition)[0].declaration },
|
||||
{ value: fnDefinition.description },
|
||||
],
|
||||
};
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,8 +187,8 @@ export async function getHoverItem(
|
|||
if (isSourceItem(astContext.node) && astContext.node.sourceType === 'policy') {
|
||||
const policyMetadata = await getPolicyMetadata(astContext.node.name);
|
||||
if (policyMetadata) {
|
||||
return {
|
||||
contents: [
|
||||
hoverContent.contents.push(
|
||||
...[
|
||||
{
|
||||
value: `${i18n.translate('monaco.esql.hover.policyIndexes', {
|
||||
defaultMessage: '**Indexes**',
|
||||
|
@ -75,8 +204,8 @@ export async function getHoverItem(
|
|||
defaultMessage: '**Fields**',
|
||||
})}: ${policyMetadata.enrichFields.join(', ')}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isSettingItem(astContext.node)) {
|
||||
|
@ -86,18 +215,17 @@ export async function getHoverItem(
|
|||
);
|
||||
if (settingDef) {
|
||||
const mode = settingDef.values.find(({ name }) => name === astContext.node!.name)!;
|
||||
return {
|
||||
contents: [
|
||||
hoverContent.contents.push(
|
||||
...[
|
||||
{ value: settingDef.description },
|
||||
{
|
||||
value: `**${mode.name}**: ${mode.description}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { contents: [] };
|
||||
return hoverContent;
|
||||
}
|
||||
|
|
|
@ -555,6 +555,9 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
|
|||
onLayoutChangeRef.current = onLayoutChange;
|
||||
|
||||
const codeEditorOptions: CodeEditorProps['options'] = {
|
||||
hover: {
|
||||
above: false,
|
||||
},
|
||||
accessibilitySupport: 'off',
|
||||
autoIndent: 'none',
|
||||
automaticLayout: true,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue