Enhancements to painless autocomplete in monaco (#85055) (#85306)

This commit is contained in:
Alison Goryachev 2020-12-08 14:07:33 -05:00 committed by GitHub
parent 969a122eea
commit 928254db4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 168 additions and 33 deletions

View file

@ -21,7 +21,7 @@ const { accessSync, mkdirSync } = require('fs');
const { join } = require('path'); const { join } = require('path');
const simpleGit = require('simple-git'); 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: // so this script may fail until code in this branch has been merged:
// https://github.com/stu-elastic/elasticsearch/tree/scripting/whitelists // https://github.com/stu-elastic/elasticsearch/tree/scripting/whitelists
const esRepo = 'https://github.com/elastic/elasticsearch.git'; const esRepo = 'https://github.com/elastic/elasticsearch.git';

View file

@ -18,9 +18,9 @@
*/ */
import { ID } from './constants'; import { ID } from './constants';
import { lexerRules } from './lexer_rules'; import { lexerRules, languageConfiguration } from './lexer_rules';
import { getSuggestionProvider } from './language'; import { getSuggestionProvider } from './language';
export const PainlessLang = { ID, getSuggestionProvider, lexerRules }; export const PainlessLang = { ID, getSuggestionProvider, lexerRules, languageConfiguration };
export { PainlessContext, PainlessAutocompleteField } from './types'; export { PainlessContext, PainlessAutocompleteField } from './types';

View file

@ -17,4 +17,4 @@
* under the License. * under the License.
*/ */
export { lexerRules } from './painless'; export { lexerRules, languageConfiguration } from './painless';

View file

@ -180,3 +180,17 @@ export const lexerRules = {
], ],
}, },
} as Language; } as Language;
export const languageConfiguration: monaco.languages.LanguageConfiguration = {
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
],
};

View file

@ -18,7 +18,6 @@
*/ */
import { PainlessCompletionItem } from '../../types'; import { PainlessCompletionItem } from '../../types';
import { lexerRules } from '../../lexer_rules';
import { import {
getStaticSuggestions, getStaticSuggestions,
@ -26,17 +25,11 @@ import {
getClassMemberSuggestions, getClassMemberSuggestions,
getPrimitives, getPrimitives,
getConstructorSuggestions, getConstructorSuggestions,
getKeywords,
Suggestion, Suggestion,
} from './autocomplete'; } from './autocomplete';
const keywords: PainlessCompletionItem[] = lexerRules.keywords.map((keyword) => { const keywords: PainlessCompletionItem[] = getKeywords();
return {
label: keyword,
kind: 'keyword',
documentation: 'Keyword: char',
insertText: keyword,
};
});
const testSuggestions: Suggestion[] = [ const testSuggestions: Suggestion[] = [
{ {
@ -101,7 +94,7 @@ const testSuggestions: Suggestion[] = [
describe('Autocomplete lib', () => { describe('Autocomplete lib', () => {
describe('Static suggestions', () => { describe('Static suggestions', () => {
test('returns static suggestions', () => { test('returns static suggestions', () => {
expect(getStaticSuggestions(testSuggestions, false)).toEqual({ expect(getStaticSuggestions({ suggestions: testSuggestions })).toEqual({
isIncomplete: false, isIncomplete: false,
suggestions: [ suggestions: [
{ {
@ -134,12 +127,26 @@ describe('Autocomplete lib', () => {
}); });
test('returns doc keyword when fields exist', () => { test('returns doc keyword when fields exist', () => {
const autocompletion = getStaticSuggestions(testSuggestions, true); const autocompletion = getStaticSuggestions({
suggestions: testSuggestions,
hasFields: true,
});
const docSuggestion = autocompletion.suggestions.find( const docSuggestion = autocompletion.suggestions.find(
(suggestion) => suggestion.label === 'doc' (suggestion) => suggestion.label === 'doc'
); );
expect(Boolean(docSuggestion)).toBe(true); 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()', () => { describe('getPrimitives()', () => {

View file

@ -53,14 +53,42 @@ export interface Suggestion extends PainlessCompletionItem {
constructorDefinition?: PainlessCompletionItem; constructorDefinition?: PainlessCompletionItem;
} }
const keywords: PainlessCompletionItem[] = lexerRules.keywords.map((keyword) => { export const getKeywords = (): PainlessCompletionItem[] => {
return { const lexerKeywords: PainlessCompletionItem[] = lexerRules.keywords.map((keyword) => {
label: keyword, return {
kind: 'keyword', label: keyword,
documentation: 'Keyword: char', kind: 'keyword',
insertText: 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[] } } = { const mapContextToData: { [key: string]: { suggestions: any[] } } = {
painless_test: painlessTestContext, painless_test: painlessTestContext,
@ -75,16 +103,23 @@ const mapContextToData: { [key: string]: { suggestions: any[] } } = {
string_script_field_script_field: stringScriptFieldScriptFieldContext, string_script_field_script_field: stringScriptFieldScriptFieldContext,
}; };
export const getStaticSuggestions = ( export const getStaticSuggestions = ({
suggestions: Suggestion[], suggestions,
hasFields: boolean hasFields,
): PainlessCompletionResult => { isRuntimeContext,
}: {
suggestions: Suggestion[];
hasFields?: boolean;
isRuntimeContext?: boolean;
}): PainlessCompletionResult => {
const classSuggestions: PainlessCompletionItem[] = suggestions.map((suggestion) => { const classSuggestions: PainlessCompletionItem[] = suggestions.map((suggestion) => {
const { properties, constructorDefinition, ...rootSuggestion } = suggestion; const { properties, constructorDefinition, ...rootSuggestion } = suggestion;
return rootSuggestion; return rootSuggestion;
}); });
const keywordSuggestions: PainlessCompletionItem[] = hasFields const keywords = getKeywords();
let keywordSuggestions: PainlessCompletionItem[] = hasFields
? [ ? [
...keywords, ...keywords,
{ {
@ -102,6 +137,23 @@ export const getStaticSuggestions = (
] ]
: keywords; : keywords;
keywordSuggestions = isRuntimeContext
? [
...keywordSuggestions,
{
label: 'emit',
kind: 'keyword',
documentation: i18n.translate(
'monaco.painlessLanguage.autocomplete.emitKeywordDescription',
{
defaultMessage: 'Emit value without returning.',
}
),
insertText: 'emit',
},
]
: keywordSuggestions;
return { return {
isIncomplete: false, isIncomplete: false,
suggestions: [...classSuggestions, ...keywordSuggestions], suggestions: [...classSuggestions, ...keywordSuggestions],
@ -176,6 +228,12 @@ export const getAutocompleteSuggestions = (
// What the user is currently typing // What the user is currently typing
const activeTyping = words[words.length - 1]; const activeTyping = words[words.length - 1];
const primitives = getPrimitives(suggestions); 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 = { let autocompleteSuggestions: PainlessCompletionResult = {
isIncomplete: false, isIncomplete: false,
@ -184,13 +242,13 @@ export const getAutocompleteSuggestions = (
if (isConstructorInstance(words)) { if (isConstructorInstance(words)) {
autocompleteSuggestions = getConstructorSuggestions(suggestions); autocompleteSuggestions = getConstructorSuggestions(suggestions);
} else if (fields && isDeclaringField(activeTyping)) { } else if (filteredFields && isDeclaringField(activeTyping)) {
autocompleteSuggestions = getFieldSuggestions(fields); autocompleteSuggestions = getFieldSuggestions(filteredFields);
} else if (isAccessingProperty(activeTyping)) { } else if (isAccessingProperty(activeTyping)) {
const className = activeTyping.substring(0, activeTyping.length - 1).split('.')[0]; const className = activeTyping.substring(0, activeTyping.length - 1).split('.')[0];
autocompleteSuggestions = getClassMemberSuggestions(suggestions, className); autocompleteSuggestions = getClassMemberSuggestions(suggestions, className);
} else if (showStaticSuggestions(activeTyping, words, primitives)) { } else if (showStaticSuggestions(activeTyping, words, primitives)) {
autocompleteSuggestions = getStaticSuggestions(suggestions, Boolean(fields?.length)); autocompleteSuggestions = getStaticSuggestions({ suggestions, hasFields, isRuntimeContext });
} }
return autocompleteSuggestions; return autocompleteSuggestions;
}; };

View file

@ -23,6 +23,8 @@ import {
hasDeclaredType, hasDeclaredType,
isAccessingProperty, isAccessingProperty,
showStaticSuggestions, showStaticSuggestions,
isDefiningString,
isDefiningBoolean,
} from './autocomplete_utils'; } from './autocomplete_utils';
const primitives = ['boolean', 'int', 'char', 'float', 'double']; 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()', () => { describe('showStaticSuggestions()', () => {
test('returns true or false depending if a type is declared or the string contains a "."', () => { test('returns true or false depending if a type is declared or the string contains a "."', () => {
expect(showStaticSuggestions('a', ['a'], primitives)).toEqual(true); expect(showStaticSuggestions('a', ['a'], primitives)).toEqual(true);

View file

@ -36,11 +36,39 @@ export const isAccessingProperty = (activeTyping: string): boolean => {
/** /**
* If the preceding word is a primitive type, e.g., "boolean", * If the preceding word is a primitive type, e.g., "boolean",
* we assume the user is declaring a variable and will skip autocomplete * 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 => { export const hasDeclaredType = (activeLineWords: string[], primitives: string[]): boolean => {
return activeLineWords.length === 2 && primitives.includes(activeLineWords[0]); 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 * 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 * 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 * 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 * 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 = ( export const showStaticSuggestions = (
activeTyping: string, activeTyping: string,
@ -72,5 +102,10 @@ export const showStaticSuggestions = (
): boolean => { ): boolean => {
const activeTypingParts = activeTyping.split('.'); 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
);
}; };

View file

@ -36,6 +36,7 @@ monaco.languages.setMonarchTokensProvider(XJsonLang.ID, XJsonLang.lexerRules);
monaco.languages.setLanguageConfiguration(XJsonLang.ID, XJsonLang.languageConfiguration); monaco.languages.setLanguageConfiguration(XJsonLang.ID, XJsonLang.languageConfiguration);
monaco.languages.register({ id: PainlessLang.ID }); monaco.languages.register({ id: PainlessLang.ID });
monaco.languages.setMonarchTokensProvider(PainlessLang.ID, PainlessLang.lexerRules); monaco.languages.setMonarchTokensProvider(PainlessLang.ID, PainlessLang.lexerRules);
monaco.languages.setLanguageConfiguration(PainlessLang.ID, PainlessLang.languageConfiguration);
monaco.languages.register({ id: EsqlLang.ID }); monaco.languages.register({ id: EsqlLang.ID });
monaco.languages.setMonarchTokensProvider(EsqlLang.ID, EsqlLang.lexerRules); monaco.languages.setMonarchTokensProvider(EsqlLang.ID, EsqlLang.lexerRules);