[Console] Add theme and more lexer rules (#178757)

## Summary

This PR adds a theme for the Console language in Monaco editor and adds
more lexer rules to bring the highlighting of the input closed to the
original in Ace editor.

### Screenshots
Monaco editor 
<img width="682" alt="Screenshot 2024-03-19 at 12 38 07"
src="98a1acc7-3a8a-4ad9-a79e-5236091c4c39">

Ace editor
<img width="651" alt="Screenshot 2024-03-19 at 12 37 52"
src="37935a68-923b-493c-ac56-ef4982f27fdf">

### How to test
1. Add `console.dev.enableMonaco: true` to `kibana.dev.yml``
2. Type different requests into Console and check that the highlighting
works the same as in Ace. For example, use the following requests

```
GET ${pathVariable}/_search
{
 "query": {
   "match": {
     "${bodyNameVariable}": "${bodyValueVariable}",
     "number_property": 1234,
     "array_property": ["test1", 1234, false], 
     "boolean_property": true,
     "text_property": "text_value",
     "triple_quote": """
     inside triple quote
     """
     // line comment
     /* 
      block comment
    */
   }
 }
}

// line comment
/* 
block comment
*/

GET _sql
{
  "query": """
  SELECT "field" FROM "index-*" WHERE "column" = "value"
  """
}
```
3. To check that `xjson` highlighting still works
 a. Navigate to Ingest pipelines and click the "create from csv" button
b. Load a valid csv file, for example this
[one](https://github.com/kgeller/ecs-mapper/blob/master/example/mapping.csv)

#### Known issues that will be addressed in follow up PRs
- SQL highlighting needs to be re-implemented (added to the follow up
list in https://github.com/elastic/kibana/issues/176926)
- Strings inside triple quotes are not using italics (added to the
follow up list in https://github.com/elastic/kibana/issues/176926)
- Font size needs to be set via settings and the default value provided
(fixed via https://github.com/elastic/kibana/pull/178982)
- Font family: do we want to use the same font as for other Monaco
languages are use the one for Ace? (added to the follow up list in
https://github.com/elastic/kibana/issues/176926)
- In the future, we might want to use the same theme for `xjson` and
Console (added to the follow up list in
https://github.com/elastic/kibana/issues/176926)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yulia Čech 2024-03-27 11:06:40 +01:00 committed by GitHub
parent f320d560b1
commit c59016ed9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 305 additions and 51 deletions

View file

@ -32,4 +32,4 @@ import { registerLanguage } from './src/helpers';
export { BarePluginApi, registerLanguage };
export * from './src/types';
export { CONSOLE_LANG_ID } from './src/console';
export { CONSOLE_LANG_ID, CONSOLE_THEME_ID } from './src/console';

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
export const themeRuleGroupBuilderFactory =
(postfix: string = '') =>
(tokens: string[], color: string, isBold: boolean = false) =>
tokens.map((i) => ({
token: i + postfix,
foreground: color,
fontStyle: isBold ? 'bold' : '',
}));

View file

@ -7,3 +7,5 @@
*/
export const CONSOLE_LANG_ID = 'console';
export const CONSOLE_THEME_ID = 'consoleTheme';
export const CONSOLE_POSTFIX = '.console';

View file

@ -15,7 +15,9 @@ import type { LangModuleType } from '../types';
import { CONSOLE_LANG_ID } from './constants';
import { lexerRules, languageConfiguration } from './lexer_rules';
export { CONSOLE_LANG_ID } from './constants';
export { CONSOLE_LANG_ID, CONSOLE_THEME_ID } from './constants';
export { buildConsoleTheme } from './theme';
export const ConsoleLang: LangModuleType = {
ID: CONSOLE_LANG_ID,

View file

@ -7,46 +7,191 @@
*/
import { monaco } from '../../monaco_imports';
import { globals } from '../../common/lexer_rules';
import { buildXjsonRules } from '../../xjson/lexer_rules/xjson';
export const languageConfiguration: monaco.languages.LanguageConfiguration = {};
export const languageConfiguration: monaco.languages.LanguageConfiguration = {
brackets: [
['{', '}'],
['[', ']'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '"', close: '"' },
],
};
/*
util function to build the action object
*/
const addNextStateToAction = (tokens: string[], nextState?: string) => {
return tokens.map((token, index) => {
// only last action needs to specify the next state
if (index === tokens.length - 1 && nextState) {
return { token, next: nextState };
}
return token;
});
};
/*
if regex is matched, tokenize as "token" and move to the state "nextState" if defined
*/
const matchToken = (token: string, regex: string | RegExp, nextState?: string) => {
if (nextState) {
return { regex, action: { token, next: nextState } };
}
return { regex, action: { token } };
};
/*
if regex is matched, tokenize as "tokens" consecutively and move to the state "nextState"
regex needs to have the same number of capturing group as the number of tokens
*/
const matchTokens = (tokens: string[], regex: string | RegExp, nextState?: string) => {
const action = addNextStateToAction(tokens, nextState);
return {
regex,
action,
};
};
const matchTokensWithEOL = (
tokens: string | string[],
regex: string | RegExp,
nextIfEOL: string,
normalNext?: string
) => {
if (Array.isArray(tokens)) {
const endOfLineAction = addNextStateToAction(tokens, nextIfEOL);
const action = addNextStateToAction(tokens, normalNext);
return {
regex,
action: {
cases: {
'@eos': endOfLineAction,
'@default': action,
},
},
};
}
return {
regex,
action: {
cases: {
'@eos': { token: tokens, next: nextIfEOL },
'@default': { token: tokens, next: normalNext },
},
},
};
};
const xjsonRules = { ...buildXjsonRules('json_root') };
// @ts-expect-error include comments into json
xjsonRules.json_root = [{ include: '@comments' }, ...xjsonRules.json_root];
xjsonRules.json_root = [
// @ts-expect-error include variables into json
matchToken('variable.template', /("\${\w+}")/),
...xjsonRules.json_root,
];
export const lexerRules: monaco.languages.IMonarchLanguage = {
...(globals as any),
defaultToken: 'invalid',
regex_method: /get|post|put|patch|delete/,
regex_url: /.*$/,
// C# style strings
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
ignoreCase: true,
tokenizer: {
root: [
// whitespace
{ include: '@rule_whitespace' },
// start a multi-line comment
{ include: '@rule_start_multi_comment' },
// a one-line comment
[/\/\/.*$/, 'comment'],
// warning comment
matchToken('warning', '#!.*$'),
// comments
{ include: '@comments' },
// start of json
matchToken('paren.lparen', '{', 'json_root'),
// method
[/@regex_method/, 'keyword'],
// url
[/@regex_url/, 'identifier'],
matchTokensWithEOL('method', /([a-zA-Z]+)/, 'root', 'method_sep'),
// whitespace
matchToken('whitespace', '\\s+'),
// text
matchToken('text', '.+?'),
],
rule_whitespace: [[/[ \t\r\n]+/, 'WHITESPACE']],
rule_start_multi_comment: [[/\/\*/, 'comment', '@rule_multi_comment']],
rule_multi_comment: [
method_sep: [
// protocol host with slash
matchTokensWithEOL(
['whitespace', 'url.protocol_host', 'url.slash'],
/(\s+)(https?:\/\/[^?\/,]+)(\/)/,
'root',
'url'
),
// variable template
matchTokensWithEOL(['whitespace', 'variable.template'], /(\s+)(\${\w+})/, 'root', 'url'),
// protocol host
matchTokensWithEOL(
['whitespace', 'url.protocol_host'],
/(\s+)(https?:\/\/[^?\/,]+)/,
'root',
'url'
),
// slash
matchTokensWithEOL(['whitespace', 'url.slash'], /(\s+)(\/)/, 'root', 'url'),
// whitespace
matchTokensWithEOL('whitespace', /(\s+)/, 'root', 'url'),
],
url: [
// variable template
matchTokensWithEOL('variable.template', /(\${\w+})/, 'root'),
// pathname
matchTokensWithEOL('url.part', /([^?\/,\s]+)\s*/, 'root'),
// comma
matchTokensWithEOL('url.comma', /(,)/, 'root'),
// slash
matchTokensWithEOL('url.slash', /(\/)/, 'root'),
// question mark
matchTokensWithEOL('url.questionmark', /(\?)/, 'root', 'urlParams'),
// comment
matchTokensWithEOL(
['whitespace', 'comment.punctuation', 'comment.line'],
/(\s+)(\/\/)(.*$)/,
'root'
),
],
urlParams: [
// param with variable template
matchTokensWithEOL(
['url.param', 'url.equal', 'variable.template'],
/([^&=]+)(=)(\${\w+})/,
'root'
),
// param with value
matchTokensWithEOL(['url.param', 'url.equal', 'url.value'], /([^&=]+)(=)([^&]*)/, 'root'),
// param
matchTokensWithEOL('url.param', /([^&=]+)/, 'root'),
// ampersand
matchTokensWithEOL('url.amp', /(&)/, 'root'),
// comment
matchTokensWithEOL(
['whitespace', 'comment.punctuation', 'comment.line'],
/(\s+)(\/\/)(.*$)/,
'root'
),
],
comments: [
// line comment indicated by #
matchTokens(['comment.punctuation', 'comment.line'], /(#)(.*$)/),
// start a block comment indicated by /*
matchToken('comment.punctuation', /\/\*/, 'block_comment'),
// line comment indicated by //
matchTokens(['comment.punctuation', 'comment.line'], /(\/\/)(.*$)/),
],
block_comment: [
// match everything on a single line inside the comment except for chars / and *
[/[^\/*]+/, 'comment'],
// start a nested comment by going 1 level down
[/\/\*/, 'comment', '@push'],
// match the closing of the comment and return 1 level up
['\\*/', 'comment', '@pop'],
matchToken('comment', /[^\/*]+/),
// end block comment
matchToken('comment.punctuation', /\*\//, '@pop'),
// match individual chars inside a multi-line comment
[/[\/*]/, 'comment'],
],
string: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
matchToken('comment', /[\/*]/),
],
// include json rules
...xjsonRules,
},
};

View 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 { makeHighContrastColor } from '@elastic/eui';
import { darkMode, euiThemeVars } from '@kbn/ui-theme';
import { themeRuleGroupBuilderFactory } from '../common/theme';
import { monaco } from '../monaco_imports';
const buildRuleGroup = themeRuleGroupBuilderFactory();
const background = euiThemeVars.euiColorLightestShade;
const methodTextColor = '#DD0A73';
const urlTextColor = '#00A69B';
const stringTextColor = '#009926';
const commentTextColor = '#4C886B';
const variableTextColor = '#0079A5';
const booleanTextColor = '#585CF6';
const numericTextColor = variableTextColor;
export const buildConsoleTheme = (): monaco.editor.IStandaloneThemeData => {
return {
base: darkMode ? 'vs-dark' : 'vs',
inherit: true,
rules: [
...buildRuleGroup(['method'], makeHighContrastColor(methodTextColor)(background)),
...buildRuleGroup(['url'], makeHighContrastColor(urlTextColor)(background)),
...buildRuleGroup(
['string', 'string-literal', 'multi-string', 'punctuation.end-triple-quote'],
makeHighContrastColor(stringTextColor)(background)
),
...buildRuleGroup(['comment'], makeHighContrastColor(commentTextColor)(background)),
...buildRuleGroup(['variable'], makeHighContrastColor(variableTextColor)(background)),
...buildRuleGroup(
['constant.language.boolean'],
makeHighContrastColor(booleanTextColor)(background)
),
...buildRuleGroup(['constant.numeric'], makeHighContrastColor(numericTextColor)(background)),
],
colors: {
'editor.background': background,
// color of the line numbers
'editorLineNumber.foreground': euiThemeVars.euiColorDarkShade,
// color of the active line number
'editorLineNumber.activeForeground': euiThemeVars.euiColorDarkShade,
// background of the line numbers side panel
'editorGutter.background': euiThemeVars.euiColorEmptyShade,
},
};
};

View file

@ -7,15 +7,11 @@
*/
import { euiThemeVars, darkMode } from '@kbn/ui-theme';
import { themeRuleGroupBuilderFactory } from '../../../common/theme';
import { ESQL_TOKEN_POSTFIX } from '../constants';
import { monaco } from '../../../monaco_imports';
const buildRuleGroup = (tokens: string[], color: string, isBold: boolean = false) =>
tokens.map((i) => ({
token: i + ESQL_TOKEN_POSTFIX,
foreground: color,
fontStyle: isBold ? 'bold' : '',
}));
const buildRuleGroup = themeRuleGroupBuilderFactory(ESQL_TOKEN_POSTFIX);
export const buildESQlTheme = (): monaco.editor.IStandaloneThemeData => ({
base: darkMode ? 'vs-dark' : 'vs',

View file

@ -13,7 +13,7 @@ import { monaco } from './monaco_imports';
import { ESQL_THEME_ID, ESQLLang, buildESQlTheme } from './esql';
import { YAML_LANG_ID } from './yaml';
import { registerLanguage, registerTheme } from './helpers';
import { ConsoleLang } from './console';
import { ConsoleLang, CONSOLE_THEME_ID, buildConsoleTheme } from './console';
export const DEFAULT_WORKER_ID = 'default';
const langSpecificWorkerIds = [
@ -38,6 +38,7 @@ registerLanguage(ConsoleLang);
* Register custom themes
*/
registerTheme(ESQL_THEME_ID, buildESQlTheme());
registerTheme(CONSOLE_THEME_ID, buildConsoleTheme());
const monacoBundleDir = (window as any).__kbnPublicPath__?.['kbn-monaco'];

View file

@ -8,16 +8,11 @@
import { monaco } from '../../monaco_imports';
import { globals } from './shared';
import { globals } from '../../common/lexer_rules';
export const lexerRules: monaco.languages.IMonarchLanguage = {
...(globals as any),
defaultToken: 'invalid',
tokenPostfix: '',
tokenizer: {
root: [
export const buildXjsonRules = (root: string = 'root') => {
return {
[root]: [
[
/("(?:[^"]*_)?script"|"inline"|"source")(\s*?)(:)(\s*?)(""")/,
[
@ -106,7 +101,15 @@ export const lexerRules: monaco.languages.IMonarchLanguage = {
[/\\""""/, { token: 'punctuation.end_triple_quote', next: '@pop' }],
[/./, { token: 'multi_string' }],
],
},
};
};
export const lexerRules: monaco.languages.IMonarchLanguage = {
...(globals as any),
defaultToken: 'invalid',
tokenPostfix: '',
tokenizer: { ...buildXjsonRules() },
};
export const languageConfiguration: monaco.languages.LanguageConfiguration = {

View file

@ -9,7 +9,7 @@
import React, { FunctionComponent, useState } from 'react';
import { CodeEditor } from '@kbn/code-editor';
import { css } from '@emotion/react';
import { CONSOLE_LANG_ID } from '@kbn/monaco';
import { CONSOLE_LANG_ID, CONSOLE_THEME_ID } from '@kbn/monaco';
import { useEditorReadContext } from '../../../contexts';
export const MonacoEditor: FunctionComponent = () => {
@ -31,6 +31,7 @@ export const MonacoEditor: FunctionComponent = () => {
options={{
fontSize: settings.fontSize,
wordWrap: settings.wrapMode === true ? 'on' : 'off',
theme: CONSOLE_THEME_ID,
}}
/>
</div>

View file

@ -31,11 +31,12 @@ export function addEOL(
export const mergeTokens = (...args: any[]) => [].concat.apply([], args);
const TextHighlightRules = ace.acequire('ace/mode/text_highlight_rules').TextHighlightRules;
// translating this to monaco
export class InputHighlightRules extends TextHighlightRules {
constructor() {
super();
this.$rules = {
// TODO
'start-sql': [
{ token: 'whitespace', regex: '\\s+' },
{ token: 'paren.lparen', regex: '{', next: 'json-sql', push: true },
@ -43,16 +44,22 @@ export class InputHighlightRules extends TextHighlightRules {
],
start: mergeTokens(
[
// done
{ token: 'warning', regex: '#!.*$' },
// done
{ include: 'comments' },
// done
{ token: 'paren.lparen', regex: '{', next: 'json', push: true },
],
// done
addEOL(['method'], /([a-zA-Z]+)/, 'start', 'method_sep'),
[
// done
{
token: 'whitespace',
regex: '\\s+',
},
// done
{
token: 'text',
regex: '.+?',
@ -60,39 +67,58 @@ export class InputHighlightRules extends TextHighlightRules {
]
),
method_sep: mergeTokens(
// done
addEOL(
['whitespace', 'url.protocol_host', 'url.slash'],
/(\s+)(https?:\/\/[^?\/,]+)(\/)/,
'start',
'url'
),
// done
addEOL(['whitespace', 'variable.template'], /(\s+)(\${\w+})/, 'start', 'url'),
// done
addEOL(['whitespace', 'url.protocol_host'], /(\s+)(https?:\/\/[^?\/,]+)/, 'start', 'url'),
// done
addEOL(['whitespace', 'url.slash'], /(\s+)(\/)/, 'start', 'url'),
// done
addEOL(['whitespace'], /(\s+)/, 'start', 'url')
),
url: mergeTokens(
// done
addEOL(['variable.template'], /(\${\w+})/, 'start'),
// TODO
addEOL(['url.part'], /(_sql)/, 'start-sql', 'url-sql'),
// done
addEOL(['url.part'], /([^?\/,\s]+)/, 'start'),
// done
addEOL(['url.comma'], /(,)/, 'start'),
// done
addEOL(['url.slash'], /(\/)/, 'start'),
// done
addEOL(['url.questionmark'], /(\?)/, 'start', 'urlParams'),
// done
addEOL(['whitespace', 'comment.punctuation', 'comment.line'], /(\s+)(\/\/)(.*$)/, 'start')
),
urlParams: mergeTokens(
// done
addEOL(['url.param', 'url.equal', 'variable.template'], /([^&=]+)(=)(\${\w+})/, 'start'),
// done
addEOL(['url.param', 'url.equal', 'url.value'], /([^&=]+)(=)([^&]*)/, 'start'),
// done
addEOL(['url.param'], /([^&=]+)/, 'start'),
// done
addEOL(['url.amp'], /(&)/, 'start'),
// done
addEOL(['whitespace', 'comment.punctuation', 'comment.line'], /(\s+)(\/\/)(.*$)/, 'start')
),
// TODO
'url-sql': mergeTokens(
addEOL(['url.part'], /([^?\/,\s]+)/, 'start-sql'),
addEOL(['url.comma'], /(,)/, 'start-sql'),
addEOL(['url.slash'], /(\/)/, 'start-sql'),
addEOL(['url.questionmark'], /(\?)/, 'start-sql', 'urlParams-sql')
),
// TODO
'urlParams-sql': mergeTokens(
addEOL(['url.param', 'url.equal', 'url.value'], /([^&=]+)(=)([^&]*)/, 'start-sql'),
addEOL(['url.param'], /([^&=]+)/, 'start-sql'),
@ -108,27 +134,32 @@ export class InputHighlightRules extends TextHighlightRules {
comments: [
{
// Capture a line comment, indicated by #
// done
token: ['comment.punctuation', 'comment.line'],
regex: /(#)(.*$)/,
},
{
// Begin capturing a block comment, indicated by /*
// done
token: 'comment.punctuation',
regex: /\/\*/,
push: [
{
// Finish capturing a block comment, indicated by */
// done
token: 'comment.punctuation',
regex: /\*\//,
next: 'pop',
},
{
// done
defaultToken: 'comment.block',
},
],
},
{
// Capture a line comment, indicated by //
// done
token: ['comment.punctuation', 'comment.line'],
regex: /(\/\/)(.*$)/,
},

View file

@ -16,6 +16,7 @@ import { mockManagementPlugin } from '../../mocks';
import { createComponentWithContext } from '../test_utils';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
EuiBasicTable: 'eui-basic-table',
EuiButton: 'eui-button',
EuiButtonEmpty: 'eui-button-empty',

View file

@ -11,6 +11,7 @@ import { shallow } from 'enzyme';
import { SplitByTermsUI } from './terms';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
htmlIdGenerator: jest.fn(() => () => '42'),
EuiFlexGroup: jest.requireActual('@elastic/eui').EuiFlexGroup,
EuiFlexItem: jest.requireActual('@elastic/eui').EuiFlexItem,

View file

@ -17,6 +17,7 @@ jest.mock('../../context/step_context');
jest.mock('./content_wrapper');
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
EuiFlexGroup: ({ children, onClick }: EuiFlexGroupProps) => {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events