Skip to content

Commit

Permalink
Enhancements to painless autocomplete in monaco (#85055)
Browse files Browse the repository at this point in the history
  • Loading branch information
alisonelizabeth authored Dec 8, 2020
1 parent 7ab5b03 commit 7b36245
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 33 deletions.
2 changes: 1 addition & 1 deletion packages/kbn-monaco/scripts/utils/clone_es.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/kbn-monaco/src/painless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 1 addition & 1 deletion packages/kbn-monaco/src/painless/lexer_rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
* under the License.
*/

export { lexerRules } from './painless';
export { lexerRules, languageConfiguration } from './painless';
14 changes: 14 additions & 0 deletions packages/kbn-monaco/src/painless/lexer_rules/painless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '"' },
],
};
29 changes: 18 additions & 11 deletions packages/kbn-monaco/src/painless/worker/lib/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,18 @@
*/

import { PainlessCompletionItem } from '../../types';
import { lexerRules } from '../../lexer_rules';

import {
getStaticSuggestions,
getFieldSuggestions,
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[] = [
{
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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()', () => {
Expand Down
90 changes: 74 additions & 16 deletions packages/kbn-monaco/src/painless/worker/lib/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
{
Expand All @@ -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],
Expand Down Expand Up @@ -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,
Expand All @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
hasDeclaredType,
isAccessingProperty,
showStaticSuggestions,
isDefiningString,
isDefiningBoolean,
} from './autocomplete_utils';

const primitives = ['boolean', 'int', 'char', 'float', 'double'];
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 37 additions & 2 deletions packages/kbn-monaco/src/painless/worker/lib/autocomplete_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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,
Expand All @@ -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
);
};
1 change: 1 addition & 0 deletions packages/kbn-monaco/src/register_globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down

0 comments on commit 7b36245

Please sign in to comment.