From e5f98db397bf11a5141609dc4747a28963eaba8a Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:34:38 +0200 Subject: [PATCH] [ES|QL] Comment parsing and pretty-printing (#192173) ## Summary TL;DR - Adds ability to parse out comments from source to AST. - Adds ability for every AST node to have *decoration*—comments, which can be attached from left, top, and right from the node. - Implements routine which attached comments to AST nodes. - In `BasicPrettyPrinter` adds support only for *left* and *right* comment printing, as the basic printer prints only on one line. - In `WrappingPrettyPrinter` adds support for all comment printing for all AST nodes. - Introduces a `Query` object and `query` AST node, which represent thole query—the root node, list of commands. - The ES|QL AST example plugin now displays the pretty-printed text version. ### Comments This PR introduced an optional `formatting` field for all AST nodes. In the `formatting` field one can specify comment decorations from different sides of a node. When parsing, once can now specify the `{ withComments: true }` option, which will collect all comments from the source while parsing using the `collectDecorations` routine. It will then also call the `attachDecorations`, which walks the AST and assigns each comment to some AST node. Further, traversal and pretty-print API have been updated to work with comments: - The `Walker` has been updated to be able to walk all comments from the AST. - The `BasicPrettyPrinter` adds support only for *left* and *right* inline comment printing, as the basic printer prints only on one line. - The `WrappingPrettyPrinter` adds support for all comment printing for all AST nodes. It switches to line-break printing mode if it detects there are comments with line breaks (those could be multi-line comments, or single line comments—single line comments are always followed by a line break). It also correctly inserts punctuation, when an AST node is surrounded by comments. ### Parsing utils All parsing utils have been moved to the `/parser` sub-folder. Files in the `/parser` folder have been renamed as per Kibana convention to reflect what is inside the file. For example, the `EsqlErrorListener` class is in a file named `esql_error_listener.ts`. A `Query` class and `ESQLAstQueryExpression` AST nodes have been introduced. They represent the result of a full query parse. (Before that, the AST root was just an array of command nodes, now the AST root is represented by the `ESQLAstQueryExpression` node.) ### Builder I have started the implementation of the `Builder` static class in the `/builder` folder. It is simply a collection of stateless AST node factories—functions which construct AST nodes. Some of the `Builder` methods are already used by the parser, more will follow. We will also use the `Builder` in upcoming [*Mutation API*](https://github.com/elastic/kibana/issues/191812). ### ES|QL Example Plugin This PR sets up Storybook and implements few Storybook stories for the ES|QL AST example plugin, run it with: ``` yarn storybook esql_ast_inspector ``` This PR updates the *ES|QL AST Explorer* example plugin. Start Kibana with example plugins enabled: ``` yarn start --run-examples ``` And navigate to [`/app/esql_ast_inspector`](http://localhost:5601/app/esql_ast_inspector) to see the new example plugin UI. ![esql-ast-explorer](https://github.com/user-attachments/assets/8ded91ea-1b60-4514-8cf5-c8a4066a3a12) ### Checklist - [x] [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 ### For maintainers - [x] 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: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine Co-authored-by: Stratoula Kalafateli (cherry picked from commit 2217337c5d91340ba67e0bedaab0762502518993) --- .../esql_ast_inspector/.storybook/main.js | 10 + examples/esql_ast_inspector/public/app.tsx | 75 +- .../annotations/annotations.stories.tsx | 28 + .../components/annotations/annotations.tsx | 43 + .../public/components/annotations/index.ts | 11 + .../public/components/annotations/types.ts | 21 +- .../components/esql_editor/esql_editor.tsx | 91 ++ .../components/editor/index.tsx | 139 +++ .../preview/components/preview_ast/index.tsx | 36 + .../components/basic_printer/index.tsx | 69 ++ .../components/wrapping_printer/index.tsx | 90 ++ .../components/preview_print/index.tsx | 45 + .../components/preview_tokens/index.tsx | 86 ++ .../components/from_command/index.tsx | 67 ++ .../components/from_command/source.tsx | 110 ++ .../components/limit_command/index.tsx | 126 +++ .../preview/components/preview_ui/index.tsx | 33 + .../components/preview/index.tsx | 42 + .../components/esql_inspector/context.ts | 14 +- .../esql_inspector/esql_inspector.stories.tsx | 18 + .../esql_inspector/esql_inspector.tsx | 29 + .../esql_inspector_connected.tsx | 38 + .../esql_inspector/esql_inspector_state.ts | 85 ++ .../components/esql_inspector/helpers.tsx | 151 +++ .../public/components/esql_inspector/index.ts | 12 + .../flexible_input/flexible_input.stories.tsx | 39 + .../flexible_input/flexible_input.tsx | 218 ++++ .../components/flexible_input/helpers.ts | 14 + .../public/components/pretty_print/index.ts | 10 + .../components/pretty_print/pretty_print.tsx | 35 + .../components/pretty_print_basic/index.ts | 10 + .../pretty_print_basic/pretty_print.tsx | 35 + .../public/hooks/use_behavior_subject.ts | 26 + examples/esql_ast_inspector/public/mount.tsx | 2 +- packages/kbn-esql-ast/index.ts | 34 +- packages/kbn-esql-ast/src/antlr_facade.ts | 46 - packages/kbn-esql-ast/src/ast_parser.ts | 39 - .../{index.test.ts => builder.test.ts} | 2 +- packages/kbn-esql-ast/src/builder/builder.ts | 131 +++ packages/kbn-esql-ast/src/builder/index.ts | 41 +- packages/kbn-esql-ast/src/parser/README.md | 27 + .../__tests__/ast_parser.source.test.ts | 2 +- .../__tests__/columns.test.ts} | 2 +- .../__tests__/commands.test.ts} | 2 +- .../src/parser/__tests__/comments.test.ts | 940 ++++++++++++++++++ .../__tests__/from.test.ts} | 2 +- .../__tests__/function.test.ts} | 4 +- .../__tests__/inlinecast.test.ts} | 8 +- .../__tests__/literal.test.ts} | 4 +- .../__tests__/metrics.test.ts} | 2 +- .../__tests__/params.test.ts} | 4 +- .../__tests__/rename.test.ts} | 2 +- .../__tests__/sort.test.ts} | 2 +- .../__tests__/where.test.ts} | 2 +- .../src/{ => parser}/constants.ts | 10 + .../esql_ast_builder_listener.ts} | 14 +- .../esql_error_listener.ts} | 4 +- .../{ast_helpers.ts => parser/factories.ts} | 85 +- .../kbn-esql-ast/src/parser/formatting.ts | 270 +++++ packages/kbn-esql-ast/src/parser/helpers.ts | 140 +++ packages/kbn-esql-ast/src/parser/index.ts | 23 + packages/kbn-esql-ast/src/parser/parser.ts | 139 +++ packages/kbn-esql-ast/src/parser/types.ts | 63 ++ .../src/{ast_walker.ts => parser/walkers.ts} | 14 +- .../basic_pretty_printer.comments.test.ts | 186 ++++ .../__tests__/basic_pretty_printer.test.ts | 32 +- .../wrapping_pretty_printer.comments.test.ts | 545 ++++++++++ .../__tests__/wrapping_pretty_printer.test.ts | 55 +- .../src/pretty_print/basic_pretty_printer.ts | 93 +- .../kbn-esql-ast/src/pretty_print/helpers.ts | 79 ++ .../kbn-esql-ast/src/pretty_print/index.ts | 21 + .../src/pretty_print/leaf_printer.ts | 32 +- .../pretty_print/wrapping_pretty_printer.ts | 365 +++++-- packages/kbn-esql-ast/src/query/index.ts | 10 + packages/kbn-esql-ast/src/query/query.ts | 49 + packages/kbn-esql-ast/src/types.ts | 46 +- .../src/visitor/__tests__/expressions.test.ts | 14 +- .../src/visitor/__tests__/scenarios.test.ts | 16 +- .../src/visitor/__tests__/visitor.test.ts | 28 +- packages/kbn-esql-ast/src/visitor/contexts.ts | 28 +- packages/kbn-esql-ast/src/visitor/types.ts | 7 +- packages/kbn-esql-ast/src/visitor/visitor.ts | 166 +++- .../kbn-esql-ast/src/walker/walker.test.ts | 94 +- packages/kbn-esql-ast/src/walker/walker.ts | 103 +- .../src/utils/query_parsing_helpers.ts | 18 +- .../kbn-monaco/src/esql/worker/esql_worker.ts | 33 +- src/dev/storybook/aliases.ts | 1 + 87 files changed, 5330 insertions(+), 607 deletions(-) create mode 100644 examples/esql_ast_inspector/.storybook/main.js create mode 100644 examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx create mode 100644 examples/esql_ast_inspector/public/components/annotations/annotations.tsx create mode 100644 examples/esql_ast_inspector/public/components/annotations/index.ts rename packages/kbn-esql-ast/src/ast_position_utils.ts => examples/esql_ast_inspector/public/components/annotations/types.ts (50%) create mode 100644 examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx rename packages/kbn-esql-ast/src/ast_errors.ts => examples/esql_ast_inspector/public/components/esql_inspector/context.ts (58%) create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx create mode 100644 examples/esql_ast_inspector/public/components/esql_inspector/index.ts create mode 100644 examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx create mode 100644 examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx create mode 100644 examples/esql_ast_inspector/public/components/flexible_input/helpers.ts create mode 100644 examples/esql_ast_inspector/public/components/pretty_print/index.ts create mode 100644 examples/esql_ast_inspector/public/components/pretty_print/pretty_print.tsx create mode 100644 examples/esql_ast_inspector/public/components/pretty_print_basic/index.ts create mode 100644 examples/esql_ast_inspector/public/components/pretty_print_basic/pretty_print.tsx create mode 100644 examples/esql_ast_inspector/public/hooks/use_behavior_subject.ts delete mode 100644 packages/kbn-esql-ast/src/antlr_facade.ts delete mode 100644 packages/kbn-esql-ast/src/ast_parser.ts rename packages/kbn-esql-ast/src/builder/{index.test.ts => builder.test.ts} (88%) create mode 100644 packages/kbn-esql-ast/src/builder/builder.ts create mode 100644 packages/kbn-esql-ast/src/parser/README.md rename packages/kbn-esql-ast/src/{ => parser}/__tests__/ast_parser.source.test.ts (98%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.columns.test.ts => parser/__tests__/columns.test.ts} (97%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.commands.test.ts => parser/__tests__/commands.test.ts} (99%) create mode 100644 packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts rename packages/kbn-esql-ast/src/{__tests__/ast_parser.from.test.ts => parser/__tests__/from.test.ts} (98%) rename packages/kbn-esql-ast/src/{__tests__/ast.function.test.ts => parser/__tests__/function.test.ts} (98%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.inlinecast.test.ts => parser/__tests__/inlinecast.test.ts} (93%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.literal.test.ts => parser/__tests__/literal.test.ts} (92%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.metrics.test.ts => parser/__tests__/metrics.test.ts} (98%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.params.test.ts => parser/__tests__/params.test.ts} (97%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.rename.test.ts => parser/__tests__/rename.test.ts} (93%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.sort.test.ts => parser/__tests__/sort.test.ts} (98%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.where.test.ts => parser/__tests__/where.test.ts} (94%) rename packages/kbn-esql-ast/src/{ => parser}/constants.ts (71%) rename packages/kbn-esql-ast/src/{ast_factory.ts => parser/esql_ast_builder_listener.ts} (96%) rename packages/kbn-esql-ast/src/{antlr_error_listener.ts => parser/esql_error_listener.ts} (94%) rename packages/kbn-esql-ast/src/{ast_helpers.ts => parser/factories.ts} (90%) create mode 100644 packages/kbn-esql-ast/src/parser/formatting.ts create mode 100644 packages/kbn-esql-ast/src/parser/index.ts create mode 100644 packages/kbn-esql-ast/src/parser/parser.ts create mode 100644 packages/kbn-esql-ast/src/parser/types.ts rename packages/kbn-esql-ast/src/{ast_walker.ts => parser/walkers.ts} (99%) create mode 100644 packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.comments.test.ts create mode 100644 packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts create mode 100644 packages/kbn-esql-ast/src/pretty_print/helpers.ts create mode 100644 packages/kbn-esql-ast/src/pretty_print/index.ts create mode 100644 packages/kbn-esql-ast/src/query/index.ts create mode 100644 packages/kbn-esql-ast/src/query/query.ts diff --git a/examples/esql_ast_inspector/.storybook/main.js b/examples/esql_ast_inspector/.storybook/main.js new file mode 100644 index 0000000000000..4c71be3362b05 --- /dev/null +++ b/examples/esql_ast_inspector/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/examples/esql_ast_inspector/public/app.tsx b/examples/esql_ast_inspector/public/app.tsx index 80dee5776ce31..82292945f2ab3 100644 --- a/examples/esql_ast_inspector/public/app.tsx +++ b/examples/esql_ast_inspector/public/app.tsx @@ -7,84 +7,21 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useRef, useState } from 'react'; -import { - EuiPage, - EuiPageBody, - EuiPageSection, - EuiPageHeader, - EuiSpacer, - EuiForm, - EuiTextArea, - EuiFormRow, - EuiButton, -} from '@elastic/eui'; +import * as React from 'react'; +import { EuiPage, EuiPageBody, EuiPageSection, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { EuiProvider } from '@elastic/eui'; +import { EsqlInspector } from './components/esql_inspector'; -import type { CoreStart } from '@kbn/core/public'; - -import { EditorError, ESQLAst, getAstAndSyntaxErrors } from '@kbn/esql-ast'; -import { CodeEditor } from '@kbn/code-editor'; -import type { StartDependencies } from './plugin'; - -export const App = (props: { core: CoreStart; plugins: StartDependencies }) => { - const [currentErrors, setErrors] = useState([]); - const [currentQuery, setQuery] = useState( - 'from index1 | eval var0 = round(numberField, 2) | stats by stringField' - ); - - const inputRef = useRef(null); - - const [ast, setAST] = useState(getAstAndSyntaxErrors(currentQuery).ast); - - const parseQuery = (query: string) => { - const { ast: _ast, errors } = getAstAndSyntaxErrors(query); - setErrors(errors); - setAST(_ast); - }; - +export const App = () => { return ( - +

This app gives you the AST for a particular ES|QL query.

- - - - - error.message)} - > - { - inputRef.current = node; - }} - isInvalid={Boolean(currentErrors.length)} - fullWidth - value={currentQuery} - onChange={(e) => setQuery(e.target.value)} - css={{ - height: '5em', - }} - /> - - - parseQuery(inputRef.current?.value ?? '')}> - Parse - - - - +
diff --git a/examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx b/examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx new file mode 100644 index 0000000000000..389527ce01d6c --- /dev/null +++ b/examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx @@ -0,0 +1,28 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { Annotations } from './annotations'; + +export default { + title: '', + parameters: {}, +}; + +export const Default = () => ( + {text}], + [5, 10, (text) => {text}], + [13, 18, (text) => {text}], + [19, 21, (text) => {text}], + ]} + /> +); diff --git a/examples/esql_ast_inspector/public/components/annotations/annotations.tsx b/examples/esql_ast_inspector/public/components/annotations/annotations.tsx new file mode 100644 index 0000000000000..b76af4e3b0acc --- /dev/null +++ b/examples/esql_ast_inspector/public/components/annotations/annotations.tsx @@ -0,0 +1,43 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import type { Annotation } from './types'; + +export interface AnnotationsProps { + value: string; + annotations?: Annotation[]; +} + +export const Annotations: React.FC = (props) => { + const { value, annotations = [] } = props; + const annotationNodes: React.ReactNode[] = []; + + let pos = 0; + + for (const [start, end, render] of annotations) { + if (start > pos) { + const text = value.slice(pos, start); + + annotationNodes.push({text}); + } + + const text = value.slice(start, end); + + pos = end; + annotationNodes.push(render(text)); + } + + if (pos < value.length) { + const text = value.slice(pos); + annotationNodes.push({text}); + } + + return React.createElement('span', {}, ...annotationNodes); +}; diff --git a/examples/esql_ast_inspector/public/components/annotations/index.ts b/examples/esql_ast_inspector/public/components/annotations/index.ts new file mode 100644 index 0000000000000..afba4341dcf35 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/annotations/index.ts @@ -0,0 +1,11 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { Annotations, type AnnotationsProps } from './annotations'; +export type { Annotation } from './types'; diff --git a/packages/kbn-esql-ast/src/ast_position_utils.ts b/examples/esql_ast_inspector/public/components/annotations/types.ts similarity index 50% rename from packages/kbn-esql-ast/src/ast_position_utils.ts rename to examples/esql_ast_inspector/public/components/annotations/types.ts index ab4603ee0a7d0..b62da2740c174 100644 --- a/packages/kbn-esql-ast/src/ast_position_utils.ts +++ b/examples/esql_ast_inspector/public/components/annotations/types.ts @@ -7,19 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Token } from 'antlr4'; +import * as React from 'react'; -export function getPosition( - token: Pick | null, - lastToken?: Pick | undefined -) { - if (!token || token.start < 0) { - return { min: 0, max: 0 }; - } - const endFirstToken = token.stop > -1 ? Math.max(token.stop + 1, token.start) : undefined; - const endLastToken = lastToken?.stop; - return { - min: token.start, - max: endLastToken ?? endFirstToken ?? Infinity, - }; -} +export type Annotation = [ + start: number, + end: number, + annotation: (text: string) => React.ReactNode +]; diff --git a/examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx b/examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx new file mode 100644 index 0000000000000..e1d6a2418b0c9 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx @@ -0,0 +1,91 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { css } from '@emotion/react'; +import { Annotations, type Annotation } from '../annotations'; +import { FlexibleInput } from '../flexible_input/flexible_input'; + +const blockCss = css({ + display: 'inline-block', + position: 'relative', + width: '100%', + fontSize: '18px', + lineHeight: '1.3', + fontFamily: + "'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace", +}); + +const backdropCss = css({ + display: 'inline-block', + position: 'absolute', + left: 0, + width: '100%', + pointerEvents: 'all', + userSelect: 'none', + whiteSpace: 'pre', + color: 'rgba(255, 255, 255, 0.01)', +}); + +const inputCss = css({ + display: 'inline-block', + color: 'rgba(255, 255, 255, 0.01)', + caretColor: '#07f', +}); + +const overlayCss = css({ + display: 'inline-block', + position: 'absolute', + left: 0, + width: '100%', + pointerEvents: 'none', + userSelect: 'none', + whiteSpace: 'pre', +}); + +export interface EsqlEditorProps { + src: string; + backdrops?: Annotation[][]; + highlight?: Annotation[]; + onChange: (src: string) => void; +} + +export const EsqlEditor: React.FC = (props) => { + const { src, highlight, onChange } = props; + + const backdrops: React.ReactNode[] = []; + + if (props.backdrops) { + for (let i = 0; i < props.backdrops.length; i++) { + const backdrop = props.backdrops[i]; + + backdrops.push( +
+ +
+ ); + } + } + + const overlay = !!highlight && ( +
+ +
+ ); + + return ( +
+ {backdrops} +
+ onChange(e.target.value)} /> +
+ {overlay} +
+ ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx new file mode 100644 index 0000000000000..dbb7bbe94693f --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx @@ -0,0 +1,139 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiButton, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { Walker } from '@kbn/esql-ast'; +import { EsqlEditor } from '../../../esql_editor/esql_editor'; +import { useEsqlInspector } from '../../context'; +import { useBehaviorSubject } from '../../../../hooks/use_behavior_subject'; +import { Annotation } from '../../../annotations'; + +export const Editor: React.FC = () => { + const state = useEsqlInspector(); + const src = useBehaviorSubject(state.src$); + const highlight = useBehaviorSubject(state.highlight$); + const focusedNode = useBehaviorSubject(state.focusedNode$); + const limit = useBehaviorSubject(state.limit$); + + const targetsBackdrop: Annotation[] = []; + const focusBackdrop: Annotation[] = []; + const query = state.query$.getValue(); + + if (focusedNode) { + const location = focusedNode.location; + + if (location) { + focusBackdrop.push([ + location.min, + location.max + 1, + (text) => ( + + {text} + + ), + ]); + } + } + + if (query) { + Walker.walk(query.ast, { + visitSource: (node) => { + const location = node.location; + if (!location) return; + targetsBackdrop.push([ + location.min, + location.max + 1, + (text) => ( + { + state.focusedNode$.next(node); + }} + > + {text} + + ), + ]); + }, + }); + } + + if (limit) { + const location = limit.location; + + if (!location) return null; + + targetsBackdrop.push([ + location.min, + location.max + 1, + (text) => ( + { + state.focusedNode$.next(limit); + }} + > + {text} + + ), + ]); + } + + return ( + <> + +
+ { + const value = state.query$.getValue(); + + if (!value) { + return; + } + + state.src$.next(value.print()); + }} + > + Re-format + +
+ + state.src$.next(newSrc)} + backdrops={[targetsBackdrop, focusBackdrop]} + highlight={highlight} + /> +
+ + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx new file mode 100644 index 0000000000000..50926dc272245 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx @@ -0,0 +1,36 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { CodeEditor } from '@kbn/code-editor'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; + +export const PreviewAst: React.FC = (props) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + + if (!query) { + return null; + } + + return ( + <> + + + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx new file mode 100644 index 0000000000000..d20dc4e46af59 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx @@ -0,0 +1,69 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import { PrettyPrintBasic } from '../../../../../../../pretty_print_basic'; + +export interface BasicPrinterProps { + src: string; +} + +export const BasicPrinter: React.FC = ({ src }) => { + const [lowercase, setLowercase] = React.useState(false); + const [multiline, setMultiline] = React.useState(false); + const [pipeTab, setPipeTab] = React.useState(' '); + + return ( + + + + + + + + + setLowercase((x) => !x)} + compressed + /> + + + + + setMultiline((x) => !x)} + compressed + /> + + + + + + + {!!multiline && ( + + setPipeTab(e.target.value)} /> + + )} + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx new file mode 100644 index 0000000000000..d365335029dd0 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx @@ -0,0 +1,90 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiRange, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import { PrettyPrint } from '../../../../../../../pretty_print'; + +export interface WrappingPrinterProps { + src: string; +} + +export const WrappingPrinter: React.FC = ({ src }) => { + const [lowercase, setLowercase] = React.useState(false); + const [multiline, setMultiline] = React.useState(false); + const [wrap, setWrap] = React.useState(80); + const [tab, setTab] = React.useState(' '); + const [pipeTab, setPipeTab] = React.useState(' '); + const [indent, setIndent] = React.useState(''); + + return ( + + + + + + + + + setLowercase((x) => !x)} + compressed + /> + + + + + setMultiline((x) => !x)} + compressed + /> + + + + + + + + setWrap(Number(e.currentTarget.value))} + showInput + aria-label="Wrapping line width" + /> + + + + setIndent(e.target.value)} /> + + + + setTab(e.target.value)} /> + + + + setPipeTab(e.target.value)} /> + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx new file mode 100644 index 0000000000000..70e1cf7ba9cbd --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx @@ -0,0 +1,45 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiCode, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; +import { WrappingPrinter } from './components/wrapping_printer'; +import { BasicPrinter } from './components/basic_printer'; + +export const PreviewPrint: React.FC = (props) => { + const state = useEsqlInspector(); + const src = useBehaviorSubject(state.src$); + + return ( + <> + + + +

+ Formatted with WrappingPrettyPrinter: +

+
+ + + + + + +

+ Formatted with BasicPrettyPrinter: +

+
+ + +
+ + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx new file mode 100644 index 0000000000000..024af47549e71 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx @@ -0,0 +1,86 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiDataGrid, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { createParser } from '@kbn/esql-ast'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; + +const columns = [ + { + id: 'token', + display: 'Token', + }, + { + id: 'symbol', + display: 'Symbol', + }, + { + id: 'type', + display: 'Type', + }, + { + id: 'channel', + display: 'Channel', + }, +]; + +const symbolicNames = createParser('').lexer.symbolicNames; + +export const PreviewTokens: React.FC = (props) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + + const [visibleColumns, setVisibleColumns] = React.useState(columns.map(({ id }) => id)); + + if (!query) { + return null; + } + + interface Row { + token: string; + symbol: string; + type: number; + channel: number; + } + + const data: Row[] = []; + + for (const token of query.tokens) { + data.push({ + token: token.text, + symbol: symbolicNames[token.type] ?? '', + type: token.type, + channel: token.channel, + }); + } + + return ( + <> + + + (data as any)[rowIndex][columnId]} + /> + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx new file mode 100644 index 0000000000000..58d22f5767ca6 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx @@ -0,0 +1,67 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiButton, EuiFormRow, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { Builder, ESQLSource } from '@kbn/esql-ast'; +import { useEsqlInspector } from '../../../../../../context'; +import { useBehaviorSubject } from '../../../../../../../../hooks/use_behavior_subject'; +import { Source } from './source'; + +export const FromCommand: React.FC = () => { + const state = useEsqlInspector(); + const from = useBehaviorSubject(state.from$); + + if (!from) { + return null; + } + + const sources: React.ReactNode[] = []; + let i = 0; + + for (const arg of from.args) { + if ((arg as any).type !== 'source') continue; + sources.push(); + i++; + } + + return ( + + +

Sources

+
+
+ {sources} +
+ + + { + const length = from.args.length; + const source = Builder.expression.source({ + name: `source${length + 1}`, + sourceType: 'index', + }); + from.args.push(source); + state.reprint(); + }} + > + Add source + + +
+ ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx new file mode 100644 index 0000000000000..0c35f11b089ab --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx @@ -0,0 +1,110 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiButtonIcon, + EuiFieldText, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import { ESQLSource } from '@kbn/esql-ast'; +import { ESQLAstBaseItem } from '@kbn/esql-ast/src/types'; +import { useEsqlInspector } from '../../../../../../context'; +import { useBehaviorSubject } from '../../../../../../../../hooks/use_behavior_subject'; + +const getFirstComment = (node: ESQLAstBaseItem): string | undefined => { + const list = node.formatting?.top ?? node.formatting?.left ?? node.formatting?.right; + if (list) { + for (const decoration of list) { + if (decoration.type === 'comment') { + return decoration.text; + } + } + } + return undefined; +}; + +export interface SourceProps { + node: ESQLSource; + index: number; +} + +export const Source: React.FC = ({ node, index }) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + const focusedNode = useBehaviorSubject(state.focusedNode$); + + if (!query) { + return null; + } + + const comment = getFirstComment(node); + + return ( + <> + +
{ + state.focusedNode$.next(node); + }} + style={{ + background: focusedNode === node ? 'rgb(190, 237, 224)' : 'transparent', + padding: 8, + margin: -8, + borderRadius: 8, + position: 'relative', + }} + > + + + Source {index} + + + ) : ( + <>Source {index} + ) + } + > + { + node.name = e.target.value; + state.reprint(); + }} + /> + +
+ + { + if (!query) return; + const from = state.from$.getValue(); + if (!from) return; + from.args = from.args.filter((c) => c !== node); + state.reprint(); + }} + /> + +
+
+ + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx new file mode 100644 index 0000000000000..891a571254fb4 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx @@ -0,0 +1,126 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiButton, + EuiButtonIcon, + EuiFieldText, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { Builder } from '@kbn/esql-ast'; +import { useEsqlInspector } from '../../../../../../context'; +import { useBehaviorSubject } from '../../../../../../../../hooks/use_behavior_subject'; + +export const LimitCommand: React.FC = () => { + const state = useEsqlInspector(); + const limit = useBehaviorSubject(state.limit$); + const focusedNode = useBehaviorSubject(state.focusedNode$); + + if (!limit) { + return ( + + + { + const query = state.query$.getValue(); + if (!query) return; + const literal = Builder.expression.literal.numeric({ + value: 10, + literalType: 'integer', + }); + const command = Builder.command({ + name: 'limit', + args: [literal], + }); + query.ast.commands.push(command); + state.reprint(); + }} + > + Add limit + + + + ); + } + + const value = +(limit.args[0] as any)?.value; + + if (typeof value !== 'number') { + return null; + } + + return ( + +
{ + state.focusedNode$.next(limit); + }} + style={{ + background: focusedNode === limit ? 'rgb(190, 237, 224)' : 'transparent', + padding: 8, + margin: -8, + borderRadius: 8, + position: 'relative', + }} + > + +

Limit

+
+
+ + { + const newValue = +e.target.value; + + if (newValue !== newValue) { + return; + } + + const literal = Builder.expression.literal.numeric({ + value: newValue, + literalType: 'integer', + }); + + limit.args[0] = literal; + state.reprint(); + }} + /> + +
+
+ + { + const query = state.query$.getValue(); + if (!query) return; + query.ast.commands = query.ast.commands.filter((c) => c !== limit); + state.reprint(); + }} + /> + +
+
+
+ ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx new file mode 100644 index 0000000000000..f046f0b89087d --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx @@ -0,0 +1,33 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; +import { FromCommand } from './components/from_command'; +import { LimitCommand } from './components/limit_command'; + +export const PreviewUi: React.FC = (props) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + + if (!query) { + return null; + } + + return ( + <> + + + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx new file mode 100644 index 0000000000000..8b6811333bc6a --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx @@ -0,0 +1,42 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiTabbedContent, EuiTabbedContentProps } from '@elastic/eui'; +import { PreviewAst } from './components/preview_ast'; +import { PreviewTokens } from './components/preview_tokens'; +import { PreviewUi } from './components/preview_ui'; +import { PreviewPrint } from './components/preview_print'; + +export const Preview: React.FC = () => { + const tabs: EuiTabbedContentProps['tabs'] = [ + { + id: 'ui', + name: 'UI', + content: , + }, + { + id: 'formatter', + name: 'Formatter', + content: , + }, + { + id: 'ast', + name: 'AST', + content: , + }, + { + id: 'tokens', + name: 'Tokens', + content: , + }, + ]; + + return ; +}; diff --git a/packages/kbn-esql-ast/src/ast_errors.ts b/examples/esql_ast_inspector/public/components/esql_inspector/context.ts similarity index 58% rename from packages/kbn-esql-ast/src/ast_errors.ts rename to examples/esql_ast_inspector/public/components/esql_inspector/context.ts index c9099e801708b..cdb82d1ef9da2 100644 --- a/packages/kbn-esql-ast/src/ast_errors.ts +++ b/examples/esql_ast_inspector/public/components/esql_inspector/context.ts @@ -7,15 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { RecognitionException } from 'antlr4'; -import { getPosition } from './ast_position_utils'; +import * as React from 'react'; +import { EsqlInspectorState } from './esql_inspector_state'; -export function createError(exception: RecognitionException) { - const token = exception.offendingToken; +export const context = React.createContext(null); - return { - type: 'error' as const, - text: `SyntaxError: ${exception.message}`, - location: getPosition(token), - }; -} +export const useEsqlInspector = () => React.useContext(context)!; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx new file mode 100644 index 0000000000000..b42e9d957f8f2 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx @@ -0,0 +1,18 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EsqlInspector } from './esql_inspector'; + +export default { + title: '', + parameters: {}, +}; + +export const Default = () => ; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx new file mode 100644 index 0000000000000..727894e99f87b --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx @@ -0,0 +1,29 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EsqlInspectorState } from './esql_inspector_state'; +import { context } from './context'; +import { EsqlInspectorConnected } from './esql_inspector_connected'; + +export interface EsqlInspectorProps { + state?: EsqlInspectorState; +} + +export const EsqlInspector: React.FC = (props) => { + const state = React.useMemo(() => { + return props.state ?? new EsqlInspectorState(); + }, [props.state]); + + return ( + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx new file mode 100644 index 0000000000000..aa848e691c1c2 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx @@ -0,0 +1,38 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EsqlInspectorState } from './esql_inspector_state'; +import { Editor } from './components/editor'; +import { Preview } from './components/preview'; + +export interface EsqlInspectorConnectedProps { + state?: EsqlInspectorState; +} + +export const EsqlInspectorConnected: React.FC = (props) => { + return ( + <> + + + + + + + + + + + + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts new file mode 100644 index 0000000000000..4938f245bd9cf --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts @@ -0,0 +1,85 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject } from 'rxjs'; +import { ESQLCommand, EsqlQuery, Walker } from '@kbn/esql-ast'; +import { ESQLProperNode } from '@kbn/esql-ast/src/types'; +import { Annotation } from '../annotations'; +import { highlight } from './helpers'; + +const defaultSrc = `FROM kibana_sample_data_logs, another_index + | KEEP bytes, clientip, url.keyword, response.keyword + | STATS Visits = COUNT(), Unique = COUNT_DISTINCT(clientip), + p95 = PERCENTILE(bytes, 95), median = MEDIAN(bytes) + BY type, url.keyword + | EVAL total_records = TO_DOUBLE(count_4xx + count_5xx + count_rest) + | DROP count_4xx, count_rest, total_records + | LIMIT 123`; + +export class EsqlInspectorState { + public readonly src$ = new BehaviorSubject(defaultSrc); + public readonly query$ = new BehaviorSubject(null); + public readonly queryLastValid$ = new BehaviorSubject(EsqlQuery.fromSrc('')); + public readonly highlight$ = new BehaviorSubject([]); + public readonly from$ = new BehaviorSubject(null); + public readonly limit$ = new BehaviorSubject(null); + public readonly focusedNode$ = new BehaviorSubject(null); + + constructor() { + this.src$.subscribe((src) => { + this.focusedNode$.next(null); + try { + this.query$.next(EsqlQuery.fromSrc(src, { withFormatting: true })); + } catch (e) { + this.query$.next(null); + } + }); + + this.query$.subscribe((query) => { + if (query instanceof EsqlQuery) { + this.queryLastValid$.next(query); + + this.highlight$.next(highlight(query)); + + const from = Walker.match(query?.ast, { + type: 'command', + name: 'from', + }); + + if (from) { + this.from$.next(from as ESQLCommand); + } else { + this.from$.next(null); + } + + const limit = Walker.match(query?.ast, { + type: 'command', + name: 'limit', + }); + + if (limit) { + this.limit$.next(limit as ESQLCommand); + } else { + this.limit$.next(null); + } + } + }); + } + + public readonly reprint = () => { + const query = this.query$.getValue(); + + if (!query) { + return; + } + + const src = query.print(); + this.src$.next(src); + }; +} diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx new file mode 100644 index 0000000000000..a117062f7efa9 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx @@ -0,0 +1,151 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EsqlQuery, Walker } from '@kbn/esql-ast'; +import { euiPaletteColorBlind } from '@elastic/eui'; +import { Annotation } from '../annotations'; + +const palette = euiPaletteColorBlind(); + +const colors = { + command: palette[2], + literal: palette[0], + source: palette[3], + operator: palette[9], + column: palette[6], + function: palette[8], +}; + +export const highlight = (query: EsqlQuery): Annotation[] => { + const annotations: Annotation[] = []; + + Walker.walk(query.ast, { + visitCommand: (node) => { + const location = node.location; + if (!location) return; + const color = node.name === 'from' ? '#07f' : colors.command; + annotations.push([ + location.min, + location.min + node.name.length, + (text) => {text}, + ]); + }, + + visitSource: (node) => { + const location = node.location; + if (!location) return; + annotations.push([ + location.min, + location.max + 1, + (text) => {text}, + ]); + }, + + visitColumn: (node) => { + const location = node.location; + if (!location) return; + annotations.push([ + location.min, + location.max + 1, + (text) => {text}, + ]); + }, + + visitFunction: (node) => { + const location = node.location; + if (!location) return; + if (node.subtype === 'variadic-call') { + annotations.push([ + location.min, + location.min + node.name.length, + (text) => {text}, + ]); + } + }, + + visitLiteral: (node) => { + const location = node.location; + if (!location) return; + annotations.push([ + location.min, + location.max + 1, + (text) => {text}, + ]); + }, + }); + + Walker.visitComments(query.ast, (comment) => { + annotations.push([ + comment.location.min, + comment.location.max, + (text) => {text}, + ]); + }); + + for (const token of query.tokens) { + switch (token.type) { + // PIPE + case 30: { + const pos = token.start; + + annotations.push([ + pos, + pos + 1, + (text) => {text}, + ]); + + break; + } + case 34: // BY + case 78: { + // METADATA + const pos = token.start; + + annotations.push([ + pos, + pos + token.text.length, + (text) => {text}, + ]); + + break; + } + default: { + switch (token.text) { + case '+': + case '-': + case '*': + case '/': + case '%': + case '!=': + case '>': + case '>=': + case '<': + case '<=': + case 'and': + case 'AND': + case 'or': + case 'OR': + case 'not': + case 'NOT': { + annotations.push([ + token.start, + token.start + token.text.length, + (text) => {text}, + ]); + } + } + } + } + } + + annotations.sort((a, b) => a[0] - b[0]); + + return annotations; +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/index.ts b/examples/esql_ast_inspector/public/components/esql_inspector/index.ts new file mode 100644 index 0000000000000..b6bc9b72a7a93 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/index.ts @@ -0,0 +1,12 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { context, useEsqlInspector } from './context'; +export { EsqlInspectorState } from './esql_inspector_state'; +export { EsqlInspector, type EsqlInspectorProps } from './esql_inspector'; diff --git a/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx new file mode 100644 index 0000000000000..ea50b01428b74 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx @@ -0,0 +1,39 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { FlexibleInput, FlexibleInputProps } from './flexible_input'; + +export default { + title: '', + parameters: {}, +}; + +const Demo: React.FC = (props) => { + const [value, setValue] = React.useState(props.value); + + return ( + + { + setValue(e.target.value); + }} + /> + + ); +}; + +const src = `FROM index, index2 + | WHERE language == "esql" + | LIMIT 10 +`; + +export const Example = () => ; diff --git a/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx new file mode 100644 index 0000000000000..1975789cb7916 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx @@ -0,0 +1,218 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { css } from '@emotion/react'; +import { copyStyles } from './helpers'; + +const blockCss = css({ + display: 'inline-block', + position: 'relative', + width: '100%', +}); + +const inputCss = css({ + display: 'inline-block', + verticalAlign: 'bottom', + boxSizing: 'border-box', + overflow: 'hidden', + padding: 0, + margin: 0, + background: 0, + outline: '0 !important', + border: 0, + color: 'inherit', + fontWeight: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + whiteSpace: 'pre', + resize: 'none', +}); + +const sizerCss = css({ + display: 'inline-block', + position: 'absolute', + pointerEvents: 'none', + userSelect: 'none', + boxSizing: 'border-box', + top: 0, + left: 0, + border: 0, + whiteSpace: 'pre', +}); + +export interface FlexibleInputProps { + /** The string to display and edit. */ + value: string; + + /** Ref to the input element. */ + inp?: (el: HTMLInputElement | HTMLTextAreaElement | null) => void; + + /** Whether the input is multiline. */ + multiline?: boolean; + + /** Whether to wrap text to a new line when it exceeds the length of current. */ + wrap?: boolean; + + /** + * Whether the input should take the full width of the parent, even when there + * is not enough text to do that naturally with content. + */ + fullWidth?: boolean; + + /** Typeahead string to add to the value. It is visible at half opacity. */ + typeahead?: string; + + /** Addition width to add, for example, to account for number stepper. */ + extraWidth?: number; + + /** Minimum width to allow. */ + minWidth?: number; + + /** Maximum width to allow. */ + maxWidth?: number; + + /** Whether the input is focused on initial render. */ + focus?: boolean; + + /** Callback for when the input value changes. */ + onChange?: React.ChangeEventHandler; + + /** Callback for when the input is focused. */ + onFocus?: React.FocusEventHandler; + + /** Callback for when the input is blurred. */ + onBlur?: React.FocusEventHandler; + + /** Callback for when a key is pressed. */ + onKeyDown?: React.KeyboardEventHandler; + + /** Callback for when the Enter key is pressed. */ + onSubmit?: React.KeyboardEventHandler; + + /** Callback for when the Escape key is pressed. */ + onCancel?: React.KeyboardEventHandler; + + /** Callback for when the Tab key is pressed. */ + onTab?: React.KeyboardEventHandler; +} + +export const FlexibleInput: React.FC = ({ + value, + inp, + multiline, + wrap, + fullWidth, + typeahead = '', + extraWidth, + minWidth = 8, + maxWidth, + focus, + onChange, + onFocus, + onBlur, + onKeyDown, + onSubmit, + onCancel, + onTab, +}) => { + const inputRef = React.useRef(null); + const sizerRef = React.useRef(null); + const sizerValueRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (!inputRef.current || !sizerRef.current) return; + if (focus) inputRef.current.focus(); + copyStyles(inputRef.current, sizerRef.current!, [ + 'font', + 'fontSize', + 'fontFamily', + 'fontWeight', + 'fontStyle', + 'letterSpacing', + 'textTransform', + 'boxSizing', + ]); + }, [focus]); + + React.useLayoutEffect(() => { + const sizerValue = sizerValueRef.current; + if (sizerValue) sizerValue.textContent = value; + const input = inputRef.current; + const sizer = sizerRef.current; + if (!input || !sizer) return; + let width = sizer.scrollWidth; + if (extraWidth) width += extraWidth; + if (minWidth) width = Math.max(width, minWidth); + if (maxWidth) width = Math.min(width, maxWidth); + const style = input.style; + style.width = width + 'px'; + if (multiline) { + const height = sizer.scrollHeight; + style.height = height + 'px'; + } + }, [value, extraWidth, minWidth, maxWidth, multiline]); + + const attr: React.InputHTMLAttributes & { ref: any } = { + ref: (input: unknown) => { + (inputRef as any).current = input; + if (inp) inp(input as HTMLInputElement | HTMLTextAreaElement); + }, + value, + style: { + width: fullWidth ? '100%' : undefined, + whiteSpace: wrap ? 'pre-wrap' : 'pre', + display: fullWidth ? 'block' : 'inline-block', + }, + onChange: (e) => { + if (onChange) onChange(e); + }, + onFocus, + onBlur, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (!multiline || e.ctrlKey)) { + if (onSubmit) onSubmit(e as any); + } else if (e.key === 'Escape') { + if (onCancel) onCancel(e as any); + } else if (e.key === 'Tab') { + if (onTab) onTab(e as any); + } + if (onKeyDown) onKeyDown(e as any); + }, + }; + + const input = multiline ? ( +