mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
Enhancements to painless autocomplete in monaco (#85055)
This commit is contained in:
parent
7ab5b03948
commit
7b362458e7
9 changed files with 168 additions and 33 deletions
|
@ -21,7 +21,7 @@ const { accessSync, mkdirSync } = require('fs');
|
|||
const { join } = require('path');
|
||||
const simpleGit = require('simple-git');
|
||||
|
||||
// Note: The generated whitelists have not yet been merged to master
|
||||
// Note: The generated allowlists have not yet been merged to ES
|
||||
// so this script may fail until code in this branch has been merged:
|
||||
// https://github.com/stu-elastic/elasticsearch/tree/scripting/whitelists
|
||||
const esRepo = 'https://github.com/elastic/elasticsearch.git';
|
||||
|
|
|
@ -18,9 +18,9 @@
|
|||
*/
|
||||
|
||||
import { ID } from './constants';
|
||||
import { lexerRules } from './lexer_rules';
|
||||
import { lexerRules, languageConfiguration } from './lexer_rules';
|
||||
import { getSuggestionProvider } from './language';
|
||||
|
||||
export const PainlessLang = { ID, getSuggestionProvider, lexerRules };
|
||||
export const PainlessLang = { ID, getSuggestionProvider, lexerRules, languageConfiguration };
|
||||
|
||||
export { PainlessContext, PainlessAutocompleteField } from './types';
|
||||
|
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export { lexerRules } from './painless';
|
||||
export { lexerRules, languageConfiguration } from './painless';
|
||||
|
|
|
@ -180,3 +180,17 @@ export const lexerRules = {
|
|||
],
|
||||
},
|
||||
} as Language;
|
||||
|
||||
export const languageConfiguration: monaco.languages.LanguageConfiguration = {
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
],
|
||||
};
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
*/
|
||||
|
||||
import { PainlessCompletionItem } from '../../types';
|
||||
import { lexerRules } from '../../lexer_rules';
|
||||
|
||||
import {
|
||||
getStaticSuggestions,
|
||||
|
@ -26,17 +25,11 @@ import {
|
|||
getClassMemberSuggestions,
|
||||
getPrimitives,
|
||||
getConstructorSuggestions,
|
||||
getKeywords,
|
||||
Suggestion,
|
||||
} from './autocomplete';
|
||||
|
||||
const keywords: PainlessCompletionItem[] = lexerRules.keywords.map((keyword) => {
|
||||
return {
|
||||
label: keyword,
|
||||
kind: 'keyword',
|
||||
documentation: 'Keyword: char',
|
||||
insertText: keyword,
|
||||
};
|
||||
});
|
||||
const keywords: PainlessCompletionItem[] = getKeywords();
|
||||
|
||||
const testSuggestions: Suggestion[] = [
|
||||
{
|
||||
|
@ -101,7 +94,7 @@ const testSuggestions: Suggestion[] = [
|
|||
describe('Autocomplete lib', () => {
|
||||
describe('Static suggestions', () => {
|
||||
test('returns static suggestions', () => {
|
||||
expect(getStaticSuggestions(testSuggestions, false)).toEqual({
|
||||
expect(getStaticSuggestions({ suggestions: testSuggestions })).toEqual({
|
||||
isIncomplete: false,
|
||||
suggestions: [
|
||||
{
|
||||
|
@ -134,12 +127,26 @@ describe('Autocomplete lib', () => {
|
|||
});
|
||||
|
||||
test('returns doc keyword when fields exist', () => {
|
||||
const autocompletion = getStaticSuggestions(testSuggestions, true);
|
||||
const autocompletion = getStaticSuggestions({
|
||||
suggestions: testSuggestions,
|
||||
hasFields: true,
|
||||
});
|
||||
const docSuggestion = autocompletion.suggestions.find(
|
||||
(suggestion) => suggestion.label === 'doc'
|
||||
);
|
||||
expect(Boolean(docSuggestion)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns emit keyword for runtime fields', () => {
|
||||
const autocompletion = getStaticSuggestions({
|
||||
suggestions: testSuggestions,
|
||||
isRuntimeContext: true,
|
||||
});
|
||||
const emitSuggestion = autocompletion.suggestions.find(
|
||||
(suggestion) => suggestion.label === 'emit'
|
||||
);
|
||||
expect(Boolean(emitSuggestion)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrimitives()', () => {
|
||||
|
|
|
@ -53,14 +53,42 @@ export interface Suggestion extends PainlessCompletionItem {
|
|||
constructorDefinition?: PainlessCompletionItem;
|
||||
}
|
||||
|
||||
const keywords: PainlessCompletionItem[] = lexerRules.keywords.map((keyword) => {
|
||||
return {
|
||||
label: keyword,
|
||||
kind: 'keyword',
|
||||
documentation: 'Keyword: char',
|
||||
insertText: keyword,
|
||||
};
|
||||
});
|
||||
export const getKeywords = (): PainlessCompletionItem[] => {
|
||||
const lexerKeywords: PainlessCompletionItem[] = lexerRules.keywords.map((keyword) => {
|
||||
return {
|
||||
label: keyword,
|
||||
kind: 'keyword',
|
||||
documentation: `Keyword: ${keyword}`,
|
||||
insertText: keyword,
|
||||
};
|
||||
});
|
||||
|
||||
const allKeywords: PainlessCompletionItem[] = [
|
||||
...lexerKeywords,
|
||||
{
|
||||
label: 'params',
|
||||
kind: 'keyword',
|
||||
documentation: i18n.translate(
|
||||
'monaco.painlessLanguage.autocomplete.paramsKeywordDescription',
|
||||
{
|
||||
defaultMessage: 'Access variables passed into the script.',
|
||||
}
|
||||
),
|
||||
insertText: 'params',
|
||||
},
|
||||
];
|
||||
|
||||
return allKeywords;
|
||||
};
|
||||
|
||||
const runtimeContexts: PainlessContext[] = [
|
||||
'boolean_script_field_script_field',
|
||||
'date_script_field',
|
||||
'double_script_field_script_field',
|
||||
'ip_script_field_script_field',
|
||||
'long_script_field_script_field',
|
||||
'string_script_field_script_field',
|
||||
];
|
||||
|
||||
const mapContextToData: { [key: string]: { suggestions: any[] } } = {
|
||||
painless_test: painlessTestContext,
|
||||
|
@ -75,16 +103,23 @@ const mapContextToData: { [key: string]: { suggestions: any[] } } = {
|
|||
string_script_field_script_field: stringScriptFieldScriptFieldContext,
|
||||
};
|
||||
|
||||
export const getStaticSuggestions = (
|
||||
suggestions: Suggestion[],
|
||||
hasFields: boolean
|
||||
): PainlessCompletionResult => {
|
||||
export const getStaticSuggestions = ({
|
||||
suggestions,
|
||||
hasFields,
|
||||
isRuntimeContext,
|
||||
}: {
|
||||
suggestions: Suggestion[];
|
||||
hasFields?: boolean;
|
||||
isRuntimeContext?: boolean;
|
||||
}): PainlessCompletionResult => {
|
||||
const classSuggestions: PainlessCompletionItem[] = suggestions.map((suggestion) => {
|
||||
const { properties, constructorDefinition, ...rootSuggestion } = suggestion;
|
||||
return rootSuggestion;
|
||||
});
|
||||
|
||||
const keywordSuggestions: PainlessCompletionItem[] = hasFields
|
||||
const keywords = getKeywords();
|
||||
|
||||
let keywordSuggestions: PainlessCompletionItem[] = hasFields
|
||||
? [
|
||||
...keywords,
|
||||
{
|
||||
|
@ -102,6 +137,23 @@ export const getStaticSuggestions = (
|
|||
]
|
||||
: keywords;
|
||||
|
||||
keywordSuggestions = isRuntimeContext
|
||||
? [
|
||||
...keywordSuggestions,
|
||||
{
|
||||
label: 'emit',
|
||||
kind: 'keyword',
|
||||
documentation: i18n.translate(
|
||||
'monaco.painlessLanguage.autocomplete.emitKeywordDescription',
|
||||
{
|
||||
defaultMessage: 'Emit value without returning.',
|
||||
}
|
||||
),
|
||||
insertText: 'emit',
|
||||
},
|
||||
]
|
||||
: keywordSuggestions;
|
||||
|
||||
return {
|
||||
isIncomplete: false,
|
||||
suggestions: [...classSuggestions, ...keywordSuggestions],
|
||||
|
@ -176,6 +228,12 @@ export const getAutocompleteSuggestions = (
|
|||
// What the user is currently typing
|
||||
const activeTyping = words[words.length - 1];
|
||||
const primitives = getPrimitives(suggestions);
|
||||
// This logic may end up needing to be more robust as we integrate autocomplete into more editors
|
||||
// For now, we're assuming there is a list of painless contexts that are only applicable in runtime fields
|
||||
const isRuntimeContext = runtimeContexts.includes(painlessContext);
|
||||
// "text" field types are not available in doc values and should be removed for autocompletion
|
||||
const filteredFields = fields?.filter((field) => field.type !== 'text');
|
||||
const hasFields = Boolean(filteredFields?.length);
|
||||
|
||||
let autocompleteSuggestions: PainlessCompletionResult = {
|
||||
isIncomplete: false,
|
||||
|
@ -184,13 +242,13 @@ export const getAutocompleteSuggestions = (
|
|||
|
||||
if (isConstructorInstance(words)) {
|
||||
autocompleteSuggestions = getConstructorSuggestions(suggestions);
|
||||
} else if (fields && isDeclaringField(activeTyping)) {
|
||||
autocompleteSuggestions = getFieldSuggestions(fields);
|
||||
} else if (filteredFields && isDeclaringField(activeTyping)) {
|
||||
autocompleteSuggestions = getFieldSuggestions(filteredFields);
|
||||
} else if (isAccessingProperty(activeTyping)) {
|
||||
const className = activeTyping.substring(0, activeTyping.length - 1).split('.')[0];
|
||||
autocompleteSuggestions = getClassMemberSuggestions(suggestions, className);
|
||||
} else if (showStaticSuggestions(activeTyping, words, primitives)) {
|
||||
autocompleteSuggestions = getStaticSuggestions(suggestions, Boolean(fields?.length));
|
||||
autocompleteSuggestions = getStaticSuggestions({ suggestions, hasFields, isRuntimeContext });
|
||||
}
|
||||
return autocompleteSuggestions;
|
||||
};
|
||||
|
|
|
@ -23,6 +23,8 @@ import {
|
|||
hasDeclaredType,
|
||||
isAccessingProperty,
|
||||
showStaticSuggestions,
|
||||
isDefiningString,
|
||||
isDefiningBoolean,
|
||||
} from './autocomplete_utils';
|
||||
|
||||
const primitives = ['boolean', 'int', 'char', 'float', 'double'];
|
||||
|
@ -62,6 +64,24 @@ describe('Utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isDefiningBoolean()', () => {
|
||||
test('returns true or false depending if an array contains a boolean type and "=" token at a specific index', () => {
|
||||
expect(isDefiningBoolean(['boolean', 'myBoolean', '=', 't'])).toEqual(true);
|
||||
expect(isDefiningBoolean(['double', 'myBoolean', '=', 't'])).toEqual(false);
|
||||
expect(isDefiningBoolean(['boolean', '='])).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefiningString()', () => {
|
||||
test('returns true or false depending if active typing contains a single or double quotation mark', () => {
|
||||
expect(isDefiningString(`'mystring'`)).toEqual(true);
|
||||
expect(isDefiningString(`"mystring"`)).toEqual(true);
|
||||
expect(isDefiningString(`'`)).toEqual(true);
|
||||
expect(isDefiningString(`"`)).toEqual(true);
|
||||
expect(isDefiningString('mystring')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showStaticSuggestions()', () => {
|
||||
test('returns true or false depending if a type is declared or the string contains a "."', () => {
|
||||
expect(showStaticSuggestions('a', ['a'], primitives)).toEqual(true);
|
||||
|
|
|
@ -36,11 +36,39 @@ export const isAccessingProperty = (activeTyping: string): boolean => {
|
|||
/**
|
||||
* If the preceding word is a primitive type, e.g., "boolean",
|
||||
* we assume the user is declaring a variable and will skip autocomplete
|
||||
*
|
||||
* Note: this isn't entirely exhaustive. For example, "def myVar =" is not included in context
|
||||
* It's also acceptable to use a class as a type, e.g., "String myVar ="
|
||||
*/
|
||||
export const hasDeclaredType = (activeLineWords: string[], primitives: string[]): boolean => {
|
||||
return activeLineWords.length === 2 && primitives.includes(activeLineWords[0]);
|
||||
};
|
||||
|
||||
/**
|
||||
* If the active line words contains the "boolean" type and "=" token,
|
||||
* we assume the user is defining a boolean value and skip autocomplete
|
||||
*/
|
||||
export const isDefiningBoolean = (activeLineWords: string[]): boolean => {
|
||||
if (activeLineWords.length === 4) {
|
||||
const maybePrimitiveType = activeLineWords[0];
|
||||
const maybeEqualToken = activeLineWords[2];
|
||||
return maybePrimitiveType === 'boolean' && maybeEqualToken === '=';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* If the active typing contains a start or end quotation mark,
|
||||
* we assume the user is defining a string and skip autocomplete
|
||||
*/
|
||||
export const isDefiningString = (activeTyping: string): boolean => {
|
||||
const quoteTokens = [`'`, `"`];
|
||||
const activeTypingParts = activeTyping.split('');
|
||||
const startCharacter = activeTypingParts[0];
|
||||
const endCharacter = activeTypingParts[activeTypingParts.length - 1];
|
||||
return quoteTokens.includes(startCharacter) || quoteTokens.includes(endCharacter);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the preceding word contains the "new" keyword
|
||||
*/
|
||||
|
@ -62,8 +90,10 @@ export const isDeclaringField = (activeTyping: string): boolean => {
|
|||
/**
|
||||
* Static suggestions serve as a catch-all most of the time
|
||||
* However, there are a few situations where we do not want to show them and instead default to the built-in monaco (abc) autocomplete
|
||||
* 1. If the preceding word is a type, e.g., "boolean", we assume the user is declaring a variable name
|
||||
* 1. If the preceding word is a primitive type, e.g., "boolean", we assume the user is declaring a variable name
|
||||
* 2. If the string contains a "dot" character, we assume the user is attempting to access a property that we do not have information for
|
||||
* 3. If the user is defining a variable with a boolean type, e.g., "boolean myBoolean ="
|
||||
* 4. If the user is defining a string
|
||||
*/
|
||||
export const showStaticSuggestions = (
|
||||
activeTyping: string,
|
||||
|
@ -72,5 +102,10 @@ export const showStaticSuggestions = (
|
|||
): boolean => {
|
||||
const activeTypingParts = activeTyping.split('.');
|
||||
|
||||
return hasDeclaredType(activeLineWords, primitives) === false && activeTypingParts.length === 1;
|
||||
return (
|
||||
hasDeclaredType(activeLineWords, primitives) === false &&
|
||||
isDefiningBoolean(activeLineWords) === false &&
|
||||
isDefiningString(activeTyping) === false &&
|
||||
activeTypingParts.length === 1
|
||||
);
|
||||
};
|
||||
|
|
|
@ -36,6 +36,7 @@ monaco.languages.setMonarchTokensProvider(XJsonLang.ID, XJsonLang.lexerRules);
|
|||
monaco.languages.setLanguageConfiguration(XJsonLang.ID, XJsonLang.languageConfiguration);
|
||||
monaco.languages.register({ id: PainlessLang.ID });
|
||||
monaco.languages.setMonarchTokensProvider(PainlessLang.ID, PainlessLang.lexerRules);
|
||||
monaco.languages.setLanguageConfiguration(PainlessLang.ID, PainlessLang.languageConfiguration);
|
||||
monaco.languages.register({ id: EsqlLang.ID });
|
||||
monaco.languages.setMonarchTokensProvider(EsqlLang.ID, EsqlLang.lexerRules);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue