From d996c769bb4d869f83a3da93aecffbe46dbb5926 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Thu, 5 Sep 2024 14:30:02 +0200 Subject: [PATCH 01/50] comment parsing and pretty-printing --- examples/esql_ast_inspector/public/app.tsx | 15 +- .../public/pretty_print.tsx | 33 + packages/kbn-esql-ast/index.ts | 30 +- packages/kbn-esql-ast/src/antlr_facade.ts | 45 -- packages/kbn-esql-ast/src/ast_parser.ts | 38 - .../kbn-esql-ast/src/ast_position_utils.ts | 24 - .../{index.test.ts => builder.test.ts} | 2 +- packages/kbn-esql-ast/src/builder/builder.ts | 118 +++ packages/kbn-esql-ast/src/builder/index.ts | 41 +- packages/kbn-esql-ast/src/parser/README.md | 27 + .../__tests__/columns.test.ts} | 2 +- .../__tests__/commands.test.ts} | 2 +- .../src/parser/__tests__/comments.test.ts | 731 ++++++++++++++++++ .../__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 | 269 +++++++ packages/kbn-esql-ast/src/parser/helpers.ts | 140 ++++ .../src/{ast_errors.ts => parser/index.ts} | 20 +- packages/kbn-esql-ast/src/parser/parser.ts | 117 +++ packages/kbn-esql-ast/src/parser/types.ts | 62 ++ .../src/{ast_walker.ts => parser/walkers.ts} | 13 +- .../basic_pretty_printer.comments.test.ts | 185 +++++ .../__tests__/basic_pretty_printer.test.ts | 64 +- .../wrapping_pretty_printer.comments.test.ts | 477 ++++++++++++ .../__tests__/wrapping_pretty_printer.test.ts | 55 +- .../src/pretty_print/basic_pretty_printer.ts | 120 ++- .../kbn-esql-ast/src/pretty_print/helpers.ts | 78 ++ .../kbn-esql-ast/src/pretty_print/index.ts | 20 + .../src/pretty_print/leaf_printer.ts | 32 +- .../pretty_print/wrapping_pretty_printer.ts | 329 ++++++-- packages/kbn-esql-ast/src/query/index.ts | 9 + packages/kbn-esql-ast/src/query/query.ts | 48 ++ 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 | 27 +- packages/kbn-esql-ast/src/visitor/types.ts | 7 +- packages/kbn-esql-ast/src/visitor/visitor.ts | 148 +++- .../kbn-esql-ast/src/walker/walker.test.ts | 94 +-- packages/kbn-esql-ast/src/walker/walker.ts | 49 +- .../kbn-monaco/src/esql/worker/esql_worker.ts | 2 +- 53 files changed, 3213 insertions(+), 507 deletions(-) create mode 100644 examples/esql_ast_inspector/public/pretty_print.tsx delete mode 100644 packages/kbn-esql-ast/src/antlr_facade.ts delete mode 100644 packages/kbn-esql-ast/src/ast_parser.ts delete mode 100644 packages/kbn-esql-ast/src/ast_position_utils.ts rename packages/kbn-esql-ast/src/builder/{index.test.ts => builder.test.ts} (85%) 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/{__tests__/ast_parser.columns.test.ts => parser/__tests__/columns.test.ts} (96%) 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} (91%) 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} (92%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.sort.test.ts => parser/__tests__/sort.test.ts} (97%) rename packages/kbn-esql-ast/src/{__tests__/ast_parser.where.test.ts => parser/__tests__/where.test.ts} (93%) rename packages/kbn-esql-ast/src/{ => parser}/constants.ts (68%) 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} (93%) rename packages/kbn-esql-ast/src/{ast_helpers.ts => parser/factories.ts} (88%) create mode 100644 packages/kbn-esql-ast/src/parser/formatting.ts rename packages/kbn-esql-ast/src/{ast_errors.ts => parser/index.ts} (51%) 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/public/app.tsx b/examples/esql_ast_inspector/public/app.tsx index ac2a543bc40e5..1d6a0c8c4a96f 100644 --- a/examples/esql_ast_inspector/public/app.tsx +++ b/examples/esql_ast_inspector/public/app.tsx @@ -22,9 +22,10 @@ import { EuiProvider } from '@elastic/eui'; import type { CoreStart } from '@kbn/core/public'; -import { EditorError, ESQLAst, getAstAndSyntaxErrors } from '@kbn/esql-ast'; +import { EditorError, ESQLAstQueryExpression, parse } from '@kbn/esql-ast'; import { CodeEditor } from '@kbn/code-editor'; import type { StartDependencies } from './plugin'; +import { PrettyPrint } from './pretty_print'; export const App = (props: { core: CoreStart; plugins: StartDependencies }) => { const [currentErrors, setErrors] = useState([]); @@ -34,12 +35,14 @@ export const App = (props: { core: CoreStart; plugins: StartDependencies }) => { const inputRef = useRef(null); - const [ast, setAST] = useState(getAstAndSyntaxErrors(currentQuery).ast); + const [root, setAST] = useState( + parse(currentQuery, { withFormatting: true }).root + ); const parseQuery = (query: string) => { - const { ast: _ast, errors } = getAstAndSyntaxErrors(query); + const { root: _root, errors } = parse(query, { withFormatting: true }); setErrors(errors); - setAST(_ast); + setAST(_root); }; return ( @@ -79,10 +82,12 @@ export const App = (props: { core: CoreStart; plugins: StartDependencies }) => { + + diff --git a/examples/esql_ast_inspector/public/pretty_print.tsx b/examples/esql_ast_inspector/public/pretty_print.tsx new file mode 100644 index 0000000000000..2f61447115f8f --- /dev/null +++ b/examples/esql_ast_inspector/public/pretty_print.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 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 { parse, WrappingPrettyPrinter } from '@kbn/esql-ast'; +import * as React from 'react'; +import { EuiCodeBlock } from '@elastic/eui'; + +export interface PrettyPrintProps { + src: string; +} + +export const PrettyPrint: React.FC = ({ src }) => { + const formatted = React.useMemo(() => { + try { + const { root } = parse(src, { withFormatting: true }); + + return WrappingPrettyPrinter.print(root); + } catch { + return ''; + } + }, [src]); + + return ( + + {formatted} + + ); +}; diff --git a/packages/kbn-esql-ast/index.ts b/packages/kbn-esql-ast/index.ts index 6c8cd3c23e50b..a6fb3ea6c61c6 100644 --- a/packages/kbn-esql-ast/index.ts +++ b/packages/kbn-esql-ast/index.ts @@ -19,6 +19,7 @@ export type { ESQLLocation, ESQLMessage, ESQLSingleAstItem, + ESQLAstQueryExpression, ESQLSource, ESQLColumn, ESQLLiteral, @@ -28,16 +29,25 @@ export type { ESQLAstNode, } from './src/types'; -// Low level functions to parse grammar -export { getParser, getLexer, ROOT_STATEMENT } from './src/antlr_facade'; +export { + getParser, + getLexer, + parse, + type ParseOptions, + type ParseResult, + getAstAndSyntaxErrors, + ESQLErrorListener, +} from './src/parser'; -/** - * ES|QL Query string -> AST data structure - * this is the foundational building block for any advanced feature - * a developer wants to build on top of the ESQL language - **/ -export { getAstAndSyntaxErrors } from './src/ast_parser'; +export { Walker, type WalkerOptions, walk } from './src/walker'; -export { ESQLErrorListener } from './src/antlr_error_listener'; +export { + LeafPrinter, + BasicPrettyPrinter, + type BasicPrettyPrinterMultilineOptions, + type BasicPrettyPrinterOptions, + WrappingPrettyPrinter, + type WrappingPrettyPrinterOptions, +} from './src/pretty_print'; -export { Walker, type WalkerOptions, walk } from './src/walker'; +export { EsqlQuery } from './src/query'; diff --git a/packages/kbn-esql-ast/src/antlr_facade.ts b/packages/kbn-esql-ast/src/antlr_facade.ts deleted file mode 100644 index ba35dc342552b..0000000000000 --- a/packages/kbn-esql-ast/src/antlr_facade.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { CommonTokenStream, type CharStream, type ErrorListener } from 'antlr4'; - -import { default as ESQLLexer } from './antlr/esql_lexer'; -import { default as ESQLParser } from './antlr/esql_parser'; -import { default as ESQLParserListener } from './antlr/esql_parser_listener'; - -export const ROOT_STATEMENT = 'singleStatement'; - -export const getParser = ( - inputStream: CharStream, - errorListener: ErrorListener, - parseListener?: ESQLParserListener -) => { - const lexer = getLexer(inputStream, errorListener); - const tokenStream = new CommonTokenStream(lexer); - const parser = new ESQLParser(tokenStream); - - parser.removeErrorListeners(); - parser.addErrorListener(errorListener); - - if (parseListener) { - // @ts-expect-error the addParseListener API does exist and is documented here - // https://github.com/antlr/antlr4/blob/dev/doc/listeners.md - parser.addParseListener(parseListener); - } - - return parser; -}; - -export const getLexer = (inputStream: CharStream, errorListener: ErrorListener) => { - const lexer = new ESQLLexer(inputStream); - - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); - - return lexer; -}; diff --git a/packages/kbn-esql-ast/src/ast_parser.ts b/packages/kbn-esql-ast/src/ast_parser.ts deleted file mode 100644 index 44c5dfcd9353f..0000000000000 --- a/packages/kbn-esql-ast/src/ast_parser.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { CharStreams } from 'antlr4'; -import { ESQLErrorListener } from './antlr_error_listener'; -import { getParser, ROOT_STATEMENT } from './antlr_facade'; -import { AstListener } from './ast_factory'; -import type { ESQLAst, EditorError } from './types'; - -// These will need to be manually updated whenever the relevant grammar changes. -const SYNTAX_ERRORS_TO_IGNORE = [ - `SyntaxError: mismatched input '' expecting {'explain', 'from', 'meta', 'metrics', 'row', 'show'}`, -]; - -export function getAstAndSyntaxErrors(text: string | undefined): { - errors: EditorError[]; - ast: ESQLAst; -} { - if (text == null) { - return { ast: [], errors: [] }; - } - const errorListener = new ESQLErrorListener(); - const parseListener = new AstListener(); - const parser = getParser(CharStreams.fromString(text), errorListener, parseListener); - - parser[ROOT_STATEMENT](); - - const errors = errorListener.getErrors().filter((error) => { - return !SYNTAX_ERRORS_TO_IGNORE.includes(error.message); - }); - - return { ...parseListener.getAst(), errors }; -} diff --git a/packages/kbn-esql-ast/src/ast_position_utils.ts b/packages/kbn-esql-ast/src/ast_position_utils.ts deleted file mode 100644 index 6c0c6bf9e7b37..0000000000000 --- a/packages/kbn-esql-ast/src/ast_position_utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 type { Token } from 'antlr4'; - -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, - }; -} diff --git a/packages/kbn-esql-ast/src/builder/index.test.ts b/packages/kbn-esql-ast/src/builder/builder.test.ts similarity index 85% rename from packages/kbn-esql-ast/src/builder/index.test.ts rename to packages/kbn-esql-ast/src/builder/builder.test.ts index d8199027ea1c8..0215420ca4f57 100644 --- a/packages/kbn-esql-ast/src/builder/index.test.ts +++ b/packages/kbn-esql-ast/src/builder/builder.test.ts @@ -9,7 +9,7 @@ import { Builder } from '.'; test('can mint a numeric literal', () => { - const node = Builder.numericLiteral({ value: 42 }); + const node = Builder.expression.literal.numeric({ value: 42, literalType: 'integer' }); expect(node).toMatchObject({ type: 'literal', diff --git a/packages/kbn-esql-ast/src/builder/builder.ts b/packages/kbn-esql-ast/src/builder/builder.ts new file mode 100644 index 0000000000000..9863061a8b3d8 --- /dev/null +++ b/packages/kbn-esql-ast/src/builder/builder.ts @@ -0,0 +1,118 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-namespace */ + +import { + ESQLAstComment, + ESQLAstQueryExpression, + ESQLCommand, + ESQLDecimalLiteral, + ESQLInlineCast, + ESQLIntegerLiteral, + ESQLList, + ESQLLocation, +} from '../types'; +import { AstNodeParserFields, AstNodeTemplate } from './types'; + +export namespace Builder { + /** + * Constructs fields which are only available when the node is minted by + * the parser. + */ + export const parserFields = ({ + location = { min: 0, max: 0 }, + text = '', + incomplete = false, + }: Partial = {}): AstNodeParserFields => ({ + location, + text, + incomplete, + }); + + export const command = ( + template: AstNodeTemplate, + fromParser?: Partial + ): ESQLCommand => { + return { + ...template, + ...Builder.parserFields(fromParser), + type: 'command', + }; + }; + + export const comment = ( + subtype: ESQLAstComment['subtype'], + text: string, + location: ESQLLocation + ): ESQLAstComment => { + return { + type: 'comment', + subtype, + text, + location, + }; + }; + + export namespace expression { + export const query = ( + commands: ESQLAstQueryExpression['commands'] = [], + fromParser?: Partial + ): ESQLAstQueryExpression => { + return { + ...Builder.parserFields(fromParser), + commands, + type: 'query', + name: '', + }; + }; + + export const inlineCast = ( + template: Omit, 'name'>, + fromParser?: Partial + ): ESQLInlineCast => { + return { + ...template, + ...Builder.parserFields(fromParser), + type: 'inlineCast', + name: '', + }; + }; + + export namespace literal { + /** + * Constructs an integer literal node. + */ + export const numeric = ( + template: Omit, 'name'>, + fromParser?: Partial + ): ESQLIntegerLiteral | ESQLDecimalLiteral => { + const node: ESQLIntegerLiteral | ESQLDecimalLiteral = { + ...template, + ...Builder.parserFields(fromParser), + type: 'literal', + name: template.value.toString(), + }; + + return node; + }; + + export const list = ( + template: Omit, 'name'>, + fromParser?: Partial + ): ESQLList => { + return { + ...template, + ...Builder.parserFields(fromParser), + type: 'list', + name: '', + }; + }; + } + } +} diff --git a/packages/kbn-esql-ast/src/builder/index.ts b/packages/kbn-esql-ast/src/builder/index.ts index 524301111ed4d..0c9820a486957 100644 --- a/packages/kbn-esql-ast/src/builder/index.ts +++ b/packages/kbn-esql-ast/src/builder/index.ts @@ -6,42 +6,5 @@ * Side Public License, v 1. */ -import { ESQLDecimalLiteral, ESQLIntegerLiteral, ESQLNumericLiteralType } from '../types'; -import { AstNodeParserFields, AstNodeTemplate } from './types'; - -export class Builder { - /** - * Constructs fields which are only available when the node is minted by - * the parser. - */ - public static readonly parserFields = ({ - location = { min: 0, max: 0 }, - text = '', - incomplete = false, - }: Partial): AstNodeParserFields => ({ - location, - text, - incomplete, - }); - - /** - * Constructs a integer literal node. - */ - public static readonly numericLiteral = ( - template: Omit< - AstNodeTemplate, - 'literalType' | 'name' - >, - type: ESQLNumericLiteralType = 'integer' - ): ESQLIntegerLiteral | ESQLDecimalLiteral => { - const node: ESQLIntegerLiteral | ESQLDecimalLiteral = { - ...template, - ...Builder.parserFields(template), - type: 'literal', - literalType: type, - name: template.value.toString(), - }; - - return node; - }; -} +export type * from './types'; +export { Builder } from './builder'; diff --git a/packages/kbn-esql-ast/src/parser/README.md b/packages/kbn-esql-ast/src/parser/README.md new file mode 100644 index 0000000000000..1500be94c40c8 --- /dev/null +++ b/packages/kbn-esql-ast/src/parser/README.md @@ -0,0 +1,27 @@ +## Comments + +### Inter-node comment places + +Around colon in source identifier: + +```eslq +FROM cluster /* comment */ : index +``` + +Arounds dots in column identifier: + +```eslq +KEEP column /* comment */ . subcolumn +``` + +Cast expressions: + +```eslq +STATS "abc":: /* asdf */ integer +``` + +Time interface expressions: + +```eslq +STATS 1 /* asdf */ DAY +``` diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.columns.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts similarity index 96% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.columns.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts index 0a6dbe1f772a2..fae1286e6251a 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.columns.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('Column Identifier Expressions', () => { it('can parse un-quoted identifiers', () => { diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.commands.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/commands.test.ts similarity index 99% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.commands.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/commands.test.ts index a636f4a448595..ddc96b40bd452 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.commands.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/commands.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('commands', () => { describe('correctly formatted, basic usage', () => { diff --git a/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts new file mode 100644 index 0000000000000..e5ea9f5f3519a --- /dev/null +++ b/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts @@ -0,0 +1,731 @@ +/* + * 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 { parse } from '..'; + +describe('Comments', () => { + describe('can attach "top" comment(s)', () => { + it('to a single command', () => { + const text = ` +//comment +FROM index`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[0]).toMatchObject({ + type: 'command', + name: 'from', + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: 'comment', + }, + ], + }, + }); + }); + + it('to the second command', () => { + const text = ` + FROM abc + + // Good limit + | LIMIT 10`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'limit', + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' Good limit', + }, + ], + }, + }, + ]); + }); + + it('to a command (multiline)', () => { + const text = ` + FROM abc + + /* Good limit */ + | LIMIT 10`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'limit', + formatting: { + top: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' Good limit ', + }, + ], + }, + }, + ]); + }); + + it('to a command (multiple comments)', () => { + const text = ` + FROM abc + + /* 1 */ + // 2 + /* 3 */ + + | LIMIT 10`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'limit', + formatting: { + top: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' 1 ', + }, + { + type: 'comment', + subtype: 'single-line', + text: ' 2', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' 3 ', + }, + ], + }, + }, + ]); + }); + + it('to an expression', () => { + const text = ` + FROM + + // "abc" is the best source + abc`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' "abc" is the best source', + }, + ], + }, + }, + ], + }, + ]); + }); + + it('to an expression (multiple comments)', () => { + const text = ` + FROM + // "abc" is the best source + /* another comment */ /* one more */ + abc`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' "abc" is the best source', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' another comment ', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' one more ', + }, + ], + }, + }, + ], + }, + ]); + }); + + it('to a nested expression', () => { + const text = ` + FROM a + | STATS 1 + + // 2 is the best number + 2`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'stats', + args: [ + { + type: 'function', + name: '+', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'literal', + value: 2, + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' 2 is the best number', + }, + ], + }, + }, + ], + }, + ], + }, + ]); + }); + }); + + describe('can attach "left" comment(s)', () => { + it('to a command', () => { + const text = `/* hello */ FROM abc`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + formatting: { + left: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' hello ', + }, + ], + }, + }, + ]); + }); + + it('to an expression, multiple comments', () => { + const text = `FROM /* aha */ source, /* 1 */ /*2*/ /* 3 */ abc`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'source', + formatting: { + left: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' aha ', + }, + ], + }, + }, + { + type: 'source', + name: 'abc', + formatting: { + left: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' 1 ', + }, + { + type: 'comment', + subtype: 'multi-line', + text: '2', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' 3 ', + }, + ], + }, + }, + ], + }, + ]); + }); + + it('to sub-expression', () => { + const text = `FROM index | STATS 1 + /* aha */ 2`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'stats', + args: [ + { + name: '+', + args: [ + {}, + { + type: 'literal', + value: 2, + formatting: { + left: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' aha ', + }, + ], + }, + }, + ], + }, + ], + }, + ]); + }); + }); + + describe('can attach "right" comment(s)', () => { + it('to an expression', () => { + const text = `FROM abc /* hello */`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'abc', + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' hello ', + }, + ], + }, + }, + ], + }, + ]); + }); + + it('to an expression, multiple comments', () => { + const text = `FROM abc /* a */ /* b */, def /* c */`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'abc', + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' a ', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' b ', + }, + ], + }, + }, + { + type: 'source', + name: 'def', + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' c ', + }, + ], + }, + }, + ], + }, + ]); + }); + + it('to a nested expression', () => { + const text = `FROM a | STATS 1 + 2 /* hello */`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[1]).toMatchObject({ + type: 'command', + name: 'stats', + args: [ + { + name: '+', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'literal', + value: 2, + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' hello ', + }, + ], + }, + }, + ], + }, + ], + }); + }); + + it('to a nested expression - 2', () => { + const text = `FROM a | STATS 1 + 2 /* 2 */ + 3`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[1]).toMatchObject({ + type: 'command', + name: 'stats', + args: [ + { + name: '+', + args: [ + { + name: '+', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'literal', + value: 2, + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' 2 ', + }, + ], + }, + }, + ], + }, + { + type: 'literal', + value: 3, + }, + ], + }, + ], + }); + }); + + it('to a nested expression - 3', () => { + const text = `FROM a | STATS 1 /* 1 */ + 2 /* 2.1 */ /* 2.2 */ /* 2.3 */ + 3 /* 3.1 */ /* 3.2 */`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[1]).toMatchObject({ + type: 'command', + name: 'stats', + args: [ + { + name: '+', + args: [ + { + name: '+', + args: [ + { + type: 'literal', + value: 1, + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' 1 ', + }, + ], + }, + }, + { + type: 'literal', + value: 2, + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' 2.1 ', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' 2.2 ', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' 2.3 ', + }, + ], + }, + }, + ], + }, + { + type: 'literal', + value: 3, + formatting: { + right: [ + { + type: 'comment', + subtype: 'multi-line', + text: ' 3.1 ', + }, + { + type: 'comment', + subtype: 'multi-line', + text: ' 3.2 ', + }, + ], + }, + }, + ], + }, + ], + }); + }); + }); + + describe('can attach "right end" comments', () => { + it('to an expression', () => { + const text = `FROM abc // hello`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + { + type: 'source', + name: 'abc', + formatting: { + rightSingleLine: { + type: 'comment', + subtype: 'single-line', + text: ' hello', + }, + }, + }, + ], + }, + ]); + }); + + it('to the second expression', () => { + const text = `FROM a1, a2 // hello world + | LIMIT 1`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + { + type: 'command', + name: 'from', + args: [ + {}, + { + type: 'source', + name: 'a2', + formatting: { + rightSingleLine: { + type: 'comment', + subtype: 'single-line', + text: ' hello world', + }, + }, + }, + ], + }, + {}, + ]); + }); + + it('to nested expression', () => { + const text = ` + FROM a + | STATS 1 + 2 // hello world +`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'stats', + args: [ + { + name: '+', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'literal', + value: 2, + formatting: { + rightSingleLine: { + type: 'comment', + subtype: 'single-line', + text: ' hello world', + }, + }, + }, + ], + }, + ], + }, + ]); + }); + + it('to nested expression - 2', () => { + const text = ` + FROM a + | STATS 1 // The 1 is important + + 2 +`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'stats', + args: [ + { + name: '+', + args: [ + { + type: 'literal', + value: 1, + formatting: { + rightSingleLine: { + type: 'comment', + subtype: 'single-line', + text: ' The 1 is important', + }, + }, + }, + { + type: 'literal', + value: 2, + }, + ], + }, + ], + }, + ]); + }); + }); + + describe('can attach "bottom" comment(s)', () => { + it('attaches comment at the end of the program to the last command node from the "bottom"', () => { + const text = ` +FROM a +| LIMIT 1 +// the end +`; + const { ast } = parse(text, { withFormatting: true }); + + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'limit', + formatting: { + bottom: [ + { + type: 'comment', + subtype: 'single-line', + text: ' the end', + }, + ], + }, + }, + ]); + }); + }); +}); diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.from.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/from.test.ts similarity index 98% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.from.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/from.test.ts index a88b51f63ea16..47790d9155cba 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.from.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/from.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('FROM', () => { describe('correctly formatted', () => { diff --git a/packages/kbn-esql-ast/src/__tests__/ast.function.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/function.test.ts similarity index 98% rename from packages/kbn-esql-ast/src/__tests__/ast.function.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/function.test.ts index e40320a514593..e3303ad9d6df2 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast.function.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/function.test.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; -import { Walker } from '../walker'; +import { getAstAndSyntaxErrors as parse } from '..'; +import { Walker } from '../../walker'; describe('function AST nodes', () => { describe('"variadic-call"', () => { diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.inlinecast.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/inlinecast.test.ts similarity index 93% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.inlinecast.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/inlinecast.test.ts index 3f45f55b549da..a956b141589d8 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.inlinecast.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/inlinecast.test.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; -import { ESQLFunction, ESQLInlineCast, ESQLSingleAstItem } from '../types'; +import { getAstAndSyntaxErrors as parse } from '..'; +import { ESQLFunction, ESQLInlineCast, ESQLSingleAstItem } from '../../types'; describe('Inline cast (::)', () => { describe('correctly formatted', () => { @@ -19,7 +19,7 @@ describe('Inline cast (::)', () => { expect(ast[1].args[0]).toEqual( expect.objectContaining({ castType: 'string', - name: 'inlineCast', + name: '', type: 'inlineCast', value: expect.objectContaining({ name: 'field', @@ -37,7 +37,7 @@ describe('Inline cast (::)', () => { expect((ast[1].args[0] as ESQLFunction).args[0]).toEqual( expect.objectContaining({ castType: 'long', - name: 'inlineCast', + name: '', type: 'inlineCast', value: expect.objectContaining({ name: 'field', diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts similarity index 91% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts index cc03bd3db0a9d..06c14a5c7916c 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; -import { ESQLLiteral } from '../types'; +import { getAstAndSyntaxErrors as parse } from '..'; +import { ESQLLiteral } from '../../types'; describe('literal expression', () => { it('numeric expression captures "value", and "name" fields', () => { diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.metrics.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/metrics.test.ts similarity index 98% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.metrics.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/metrics.test.ts index ec56b1d015c64..51aa2a06a1561 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.metrics.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/metrics.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('METRICS', () => { describe('correctly formatted', () => { diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.params.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/params.test.ts similarity index 97% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.params.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/params.test.ts index f69412a053e02..512741de88f0a 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.params.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/params.test.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; -import { Walker } from '../walker'; +import { getAstAndSyntaxErrors as parse } from '..'; +import { Walker } from '../../walker'; /** * Un-named parameters are represented by a question mark "?". diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.rename.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/rename.test.ts similarity index 92% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.rename.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/rename.test.ts index 0ad62b59b9cb8..3d67ae809e416 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.rename.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/rename.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('RENAME', () => { /** diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/sort.test.ts similarity index 97% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/sort.test.ts index ccfbceb890893..c69871500da32 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/sort.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('SORT', () => { describe('correctly formatted', () => { diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.where.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/where.test.ts similarity index 93% rename from packages/kbn-esql-ast/src/__tests__/ast_parser.where.test.ts rename to packages/kbn-esql-ast/src/parser/__tests__/where.test.ts index 34148ec1aecd2..ac7fbee3b68fb 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.where.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/where.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors as parse } from '../ast_parser'; +import { getAstAndSyntaxErrors as parse } from '..'; describe('WHERE', () => { describe('correctly formatted', () => { diff --git a/packages/kbn-esql-ast/src/constants.ts b/packages/kbn-esql-ast/src/parser/constants.ts similarity index 68% rename from packages/kbn-esql-ast/src/constants.ts rename to packages/kbn-esql-ast/src/parser/constants.ts index ed4e854f95e97..013fba1c3ac1e 100644 --- a/packages/kbn-esql-ast/src/constants.ts +++ b/packages/kbn-esql-ast/src/parser/constants.ts @@ -6,6 +6,13 @@ * Side Public License, v 1. */ +import { Token } from 'antlr4'; + +/** + * The root ANTLR rule to start parsing from. + */ +export const GRAMMAR_ROOT_RULE = 'singleStatement'; + export const EDITOR_MARKER = 'marker_esql_editor'; export const TICKS_REGEX = /^`{1}|`{1}$/g; @@ -13,3 +20,6 @@ export const DOUBLE_TICKS_REGEX = /``/g; export const SINGLE_TICK_REGEX = /`/g; export const SINGLE_BACKTICK = '`'; export const DOUBLE_BACKTICK = '``'; + +export const DEFAULT_CHANNEL: number = +(Token as any).DEFAULT_CHANNEL; +export const HIDDEN_CHANNEL: number = +(Token as any).HIDDEN_CHANNEL; diff --git a/packages/kbn-esql-ast/src/ast_factory.ts b/packages/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts similarity index 96% rename from packages/kbn-esql-ast/src/ast_factory.ts rename to packages/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts index 09d8cc174b842..8b399b3f14e6f 100644 --- a/packages/kbn-esql-ast/src/ast_factory.ts +++ b/packages/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts @@ -31,8 +31,8 @@ import { type MetricsCommandContext, IndexPatternContext, InlinestatsCommandContext, -} from './antlr/esql_parser'; -import { default as ESQLParserListener } from './antlr/esql_parser_listener'; +} from '../antlr/esql_parser'; +import { default as ESQLParserListener } from '../antlr/esql_parser_listener'; import { createCommand, createFunction, @@ -41,8 +41,8 @@ import { textExistsAndIsValid, createSource, createAstBaseItem, -} from './ast_helpers'; -import { getPosition } from './ast_position_utils'; +} from './factories'; +import { getPosition } from './helpers'; import { collectAllSourceIdentifiers, collectAllFields, @@ -56,10 +56,10 @@ import { getPolicyName, getMatchField, getEnrichClauses, -} from './ast_walker'; -import type { ESQLAst, ESQLAstMetricsCommand } from './types'; +} from './walkers'; +import type { ESQLAst, ESQLAstMetricsCommand } from '../types'; -export class AstListener implements ESQLParserListener { +export class ESQLAstBuilderListener implements ESQLParserListener { private ast: ESQLAst = []; public getAst() { diff --git a/packages/kbn-esql-ast/src/antlr_error_listener.ts b/packages/kbn-esql-ast/src/parser/esql_error_listener.ts similarity index 93% rename from packages/kbn-esql-ast/src/antlr_error_listener.ts rename to packages/kbn-esql-ast/src/parser/esql_error_listener.ts index add9a7bcace0f..afd0cbf6c8a3f 100644 --- a/packages/kbn-esql-ast/src/antlr_error_listener.ts +++ b/packages/kbn-esql-ast/src/parser/esql_error_listener.ts @@ -8,8 +8,8 @@ import type { Recognizer, RecognitionException } from 'antlr4'; import { ErrorListener } from 'antlr4'; -import type { EditorError } from './types'; -import { getPosition } from './ast_position_utils'; +import { getPosition } from './helpers'; +import type { EditorError } from '../types'; export class ESQLErrorListener extends ErrorListener { protected errors: EditorError[] = []; diff --git a/packages/kbn-esql-ast/src/ast_helpers.ts b/packages/kbn-esql-ast/src/parser/factories.ts similarity index 88% rename from packages/kbn-esql-ast/src/ast_helpers.ts rename to packages/kbn-esql-ast/src/parser/factories.ts index 44f9a2663db17..63a831caaa893 100644 --- a/packages/kbn-esql-ast/src/ast_helpers.ts +++ b/packages/kbn-esql-ast/src/parser/factories.ts @@ -10,7 +10,7 @@ * In case of changes in the grammar, this script should be updated: esql_update_ast_script.js */ -import { type Token, type ParserRuleContext, type TerminalNode } from 'antlr4'; +import type { Token, ParserRuleContext, TerminalNode, RecognitionException } from 'antlr4'; import { QualifiedNameContext, type ArithmeticUnaryContext, @@ -19,12 +19,10 @@ import { type IntegerValueContext, type QualifiedIntegerLiteralContext, QualifiedNamePatternContext, -} from './antlr/esql_parser'; -import { getPosition } from './ast_position_utils'; +} from '../antlr/esql_parser'; import { DOUBLE_TICKS_REGEX, SINGLE_BACKTICK, TICKS_REGEX } from './constants'; import type { ESQLAstBaseItem, - ESQLCommand, ESQLLiteral, ESQLList, ESQLTimeInterval, @@ -40,8 +38,9 @@ import type { ESQLNumericLiteralType, FunctionSubtype, ESQLNumericLiteral, -} from './types'; -import { parseIdentifier } from './parser/helpers'; +} from '../types'; +import { parseIdentifier, getPosition } from './helpers'; +import { Builder, type AstNodeParserFields } from '../builder'; export function nonNullable(v: T): v is NonNullable { return v != null; @@ -59,54 +58,32 @@ export function createAstBaseItem( }; } -export function createCommand(name: string, ctx: ParserRuleContext): ESQLCommand { - return { - type: 'command', - name, - text: ctx.getText(), - args: [], - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; -} +const createParserFields = (ctx: ParserRuleContext): AstNodeParserFields => ({ + text: ctx.getText(), + location: getPosition(ctx.start, ctx.stop), + incomplete: Boolean(ctx.exception), +}); -export function createInlineCast(ctx: InlineCastContext): Omit { - return { - type: 'inlineCast', - name: 'inlineCast', - text: ctx.getText(), - castType: ctx.dataType().getText(), - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; -} +export const createCommand = (name: string, ctx: ParserRuleContext) => + Builder.command({ name, args: [] }, createParserFields(ctx)); -export function createList(ctx: ParserRuleContext, values: ESQLLiteral[]): ESQLList { - return { - type: 'list', - name: ctx.getText(), - values, - text: ctx.getText(), - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; -} +export const createInlineCast = (ctx: InlineCastContext, value: ESQLInlineCast['value']) => + Builder.expression.inlineCast( + { castType: ctx.dataType().getText(), value }, + createParserFields(ctx) + ); + +export const createList = (ctx: ParserRuleContext, values: ESQLLiteral[]): ESQLList => + Builder.expression.literal.list({ values }, createParserFields(ctx)); -export function createNumericLiteral( +export const createNumericLiteral = ( ctx: DecimalValueContext | IntegerValueContext, literalType: ESQLNumericLiteralType -): ESQLLiteral { - const text = ctx.getText(); - return { - type: 'literal', - literalType, - text, - name: text, - value: Number(text), - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; -} +): ESQLLiteral => + Builder.expression.literal.numeric( + { value: Number(ctx.getText()), literalType }, + createParserFields(ctx) + ); export function createFakeMultiplyLiteral( ctx: ArithmeticUnaryContext, @@ -426,3 +403,13 @@ export function createUnknownItem(ctx: ParserRuleContext): ESQLUnknownItem { incomplete: Boolean(ctx.exception), }; } + +export function createError(exception: RecognitionException) { + const token = exception.offendingToken; + + return { + type: 'error' as const, + text: `SyntaxError: ${exception.message}`, + location: getPosition(token), + }; +} diff --git a/packages/kbn-esql-ast/src/parser/formatting.ts b/packages/kbn-esql-ast/src/parser/formatting.ts new file mode 100644 index 0000000000000..77b4b038f8e52 --- /dev/null +++ b/packages/kbn-esql-ast/src/parser/formatting.ts @@ -0,0 +1,269 @@ +/* + * 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 { type CommonTokenStream, Token } from 'antlr4'; +import { Builder } from '../builder'; +import { Visitor } from '../visitor'; +import type { + ESQLAstComment, + ESQLAstCommentMultiLine, + ESQLAstCommentSingleLine, + ESQLAstNodeFormatting, + ESQLAstQueryExpression, + ESQLProperNode, +} from '../types'; +import type { + ParsedFormattingCommentDecoration, + ParsedFormattingDecoration, + ParsedFormattingDecorationLines, +} from './types'; +import { HIDDEN_CHANNEL } from './constants'; +import { findVisibleToken, isLikelyPunctuation } from './helpers'; + +const commentSubtype = (text: string): ESQLAstComment['subtype'] | undefined => { + if (text[0] === '/') { + if (text[1] === '/') { + return 'single-line'; + } + if (text[1] === '*') { + const end = text.length - 1; + if (text[end] === '/' && text[end - 1] === '*') { + return 'multi-line'; + } + } + } +}; + +const trimRightNewline = (text: string): string => { + const last = text.length - 1; + if (text[last] === '\n') { + return text.slice(0, last); + } + return text; +}; + +/** + * Collects *decorations* (all comments and whitespace of interest) from the + * token stream. + * + * @param tokens Lexer token stream + * @returns List of comments found in the token stream + */ +export const collectDecorations = ( + tokens: CommonTokenStream +): { comments: ESQLAstComment[]; lines: ParsedFormattingDecorationLines } => { + const comments: ESQLAstComment[] = []; + const list = tokens.tokens; + const length = list.length; + const lines: ParsedFormattingDecorationLines = []; + + let line: ParsedFormattingDecoration[] = []; + let pos = 0; + let hasContentToLeft = false; + + // The last token in token, which we don't need to process. + for (let i = 0; i < length - 1; i++) { + const token = list[i]; + const { channel, text } = token; + const min = pos; + const max = min + text.length; + + pos = max; + + const isContentToken = channel !== HIDDEN_CHANNEL; + + if (isContentToken) { + const isPunctuation = isLikelyPunctuation(text); + + if (!isPunctuation) { + hasContentToLeft = true; + for (const decoration of line) { + if (decoration.type === 'comment') { + decoration.hasContentToRight = true; + } + } + continue; + } + } + + const subtype = commentSubtype(text); + const isComment = !!subtype; + + if (!isComment) { + const hasLineBreak = text.lastIndexOf('\n') !== -1; + + if (hasLineBreak) { + lines.push(line); + line = []; + hasContentToLeft = false; + } + continue; + } + + const cleanText = + subtype === 'single-line' ? trimRightNewline(text.slice(2)) : text.slice(2, -2); + const node = Builder.comment(subtype, cleanText, { min, max }); + const comment: ParsedFormattingCommentDecoration = { + type: 'comment', + hasContentToLeft, + hasContentToRight: false, + node, + }; + + comments.push(comment.node); + line.push(comment); + + if (subtype === 'single-line') { + const hasLineBreak = text[text.length - 1] === '\n'; + + if (hasLineBreak) { + lines.push(line); + line = []; + hasContentToLeft = false; + } + } + } + + if (line.length > 0) { + lines.push(line); + } + + return { comments, lines }; +}; + +const attachTopComment = (node: ESQLProperNode, comment: ESQLAstComment) => { + const formatting: ESQLAstNodeFormatting = node.formatting || (node.formatting = {}); + const list = formatting.top || (formatting.top = []); + list.push(comment); +}; + +const attachBottomComment = (node: ESQLProperNode, comment: ESQLAstComment) => { + const formatting: ESQLAstNodeFormatting = node.formatting || (node.formatting = {}); + const list = formatting.bottom || (formatting.bottom = []); + list.push(comment); +}; + +const attachLeftComment = (node: ESQLProperNode, comment: ESQLAstCommentMultiLine) => { + const formatting: ESQLAstNodeFormatting = node.formatting || (node.formatting = {}); + const list = formatting.left || (formatting.left = []); + list.push(comment); +}; + +const attachRightComment = (node: ESQLProperNode, comment: ESQLAstCommentMultiLine) => { + const formatting: ESQLAstNodeFormatting = node.formatting || (node.formatting = {}); + const list = formatting.right || (formatting.right = []); + list.push(comment); +}; + +const attachRightEndComment = (node: ESQLProperNode, comment: ESQLAstCommentSingleLine) => { + const formatting: ESQLAstNodeFormatting = node.formatting || (node.formatting = {}); + formatting.rightSingleLine = comment; +}; + +const attachCommentDecoration = ( + ast: ESQLAstQueryExpression, + tokens: Token[], + comment: ParsedFormattingCommentDecoration +) => { + const commentConsumesWholeLine = !comment.hasContentToLeft && !comment.hasContentToRight; + + if (commentConsumesWholeLine) { + const node = Visitor.findNodeAtOrAfter(ast, comment.node.location.max - 1); + + if (!node) { + // No node after the comment found, it is probably at the end of the file. + // So we attach it to the last command from the "bottom". + const commands = ast.commands; + const lastCommand = commands[commands.length - 1]; + if (lastCommand) { + attachBottomComment(lastCommand, comment.node); + } + return; + } + + attachTopComment(node, comment.node); + return; + } + + if (comment.hasContentToRight && comment.node.subtype === 'multi-line') { + const nodeToRight = Visitor.findNodeAtOrAfter(ast, comment.node.location.max - 1); + + if (!nodeToRight) { + const nodeToLeft = Visitor.findNodeAtOrBefore(ast, comment.node.location.min); + + if (nodeToLeft) { + attachRightComment(nodeToLeft, comment.node); + } + + return; + } + + const isInsideNode = nodeToRight.location.min <= comment.node.location.min; + + if (isInsideNode) { + attachLeftComment(nodeToRight, comment.node); + return; + } + + const visibleTokenBetweenCommentAndNodeToRight = findVisibleToken( + tokens, + comment.node.location.max, + nodeToRight.location.min - 1 + ); + + if (visibleTokenBetweenCommentAndNodeToRight) { + const nodeToLeft = Visitor.findNodeAtOrBefore(ast, comment.node.location.min); + + if (nodeToLeft) { + attachRightComment(nodeToLeft, comment.node); + return; + } + } + + attachLeftComment(nodeToRight, comment.node); + return; + } + + if (comment.hasContentToLeft) { + const node = Visitor.findNodeAtOrBefore(ast, comment.node.location.min); + + if (!node) return; + + if (comment.node.subtype === 'multi-line') { + attachRightComment(node, comment.node); + } else if (comment.node.subtype === 'single-line') { + attachRightEndComment(node, comment.node); + } + + return; + } +}; + +/** + * Walks through the AST and - for each decoration - attaches it to the + * appropriate AST node, which is determined by the layout of the source text. + * + * @param ast AST to attach comments to. + * @param comments List of comments to attach to the AST. + */ +export const attachDecorations = ( + ast: ESQLAstQueryExpression, + tokens: Token[], + lines: ParsedFormattingDecorationLines +) => { + for (const line of lines) { + for (const decoration of line) { + switch (decoration.type) { + case 'comment': { + attachCommentDecoration(ast, tokens, decoration); + break; + } + } + } + } +}; diff --git a/packages/kbn-esql-ast/src/parser/helpers.ts b/packages/kbn-esql-ast/src/parser/helpers.ts index 9aea72a3a2073..f308c6bfca59b 100644 --- a/packages/kbn-esql-ast/src/parser/helpers.ts +++ b/packages/kbn-esql-ast/src/parser/helpers.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +import type { Token } from 'antlr4'; +import { DEFAULT_CHANNEL } from './constants'; + export const isQuotedIdentifier = (text: string): boolean => { const firstChar = text[0]; const lastChar = text[text.length - 1]; @@ -35,3 +38,140 @@ export const formatIdentifier = (text: string): string => { export const formatIdentifierParts = (parts: string[]): string => parts.map(formatIdentifier).join('.'); + +export const 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, + }; +}; + +/** + * Finds all tokens in the given range using binary search. Allows to further + * filter the tokens using a predicate. + * + * @param tokens List of ANTLR tokens. + * @param min Text position to start searching from. + * @param max Text position to stop searching at. + * @param predicate Function to test each token. + */ +export const findTokens = function* ( + tokens: Token[], + min: number = 0, + max: number = tokens.length ? tokens[tokens.length - 1].stop : 0, + predicate: (token: Token) => boolean = () => true +): Iterable { + let index = 0; + let left = 0; + let right = tokens.length - 1; + + // Find the first token index. + while (left <= right) { + const mid = left + Math.floor((right - left) / 2); + const token = tokens[mid]; + + if (token.start < min) { + left = mid + 1; + } else if (token.stop > min) { + right = mid - 1; + } else { + index = mid; + break; + } + } + + // Return all tokens in the range, which satisfy the predicate. + for (; index < tokens.length; index++) { + const token = tokens[index]; + + if (token.start > max) { + break; + } + if (predicate(token)) { + yield token; + } + } +}; + +/** + * Finds the first token in the given range using binary search. Allows to + * further filter the tokens using a predicate. + * + * @param tokens List of ANTLR tokens. + * @param min Text position to start searching from. + * @param max Text position to stop searching at. + * @param predicate Function to test each token. + * @returns The first token that matches the predicate or `null` if no token is found. + */ +export const findFirstToken = ( + tokens: Token[], + min: number = 0, + max: number = tokens.length ? tokens[tokens.length - 1].stop : 0, + predicate: (token: Token) => boolean = () => true +): Token | null => { + for (const token of findTokens(tokens, min, max, predicate)) { + return token; + } + + return null; +}; + +/** + * Finds the first visible token in the given token range using binary search. + * + * @param tokens List of ANTLR tokens. + * @param min Text position to start searching from. + * @param max Text position to stop searching at. + * @returns The first punctuation token or `null` if no token is found. + */ +export const findVisibleToken = ( + tokens: Token[], + min: number = 0, + max: number = tokens.length ? tokens[tokens.length - 1].stop : 0 +): Token | null => { + return findFirstToken( + tokens, + min, + max, + ({ channel, text }) => channel === DEFAULT_CHANNEL && text.length > 0 + ); +}; + +/** + * A heuristic set of punctuation characters. + */ +const punctuationChars = new Set(['.', ',', ';', ':', '(', ')', '[', ']', '{', '}']); + +export const isLikelyPunctuation = (text: string): boolean => + text.length === 1 && punctuationChars.has(text); + +/** + * Finds the first punctuation token in the given token range using binary + * search. + * + * @param tokens List of ANTLR tokens. + * @param min Text position to start searching from. + * @param max Text position to stop searching at. + * @returns The first punctuation token or `null` if no token is found. + */ +export const findPunctuationToken = ( + tokens: Token[], + min: number = 0, + max: number = tokens.length ? tokens[tokens.length - 1].stop : 0 +): Token | null => { + return findFirstToken( + tokens, + min, + max, + ({ channel, text }) => + channel === DEFAULT_CHANNEL && text.length === 1 && punctuationChars.has(text) + ); +}; diff --git a/packages/kbn-esql-ast/src/ast_errors.ts b/packages/kbn-esql-ast/src/parser/index.ts similarity index 51% rename from packages/kbn-esql-ast/src/ast_errors.ts rename to packages/kbn-esql-ast/src/parser/index.ts index 976de99250d4d..2d96777d2cd0a 100644 --- a/packages/kbn-esql-ast/src/ast_errors.ts +++ b/packages/kbn-esql-ast/src/parser/index.ts @@ -6,15 +6,15 @@ * Side Public License, v 1. */ -import type { RecognitionException } from 'antlr4'; -import { getPosition } from './ast_position_utils'; +export { + getLexer, + getParser, + parse, + type ParseOptions, + type ParseResult, -export function createError(exception: RecognitionException) { - const token = exception.offendingToken; + /** @deprecated Use `parse` instead. */ + parse as getAstAndSyntaxErrors, +} from './parser'; - return { - type: 'error' as const, - text: `SyntaxError: ${exception.message}`, - location: getPosition(token), - }; -} +export { ESQLErrorListener } from './esql_error_listener'; diff --git a/packages/kbn-esql-ast/src/parser/parser.ts b/packages/kbn-esql-ast/src/parser/parser.ts new file mode 100644 index 0000000000000..cb3485a1816cc --- /dev/null +++ b/packages/kbn-esql-ast/src/parser/parser.ts @@ -0,0 +1,117 @@ +/* + * 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 { CharStreams, type Token } from 'antlr4'; +import { CommonTokenStream, type CharStream, type ErrorListener } from 'antlr4'; +import { ESQLErrorListener } from './esql_error_listener'; +import { ESQLAstBuilderListener } from './esql_ast_builder_listener'; +import { GRAMMAR_ROOT_RULE } from './constants'; +import { attachDecorations, collectDecorations } from './formatting'; +import type { ESQLAst, ESQLAstQueryExpression, EditorError } from '../types'; +import { Builder } from '../builder'; +import { default as ESQLLexer } from '../antlr/esql_lexer'; +import { default as ESQLParser } from '../antlr/esql_parser'; +import { default as ESQLParserListener } from '../antlr/esql_parser_listener'; + +export const getLexer = (inputStream: CharStream, errorListener: ErrorListener) => { + const lexer = new ESQLLexer(inputStream); + + lexer.removeErrorListeners(); + lexer.addErrorListener(errorListener); + + return lexer; +}; + +export const getParser = ( + inputStream: CharStream, + errorListener: ErrorListener, + parseListener?: ESQLParserListener +) => { + const lexer = getLexer(inputStream, errorListener); + const tokens = new CommonTokenStream(lexer); + const parser = new ESQLParser(tokens); + + // lexer.symbolicNames + + parser.removeErrorListeners(); + parser.addErrorListener(errorListener); + + if (parseListener) { + // @ts-expect-error the addParseListener API does exist and is documented here + // https://github.com/antlr/antlr4/blob/dev/doc/listeners.md + parser.addParseListener(parseListener); + } + + return { + lexer, + tokens, + parser, + }; +}; + +// These will need to be manually updated whenever the relevant grammar changes. +const SYNTAX_ERRORS_TO_IGNORE = [ + `SyntaxError: mismatched input '' expecting {'explain', 'from', 'meta', 'metrics', 'row', 'show'}`, +]; + +export interface ParseOptions { + /** + * Whether to collect and attach to AST nodes user's custom formatting: + * comments and whitespace. + */ + withFormatting?: boolean; +} + +export interface ParseResult { + /** + * The root *QueryExpression* node of the parsed tree. + */ + root: ESQLAstQueryExpression; + + /** + * List of parsed commands. + * + * @deprecated Use `root` instead. + */ + ast: ESQLAst; + + /** + * List of ANTLR tokens generated by the lexer. + */ + tokens: Token[]; + + /** + * List of parsing errors. + */ + errors: EditorError[]; +} + +export const parse = (text: string | undefined, options: ParseOptions = {}): ParseResult => { + if (text == null) { + const commands: ESQLAstQueryExpression['commands'] = []; + return { ast: commands, root: Builder.expression.query(commands), errors: [], tokens: [] }; + } + const errorListener = new ESQLErrorListener(); + const parseListener = new ESQLAstBuilderListener(); + const { tokens, parser } = getParser(CharStreams.fromString(text), errorListener, parseListener); + + parser[GRAMMAR_ROOT_RULE](); + + const errors = errorListener.getErrors().filter((error) => { + return !SYNTAX_ERRORS_TO_IGNORE.includes(error.message); + }); + const { ast: commands } = parseListener.getAst(); + const root = Builder.expression.query(commands); + + if (options.withFormatting) { + const decorations = collectDecorations(tokens); + attachDecorations(root, tokens.tokens, decorations.lines); + } + + return { root, ast: commands, errors, tokens: tokens.tokens }; +}; diff --git a/packages/kbn-esql-ast/src/parser/types.ts b/packages/kbn-esql-ast/src/parser/types.ts new file mode 100644 index 0000000000000..226c7586c1256 --- /dev/null +++ b/packages/kbn-esql-ast/src/parser/types.ts @@ -0,0 +1,62 @@ +/* + * 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 { ESQLAstComment } from '../types'; + +/** + * Lines of decorations per *whitespace line*. A *whitespace line* is a line + * which tracks line breaks only from the HIDDEN channel. It does not take into + * account line breaks from the DEFAULT channel, i.e. content lines. For example, + * it will ignore line breaks from triple-quoted strings, but will track line + * breaks from comments and whitespace. + * + * Each list entry represents a line of decorations. + */ +export type ParsedFormattingDecorationLines = ParsedFormattingDecoration[][]; + +/** + * A source text decoration that we are interested in. + * + * - Comments: we preserve user comments when pretty-printing. + * - Line breaks: we allow users to specify one custom line break. + */ +export type ParsedFormattingDecoration = + | ParsedFormattingCommentDecoration + | ParsedFormattingLineBreakDecoration; + +/** + * A comment AST node with additional information about its position in the + * source text. + */ +export interface ParsedFormattingCommentDecoration { + type: 'comment'; + + /** + * Whether the comment has content on the same line to the left of it. + */ + hasContentToLeft: boolean; + + /** + * Whether the comment has content on the same line to the right of it. + */ + hasContentToRight: boolean; + + /** + * The comment AST node. + */ + node: ESQLAstComment; +} + +export interface ParsedFormattingLineBreakDecoration { + type: 'line-break'; + + /** + * The number of line breaks in the source text. + */ + lines: number; +} diff --git a/packages/kbn-esql-ast/src/ast_walker.ts b/packages/kbn-esql-ast/src/parser/walkers.ts similarity index 99% rename from packages/kbn-esql-ast/src/ast_walker.ts rename to packages/kbn-esql-ast/src/parser/walkers.ts index 7400d23d0dba2..ed34f21ca4e7a 100644 --- a/packages/kbn-esql-ast/src/ast_walker.ts +++ b/packages/kbn-esql-ast/src/parser/walkers.ts @@ -62,7 +62,7 @@ import { InputParamContext, IndexPatternContext, InlinestatsCommandContext, -} from './antlr/esql_parser'; +} from '../antlr/esql_parser'; import { createSource, createColumn, @@ -83,8 +83,8 @@ import { textExistsAndIsValid, createInlineCast, createUnknownItem, -} from './ast_helpers'; -import { getPosition } from './ast_position_utils'; +} from './factories'; +import { getPosition } from './helpers'; import { ESQLLiteral, ESQLColumn, @@ -96,7 +96,7 @@ import { ESQLUnnamedParamLiteral, ESQLPositionalParamLiteral, ESQLNamedParamLiteral, -} from './types'; +} from '../types'; export function collectAllSourceIdentifiers(ctx: FromCommandContext): ESQLAstItem[] { const fromContexts = ctx.getTypedRuleContexts(IndexPatternContext); @@ -487,10 +487,7 @@ export function visitPrimaryExpression(ctx: PrimaryExpressionContext): ESQLAstIt function collectInlineCast(ctx: InlineCastContext): ESQLInlineCast { const primaryExpression = visitPrimaryExpression(ctx.primaryExpression()); - return { - ...createInlineCast(ctx), - value: primaryExpression, - }; + return createInlineCast(ctx, primaryExpression); } export function collectLogicalExpression(ctx: BooleanExpressionContext) { diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.comments.test.ts new file mode 100644 index 0000000000000..573a83819387a --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.comments.test.ts @@ -0,0 +1,185 @@ +/* + * 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 { parse } from '../../parser'; +import { BasicPrettyPrinter } from '../basic_pretty_printer'; + +const reprint = (src: string) => { + const { root } = parse(src, { withFormatting: true }); + const text = BasicPrettyPrinter.print(root); + + // console.log(JSON.stringify(ast, null, 2)); + + return { text }; +}; + +const assertPrint = (src: string, expected: string = src) => { + const { text } = reprint(src); + + expect(text).toBe(expected); +}; + +describe('source expression', () => { + test('can print source left comment', () => { + assertPrint('FROM /* cmt */ expr'); + }); + + test('can print source right comment', () => { + assertPrint('FROM expr /* cmt */'); + }); + + test('can print source right comment with comma separating from next source', () => { + assertPrint('FROM expr /* cmt */, expr2'); + }); + + test('can print source left and right comments', () => { + assertPrint( + 'FROM /*a*/ /* b */ index1 /* c */, /* d */ index2 /* e */ /* f */, /* g */ index3' + ); + }); +}); + +describe('source column expression', () => { + test('can print source left comment', () => { + assertPrint('FROM a | STATS /* cmt */ col'); + }); + + test('can print column right comment', () => { + assertPrint('FROM a | STATS col /* cmt */'); + }); + + test('can print column left and right comments', () => { + assertPrint( + 'FROM a | STATS /*a*/ /* b */ col /* c */ /* d */, /* e */ col2 /* f */, col3 /* comment3 */, col4' + ); + }); +}); + +describe('literal expression', () => { + test('can print source left comment', () => { + assertPrint('FROM a | STATS /* cmt */ 1'); + }); + + test('can print column right comment', () => { + assertPrint('FROM a | STATS "str" /* cmt */'); + }); + + test('can print column left and right comments', () => { + assertPrint( + 'FROM a | STATS /*a*/ /* b */ TRUE /* c */ /* d */, /* e */ 1.1 /* f */, FALSE /* comment3 */, NULL' + ); + }); +}); + +describe('time interval expression', () => { + test('can print source left comment', () => { + assertPrint('FROM a | STATS /* cmt */ 1d'); + }); + + test('can print column right comment', () => { + assertPrint('FROM a | STATS 2 years /* cmt */'); + }); + + test('can print column left and right comments', () => { + assertPrint( + 'FROM a | STATS /*a*/ /* b */ 2 years /* c */ /* d */, /* e */ 3d /* f */, 1 week /* comment3 */, 1 weeks' + ); + }); +}); + +describe('inline cast expression', () => { + test('can print source left comment', () => { + assertPrint('FROM a | STATS /* 1 */ /* 2 */ 123::INTEGER /* 3 */'); + }); +}); + +describe('list literal expression', () => { + test('can print source left comment', () => { + assertPrint('FROM a | STATS /* 1 */ /* 2 */ [1, 2, 3] /* 3 */'); + }); +}); + +describe('function call expressions', () => { + test('left of function call', () => { + assertPrint('FROM a | STATS /* 1 */ FN()'); + }); + + test('right of function call', () => { + assertPrint('FROM a | STATS FN() /* asdf */'); + }); + + test('various sides from function calls', () => { + assertPrint('FROM a | STATS FN() /* asdf */, /*1*/ FN2() /*2*/, FN3() /*3*/'); + }); + + test('left of function call, when function as an argument', () => { + assertPrint('FROM a | STATS /* 1 */ FN(1)'); + }); + + test('right comments respect function bracket', () => { + assertPrint('FROM a | STATS FN(1 /* 1 */) /* 2 */'); + }); + + test('around function argument', () => { + assertPrint('FROM a | STATS /*1*/ FN(/*2*/ 1 /*3*/) /*4*/'); + }); + + test('around function arguments', () => { + assertPrint('FROM a | STATS /*1*/ FN(/*2*/ 1 /*3*/, /*4*/ /*5*/ 2 /*6*/ /*7*/) /*8*/'); + }); +}); + +describe('binary expressions', () => { + test('around binary expression operands', () => { + assertPrint('FROM a | STATS /* a */ 1 /* b */ + /* c */ 2 /* d */'); + }); + + test('around binary expression operands, twice', () => { + assertPrint('FROM a | STATS /* a */ 1 /* b */ + /* c */ 2 /* d */ + /* e */ 3 /* f */'); + }); + + test('around binary expression operands, trice', () => { + assertPrint( + 'FROM a | STATS /* a */ /* a.2 */ 1 /* b */ + /* c */ 2 /* d */ + /* e */ 3 /* f */ + /* g */ 4 /* h */ /* h.2 */' + ); + }); +}); + +describe('unary expressions', () => { + test('around binary expression operands', () => { + assertPrint('FROM a | STATS /* a */ NOT /* b */ 1 /* c */'); + }); + + test('around binary expression operands, with trailing argument', () => { + assertPrint('FROM a | STATS /* a */ NOT /* b */ 1 /* c */, 2'); + }); +}); + +describe('post-fix unary expressions', () => { + test('around binary expression operands', () => { + assertPrint('FROM a | STATS /*I*/ 0 /*II*/ IS NULL /*III*/'); + }); + + test('around binary expression operands, with surrounding args', () => { + assertPrint('FROM a | STATS FN(1, /*I*/ 0 /*II*/ IS NULL /*III*/, 2)'); + }); +}); + +describe('rename expressions', () => { + test('around the rename expression', () => { + assertPrint('FROM a | RENAME /*I*/ a AS b /*II*/'); + }); + + test('around two rename expressions', () => { + assertPrint('FROM a | RENAME /*I*/ a AS b /*II*/, /*III*/ c AS d /*IV*/'); + }); + + test('inside rename expression', () => { + assertPrint('FROM a | RENAME /*I*/ a /*II*/ AS /*III*/ b /*IV*/, c AS d'); + }); +}); diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts index 0afb4e8e42ce4..d062eae2bcdc2 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors } from '../../ast_parser'; +import { parse } from '../../parser'; import { ESQLFunction } from '../../types'; import { Walker } from '../../walker'; import { BasicPrettyPrinter, BasicPrettyPrinterMultilineOptions } from '../basic_pretty_printer'; const reprint = (src: string) => { - const { ast } = getAstAndSyntaxErrors(src); - const text = BasicPrettyPrinter.print(ast); + const { root } = parse(src); + const text = BasicPrettyPrinter.print(root); // console.log(JSON.stringify(ast, null, 2)); @@ -211,7 +211,7 @@ describe('single line query', () => { }); }); - describe('binary expression expression', () => { + describe('binary expression', () => { test('arithmetic expression', () => { const { text } = reprint('ROW 1 + 2'); @@ -235,6 +235,36 @@ describe('single line query', () => { expect(text).toBe('FROM a | WHERE a LIKE "b"'); }); + + test('inserts brackets where necessary due precedence', () => { + const { text } = reprint('FROM a | WHERE (1 + 2) * 3'); + + expect(text).toBe('FROM a | WHERE (1 + 2) * 3'); + }); + + test('inserts brackets where necessary due precedence - 2', () => { + const { text } = reprint('FROM a | WHERE (1 + 2) * (3 - 4)'); + + expect(text).toBe('FROM a | WHERE (1 + 2) * (3 - 4)'); + }); + + test('inserts brackets where necessary due precedence - 3', () => { + const { text } = reprint('FROM a | WHERE (1 + 2) * (3 - 4) / (5 + 6 + 7)'); + + expect(text).toBe('FROM a | WHERE (1 + 2) * (3 - 4) / (5 + 6 + 7)'); + }); + + test('inserts brackets where necessary due precedence - 4', () => { + const { text } = reprint('FROM a | WHERE (1 + (1 + 2)) * ((3 - 4) / (5 + 6 + 7))'); + + expect(text).toBe('FROM a | WHERE (1 + 1 + 2) * (3 - 4) / (5 + 6 + 7)'); + }); + + test('inserts brackets where necessary due precedence - 5', () => { + const { text } = reprint('FROM a | WHERE (1 + (1 + 2)) * (((3 - 4) / (5 + 6 + 7)) + 1)'); + + expect(text).toBe('FROM a | WHERE (1 + 1 + 2) * ((3 - 4) / (5 + 6 + 7) + 1)'); + }); }); }); @@ -340,17 +370,17 @@ describe('single line query', () => { describe('cast expressions', () => { test('various', () => { - expect(reprint('ROW a::string').text).toBe('ROW a::string'); - expect(reprint('ROW 123::string').text).toBe('ROW 123::string'); - expect(reprint('ROW "asdf"::number').text).toBe('ROW "asdf"::number'); + expect(reprint('ROW a::string').text).toBe('ROW a::STRING'); + expect(reprint('ROW 123::string').text).toBe('ROW 123::STRING'); + expect(reprint('ROW "asdf"::number').text).toBe('ROW "asdf"::NUMBER'); }); test('wraps into rackets complex cast expressions', () => { - expect(reprint('ROW (1 + 2)::string').text).toBe('ROW (1 + 2)::string'); + expect(reprint('ROW (1 + 2)::string').text).toBe('ROW (1 + 2)::STRING'); }); test('does not wrap function call', () => { - expect(reprint('ROW fn()::string').text).toBe('ROW FN()::string'); + expect(reprint('ROW fn()::string').text).toBe('ROW FN()::STRING'); }); }); @@ -372,8 +402,8 @@ describe('single line query', () => { describe('multiline query', () => { const multiline = (src: string, opts?: BasicPrettyPrinterMultilineOptions) => { - const { ast } = getAstAndSyntaxErrors(src); - const text = BasicPrettyPrinter.multiline(ast, opts); + const { root } = parse(src); + const text = BasicPrettyPrinter.multiline(root, opts); // console.log(JSON.stringify(ast, null, 2)); @@ -426,7 +456,9 @@ describe('single line command', () => { | EVAL avg_salary = ROUND(avg_salary) | SORT hired, languages | LIMIT 100`; - const { ast: commands } = getAstAndSyntaxErrors(query); + const { + root: { commands }, + } = parse(query); const line1 = BasicPrettyPrinter.command(commands[0]); const line2 = BasicPrettyPrinter.command(commands[1]); const line3 = BasicPrettyPrinter.command(commands[2]); @@ -444,9 +476,9 @@ describe('single line command', () => { describe('single line expression', () => { test('can print a single expression', () => { const query = `FROM a | STATS a != 1, avg(1, 2, 3)`; - const { ast } = getAstAndSyntaxErrors(query); - const comparison = Walker.match(ast, { type: 'function', name: '!=' })! as ESQLFunction; - const func = Walker.match(ast, { type: 'function', name: 'avg' })! as ESQLFunction; + const { root } = parse(query); + const comparison = Walker.match(root, { type: 'function', name: '!=' })! as ESQLFunction; + const func = Walker.match(root, { type: 'function', name: 'avg' })! as ESQLFunction; const text1 = BasicPrettyPrinter.expression(comparison); const text2 = BasicPrettyPrinter.expression(func); @@ -455,3 +487,5 @@ describe('single line expression', () => { expect(text2).toBe('AVG(1, 2, 3)'); }); }); + +it.todo('test for NOT unary expression'); diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts new file mode 100644 index 0000000000000..5c0a348f20865 --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -0,0 +1,477 @@ +/* + * 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 { parse } from '../../parser'; +import { WrappingPrettyPrinter, WrappingPrettyPrinterOptions } from '../wrapping_pretty_printer'; + +const reprint = (src: string, opts?: WrappingPrettyPrinterOptions) => { + const { root } = parse(src, { withFormatting: true }); + const text = WrappingPrettyPrinter.print(root, opts); + + return { text }; +}; + +describe('commands', () => { + describe('top comments', () => { + test('preserves single command top comment', () => { + const query = ` +//comment +FROM index +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +//comment +FROM index`); + }); + + test('over second command', () => { + const query = ` +FROM index | +//comment +LIMIT 123 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + //comment + | LIMIT 123`); + }); + + test('over the last command', () => { + const query = ` +FROM index | SORT abc | +//comment +LIMIT 123 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | SORT abc + //comment + | LIMIT 123`); + }); + + test('multiple comments over multiple commands', () => { + const query = ` +// 1 +// 2 +/* 3 */ +FROM index +/* 1 + 2 + 3 */ +// sort +/* sort 2 */ +| SORT abc +| +//comment +/* limit */ +// LIMIT +LIMIT 123 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +// 1 +// 2 +/* 3 */ +FROM index + /* 1 + 2 + 3 */ + // sort + /* sort 2 */ + | SORT abc + //comment + /* limit */ + // LIMIT + | LIMIT 123`); + }); + }); +}); + +describe('expressions', () => { + describe('source expression', () => { + describe('top comments', () => { + test('single line comment', () => { + const query = ` +FROM + + // the comment + index +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + // the comment + index`); + }); + + test('multi line comment', () => { + const query = ` +FROM + + /* the comment */ + index +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + /* the comment */ + index`); + }); + + test('multiple comments', () => { + const query = ` +FROM + + // 1 + /* 2 */ + // 3 + /* 4 */ + index +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + // 1 + /* 2 */ + // 3 + /* 4 */ + index`); + }); + }); + + describe('left comments', () => { + test('single left comment', () => { + const query = ` +FROM /*1*/ index +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM /*1*/ index`); + }); + + test('multiple left comments', () => { + const query = ` +FROM /*1*/ /*2*/ /*3*/ index +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM /*1*/ /*2*/ /*3*/ index`); + }); + + test('multiple left comments, and multiple arguments', () => { + const query = ` +FROM index1, /*1*/ /*2*/ /*3*/ index2, index3 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index1, /*1*/ /*2*/ /*3*/ index2, index3`); + }); + }); + + describe('right comments', () => { + test('single multi-line right comment', () => { + const query = ` +FROM index /*1*/ +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index /*1*/`); + }); + + test('multiple multi-line right comments', () => { + const query = ` +FROM index /*1*/ /*2*/ /*3*/ +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index /*1*/ /*2*/ /*3*/`); + }); + + test('multiple multi-line right comment and multiple arguments', () => { + const query = ` +FROM index1, index2 /*1*/ /*2*/ /*3*/, index3 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index1, index2 /*1*/ /*2*/ /*3*/, index3`); + }); + + test('a single-line comment', () => { + const query = ` +FROM index1 // 1 + , index2 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + index1, // 1 + index2`); + }); + }); + + test('surrounding source from three sides', () => { + const query = ` + FROM index0, + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ index1, /* 7 */ /* 8 */ // 9 + index2 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + index0, + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ index1, /* 7 */ /* 8 */ // 9 + index2`); + }); + }); + + describe('column expression', () => { + test('surrounded from three sides', () => { + const query = ` + FROM index | KEEP + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ field /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | KEEP + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ field /* 7 */ /* 8 */ // 9`); + }); + + test('nested in function', () => { + const query = ` + FROM index | STATS fn( + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ field /* 7 */ /* 8 */ // 9 + )`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + FN( + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ field /* 7 */ /* 8 */ // 9 + )`); + }); + }); + + describe('literal expressions', () => { + test('numeric literal, surrounded from three sides', () => { + const query = ` + ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ 123 /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ 123 /* 7 */ /* 8 */ // 9`); + }); + + test('string literal, surrounded from three sides', () => { + const query = ` + ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ "asdf" /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ "asdf" /* 7 */ /* 8 */ // 9`); + }); + + // Enable this test once triple quoted strings are properly supported + test.skip('triple quoted string literal, surrounded from three sides', () => { + const query = ` + ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ """a +b""" /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ """a\nb""" /* 7 */ /* 8 */ // 9`); + }); + }); + + describe('time interval literal expressions', () => { + test('numeric literal, surrounded from three sides', () => { + const query = ` + ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ 1 day /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ 1 day /* 7 */ /* 8 */ // 9`); + }); + }); + + describe('inline cast expressions', () => { + test('numeric literal, surrounded from three sides', () => { + const query = ` + ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ 1::INTEGER /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ 1::INTEGER /* 7 */ /* 8 */ // 9`); + }); + }); + + describe('list literal expressions', () => { + test('numeric list literal, surrounded from three sides', () => { + const query = ` + ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ [1, 2, 3] /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ [1, 2, 3] /* 7 */ /* 8 */ // 9`); + }); + }); + + describe('rename expressions expressions', () => { + test('rename expression, surrounded from three sides', () => { + const query = ` + ROW 1 | RENAME + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ a AS b /* 7 */ /* 8 */ // 9`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW 1 + | RENAME + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ a AS b /* 7 */ /* 8 */ // 9`); + }); + }); + + describe('function call expressions', () => { + describe('binary expressions', () => { + test('first operand surrounded by inline comments', () => { + const query = `ROW /* 1 */ /* 2 */ 1 /* 3 */ /* 4 */ + 2`; + const text = reprint(query).text; + + expect(text).toBe(`ROW /* 1 */ /* 2 */ 1 /* 3 */ /* 4 */ + 2`); + }); + + test('second operand surrounded by inline comments', () => { + const query = `ROW 1 * /* 1 */ /* 2 */ 2 /* 3 */ /* 4 */`; + const text = reprint(query).text; + + expect(text).toBe(`ROW 1 * /* 1 */ /* 2 */ 2 /* 3 */ /* 4 */`); + }); + + test('first operand with top comment', () => { + const query = `ROW + // One is important here + 1 + + 2`; + const text = reprint(query).text; + + console.log(text); + + expect(text).toBe(`ROW /* 1 */ /* 2 */ 1 /* 3 */ /* 4 */ + 2`); + }); + }); + }); +}); diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts index 4cbebb5d66b67..59e04e64ee993 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors } from '../../ast_parser'; +import { parse } from '../../parser'; import { WrappingPrettyPrinter, WrappingPrettyPrinterOptions } from '../wrapping_pretty_printer'; const reprint = (src: string, opts?: WrappingPrettyPrinterOptions) => { - const { ast } = getAstAndSyntaxErrors(src); - const text = WrappingPrettyPrinter.print(ast, opts); + const { root } = parse(src); + const text = WrappingPrettyPrinter.print(root, opts); // console.log(JSON.stringify(ast, null, 2)); @@ -215,6 +215,20 @@ FROM index1, index2, index2, index3, index4, index5, index6 METADATA _id, _source`); }); + test("indents options such that they don't align with sub-commands", () => { + const query = ` +FROM index1, index2, index2, index3, index4, index5, index6 METADATA _id, _source +| WHERE language == "javascript" +| LIMIT 123`; + const text = reprint(query, { pipeTab: ' ' }).text; + + expect('\n' + text).toBe(` +FROM index1, index2, index2, index3, index4, index5, index6 + METADATA _id, _source + | WHERE language == "javascript" + | LIMIT 123`); + }); + test('indents METADATA option differently than the LIMIT pipe', () => { const query = ` FROM index1, index2, index2, index3, index4, index5, index6 METADATA _id, _source | LIMIT 10`; @@ -345,6 +359,19 @@ FROM index | LIMIT 10`); }); + test('single long function argument is broken by line', () => { + const query = ` +FROM index | STATS super_function("xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx") +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + SUPER_FUNCTION( + "xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx")`); + }); + test('break by line function arguments, when wrapping is not enough', () => { const query = ` FROM index @@ -473,7 +500,7 @@ FROM index test('binary expressions of different precedence are not flattened', () => { const query = ` FROM index -| STATS super_function_name(0.123123123123123 + 888811112.232323123123 * 123123123123.123123123 + 23232323.23232323123 - 123 + 999)), +| STATS fn(123456789 + 123456789 - 123456789 + 123456789 - 123456789 + 123456789 - 123456789)), | LIMIT 10 `; const text = reprint(query).text; @@ -481,12 +508,14 @@ FROM index expect('\n' + text).toBe(` FROM index | STATS - SUPER_FUNCTION_NAME( - 0.123123123123123 + - 888811112.2323232 * 123123123123.12312 + - 23232323.232323233 - - 123 + - 999)`); + FN( + 123456789 + + 123456789 - + 123456789 + + 123456789 - + 123456789 + + 123456789 - + 123456789)`); }); test('binary expressions vertical flattening child function function argument wrapping', () => { @@ -512,7 +541,7 @@ FROM index test('two binary expression lists of different precedence group', () => { const query = ` FROM index -| STATS super_function_name(11111111111111.111 + 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 + 11111111111111.111 + 11111111111111.111)), +| STATS fn(11111111111111.111 + 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 + 11111111111111.111 + 11111111111111.111)), | LIMIT 10 `; const text = reprint(query).text; @@ -520,7 +549,7 @@ FROM index expect('\n' + text).toBe(` FROM index | STATS - SUPER_FUNCTION_NAME( + FN( 11111111111111.111 + 3333333333333.3335 * 3333333333333.3335 * @@ -564,3 +593,5 @@ ROW (asdf + asdf)::string, 1.2::string, "1234"::integer, (12321342134 + 23412341 }); }); }); + +test.todo('Idempotence on multiple times pretty printing'); diff --git a/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts index 0aa3ccd608cc6..f718398adb52b 100644 --- a/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts @@ -6,17 +6,11 @@ * Side Public License, v 1. */ -import { ESQLAstCommand } from '../types'; -import { ESQLAstExpressionNode, ESQLAstQueryNode, Visitor } from '../visitor'; +import { binaryExpressionGroup } from '../ast/helpers'; +import { ESQLAstBaseItem, ESQLAstCommand, ESQLAstQueryExpression } from '../types'; +import { ESQLAstExpressionNode, Visitor } from '../visitor'; import { LeafPrinter } from './leaf_printer'; -/** - * @todo - * - * 1. Add support for binary expression wrapping into brackets, due to operator - * precedence. - */ - export interface BasicPrettyPrinterOptions { /** * Whether to break the query into multiple lines on each pipe. Defaults to @@ -64,7 +58,7 @@ export class BasicPrettyPrinter { * @returns A single-line string representation of the query. */ public static readonly print = ( - query: ESQLAstQueryNode, + query: ESQLAstQueryExpression, opts?: BasicPrettyPrinterOptions ): string => { const printer = new BasicPrettyPrinter(opts); @@ -80,7 +74,7 @@ export class BasicPrettyPrinter { * @returns A multi-line string representation of the query. */ public static readonly multiline = ( - query: ESQLAstQueryNode, + query: ESQLAstQueryExpression, opts?: BasicPrettyPrinterMultilineOptions ): string => { const printer = new BasicPrettyPrinter({ ...opts, multiline: true }); @@ -131,14 +125,57 @@ export class BasicPrettyPrinter { : word.toUpperCase(); } + protected decorateWithComments(node: ESQLAstBaseItem, formatted: string): string { + const formatting = node.formatting; + + if (!formatting) { + return formatted; + } + + if (formatting.left) { + const comments = LeafPrinter.commentList(formatting.left); + + if (comments) { + formatted = `${comments} ${formatted}`; + } + } + + if (formatting.right) { + const comments = LeafPrinter.commentList(formatting.right); + + if (comments) { + formatted = `${formatted} ${comments}`; + } + } + + return formatted; + } + protected readonly visitor = new Visitor() .on('visitExpression', (ctx) => { return ''; }) - .on('visitSourceExpression', (ctx) => LeafPrinter.source(ctx.node)) - .on('visitColumnExpression', (ctx) => LeafPrinter.column(ctx.node)) - .on('visitLiteralExpression', (ctx) => LeafPrinter.literal(ctx.node)) - .on('visitTimeIntervalLiteralExpression', (ctx) => LeafPrinter.timeInterval(ctx.node)) + + .on('visitSourceExpression', (ctx) => { + const formatted = LeafPrinter.source(ctx.node); + return this.decorateWithComments(ctx.node, formatted); + }) + + .on('visitColumnExpression', (ctx) => { + const formatted = LeafPrinter.column(ctx.node); + return this.decorateWithComments(ctx.node, formatted); + }) + + .on('visitLiteralExpression', (ctx) => { + const formatted = LeafPrinter.literal(ctx.node); + return this.decorateWithComments(ctx.node, formatted); + }) + + .on('visitTimeIntervalLiteralExpression', (ctx) => { + const formatted = LeafPrinter.timeInterval(ctx.node); + return this.decorateWithComments(ctx.node, formatted); + }) + .on('visitInlineCastExpression', (ctx) => { const value = ctx.value(); const wrapInBrackets = @@ -152,8 +189,12 @@ export class BasicPrettyPrinter { valueFormatted = `(${valueFormatted})`; } - return `${valueFormatted}::${ctx.node.castType}`; + const typeName = this.keyword(ctx.node.castType); + const formatted = `${valueFormatted}::${typeName}`; + + return this.decorateWithComments(ctx.node, formatted); }) + .on('visitListLiteralExpression', (ctx) => { let elements = ''; @@ -161,8 +202,11 @@ export class BasicPrettyPrinter { elements += (elements ? ', ' : '') + arg; } - return `[${elements}]`; + const formatted = `[${elements}]`; + + return this.decorateWithComments(ctx.node, formatted); }) + .on('visitFunctionCallExpression', (ctx) => { const opts = this.opts; const node = ctx.node; @@ -173,17 +217,39 @@ export class BasicPrettyPrinter { case 'unary-expression': { operator = this.keyword(operator); - return `${operator} ${ctx.visitArgument(0, undefined)}`; + const formatted = `${operator} ${ctx.visitArgument(0, undefined)}`; + + return this.decorateWithComments(ctx.node, formatted); } case 'postfix-unary-expression': { operator = this.keyword(operator); - return `${ctx.visitArgument(0)} ${operator}`; + const formatted = `${ctx.visitArgument(0)} ${operator}`; + + return this.decorateWithComments(ctx.node, formatted); } case 'binary-expression': { operator = this.keyword(operator); - return `${ctx.visitArgument(0)} ${operator} ${ctx.visitArgument(1)}`; + const group = binaryExpressionGroup(ctx.node); + const [left, right] = ctx.arguments(); + const groupLeft = binaryExpressionGroup(left); + const groupRight = binaryExpressionGroup(right); + + let leftFormatted = ctx.visitArgument(0); + let rightFormatted = ctx.visitArgument(1); + + if (groupLeft && groupLeft < group) { + leftFormatted = `(${leftFormatted})`; + } + + if (groupRight && groupRight < group) { + rightFormatted = `(${rightFormatted})`; + } + + const formatted = `${leftFormatted} ${operator} ${rightFormatted}`; + + return this.decorateWithComments(ctx.node, formatted); } default: { if (opts.lowercaseFunctions) { @@ -196,13 +262,19 @@ export class BasicPrettyPrinter { args += (args ? ', ' : '') + arg; } - return `${operator}(${args})`; + const formatted = `${operator}(${args})`; + + return this.decorateWithComments(ctx.node, formatted); } } }) + .on('visitRenameExpression', (ctx) => { - return `${ctx.visitArgument(0)} ${this.keyword('AS')} ${ctx.visitArgument(1)}`; + const formatted = `${ctx.visitArgument(0)} ${this.keyword('AS')} ${ctx.visitArgument(1)}`; + + return this.decorateWithComments(ctx.node, formatted); }) + .on('visitCommandOption', (ctx) => { const opts = this.opts; const option = opts.lowercaseOptions ? ctx.node.name : ctx.node.name.toUpperCase(); @@ -218,6 +290,7 @@ export class BasicPrettyPrinter { return optionFormatted; }) + .on('visitCommand', (ctx) => { const opts = this.opts; const cmd = opts.lowercaseCommands ? ctx.node.name : ctx.node.name.toUpperCase(); @@ -239,6 +312,7 @@ export class BasicPrettyPrinter { return cmdFormatted; }) + .on('visitQuery', (ctx) => { const opts = this.opts; const cmdSeparator = opts.multiline ? `\n${opts.pipeTab ?? ' '}| ` : ' | '; @@ -252,7 +326,7 @@ export class BasicPrettyPrinter { return text; }); - public print(query: ESQLAstQueryNode) { + public print(query: ESQLAstQueryExpression) { return this.visitor.visitQuery(query); } diff --git a/packages/kbn-esql-ast/src/pretty_print/helpers.ts b/packages/kbn-esql-ast/src/pretty_print/helpers.ts new file mode 100644 index 0000000000000..3c190e1786d4f --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/helpers.ts @@ -0,0 +1,78 @@ +/* + * 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 { ESQLAstBaseItem, ESQLProperNode } from '../types'; +import { Walker } from '../walker'; + +export interface QueryPrettyPrintStats { + /** + * `true` if the given AST has a line breaking decoration. A line breaking + * decoration is any decoration that requires a newline "\n" to be printed. + */ + hasLineBreakingDecorations: boolean; + + /** + * Whether the given AST has at least one single line comment to the right of + * some node. + */ + hasRightSingleLineComments: boolean; +} + +/** + * Walks once the given AST sub-tree and computes the pretty print stats. + * + * @param ast The part to compute the stats for. + */ +export const getPrettyPrintStats = (ast: ESQLProperNode): QueryPrettyPrintStats => { + const stats: QueryPrettyPrintStats = { + hasLineBreakingDecorations: false, + hasRightSingleLineComments: false, + }; + + Walker.walk(ast, { + visitAny: (node) => { + if (hasLineBreakingDecoration(node)) { + stats.hasLineBreakingDecorations = true; + } + if (!!node.formatting?.rightSingleLine) { + stats.hasRightSingleLineComments = true; + } + }, + }); + + return stats; +}; + +export const hasLineBreakingDecoration = (node: ESQLAstBaseItem): boolean => { + const formatting = node.formatting; + + if (!formatting) { + return false; + } + + if ( + (!!formatting.top && formatting.top.length > 0) || + (!!formatting.bottom && formatting.bottom.length > 0) || + !!formatting.rightSingleLine + ) { + return true; + } + + for (const decoration of [...(formatting.left ?? []), ...(formatting.right ?? [])]) { + if ( + decoration.type === 'comment' && + decoration.subtype === 'multi-line' && + !decoration.text.includes('\n') + ) { + continue; + } + return true; + } + + return false; +}; diff --git a/packages/kbn-esql-ast/src/pretty_print/index.ts b/packages/kbn-esql-ast/src/pretty_print/index.ts new file mode 100644 index 0000000000000..bde3c949c44ab --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { LeafPrinter } from './leaf_printer'; + +export { + BasicPrettyPrinter, + type BasicPrettyPrinterOptions, + type BasicPrettyPrinterMultilineOptions, +} from './basic_pretty_printer'; + +export { + WrappingPrettyPrinter, + type WrappingPrettyPrinterOptions, +} from './wrapping_pretty_printer'; diff --git a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts index b7bd13be8e8b8..263d34bbc2bee 100644 --- a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts @@ -6,7 +6,14 @@ * Side Public License, v 1. */ -import { ESQLColumn, ESQLLiteral, ESQLSource, ESQLTimeInterval } from '../types'; +import { + ESQLAstComment, + ESQLAstCommentMultiLine, + ESQLColumn, + ESQLLiteral, + ESQLSource, + ESQLTimeInterval, +} from '../types'; const regexUnquotedIdPattern = /^([a-z\*_\@]{1})[a-z0-9_\*]*$/i; @@ -90,4 +97,27 @@ export const LeafPrinter = { return `${quantity} ${unit}`; } }, + + comment: (node: ESQLAstComment): string => { + switch (node.subtype) { + case 'single-line': { + return `//${node.text}`; + } + case 'multi-line': { + return `/*${node.text}*/`; + } + default: { + return ''; + } + } + }, + + commentList: (comments: ESQLAstCommentMultiLine[]): string => { + let text = ''; + for (const comment of comments) { + const commentText = LeafPrinter.comment(comment); + if (commentText) text += (text ? ' ' : '') + commentText; + } + return text; + }, }; diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts index 24381fbcda1a8..053bb2ff30373 100644 --- a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -8,16 +8,17 @@ import { BinaryExpressionGroup } from '../ast/constants'; import { binaryExpressionGroup, isBinaryExpression } from '../ast/helpers'; +import type { ESQLAstBaseItem, ESQLAstQueryExpression } from '../types'; import { CommandOptionVisitorContext, CommandVisitorContext, - ESQLAstQueryNode, ExpressionVisitorContext, FunctionCallExpressionVisitorContext, Visitor, } from '../visitor'; import { singleItems } from '../visitor/utils'; import { BasicPrettyPrinter, BasicPrettyPrinterOptions } from './basic_pretty_printer'; +import { getPrettyPrintStats } from './helpers'; import { LeafPrinter } from './leaf_printer'; /** @@ -50,11 +51,24 @@ interface Input { * ``` */ flattenBinExpOfType?: BinaryExpressionGroup; + + /** + * Suffix text to append to the formatted output, before any comment + * decorations. + */ + suffix?: string; } interface Output { txt: string; lines?: number; + + /** + * Whether the node is returned already indented. This is done, when the + * node, for example, line braking decorations (multi-line comments), then + * the node and its decorations are returned already indented. + */ + indented?: boolean; } export interface WrappingPrettyPrinterOptions extends BasicPrettyPrinterOptions { @@ -95,7 +109,7 @@ export interface WrappingPrettyPrinterOptions extends BasicPrettyPrinterOptions export class WrappingPrettyPrinter { public static readonly print = ( - query: ESQLAstQueryNode, + query: ESQLAstQueryExpression, opts?: WrappingPrettyPrinterOptions ): string => { const printer = new WrappingPrettyPrinter(opts); @@ -137,6 +151,7 @@ export class WrappingPrettyPrinter { const groupLeft = binaryExpressionGroup(left); const groupRight = binaryExpressionGroup(right); const continueVerticalFlattening = group && inp.flattenBinExpOfType === group; + const suffix = inp.suffix ?? ''; if (continueVerticalFlattening) { const parent = ctx.parent?.node; @@ -154,7 +169,7 @@ export class WrappingPrettyPrinter { const leftOut = ctx.visitArgument(0, leftInput); const rightOut = ctx.visitArgument(1, rightInput); const rightTab = isLeftChild ? this.opts.tab : ''; - const txt = `${leftOut.txt} ${operator}\n${inp.indent}${rightTab}${rightOut.txt}`; + const txt = `${leftOut.txt} ${operator}\n${inp.indent}${rightTab}${rightOut.txt}${suffix}`; return { txt }; } @@ -175,7 +190,7 @@ export class WrappingPrettyPrinter { const fitsOnOneLine = length <= inp.remaining; if (fitsOnOneLine) { - txt = `${leftFormatted} ${operator} ${rightFormatted}`; + txt = `${leftFormatted} ${operator} ${rightFormatted}${suffix}`; } else { const flattenVertically = group === groupLeft || group === groupRight; const flattenBinExpOfType = flattenVertically ? group : undefined; @@ -192,7 +207,7 @@ export class WrappingPrettyPrinter { const leftOut = ctx.visitArgument(0, leftInput); const rightOut = ctx.visitArgument(1, rightInput); - txt = `${leftOut.txt} ${operator}\n${inp.indent}${this.opts.tab}${rightOut.txt}`; + txt = `${leftOut.txt} ${operator}\n${inp.indent}${this.opts.tab}${rightOut.txt}${suffix}`; } return { txt }; @@ -211,53 +226,63 @@ export class WrappingPrettyPrinter { let remainingCurrentLine = inp.remaining; let oneArgumentPerLine = false; - ARGS: for (const arg of singleItems(ctx.arguments())) { - if (arg.type === 'option') { - continue; + for (const child of singleItems(ctx.node.args)) { + if (getPrettyPrintStats(child).hasLineBreakingDecorations) { + oneArgumentPerLine = true; + break; } + } - const formattedArg = BasicPrettyPrinter.expression(arg, this.opts); - const formattedArgLength = formattedArg.length; - const needsWrap = remainingCurrentLine < formattedArgLength; - if (formattedArgLength > largestArg) { - largestArg = formattedArgLength; - } - let separator = txt ? ',' : ''; - let fragment = ''; - - if (needsWrap) { - separator += - '\n' + - inp.indent + - this.opts.tab + - (ctx instanceof CommandVisitorContext ? this.opts.commandTab : ''); - fragment = separator + formattedArg; - lines++; - if (argsPerLine > maxArgsPerLine) { - maxArgsPerLine = argsPerLine; + if (!oneArgumentPerLine) { + ARGS: for (const arg of singleItems(ctx.arguments())) { + if (arg.type === 'option') { + continue; } - if (argsPerLine < minArgsPerLine) { - minArgsPerLine = argsPerLine; - if (minArgsPerLine < 2) { - oneArgumentPerLine = true; - break ARGS; + + const formattedArg = BasicPrettyPrinter.expression(arg, this.opts); + const formattedArgLength = formattedArg.length; + const needsWrap = remainingCurrentLine < formattedArgLength; + if (formattedArgLength > largestArg) { + largestArg = formattedArgLength; + } + let separator = txt ? ',' : ''; + let fragment = ''; + + if (needsWrap) { + separator += + '\n' + + inp.indent + + this.opts.tab + + (ctx instanceof CommandVisitorContext ? this.opts.commandTab : ''); + fragment = separator + formattedArg; + lines++; + if (argsPerLine > maxArgsPerLine) { + maxArgsPerLine = argsPerLine; + } + if (argsPerLine < minArgsPerLine) { + minArgsPerLine = argsPerLine; + if (minArgsPerLine < 2) { + oneArgumentPerLine = true; + break ARGS; + } } + remainingCurrentLine = + inp.remaining - formattedArgLength - this.opts.tab.length - this.opts.commandTab.length; + argsPerLine = 1; + } else { + argsPerLine++; + fragment = separator + (separator ? ' ' : '') + formattedArg; + remainingCurrentLine -= fragment.length; } - remainingCurrentLine = - inp.remaining - formattedArgLength - this.opts.tab.length - this.opts.commandTab.length; - argsPerLine = 1; - } else { - argsPerLine++; - fragment = separator + (separator ? ' ' : '') + formattedArg; - remainingCurrentLine -= fragment.length; + txt += fragment; } - txt += fragment; } let indent = inp.indent + this.opts.tab; if (ctx instanceof CommandVisitorContext) { - const isFirstCommand = (ctx.parent?.node as ESQLAstQueryNode)?.[0] === ctx.node; + const isFirstCommand = + (ctx.parent?.node as ESQLAstQueryExpression)?.commands?.[0] === ctx.node; if (!isFirstCommand) { indent += this.opts.commandTab; } @@ -265,48 +290,146 @@ export class WrappingPrettyPrinter { if (oneArgumentPerLine) { lines = 1; - txt = ctx instanceof CommandVisitorContext ? indent : '\n' + indent; - let i = 0; - for (const arg of ctx.visitArguments({ - indent, - remaining: this.opts.wrap - indent.length, - })) { + txt = ctx instanceof CommandVisitorContext ? '' : '\n'; + const args = [...ctx.arguments()].filter((arg) => { + if (arg.type === 'option') return arg.name === 'as'; + return true; + }); + const length = args.length; + const last = length - 1; + for (let i = 0; i <= last; i++) { const isFirstArg = i === 0; - const separator = isFirstArg ? '' : ',\n' + indent; - txt += separator + arg.txt; + const isLastArg = i === last; + const arg = ctx.visitExpression(args[i], { + indent, + remaining: this.opts.wrap - indent.length, + suffix: isLastArg ? '' : ',', + }); + const separator = isFirstArg ? '' : '\n'; + const indentation = arg.indented ? '' : indent; + txt += separator + indentation + arg.txt; lines++; - i++; } } return { txt, lines, indent, oneArgumentPerLine }; } + protected printTopDecorations(indent: string, node: ESQLAstBaseItem): string { + const formatting = node.formatting; + + if (!formatting || !formatting.top || !formatting.top.length) { + return ''; + } + + let txt = ''; + + for (const decoration of formatting.top) { + if (decoration.type === 'comment') { + txt += indent + LeafPrinter.comment(decoration) + '\n'; + } + } + + return txt; + } + + protected decorateWithComments( + indent: string, + node: ESQLAstBaseItem, + txt: string + ): { txt: string; indented: boolean } { + let indented: boolean = false; + const formatting = node.formatting; + + if (!formatting) { + return { txt, indented }; + } + + if (formatting.left) { + const comments = LeafPrinter.commentList(formatting.left); + + if (comments) { + indented = true; + txt = `${indent}${comments} ${txt}`; + } + } + + if (formatting.top) { + const top = formatting.top; + const length = top.length; + + for (let i = length - 1; i >= 0; i--) { + const decoration = top[i]; + + if (decoration.type === 'comment') { + if (!indented) { + txt = indent + txt; + indented = true; + } + txt = indent + LeafPrinter.comment(decoration) + '\n' + txt; + } + } + } + + if (formatting.right) { + const comments = LeafPrinter.commentList(formatting.right); + + if (comments) { + txt = `${txt} ${comments}`; + } + } + + if (formatting.rightSingleLine) { + const comment = LeafPrinter.comment(formatting.rightSingleLine); + + txt += ` ${comment}`; + } + + if (formatting.bottom) { + for (const decoration of formatting.bottom) { + if (decoration.type === 'comment') { + indented = true; + txt = txt + '\n' + indent + LeafPrinter.comment(decoration); + } + } + } + + return { txt, indented }; + } + protected readonly visitor = new Visitor() .on('visitExpression', (ctx, inp: Input): Output => { const txt = ctx.node.text ?? ''; return { txt }; }) - .on( - 'visitSourceExpression', - (ctx, inp: Input): Output => ({ txt: LeafPrinter.source(ctx.node) }) - ) + .on('visitSourceExpression', (ctx, inp: Input): Output => { + const formatted = LeafPrinter.source(ctx.node) + (inp.suffix ?? ''); + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); - .on( - 'visitColumnExpression', - (ctx, inp: Input): Output => ({ txt: LeafPrinter.column(ctx.node) }) - ) + return { txt, indented }; + }) + + .on('visitColumnExpression', (ctx, inp: Input): Output => { + const formatted = LeafPrinter.column(ctx.node) + (inp.suffix ?? ''); + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); - .on( - 'visitLiteralExpression', - (ctx, inp: Input): Output => ({ txt: LeafPrinter.literal(ctx.node) }) - ) + return { txt, indented }; + }) - .on( - 'visitTimeIntervalLiteralExpression', - (ctx, inp: Input): Output => ({ txt: LeafPrinter.timeInterval(ctx.node) }) - ) + .on('visitLiteralExpression', (ctx, inp: Input): Output => { + const formatted = LeafPrinter.literal(ctx.node) + (inp.suffix ?? ''); + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); + + return { txt, indented }; + }) + + .on('visitTimeIntervalLiteralExpression', (ctx, inp: Input): Output => { + const formatted = LeafPrinter.timeInterval(ctx.node) + (inp.suffix ?? ''); + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); + + return { txt, indented }; + }) .on('visitInlineCastExpression', (ctx, inp: Input): Output => { const value = ctx.value(); @@ -325,25 +448,31 @@ export class WrappingPrettyPrinter { valueFormatted = `(${valueFormatted})`; } - const txt = `${valueFormatted}::${ctx.node.castType}`; + const formatted = `${valueFormatted}::${ctx.node.castType}${inp.suffix ?? ''}`; + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); - return { txt }; + return { txt, indented }; }) .on('visitRenameExpression', (ctx, inp: Input): Output => { const operator = this.keyword('AS'); - return this.visitBinaryExpression(ctx, operator, inp); + const { txt: formatted } = this.visitBinaryExpression(ctx, operator, inp); + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); + + return { txt, indented }; }) .on('visitListLiteralExpression', (ctx, inp: Input): Output => { let elements = ''; - for (const out of ctx.visitElements()) { + for (const out of ctx.visitElements(inp)) { elements += (elements ? ', ' : '') + out.txt; } - const txt = `[${elements}]`; - return { txt }; + const formatted = `[${elements}]${inp.suffix ?? ''}`; + const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); + + return { txt, indented }; }) .on('visitFunctionCallExpression', (ctx, inp: Input): Output => { @@ -361,7 +490,8 @@ export class WrappingPrettyPrinter { break; } case 'postfix-unary-expression': { - txt = `${ctx.visitArgument(0, inp).txt} ${operator}`; + const suffix = inp.suffix ?? ''; + txt = `${ctx.visitArgument(0, { ...inp, suffix: '' }).txt} ${operator}${suffix}`; break; } case 'binary-expression': { @@ -373,7 +503,19 @@ export class WrappingPrettyPrinter { remaining: inp.remaining - operator.length - 1, }); - txt = `${operator}(${args.txt})`; + let breakClosingParenthesis = false; + + if (getPrettyPrintStats(ctx.node).hasRightSingleLineComments) { + breakClosingParenthesis = true; + } + + let closingParenthesisFormatted = ')'; + + if (breakClosingParenthesis) { + closingParenthesisFormatted = '\n' + inp.indent + ')'; + } + + txt = `${operator}(${args.txt}${closingParenthesisFormatted}${inp.suffix ?? ''}`; } } @@ -431,6 +573,7 @@ export class WrappingPrettyPrinter { const optionsWithWhitespace = options ? `${breakOptions ? '\n' + optionIndent : ' '}${options}` : ''; + const txt = `${cmd}${argsWithWhitespace}${optionsWithWhitespace}`; return { txt, lines: args.lines /* add options lines count */ }; @@ -439,9 +582,18 @@ export class WrappingPrettyPrinter { .on('visitQuery', (ctx) => { const opts = this.opts; const indent = opts.indent ?? ''; - const commandCount = ctx.node.length; + const commands = ctx.node.commands; + const commandCount = commands.length; + let multiline = opts.multiline ?? commandCount > 3; + if (!multiline) { + const stats = getPrettyPrintStats(ctx.node); + if (stats.hasLineBreakingDecorations) { + multiline = true; + } + } + if (!multiline) { const oneLine = indent + BasicPrettyPrinter.print(ctx.node, opts); if (oneLine.length <= opts.wrap) { @@ -452,18 +604,37 @@ export class WrappingPrettyPrinter { } let text = indent; - const cmdSeparator = multiline ? `\n${indent}${opts.pipeTab ?? ' '}| ` : ' | '; + const pipedCommandIndent = `${indent}${opts.pipeTab ?? ' '}`; + const cmdSeparator = multiline ? `${pipedCommandIndent}| ` : ' | '; let i = 0; let prevOut: Output | undefined; for (const out of ctx.visitCommands({ indent, remaining: opts.wrap - indent.length })) { + const isFirstCommand = i === 0; const isSecondCommand = i === 1; + if (isSecondCommand) { const firstCommandIsMultiline = prevOut?.lines && prevOut.lines > 1; if (firstCommandIsMultiline) text += '\n' + indent; } - const isFirstCommand = i === 0; - if (!isFirstCommand) text += cmdSeparator; + + const commandIndent = isFirstCommand ? indent : pipedCommandIndent; + const topDecorations = this.printTopDecorations(commandIndent, commands[i]); + + if (topDecorations) { + if (!isFirstCommand) { + text += '\n'; + } + text += topDecorations; + } + + if (!isFirstCommand) { + if (multiline && !topDecorations) { + text += '\n'; + } + text += cmdSeparator; + } + text += out.txt; i++; prevOut = out; @@ -472,7 +643,7 @@ export class WrappingPrettyPrinter { return text; }); - public print(query: ESQLAstQueryNode) { + public print(query: ESQLAstQueryExpression) { return this.visitor.visitQuery(query); } } diff --git a/packages/kbn-esql-ast/src/query/index.ts b/packages/kbn-esql-ast/src/query/index.ts new file mode 100644 index 0000000000000..219d6767d6d8b --- /dev/null +++ b/packages/kbn-esql-ast/src/query/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { EsqlQuery } from './query'; diff --git a/packages/kbn-esql-ast/src/query/query.ts b/packages/kbn-esql-ast/src/query/query.ts new file mode 100644 index 0000000000000..1dd120944e513 --- /dev/null +++ b/packages/kbn-esql-ast/src/query/query.ts @@ -0,0 +1,48 @@ +/* + * 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 type { Token } from 'antlr4'; +import { parse } from '../parser'; +import type { ESQLAstQueryExpression } from '../types'; +import { + WrappingPrettyPrinter, + WrappingPrettyPrinterOptions, +} from '../pretty_print/wrapping_pretty_printer'; + +export class EsqlQuery { + public static readonly fromSrc = (src: string): EsqlQuery => { + const { root, tokens } = parse(src); + return new EsqlQuery(root, src, tokens); + }; + + constructor( + /** + * The parsed or programmatically created ES|QL AST. The AST is the only + * required property for the query and is the source of truth for the query. + */ + public readonly ast: ESQLAstQueryExpression, + + /** + * Optional source code that was used to generate the AST. Provide this + * if the query was created from a parsed source code. Otherwise, set to + * an empty string. + */ + public readonly src: string = '', + + /** + * Optional array of ANTLR tokens, in case the query was parsed from a + * source code. + */ + public readonly tokens: Token[] = [] + ) {} + + public print(opts?: WrappingPrettyPrinterOptions): string { + const printer = new WrappingPrettyPrinter(opts); + return printer.print(this.ast); + } +} diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index ae675a375a430..c3741d842d1da 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -10,21 +10,23 @@ export type ESQLAst = ESQLAstCommand[]; export type ESQLAstCommand = ESQLCommand | ESQLAstMetricsCommand; -export type ESQLAstNode = ESQLAstCommand | ESQLAstItem; +export type ESQLAstNode = ESQLAstCommand | ESQLAstExpression | ESQLAstItem; /** * Represents an *expression* in the AST. */ +export type ESQLAstExpression = ESQLSingleAstItem | ESQLAstQueryExpression; + export type ESQLSingleAstItem = - | ESQLFunction // "function call expression" + | ESQLFunction | ESQLCommandOption - | ESQLSource // "source identifier expression" - | ESQLColumn // "field identifier expression" + | ESQLSource + | ESQLColumn | ESQLTimeInterval - | ESQLList // "list expression" - | ESQLLiteral // "literal expression" + | ESQLList + | ESQLLiteral | ESQLCommandMode - | ESQLInlineCast // "inline cast expression" + | ESQLInlineCast | ESQLUnknownItem; export type ESQLAstField = ESQLFunction | ESQLColumn; @@ -42,7 +44,7 @@ export type ESQLAstNodeWithArgs = ESQLCommand | ESQLCommandOption | ESQLFunction * of the nodes which are plain arrays, all nodes will be *proper* and we can * remove this type. */ -export type ESQLProperNode = ESQLSingleAstItem | ESQLAstCommand; +export type ESQLProperNode = ESQLAstExpression | ESQLAstCommand; export interface ESQLLocation { min: number; @@ -54,6 +56,18 @@ export interface ESQLAstBaseItem { text: string; location: ESQLLocation; incomplete: boolean; + formatting?: ESQLAstNodeFormatting; +} + +/** + * Contains optional formatting information used by the pretty printer. + */ +export interface ESQLAstNodeFormatting { + top?: ESQLAstComment[]; + left?: ESQLAstCommentMultiLine[]; + right?: ESQLAstCommentMultiLine[]; + rightSingleLine?: ESQLAstCommentSingleLine; + bottom?: ESQLAstComment[]; } export interface ESQLCommand extends ESQLAstBaseItem { @@ -84,6 +98,11 @@ export interface ESQLCommandMode extends ESQLAstBaseItem { type: 'mode'; } +export interface ESQLAstQueryExpression extends ESQLAstBaseItem<''> { + type: 'query'; + commands: ESQLAstCommand[]; +} + /** * We coalesce all function calls and expressions into a single "function" * node type. This subtype is used to distinguish between different types @@ -305,3 +324,14 @@ export interface EditorError { code?: string; severity: 'error' | 'warning' | number; } + +export interface ESQLAstGenericComment { + type: 'comment'; + subtype: SubType; + text: string; + location: ESQLLocation; +} + +export type ESQLAstCommentSingleLine = ESQLAstGenericComment<'single-line'>; +export type ESQLAstCommentMultiLine = ESQLAstGenericComment<'multi-line'>; +export type ESQLAstComment = ESQLAstCommentSingleLine | ESQLAstCommentMultiLine; diff --git a/packages/kbn-esql-ast/src/visitor/__tests__/expressions.test.ts b/packages/kbn-esql-ast/src/visitor/__tests__/expressions.test.ts index efd30f035e7ca..d75ce9e695ce0 100644 --- a/packages/kbn-esql-ast/src/visitor/__tests__/expressions.test.ts +++ b/packages/kbn-esql-ast/src/visitor/__tests__/expressions.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors } from '../../ast_parser'; +import { parse } from '../../parser'; import { Visitor } from '../visitor'; test('"visitExpression" captures all non-captured expressions', () => { - const { ast } = getAstAndSyntaxErrors(` + const { ast } = parse(` FROM index | STATS 1, "str", [true], a = b BY field | LIMIT 123 @@ -35,7 +35,7 @@ test('"visitExpression" captures all non-captured expressions', () => { test('can terminate walk early, does not visit all literals', () => { const numbers: number[] = []; - const { ast } = getAstAndSyntaxErrors(` + const { ast } = parse(` FROM index | STATS 0, 1, 2, 3 | LIMIT 123 @@ -61,7 +61,7 @@ test('can terminate walk early, does not visit all literals', () => { }); test('"visitColumnExpression" takes over all column visits', () => { - const { ast } = getAstAndSyntaxErrors(` + const { ast } = parse(` FROM index | STATS a `); const visitor = new Visitor() @@ -84,7 +84,7 @@ test('"visitColumnExpression" takes over all column visits', () => { }); test('"visitSourceExpression" takes over all source visits', () => { - const { ast } = getAstAndSyntaxErrors(` + const { ast } = parse(` FROM index | STATS 1, "str", [true], a = b BY field | LIMIT 123 @@ -109,7 +109,7 @@ test('"visitSourceExpression" takes over all source visits', () => { }); test('"visitFunctionCallExpression" takes over all literal visits', () => { - const { ast } = getAstAndSyntaxErrors(` + const { ast } = parse(` FROM index | STATS 1, "str", [true], a = b BY field | LIMIT 123 @@ -134,7 +134,7 @@ test('"visitFunctionCallExpression" takes over all literal visits', () => { }); test('"visitLiteral" takes over all literal visits', () => { - const { ast } = getAstAndSyntaxErrors(` + const { ast } = parse(` FROM index | STATS 1, "str", [true], a = b BY field | LIMIT 123 diff --git a/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts b/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts index d0e597ea553de..6e5cdfdc4d767 100644 --- a/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts +++ b/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts @@ -13,12 +13,12 @@ * visitor to traverse the AST and make changes to it, or how to extract useful */ -import { getAstAndSyntaxErrors } from '../../ast_parser'; -import { ESQLAstQueryNode } from '../types'; +import { parse } from '../../parser'; +import { ESQLAstQueryExpression } from '../../types'; import { Visitor } from '../visitor'; test('change LIMIT from 24 to 42', () => { - const { ast } = getAstAndSyntaxErrors(` + const { root } = parse(` FROM index | STATS 1, "str", [true], a = b BY field | LIMIT 24 @@ -30,7 +30,7 @@ test('change LIMIT from 24 to 42', () => { .on('visitLimitCommand', (ctx) => ctx.numeric()) .on('visitCommand', () => null) .on('visitQuery', (ctx) => [...ctx.visitCommands()]) - .visitQuery(ast) + .visitQuery(root) .filter(Boolean)[0]; expect(limit()).toBe(24); @@ -42,7 +42,7 @@ test('change LIMIT from 24 to 42', () => { }) .on('visitCommand', () => {}) .on('visitQuery', (ctx) => [...ctx.visitCommands()]) - .visitQuery(ast); + .visitQuery(root); expect(limit()).toBe(42); }); @@ -55,7 +55,7 @@ test('change LIMIT from 24 to 42', () => { test.todo('can modify sorting orders'); test('can remove a specific WHERE command', () => { - const query = getAstAndSyntaxErrors(` + const query = parse(` FROM employees | KEEP first_name, last_name, still_hired | WHERE still_hired == true @@ -114,7 +114,7 @@ test('can remove a specific WHERE command', () => { expect(print()).toBe(''); }); -export const prettyPrint = (ast: ESQLAstQueryNode) => +export const prettyPrint = (ast: ESQLAstQueryExpression | ESQLAstQueryExpression['commands']) => new Visitor() .on('visitExpression', (ctx) => { return ''; @@ -182,7 +182,7 @@ export const prettyPrint = (ast: ESQLAstQueryNode) => .visitQuery(ast); test('can print a query to text', () => { - const { ast } = getAstAndSyntaxErrors( + const { ast } = parse( 'FROM index METADATA _id, asdf, 123 | STATS fn([1,2], 1d, 1::string, x in (1, 2)), a = b | LIMIT 1000' ); const text = prettyPrint(ast); diff --git a/packages/kbn-esql-ast/src/visitor/__tests__/visitor.test.ts b/packages/kbn-esql-ast/src/visitor/__tests__/visitor.test.ts index 24944f635ee44..3fd12678364fe 100644 --- a/packages/kbn-esql-ast/src/visitor/__tests__/visitor.test.ts +++ b/packages/kbn-esql-ast/src/visitor/__tests__/visitor.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors } from '../../ast_parser'; +import { parse } from '../../parser'; import { CommandVisitorContext, WhereCommandVisitorContext } from '../contexts'; import { Visitor } from '../visitor'; @@ -23,7 +23,7 @@ test('can collect all command names in type safe way', () => { return cmds; }); - const { ast } = getAstAndSyntaxErrors('FROM index | LIMIT 123'); + const { ast } = parse('FROM index | LIMIT 123'); const res = visitor.visitQuery(ast); expect(res).toEqual(['from', 'limit']); @@ -42,16 +42,14 @@ test('can pass inputs to visitors', () => { return cmds; }); - const { ast } = getAstAndSyntaxErrors('FROM index | LIMIT 123'); + const { ast } = parse('FROM index | LIMIT 123'); const res = visitor.visitQuery(ast); expect(res).toEqual(['pfx:from', 'pfx:limit']); }); test('can specify specific visitors for commands', () => { - const { ast } = getAstAndSyntaxErrors( - 'FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123' - ); + const { ast } = parse('FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123'); const res = new Visitor() .on('visitWhereCommand', () => 'where') .on('visitSortCommand', () => 'sort') @@ -64,28 +62,24 @@ test('can specify specific visitors for commands', () => { }); test('a command can access parent query node', () => { - const { ast } = getAstAndSyntaxErrors( - 'FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123' - ); + const { root } = parse('FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123'); new Visitor() .on('visitWhereCommand', (ctx) => { - if (ctx.parent!.node !== ast) { + if (ctx.parent!.node !== root) { throw new Error('Expected parent to be query node'); } }) .on('visitCommand', (ctx) => { - if (ctx.parent!.node !== ast) { + if (ctx.parent!.node !== root) { throw new Error('Expected parent to be query node'); } }) .on('visitQuery', (ctx) => [...ctx.visitCommands()]) - .visitQuery(ast); + .visitQuery(root); }); test('specific commands receive specific visitor contexts', () => { - const { ast } = getAstAndSyntaxErrors( - 'FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123' - ); + const { root } = parse('FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123'); new Visitor() .on('visitWhereCommand', (ctx) => { @@ -102,7 +96,7 @@ test('specific commands receive specific visitor contexts', () => { } }) .on('visitQuery', (ctx) => [...ctx.visitCommands()]) - .visitQuery(ast); + .visitQuery(root); new Visitor() .on('visitCommand', (ctx) => { @@ -114,5 +108,5 @@ test('specific commands receive specific visitor contexts', () => { } }) .on('visitQuery', (ctx) => [...ctx.visitCommands()]) - .visitQuery(ast); + .visitQuery(root); }); diff --git a/packages/kbn-esql-ast/src/visitor/contexts.ts b/packages/kbn-esql-ast/src/visitor/contexts.ts index 376825f88577f..849dd8f66cd67 100644 --- a/packages/kbn-esql-ast/src/visitor/contexts.ts +++ b/packages/kbn-esql-ast/src/visitor/contexts.ts @@ -70,7 +70,9 @@ export class VisitorContext< ) {} public *visitArguments( - input: VisitorInput + input: + | VisitorInput + | (() => VisitorInput) ): Iterable> { this.ctx.assertMethodExists('visitExpression'); @@ -84,7 +86,12 @@ export class VisitorContext< if (arg.type === 'option' && arg.name !== 'as') { continue; } - yield this.visitExpression(arg, input as any); + yield this.visitExpression( + arg, + typeof input === 'function' + ? (input as () => VisitorInput)() + : (input as VisitorInput) + ); } } @@ -92,7 +99,7 @@ export class VisitorContext< const node = this.node; if (!isNodeWithArgs(node)) { - throw new Error('Node does not have arguments'); + return []; } const args: ESQLAstExpressionNode[] = []; @@ -146,6 +153,10 @@ export class QueryVisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData > extends VisitorContext { + public *commands(): Iterable { + yield* this.node.commands; + } + public *visitCommands( input: UndefinedToVoid>[1]> ): Iterable< @@ -154,7 +165,7 @@ export class QueryVisitorContext< > { this.ctx.assertMethodExists('visitCommand'); - for (const cmd of this.node) { + for (const cmd of this.node.commands) { yield this.visitCommand(cmd, input as any); } } @@ -335,7 +346,7 @@ export class LimitCommandVisitorContext< } public setLimit(value: number): void { - const literalNode = Builder.numericLiteral({ value }); + const literalNode = Builder.expression.literal.numeric({ value, literalType: 'integer' }); this.node.args = [literalNode]; } @@ -502,12 +513,14 @@ export class ListLiteralExpressionVisitorContext< Node extends ESQLList = ESQLList > extends ExpressionVisitorContext { public *visitElements( - input: ExpressionVisitorInput + input: + | VisitorInput + | (() => VisitorInput) ): Iterable> { this.ctx.assertMethodExists('visitExpression'); for (const value of this.node.values) { - yield this.visitExpression(value, input as any); + yield this.visitExpression(value, typeof input === 'function' ? (input as any)() : input); } } } diff --git a/packages/kbn-esql-ast/src/visitor/types.ts b/packages/kbn-esql-ast/src/visitor/types.ts index beb0aed3570b2..874933c487913 100644 --- a/packages/kbn-esql-ast/src/visitor/types.ts +++ b/packages/kbn-esql-ast/src/visitor/types.ts @@ -11,10 +11,9 @@ import type * as ast from '../types'; import type * as contexts from './contexts'; /** - * We don't have a dedicated "query" AST node, so - for now - we use the root - * array of commands as the "query" node. + * @deprecated Use `ESQLAstQueryExpression` directly. */ -export type ESQLAstQueryNode = ast.ESQLAst; +export type ESQLAstQueryNode = ast.ESQLAstQueryExpression; /** * Represents an "expression" node in the AST. @@ -25,7 +24,7 @@ export type ESQLAstExpressionNode = ast.ESQLSingleAstItem; /** * All possible AST nodes supported by the visitor. */ -export type VisitorAstNode = ESQLAstQueryNode | ast.ESQLAstNode; +export type VisitorAstNode = ast.ESQLAstQueryExpression | ast.ESQLAstNode; export type Visitor = ( ctx: Ctx, diff --git a/packages/kbn-esql-ast/src/visitor/visitor.ts b/packages/kbn-esql-ast/src/visitor/visitor.ts index 1a4554f1f2cf4..26b250a39c05f 100644 --- a/packages/kbn-esql-ast/src/visitor/visitor.ts +++ b/packages/kbn-esql-ast/src/visitor/visitor.ts @@ -13,11 +13,11 @@ import type { AstNodeToVisitorName, EnsureFunction, ESQLAstExpressionNode, - ESQLAstQueryNode, UndefinedToVoid, VisitorMethods, } from './types'; -import { ESQLCommand } from '../types'; +import type { ESQLAstQueryExpression, ESQLCommand, ESQLProperNode } from '../types'; +import { Builder } from '../builder'; export interface VisitorOptions< Methods extends VisitorMethods = VisitorMethods, @@ -31,6 +31,131 @@ export class Visitor< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData > { + /** + * Finds the most specific node immediately after the given position. If the + * position is inside a node, it will return the node itself. If no node is + * found, it returns `null`. + * + * @param ast ES|QL AST + * @param pos Offset position in the source text + * @returns The node at or after the given position + */ + public static readonly findNodeAtOrAfter = ( + ast: ESQLAstQueryExpression, + pos: number + ): ESQLProperNode | null => { + return new Visitor() + .on('visitExpression', (ctx): ESQLProperNode | null => { + for (const node of ctx.arguments()) { + const { location } = node; + if (!location) continue; + const isInside = location.min <= pos && location.max >= pos; + if (isInside) return ctx.visitExpression(node, undefined); + const isBefore = location.min > pos; + if (isBefore) return ctx.visitExpression(node, undefined) || node; + } + return null; + }) + .on('visitCommand', (ctx): ESQLProperNode | null => { + for (const node of ctx.arguments()) { + const { location } = node; + if (!location) continue; + const isInside = location.min <= pos && location.max >= pos; + if (isInside) return ctx.visitExpression(node); + const isBefore = location.min > pos; + if (isBefore) return node; + } + return null; + }) + .on('visitQuery', (ctx): ESQLProperNode | null => { + for (const node of ctx.commands()) { + const { location } = node; + if (!location) continue; + const isInside = location.min <= pos && location.max >= pos; + if (isInside) return ctx.visitCommand(node); + const isBefore = location.min > pos; + if (isBefore) return node; + } + return null; + }) + .visitQuery(ast); + }; + + /** + * Finds the most specific node immediately before the given position. If the + * position is inside a node, it will return the node itself. If no node is + * found, it returns `null`. + * + * @param ast ES|QL AST + * @param pos Offset position in the source text + * @returns The node at or before the given position + */ + public static readonly findNodeAtOrBefore = ( + ast: ESQLAstQueryExpression, + pos: number + ): ESQLProperNode | null => { + return new Visitor() + .on('visitExpression', (ctx): ESQLProperNode | null => { + const nodeLocation = ctx.node.location; + const nodes = [...ctx.arguments()]; + + if (nodeLocation && nodeLocation.max < pos) { + const last = nodes[nodes.length - 1]; + if (last && last.location && last.location.max === nodeLocation.max) { + return ctx.visitExpression(last, undefined) || last; + } else { + return ctx.node; + } + } + + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + const { location } = node; + if (!location) continue; + const isInside = location.min <= pos && location.max >= pos; + if (isInside) return ctx.visitExpression(node, undefined); + const isAfter = location.max < pos; + if (isAfter) { + return ctx.visitExpression(node, undefined) || node; + } + } + + return null; + }) + .on('visitCommand', (ctx): ESQLProperNode | null => { + const nodes = [...ctx.arguments()]; + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + const { location } = node; + if (!location) continue; + const isInside = location.min <= pos && location.max >= pos; + if (isInside) return ctx.visitExpression(node); + const isAfter = location.max < pos; + if (isAfter) { + if (ctx.node.location && ctx.node.location.max === location.max) { + return ctx.visitExpression(node) || node; + } + return node; + } + } + return null; + }) + .on('visitQuery', (ctx): ESQLProperNode | null => { + const nodes = [...ctx.commands()]; + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + const { location } = node; + if (!location) continue; + const isInside = location.min <= pos && location.max >= pos; + if (isInside) return ctx.visitCommand(node); + const isAfter = location.max < pos; + if (isAfter) return ctx.visitCommand(node) || node; + } + return null; + }) + .visitQuery(ast); + }; + public readonly ctx: GlobalVisitorContext; constructor(protected readonly options: VisitorOptions = {}) { @@ -68,17 +193,21 @@ export class Visitor< ): ReturnType]>> { const node = ctx.node; if (node instanceof Array) { - this.ctx.assertMethodExists('visitQuery'); - return this.ctx.methods.visitQuery!(ctx as any, input) as ReturnType< - NonNullable - >; + throw new Error(`Unsupported node type: ${typeof node}`); } else if (node && typeof node === 'object') { switch (node.type) { - case 'command': + case 'query': { + this.ctx.assertMethodExists('visitQuery'); + return this.ctx.methods.visitQuery!(ctx as any, input) as ReturnType< + NonNullable + >; + } + case 'command': { this.ctx.assertMethodExists('visitCommand'); return this.ctx.methods.visitCommand!(ctx as any, input) as ReturnType< NonNullable >; + } } } throw new Error(`Unsupported node type: ${typeof node}`); @@ -92,9 +221,12 @@ export class Visitor< * @returns The result of the query visitor. */ public visitQuery( - node: ESQLAstQueryNode, + nodeOrCommands: ESQLAstQueryExpression | ESQLAstQueryExpression['commands'], input: UndefinedToVoid>[1]> ) { + const node = Array.isArray(nodeOrCommands) + ? Builder.expression.query(nodeOrCommands) + : nodeOrCommands; const queryContext = new QueryVisitorContext(this.ctx, node, null); return this.visit(queryContext, input); } diff --git a/packages/kbn-esql-ast/src/walker/walker.test.ts b/packages/kbn-esql-ast/src/walker/walker.test.ts index 1a3f1b0a0b6d7..ce7dadc0da12d 100644 --- a/packages/kbn-esql-ast/src/walker/walker.test.ts +++ b/packages/kbn-esql-ast/src/walker/walker.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { getAstAndSyntaxErrors } from '../ast_parser'; +import { parse } from '../parser'; import { ESQLColumn, ESQLCommand, @@ -23,10 +23,10 @@ import { import { walk, Walker } from './walker'; test('can walk all functions', () => { - const { ast } = getAstAndSyntaxErrors('METRICS index a(b(c(foo)))'); + const { root } = parse('METRICS index a(b(c(foo)))'); const functions: string[] = []; - walk(ast, { + walk(root, { visitFunction: (fn) => functions.push(fn.name), }); @@ -35,7 +35,7 @@ test('can walk all functions', () => { test('can find assignment expression', () => { const query = 'METRICS source var0 = bucket(bytes, 1 hour)'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const functions: ESQLFunction[] = []; Walker.walk(ast, { @@ -55,7 +55,7 @@ test('can find assignment expression', () => { describe('structurally can walk all nodes', () => { describe('commands', () => { test('can visit a single source command', () => { - const { ast } = getAstAndSyntaxErrors('FROM index'); + const { ast } = parse('FROM index'); const commands: ESQLCommand[] = []; walk(ast, { @@ -66,7 +66,7 @@ describe('structurally can walk all nodes', () => { }); test('can visit all commands', () => { - const { ast } = getAstAndSyntaxErrors('FROM index | STATS a = 123 | WHERE 123 | LIMIT 10'); + const { ast } = parse('FROM index | STATS a = 123 | WHERE 123 | LIMIT 10'); const commands: ESQLCommand[] = []; walk(ast, { @@ -82,7 +82,7 @@ describe('structurally can walk all nodes', () => { }); test('"visitAny" can capture command nodes', () => { - const { ast } = getAstAndSyntaxErrors('FROM index | STATS a = 123 | WHERE 123 | LIMIT 10'); + const { ast } = parse('FROM index | STATS a = 123 | WHERE 123 | LIMIT 10'); const commands: ESQLCommand[] = []; walk(ast, { @@ -101,7 +101,7 @@ describe('structurally can walk all nodes', () => { describe('command options', () => { test('can visit command options', () => { - const { ast } = getAstAndSyntaxErrors('FROM index METADATA _index'); + const { ast } = parse('FROM index METADATA _index'); const options: ESQLCommandOption[] = []; walk(ast, { @@ -113,7 +113,7 @@ describe('structurally can walk all nodes', () => { }); test('"visitAny" can capture an options node', () => { - const { ast } = getAstAndSyntaxErrors('FROM index METADATA _index'); + const { ast } = parse('FROM index METADATA _index'); const options: ESQLCommandOption[] = []; walk(ast, { @@ -129,7 +129,7 @@ describe('structurally can walk all nodes', () => { describe('command mode', () => { test('visits "mode" nodes', () => { - const { ast } = getAstAndSyntaxErrors('FROM index | ENRICH a:b'); + const { ast } = parse('FROM index | ENRICH a:b'); const modes: ESQLCommandMode[] = []; walk(ast, { @@ -141,7 +141,7 @@ describe('structurally can walk all nodes', () => { }); test('"visitAny" can capture a mode node', () => { - const { ast } = getAstAndSyntaxErrors('FROM index | ENRICH a:b'); + const { ast } = parse('FROM index | ENRICH a:b'); const modes: ESQLCommandMode[] = []; walk(ast, { @@ -158,7 +158,7 @@ describe('structurally can walk all nodes', () => { describe('expressions', () => { describe('sources', () => { test('iterates through a single source', () => { - const { ast } = getAstAndSyntaxErrors('FROM index'); + const { ast } = parse('FROM index'); const sources: ESQLSource[] = []; walk(ast, { @@ -170,7 +170,7 @@ describe('structurally can walk all nodes', () => { }); test('"visitAny" can capture a source node', () => { - const { ast } = getAstAndSyntaxErrors('FROM index'); + const { ast } = parse('FROM index'); const sources: ESQLSource[] = []; walk(ast, { @@ -184,7 +184,7 @@ describe('structurally can walk all nodes', () => { }); test('iterates through all sources', () => { - const { ast } = getAstAndSyntaxErrors('METRICS index, index2, index3, index4'); + const { ast } = parse('METRICS index, index2, index3, index4'); const sources: ESQLSource[] = []; walk(ast, { @@ -204,7 +204,7 @@ describe('structurally can walk all nodes', () => { describe('columns', () => { test('can walk through a single column', () => { const query = 'ROW x = 1'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const columns: ESQLColumn[] = []; walk(ast, { @@ -221,7 +221,7 @@ describe('structurally can walk all nodes', () => { test('"visitAny" can capture a column', () => { const query = 'ROW x = 1'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const columns: ESQLColumn[] = []; walk(ast, { @@ -240,7 +240,7 @@ describe('structurally can walk all nodes', () => { test('can walk through multiple columns', () => { const query = 'FROM index | STATS a = 123, b = 456'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const columns: ESQLColumn[] = []; walk(ast, { @@ -263,7 +263,7 @@ describe('structurally can walk all nodes', () => { describe('functions', () => { test('can walk through functions', () => { const query = 'FROM a | STATS fn(1), agg(true)'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const nodes: ESQLFunction[] = []; walk(ast, { @@ -284,7 +284,7 @@ describe('structurally can walk all nodes', () => { test('"visitAny" can capture function nodes', () => { const query = 'FROM a | STATS fn(1), agg(true)'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const nodes: ESQLFunction[] = []; walk(ast, { @@ -309,7 +309,7 @@ describe('structurally can walk all nodes', () => { describe('literals', () => { test('can walk a single literal', () => { const query = 'ROW x = 1'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const columns: ESQLLiteral[] = []; walk(ast, { @@ -326,7 +326,7 @@ describe('structurally can walk all nodes', () => { test('can walk through all literals', () => { const query = 'FROM index | STATS a = 123, b = "foo", c = true AND false'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const columns: ESQLLiteral[] = []; walk(ast, { @@ -359,7 +359,7 @@ describe('structurally can walk all nodes', () => { test('can walk through literals inside functions', () => { const query = 'FROM index | STATS f(1, "2", g(true) + false, h(j(k(3.14))))'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const columns: ESQLLiteral[] = []; walk(ast, { @@ -400,7 +400,7 @@ describe('structurally can walk all nodes', () => { describe('numeric', () => { test('can walk a single numeric list literal', () => { const query = 'ROW x = [1, 2]'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const lists: ESQLList[] = []; walk(ast, { @@ -428,7 +428,7 @@ describe('structurally can walk all nodes', () => { test('"visitAny" can capture a list literal', () => { const query = 'ROW x = [1, 2]'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const lists: ESQLList[] = []; walk(ast, { @@ -442,7 +442,7 @@ describe('structurally can walk all nodes', () => { test('can walk plain literals inside list literal', () => { const query = 'ROW x = [1, 2] + [3.3]'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const lists: ESQLList[] = []; const literals: ESQLLiteral[] = []; @@ -501,7 +501,7 @@ describe('structurally can walk all nodes', () => { describe('boolean', () => { test('can walk a single numeric list literal', () => { const query = 'ROW x = [true, false]'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const lists: ESQLList[] = []; walk(ast, { @@ -529,7 +529,7 @@ describe('structurally can walk all nodes', () => { test('can walk plain literals inside list literal', () => { const query = 'ROW x = [false, false], b([true, true, true])'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const lists: ESQLList[] = []; const literals: ESQLLiteral[] = []; @@ -579,7 +579,7 @@ describe('structurally can walk all nodes', () => { describe('string', () => { test('can walk string literals', () => { const query = 'ROW x = ["a", "b"], b(["c", "d", "e"])'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const lists: ESQLList[] = []; const literals: ESQLLiteral[] = []; @@ -630,7 +630,7 @@ describe('structurally can walk all nodes', () => { describe('time interval', () => { test('can visit time interval nodes', () => { const query = 'FROM index | STATS a = 123 BY 1h'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const intervals: ESQLTimeInterval[] = []; walk(ast, { @@ -648,7 +648,7 @@ describe('structurally can walk all nodes', () => { test('"visitAny" can capture time interval expressions', () => { const query = 'FROM index | STATS a = 123 BY 1h'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const intervals: ESQLTimeInterval[] = []; walk(ast, { @@ -668,7 +668,7 @@ describe('structurally can walk all nodes', () => { test('"visitAny" does not capture time interval node if type-specific callback provided', () => { const query = 'FROM index | STATS a = 123 BY 1h'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const intervals1: ESQLTimeInterval[] = []; const intervals2: ESQLTimeInterval[] = []; @@ -687,7 +687,7 @@ describe('structurally can walk all nodes', () => { describe('cast expression', () => { test('can visit cast expression', () => { const query = 'FROM index | STATS a = 123::integer'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const casts: ESQLInlineCast[] = []; @@ -710,7 +710,7 @@ describe('structurally can walk all nodes', () => { test('"visitAny" can capture cast expression', () => { const query = 'FROM index | STATS a = 123::integer'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const casts: ESQLInlineCast[] = []; walk(ast, { @@ -737,7 +737,7 @@ describe('structurally can walk all nodes', () => { describe('unknown nodes', () => { test('can iterate through "unknown" nodes', () => { - const { ast } = getAstAndSyntaxErrors('FROM index'); + const { ast } = parse('FROM index'); let source: ESQLSource | undefined; walk(ast, { @@ -763,7 +763,7 @@ describe('structurally can walk all nodes', () => { describe('Walker.commands()', () => { test('can collect all commands', () => { - const { ast } = getAstAndSyntaxErrors('FROM index | STATS a = 123 | WHERE 123 | LIMIT 10'); + const { ast } = parse('FROM index | STATS a = 123 | WHERE 123 | LIMIT 10'); const commands = Walker.commands(ast); expect(commands.map(({ name }) => name).sort()).toStrictEqual([ @@ -778,7 +778,7 @@ describe('Walker.commands()', () => { describe('Walker.params()', () => { test('can collect all params', () => { const query = 'ROW x = ?'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const params = Walker.params(ast); expect(params).toMatchObject([ @@ -793,7 +793,7 @@ describe('Walker.params()', () => { test('can collect all params from grouping functions', () => { const query = 'ROW x=1, time=2024-07-10 | stats z = avg(x) by bucket(time, 20, ?t_start,?t_end)'; - const { ast } = getAstAndSyntaxErrors(query); + const { ast } = parse(query); const params = Walker.params(ast); expect(params).toMatchObject([ @@ -817,7 +817,7 @@ describe('Walker.find()', () => { test('can find a bucket() function', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; const fn = Walker.find( - getAstAndSyntaxErrors(query).ast!, + parse(query).ast!, (node) => node.type === 'function' && node.name === 'bucket' ); @@ -830,7 +830,7 @@ describe('Walker.find()', () => { test('finds the first "fn" function', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; const fn = Walker.find( - getAstAndSyntaxErrors(query).ast!, + parse(query).ast!, (node) => node.type === 'function' && node.name === 'fn' ); @@ -851,7 +851,7 @@ describe('Walker.findAll()', () => { test('find all "fn" functions', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; const list = Walker.findAll( - getAstAndSyntaxErrors(query).ast!, + parse(query).ast!, (node) => node.type === 'function' && node.name === 'fn' ); @@ -883,7 +883,7 @@ describe('Walker.findAll()', () => { describe('Walker.match()', () => { test('can find a bucket() function', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; - const fn = Walker.match(getAstAndSyntaxErrors(query).ast!, { + const fn = Walker.match(parse(query).ast!, { type: 'function', name: 'bucket', }); @@ -896,7 +896,7 @@ describe('Walker.match()', () => { test('finds the first "fn" function', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; - const fn = Walker.match(getAstAndSyntaxErrors(query).ast!, { type: 'function', name: 'fn' }); + const fn = Walker.match(parse(query).ast!, { type: 'function', name: 'fn' }); expect(fn).toMatchObject({ type: 'function', @@ -914,7 +914,7 @@ describe('Walker.match()', () => { describe('Walker.matchAll()', () => { test('find all "fn" functions', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; - const list = Walker.matchAll(getAstAndSyntaxErrors(query).ast!, { + const list = Walker.matchAll(parse(query).ast!, { type: 'function', name: 'fn', }); @@ -945,7 +945,7 @@ describe('Walker.matchAll()', () => { test('find all "fn" and "agg" functions', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; - const list = Walker.matchAll(getAstAndSyntaxErrors(query).ast!, { + const list = Walker.matchAll(parse(query).ast!, { type: 'function', name: ['fn', 'agg'], }); @@ -980,7 +980,7 @@ describe('Walker.matchAll()', () => { test('find all functions which start with "b" or "a"', () => { const query = 'FROM b | STATS var0 = bucket(bytes, 1 hour), fn(1), fn(2), agg(true)'; - const list = Walker.matchAll(getAstAndSyntaxErrors(query).ast!, { + const list = Walker.matchAll(parse(query).ast!, { type: 'function', name: /^a|b/i, }); @@ -1002,8 +1002,8 @@ describe('Walker.hasFunction()', () => { test('can find assignment expression', () => { const query1 = 'FROM a | STATS bucket(bytes, 1 hour)'; const query2 = 'FROM b | STATS var0 = bucket(bytes, 1 hour)'; - const has1 = Walker.hasFunction(getAstAndSyntaxErrors(query1).ast!, '='); - const has2 = Walker.hasFunction(getAstAndSyntaxErrors(query2).ast!, '='); + const has1 = Walker.hasFunction(parse(query1).ast!, '='); + const has2 = Walker.hasFunction(parse(query2).ast!, '='); expect(has1).toBe(false); expect(has2).toBe(true); diff --git a/packages/kbn-esql-ast/src/walker/walker.ts b/packages/kbn-esql-ast/src/walker/walker.ts index e6ed54517435e..9ad379e60b07a 100644 --- a/packages/kbn-esql-ast/src/walker/walker.ts +++ b/packages/kbn-esql-ast/src/walker/walker.ts @@ -8,8 +8,10 @@ import type { ESQLAstCommand, + ESQLAstExpression, ESQLAstItem, ESQLAstNode, + ESQLAstQueryExpression, ESQLColumn, ESQLCommand, ESQLCommandMode, @@ -33,9 +35,11 @@ export interface WalkerOptions { visitCommand?: (node: ESQLCommand) => void; visitCommandOption?: (node: ESQLCommandOption) => void; visitCommandMode?: (node: ESQLCommandMode) => void; - visitSingleAstItem?: (node: ESQLSingleAstItem) => void; - visitSource?: (node: ESQLSource) => void; + /** @todo Rename to `visitExpression`. */ + visitSingleAstItem?: (node: ESQLAstExpression) => void; + visitQuery?: (node: ESQLAstQueryExpression) => void; visitFunction?: (node: ESQLFunction) => void; + visitSource?: (node: ESQLSource) => void; visitColumn?: (node: ESQLColumn) => void; visitLiteral?: (node: ESQLLiteral) => void; visitListLiteral?: (node: ESQLList) => void; @@ -287,10 +291,36 @@ export class Walker { } } - public walkSingleAstItem(node: ESQLSingleAstItem): void { + public walkFunction(node: ESQLFunction): void { + const { options } = this; + (options.visitFunction ?? options.visitAny)?.(node); + const args = node.args; + const length = args.length; + for (let i = 0; i < length; i++) { + const arg = args[i]; + this.walkAstItem(arg); + } + } + + public walkQuery(node: ESQLAstQueryExpression): void { + const { options } = this; + (options.visitQuery ?? options.visitAny)?.(node); + const commands = node.commands; + const length = commands.length; + for (let i = 0; i < length; i++) { + const arg = commands[i]; + this.walkCommand(arg); + } + } + + public walkSingleAstItem(node: ESQLAstExpression): void { const { options } = this; options.visitSingleAstItem?.(node); switch (node.type) { + case 'query': { + this.walkQuery(node as ESQLAstQueryExpression); + break; + } case 'function': { this.walkFunction(node as ESQLFunction); break; @@ -312,7 +342,7 @@ export class Walker { break; } case 'literal': { - options.visitLiteral?.(node); + (options.visitLiteral ?? options.visitAny)?.(node); break; } case 'list': { @@ -333,17 +363,6 @@ export class Walker { } } } - - public walkFunction(node: ESQLFunction): void { - const { options } = this; - (options.visitFunction ?? options.visitAny)?.(node); - const args = node.args; - const length = args.length; - for (let i = 0; i < length; i++) { - const arg = args[i]; - this.walkAstItem(arg); - } - } } export const walk = Walker.walk; diff --git a/packages/kbn-monaco/src/esql/worker/esql_worker.ts b/packages/kbn-monaco/src/esql/worker/esql_worker.ts index 4b268dc8b3b58..2c4cadfa65c7f 100644 --- a/packages/kbn-monaco/src/esql/worker/esql_worker.ts +++ b/packages/kbn-monaco/src/esql/worker/esql_worker.ts @@ -51,7 +51,7 @@ export class ESQLWorker implements BaseWorkerDefinition { if (inputStream) { const errorListener = new ESQLErrorListener(); - const parser = getParser(inputStream, errorListener); + const { parser } = getParser(inputStream, errorListener); parser[ROOT_STATEMENT](); From 3cd5b151f8570ef07460b6368e352ac7b09ceef4 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Fri, 6 Sep 2024 13:37:36 +0200 Subject: [PATCH 02/50] assign comments correctly to binary expression operands --- .../src/parser/__tests__/comments.test.ts | 80 +++++++++++++++++++ packages/kbn-esql-ast/src/visitor/visitor.ts | 4 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts index e5ea9f5f3519a..bfcb9fb9d702f 100644 --- a/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts @@ -233,6 +233,86 @@ FROM index`; }, ]); }); + + it('to first binary expression operand', () => { + const text = ` + ROW + + // 1 + 1 + + 2`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[0]).toMatchObject({ + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: '+', + args: [ + { + type: 'literal', + value: 1, + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' 1', + }, + ], + }, + }, + { + type: 'literal', + value: 2, + }, + ], + }, + ], + }); + }); + + it('to second binary expression operand', () => { + const text = ` + ROW + 1 + + + // 2 + 2`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[0]).toMatchObject({ + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: '+', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'literal', + value: 2, + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' 2', + }, + ], + }, + }, + ], + }, + ], + }); + }); }); describe('can attach "left" comment(s)', () => { diff --git a/packages/kbn-esql-ast/src/visitor/visitor.ts b/packages/kbn-esql-ast/src/visitor/visitor.ts index 26b250a39c05f..80963b48c128b 100644 --- a/packages/kbn-esql-ast/src/visitor/visitor.ts +++ b/packages/kbn-esql-ast/src/visitor/visitor.ts @@ -63,7 +63,9 @@ export class Visitor< const isInside = location.min <= pos && location.max >= pos; if (isInside) return ctx.visitExpression(node); const isBefore = location.min > pos; - if (isBefore) return node; + if (isBefore) { + return ctx.visitExpression(node) || node; + } } return null; }) From a8c461fb5f7f35f2053e9f128de60a5aebe9508c Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Fri, 6 Sep 2024 13:39:50 +0200 Subject: [PATCH 03/50] improve tests --- .../src/parser/__tests__/comments.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts index bfcb9fb9d702f..e8fa03c6ac18d 100644 --- a/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/comments.test.ts @@ -274,6 +274,53 @@ FROM index`; }); }); + it('to first binary expression operand, nested in function', () => { + const text = ` + ROW fn( + + // 1 + 1 + + 2 + )`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[0]).toMatchObject({ + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: 'fn', + args: [ + { + type: 'function', + name: '+', + args: [ + { + type: 'literal', + value: 1, + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' 1', + }, + ], + }, + }, + { + type: 'literal', + value: 2, + }, + ], + }, + ], + }, + ], + }); + }); + it('to second binary expression operand', () => { const text = ` ROW @@ -313,6 +360,53 @@ FROM index`; ], }); }); + + it('to second binary expression operand, nested in function', () => { + const text = ` + ROW fn( + 1 + + + // 2 + 2 + )`; + const { root } = parse(text, { withFormatting: true }); + + expect(root.commands[0]).toMatchObject({ + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: 'fn', + args: [ + { + type: 'function', + name: '+', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'literal', + value: 2, + formatting: { + top: [ + { + type: 'comment', + subtype: 'single-line', + text: ' 2', + }, + ], + }, + }, + ], + }, + ], + }, + ], + }); + }); }); describe('can attach "left" comment(s)', () => { From 2027672425320af60ee90af46f74b1b753ba9a48 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Fri, 6 Sep 2024 14:09:25 +0200 Subject: [PATCH 04/50] ability to add top comment to binary expression left operand --- .../__tests__/wrapping_pretty_printer.comments.test.ts | 8 +++++--- .../src/pretty_print/wrapping_pretty_printer.ts | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts index 5c0a348f20865..a62455d2959e3 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -468,9 +468,11 @@ ROW 1 2`; const text = reprint(query).text; - console.log(text); - - expect(text).toBe(`ROW /* 1 */ /* 2 */ 1 /* 3 */ /* 4 */ + 2`); + expect('\n' + text).toBe(` +ROW + // One is important here + 1 + + 2`); }); }); }); diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts index 053bb2ff30373..799e5fe3d9fd0 100644 --- a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -152,8 +152,11 @@ export class WrappingPrettyPrinter { const groupRight = binaryExpressionGroup(right); const continueVerticalFlattening = group && inp.flattenBinExpOfType === group; const suffix = inp.suffix ?? ''; + const oneArgumentPerLine = + getPrettyPrintStats(left).hasLineBreakingDecorations || + getPrettyPrintStats(right).hasLineBreakingDecorations; - if (continueVerticalFlattening) { + if (continueVerticalFlattening || oneArgumentPerLine) { const parent = ctx.parent?.node; const isLeftChild = isBinaryExpression(parent) && parent.args[0] === node; const leftInput: Input = { @@ -171,7 +174,7 @@ export class WrappingPrettyPrinter { const rightTab = isLeftChild ? this.opts.tab : ''; const txt = `${leftOut.txt} ${operator}\n${inp.indent}${rightTab}${rightOut.txt}${suffix}`; - return { txt }; + return { txt, indented: leftOut.indented || rightOut.indented }; } let txt: string = ''; From 8538a7c562f616c31fc8514c13c5cd38d6062a9c Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Fri, 6 Sep 2024 14:30:33 +0200 Subject: [PATCH 05/50] support top decorations on second bin exp operand --- .../wrapping_pretty_printer.comments.test.ts | 16 +++++++++- .../pretty_print/wrapping_pretty_printer.ts | 29 +++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts index a62455d2959e3..0d87e8bfc26db 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -472,7 +472,21 @@ ROW 1 ROW // One is important here 1 + - 2`); + 2`); + }); + + test('second operand with top comment', () => { + const query = `ROW + 1 + + // Two is more important here + 2`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + 1 + + // Two is more important here + 2`); }); }); }); diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts index 799e5fe3d9fd0..01e1ed59ea235 100644 --- a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -164,17 +164,25 @@ export class WrappingPrettyPrinter { remaining: inp.remaining, flattenBinExpOfType: group, }; + const rightTab = isLeftChild ? this.opts.tab : ''; + const rightIndent = inp.indent + rightTab + (oneArgumentPerLine ? this.opts.tab : ''); const rightInput: Input = { - indent: inp.indent + this.opts.tab, + indent: rightIndent, remaining: inp.remaining - this.opts.tab.length, flattenBinExpOfType: group, }; const leftOut = ctx.visitArgument(0, leftInput); const rightOut = ctx.visitArgument(1, rightInput); - const rightTab = isLeftChild ? this.opts.tab : ''; - const txt = `${leftOut.txt} ${operator}\n${inp.indent}${rightTab}${rightOut.txt}${suffix}`; - return { txt, indented: leftOut.indented || rightOut.indented }; + let txt = `${leftOut.txt} ${operator}\n`; + + if (!rightOut.indented) { + txt += rightIndent; + } + + txt += rightOut.txt + suffix; + + return { txt, indented: leftOut.indented }; } let txt: string = ''; @@ -192,6 +200,8 @@ export class WrappingPrettyPrinter { const length = leftFormatted.length + rightFormatted.length + operator.length + 2; const fitsOnOneLine = length <= inp.remaining; + let indented = false; + if (fitsOnOneLine) { txt = `${leftFormatted} ${operator} ${rightFormatted}${suffix}`; } else { @@ -210,10 +220,17 @@ export class WrappingPrettyPrinter { const leftOut = ctx.visitArgument(0, leftInput); const rightOut = ctx.visitArgument(1, rightInput); - txt = `${leftOut.txt} ${operator}\n${inp.indent}${this.opts.tab}${rightOut.txt}${suffix}`; + txt = `${leftOut.txt} ${operator}\n`; + + if (!rightOut.indented) { + txt += `${inp.indent}${this.opts.tab}`; + } + + txt += `${rightOut.txt}${suffix}`; + indented = leftOut.indented; } - return { txt }; + return { txt, indented }; } private printArguments( From 2b2935bc39a908ff0641523ac072ab14cb7420ab Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Mon, 9 Sep 2024 15:02:17 +0200 Subject: [PATCH 06/50] correctly indent rename expression top comments --- .../wrapping_pretty_printer.comments.test.ts | 3 ++- .../src/pretty_print/wrapping_pretty_printer.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts index 0d87e8bfc26db..62efe436dab68 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -441,7 +441,8 @@ ROW 1 // 2 /* 3 */ // 4 - /* 5 */ /* 6 */ a AS b /* 7 */ /* 8 */ // 9`); + /* 5 */ /* 6 */ a AS + b /* 7 */ /* 8 */ // 9`); }); }); diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts index 01e1ed59ea235..abf7c21c81fc2 100644 --- a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -356,9 +356,9 @@ export class WrappingPrettyPrinter { protected decorateWithComments( indent: string, node: ESQLAstBaseItem, - txt: string + txt: string, + indented: boolean = false ): { txt: string; indented: boolean } { - let indented: boolean = false; const formatting = node.formatting; if (!formatting) { @@ -476,8 +476,13 @@ export class WrappingPrettyPrinter { .on('visitRenameExpression', (ctx, inp: Input): Output => { const operator = this.keyword('AS'); - const { txt: formatted } = this.visitBinaryExpression(ctx, operator, inp); - const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); + const expression = this.visitBinaryExpression(ctx, operator, inp); + const { txt, indented } = this.decorateWithComments( + inp.indent, + ctx.node, + expression.txt, + expression.indented + ); return { txt, indented }; }) From 2dfa74eab63420d035641449a380e23cfa5ee036 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Mon, 9 Sep 2024 15:05:14 +0200 Subject: [PATCH 07/50] add argument surround text --- .../wrapping_pretty_printer.comments.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts index 62efe436dab68..f3387ed201c3e 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -444,6 +444,34 @@ ROW 1 /* 5 */ /* 6 */ a AS b /* 7 */ /* 8 */ // 9`); }); + + test('rename expression, surrounded from three sides with comments, and between other expressions', () => { + const query = ` + ROW 1 | RENAME + x AS y, + + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ a AS b /* 7 */ /* 8 */, // 9 + + x AS y + `; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW 1 + | RENAME + x AS y, + /* 1 */ + // 2 + /* 3 */ + // 4 + /* 5 */ /* 6 */ a AS + b, /* 7 */ /* 8 */ // 9 + x AS y`); + }); }); describe('function call expressions', () => { From eaf815284b872344d6aa871627fb1a6135a5271e Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Mon, 9 Sep 2024 18:02:51 +0200 Subject: [PATCH 08/50] add rename expression operand comment test --- .../wrapping_pretty_printer.comments.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts index f3387ed201c3e..784375e77a2be 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -423,7 +423,7 @@ ROW }); }); - describe('rename expressions expressions', () => { + describe('rename expressions', () => { test('rename expression, surrounded from three sides', () => { const query = ` ROW 1 | RENAME @@ -472,6 +472,28 @@ ROW 1 b, /* 7 */ /* 8 */ // 9 x AS y`); }); + + test('rename operands surrounds from all sides', () => { + const query = ` + ROW 1 | RENAME + x AS y, + /* 1 */ + /* 2 */ a /* 3 */ AS + + /* 4 */ + /* 5 */ b, /* 6 */ + x AS y`; + const text = reprint(query).text; + expect('\n' + text).toBe(` +ROW 1 + | RENAME + x AS y, + /* 1 */ + /* 2 */ a /* 3 */ AS + /* 4 */ + /* 5 */ b, /* 6 */ + x AS y`); + }); }); describe('function call expressions', () => { From 5cd78c26311aefbbcd9c4b48252df4b04788d754 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Tue, 10 Sep 2024 14:18:00 +0200 Subject: [PATCH 09/50] setup storybook --- examples/esql_ast_inspector/.storybook/main.js | 9 +++++++++ .../public/stories/todo.stories.tsx | 16 ++++++++++++++++ src/dev/storybook/aliases.ts | 1 + 3 files changed, 26 insertions(+) create mode 100644 examples/esql_ast_inspector/.storybook/main.js create mode 100644 examples/esql_ast_inspector/public/stories/todo.stories.tsx diff --git a/examples/esql_ast_inspector/.storybook/main.js b/examples/esql_ast_inspector/.storybook/main.js new file mode 100644 index 0000000000000..8dc3c5d1518f4 --- /dev/null +++ b/examples/esql_ast_inspector/.storybook/main.js @@ -0,0 +1,9 @@ +/* + * 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. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/examples/esql_ast_inspector/public/stories/todo.stories.tsx b/examples/esql_ast_inspector/public/stories/todo.stories.tsx new file mode 100644 index 0000000000000..a1b212a5ef6b5 --- /dev/null +++ b/examples/esql_ast_inspector/public/stories/todo.stories.tsx @@ -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. + */ + +import * as React from 'react'; + +export default { + title: 'Test', + parameters: {}, +}; + +export const Example = () =>
TEST
; diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 5b5c7d2606cdf..64e641f5cc17a 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -29,6 +29,7 @@ export const storybookAliases = { data: 'src/plugins/data/.storybook', discover: 'src/plugins/discover/.storybook', embeddable: 'src/plugins/embeddable/.storybook', + esql_ast_inspector: 'examples/esql_ast_inspector/.storybook', es_ui_shared: 'src/plugins/es_ui_shared/.storybook', expandable_flyout: 'packages/kbn-expandable-flyout/.storybook', expression_error: 'src/plugins/expression_error/.storybook', From 4ef2f14c3b8fa75cd9025651d85d2b1157e57679 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Tue, 10 Sep 2024 14:22:31 +0200 Subject: [PATCH 10/50] add slate dependencies --- package.json | 2 ++ yarn.lock | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6ef8a524f4a3b..d845639e01ead 100644 --- a/package.json +++ b/package.json @@ -1763,6 +1763,8 @@ "sharp": "0.32.6", "simple-git": "^3.16.0", "sinon": "^7.4.2", + "slate": "^0.103.0", + "slate-react": "^0.110.0", "sort-package-json": "^1.53.1", "source-map": "^0.7.4", "string-replace-loader": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index ab818bc120054..7c2cec122726b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3292,7 +3292,7 @@ resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== -"@juggle/resize-observer@^3.3.1": +"@juggle/resize-observer@^3.3.1", "@juggle/resize-observer@^3.4.0": version "3.4.0" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== @@ -14703,6 +14703,11 @@ compute-scroll-into-view@^1.0.9: resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== +compute-scroll-into-view@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87" + integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -16308,6 +16313,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +direction@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/direction/-/direction-1.0.4.tgz#2b86fb686967e987088caf8b89059370d4837442" + integrity sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ== + discontinuous-range@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" @@ -20019,6 +20029,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + immer@^9.0.21: version "9.0.21" resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz" @@ -20531,6 +20546,11 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.1.tgz#6e084bbc92061fbb0971ec58b6ce6d404e24da69" integrity sha1-bghLvJIGH7sJcexYts5tQE4k2mk= +is-hotkey@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.2.0.tgz#1835a68171a91e5c9460869d96336947c8340cef" + integrity sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw== + is-in-ci@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-0.1.0.tgz#5e07d6a02ec3a8292d3f590973357efa3fceb0d3" @@ -28408,6 +28428,13 @@ screenfull@^5.0.0: resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6" integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA== +scroll-into-view-if-needed@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz#fa9524518c799b45a2ef6bbffb92bcad0296d01f" + integrity sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ== + dependencies: + compute-scroll-into-view "^3.0.2" + secure-json-parse@^2.4.0: version "2.6.0" resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.6.0.tgz#95d89f84adf32d76ff7800e68a673b129fe918b0" @@ -28870,6 +28897,28 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slate-react@^0.110.0: + version "0.110.0" + resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.110.0.tgz#f2dda9cd53f1c3447e57bd51ee0e4689fe9b491f" + integrity sha512-CKAGXmaErV2MaOnW7cfbfooYRjkP7xHGbu6/xWRQNHfrEEOR6ld6wmimrgvkuq6cHoCyXYkihED8iY8qkVWueA== + dependencies: + "@juggle/resize-observer" "^3.4.0" + direction "^1.0.4" + is-hotkey "^0.2.0" + is-plain-object "^5.0.0" + lodash "^4.17.21" + scroll-into-view-if-needed "^3.1.0" + tiny-invariant "1.3.1" + +slate@^0.103.0: + version "0.103.0" + resolved "https://registry.yarnpkg.com/slate/-/slate-0.103.0.tgz#deaf1148dd9a2a5a71d4bc71c8005f5468b67079" + integrity sha512-eCUOVqUpADYMZ59O37QQvUdnFG+8rin0OGQAXNHvHbQeVJ67Bu0spQbcy621vtf8GQUXTEQBlk6OP9atwwob4w== + dependencies: + immer "^10.0.3" + is-plain-object "^5.0.0" + tiny-warning "^1.0.3" + slice-ansi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" @@ -30446,12 +30495,17 @@ tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== +tiny-invariant@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" + integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== + tiny-invariant@^1.0.2, tiny-invariant@^1.0.6, tiny-invariant@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== -tiny-warning@^1.0.0, tiny-warning@^1.0.2: +tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From 2d4d0a98377e088f22a82ba67f04f5d7cb4aea9f Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Tue, 10 Sep 2024 14:34:26 +0200 Subject: [PATCH 11/50] add slate-history package --- package.json | 1 + yarn.lock | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/package.json b/package.json index d845639e01ead..9b6ce351859c7 100644 --- a/package.json +++ b/package.json @@ -1764,6 +1764,7 @@ "simple-git": "^3.16.0", "sinon": "^7.4.2", "slate": "^0.103.0", + "slate-history": "^0.109.0", "slate-react": "^0.110.0", "sort-package-json": "^1.53.1", "source-map": "^0.7.4", diff --git a/yarn.lock b/yarn.lock index 7c2cec122726b..917bc3665b862 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28897,6 +28897,13 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slate-history@^0.109.0: + version "0.109.0" + resolved "https://registry.yarnpkg.com/slate-history/-/slate-history-0.109.0.tgz#4e93e090a565bbb06b2f9914927705ad46080f64" + integrity sha512-DHavPwrTTAEAV66eAocB3iQHEj65N6IVtbRK98ZuqGT0S44T3zXlhzY+5SZ7EPxRcoOYVt1dioRxXYM/+PmCiQ== + dependencies: + is-plain-object "^5.0.0" + slate-react@^0.110.0: version "0.110.0" resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.110.0.tgz#f2dda9cd53f1c3447e57bd51ee0e4689fe9b491f" From bc30d0cb9682ad56cfdab22f96b849f1ae80de3d Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Tue, 10 Sep 2024 18:05:23 +0200 Subject: [PATCH 12/50] remove slate packages --- package.json | 3 --- yarn.lock | 65 ++-------------------------------------------------- 2 files changed, 2 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index 9b6ce351859c7..6ef8a524f4a3b 100644 --- a/package.json +++ b/package.json @@ -1763,9 +1763,6 @@ "sharp": "0.32.6", "simple-git": "^3.16.0", "sinon": "^7.4.2", - "slate": "^0.103.0", - "slate-history": "^0.109.0", - "slate-react": "^0.110.0", "sort-package-json": "^1.53.1", "source-map": "^0.7.4", "string-replace-loader": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index 917bc3665b862..ab818bc120054 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3292,7 +3292,7 @@ resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== -"@juggle/resize-observer@^3.3.1", "@juggle/resize-observer@^3.4.0": +"@juggle/resize-observer@^3.3.1": version "3.4.0" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== @@ -14703,11 +14703,6 @@ compute-scroll-into-view@^1.0.9: resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== -compute-scroll-into-view@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87" - integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -16313,11 +16308,6 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -direction@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/direction/-/direction-1.0.4.tgz#2b86fb686967e987088caf8b89059370d4837442" - integrity sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ== - discontinuous-range@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" @@ -20029,11 +20019,6 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= -immer@^10.0.3: - version "10.1.1" - resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" - integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== - immer@^9.0.21: version "9.0.21" resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz" @@ -20546,11 +20531,6 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.1.tgz#6e084bbc92061fbb0971ec58b6ce6d404e24da69" integrity sha1-bghLvJIGH7sJcexYts5tQE4k2mk= -is-hotkey@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.2.0.tgz#1835a68171a91e5c9460869d96336947c8340cef" - integrity sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw== - is-in-ci@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-0.1.0.tgz#5e07d6a02ec3a8292d3f590973357efa3fceb0d3" @@ -28428,13 +28408,6 @@ screenfull@^5.0.0: resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6" integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA== -scroll-into-view-if-needed@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz#fa9524518c799b45a2ef6bbffb92bcad0296d01f" - integrity sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ== - dependencies: - compute-scroll-into-view "^3.0.2" - secure-json-parse@^2.4.0: version "2.6.0" resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.6.0.tgz#95d89f84adf32d76ff7800e68a673b129fe918b0" @@ -28897,35 +28870,6 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slate-history@^0.109.0: - version "0.109.0" - resolved "https://registry.yarnpkg.com/slate-history/-/slate-history-0.109.0.tgz#4e93e090a565bbb06b2f9914927705ad46080f64" - integrity sha512-DHavPwrTTAEAV66eAocB3iQHEj65N6IVtbRK98ZuqGT0S44T3zXlhzY+5SZ7EPxRcoOYVt1dioRxXYM/+PmCiQ== - dependencies: - is-plain-object "^5.0.0" - -slate-react@^0.110.0: - version "0.110.0" - resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.110.0.tgz#f2dda9cd53f1c3447e57bd51ee0e4689fe9b491f" - integrity sha512-CKAGXmaErV2MaOnW7cfbfooYRjkP7xHGbu6/xWRQNHfrEEOR6ld6wmimrgvkuq6cHoCyXYkihED8iY8qkVWueA== - dependencies: - "@juggle/resize-observer" "^3.4.0" - direction "^1.0.4" - is-hotkey "^0.2.0" - is-plain-object "^5.0.0" - lodash "^4.17.21" - scroll-into-view-if-needed "^3.1.0" - tiny-invariant "1.3.1" - -slate@^0.103.0: - version "0.103.0" - resolved "https://registry.yarnpkg.com/slate/-/slate-0.103.0.tgz#deaf1148dd9a2a5a71d4bc71c8005f5468b67079" - integrity sha512-eCUOVqUpADYMZ59O37QQvUdnFG+8rin0OGQAXNHvHbQeVJ67Bu0spQbcy621vtf8GQUXTEQBlk6OP9atwwob4w== - dependencies: - immer "^10.0.3" - is-plain-object "^5.0.0" - tiny-warning "^1.0.3" - slice-ansi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" @@ -30502,17 +30446,12 @@ tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== -tiny-invariant@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" - integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== - tiny-invariant@^1.0.2, tiny-invariant@^1.0.6, tiny-invariant@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.0, tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From 37b7c3cf6b88ffd45223a5c2eaf62a55c91862c1 Mon Sep 17 00:00:00 2001 From: vadimkibana Date: Tue, 10 Sep 2024 18:05:39 +0200 Subject: [PATCH 13/50] add flexible input component --- .../flexible_input/flexible_input.stories.tsx | 31 +++ .../flexible_input/flexible_input.tsx | 218 ++++++++++++++++++ .../components/flexible_input/helpers.ts | 13 ++ .../esql_inspector_state.ts} | 11 +- 4 files changed, 266 insertions(+), 7 deletions(-) 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 rename examples/esql_ast_inspector/public/{stories/todo.stories.tsx => state/esql_inspector_state.ts} (72%) 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..eff821cda59d0 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx @@ -0,0 +1,31 @@ +/* + * 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 * 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); + }} + /> + ); +}; + +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..0f7e794e9172f --- /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 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 * as React from 'react'; +import { css } from '@emotion/react'; +import { copyStyles } from './helpers'; + +const blockCss = css({ + display: 'inline-block', + position: 'relative', + width: '100%', + border: '1px solid red', +}); + +const inputCss = css({ + display: 'inline-block', + verticalAlign: 'bottom', + boxSizing: 'border-box', + overflow: 'hidden', + padding: 0, + margin: 0, + background: 0, + outline: 0, + 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 ? ( +