You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by vo...@apache.org on 2026/10/09 05:46:57 UTC
(druid) branch master updated: feat: Web console: replace Ace with CodeMirror 6 (#20507)
This is an automated email from the ASF dual-hosted git repository.
vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new bcca2884b58 feat: Web console: replace Ace with CodeMirror 6 (#20507)
bcca2884b58 is described below
commit bcca2884b58597b416ec92e48db91abf46627f05
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Thu Oct 8 22:46:52 2026 -0700
feat: Web console: replace Ace with CodeMirror 6 (#20507)
* web-console: bump http-cache-semantics to 4.3.0
Fixes GHSA-ch52-4w7c-c8xp via npm audit fix.
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* web-console, website: migrate from Volta to mise for Node version management
- Add .node-version (24.21.0 for web-console, 22 for website) and mise.toml
- Remove the volta block from package.json
- Exclude .node-version from the RAT license check
- Document mise setup in both READMEs
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* change from Ace to CodeMirror
* better completions
* remove html strings
* some cleanup
* mode -> language
* new completions
* deduplicate suggestions
* refactor sub-query markers
* clean up props
* tidy up undo interface
* better search panel
* move from ace to lazer grammer
* move keywords out of lib
* new plan
* show hjson error
* get Hjson context from the syntax tree
* better parsing
* clean up comments
* finish the cleanup
* add AGENTS.md
* add tooltip to the sub query marker
* fix title
* fix CodeQL alerts: avoid polynomial regex, replace cursor marker by index
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* web console lint: catch CodeQL regex and string-replace issues locally
Add eslint-plugin-regexp's no-super-linear-backtracking and no-super-linear-move rules
(CodeQL: polynomial / inefficient regular expressions) and a no-restricted-syntax rule for
replacing a single escape-like character with a string (CodeQL: incomplete string escaping),
and rewrite the existing regular expressions they flag.
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* move the generated SQL docs from lib/sql-docs.ts to src/editor-languages/dsql-docs.ts
The lib folder no longer exists in a fresh checkout so writing the generated docs there failed.
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* remove the stale web-console/lib exclude from the source assembly
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* add the license header to web-console/AGENTS.md
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* update the list of APIs used by the web console in the README
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* fix the e2e tests to type into the CodeMirror query input
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
* AGENTS.md: describe developing and running e2e tests against a running Druid
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
distribution/src/assembly/source-assembly.xml | 1 -
licenses.yaml | 169 ++++++--
...ast-equals.MIT => @codemirror-autocomplete.MIT} | 10 +-
.../{fast-equals.MIT => @codemirror-commands.MIT} | 10 +-
.../{fast-equals.MIT => @codemirror-language.MIT} | 10 +-
.../{fast-equals.MIT => @codemirror-search.MIT} | 10 +-
.../bin/{fast-equals.MIT => @codemirror-state.MIT} | 10 +-
.../bin/{fast-equals.MIT => @codemirror-view.MIT} | 10 +-
.../bin/{fast-equals.MIT => @lezer-common.MIT} | 10 +-
.../bin/{fast-equals.MIT => @lezer-highlight.MIT} | 10 +-
licenses/bin/{fast-equals.MIT => @lezer-lr.MIT} | 10 +-
...t-equals.MIT => @marijn-find-cluster-break.MIT} | 10 +-
licenses/bin/ace-builds.BSD3 | 24 --
licenses/bin/{react-ace.MIT => crelt.MIT} | 13 +-
licenses/bin/{react-ace.MIT => style-mod.MIT} | 13 +-
licenses/bin/{react-ace.MIT => w3c-keyname.MIT} | 13 +-
pom.xml | 1 +
web-console/.gitignore | 9 +-
web-console/.node-version | 1 +
web-console/AGENTS.md | 104 +++++
web-console/README.md | 181 ++++++++-
web-console/e2e-tests/component/query/overview.ts | 8 +-
.../e2e-tests/component/workbench/overview.ts | 5 +-
web-console/e2e-tests/util/playwright.ts | 6 +
web-console/eslint.config.mjs | 32 +-
web-console/mise.toml | 21 +
web-console/package-lock.json | 301 +++++++++++---
web-console/package.json | 20 +-
web-console/script/build | 7 +
web-console/script/build-grammars.mjs | 91 +++++
web-console/script/build-sql-docs | 2 +-
web-console/script/clean | 5 +-
web-console/script/create-sql-docs.mjs | 113 +++++-
.../script/generate-blueprint-token-overrides.mjs | 13 +-
.../__snapshots__/make-doc-html.spec.ts.snap | 7 -
.../src/ace-completions/sql-completions.spec.ts | 332 ----------------
web-console/src/ace-completions/sql-completions.ts | 369 ------------------
web-console/src/ace-modes/ace-mode-helpers.ts | 45 ---
web-console/src/ace-modes/ace-modes.spec.ts | 75 ----
web-console/src/ace-modes/dsql.ts | 136 -------
web-console/src/ace-modes/hjson.ts | 260 -------------
web-console/src/bootstrap/ace.scss | 130 -------
web-console/src/bootstrap/ace.ts | 28 --
.../__snapshots__/auto-form.spec.tsx.snap | 11 +-
web-console/src/components/code-editor/README.md | 323 +++++++++++++++
.../components/code-editor/code-editor-theme.ts | 223 +++++++++++
.../code-editor/code-editor.scss} | 44 ++-
.../components/code-editor/code-editor.spec.tsx | 78 ++++
.../src/components/code-editor/code-editor.tsx | 396 +++++++++++++++++++
.../components/code-editor/completion-doc.spec.ts | 55 +++
.../src/components/code-editor/completion-doc.ts | 61 +++
.../code-editor/completion-source.spec.ts | 57 +++
.../components/code-editor/completion-source.ts | 109 ++++++
.../components/code-editor/doc-markdown.spec.ts | 61 +++
.../src/components/code-editor/doc-markdown.ts | 76 ++++
.../src/components/code-editor/error-mark.spec.ts | 87 +++++
.../src/components/code-editor/error-mark.ts | 100 +++++
.../src/components/code-editor/search-panel.tsx | 201 ++++++++++
web-console/src/components/index.ts | 1 +
.../__snapshots__/json-input.spec.tsx.snap | 22 +-
.../src/components/json-input/json-input.scss | 4 +
.../src/components/json-input/json-input.spec.tsx | 34 +-
.../src/components/json-input/json-input.tsx | 188 +++++----
.../query-error-pane/query-error-pane.tsx | 10 +-
.../show-json-or-stages/show-json-or-stages.scss | 4 +
.../show-json-or-stages/show-json-or-stages.tsx | 15 +-
.../src/components/show-json/show-json.scss | 4 +
web-console/src/components/show-json/show-json.tsx | 15 +-
web-console/src/console-application.tsx | 2 -
.../edit-context-dialog.spec.tsx.snap | 11 +-
.../__snapshots__/lookup-edit-dialog.spec.tsx.snap | 11 +-
.../dialogs/retention-dialog/retention-dialog.tsx | 2 +-
.../show-value-dialog/show-value-dialog.scss | 3 +-
.../show-value-dialog/show-value-dialog.tsx | 16 +-
.../__snapshots__/spec-dialog.spec.tsx.snap | 20 +-
.../src/dialogs/spec-dialog/spec-dialog.scss | 2 +
.../src/dialogs/spec-dialog/spec-dialog.tsx | 32 +-
.../workbench-query/workbench-query.spec.ts | 10 +-
.../workbench-query/workbench-query.ts | 6 +-
.../editor-completion.ts} | 26 +-
.../hjson-completions.spec.ts | 107 ++---
.../hjson-completions.ts | 78 ++--
.../hjson-context.spec.ts | 432 +++++++++++----------
.../src/editor-completions/hjson-context.ts | 235 +++++++++++
.../src/editor-completions/sql-completions.spec.ts | 358 +++++++++++++++++
.../src/editor-completions/sql-completions.ts | 417 ++++++++++++++++++++
.../editor-languages/dsql-keywords.ts} | 0
web-console/src/editor-languages/dsql-tokens.ts | 74 ++++
web-console/src/editor-languages/dsql.grammar | 96 +++++
web-console/src/editor-languages/dsql.ts | 104 +++++
.../src/editor-languages/editor-languages.spec.ts | 226 +++++++++++
.../editor-tags.ts} | 20 +-
web-console/src/editor-languages/hjson-tokens.ts | 183 +++++++++
web-console/src/editor-languages/hjson.grammar | 95 +++++
web-console/src/editor-languages/hjson.ts | 87 +++++
web-console/src/entry.tsx | 2 -
web-console/src/helpers/capabilities.ts | 2 +-
web-console/src/setup-tests.ts | 2 -
.../src/singletons/ace-editor-state-cache.ts | 46 ---
web-console/src/test-utils/completion-context.ts | 42 ++
web-console/src/test-utils/snapshot-serializer.ts | 32 +-
web-console/src/utils/base64-url.ts | 2 +-
web-console/src/utils/druid-query.spec.ts | 24 +-
web-console/src/utils/druid-query.ts | 26 +-
web-console/src/utils/explain.ts | 2 +-
web-console/src/utils/general.spec.ts | 36 +-
web-console/src/utils/general.tsx | 21 +-
web-console/src/utils/hash-routing.ts | 3 +-
web-console/src/utils/hjson-context.ts | 405 -------------------
web-console/src/utils/index.tsx | 1 -
web-console/src/utils/sampler.ts | 2 +-
web-console/src/utils/sql.spec.ts | 416 ++++++++++----------
web-console/src/utils/sql.ts | 15 +-
.../filter-pane/filter-menu/filter-menu.tsx | 2 +-
.../resource-pane/column-dialog/column-dialog.tsx | 3 +-
.../measure-dialog/measure-dialog.tsx | 3 +-
.../components/resource-pane/resource-pane.tsx | 4 +-
.../components/sql-input/sql-input.tsx | 112 ++----
.../src/views/explore-view/models/measure.ts | 2 +-
.../column-editor/column-editor.tsx | 2 +-
.../expression-editor-dialog.tsx | 2 +-
.../schema-step/schema-step.tsx | 2 +-
.../execution-details-pane.tsx | 3 +-
.../execution-submit-dialog.tsx | 6 +-
.../__snapshots__/explain-dialog.spec.tsx.snap | 20 +-
.../explain-dialog/explain-dialog.scss | 4 +
.../explain-dialog/explain-dialog.spec.tsx | 1 +
.../explain-dialog/explain-dialog.tsx | 18 +-
.../flexible-query-input.spec.tsx.snap | 12 +-
.../flexible-query-input/flexible-query-input.scss | 45 +--
.../flexible-query-input/flexible-query-input.tsx | 332 ++++------------
.../flexible-query-input/sub-query-markers.spec.ts | 76 ++++
.../flexible-query-input/sub-query-markers.ts | 185 +++++++++
.../views/workbench-view/query-tab/query-tab.tsx | 31 +-
.../workbench-history-dialog.tsx | 22 +-
.../src/views/workbench-view/workbench-view.tsx | 5 +-
web-console/tsconfig.json | 1 -
website/.node-version | 1 +
website/README.md | 4 +-
website/mise.toml | 21 +
website/package.json | 4 -
141 files changed, 6081 insertions(+), 3388 deletions(-)
diff --git a/distribution/src/assembly/source-assembly.xml b/distribution/src/assembly/source-assembly.xml
index 01edf601384..eeda995ebf2 100644
--- a/distribution/src/assembly/source-assembly.xml
+++ b/distribution/src/assembly/source-assembly.xml
@@ -93,7 +93,6 @@
<exclude>web-console/node/**</exclude>
<exclude>web-console/resources/**</exclude>
<exclude>web-console/public/**</exclude>
- <exclude>web-console/lib/*.css</exclude>
<exclude>web-console/coordinator-console/**</exclude>
<exclude>web-console/pages/**</exclude>
<exclude>web-console/index.html</exclude>
diff --git a/licenses.yaml b/licenses.yaml
index 9f0d1ad4172..1fe00a863c8 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5441,6 +5441,66 @@ version: 6.3.6
---
+name: "@codemirror/autocomplete"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 6.20.3
+license_file_path: licenses/bin/@codemirror-autocomplete.MIT
+
+---
+
+name: "@codemirror/commands"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 6.11.1
+license_file_path: licenses/bin/@codemirror-commands.MIT
+
+---
+
+name: "@codemirror/language"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 6.12.4
+license_file_path: licenses/bin/@codemirror-language.MIT
+
+---
+
+name: "@codemirror/search"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 6.7.2
+license_file_path: licenses/bin/@codemirror-search.MIT
+
+---
+
+name: "@codemirror/state"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 6.7.6
+license_file_path: licenses/bin/@codemirror-state.MIT
+
+---
+
+name: "@codemirror/view"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 6.43.13
+license_file_path: licenses/bin/@codemirror-view.MIT
+
+---
+
name: "@emotion/babel-plugin"
license_category: binary
module: web-console
@@ -5680,6 +5740,46 @@ license_file_path: licenses/bin/@jridgewell-trace-mapping.MIT
---
+name: "@lezer/common"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 1.5.3
+license_file_path: licenses/bin/@lezer-common.MIT
+
+---
+
+name: "@lezer/highlight"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 1.2.5
+license_file_path: licenses/bin/@lezer-highlight.MIT
+
+---
+
+name: "@lezer/lr"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 1.4.10
+license_file_path: licenses/bin/@lezer-lr.MIT
+
+---
+
+name: "@marijn/find-cluster-break"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 1.0.4
+license_file_path: licenses/bin/@marijn-find-cluster-break.MIT
+
+---
+
name: "@popperjs/core"
license_category: binary
module: web-console
@@ -5759,16 +5859,6 @@ license_file_path: licenses/bin/@types-react.MIT
---
-name: "ace-builds"
-license_category: binary
-module: web-console
-license_name: BSD-3-Clause License
-copyright: Ajax.org B.V.
-version: 1.44.0
-license_file_path: licenses/bin/ace-builds.BSD3
-
----
-
name: "agent-base"
license_category: binary
module: web-console
@@ -5938,6 +6028,16 @@ license_file_path: licenses/bin/cosmiconfig.MIT
---
+name: "crelt"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 1.0.7
+license_file_path: licenses/bin/crelt.MIT
+
+---
+
name: "csstype"
license_category: binary
module: web-console
@@ -6118,15 +6218,6 @@ license_file_path: licenses/bin/delayed-stream.MIT
---
-name: "diff-match-patch"
-license_category: binary
-module: web-console
-license_name: Apache License version 2.0
-copyright: Google
-version: 1.0.5
-
----
-
name: "diff"
license_category: binary
module: web-console
@@ -6245,16 +6336,6 @@ license_file_path: licenses/bin/escape-string-regexp.MIT
---
-name: "fast-equals"
-license_category: binary
-module: web-console
-license_name: MIT License
-copyright: Tony Quetano
-version: 5.4.3
-license_file_path: licenses/bin/fast-equals.MIT
-
----
-
name: "file-saver"
license_category: binary
module: web-console
@@ -6754,16 +6835,6 @@ license_file_path: licenses/bin/proxy-from-env.MIT
---
-name: "react-ace"
-license_category: binary
-module: web-console
-license_name: MIT License
-copyright: James Hrisho
-version: 15.0.0
-license_file_path: licenses/bin/react-ace.MIT
-
----
-
name: "react-day-picker"
license_category: binary
module: web-console
@@ -6934,6 +7005,16 @@ license_file_path: licenses/bin/source-map.BSD3
---
+name: "style-mod"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 4.1.4
+license_file_path: licenses/bin/style-mod.MIT
+
+---
+
name: "stylis"
license_category: binary
module: web-console
@@ -7014,6 +7095,16 @@ license_file_path: licenses/bin/uuid.MIT
---
+name: "w3c-keyname"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Marijn Haverbeke
+version: 2.2.8
+license_file_path: licenses/bin/w3c-keyname.MIT
+
+---
+
name: "warning"
license_category: binary
module: web-console
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@codemirror-autocomplete.MIT
similarity index 84%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@codemirror-autocomplete.MIT
index a90404fced8..9a91f48619f 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@codemirror-autocomplete.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018-2021 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@codemirror-commands.MIT
similarity index 84%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@codemirror-commands.MIT
index a90404fced8..9a91f48619f 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@codemirror-commands.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018-2021 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@codemirror-language.MIT
similarity index 84%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@codemirror-language.MIT
index a90404fced8..9a91f48619f 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@codemirror-language.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018-2021 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@codemirror-search.MIT
similarity index 84%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@codemirror-search.MIT
index a90404fced8..9a91f48619f 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@codemirror-search.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018-2021 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@codemirror-state.MIT
similarity index 84%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@codemirror-state.MIT
index a90404fced8..9a91f48619f 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@codemirror-state.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018-2021 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@codemirror-view.MIT
similarity index 84%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@codemirror-view.MIT
index a90404fced8..9a91f48619f 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@codemirror-view.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018-2021 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@lezer-common.MIT
similarity index 85%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@lezer-common.MIT
index a90404fced8..c21df7e44f0 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@lezer-common.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@lezer-highlight.MIT
similarity index 85%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@lezer-highlight.MIT
index a90404fced8..c21df7e44f0 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@lezer-highlight.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@lezer-lr.MIT
similarity index 85%
copy from licenses/bin/fast-equals.MIT
copy to licenses/bin/@lezer-lr.MIT
index a90404fced8..c21df7e44f0 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@lezer-lr.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2018 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/@marijn-find-cluster-break.MIT
similarity index 86%
rename from licenses/bin/fast-equals.MIT
rename to licenses/bin/@marijn-find-cluster-break.MIT
index a90404fced8..0f141f5d1c2 100644
--- a/licenses/bin/fast-equals.MIT
+++ b/licenses/bin/@marijn-find-cluster-break.MIT
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Tony Quetano
+Copyright (C) 2024 by Marijn Haverbeke <ma...@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/ace-builds.BSD3 b/licenses/bin/ace-builds.BSD3
deleted file mode 100644
index 4760be2a622..00000000000
--- a/licenses/bin/ace-builds.BSD3
+++ /dev/null
@@ -1,24 +0,0 @@
-Copyright (c) 2010, Ajax.org B.V.
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
- * Redistributions of source code must retain the above copyright
- notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above copyright
- notice, this list of conditions and the following disclaimer in the
- documentation and/or other materials provided with the distribution.
- * Neither the name of Ajax.org B.V. nor the
- names of its contributors may be used to endorse or promote products
- derived from this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
-ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY
-DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
-ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/licenses/bin/react-ace.MIT b/licenses/bin/crelt.MIT
similarity index 86%
copy from licenses/bin/react-ace.MIT
copy to licenses/bin/crelt.MIT
index 3010ee6d923..c5958659813 100644
--- a/licenses/bin/react-ace.MIT
+++ b/licenses/bin/crelt.MIT
@@ -1,6 +1,4 @@
-The MIT License (MIT)
-
-Copyright (c) 2014 James Hrisho
+Copyright (C) 2020 by Marijn Haverbeke <ma...@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,14 +7,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/react-ace.MIT b/licenses/bin/style-mod.MIT
similarity index 85%
copy from licenses/bin/react-ace.MIT
copy to licenses/bin/style-mod.MIT
index 3010ee6d923..f1f221a6053 100644
--- a/licenses/bin/react-ace.MIT
+++ b/licenses/bin/style-mod.MIT
@@ -1,6 +1,4 @@
-The MIT License (MIT)
-
-Copyright (c) 2014 James Hrisho
+Copyright (C) 2018 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,14 +7,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/licenses/bin/react-ace.MIT b/licenses/bin/w3c-keyname.MIT
similarity index 85%
rename from licenses/bin/react-ace.MIT
rename to licenses/bin/w3c-keyname.MIT
index 3010ee6d923..17da88ae485 100644
--- a/licenses/bin/react-ace.MIT
+++ b/licenses/bin/w3c-keyname.MIT
@@ -1,6 +1,4 @@
-The MIT License (MIT)
-
-Copyright (c) 2014 James Hrisho
+Copyright (C) 2016 by Marijn Haverbeke <ma...@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,14 +7,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/pom.xml b/pom.xml
index efd4ea828ae..f0fc85285d2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2415,6 +2415,7 @@
<exclude>.asf.yaml</exclude>
<exclude>**/dependency-reduced-pom.xml</exclude>
<exclude>**/.editorconfig</exclude>
+ <exclude>**/.node-version</exclude>
<exclude>**/node_modules/**</exclude>
<exclude>**/public/**</exclude>
<exclude>**/hadoop.indexer.libs.version</exclude>
diff --git a/web-console/.gitignore b/web-console/.gitignore
index e8279460795..940182a3e40 100644
--- a/web-console/.gitignore
+++ b/web-console/.gitignore
@@ -3,16 +3,15 @@ node_modules/
resources/
public/
private/
-lib/*.css
coverage/
.claude/
-CLAUDE.md
coordinator-console/
pages/
/index.html
-lib/sql-docs.*
+# Generated by script/create-sql-docs.mjs
+src/editor-languages/dsql-docs.ts
.tscache
tscommand-*.tmp.txt
@@ -22,3 +21,7 @@ licenses.json
# Screenshots the e2e tests write on failure
/*.jpeg
+
+# Generated by script/build-grammars.mjs
+src/editor-languages/*.parser.ts
+src/editor-languages/*.parser.terms.ts
diff --git a/web-console/.node-version b/web-console/.node-version
new file mode 100644
index 00000000000..df6ae3370f9
--- /dev/null
+++ b/web-console/.node-version
@@ -0,0 +1 @@
+24.21.0
diff --git a/web-console/AGENTS.md b/web-console/AGENTS.md
new file mode 100644
index 00000000000..ef2cf1821f3
--- /dev/null
+++ b/web-console/AGENTS.md
@@ -0,0 +1,104 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ -->
+
+# AGENTS.md
+
+The Apache Druid web console: a React + TypeScript single page app built with Blueprint.js, served by the Druid router.
+`README.md` has the setup and the directory layout.
+
+## Commands
+
+- **Node** is pinned in `.node-version` and installed with mise (`mise install`). Don't change the global Node version.
+- **Generated sources**: `src/editor-languages/dsql-docs.ts` and `src/editor-languages/*.parser*.ts` are generated and gitignored.
+ `npm run compile` (`script/build`) makes them, and only rebuilds what is out of date. Run it after a fresh clone, after
+ changing a `.grammar` file or the SQL docs, or when typecheck can't find those files. Without
+ `public/web-console-<version>.js` it also builds the whole console bundle, which is slow.
+- **Dev server**: `npm start` serves on http://localhost:18081 and proxies API calls to Druid on `localhost:8888`
+ (`druid_host=host:port npm start` for another one). Agents run their own on :18082 instead, see below. Views are
+ hash routes, like `/unified-console.html#workbench`.
+- **Checks**:
+ - `npx jest src/path/to/file.spec.ts` runs some specs (add `-u` to update their snapshots), `npm run jest` all of
+ them.
+ - `npm run typecheck`, `npm run eslint`, `npm run sasslint`, `npm run prettify-check`.
+ - `npm run test-unit` runs everything above (and the generators) without building the bundle. `npm test` builds the
+ bundle first.
+ - `npm run autofix` fixes ESLint, stylelint and Prettier issues.
+- **End-to-end tests** (`e2e-tests/`, Playwright) need a running Druid: `script/druid build && script/druid start`,
+ then `npm run test-e2e`. See the README for running one test or with a visible browser, and below for running them
+ against your own changes.
+
+## Developing against a running Druid
+
+- **Druid on :8888**: expect an up to date Druid (a cluster built with all the relevant changes) to be running on
+ `localhost:8888`. Its router serves the console that was bundled when Druid was built, not the code in the working
+ tree.
+- **Your own dev server on :18082**: the user likely has their own dev server on :18081, so don't use, restart or stop
+ it. Start one you manage on :18082 with `npx webpack serve --port 18082 --no-open` (it proxies to :8888 like
+ `npm start`), wait for `compiled successfully`, and note its PID. When done, stop only the processes you started
+ (`npx` spawns `npm exec` and then `webpack`, so stop the children too).
+- **End-to-end tests against your changes**: `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT=18082 npm run test-e2e`. Without
+ it the tests run against :8888 and so test the console bundled with Druid, not your changes. The full suite takes
+ several minutes and prints a lot, so send its output to a file.
+- **Comparing with :8888**: it is always fine to open the console on :18082 and on :8888 in a browser and compare them,
+ to check that something you don't expect to change has stayed the same.
+
+## Where things are
+
+- `src/views/` - one folder per top-level view (`workbench-view` is the SQL editor, `explore-view`,
+ `load-data-view`, `datasources-view`, ...), `src/dialogs/`, `src/components/` - the UI.
+- `src/druid-models/` - TypeScript models of Druid concepts (ingestion specs, tasks, queries) and their `AutoForm`
+ field definitions and JSON completions.
+- `src/singletons/` - `Api.instance` (the axios client for Druid, which turns error responses into readable
+ messages), `AppToaster`, caches. `src/helpers/capabilities.ts` - what the cluster supports (SQL, proxy, ...).
+- `src/utils/` - general helpers, `QueryManager` (async work with loading, error and cancellation states; use it from
+ components with `useQueryManager` in `src/hooks/`), hash routing, local storage keys.
+- `src/components/code-editor/` - the CodeMirror based `CodeEditor`. Read its `README.md` before changing the editor,
+ the languages (`src/editor-languages/`: Lezer grammars for DruidSQL and Hjson) or the completions
+ (`src/editor-completions/`).
+- SQL is built and parsed with `druid-query-toolkit` (`SqlQuery`, `C`, `F`, `T`, `L`, `N`, ...) rather than with
+ strings.
+
+## Conventions
+
+- **License header**: every source file (`.ts`, `.tsx`, `.scss`, `.mjs`, and `.md` as an HTML comment) starts with the
+ Apache 2.0 header; copy it from a neighboring file of the same type. ESLint checks it in TypeScript files.
+- **Components** live in `component-name/component-name.tsx` with their `.scss` and `.spec.tsx` next to them, and are
+ exported from `src/components/index.ts` (likewise for dialogs, views, utils, hooks, druid-models). Import from those
+ barrels (`../../components`, `../../utils`).
+- **Snapshots**: many specs snapshot the DOM. The serializer (`src/test-utils/snapshot-serializer.ts`) reduces icons to
+ their name and each code editor to a comment (language, value, placeholder), so only real changes show up.
+- **Forms** for Druid specs are usually an `AutoForm` driven by `Field` definitions in `src/druid-models/`, not
+ hand-written inputs.
+- **Styling**: Blueprint components first. In SCSS, prefer the variables (`@use '../../variables' as *`, which has the
+ Blueprint ones) over literal colors and sizes.
+
+### Tooltips: use `data-tooltip`
+For a plain text hint on hover, put a `data-tooltip` attribute on the element. This is the preferred way to add a
+tooltip in the console, use it rather than Blueprint's `Tooltip` or a `title` attribute.
+- It shows instantly and follows the mouse. `initMouseTooltip` (`src/utils/mouse-tooltip/`, called once in
+ `src/entry.tsx`) listens on the document and shows the `data-tooltip` of the nearest element under the mouse (or its
+ closest ancestor that has one), so nothing needs to be wrapped or imported.
+- `\n` makes a new line (the tooltip is `white-space: pre-wrap`); `undefined` (or an empty string) means no tooltip.
+- It works on anything that ends up in the DOM: plain elements, Blueprint components that pass props through
+ (`<Button icon={IconNames.MORE} data-tooltip="More column options" />`, `<Icon ... data-tooltip="Experimental" />`),
+ and DOM that React does not render, like CodeMirror decorations (`attributes: { 'data-tooltip': message }` in
+ `src/components/code-editor/error-mark.ts`).
+- Examples: `click-to-copy.tsx` (`` data-tooltip={`Click to copy:\n${text}`} ``), `segment-timeline.tsx`,
+ `execution-stages-pane.tsx`, `resource-pane.tsx`, `header-bar.tsx`, `filter-pane.tsx`.
+- Use a Blueprint `Popover` (not a tooltip) only when the content is rich or interactive.
diff --git a/web-console/README.md b/web-console/README.md
index 94fba2c1630..baa34e74bc6 100644
--- a/web-console/README.md
+++ b/web-console/README.md
@@ -26,9 +26,10 @@ This is the Druid web console that serves as a data management interface for Dru
### Getting started
1. You need to be within the `web-console` directory
-2. Install the modules with `npm install`
-3. Run `npm run compile` to compile the SCSS files (this usually needs to be done only once)
-4. Run `npm start` to start in development mode and proxy Druid requests to `localhost:8888`
+2. Install [mise](https://mise.jdx.dev/getting-started.html) and run `mise trust` and `mise install` to get the correct Node.js version (pinned in `.node-version`)
+3. Install the modules with `npm install`
+4. Run `npm run compile` to compile the SCSS files (this usually needs to be done only once)
+5. Run `npm start` to start in development mode and proxy Druid requests to `localhost:8888`
**Note:** you can provide an environment variable to proxy to a different Druid host like so: `druid_host=1.2.3.4:8888 npm start`
**Note:** you can provide an environment variable to use webpack-bundle-analyzer as a plugin in the build script like so: `BUNDLE_ANALYZER_PLUGIN='TRUE' npm start`
@@ -142,23 +143,161 @@ As part of this directory:
- `assets/` - The images (and other assets) used within the console
- `e2e-tests/` - End-to-end tests for the console
-- `lib/` - A place where keywords and generated docs live.
- `public/` - The compiled destination for the files powering this console
- `script/` - Some helper bash scripts for running this console
-- `src/` - This directory (together with `lib`) constitutes all the source code for this console
-
-## List of non SQL data reading APIs used
-
-```
-GET /status
-GET /druid/indexer/v1/supervisor?full
-POST /druid/indexer/v1/worker
-GET /druid/indexer/v1/workers
-GET /druid/indexer/v1/tasks
-GET /druid/coordinator/v1/loadqueue?simple
-GET /druid/coordinator/v1/config
-GET /druid/coordinator/v1/metadata/datasources?includeUnused
-GET /druid/coordinator/v1/rules
-GET /druid/coordinator/v1/config/compaction
-GET /druid/coordinator/v1/tiers
-```
+- `src/` - This directory constitutes all the source code for this console
+
+## List of APIs used
+
+All requests are made relative to the service the console is served from (normally the Router, which forwards
+`/druid/coordinator/*` and `/druid/indexer/*` to the Coordinator and Overlord via the management proxy).
+Path parameters are shown in `{braces}`.
+
+### APIs used in normal operation
+
+#### Capability detection (on console load)
+
+| API | Used for | Where |
+|-------------------------------------------------------|----------------------------------------------------------------------------|-------------|
+| `POST /druid/v2/sql?capabilities` | Probing whether the SQL endpoint is available | Console load |
+| `GET /status?capabilities` | Probing that the service is reachable at all (when the SQL probe fails) | Console load |
+| `POST /druid/v2?capabilities` | Probing whether native queries work when SQL does not | Console load |
+| `GET /proxy/enabled?capabilities` | Detecting whether the Router management proxy is enabled | Console load |
+| `GET /druid/coordinator/v1/isLeader?capabilities` | Detecting a Coordinator when the console is not served from the Router | Console load |
+| `GET /druid/indexer/v1/isLeader?capabilities` | Detecting an Overlord when the console is not served from the Router | Console load |
+| `GET /druid/v2/sql/task/enabled?capabilities` | Detecting whether the MSQ task engine is available | Console load |
+| `GET /druid/v2/sql/engines?capabilities` | Detecting whether the MSQ Dart engine is available | Console load |
+| `POST /druid/v2/sql?capabilities-functions` | Listing available SQL functions (for autocomplete and docs) | Console load |
+| `GET /druid/indexer/v1/totalWorkerCapacity` | Cluster task slot capacity | Console load, Home (Tasks card), Query view and SQL data loader (stuck-stage warnings) |
+
+#### Status and cluster health
+
+| API | Used for | Where |
+|----------------------------------------------|-------------------------------------------------------------|----------------------------------------|
+| `GET /status` | Druid version and loaded extensions | Home (Status card and Status dialog), Query view (query detail archive download) |
+| `GET /status/properties` | Runtime properties of the service the console is served from | Doctor dialog |
+| `GET /proxy/coordinator/status` | Coordinator version check | Doctor dialog |
+| `GET /proxy/coordinator/status/properties` | Coordinator runtime properties check | Doctor dialog |
+| `GET /proxy/overlord/status` | Overlord version check; list of extensions loaded on the Overlord | Doctor dialog, Load data view |
+| `GET /proxy/overlord/status/properties` | Overlord runtime properties check | Doctor dialog |
+
+#### Queries
+
+| API | Used for | Where |
+|-------------------------------------------------------------|------------------------------------------------------------------------------|-------|
+| `POST /druid/v2/sql` | Running SQL queries (including all `sys.*` table queries that power the views) | Everywhere: Query view, Explore view, all table views and Home cards, preview panes, Doctor dialog |
+| `POST /druid/v2` | Running native queries | Query view (native engine), Load data view (reindexing from a Druid datasource) |
+| `DELETE /druid/v2/sql/{queryId}` | Cancelling a running SQL query | Query view (native and Dart engines, current Dart queries panel) |
+| `DELETE /druid/v2/{queryId}` | Cancelling a running native query | Query view |
+| `GET /druid/v2/sql/queries?includeComplete` | Listing current and recent Dart queries | Query view (current Dart queries panel) |
+| `GET /druid/v2/sql/queries/{queryId}/reports` | Query report / execution details of a Dart query | Query view |
+| `POST /druid/v2/sql/task` | Explaining an MSQ query | Query view (Explain dialog) |
+| `POST /druid/v2/sql/statements` | Submitting an MSQ task query | Query view, SQL data loader |
+| `GET /druid/v2/sql/statements/{queryId}` | Status, detail, and result pages of an MSQ query | Query view, SQL data loader |
+| `GET /druid/v2/sql/statements/{queryId}/results` | Downloading the results of an MSQ query | Query view (destination pages) |
+| `GET /druid/indexer/v1/task/{taskId}/reports` | Live reports (stages, counters) of an MSQ task | Query view, SQL data loader |
+| `GET /druid/indexer/v1/task/{taskId}` | Payload of an MSQ task | Query view, SQL data loader |
+| `POST /druid/indexer/v1/task/{taskId}/shutdown` | Cancelling an MSQ task query | Query view, SQL data loader |
+
+#### Datasources, segments, retention, and compaction
+
+| API | Used for | Where |
+|---------------------------------------------------------------------------|--------------------------------------------------------------|-------|
+| `GET /druid/coordinator/v1/datasources` | Checking which datasources already exist | Load data view |
+| `GET /druid/coordinator/v1/metadata/datasources?includeUnused` | Listing datasources that only have unused segments | Datasources view ("Show unused") |
+| `GET /druid/coordinator/v1/rules` | Load rules for all datasources (and the cluster defaults) | Datasources view (Retention column), Segment timeline (Datasources and Segments views) |
+| `POST /druid/coordinator/v1/rules/{datasource}` | Saving the load rules of a datasource | Datasources view (Retention dialog) |
+| `GET /druid/coordinator/v1/rules/{datasource}/history?count=200` | Load rule change history | Datasources view (Retention dialog) |
+| `DELETE /druid/coordinator/v1/datasources/{datasource}/intervals/{interval}` | Permanently deleting (killing) unused segments | Datasources view (Kill dialog) |
+| `POST /druid/indexer/v1/datasources/{datasource}` | Marking all segments of a datasource as used | Datasources view |
+| `DELETE /druid/indexer/v1/datasources/{datasource}` | Marking all segments of a datasource as unused | Datasources view |
+| `POST /druid/indexer/v1/datasources/{datasource}/markUsed` | Marking segments in an interval as used | Datasources view |
+| `POST /druid/indexer/v1/datasources/{datasource}/markUnused` | Marking segments in an interval as unused | Datasources view |
+| `DELETE /druid/indexer/v1/datasources/{datasource}/segments/{segmentId}` | Marking a single segment as unused (drop) | Segments view |
+| `GET /druid/coordinator/v1/metadata/datasources/{datasource}/segments/{segmentId}` | Segment metadata | Segments view (segment detail dialog) |
+| `GET /druid/indexer/v1/compaction/config/datasources` | Auto-compaction configs of all datasources | Datasources view (Compaction column), Doctor dialog |
+| `POST /druid/indexer/v1/compaction/config/datasources/{datasource}` | Saving the auto-compaction config of a datasource | Datasources view (Compaction dialog) |
+| `DELETE /druid/indexer/v1/compaction/config/datasources/{datasource}` | Deleting the auto-compaction config of a datasource | Datasources view (Compaction dialog) |
+| `GET /druid/indexer/v1/compaction/config/datasources/{datasource}/history?count=20` | Auto-compaction config change history | Datasources view (Compaction dialog) |
+| `GET /druid/indexer/v1/compaction/status/datasources` | Auto-compaction progress ("% Compacted", "Left to be compacted") | Datasources view |
+| `POST /druid/coordinator/v1/compaction/compact` | Forcing a compaction run | Datasources view |
+| `GET /druid/indexer/v1/compaction/config/cluster` | Cluster compaction dynamic config | Header bar (Compaction dynamic config dialog) |
+| `POST /druid/indexer/v1/compaction/config/cluster` | Saving the cluster compaction dynamic config | Header bar (Compaction dynamic config dialog) |
+
+#### Ingestion: tasks and supervisors
+
+| API | Used for | Where |
+|---------------------------------------------------------------------------|--------------------------------------------------------------|-------|
+| `POST /druid/indexer/v1/sampler?for={step}` | Sampling data for each step of the data loader | Load data view, Doctor dialog |
+| `POST /druid/indexer/v1/task` | Submitting a task | Load data view, Tasks view |
+| `GET /druid/indexer/v1/task/{taskId}` | Task payload | Tasks view (task dialog), Load data view (editing an existing task's spec) |
+| `GET /druid/indexer/v1/task/{taskId}/status` | Task status | Tasks view (task dialog) |
+| `GET /druid/indexer/v1/task/{taskId}/reports` | Task reports | Tasks view (task dialog) |
+| `GET /druid/indexer/v1/task/{taskId}/log` | Task log (tailed) | Tasks view (task dialog) |
+| `POST /druid/indexer/v1/task/{taskId}/shutdown` | Killing a task | Tasks view |
+| `GET /druid/indexer/v1/supervisor` | Listing supervisor IDs | Query view (Supervisor to SQL dialog) |
+| `POST /druid/indexer/v1/supervisor` | Submitting (creating or updating) a supervisor | Load data view, Supervisors view |
+| `GET /druid/indexer/v1/supervisor/{supervisorId}` | Supervisor spec | Supervisors view (supervisor dialog), Load data view (editing an existing supervisor), Query view (Supervisor to SQL dialog) |
+| `GET /druid/indexer/v1/supervisor/{supervisorId}/status` | Supervisor status (running tasks, details, recent errors, partition offsets) | Supervisors view (table, supervisor dialog, Reset offsets dialog) |
+| `GET /druid/indexer/v1/supervisor/{supervisorId}/stats` | Supervisor ingestion stats | Supervisors view (Stats column, supervisor dialog) |
+| `GET /druid/indexer/v1/supervisor/{supervisorId}/history?count=100` | Supervisor spec history | Supervisors view (supervisor dialog) |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/autoscaler/simulate` | Simulating the auto-scaler | Supervisors view (supervisor dialog) |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/resume` | Resuming a supervisor | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/suspend` | Suspending a supervisor | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/reset` | Hard resetting a supervisor | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/resetOffsets` | Resetting specific partition offsets | Supervisors view (Reset offsets dialog) |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/resetToLatestAndBackfill` | Resetting to latest offsets and backfilling | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/taskGroups/handoff` | Triggering early handoff of task groups | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/{supervisorId}/terminate` | Terminating a supervisor | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/resumeAll` | Resuming all supervisors | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/suspendAll` | Suspending all supervisors | Supervisors view |
+| `POST /druid/indexer/v1/supervisor/terminateAll` | Terminating all supervisors | Supervisors view |
+
+#### Services and dynamic configs
+
+| API | Used for | Where |
+|-------------------------------------------------------------|--------------------------------------------------------------|-------|
+| `GET /druid/coordinator/v1/loadqueue?simple` | Segment load/drop queue per Historical | Services view (Detail column) |
+| `GET /druid/coordinator/v1/config/cloneStatus` | Historical cloning status | Services view (Detail column) |
+| `GET /druid/coordinator/v1/config` | Coordinator dynamic config (turbo loading and decommissioning nodes) | Services view (Detail column), Header bar (Coordinator dynamic config dialog) |
+| `POST /druid/coordinator/v1/config` | Saving the Coordinator dynamic config | Header bar (Coordinator dynamic config dialog) |
+| `GET /druid/coordinator/v1/config/history?count=100` | Coordinator dynamic config history | Header bar (Coordinator dynamic config dialog) |
+| `GET /druid/coordinator/v1/broker/config` | Broker dynamic config | Header bar (Broker dynamic config dialog) |
+| `POST /druid/coordinator/v1/broker/config` | Saving the Broker dynamic config | Header bar (Broker dynamic config dialog) |
+| `GET /druid/coordinator/v1/broker/config/history?count=100` | Broker dynamic config history | Header bar (Broker dynamic config dialog) |
+| `GET /druid/indexer/v1/workers` | Middle Manager / Indexer worker info (capacity, enabled state) | Services view |
+| `POST /druid/indexer/v1/worker/{host}/enable` | Enabling a Middle Manager | Services view |
+| `POST /druid/indexer/v1/worker/{host}/disable` | Disabling a Middle Manager | Services view |
+| `GET /druid/indexer/v1/worker` | Overlord dynamic config | Header bar (Overlord dynamic config dialog) |
+| `POST /druid/indexer/v1/worker` | Saving the Overlord dynamic config | Header bar (Overlord dynamic config dialog) |
+| `GET /druid/indexer/v1/worker/history?count=100` | Overlord dynamic config history | Header bar (Overlord dynamic config dialog) |
+
+#### Lookups
+
+| API | Used for | Where |
+|----------------------------------------------------------------|--------------------------------------------|-------|
+| `GET /druid/coordinator/v1/lookups/config?discover=true` | Discovering lookup tiers | Lookups view |
+| `GET /druid/coordinator/v1/lookups/config/all` | All lookup specs | Lookups view |
+| `GET /druid/coordinator/v1/lookups/status` | Checking whether lookups are initialized | Home (Lookups card) |
+| `POST /druid/coordinator/v1/lookups/config` | Initializing lookups (empty body) and creating a lookup | Lookups view |
+| `POST /druid/coordinator/v1/lookups/config/{tier}/{lookupId}` | Updating a lookup | Lookups view |
+| `DELETE /druid/coordinator/v1/lookups/config/{tier}/{lookupId}` | Deleting a lookup | Lookups view |
+
+### Fallback APIs used when SQL is not available
+
+These APIs are only called when the console detects that the SQL endpoint is not available (for example, when it is
+served directly from the Coordinator or Overlord rather than the Router). They stand in for queries against the
+`sys.*` and `INFORMATION_SCHEMA` tables and generally provide less information.
+
+| API | Replaces | Used for | Where |
+|------------------------------------------------------------------------------------------|-----------------------------|-------------------------------------------------------------|-------|
+| `GET /druid/coordinator/v1/datasources` | `INFORMATION_SCHEMA.TABLES` | Listing datasources | Home (Datasources card), Segment timeline (Datasources and Segments views) |
+| `GET /druid/coordinator/v1/datasources?simple` | `sys.segments` | Datasource list with segment counts and sizes; total available segments | Datasources view, Home (Segments card) |
+| `GET /druid/coordinator/v1/loadstatus?simple` | `sys.segments` | Number of segments left to load per datasource | Datasources view, Home (Segments card) |
+| `GET /druid/coordinator/v1/metadata/datasources` | `sys.segments` | Resolving the datasource filter | Segments view |
+| `GET /druid/coordinator/v1/metadata/segments?includeOvershadowedStatus&includeRealtimeSegments` | `sys.segments` | Segment list; segment intervals for the timeline | Segments view, Segment timeline (Datasources and Segments views) |
+| `GET /druid/coordinator/v1/servers?simple` | `sys.servers` | Service list; Historical tiers; Peon count | Services view, Datasources view (Retention dialog), Home (Services card) |
+| `GET /druid/coordinator/v1/cluster` | `sys.servers` | Service counts by role | Home (Services card) |
+| `GET /druid/indexer/v1/supervisor?full` | `sys.supervisors` | Supervisor list | Supervisors view, Home (Supervisors card) |
+| `GET /druid/indexer/v1/tasks` | `sys.tasks` | Task list; task counts by status | Tasks view, Home (Tasks card) |
+| `GET /druid/indexer/v1/tasks?state=running` | `sys.tasks` | Running task counts per datasource | Datasources view (Running tasks column) |
diff --git a/web-console/e2e-tests/component/query/overview.ts b/web-console/e2e-tests/component/query/overview.ts
index 25ea8e46b8d..541941d18bd 100644
--- a/web-console/e2e-tests/component/query/overview.ts
+++ b/web-console/e2e-tests/component/query/overview.ts
@@ -18,7 +18,7 @@
import type * as playwright from 'playwright-chromium';
-import { clickButton, clickText } from '../../util/playwright';
+import { clickButton, clickText, setQueryInput } from '../../util/playwright';
import { extractTable } from '../../util/table';
/**
@@ -37,8 +37,7 @@ export class QueryOverview {
await this.page.goto(this.baseUrl);
await this.page.reload({ waitUntil: 'networkidle' });
- const input = await this.page.waitForSelector('div.flexible-query-input textarea');
- await input.fill(query);
+ await setQueryInput(this.page, query);
await clickButton(this.page, 'Run');
await this.page.waitForSelector('div.result-table-pane');
@@ -50,8 +49,7 @@ export class QueryOverview {
await this.page.goto(this.baseUrl);
await this.page.reload({ waitUntil: 'networkidle' });
- const input = await this.page.waitForSelector('div.flexible-query-input textarea');
- await input.fill(query);
+ await setQueryInput(this.page, query);
await Promise.all([
this.page.waitForRequest(
diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts
index 1cdc0187a52..fc19a40812a 100644
--- a/web-console/e2e-tests/component/workbench/overview.ts
+++ b/web-console/e2e-tests/component/workbench/overview.ts
@@ -18,7 +18,7 @@
import type * as playwright from 'playwright-chromium';
-import { clickButton } from '../../util/playwright';
+import { clickButton, setQueryInput } from '../../util/playwright';
import { extractTable } from '../../util/table';
/**
@@ -37,8 +37,7 @@ export class WorkbenchOverview {
await this.page.goto(this.baseUrl);
await this.page.reload({ waitUntil: 'networkidle' });
- const input = await this.page.waitForSelector('div.flexible-query-input textarea');
- await input.fill(query);
+ await setQueryInput(this.page, query);
await clickButton(this.page, 'Run');
const results = this.page.locator('div.result-table-pane');
diff --git a/web-console/e2e-tests/util/playwright.ts b/web-console/e2e-tests/util/playwright.ts
index 4fab8c5925c..efd79764b5a 100644
--- a/web-console/e2e-tests/util/playwright.ts
+++ b/web-console/e2e-tests/util/playwright.ts
@@ -108,6 +108,12 @@ export async function setInput(
await input.fill(value);
}
+export async function setQueryInput(page: playwright.Page, value: string): Promise<void> {
+ // The query input is a CodeMirror editor, its editable surface is a contenteditable div
+ const editor = await page.waitForSelector('div.flexible-query-input .cm-content');
+ await editor.fill(value);
+}
+
function buttonSelector(text: string) {
return `//button/*[contains(text(),"${text}")]`;
}
diff --git a/web-console/eslint.config.mjs b/web-console/eslint.config.mjs
index 5236037b079..b5739a2d82a 100644
--- a/web-console/eslint.config.mjs
+++ b/web-console/eslint.config.mjs
@@ -19,13 +19,20 @@
import awesomeCodeStyle, { configs } from '@awesome-code-style/eslint-config';
import awesomeCodeStyleReact, { reactConfigs } from '@awesome-code-style/eslint-config/react';
import notice from 'eslint-plugin-notice';
+import regexp from 'eslint-plugin-regexp';
import globals from 'globals';
const TYPESCRIPT_FILES = ['**/*.ts', '**/*.tsx'];
export default [
{
- ignores: ['public', 'target'],
+ // The parsers generated from the grammars by script/build-grammars.mjs
+ ignores: [
+ 'public',
+ 'target',
+ 'src/editor-languages/*.parser.ts',
+ 'src/editor-languages/*.parser.terms.ts',
+ ],
},
...awesomeCodeStyle,
...awesomeCodeStyleReact,
@@ -48,7 +55,28 @@ export default [
},
},
{
- files: ['*.js', 'lib/*.js', 'script/*.js'],
+ // Local versions of the CodeQL checks run on the PRs
+ plugins: {
+ regexp,
+ },
+ rules: {
+ // CodeQL: Inefficient regular expression / Polynomial regular expression used on uncontrolled data
+ 'regexp/no-super-linear-backtracking': [2],
+ 'regexp/no-super-linear-move': [2],
+ // CodeQL: Incomplete string escaping or encoding (a string pattern only replaces the first occurrence)
+ 'no-restricted-syntax': [
+ 2,
+ {
+ selector:
+ "CallExpression[callee.property.name='replace'][arguments.0.value=/^[\\\\'\"`<>&|]$/]",
+ message:
+ 'Replacing a string only replaces the first occurrence, use replaceAll or a regular expression (with the g flag if all occurrences should be replaced)',
+ },
+ ],
+ },
+ },
+ {
+ files: ['*.js', 'script/*.js'],
languageOptions: {
globals: globals.node,
},
diff --git a/web-console/mise.toml b/web-console/mise.toml
new file mode 100644
index 00000000000..1ab888d1cfb
--- /dev/null
+++ b/web-console/mise.toml
@@ -0,0 +1,21 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+[settings]
+idiomatic_version_file_enable_tools = ["node"]
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 7d581a6b6c8..b27cefd9a46 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -13,11 +13,19 @@
"@blueprintjs/datetime": "^6.2.6",
"@blueprintjs/icons": "^6.14.1",
"@blueprintjs/select": "^6.3.6",
+ "@codemirror/autocomplete": "^6.20.3",
+ "@codemirror/commands": "^6.11.1",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/search": "^6.7.2",
+ "@codemirror/state": "^6.7.6",
+ "@codemirror/view": "^6.43.13",
"@flatten-js/interval-tree": "^2.0.3",
"@fontsource/open-sans": "^5.0.30",
"@internationalized/date": "^3.5.6",
+ "@lezer/common": "^1.5.3",
+ "@lezer/highlight": "^1.2.5",
+ "@lezer/lr": "^1.4.10",
"@tanstack/react-table": "^9.2.4",
- "ace-builds": "~1.44.0",
"axios": "^1.18.0",
"chronoshift": "^1.2.3",
"classnames": "^2.2.6",
@@ -41,7 +49,6 @@
"memoize-one": "^6.0.0",
"numeral": "^2.0.6",
"react": "^19.3.0",
- "react-ace": "^15.0.0",
"react-diff-viewer-continued": "~4.0.6",
"react-dom": "^19.3.0",
"tslib": "^2.8.0",
@@ -52,6 +59,7 @@
"@awesome-code-style/eslint-config": "^6.2.1",
"@awesome-code-style/prettier-config": "^5.0.2",
"@awesome-code-style/stylelint-config": "^6.0.2",
+ "@lezer/generator": "^1.8.1",
"@lizenz/checker": "^0.0.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1",
@@ -79,6 +87,7 @@
"eslint-plugin-notice": "^1.0.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-regexp": "^3.3.1",
"globals": "^17.12.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.5.2",
@@ -90,7 +99,6 @@
"prettier": "^3.9.9",
"sass": "^1.99.0",
"sass-loader": "^17.0.1",
- "snarkdown": "^2.0.0",
"style-loader": "^4.0.0",
"stylelint": "^16.26.1",
"ts-jest": "^29.4.13",
@@ -879,6 +887,76 @@
"@keyv/serialize": "^1.1.1"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.11.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
+ "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
+ "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
+ "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.13",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
+ "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@csstools/cascade-layer-name-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-3.0.1.tgz",
@@ -6249,6 +6327,44 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@lezer/common": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
+ "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/generator": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.8.1.tgz",
+ "integrity": "sha512-7q7szXqT7EPTs6SODBl58Qoz/4OrhNvm5pW4lbu1ZDnG5e0nn7drLzeN9Nxkm8j/1iMs5QZ9j3i4SV+W4Z6SQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.1.0",
+ "@lezer/lr": "^1.3.0"
+ },
+ "bin": {
+ "lezer-generator": "src/lezer-generator.cjs"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
+ "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
"node_modules/@lizenz/checker": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@lizenz/checker/-/checker-0.0.2.tgz",
@@ -6288,6 +6404,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
@@ -9304,12 +9426,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/ace-builds": {
- "version": "1.44.0",
- "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
- "integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
- "license": "BSD-3-Clause"
- },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -10615,6 +10731,16 @@
"node": ">= 10"
}
},
+ "node_modules/comment-parser": {
+ "version": "1.4.9",
+ "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.9.tgz",
+ "integrity": "sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
"node_modules/common-ancestor-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz",
@@ -10791,6 +10917,12 @@
}
}
},
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -11433,12 +11565,6 @@
"node": ">=8"
}
},
- "node_modules/diff-match-patch": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
- "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
- "license": "Apache-2.0"
- },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -12209,6 +12335,28 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/eslint-plugin-regexp": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-3.3.1.tgz",
+ "integrity": "sha512-58bGUci/AaWAo1ETj6Lsnmy6QXwnSyVONRA805828nnqp7vrTwdaQMc02vtowmYB3A5OB3SLr3r5HgmfxX4dLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.11.0",
+ "comment-parser": "^1.4.0",
+ "jsdoc-type-pratt-parser": "^9.0.0",
+ "refa": "^0.12.1",
+ "regexp-ast-analysis": "^0.7.1",
+ "scslre": "^0.3.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "peerDependencies": {
+ "eslint": ">=9.38.0"
+ }
+ },
"node_modules/eslint-plugin-simple-import-sort": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-14.0.0.tgz",
@@ -12555,15 +12703,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-equals": {
- "version": "5.4.3",
- "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.3.tgz",
- "integrity": "sha512-8unmvA0qrfpFWBYUate9BTGtQdKHWBYt+KdAIkag7Vvxe7wz8Q2nJg7fLMC0bJqe0bjP31kca7Ckaawrs4Istg==",
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -13485,9 +13624,9 @@
}
},
"node_modules/http-cache-semantics": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
- "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz",
+ "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==",
"dev": true,
"license": "BSD-2-Clause"
},
@@ -15343,6 +15482,37 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdoc-type-pratt-parser": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.2.tgz",
+ "integrity": "sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.9",
+ "@types/node": "^26.4.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || >=24.15.0"
+ }
+ },
+ "node_modules/jsdoc-type-pratt-parser/node_modules/@types/node": {
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/jsdoc-type-pratt-parser/node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
@@ -19012,22 +19182,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/react-ace": {
- "version": "15.0.0",
- "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
- "integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
- "license": "MIT",
- "dependencies": {
- "ace-builds": "^1.36.3",
- "diff-match-patch": "^1.0.5",
- "fast-equals": "^5.3.3",
- "prop-types": "^15.8.1"
- },
- "peerDependencies": {
- "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/react-day-picker": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
@@ -19196,6 +19350,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/refa": {
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz",
+ "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.8.0"
+ },
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -19226,6 +19393,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regexp-ast-analysis": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz",
+ "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.8.0",
+ "refa": "^0.12.1"
+ },
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -19602,6 +19783,21 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/scslre": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz",
+ "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.8.0",
+ "refa": "^0.12.0",
+ "regexp-ast-analysis": "^0.7.0"
+ },
+ "engines": {
+ "node": "^14.0.0 || >=16.0.0"
+ }
+ },
"node_modules/selfsigned": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz",
@@ -20085,13 +20281,6 @@
"tslib": "^2.0.3"
}
},
- "node_modules/snarkdown": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/snarkdown/-/snarkdown-2.0.0.tgz",
- "integrity": "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
@@ -20560,6 +20749,12 @@
"webpack": "^5.27.0"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
+ "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
+ "license": "MIT"
+ },
"node_modules/stylelint": {
"version": "16.26.1",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz",
@@ -21831,6 +22026,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/web-console/package.json b/web-console/package.json
index 08706f1365f..e32e196c7cd 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -17,7 +17,7 @@
"test": "npm run test-base -- --silent 2>&1",
"test-ci": "npm run test-base -- --coverage",
"test-e2e": "jest --runInBand --config jest.e2e.config.js e2e-tests",
- "test-unit": "./script/build-sql-docs && npm run typecheck && npm run eslint && npm run sasslint && npm run prettify-check && jest --config jest.config.js --testPathIgnorePatterns='e2e-tests'",
+ "test-unit": "./script/build-sql-docs && ./script/build-grammars.mjs && npm run typecheck && npm run eslint && npm run sasslint && npm run prettify-check && jest --config jest.config.js --testPathIgnorePatterns='e2e-tests'",
"coverage": "jest --coverage src",
"update-snapshots": "jest -u --config jest.config.js",
"autofix": "npm run eslint-fix && npm run sasslint-fix && npm run prettify",
@@ -39,10 +39,6 @@
"engines": {
"node": ">=24"
},
- "volta": {
- "node": "24.21.0",
- "npm": "11.19.0"
- },
"browserslist": [
"> 1% and supports es6",
"last 3 versions and supports es6",
@@ -55,11 +51,19 @@
"@blueprintjs/datetime": "^6.2.6",
"@blueprintjs/icons": "^6.14.1",
"@blueprintjs/select": "^6.3.6",
+ "@codemirror/autocomplete": "^6.20.3",
+ "@codemirror/commands": "^6.11.1",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/search": "^6.7.2",
+ "@codemirror/state": "^6.7.6",
+ "@codemirror/view": "^6.43.13",
"@flatten-js/interval-tree": "^2.0.3",
"@fontsource/open-sans": "^5.0.30",
"@internationalized/date": "^3.5.6",
+ "@lezer/common": "^1.5.3",
+ "@lezer/highlight": "^1.2.5",
+ "@lezer/lr": "^1.4.10",
"@tanstack/react-table": "^9.2.4",
- "ace-builds": "~1.44.0",
"axios": "^1.18.0",
"chronoshift": "^1.2.3",
"classnames": "^2.2.6",
@@ -83,7 +87,6 @@
"memoize-one": "^6.0.0",
"numeral": "^2.0.6",
"react": "^19.3.0",
- "react-ace": "^15.0.0",
"react-diff-viewer-continued": "~4.0.6",
"react-dom": "^19.3.0",
"tslib": "^2.8.0",
@@ -94,6 +97,7 @@
"@awesome-code-style/eslint-config": "^6.2.1",
"@awesome-code-style/prettier-config": "^5.0.2",
"@awesome-code-style/stylelint-config": "^6.0.2",
+ "@lezer/generator": "^1.8.1",
"@lizenz/checker": "^0.0.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1",
@@ -121,6 +125,7 @@
"eslint-plugin-notice": "^1.0.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-regexp": "^3.3.1",
"globals": "^17.12.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.5.2",
@@ -132,7 +137,6 @@
"prettier": "^3.9.9",
"sass": "^1.99.0",
"sass-loader": "^17.0.1",
- "snarkdown": "^2.0.0",
"style-loader": "^4.0.0",
"stylelint": "^16.26.1",
"ts-jest": "^29.4.13",
diff --git a/web-console/script/build b/web-console/script/build
index 8b3c4976480..168e56fb796 100755
--- a/web-console/script/build
+++ b/web-console/script/build
@@ -43,6 +43,13 @@ else
./script/build-sql-docs
fi
+# Build the parsers of the editor languages
+if [ "$FORCE_BUILD" = true ]; then
+ ./script/build-grammars.mjs --force
+else
+ ./script/build-grammars.mjs
+fi
+
# Get version from package.json
VERSION=$(node -e "console.log(require('./package.json').version)")
CONSOLE_OUTPUT="public/web-console-$VERSION.js"
diff --git a/web-console/script/build-grammars.mjs b/web-console/script/build-grammars.mjs
new file mode 100755
index 00000000000..3622f19ce61
--- /dev/null
+++ b/web-console/script/build-grammars.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Generates the Lezer parsers of the editor languages: src/editor-languages/<name>.grammar becomes <name>.parser.ts
+// (the parser) and <name>.parser.terms.ts (the ids of the named terms, for the external tokenizers). The generated
+// files are not checked in. A parser is only rebuilt when its grammar is newer, unless --force is given.
+
+import { buildParserFile } from '@lezer/generator';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+
+const GRAMMAR_DIR = 'src/editor-languages';
+
+const HEADER = `/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This file is generated by script/build-grammars.mjs, do not edit
+`;
+
+async function modifiedTime(file) {
+ try {
+ return (await fs.stat(file)).mtimeMs;
+ } catch {
+ return -1;
+ }
+}
+
+const force = process.argv.includes('--force');
+const grammarFiles = (await fs.readdir(GRAMMAR_DIR)).filter(file => file.endsWith('.grammar'));
+
+for (const grammarFile of grammarFiles) {
+ const grammarPath = path.join(GRAMMAR_DIR, grammarFile);
+ const name = path.basename(grammarFile, '.grammar');
+ const parserPath = path.join(GRAMMAR_DIR, `${name}.parser.ts`);
+ const termsPath = path.join(GRAMMAR_DIR, `${name}.parser.terms.ts`);
+
+ if (
+ !force &&
+ (await modifiedTime(parserPath)) > (await modifiedTime(grammarPath)) &&
+ (await modifiedTime(termsPath)) > (await modifiedTime(grammarPath))
+ ) {
+ // eslint-disable-next-line no-undef
+ console.log(`The ${name} parser is up to date, skipping...`);
+ continue;
+ }
+
+ // eslint-disable-next-line no-undef
+ console.log(`Building the ${name} parser...`);
+ const { parser, terms } = buildParserFile(await fs.readFile(grammarPath, 'utf8'), {
+ fileName: grammarPath,
+ typeScript: true,
+ warn: message => {
+ throw new Error(`Warning in ${grammarPath}: ${message}`);
+ },
+ });
+ await fs.writeFile(parserPath, HEADER + parser);
+ await fs.writeFile(termsPath, HEADER + terms);
+}
diff --git a/web-console/script/build-sql-docs b/web-console/script/build-sql-docs
index 0a6f49bdf7a..d61234f85d6 100755
--- a/web-console/script/build-sql-docs
+++ b/web-console/script/build-sql-docs
@@ -36,7 +36,7 @@ is_newer_than() {
[ "$1" -nt "$2" ]
}
-SQL_DOCS_OUTPUT="lib/sql-docs.ts"
+SQL_DOCS_OUTPUT="src/editor-languages/dsql-docs.ts"
SQL_DOCS_SCRIPT="script/create-sql-docs.mjs"
SQL_DOCS_FILELIST="script/sql-doc-files.txt"
diff --git a/web-console/script/clean b/web-console/script/clean
index d08071b9245..18a5669cea4 100755
--- a/web-console/script/clean
+++ b/web-console/script/clean
@@ -18,7 +18,6 @@
rm -rf \
coverage \
- lib/sql-docs.* \
+ src/editor-languages/dsql-docs.ts \
node_modules \
- public \
- index.html
+ public
diff --git a/web-console/script/create-sql-docs.mjs b/web-console/script/create-sql-docs.mjs
index dab34bf0cd7..161db1aeee6 100755
--- a/web-console/script/create-sql-docs.mjs
+++ b/web-console/script/create-sql-docs.mjs
@@ -19,36 +19,46 @@
*/
import fs from 'node:fs/promises';
-import snarkdown from 'snarkdown';
const INPUT_FILELIST_FILE = 'script/sql-doc-files.txt';
-const OUTPUT_FILE = 'lib/sql-docs.ts';
+const OUTPUT_FILE = 'src/editor-languages/dsql-docs.ts';
const MINIMUM_EXPECTED_NUMBER_OF_FUNCTIONS = 198;
const MINIMUM_EXPECTED_NUMBER_OF_DATA_TYPES = 15;
+const HTML_ENTITIES = {
+ '—': '\u2014',
+ '–': '\u2013',
+ ' ': ' ',
+ '|': '|',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ '&': '&',
+};
+
+// Emphasis: _text_ or *text*, not inside of words
+const EMPHASIS_REGEXP = /(?<![\w*])([*_])(?!\s)(.+?)(?<!\s)\1(?![\w*])/g;
+
const initialFunctionDocs = {
- TABLE: ['external', convertMarkdownToHtml('Defines a logical table from an external.')],
- EXTERN: [
- 'inputSource, inputFormat, rowSignature?',
- convertMarkdownToHtml('Reads external data.'),
- ],
+ TABLE: ['external', sanitizeMarkdown('Defines a logical table from an external.')],
+ EXTERN: ['inputSource, inputFormat, rowSignature?', sanitizeMarkdown('Reads external data.')],
TYPE: [
'nativeType',
- convertMarkdownToHtml(
+ sanitizeMarkdown(
'A purely type system modification function what wraps a Druid native type to make it into a SQL type.',
),
],
UNNEST: [
'arrayExpression',
- convertMarkdownToHtml(
+ sanitizeMarkdown(
"Unnests ARRAY typed values. The source for UNNEST can be an array type column, or an input that's been transformed into an array, such as with helper functions like `MV_TO_ARRAY` or `ARRAY`.",
),
],
};
function hasHtmlTags(str) {
- return /<(a|br|span|div|p|code)\/?>/.test(str);
+ return /<\/?[a-z][^>]*>/i.test(str);
}
function sanitizeArguments(str) {
@@ -62,18 +72,79 @@ function sanitizeArguments(str) {
return str;
}
-function convertMarkdownToHtml(markdown) {
- markdown = markdown.replace(/<br\/?>/g, '\n'); // Convert inline <br> to newlines
+function escapeDocMarkdown(text) {
+ return text.replace(/[\\*`]/g, '\\$&');
+}
+
+/**
+ * Converts the text (not code) parts of a line
+ */
+function sanitizeText(text, context) {
+ if (hasHtmlTags(text)) {
+ throw new Error(`Markdown contains HTML: ${context}`);
+ }
- // Ensure there are no more html tags other than the <br> we just removed
- if (hasHtmlTags(markdown)) {
- throw new Error(`Markdown contains HTML: ${markdown}`);
+ text = text.replace(/&[a-z]+;|&#\d+;/g, entity => {
+ if (!HTML_ENTITIES[entity]) throw new Error(`Unknown HTML entity ${entity} in: ${context}`);
+ return HTML_ENTITIES[entity];
+ });
+
+ let result = '';
+ let lastIndex = 0;
+ for (const m of text.matchAll(EMPHASIS_REGEXP)) {
+ result += escapeDocMarkdown(text.slice(lastIndex, m.index));
+ result += `*${escapeDocMarkdown(m[2])}*`;
+ lastIndex = m.index + m[0].length;
}
+ return result + escapeDocMarkdown(text.slice(lastIndex));
+}
- // Concert to markdown
- markdown = snarkdown(markdown);
+function sanitizeLine(line, context) {
+ // Keep `code` spans as they are and sanitize the text around them
+ return line
+ .split(/(`[^`]*`)/)
+ .map((part, i) => (i % 2 ? part : sanitizeText(part, context)))
+ .join('');
+}
- return markdown.replace(/<a[^>]*>(.*?)<\/a>/g, '$1'); // Remove links
+/**
+ * Converts the markdown from the docs to "doc markdown", the simplified markdown that the console renders (see
+ * renderDocMarkdown in src/components/code-editor/doc-markdown.ts):
+ * - `code` spans
+ * - *emphasis*
+ * - line breaks (\n)
+ * - list items (lines that start with "- ")
+ * - a backslash escapes the next character
+ * Links are reduced to their text and HTML is not allowed (other than <br> and simple lists, which are converted).
+ */
+function removeTrailingBreaks(text) {
+ let m;
+ while ((m = /<br\s*\/?>$/.exec(text))) text = text.slice(0, m.index);
+ return text;
+}
+
+function sanitizeMarkdown(markdown) {
+ const context = markdown;
+
+ const lines = removeTrailingBreaks(
+ markdown
+ .replace(/\[([^[\]]*)\]\([^)]*\)/g, '$1') // Remove links
+ .split(/<ul>(.*?)<\/ul>/) // The odd parts are the list items
+ .map((part, i, parts) => {
+ if (i % 2) return part.replace(/<li>(.*?)<\/li>/g, '<br>\u0000$1') + '<br>';
+ return i < parts.length - 1 ? removeTrailingBreaks(part) : part; // The breaks before a list are dropped
+ })
+ .join(''),
+ ).split(/<br\s*\/?>/);
+
+ return lines
+ .map(line => {
+ if (line.startsWith('\u0000')) return `- ${sanitizeLine(line.slice(1), context)}`;
+ line = sanitizeLine(line, context);
+ return line.startsWith('- ') ? `\\${line}` : line; // Not a list item
+ })
+ .join('\n')
+ .trim();
}
const readDoc = async () => {
@@ -98,13 +169,13 @@ const readDoc = async () => {
if (functionMatch) {
const functionName = functionMatch[1];
const args = sanitizeArguments(functionMatch[2]);
- const description = convertMarkdownToHtml(functionMatch[3].trim());
+ const description = sanitizeMarkdown(functionMatch[3].trim());
functionDocs[functionName] = [args, description];
}
const dataTypeMatch = line.match(/^\|([A-Z]+)\|([A-Z]+)\|([^|]*)\|([^|]*)\|$/);
if (dataTypeMatch) {
- dataTypeDocs[dataTypeMatch[1]] = [dataTypeMatch[2], convertMarkdownToHtml(dataTypeMatch[4])];
+ dataTypeDocs[dataTypeMatch[1]] = [dataTypeMatch[2], sanitizeMarkdown(dataTypeMatch[4])];
}
}
@@ -144,6 +215,8 @@ const readDoc = async () => {
// This file is auto generated and should not be modified
+// The descriptions are in "doc markdown", see sanitizeMarkdown in script/create-sql-docs.mjs
+
// prettier-ignore
export const SQL_DATA_TYPES = new Map<string, [runtime: string, description: string]>(Object.entries(${JSON.stringify(
dataTypeDocs,
diff --git a/web-console/script/generate-blueprint-token-overrides.mjs b/web-console/script/generate-blueprint-token-overrides.mjs
index 5d6f5f92f37..3a2f3025264 100644
--- a/web-console/script/generate-blueprint-token-overrides.mjs
+++ b/web-console/script/generate-blueprint-token-overrides.mjs
@@ -72,10 +72,10 @@ function toPaletteExpression(value) {
function convertTokenFile(file) {
const css = fs.readFileSync(`${TOKENS_DIR}/${file}`, 'utf-8');
const blocks = [];
- for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
+ for (const [, selector, body] of css.matchAll(/(?<![^{}])([^{}]+)\{([^}]*)\}/g)) {
const declarations = [];
const seen = new Set();
- for (const [, name, value] of body.matchAll(/(--bp-[a-z0-9-]+):\s*([^;]+);/g)) {
+ for (const [, name, value] of body.matchAll(/(--bp-[a-z0-9-]+):\s*([^\s;][^;]*);/g)) {
if (seen.has(name)) continue; // Later definitions are relative color syntax derived from other tokens
seen.add(name);
const expression = TOKEN_VARIABLES[name]
@@ -86,8 +86,13 @@ function convertTokenFile(file) {
const cleanSelector = selector
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
- .split(/\s*,\s*/)
- .map(part => part.replace(/"/g, "'").replace(/\.bp\d+-/g, '.#{$ns}-'))
+ .split(',')
+ .map(part =>
+ part
+ .trim()
+ .replace(/"/g, "'")
+ .replace(/\.bp\d+-/g, '.#{$ns}-'),
+ )
.join(',\n');
if (declarations.length) blocks.push(`${cleanSelector} {\n${declarations.join('\n')}\n}`);
}
diff --git a/web-console/src/ace-completions/__snapshots__/make-doc-html.spec.ts.snap b/web-console/src/ace-completions/__snapshots__/make-doc-html.spec.ts.snap
deleted file mode 100644
index 97cfff0d699..00000000000
--- a/web-console/src/ace-completions/__snapshots__/make-doc-html.spec.ts.snap
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
-
-exports[`makeDocHtml correctly formats helper HTML 1`] = `
-"<div class="doc-name">COUNT</div>
-<div class="doc-syntax">COUNT(*)</div>
-<div class="doc-description">Counts the number of things</div>"
-`;
diff --git a/web-console/src/ace-completions/sql-completions.spec.ts b/web-console/src/ace-completions/sql-completions.spec.ts
deleted file mode 100644
index abd50d6cd6d..00000000000
--- a/web-console/src/ace-completions/sql-completions.spec.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import type { ColumnMetadata } from '../utils';
-
-import { getPossibleSqlReferences, getSqlCompletions, getSqlLiterals } from './sql-completions';
-
-describe('sql-completions', () => {
- describe('getSqlCompletions', () => {
- const baseOptions = {
- allText: '',
- lineBeforePrefix: '',
- charBeforePrefix: '',
- prefix: '',
- };
-
- it('returns empty array when in a single line comment', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: '-- This is a comment',
- prefix: 'SEL',
- });
- expect(completions).toEqual([]);
- });
-
- it('returns empty array when typing after comment marker', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'SELECT * FROM table -- ',
- prefix: 'com',
- });
- expect(completions).toEqual([]);
- });
-
- it('returns empty array when comment is in middle of line', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'SELECT -- some comment',
- prefix: 'FR',
- });
- expect(completions).toEqual([]);
- });
-
- it('returns empty array when prefix is a number', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- prefix: '123',
- });
- expect(completions).toEqual([]);
- });
-
- it('returns completions before comment marker on same line', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'SELECT ',
- prefix: 'FR',
- });
- expect(completions.length).toBeGreaterThan(0);
- const keywordCompletions = completions.filter(c => c.meta === 'keyword');
- expect(keywordCompletions.length).toBeGreaterThan(0);
- });
-
- it('returns empty array even when comment contains SQL keywords', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: '-- TODO: need to SELECT * FROM',
- prefix: 'WHE',
- });
- expect(completions).toEqual([]);
- });
-
- it('returns empty array for indented comments', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: ' -- indented comment',
- prefix: 'FRO',
- });
- expect(completions).toEqual([]);
- });
-
- it('returns only literals when charBeforePrefix is single quote', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- allText: "SELECT * FROM table WHERE country = 'France' OR country = 'Germany'",
- charBeforePrefix: "'",
- prefix: 'Fr',
- });
-
- expect(completions).toHaveLength(2);
- expect(completions[0]).toEqual({
- value: 'France',
- score: 1,
- meta: 'local',
- });
- expect(completions[1]).toEqual({
- value: 'Germany',
- score: 1,
- meta: 'local',
- });
- });
-
- it('returns keyword suggestions after SELECT', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'SELECT',
- });
-
- const keywordCompletions = completions.filter(c => c.meta === 'keyword');
- const keywordValues = keywordCompletions.map(c => c.value);
-
- expect(keywordValues).toContain('DISTINCT');
- expect(keywordValues).toContain('ALL');
- expect(keywordValues).not.toContain('SELECT');
- });
-
- it('does not include functions after keywords that cannot be followed by functions', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'LIMIT',
- });
-
- const functionCompletions = completions.filter(c => c.meta === 'function');
- expect(functionCompletions).toHaveLength(0);
-
- const dynamicCompletions = completions.filter(c => c.meta === 'dynamic');
- expect(dynamicCompletions).toHaveLength(0);
- });
-
- it('includes functions after keywords that can be followed by functions', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'WHERE',
- });
-
- const functionCompletions = completions.filter(c => c.meta === 'function');
- expect(functionCompletions.length).toBeGreaterThan(0);
-
- const dynamicCompletions = completions.filter(c => c.meta === 'dynamic');
- expect(dynamicCompletions.length).toBeGreaterThan(0);
- });
-
- it('does not include column references after keywords that cannot be followed by refs', () => {
- const columnMetadata: ColumnMetadata[] = [
- {
- TABLE_SCHEMA: 'druid',
- TABLE_NAME: 'wikipedia',
- COLUMN_NAME: 'page',
- DATA_TYPE: 'VARCHAR',
- },
- {
- TABLE_SCHEMA: 'druid',
- TABLE_NAME: 'wikipedia',
- COLUMN_NAME: 'user',
- DATA_TYPE: 'VARCHAR',
- },
- ];
-
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'DESC',
- columnMetadata,
- });
-
- const columnCompletions = completions.filter(c => c.meta === 'column');
- expect(columnCompletions).toHaveLength(0);
-
- const tableCompletions = completions.filter(c => c.meta === 'table');
- expect(tableCompletions).toHaveLength(0);
- });
-
- it('includes column references after keywords that can be followed by refs', () => {
- const columnMetadata: ColumnMetadata[] = [
- {
- TABLE_SCHEMA: 'druid',
- TABLE_NAME: 'wikipedia',
- COLUMN_NAME: 'page',
- DATA_TYPE: 'VARCHAR',
- },
- {
- TABLE_SCHEMA: 'druid',
- TABLE_NAME: 'wikipedia',
- COLUMN_NAME: 'user',
- DATA_TYPE: 'VARCHAR',
- },
- ];
-
- const completions = getSqlCompletions({
- ...baseOptions,
- allText: 'SELECT * FROM "wikipedia"',
- lineBeforePrefix: 'WHERE',
- columnMetadata,
- });
-
- const columnCompletions = completions.filter(c => c.meta === 'column');
- expect(columnCompletions.length).toBeGreaterThan(0);
- expect(columnCompletions.map(c => c.value)).toContain('"page"');
- expect(columnCompletions.map(c => c.value)).toContain('"user"');
- });
-
- it('includes context keys after SET keyword', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- lineBeforePrefix: 'SET',
- });
-
- const contextCompletions = completions.filter(c => c.meta === 'context');
- expect(contextCompletions.length).toBeGreaterThan(0);
- });
-
- it('handles column names correctly with quotes', () => {
- const columns = ['column name with spaces', 'normal_column'];
-
- const completionsWithoutQuote = getSqlCompletions({
- ...baseOptions,
- columns,
- });
-
- const completionsWithQuote = getSqlCompletions({
- ...baseOptions,
- charBeforePrefix: '"',
- columns,
- });
-
- const columnWithoutQuote = completionsWithoutQuote.find(c =>
- c.value.includes('column name with spaces'),
- );
- expect(columnWithoutQuote?.value).toBe('"column name with spaces"');
-
- const columnWithQuote = completionsWithQuote.find(c => c.value === 'column name with spaces');
- expect(columnWithQuote?.value).toBe('column name with spaces');
- });
-
- it('includes constants and data types in completions', () => {
- const completions = getSqlCompletions(baseOptions);
-
- const constantCompletions = completions.filter(c => c.meta === 'constant');
- expect(constantCompletions.map(c => c.value)).toContain('NULL');
- expect(constantCompletions.map(c => c.value)).toContain('TRUE');
- expect(constantCompletions.map(c => c.value)).toContain('FALSE');
-
- const typeCompletions = completions.filter(c => c.meta === 'type');
- expect(typeCompletions.length).toBeGreaterThan(0);
- });
-
- it('includes local references from the query text', () => {
- const completions = getSqlCompletions({
- ...baseOptions,
- allText: 'SELECT my_column FROM my_table WHERE other_column = 1',
- });
-
- const localCompletions = completions.filter(c => c.meta === 'local');
- const localValues = localCompletions.map(c => c.value);
-
- expect(localValues).toContain('my_column');
- expect(localValues).toContain('my_table');
- expect(localValues).toContain('other_column');
- });
- });
-
- describe('getSqlLiterals', () => {
- it('extracts string literals from SQL text', () => {
- const sqlText =
- "SELECT * FROM table WHERE country = 'France' OR city = 'Paris' OR code = 'FR'";
- const literals = getSqlLiterals(sqlText, 10);
-
- expect(literals).toEqual(['France', 'Paris', 'FR']);
- });
-
- it('respects maxWords limit', () => {
- const sqlText = "SELECT 'one', 'two', 'three', 'four', 'five'";
- const literals = getSqlLiterals(sqlText, 3);
-
- expect(literals).toHaveLength(3);
- });
- });
-
- describe('getPossibleSqlReferences', () => {
- it('extracts quoted identifiers', () => {
- const sqlText = 'SELECT "myColumn" FROM "myTable"';
- const references = getPossibleSqlReferences(sqlText, 10);
-
- expect(references).toContain('myColumn');
- expect(references).toContain('myTable');
- });
-
- it('extracts unquoted identifiers', () => {
- const sqlText = 'SELECT column1, column2 FROM table1 WHERE column3 = 1';
- const references = getPossibleSqlReferences(sqlText, 10);
-
- expect(references).toContain('column1');
- expect(references).toContain('column2');
- expect(references).toContain('table1');
- expect(references).toContain('column3');
- });
-
- it('filters out known SQL keywords', () => {
- const sqlText = 'SELECT column1 FROM table1 WHERE column2 = 1 ORDER BY column3';
- const references = getPossibleSqlReferences(sqlText, 20);
-
- expect(references).not.toContain('SELECT');
- expect(references).not.toContain('FROM');
- expect(references).not.toContain('WHERE');
- expect(references).not.toContain('ORDER');
- expect(references).not.toContain('BY');
- });
-
- it('handles identifiers in expressions', () => {
- const sqlText = 'SELECT (column1 + column2) * column3 FROM table1';
- const references = getPossibleSqlReferences(sqlText, 10);
-
- expect(references).toContain('column1');
- expect(references).toContain('column2');
- expect(references).toContain('column3');
- expect(references).toContain('table1');
- });
- });
-});
diff --git a/web-console/src/ace-completions/sql-completions.ts b/web-console/src/ace-completions/sql-completions.ts
deleted file mode 100644
index 89fd3b82b1d..00000000000
--- a/web-console/src/ace-completions/sql-completions.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import type { Ace } from 'ace-builds';
-import { C, filterMap, N, T } from 'druid-query-toolkit';
-
-import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from '../../lib/keywords';
-import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../../lib/sql-docs';
-import { DEFAULT_SERVER_QUERY_CONTEXT } from '../druid-models';
-import type { AvailableFunctions } from '../helpers';
-import type { ColumnMetadata } from '../utils';
-import { lookupBy, uniq } from '../utils';
-
-import { makeDocHtml } from './make-doc-html';
-
-const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_FUNCTION = [
- 'AS',
- 'ASC',
- 'DESC',
- 'LIMIT',
- 'OFFSET',
- 'RETURNING',
- 'SET',
- 'VALUES',
- 'FETCH',
- 'FIRST',
- 'NEXT',
- 'ONLY',
- 'PRECEDING',
- 'FOLLOWING',
- 'UNBOUNDED',
-];
-
-const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_REF = [
- 'ASC',
- 'DESC',
- 'LIMIT',
- 'OFFSET',
- 'FIRST',
- 'NEXT',
- 'ONLY',
- 'PRECEDING',
- 'FOLLOWING',
- 'UNBOUNDED',
- 'CUBE',
- 'ROLLUP',
- 'FOR',
- 'OUTER',
- 'CROSS',
- 'INNER',
- 'LEFT',
- 'RIGHT',
- 'FULL',
- 'NATURAL',
- 'UNION',
- 'INTERSECT',
- 'EXCEPT',
-];
-
-const SQL_KEYWORD_FOLLOW_SUGGESTIONS: Record<string, string[]> = {
- // Keywords that can be followed by specific keywords
- SELECT: ['DISTINCT', 'ALL'],
- GROUP: ['BY'],
- ORDER: ['BY'],
- PARTITION: ['BY'],
- PARTITIONED: ['BY'],
- CLUSTERED: ['BY'],
- UNION: ['ALL'],
- INSERT: ['INTO'],
- REPLACE: ['INTO'],
- MERGE: ['INTO'],
- UPDATE: ['SET'],
- LEFT: ['JOIN', 'OUTER'],
- RIGHT: ['JOIN', 'OUTER'],
- INNER: ['JOIN'],
- FULL: ['JOIN', 'OUTER'],
- CROSS: ['JOIN'],
- NATURAL: ['JOIN', 'LEFT', 'RIGHT', 'INNER', 'FULL'],
- EXPLAIN: ['PLAN'],
- PLAN: ['FOR'],
- GROUPING: ['SETS'],
- FETCH: ['FIRST', 'NEXT'],
- FIRST: ['ROW', 'ROWS'],
- NEXT: ['ROW', 'ROWS'],
- ROWS: ['ONLY'],
- ROW: ['ONLY'],
- IS: ['NOT', 'DISTINCT'],
- DISTINCT: ['FROM'],
- BY: ['GROUPING', 'ROLLUP', 'CUBE', 'ALL', 'HOUR', 'DAY', 'MONTH', 'YEAR'],
- ALL: ['TIME'],
-
- // Keywords that cannot be followed by any other keyword
- AS: [],
- ASC: [],
- DESC: [],
- LIMIT: [],
- OFFSET: [],
- RETURNING: [],
- SET: [],
- VALUES: [],
- ONLY: [],
- WHERE: [],
- HAVING: [],
- USING: [],
- ON: [],
- CUBE: [],
- ROLLUP: [],
- OVERWRITE: [],
- PIVOT: [],
- UNPIVOT: [],
- MATCHED: [],
- PRECEDING: [],
- FOLLOWING: [],
- UNBOUNDED: [],
- CURRENT: [],
- EXTEND: [],
- WINDOW: [],
- RANGE: [],
- KEY: [],
- VALUE: [],
- TIME: [],
- EPOCH: [],
- MILLISECOND: [],
- SECOND: [],
- MINUTE: [],
- HOUR: [],
- DAY: [],
- DOW: [],
- ISODOW: [],
- DOY: [],
- WEEK: [],
- MONTH: [],
- QUARTER: [],
- YEAR: [],
- ISOYEAR: [],
- DECADE: [],
- CENTURY: [],
- MILLENNIUM: [],
-};
-
-const KNOWN_SQL_PARTS: Record<string, boolean> = {
- ...lookupBy(
- SQL_KEYWORDS.flatMap(k => k.split(/\s/g)), // The flatMap is needed because some keywords are like "EXPLAIN PLAN FOR"
- String,
- () => true,
- ),
- ...lookupBy(SQL_CONSTANTS, String, () => true),
- ...lookupBy(SQL_DYNAMICS, String, () => true),
- ...lookupBy(Array.from(SQL_DATA_TYPES.keys()), String, () => true),
- ...lookupBy(Array.from(SQL_FUNCTIONS.keys()), String, () => true),
-};
-
-export interface GetSqlCompletionsOptions {
- allText: string;
- lineBeforePrefix: string;
- charBeforePrefix: string;
- prefix: string;
- columnMetadata?: readonly ColumnMetadata[];
- columns?: readonly string[];
- availableSqlFunctions?: AvailableFunctions;
- skipAggregates?: boolean;
-}
-
-export function getSqlCompletions({
- allText,
- lineBeforePrefix,
- charBeforePrefix,
- prefix,
- columnMetadata,
- columns,
- availableSqlFunctions,
- skipAggregates,
-}: GetSqlCompletionsOptions): Ace.ValueCompletion[] {
- // We are in a single line comment
- if (lineBeforePrefix.startsWith('--') || lineBeforePrefix.includes(' --')) {
- return [];
- }
-
- // If we are autocompleting inside a literal, then don't do any of the standard suggestions.
- // Only autocomplete other literals. The imagined use-case for this is if you have `country = 'France'` or `TIMESTAMP '2024-03-02 O1:00:00'` you might want to reuse the literals
- if (charBeforePrefix === "'") {
- return getSqlLiterals(allText, 100).map(value => ({
- value,
- score: 1,
- meta: 'local',
- }));
- }
-
- const keywordBeforePrefix = (/(\w+)\s*$/.exec(lineBeforePrefix) || [])[1]?.toUpperCase();
-
- // Other than literals, do not autocomplete numbers
- if (/^\d+$/.test(prefix)) {
- return []; // Don't start completing if the user is typing a number
- }
-
- const possibleReferences = getPossibleSqlReferences(allText, 100);
-
- let completions: Ace.ValueCompletion[] = possibleReferences.map(value => ({
- value,
- score: 1,
- meta: 'local',
- }));
-
- const quote = charBeforePrefix === '"';
- if (!quote) {
- completions = completions.concat(
- (SQL_KEYWORD_FOLLOW_SUGGESTIONS[keywordBeforePrefix || ''] || SQL_KEYWORDS).map(v => ({
- value: v,
- score: 10,
- meta: 'keyword',
- })),
- SQL_CONSTANTS.map(v => ({ value: v, score: 11, meta: 'constant' })),
- Array.from(SQL_DATA_TYPES.entries()).map(([name, [runtime, description]]) => {
- return {
- value: name,
- score: 31,
- meta: 'type',
- docHTML: makeDocHtml({
- name,
- description,
- syntax: `Druid runtime type: ${runtime}`,
- }),
- docText: description,
- };
- }),
- );
-
- if (
- !keywordBeforePrefix ||
- !SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_FUNCTION.includes(keywordBeforePrefix)
- ) {
- completions = completions.concat(
- SQL_DYNAMICS.map(v => ({ value: v, score: 20, meta: 'dynamic' })),
- );
-
- // If availableSqlFunctions map is provided, use it; otherwise fall back to static SQL_FUNCTIONS
- if (availableSqlFunctions) {
- completions = completions.concat(
- Array.from(availableSqlFunctions.entries()).flatMap(([name, funcDef]) => {
- if (skipAggregates && funcDef.isAggregate) return [];
- const description = SQL_FUNCTIONS.get(name)?.[1];
- return funcDef.args.map(args => ({
- // Functions with several signatures are listed once per signature, but only the name is inserted
- caption: funcDef.args.length > 1 ? `${name}(${args})` : undefined,
- value: name,
- score: 30,
- meta: funcDef.isAggregate ? 'aggregate' : 'function',
- docHTML: makeDocHtml({ name, description, syntax: `${name}(${args})` }),
- docText: description,
- }));
- }),
- );
- } else {
- completions = completions.concat(
- Array.from(SQL_FUNCTIONS.entries()).map(([name, argDesc]) => {
- const [args, description] = argDesc;
- return {
- value: name,
- score: 30,
- meta: 'function',
- docHTML: makeDocHtml({ name, description, syntax: `${name}(${args})` }),
- docText: description,
- };
- }),
- );
- }
- }
- }
-
- if (
- !keywordBeforePrefix ||
- !SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_REF.includes(keywordBeforePrefix)
- ) {
- if (columnMetadata?.length) {
- const possibleReferencesLookup = lookupBy(possibleReferences, String, () => true);
-
- completions = completions.concat(
- uniq(columnMetadata.map(({ TABLE_SCHEMA }) => TABLE_SCHEMA)).map(schema => ({
- value: quote ? schema : String(N(schema)),
- score: 30,
- meta: 'schema',
- })),
- uniq(
- filterMap(columnMetadata, ({ TABLE_SCHEMA, TABLE_NAME }) =>
- TABLE_SCHEMA === 'druid' || possibleReferencesLookup[TABLE_SCHEMA]
- ? TABLE_NAME
- : undefined,
- ),
- ).map(table => ({
- value: quote ? table : String(T(table)),
- score: 40,
- meta: 'table',
- })),
- uniq(
- filterMap(columnMetadata, d =>
- possibleReferencesLookup[d.TABLE_NAME] ? d.COLUMN_NAME : undefined,
- ),
- ).map(v => ({
- value: quote ? v : String(C(v)),
- score: 50,
- meta: 'column',
- })),
- );
- }
-
- if (columns?.length) {
- completions = completions.concat(
- columns.map(column => ({
- value: quote ? column : String(C(column)),
- score: 50,
- meta: 'column',
- })),
- );
- }
- }
-
- if (keywordBeforePrefix === 'SET') {
- completions = completions.concat(
- Object.keys(DEFAULT_SERVER_QUERY_CONTEXT).map(key => ({
- value: quote ? key : String(C(key)),
- score: 50,
- meta: 'context',
- })),
- );
- }
-
- return completions;
-}
-
-export function getSqlLiterals(sqlText: string, maxWords: number): string[] {
- const literalRegexp = /'[^'\n]{2,}'/g;
- const matches = (sqlText.match(literalRegexp) || []).map(stripOuterChars);
-
- return uniq(matches).slice(0, maxWords);
-}
-
-export function getPossibleSqlReferences(sqlText: string, maxWords: number): string[] {
- const quotedRegexp = /"\w{2,}"/g;
- const quotedMatches = (sqlText.match(quotedRegexp) || []).map(stripOuterChars);
-
- // Match identifiers that are preceded by whitespace, comma, parenthesis, or operators
- // and followed by whitespace, comma, parenthesis, operators, or end of string, ensure length of at least 2 chars
- const nakedRegexp = /(?:^|[\s,([\-+*/])[a-zA-Z]\w+(?=[\s,)\]\-+*/]|$)/g;
- const nakedMatches = (sqlText.match(nakedRegexp) || []).map(s => s.replace(/^[\s,([\-+*/]/, ''));
-
- return uniq([...quotedMatches, ...nakedMatches.filter(v => !KNOWN_SQL_PARTS[v])]).slice(
- 0,
- maxWords,
- );
-}
-
-function stripOuterChars(str: string): string {
- return str.slice(1, str.length - 1);
-}
diff --git a/web-console/src/ace-modes/ace-mode-helpers.ts b/web-console/src/ace-modes/ace-mode-helpers.ts
deleted file mode 100644
index 966393177c0..00000000000
--- a/web-console/src/ace-modes/ace-mode-helpers.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import ace from 'ace-builds';
-
-// Ace does not ship typings for the classes that modes are built from
-export const TextMode: any = ace.require('ace/mode/text').Mode;
-export const TextHighlightRules: any = ace.require(
- 'ace/mode/text_highlight_rules',
-).TextHighlightRules;
-
-const defineModule: (name: string, deps: string[], payload: object) => void = (ace as any).define;
-const modeCache: Record<string, unknown> = (ace.config as any).$modes;
-
-/**
- * Registers a mode so that editors can refer to it by name, as in `mode="dsql"`. The mode is only registered once
- * (Ace ignores later definitions of a module) so a mode that needs to change should read its configuration when it is
- * constructed and be reset with `resetAceMode`.
- */
-export function registerAceMode(name: string, Mode: new () => unknown): void {
- defineModule(`ace/mode/${name}`, [], { Mode });
-}
-
-/**
- * Ace creates a single instance of each mode and shares it between all editors, forget it so that editors created from
- * now on get a new instance.
- */
-export function resetAceMode(name: string): void {
- delete modeCache[`ace/mode/${name}`];
-}
diff --git a/web-console/src/ace-modes/ace-modes.spec.ts b/web-console/src/ace-modes/ace-modes.spec.ts
deleted file mode 100644
index 7abde9a68d6..00000000000
--- a/web-console/src/ace-modes/ace-modes.spec.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import ace from 'ace-builds';
-
-import { initAceDsqlMode } from './dsql';
-
-function tokenize(mode: string, text: string): [type: string, value: string][][] {
- const session = ace.createEditSession(text);
- session.setMode(`ace/mode/${mode}`);
- const lines: [string, string][][] = [];
- for (let row = 0; row < session.getLength(); row++) {
- lines.push(session.getTokens(row).map(({ type, value }) => [type, value]));
- }
- return lines;
-}
-
-function tokenOf(mode: string, text: string, value: string): string | undefined {
- return tokenize(mode, text)
- .flat()
- .find(t => t[1] === value)?.[0];
-}
-
-describe('ace modes', () => {
- it('highlights DruidSQL', () => {
- const sql = `--:ISSUE: bad
-SELECT COUNT(*), "col", CAST(x AS VARCHAR), TRUE FROM t WHERE y <> 'lit' AND z = 3.5 -- comment`;
- expect(tokenOf('dsql', sql, '--:ISSUE: bad')).toEqual('comment.issue');
- expect(tokenOf('dsql', sql, 'SELECT')).toEqual('keyword');
- expect(tokenOf('dsql', sql, 'COUNT')).toEqual('support.function');
- expect(tokenOf('dsql', sql, '"col"')).toEqual('variable.column');
- expect(tokenOf('dsql', sql, 'VARCHAR')).toEqual('storage.type');
- expect(tokenOf('dsql', sql, 'TRUE')).toEqual('constant.language');
- expect(tokenOf('dsql', sql, "'lit'")).toEqual('string');
- expect(tokenOf('dsql', sql, '3.5')).toEqual('constant.numeric');
- expect(tokenOf('dsql', sql, '-- comment')).toEqual('comment');
- });
-
- it('highlights the available functions in editors created later', () => {
- expect(tokenOf('dsql', 'SELECT MY_FN(x)', 'MY_FN')).toEqual('identifier');
- initAceDsqlMode(new Map([['MY_FN', { args: ['x'], isAggregate: false }]]));
- expect(tokenOf('dsql', 'SELECT MY_FN(x)', 'MY_FN')).toEqual('support.function');
- initAceDsqlMode(undefined);
- });
-
- it('highlights Hjson', () => {
- const hjson = `{
- # comment
- key: "value"
- n: -1.5e3
- flag: true
- unquoted: some text
-}`;
- expect(tokenOf('hjson', hjson, ' comment')).toEqual('comment.line');
- expect(tokenOf('hjson', hjson, 'key')).toEqual('keyword');
- expect(tokenOf('hjson', hjson, '-1.5e3')).toEqual('constant.numeric');
- expect(tokenOf('hjson', hjson, 'true')).toEqual('constant');
- expect(tokenOf('hjson', hjson, 'some text')).toEqual('string');
- });
-});
diff --git a/web-console/src/ace-modes/dsql.ts b/web-console/src/ace-modes/dsql.ts
deleted file mode 100644
index 03c25cf3008..00000000000
--- a/web-console/src/ace-modes/dsql.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// This file a modified version of the file located at
-// https://github.com/thlorenz/brace/blob/master/mode/sql.js
-// Originally licensed under the MIT license (https://github.com/thlorenz/brace/blob/master/LICENSE)
-// This file was modified to make the list of keywords more closely adhere to what is found in DruidSQL
-
-import { dedupe } from 'druid-query-toolkit';
-
-import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from '../../lib/keywords';
-import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../../lib/sql-docs';
-import type { AvailableFunctions } from '../helpers';
-
-import { registerAceMode, resetAceMode, TextHighlightRules, TextMode } from './ace-mode-helpers';
-
-let availableSqlFunctions: AvailableFunctions | undefined;
-
-function createDsqlHighlightRules() {
- const rules = new TextHighlightRules();
-
- // Stuff like: 'with|select|from|where|and|or|group|by|order|limit|having|as|case|'
- const keywords = SQL_KEYWORDS.join('|').replace(/\s/g, '|');
-
- // Stuff like: 'true|false'
- const builtinConstants = SQL_CONSTANTS.join('|');
-
- // Stuff like: 'avg|count|first|last|max|min'
- const builtinFunctions = dedupe([
- ...SQL_DYNAMICS,
- ...Array.from(SQL_FUNCTIONS.keys()),
- ...(availableSqlFunctions?.keys() || []),
- ]).join('|');
-
- // Stuff like: 'int|numeric|decimal|date|varchar|char|bigint|float|double|bit|binary|text|set|timestamp'
- const dataTypes = Array.from(SQL_DATA_TYPES.keys()).join('|');
-
- const keywordMapper = rules.createKeywordMapper(
- {
- 'support.function': builtinFunctions,
- 'keyword': keywords,
- 'constant.language': builtinConstants,
- 'storage.type': dataTypes,
- },
- 'identifier',
- true,
- );
-
- rules.$rules = {
- start: [
- {
- token: 'comment.issue',
- regex: '--:ISSUE:.*$',
- },
- {
- token: 'comment',
- regex: '--.*$',
- },
- {
- token: 'comment',
- start: '/\\*',
- end: '\\*/',
- },
- {
- token: 'variable.column', // " quoted reference
- regex: '".*?"',
- },
- {
- token: 'string', // ' string literal
- regex: "'.*?'",
- },
- {
- token: 'constant.numeric', // float
- regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b',
- },
- {
- token: keywordMapper,
- regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b',
- },
- {
- token: 'keyword.operator',
- regex: '\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|=',
- },
- {
- token: 'paren.lparen',
- regex: '[\\(]',
- },
- {
- token: 'paren.rparen',
- regex: '[\\)]',
- },
- {
- token: 'text',
- regex: '\\s+',
- },
- ],
- };
- rules.normalizeRules();
- return rules;
-}
-
-class DsqlMode extends TextMode {
- $highlightRules = createDsqlHighlightRules();
- $behaviour = this.$defaultBehaviour;
- $id = 'ace/mode/dsql';
- lineCommentStart = '--';
-
- getCompletions() {
- return [];
- }
-}
-
-registerAceMode('dsql', DsqlMode);
-
-/**
- * Highlights the functions that the cluster has (in addition to the documented ones) in editors created after this call
- */
-export function initAceDsqlMode(sqlFunctions: AvailableFunctions | undefined) {
- availableSqlFunctions = sqlFunctions;
- resetAceMode('dsql');
-}
diff --git a/web-console/src/ace-modes/hjson.ts b/web-console/src/ace-modes/hjson.ts
deleted file mode 100644
index e7073a3bdb2..00000000000
--- a/web-console/src/ace-modes/hjson.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// This file a modified version of the file located at
-// https://github.com/thlorenz/brace/blob/master/mode/hjson.js
-// Originally licensed under the MIT license (https://github.com/thlorenz/brace/blob/master/LICENSE)
-// This file was modified to remove the folding functionality that did not play nice when loaded along side the
-// sql mode (which does not have any folding function)
-
-import { registerAceMode, TextHighlightRules, TextMode } from './ace-mode-helpers';
-
-function createHjsonHighlightRules() {
- const rules = new TextHighlightRules();
-
- rules.$rules = {
- 'start': [
- {
- include: '#comments',
- },
- {
- include: '#rootObject',
- },
- {
- include: '#value',
- },
- ],
- '#array': [
- {
- token: 'paren.lparen',
- regex: /\[/,
- push: [
- {
- token: 'paren.rparen',
- regex: /\]/,
- next: 'pop',
- },
- {
- include: '#value',
- },
- {
- include: '#comments',
- },
- {
- token: 'text',
- regex: /,|$/,
- },
- {
- token: 'invalid.illegal',
- regex: /[^\s\]]/,
- },
- {
- defaultToken: 'array',
- },
- ],
- },
- ],
- '#comments': [
- {
- token: ['comment.punctuation', 'comment.line'],
- regex: /(#)(.*$)/,
- },
- {
- token: 'comment.punctuation',
- regex: /\/\*/,
- push: [
- {
- token: 'comment.punctuation',
- regex: /\*\//,
- next: 'pop',
- },
- {
- defaultToken: 'comment.block',
- },
- ],
- },
- {
- token: ['comment.punctuation', 'comment.line'],
- regex: /(\/\/)(.*$)/,
- },
- ],
- '#constant': [
- {
- token: 'constant',
- regex: /\b(?:true|false|null)\b/,
- },
- ],
- '#keyname': [
- {
- token: 'keyword',
- regex: /(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*(?=:)/,
- },
- ],
- '#mstring': [
- {
- token: 'string',
- regex: /'''/,
- push: [
- {
- token: 'string',
- regex: /'''/,
- next: 'pop',
- },
- {
- defaultToken: 'string',
- },
- ],
- },
- ],
- '#number': [
- {
- token: 'constant.numeric',
- regex: /-?(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:[eE][+-]?\d+)?)?/,
- comment: 'handles integer and decimal numbers',
- },
- ],
- '#object': [
- {
- token: 'paren.lparen',
- regex: /\{/,
- push: [
- {
- token: 'paren.rparen',
- regex: /\}/,
- next: 'pop',
- },
- {
- include: '#keyname',
- },
- {
- include: '#value',
- },
- {
- token: 'text',
- regex: /:/,
- },
- {
- token: 'text',
- regex: /,/,
- },
- {
- defaultToken: 'paren',
- },
- ],
- },
- ],
- '#rootObject': [
- {
- token: 'paren',
- regex: /(?=\s*(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*:)/,
- push: [
- {
- token: 'paren.rparen',
- regex: /---none---/,
- next: 'pop',
- },
- {
- include: '#keyname',
- },
- {
- include: '#value',
- },
- {
- token: 'text',
- regex: /:/,
- },
- {
- token: 'text',
- regex: /,/,
- },
- {
- defaultToken: 'paren',
- },
- ],
- },
- ],
- '#string': [
- {
- token: 'string',
- regex: /"/,
- push: [
- {
- token: 'string',
- regex: /"/,
- next: 'pop',
- },
- {
- token: 'constant.language.escape',
- regex: /\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/,
- },
- {
- token: 'invalid.illegal',
- regex: /\\./,
- },
- {
- defaultToken: 'string',
- },
- ],
- },
- ],
- '#ustring': [
- {
- token: 'string',
- regex: /\b[^:,0-9\-{[}\]\s].*$/,
- },
- ],
- '#value': [
- {
- include: '#constant',
- },
- {
- include: '#number',
- },
- {
- include: '#string',
- },
- {
- include: '#array',
- },
- {
- include: '#object',
- },
- {
- include: '#comments',
- },
- {
- include: '#mstring',
- },
- {
- include: '#ustring',
- },
- ],
- };
-
- rules.normalizeRules();
- return rules;
-}
-
-class HjsonMode extends TextMode {
- $highlightRules = createHjsonHighlightRules();
- lineCommentStart = '//';
- blockComment = { start: '/*', end: '*/' };
- $id = 'ace/mode/hjson';
-}
-
-registerAceMode('hjson', HjsonMode);
diff --git a/web-console/src/bootstrap/ace.scss b/web-console/src/bootstrap/ace.scss
deleted file mode 100644
index 96d45d64664..00000000000
--- a/web-console/src/bootstrap/ace.scss
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-@use '../variables' as *;
-@use '../blueprint-overrides/common/colors' as *;
-
-// This file contains various overrides for the Ace editor.
-// Ideally they should be incorporated into a theme
-
-.ace-solarized-dark {
- background-color: rgba($dark-gray1, 0.5);
-
- // START: Custom code styles
- .ace_variable.ace_column {
- color: #2ceefb;
- }
-
- .ace_comment.ace_issue {
- color: #cb3116;
- text-decoration: underline;
- text-decoration-style: wavy;
- }
-
- .ace_storage.ace_type {
- color: #27c923;
- }
- // END: Custom code styles
-
- &.no-background {
- background-color: transparent;
- }
-
- &.placeholder-padding {
- .ace_placeholder {
- // react-ace renders the placeholder outside of the editor content so it does not get the padding set with
- // renderer.setPadding and renderer.setScrollMargin
- margin-top: 10px;
- }
- }
-
- .ace_gutter {
- background: $gray5;
- color: $gray1;
-
- .#{$bp-ns}-dark & {
- background: $dark-gray4;
- color: $gray5;
- }
- }
-
- .ace_gutter-active-line {
- background-color: rgba($gray1, 0.6);
- }
-
- &:not(.ace_autocomplete) .ace_text-layer {
- filter: brightness(1.5) saturate(0.9);
- }
-
- &.ace_editor.ace_dark.ace_autocomplete {
- background: $dark-gray4;
- border: none;
- border-radius: 2px;
- box-shadow: 0 5px 15px rgba(15, 19, 32, 0.45);
-
- .ace_text-layer {
- filter: brightness(1.1);
- }
- }
-}
-
-// Two classes to win over Ace's `.ace_tooltip.ace_dark`
-.ace_tooltip.ace_tooltip {
- padding: 10px;
- // The doc tooltip is rendered inside the autocomplete popup, do not inherit its code font
- font-family: $pt-font-family;
- font-size: $pt-font-size;
- line-height: normal;
- color: #c1ccd5;
- width: 500px;
- // Ace limits the doc tooltip to 400x300 (scrolling the rest)
- max-width: none !important;
- max-height: none !important;
- display: block;
- height: auto;
- white-space: initial;
- background: $gray2;
- border: none;
- border-radius: 2px;
- box-shadow: 0 5px 15px rgba(15, 19, 32, 0.45);
-
- .#{$bp-ns}-dark & {
- background: $dark-gray4;
- }
-
- & > * {
- filter: brightness(1.1);
- }
-
- .doc-name {
- font-size: 18px;
- border-bottom: 2px solid rgba(193, 204, 213, 0.5);
- padding-bottom: 4px;
- color: #93ca12;
- }
-
- .doc-syntax {
- padding-top: 8px;
- padding-bottom: 10px;
- }
-
- .doc-name,
- .doc-syntax {
- font-family: Monaco, Menlo, 'Ubuntu Mono', Consolas, source-code-pro, monospace;
- }
-}
diff --git a/web-console/src/bootstrap/ace.ts b/web-console/src/bootstrap/ace.ts
deleted file mode 100644
index d68044b5bd4..00000000000
--- a/web-console/src/bootstrap/ace.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Import Ace editor and all the sub components used in the app. Everything is bundled so Ace never needs to load
-// anything at runtime.
-import 'ace-builds';
-import 'ace-builds/src-noconflict/ext-language_tools';
-import 'ace-builds/src-noconflict/ext-searchbox';
-import 'ace-builds/src-noconflict/theme-solarized_dark';
-import '../ace-modes/dsql';
-import '../ace-modes/hjson';
-
-import './ace.scss';
diff --git a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap
index 71de572ea03..9da2e03013f 100644
--- a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap
+++ b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap
@@ -400,11 +400,14 @@ exports[`AutoForm matches snapshot 1`] = `
class="json-input"
>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark"
- id="ace-editor"
- style="width: 100%; height: 8vh; font-size: 12px;"
+ class="code-editor"
+ style="height: 8vh;"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "" -->
+ </div>
</div>
</div>
</div>
diff --git a/web-console/src/components/code-editor/README.md b/web-console/src/components/code-editor/README.md
new file mode 100644
index 00000000000..29a7a81317e
--- /dev/null
+++ b/web-console/src/components/code-editor/README.md
@@ -0,0 +1,323 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ -->
+
+# CodeEditor
+
+`CodeEditor` is the web console's code editor: a React wrapper around [CodeMirror 6](https://codemirror.net/). Use it
+whenever the console shows or edits SQL or JSON: the workbench query input, the explore view's SQL inputs, JSON spec
+dialogs, read-only JSON views and so on.
+
+It is a controlled component that you give a string and an `onChange` callback. It comes with the console's look
+(syntax colors, gutter, autocomplete popup), DruidSQL and Hjson highlighting and context-aware autocomplete.
+
+## Files
+
+| File | What it holds |
+| ---------------------- | ----------------------------------------------------------------------------- |
+| `code-editor.tsx` | The component, its props, the CodeMirror setup and the `focusEditorAt` helper |
+| `code-editor-theme.ts` | The editor theme (layout, gutter, popups) and the syntax highlighting colors |
+| `completion-source.ts` | Turns a completion builder into a CodeMirror completion source |
+| `completion-doc.ts` | Renders the documentation panel shown next to the completion list |
+| `doc-markdown.ts` | Renders "doc markdown" (the format of the SQL docs) as DOM elements |
+| `search-panel.tsx` | The find (and replace) panel, made out of Blueprint components |
+| `error-mark.ts` | Underlines an error (like a parse error) in the text, see `showEditorError` |
+| `code-editor.scss` | The styles for the wrapper element and the find panel |
+
+Related code lives elsewhere:
+
+| Location | What it holds |
+| ------------------------- | ---------------------------------------------------------------------------------- |
+| `src/editor-languages/` | The `dsql()` and `hjson()` languages: Lezer grammars, highlighting and completions |
+| `src/editor-completions/` | What to suggest when autocompleting SQL and Hjson |
+
+## Basic usage
+
+```tsx
+import { CodeEditor } from '../../components';
+import { dsql } from '../../editor-languages/dsql';
+import { hjson } from '../../editor-languages/hjson';
+
+// Editable SQL (memoize the language, see Languages)
+const sqlLanguage = useMemo(() => dsql({ availableSqlFunctions }), [availableSqlFunctions]);
+<CodeEditor language={sqlLanguage} value={sql} onChange={setSql} showLineNumbers />
+
+// Read-only JSON, sized with CSS on its className
+<CodeEditor className="spec-viewer" language={hjson()} value={JSON.stringify(spec, undefined, 2)} readOnly />
+```
+
+## Props
+
+| Prop | Type | Notes |
+| ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `value` | `string` | Required. The text to show. |
+| `onChange` | `(value: string) => void` | Called when the **user** edits the text. |
+| `readOnly` | `boolean` | Makes the editor read-only. |
+| `onBlur` | `() => void` | |
+| `language` | `LanguageSupport` | `dsql(...)` or `hjson(...)`. Brings highlighting, completions, comment toggling and bracket closing. Plain text without one. |
+| `className` | `string` | Added to the wrapper `div` (which always has the `code-editor` class). Size the editor with CSS on it. |
+| `style` | `CSSProperties` | Inline styles for the wrapper, for a size that changes (like `JsonInput`'s `height`). With no height it grows with the text. |
+| `showLineNumbers` | `boolean` | Shows the line numbers. |
+| `padding` | `number` | Pads the text on all sides (in px). By default there is only a little horizontal padding. |
+| `transparentBackground` | `boolean` | Drops the editor's own background. |
+| `placeholder` | `string` | Shown while the editor is empty. |
+| `autoFocus` | `boolean` | Focuses the editor when it mounts. |
+| `stateCacheId` | `string` | Remembers the undo history and selection under this id, so they survive the editor being unmounted. |
+| `extensions` | `Extension` | Extra CodeMirror extensions. **Only read when the editor is created.** |
+| `ref` | `Ref<EditorView>` | Gives you the underlying CodeMirror `EditorView`. |
+
+## How it works
+
+### The value is controlled
+
+The editor keeps its own CodeMirror document, and the component keeps that document in sync with `value`:
+
+- When the user types, `onChange` is called with the full new text.
+- When `value` changes from the outside (formatting a query, inserting a column name), the editor applies **only the
+ part that changed**, so the cursor stays put and the change can be undone. External changes do **not** call
+ `onChange`.
+
+You can therefore treat it like an `<input>`. If the parent rewrites or rejects what was typed, the editor shows what
+the parent passes back.
+
+### Props that change vs. props read once
+
+Most props (`language`, `onChange`, `readOnly`, `showLineNumbers`, `padding`, `transparentBackground`, `placeholder`,
+`onBlur`) can change at any time. The
+editor updates in place without being recreated. The workbench input uses this to switch between `dsql()` and
+`hjson()` as soon as the text starts with `{`. The editor is reconfigured whenever `language` is a new object, so keep
+it stable between renders (see [Languages](#languages)).
+
+`extensions` and `autoFocus` are only read when the editor is created. Changing `stateCacheId` recreates
+the editor.
+
+### Remembering state between mounts
+
+When `stateCacheId` is set, the editor saves its state (undo history and selection) when it unmounts. When an editor
+with the same id mounts again, it restores that state. The workbench uses this so that undo still works after you
+switch tabs and come back. If `value` changed while the editor was away, the restored editor is updated to match.
+
+The saved states are kept inside `code-editor.tsx`. Call `forgetEditorState(id)` once an id will not be shown again
+(the workbench does this when a tab is closed).
+
+## Autocomplete
+
+The completions come with the language: `dsql(...)` always completes and `hjson({ jsonCompletions })` completes when
+given rules. Each attaches a CodeMirror completion source as language data (`language.data.of({ autocomplete })`), so
+switching the language switches the completions. Suggestions appear as the user types a word, and Ctrl-Space opens the
+list on demand. Enter or Tab accepts the selected one. Read-only editors don't complete.
+
+The completion builders in `src/editor-completions/` (`getSqlCompletions`, `getHjsonCompletions`) are
+`EditorCompletionBuilder`s. `makeCompletionSource` (`completion-source.ts`) turns one into a completion source: it
+works out the word being completed (`state.wordAt`, so the word characters are letters, digits and `_` plus the
+language's `wordChars` language data: `$` for DruidSQL, `$` and `-` for Hjson), skips read-only editors, and calls the builder with CodeMirror's
+`CompletionContext` and the word:
+
+```ts
+type EditorCompletionBuilder = (
+ context: CompletionContext, // CodeMirror's: state, pos, explicit, matchBefore(...)
+ word: CompletionWord, // { from, text }: the (partial) word being completed, it ends at pos
+) => readonly EditorCompletion[];
+```
+
+Builders read whatever text they need from `context.state`. To find out what the cursor is in, they look at the syntax
+tree that the language builds (`tokenBefore(state, pos)` in `completion-source.ts` gives the node name, like
+`LineComment` or `String`) instead of looking for `--` or quotes in the text. Builders that look at the whole text
+(the SQL references and literals, the Hjson context) use `completeSyntaxTree(state)`, which parses the rest of the
+text if the editor has not yet. In specs, `completionContextAt(dsql(),
+'SELECT * FROM t WHERE |')` (`src/test-utils/completion-context.ts`) makes the context for the cursor at the `|`.
+
+A builder returns `EditorCompletion`s (from `src/editor-completions/editor-completion.ts`):
+
+```ts
+// CodeMirror's Completion (label, displayLabel, detail, boost, ...) with the doc as data instead of an info
+type EditorCompletion = Omit<Completion, 'info'> & {
+ doc?: CompletionDoc; // Shown in a panel next to the list when the item is selected
+};
+
+interface CompletionDoc {
+ name: string; // The title
+ syntax?: string; // Plain text in monospace under the title, like a function signature
+ description?: string; // Plain text
+ descriptionMarkdown?: string; // "Doc markdown", the simplified markdown of the docs in src/editor-languages/dsql-docs.ts
+}
+```
+
+`makeCompletionSource` turns the `doc` into CodeMirror's `info`: the editor renders the doc panel itself
+(`completion-doc.ts`), so completion builders only describe what to show. No HTML strings are involved: plain text is
+inserted as text, and "doc markdown" is turned into elements by `renderDocMarkdown` (`doc-markdown.ts`).
+
+"Doc markdown" is what `script/create-sql-docs.mjs` produces from the Druid docs for `src/editor-languages/dsql-docs.ts`. It only has
+`` `code` ``, `*emphasis*`, line breaks (`\n`), list items (lines starting with `- `) and backslash escapes. The script
+drops links, converts `<br>` and simple lists, and fails on any other HTML.
+
+Usually you don't write completions yourself. Pass what the builders need to the language:
+
+```tsx
+const language = useMemo(
+ () => dsql({ columnMetadata, availableSqlFunctions }),
+ [columnMetadata, availableSqlFunctions],
+);
+
+<CodeEditor language={language} value={sql} onChange={setSql} />;
+```
+
+Things to know:
+
+- The editor filters and ranks your list against the prefix (fuzzy matching), so you don't need to filter it yourself.
+- The popup and its doc panel are rendered in a shared container at the start of `<body>`, so they are never clipped by
+ dialogs or popovers that contain the editor.
+
+## Working with the `EditorView`
+
+For anything beyond the props, use `ref` to get the CodeMirror `EditorView`:
+
+```tsx
+const viewRef = useRef<EditorView | undefined>(undefined);
+
+<CodeEditor ref={viewRef} value={sql} onChange={setSql} />;
+
+// Later: put the cursor on line 3, column 5 (both 1-based) and focus the editor
+if (viewRef.current) focusEditorAt(viewRef.current, { line: 3, column: 5 });
+```
+
+`focusEditorAt` is exported next to the component. It takes a `LineColumn` (from `src/utils/general.tsx`): 1-based,
+the way Druid and Hjson errors report positions and the way CodeMirror numbers lines, so error positions are passed
+straight through. It clamps out-of-range positions and scrolls the cursor into view. It is used to jump to the location
+of query errors.
+
+`showEditorError(view, { position, message })` (also exported next to the component) underlines an error in the
+text with a wavy line, and shows the message in a `data-tooltip` on hover (the console's mouse tooltip, see
+`initMouseTooltip`). It marks the syntax node at the position (if it is on one line), otherwise the rest of the line,
+or the last character before the position when it is at the end of a line. The mark goes away on the next change to
+the text, or with `showEditorError(view, undefined)`. `JsonInput` marks its Hjson error once the editor loses focus
+(when the message under the editor shows up too), and the workbench marks the issue of a JSON query when it is run.
+`getHjsonEditorError` (in `json-input.tsx`) turns an Hjson error message into the position and a message without the
+position.
+
+`SqlInput` and `FlexibleQueryInput` pass their `ref` on to the `CodeEditor`, so their callers get the `EditorView` the
+same way. For example, the workbench calls `focusEditorAt(queryInputRef.current, position)` to jump to an error.
+
+If you need decorations, gutter markers or other custom behavior, pass CodeMirror extensions through `extensions`.
+The "run this query" markers of `FlexibleQueryInput` (`sub-query-markers.ts` in
+`src/views/workbench-view/flexible-query-input/`) are the example to read. `subQueryMarkers(onRun)` is one extension
+made of:
+
+- a `StateField` that finds the queries in the document whenever it changes, and provides the markers to the
+ `lineNumberMarkers` facet and the hover highlight to `EditorView.decorations`
+- `lineNumbers({ domEventHandlers })` for the click and hover, which hands over the line that was clicked or hovered
+- a `StateEffect` to set the hovered query
+
+React only provides `onRun`. Because `extensions` is only read once, `FlexibleQueryInput` puts the markers in its own
+`Compartment` and reconfigures it when they are turned on or off.
+
+## Languages
+
+`src/editor-languages/` exports a `LanguageSupport` factory per language. A `LanguageSupport` is CodeMirror's bundle
+of a language and the extensions that go with it:
+
+- **`dsql({ availableSqlFunctions, columnMetadata, columns, skipAggregates })`** (DruidSQL): Keywords, functions,
+ data types and constants come from `dsql-keywords.ts` and `dsql-docs.ts`, plus the `availableSqlFunctions` that the
+ cluster reports (from `useAvailableSqlFunctions()`). Double-quoted references (`"column"`) and `--:ISSUE:` comments
+ get their own colors. It auto-closes `(`, `[`, `'` and `"` (not `{`), and Cmd/Ctrl-/ toggles `--` comments. The
+ options are passed on to `getSqlCompletions`. Each call makes a new `LanguageSupport`, so memoize it.
+- **`hjson({ jsonCompletions })`** (Hjson/JSON): highlights keys, strings, numbers, escapes and `#`, `//` and `/* */`
+ comments, including objects without the outer braces. It returns the same object for the same `jsonCompletions`,
+ so `hjson()` can be called while rendering.
+
+Both languages are Lezer grammars (`dsql.grammar`, `hjson.grammar` in `src/editor-languages/`), defined with
+`LRLanguage.define`. The node names are mapped to highlighting tags with `styleTags` in `dsql.ts` and `hjson.ts`.
+
+- **DruidSQL** is a list of tokens (keywords, identifiers, strings, numbers, comments, ...) with nested `Parens`. It
+ does not parse the statements, Druid does that. Which identifiers are keywords, functions, constants or types is
+ decided by an external specializer (`dsql-tokens.ts`), because the functions that the cluster has are only known at
+ runtime: `getDsqlLanguage(availableSqlFunctions)` configures the parser with a specializer that knows them
+ (`parser.configure({ specializers })`), one language per set of functions (and the same one for the same set). When
+ the functions arrive, the editors that are open re-highlight right away. String literals and quoted identifiers do
+ not span lines: one that is not closed yet lasts until the end of the line. A sign (`-1`) is an operator.
+- **Hjson** is parsed for real: `Object`s of `Property`s (a `PropertyName` and a value), `Array`s, the root object
+ without braces, the kinds of strings (with `Escape`s) and comments. The two parts of Hjson that depend on the
+ context, keys without quotes and quoteless strings (which last until the end of the line, unless the line is just a
+ number or `true`, `false` or `null`), come from an external tokenizer (`hjson-tokens.ts`). Quoted strings do not
+ span lines: one that is not closed yet ends with the end of the line.
+
+The parsers are generated by `script/build-grammars.mjs` (part of `script/build`, so `npm run compile` and the Maven
+build run it) into `<name>.parser.ts` and `<name>.parser.terms.ts`. They are not checked in, like `src/editor-languages/dsql-docs.ts`.
+Run the script after changing a grammar.
+
+To add a token type, add it to the grammar, give it a tag in the language's `styleTags`, and give the tag a color in
+`codeEditorHighlightStyle` (in `code-editor-theme.ts`) if it does not have one. Mind that a child node without a tag
+is not styled as part of its parent: tokens like the quotes of a string are anonymous (lowercase) for that reason.
+
+Both languages keep the indentation of the line before on Enter (`indentNodeProp` returns `null`), rather than
+indenting inside brackets.
+
+## Styling
+
+The theme is a dark one with the `solarized_dark` colors plus the console's overrides. Most of it is in `code-editor-theme.ts` as an `EditorView.theme(...)`, which CodeMirror scopes to
+the editor and to its tooltips. Some things to know before changing it:
+
+- **The tooltips live in one shared element at the start of `<body>`** (`getTooltipHost` in `code-editor.tsx`), so
+ that dialogs, popovers and the editor's own `overflow: hidden` don't clip them, and so that it doesn't become
+ `document.body.lastChild`, which the dialog specs snapshot. The comment on `getTooltipHost` has the details.
+
+- **The token colors are pre-brightened.** They are the `solarized_dark` colors put through
+ `filter: brightness(1.5) saturate(0.9)`, and `codeEditorHighlightStyle` has the result. Don't add a CSS filter to `.cm-content`,
+ because it would also brighten the active-line and highlight backgrounds.
+- **Console colors are copied as constants.** The theme lives in TypeScript, so the few SCSS colors it needs
+ (`$dark-gray1`, `$dark-gray4`, `$gray1`, `$gray5`) are copied in with a comment pointing to
+ `blueprint-overrides/common/_colors.scss`. Keep them in sync if those change.
+- **Mind selector specificity.** CodeMirror's base theme uses selectors like `&dark .cm-gutters`. A theme rule wins
+ when its selector is as specific as the base one. `codeEditorPaddingTheme` uses slightly more specific selectors on
+ purpose so it beats the main theme.
+- **The wrapper** gets `position: relative; overflow: hidden` (in `code-editor.scss`), so the
+ editor never pushes past the size it is given in flex layouts.
+- **The find panel is React.** `createSearchPanel` (`search-panel.tsx`) replaces CodeMirror's own panel, which has
+ its own tiny buttons, with Blueprint inputs, buttons and checkboxes so that it looks like the rest of the console. It
+ renders into the panel's element with its own React root, and re-renders when the search query (or read-only state)
+ changes. The search logic is still CodeMirror's (`findNext`, `replaceAll`, …) and so is the keymap: the panel runs
+ the `search-panel` scope handlers on keydown, so Escape closes it, and Enter / Shift-Enter go to the next / previous
+ match. Its layout is in `code-editor.scss`.
+- **Style it from the outside** with the wrapper's `className` (for size, borders and layout), or `.cm-*` class names
+ under it when you need to.
+
+## Keyboard
+
+The standard CodeMirror keymaps are on: editing, undo/redo, search (Cmd/Ctrl-F) and autocomplete. With nothing
+selected Tab inserts spaces up to the next 2-column tab stop. With a selection it indents the selected lines, and
+Shift-Tab un-indents.
+
+## Testing
+
+DOM snapshots don't include CodeMirror's internal DOM. The snapshot serializer (`src/test-utils/snapshot-serializer.ts`)
+replaces each editor with a comment listing what the console configured, for example:
+
+```html
+<div class="code-editor query-string">
+ <div class="cm-editor">
+ <!-- Code editor, language: hjson, value: "{\n \"a\": 1\n}", read only -->
+ </div>
+</div>
+```
+
+So a snapshot changes only when the language, value, placeholder or read-only state changes, not when CodeMirror is
+upgraded.
+
+The languages are tested directly in `src/editor-languages/editor-languages.spec.ts`. It parses text with the
+language's parser and checks the node produced for each piece of text, checks a few styles against
+`codeEditorHighlightStyle`, and checks that `dsql()` and `hjson()` bring their completion sources.
diff --git a/web-console/src/components/code-editor/code-editor-theme.ts b/web-console/src/components/code-editor/code-editor-theme.ts
new file mode 100644
index 00000000000..12cf337f7e9
--- /dev/null
+++ b/web-console/src/components/code-editor/code-editor-theme.ts
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { HighlightStyle } from '@codemirror/language';
+import { EditorView } from '@codemirror/view';
+import { tags } from '@lezer/highlight';
+
+import { editorTags } from '../../editor-languages/editor-tags';
+
+// A dark theme with the solarized_dark colors and the console's overrides
+
+const MONOSPACE_FONT =
+ "Monaco, Menlo, 'Ubuntu Mono', Consolas, 'Source Code Pro', source-code-pro, monospace";
+
+// Colors from blueprint-overrides/common/_colors.scss
+const DARK_GRAY1 = '24, 28, 45';
+const DARK_GRAY4 = '#383d57';
+const GRAY1 = '96, 101, 128';
+const GRAY5 = '#bdc1d1';
+
+// The color of the issue comments, also used to underline errors
+const ERROR_COLOR = '#f04d29';
+
+const POPUP_SHADOW = '0 5px 15px rgba(15, 19, 32, 0.45)';
+
+export const codeEditorTheme = EditorView.theme(
+ {
+ '&': {
+ height: '100%',
+ fontSize: '12px',
+ color: '#c7dde0',
+ backgroundColor: `rgba(${DARK_GRAY1}, 0.5)`,
+ },
+ '&.cm-focused': {
+ outline: 'none',
+ },
+ '.cm-scroller': {
+ fontFamily: MONOSPACE_FONT,
+ lineHeight: 'normal',
+ },
+ '.cm-content': {
+ padding: '0',
+ },
+ '.cm-line': {
+ padding: '0 4px',
+ },
+ '.cm-cursor, .cm-dropCursor': {
+ borderLeft: '2px solid #d30102',
+ },
+ '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground':
+ {
+ background: 'rgba(255, 255, 255, 0.1)',
+ },
+ '.cm-activeLine': {
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ },
+ '.cm-gutters': {
+ backgroundColor: DARK_GRAY4,
+ color: GRAY5,
+ border: 'none',
+ },
+ '.cm-activeLineGutter': {
+ backgroundColor: `rgba(${GRAY1}, 0.6)`,
+ },
+ '.cm-lineNumbers .cm-gutterElement': {
+ padding: '0 13px 0 21px',
+ minWidth: '0',
+ },
+ '.cm-placeholder': {
+ color: '#657b83',
+ opacity: '0.7',
+ fontStyle: 'italic',
+ fontFamily: 'arial',
+ transform: 'scale(0.9)',
+ transformOrigin: 'left',
+ },
+ '&.cm-focused .cm-matchingBracket': {
+ backgroundColor: 'transparent',
+ outline: '1px solid rgba(147, 161, 161, 0.5)',
+ },
+ '&.cm-focused .cm-nonmatchingBracket': {
+ backgroundColor: 'transparent',
+ },
+ // See error-mark.ts
+ '.cm-errorMark': {
+ textDecoration: `underline wavy ${ERROR_COLOR}`,
+ textDecorationSkipInk: 'none',
+ },
+ '.cm-panels': {
+ backgroundColor: DARK_GRAY4,
+ },
+
+ // Autocomplete
+ '.cm-tooltip': {
+ border: 'none',
+ borderRadius: '2px',
+ backgroundColor: DARK_GRAY4,
+ boxShadow: POPUP_SHADOW,
+ },
+ '.cm-tooltip.cm-tooltip-autocomplete > ul': {
+ width: '300px',
+ maxHeight: `${8 * 1.4}em`,
+ fontFamily: MONOSPACE_FONT,
+ fontSize: '12px',
+ lineHeight: '1.4',
+ color: '#d4d4d4',
+ },
+ '.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
+ display: 'flex',
+ padding: '0 4px',
+ lineHeight: '1.4',
+ },
+ '.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover': {
+ backgroundColor: 'rgba(58, 103, 78, 0.62)',
+ boxShadow: 'inset 0 0 0 1px rgba(109, 150, 13, 0.8)',
+ },
+ '.cm-tooltip-autocomplete ul li[aria-selected]': {
+ backgroundColor: '#3a674e',
+ color: 'inherit',
+ },
+ '.cm-completionLabel': {
+ flex: '0 1 auto',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ '.cm-completionMatchedText': {
+ textDecoration: 'none',
+ color: '#a2de14',
+ },
+ '.cm-completionDetail': {
+ flex: 'none',
+ marginLeft: 'auto',
+ paddingLeft: '0.9em',
+ fontStyle: 'normal',
+ opacity: '0.5',
+ },
+ '.cm-tooltip.cm-completionInfo': {
+ boxSizing: 'border-box',
+ width: '500px',
+ maxWidth: 'none',
+ padding: '10px',
+ whiteSpace: 'initial',
+ color: '#c1ccd5',
+ backgroundColor: DARK_GRAY4,
+ boxShadow: POPUP_SHADOW,
+ },
+ '.cm-completionInfo > *': {
+ filter: 'brightness(1.1)',
+ },
+ '.cm-completionInfo .doc-name': {
+ fontSize: '18px',
+ borderBottom: '2px solid rgba(193, 204, 213, 0.5)',
+ paddingBottom: '4px',
+ color: '#93ca12',
+ },
+ '.cm-completionInfo .doc-syntax': {
+ paddingTop: '8px',
+ paddingBottom: '10px',
+ whiteSpace: 'pre-wrap', // One line per signature
+ },
+ '.cm-completionInfo .doc-name, .cm-completionInfo .doc-syntax': {
+ fontFamily: MONOSPACE_FONT,
+ },
+ },
+ { dark: true },
+);
+
+/**
+ * Drops the editor's own background (the selector is more specific than the one in codeEditorTheme so that it wins)
+ */
+export const codeEditorTransparentTheme = EditorView.theme({
+ '&.cm-editor': {
+ backgroundColor: 'transparent',
+ },
+});
+
+/**
+ * Pads the text by the given amount (the default is a bit of horizontal padding)
+ */
+export function codeEditorPaddingTheme(padding: number) {
+ // The selectors are more specific than the ones in codeEditorTheme so that they win
+ return EditorView.theme({
+ '.cm-scroller .cm-content': {
+ padding: `${padding}px 0`,
+ },
+ '.cm-content .cm-line': {
+ padding: `0 ${padding}px`,
+ },
+ });
+}
+
+// The colors were brightened from the solarized_dark ones with `filter: brightness(1.5) saturate(0.9)`
+export const codeEditorHighlightStyle = HighlightStyle.define([
+ { tag: [tags.keyword, tags.propertyName], color: '#c8e315' },
+ { tag: tags.function(tags.variableName), color: '#45cef7' },
+ { tag: [tags.atom, tags.escape], color: '#facd14' },
+ { tag: tags.typeName, color: '#49f943' },
+ { tag: tags.number, color: '#f256bc' },
+ { tag: tags.string, color: '#4deee1' },
+ { tag: tags.comment, color: '#9ab8c3', fontStyle: 'italic' },
+ {
+ tag: editorTags.issue,
+ color: ERROR_COLOR,
+ fontStyle: 'italic',
+ textDecoration: 'underline wavy',
+ },
+ { tag: editorTags.column, color: '#51fbfb' },
+]);
diff --git a/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss b/web-console/src/components/code-editor/code-editor.scss
similarity index 60%
copy from web-console/src/dialogs/show-value-dialog/show-value-dialog.scss
copy to web-console/src/components/code-editor/code-editor.scss
index 6ba7ebdc529..9046faad2e0 100644
--- a/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss
+++ b/web-console/src/components/code-editor/code-editor.scss
@@ -18,31 +18,37 @@
@use '../../variables' as *;
-.show-value-dialog {
- &.normal.#{$bp-ns}-dialog {
- height: 600px;
- }
+// The editor never grows past the size it is given (so that it does not push out its container in flex layouts)
+.code-editor {
+ position: relative;
+ overflow: hidden;
+}
- &.large.#{$bp-ns}-dialog {
- width: 90vw;
- height: 90vh;
- }
+// The find (and replace) panel, see search-panel.tsx
+.code-editor-search-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 5px;
+ font-size: $pt-font-size;
- .#{$bp-ns}-dialog-body {
+ .search-row,
+ .replace-row {
display: flex;
- flex-direction: column;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 5px 10px;
+ }
- .ace-editor {
- flex: 1;
- }
+ .search-field {
+ width: 220px;
+ }
- .#{$bp-ns}-input {
- flex: 1;
- resize: none;
- }
+ .#{$bp-ns}-control.#{$bp-ns}-inline {
+ margin: 0;
}
- .#{$bp-ns}-dialog-footer {
- margin-top: 0;
+ .close-button {
+ margin-left: auto;
}
}
diff --git a/web-console/src/components/code-editor/code-editor.spec.tsx b/web-console/src/components/code-editor/code-editor.spec.tsx
new file mode 100644
index 00000000000..a1bdf11bec6
--- /dev/null
+++ b/web-console/src/components/code-editor/code-editor.spec.tsx
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { undo } from '@codemirror/commands';
+import { openSearchPanel } from '@codemirror/search';
+import type { EditorView } from '@codemirror/view';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+
+import { CodeEditor, forgetEditorState } from './code-editor';
+
+describe('CodeEditor', () => {
+ it('remembers the undo history between mounts until it is forgotten', () => {
+ function mount(value: string) {
+ const ref = createRef<EditorView | undefined>();
+ const rendered = render(
+ <CodeEditor ref={ref} value={value} onChange={() => {}} stateCacheId="spec" />,
+ );
+ return { view: ref.current!, unmount: rendered.unmount };
+ }
+
+ const first = mount('SELECT 1');
+ first.view.dispatch({ changes: { from: 7, to: 8, insert: '2' }, userEvent: 'input' });
+ first.unmount();
+
+ const second = mount('SELECT 2');
+ expect(undo(second.view)).toBe(true);
+ expect(second.view.state.doc.toString()).toEqual('SELECT 1');
+ second.unmount();
+
+ forgetEditorState('spec');
+ const third = mount('SELECT 2');
+ expect(undo(third.view)).toBe(false);
+ third.unmount();
+ });
+
+ it('finds and replaces with the search panel', () => {
+ const ref = createRef<EditorView | undefined>();
+ const onChange = jest.fn();
+ const { unmount } = render(<CodeEditor ref={ref} value="foo bar foo" onChange={onChange} />);
+ const view = ref.current!;
+
+ act(() => {
+ openSearchPanel(view);
+ });
+ const searchField = screen.getByLabelText<HTMLInputElement>('Find');
+ expect(document.activeElement).toBe(searchField);
+
+ fireEvent.change(searchField, { target: { value: 'foo' } });
+ fireEvent.keyDown(searchField, { key: 'Enter', keyCode: 13 });
+ expect(view.state.selection.main).toMatchObject({ from: 0, to: 3 });
+ fireEvent.keyDown(searchField, { key: 'Enter', keyCode: 13 });
+ expect(view.state.selection.main).toMatchObject({ from: 8, to: 11 });
+
+ fireEvent.change(screen.getByLabelText('Replace'), { target: { value: 'baz' } });
+ fireEvent.click(screen.getByText('Replace all'));
+ expect(onChange).toHaveBeenLastCalledWith('baz bar baz');
+
+ fireEvent.keyDown(searchField, { key: 'Escape', keyCode: 27 });
+ expect(screen.queryByLabelText('Find')).toBeNull();
+ unmount();
+ });
+});
diff --git a/web-console/src/components/code-editor/code-editor.tsx b/web-console/src/components/code-editor/code-editor.tsx
new file mode 100644
index 00000000000..3cbb4df8c64
--- /dev/null
+++ b/web-console/src/components/code-editor/code-editor.tsx
@@ -0,0 +1,396 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { acceptCompletion, autocompletion } from '@codemirror/autocomplete';
+import {
+ defaultKeymap,
+ history,
+ historyField,
+ historyKeymap,
+ indentLess,
+ indentMore,
+} from '@codemirror/commands';
+import type { LanguageSupport } from '@codemirror/language';
+import { bracketMatching, indentUnit, syntaxHighlighting } from '@codemirror/language';
+import { search, searchKeymap } from '@codemirror/search';
+import type { ChangeSpec, Extension } from '@codemirror/state';
+import {
+ Annotation,
+ Compartment,
+ countColumn,
+ EditorSelection,
+ EditorState,
+} from '@codemirror/state';
+import type { Command, Rect } from '@codemirror/view';
+import {
+ drawSelection,
+ EditorView,
+ highlightActiveLine,
+ highlightActiveLineGutter,
+ keymap,
+ lineNumbers,
+ placeholder as placeholderExtension,
+ tooltips,
+} from '@codemirror/view';
+import classNames from 'classnames';
+import type React from 'react';
+import { useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, useRef } from 'react';
+
+import { useConstant, usePermanentCallback } from '../../hooks';
+import type { LineColumn } from '../../utils';
+
+import {
+ codeEditorHighlightStyle,
+ codeEditorPaddingTheme,
+ codeEditorTheme,
+ codeEditorTransparentTheme,
+} from './code-editor-theme';
+import { editorErrorField, lineColumnToOffset } from './error-mark';
+import { createSearchPanel } from './search-panel';
+
+import './code-editor.scss';
+
+export type { EditorError } from './error-mark';
+export { showEditorError } from './error-mark';
+
+const TAB_SIZE = 2;
+
+/**
+ * Marks the changes that come from a new value being passed in (as opposed to the user editing the text)
+ */
+const externalChange = Annotation.define<boolean>();
+
+export interface CodeEditorProps {
+ ref?: React.Ref<EditorView | undefined>;
+ className?: string;
+ value: string;
+ onChange?: (value: string) => void;
+ readOnly?: boolean;
+ onBlur?: () => void;
+ /** The language (like dsql() or hjson()), it brings its own highlighting and completions. Plain text without one */
+ language?: LanguageSupport;
+ autoFocus?: boolean;
+ /** For a size that changes, the rest is best set with CSS on className. With no height the editor grows with the text */
+ style?: React.CSSProperties;
+ showLineNumbers?: boolean;
+ /** Pads the text on all sides (in px), the default is a bit of horizontal padding */
+ padding?: number;
+ /** Drops the editor's own background */
+ transparentBackground?: boolean;
+ placeholder?: string;
+ /** Makes the editor remember its state (undo history, selection) between mounts, see forgetEditorState */
+ stateCacheId?: string;
+ /** Additional extensions, only read when the editor is created */
+ extensions?: Extension;
+}
+
+/**
+ * What EditorState.toJSON gives: the text, the selection and (with historyField) the undo history
+ */
+type SavedEditorState = Record<string, unknown>;
+
+/**
+ * The saved states of the unmounted editors that have a stateCacheId
+ */
+const savedEditorStates = new Map<string, SavedEditorState>();
+
+/**
+ * Forgets the state saved for an editor with the given stateCacheId (once that editor will not be shown again)
+ */
+export function forgetEditorState(stateCacheId: string): void {
+ savedEditorStates.delete(stateCacheId);
+}
+
+let tooltipHost: HTMLElement | undefined;
+
+/**
+ * The element that the tooltips of all editors (the autocomplete list and its doc panel) are rendered in.
+ *
+ * - It is in the body so that the tooltips are not clipped by the containers of the editor: the
+ * editor wrapper itself (`overflow: hidden`), dialogs and popovers. CodeMirror's own z-index for tooltips puts them
+ * above Blueprint overlays.
+ * - It is put at the start of the body rather than the end. Blueprint renders dialogs, popovers and toasts into portals
+ * appended to the body, and the dialog specs snapshot `document.body.lastChild` expecting that portal. Appending the
+ * host (it is created the first time an editor mounts) would make it the last child instead.
+ * - All editors share it, so only one element is ever added.
+ */
+function getTooltipHost(): HTMLElement {
+ if (!tooltipHost) {
+ tooltipHost = document.createElement('div');
+ tooltipHost.className = 'code-editor-tooltips';
+ document.body.prepend(tooltipHost);
+ }
+ return tooltipHost;
+}
+
+function lineNumbersExtension(showLineNumbers: boolean | undefined): Extension {
+ return showLineNumbers ? [lineNumbers(), highlightActiveLineGutter()] : [];
+}
+
+function paddingExtension(padding: number | undefined): Extension {
+ return typeof padding === 'number' ? codeEditorPaddingTheme(padding) : [];
+}
+
+function transparentBackgroundExtension(transparentBackground: boolean | undefined): Extension {
+ return transparentBackground ? codeEditorTransparentTheme : [];
+}
+
+function readOnlyExtension(readOnly: boolean): Extension {
+ return EditorState.readOnly.of(readOnly);
+}
+
+function placeholderTextExtension(placeholder: string | undefined): Extension {
+ return placeholder ? placeholderExtension(placeholder) : [];
+}
+
+/**
+ * With nothing selected Tab inserts spaces up to the next tab stop (CodeMirror's indentWithTab would indent the whole
+ * line), otherwise it indents the selected lines
+ */
+const insertSoftTab: Command = view => {
+ const { state } = view;
+ if (state.readOnly) return false;
+ if (state.selection.ranges.some(range => !range.empty)) return indentMore(view);
+
+ view.dispatch(
+ state.changeByRange(range => {
+ const line = state.doc.lineAt(range.head);
+ const column = countColumn(line.text.slice(0, range.head - line.from), TAB_SIZE);
+ const spaces = ' '.repeat(TAB_SIZE - (column % TAB_SIZE));
+ return {
+ changes: { from: range.head, insert: spaces },
+ range: EditorSelection.cursor(range.head + spaces.length),
+ };
+ }),
+ { scrollIntoView: true, userEvent: 'input.indent' },
+ );
+ return true;
+};
+
+/**
+ * Shows the documentation next to the completion list, aligned with its top (CodeMirror aligns it with the selected
+ * option)
+ */
+function positionInfo(_view: EditorView, list: Rect, _option: Rect, info: Rect, space: Rect) {
+ const infoWidth = info.right - info.left;
+ const infoHeight = info.bottom - info.top;
+ const spaceLeft = list.left - space.left;
+ const spaceRight = space.right - list.right;
+ const left = spaceRight < infoWidth && spaceLeft > spaceRight;
+ const top = Math.max(space.top, Math.min(list.top, space.bottom - infoHeight)) - list.top;
+ return {
+ style: `top: ${top}px`,
+ class: left ? 'cm-completionInfo-left' : 'cm-completionInfo-right',
+ };
+}
+
+/**
+ * The smallest change that turns one string into the other
+ */
+function diffStrings(from: string, to: string): ChangeSpec {
+ const minLength = Math.min(from.length, to.length);
+ let start = 0;
+ while (start < minLength && from[start] === to[start]) start++;
+ let fromEnd = from.length;
+ let toEnd = to.length;
+ while (fromEnd > start && toEnd > start && from[fromEnd - 1] === to[toEnd - 1]) {
+ fromEnd--;
+ toEnd--;
+ }
+ return { from: start, to: fromEnd, insert: to.slice(start, toEnd) };
+}
+
+/**
+ * Focuses the editor and puts the cursor at the given (1-based) line and column, as reported by Druid and Hjson errors.
+ * Positions past the end of the line or the text are clamped.
+ */
+export function focusEditorAt(view: EditorView, position: LineColumn): void {
+ view.focus();
+ view.dispatch({
+ selection: { anchor: lineColumnToOffset(view.state.doc, position) },
+ scrollIntoView: true,
+ });
+}
+
+export function CodeEditor(props: CodeEditorProps) {
+ const {
+ ref,
+ className,
+ value,
+ onChange,
+ readOnly = false,
+ onBlur,
+ language,
+ autoFocus,
+ style,
+ showLineNumbers,
+ padding,
+ transparentBackground,
+ placeholder,
+ stateCacheId,
+ extensions,
+ } = props;
+
+ const containerRef = useRef<HTMLDivElement>(null);
+ const viewRef = useRef<EditorView | undefined>(undefined);
+ const compartments = useConstant(() => ({
+ language: new Compartment(),
+ readOnly: new Compartment(),
+ lineNumbers: new Compartment(),
+ padding: new Compartment(),
+ transparentBackground: new Compartment(),
+ placeholder: new Compartment(),
+ }));
+
+ const handleChange = usePermanentCallback((newValue: string) => onChange?.(newValue));
+ const handleBlur = usePermanentCallback(() => onBlur?.());
+ const createView = useEffectEvent((container: HTMLElement) => {
+ const editorExtensions: Extension = [
+ history(),
+ drawSelection(),
+ bracketMatching(),
+ search({ createPanel: createSearchPanel }),
+ editorErrorField,
+ highlightActiveLine(),
+ syntaxHighlighting(codeEditorHighlightStyle),
+ autocompletion({
+ icons: false,
+ positionInfo,
+ }),
+ tooltips({ parent: getTooltipHost() }),
+ keymap.of([
+ { key: 'Tab', run: acceptCompletion },
+ { key: 'Tab', run: insertSoftTab, shift: indentLess },
+ ...defaultKeymap,
+ ...searchKeymap,
+ ...historyKeymap,
+ ]),
+ EditorState.tabSize.of(TAB_SIZE),
+ indentUnit.of(' '.repeat(TAB_SIZE)),
+ codeEditorTheme,
+ compartments.padding.of(paddingExtension(padding)),
+ compartments.transparentBackground.of(transparentBackgroundExtension(transparentBackground)),
+ compartments.language.of(language ?? []),
+ compartments.readOnly.of(readOnlyExtension(readOnly)),
+ compartments.lineNumbers.of(lineNumbersExtension(showLineNumbers)),
+ compartments.placeholder.of(placeholderTextExtension(placeholder)),
+ EditorView.updateListener.of(update => {
+ if (!update.docChanged) return;
+ if (update.transactions.every(tr => tr.annotation(externalChange))) return;
+ handleChange(update.state.doc.toString());
+ }),
+ EditorView.domEventHandlers({ blur: () => handleBlur() }),
+ extensions ?? [],
+ ];
+
+ let state: EditorState | undefined;
+ const cachedState = stateCacheId ? savedEditorStates.get(stateCacheId) : undefined;
+ if (cachedState) {
+ try {
+ state = EditorState.fromJSON(
+ cachedState,
+ { extensions: editorExtensions },
+ { history: historyField },
+ );
+ } catch {
+ // Fall back to a fresh state
+ }
+ }
+ state ??= EditorState.create({ doc: value, extensions: editorExtensions });
+
+ const view = new EditorView({ state, parent: container });
+ const doc = state.doc.toString();
+ if (doc !== value) {
+ view.dispatch({
+ changes: diffStrings(doc, value),
+ annotations: externalChange.of(true),
+ });
+ }
+ if (autoFocus) view.focus();
+ return view;
+ });
+
+ const saveState = useEffectEvent((view: EditorView) => {
+ if (!stateCacheId) return;
+ savedEditorStates.set(stateCacheId, view.state.toJSON({ history: historyField }));
+ });
+
+ useLayoutEffect(() => {
+ if (!containerRef.current) return;
+ const view = createView(containerRef.current);
+ viewRef.current = view;
+ return () => {
+ saveState(view);
+ view.destroy();
+ viewRef.current = undefined;
+ };
+ }, [stateCacheId]);
+
+ useImperativeHandle(ref, () => viewRef.current);
+
+ useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+ const doc = view.state.doc.toString();
+ if (doc === value) return;
+ view.dispatch({
+ changes: diffStrings(doc, value),
+ annotations: externalChange.of(true),
+ });
+ }, [value]);
+
+ useEffect(() => {
+ viewRef.current?.dispatch({
+ effects: compartments.language.reconfigure(language ?? []),
+ });
+ }, [compartments, language]);
+
+ useEffect(() => {
+ viewRef.current?.dispatch({
+ effects: compartments.readOnly.reconfigure(readOnlyExtension(readOnly)),
+ });
+ }, [compartments, readOnly]);
+
+ useEffect(() => {
+ viewRef.current?.dispatch({
+ effects: compartments.lineNumbers.reconfigure(lineNumbersExtension(showLineNumbers)),
+ });
+ }, [compartments, showLineNumbers]);
+
+ useEffect(() => {
+ viewRef.current?.dispatch({
+ effects: compartments.padding.reconfigure(paddingExtension(padding)),
+ });
+ }, [compartments, padding]);
+
+ useEffect(() => {
+ viewRef.current?.dispatch({
+ effects: compartments.transparentBackground.reconfigure(
+ transparentBackgroundExtension(transparentBackground),
+ ),
+ });
+ }, [compartments, transparentBackground]);
+
+ useEffect(() => {
+ viewRef.current?.dispatch({
+ effects: compartments.placeholder.reconfigure(placeholderTextExtension(placeholder)),
+ });
+ }, [compartments, placeholder]);
+
+ return <div className={classNames('code-editor', className)} style={style} ref={containerRef} />;
+}
diff --git a/web-console/src/components/code-editor/completion-doc.spec.ts b/web-console/src/components/code-editor/completion-doc.spec.ts
new file mode 100644
index 00000000000..ab342f6e93c
--- /dev/null
+++ b/web-console/src/components/code-editor/completion-doc.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderCompletionDoc } from './completion-doc';
+
+describe('renderCompletionDoc', () => {
+ it('renders the name, syntax and markdown description', () => {
+ expect(
+ renderCompletionDoc({
+ name: 'COUNT',
+ syntax: 'COUNT(*)',
+ descriptionMarkdown: 'Counts the number of `things`',
+ }).innerHTML,
+ ).toEqual(
+ '<div class="doc-name">COUNT</div>' +
+ '<div class="doc-syntax">COUNT(*)</div>' +
+ '<div class="doc-description">Counts the number of <code>things</code></div>',
+ );
+ });
+
+ it('escapes the plain text parts', () => {
+ expect(
+ renderCompletionDoc({
+ name: 'ARRAY<STRING>',
+ syntax: 'f(a < b)',
+ description: 'Type of COMPLEX<json>',
+ }).innerHTML,
+ ).toEqual(
+ '<div class="doc-name">ARRAY<STRING></div>' +
+ '<div class="doc-syntax">f(a < b)</div>' +
+ '<div class="doc-description">Type of COMPLEX<json></div>',
+ );
+ });
+
+ it('leaves out the empty parts', () => {
+ expect(renderCompletionDoc({ name: 'REAL', descriptionMarkdown: '' }).innerHTML).toEqual(
+ '<div class="doc-name">REAL</div>',
+ );
+ });
+});
diff --git a/web-console/src/components/code-editor/completion-doc.ts b/web-console/src/components/code-editor/completion-doc.ts
new file mode 100644
index 00000000000..0b969b42599
--- /dev/null
+++ b/web-console/src/components/code-editor/completion-doc.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { CompletionDoc } from '../../editor-completions/editor-completion';
+
+import { renderDocMarkdown } from './doc-markdown';
+
+function makeDiv(className: string): HTMLDivElement {
+ const div = document.createElement('div');
+ div.className = className;
+ return div;
+}
+
+/**
+ * Renders the documentation panel that is shown next to the completion list (styled in code-editor-theme.ts)
+ */
+export function renderCompletionDoc({
+ name,
+ syntax,
+ description,
+ descriptionMarkdown,
+}: CompletionDoc): HTMLElement {
+ const container = document.createElement('div');
+
+ const nameDiv = makeDiv('doc-name');
+ nameDiv.textContent = name;
+ container.append(nameDiv);
+
+ if (syntax) {
+ const syntaxDiv = makeDiv('doc-syntax');
+ syntaxDiv.textContent = syntax;
+ container.append(syntaxDiv);
+ }
+
+ if (descriptionMarkdown || description) {
+ const descriptionDiv = makeDiv('doc-description');
+ if (descriptionMarkdown) {
+ descriptionDiv.append(renderDocMarkdown(descriptionMarkdown));
+ } else {
+ descriptionDiv.textContent = description!;
+ }
+ container.append(descriptionDiv);
+ }
+
+ return container;
+}
diff --git a/web-console/src/components/code-editor/completion-source.spec.ts b/web-console/src/components/code-editor/completion-source.spec.ts
new file mode 100644
index 00000000000..ccdf22a738e
--- /dev/null
+++ b/web-console/src/components/code-editor/completion-source.spec.ts
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { CompletionResult } from '@codemirror/autocomplete';
+import { CompletionContext } from '@codemirror/autocomplete';
+import type { Extension } from '@codemirror/state';
+import { EditorState } from '@codemirror/state';
+
+import { dsql } from '../../editor-languages/dsql';
+import { hjson } from '../../editor-languages/hjson';
+import { completionContextAt } from '../../test-utils/completion-context';
+
+import { makeCompletionSource } from './completion-source';
+
+describe('completion source', () => {
+ // The word being completed with the cursor at the |
+ function wordAt(extension: Extension, textWithCursor: string): string {
+ return completionContextAt(extension, textWithCursor)[1].text;
+ }
+
+ it('uses the word characters of the language', () => {
+ expect(wordAt(dsql(), 'SELECT a-co|')).toEqual('co');
+ expect(wordAt(dsql(), 'SELECT $my_v|')).toEqual('$my_v');
+ expect(wordAt(dsql(), 'SELECT "café|')).toEqual('café');
+ expect(wordAt(hjson(), '{ type: mv-fil|')).toEqual('mv-fil');
+ expect(wordAt(hjson(), '{ type: "c5.lar|')).toEqual('lar');
+ expect(wordAt(dsql(), 'SELECT (|')).toEqual('');
+ });
+
+ it('only completes a word that ends at the cursor', () => {
+ expect(wordAt(dsql(), 'SELECT co|unt')).toEqual('co');
+ });
+
+ it('keeps the result while the word grows', () => {
+ const source = makeCompletionSource(() => [{ label: 'my-value' }]);
+ const state = EditorState.create({ doc: '{ a: my', extensions: hjson() });
+ const result = source(new CompletionContext(state, 7, false)) as CompletionResult;
+ const validFor = result.validFor as Exclude<CompletionResult['validFor'], RegExp | undefined>;
+ expect(validFor('my-va', 5, 10, state)).toBe(true);
+ expect(validFor('my va', 5, 10, state)).toBe(false);
+ });
+});
diff --git a/web-console/src/components/code-editor/completion-source.ts b/web-console/src/components/code-editor/completion-source.ts
new file mode 100644
index 00000000000..b18416f4f39
--- /dev/null
+++ b/web-console/src/components/code-editor/completion-source.ts
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type {
+ Completion,
+ CompletionContext,
+ CompletionResult,
+ CompletionSource,
+} from '@codemirror/autocomplete';
+import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
+import type { EditorState } from '@codemirror/state';
+import { CharCategory } from '@codemirror/state';
+import type { Tree } from '@lezer/common';
+
+import type { EditorCompletion } from '../../editor-completions/editor-completion';
+
+import { renderCompletionDoc } from './completion-doc';
+
+/**
+ * The (partial) word being completed, it ends at the cursor
+ */
+export interface CompletionWord {
+ from: number;
+ text: string;
+}
+
+/**
+ * Builds the completions for the word being completed, CodeMirror filters and ranks them against it
+ */
+export type EditorCompletionBuilder = (
+ context: CompletionContext,
+ word: CompletionWord,
+) => readonly EditorCompletion[];
+
+/**
+ * The word before the cursor, or undefined when there is nothing to complete (no word typed and the completion was not
+ * asked for explicitly). What is a word is up to the language: letters, digits and _, plus the language's `wordChars`
+ * (language data).
+ */
+export function matchCompletionWord(context: CompletionContext): CompletionWord | undefined {
+ const { state, pos } = context;
+ const word = state.wordAt(pos);
+ const from = word && word.from < pos ? word.from : pos;
+ if (from === pos && !context.explicit) return;
+ return { from, text: state.sliceDoc(from, pos) };
+}
+
+/**
+ * The syntax node (the token) that the cursor is in or right after, like 'LineComment' or 'String'
+ */
+export function tokenBefore(
+ state: EditorState,
+ pos: number,
+): { name: string; from: number; to: number } {
+ const { name, from, to } = syntaxTree(state).resolveInner(pos, -1);
+ return { name, from, to };
+}
+
+/**
+ * The syntax tree of the whole text, for completions that look at more than the text around the cursor. The editor
+ * parses in the background, so the end of a long text might not be parsed yet. It is parsed here (for a short while),
+ * otherwise the tree is what has been parsed so far.
+ */
+export function completeSyntaxTree(state: EditorState): Tree {
+ return ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state);
+}
+
+function toCompletion({ doc, ...completion }: EditorCompletion): Completion {
+ return doc ? { ...completion, info: () => renderCompletionDoc(doc) } : completion;
+}
+
+/**
+ * Makes a CodeMirror completion source out of a completion builder (like getSqlCompletions)
+ */
+export function makeCompletionSource(build: EditorCompletionBuilder): CompletionSource {
+ return (context): CompletionResult | null => {
+ if (context.state.readOnly) return null;
+ const word = matchCompletionWord(context);
+ if (!word) return null;
+
+ const options = build(context, word).map(toCompletion);
+ if (!options.length) return null;
+
+ return {
+ from: word.from,
+ options,
+ // Keep filtering the options (rather than asking again) while the word grows
+ validFor: (text, from, _to, state) => {
+ const categorize = state.charCategorizer(from);
+ return Array.from(text).every(char => categorize(char) === CharCategory.Word);
+ },
+ };
+ };
+}
diff --git a/web-console/src/components/code-editor/doc-markdown.spec.ts b/web-console/src/components/code-editor/doc-markdown.spec.ts
new file mode 100644
index 00000000000..0387f976d5c
--- /dev/null
+++ b/web-console/src/components/code-editor/doc-markdown.spec.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderDocMarkdown } from './doc-markdown';
+
+function renderToHtml(markdown: string): string {
+ const div = document.createElement('div');
+ div.append(renderDocMarkdown(markdown));
+ return div.innerHTML;
+}
+
+describe('renderDocMarkdown', () => {
+ it('renders plain text', () => {
+ expect(renderToHtml('Reads external data.')).toEqual('Reads external data.');
+ });
+
+ it('renders code and emphasis', () => {
+ expect(renderToHtml('*Deprecated.* Use `APPROX_QUANTILE_DS` instead.')).toEqual(
+ '<em>Deprecated.</em> Use <code>APPROX_QUANTILE_DS</code> instead.',
+ );
+ expect(renderToHtml('*Use `X`* now')).toEqual('<em>Use <code>X</code></em> now');
+ });
+
+ it('keeps the content of code as text', () => {
+ expect(renderToHtml('Returns `ARRAY<COMPLEX<json>>` or `a * b`')).toEqual(
+ 'Returns <code>ARRAY<COMPLEX<json>></code> or <code>a * b</code>',
+ );
+ });
+
+ it('renders escaped characters as text', () => {
+ expect(renderToHtml('polar \\* coordinates, \\`, \\\\ and <b>')).toEqual(
+ 'polar * coordinates, `, \\ and <b>',
+ );
+ expect(renderToHtml('\\- not a list item')).toEqual('- not a list item');
+ });
+
+ it('renders line breaks', () => {
+ expect(renderToHtml('First.\n\nSecond.')).toEqual('First.<br><br>Second.');
+ });
+
+ it('renders lists', () => {
+ expect(renderToHtml('Extensions:\n- `a`: one\n- `b`: two\nAfter')).toEqual(
+ 'Extensions:<ul><li><code>a</code>: one</li><li><code>b</code>: two</li></ul>After',
+ );
+ });
+});
diff --git a/web-console/src/components/code-editor/doc-markdown.ts b/web-console/src/components/code-editor/doc-markdown.ts
new file mode 100644
index 00000000000..4ca8d26a3dd
--- /dev/null
+++ b/web-console/src/components/code-editor/doc-markdown.ts
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// `code`, *emphasis* (which can contain code) or a backslash escaped character
+const INLINE_REGEXP = /`([^`]*)`|\*((?:\\.|`[^`]*`|[^*\\`])+)\*|\\(.)/g;
+
+function renderInline(text: string): (Node | string)[] {
+ const nodes: (Node | string)[] = [];
+ let lastIndex = 0;
+ for (const m of text.matchAll(INLINE_REGEXP)) {
+ if (m.index > lastIndex) nodes.push(text.slice(lastIndex, m.index));
+ const [, code, emphasis, escaped] = m;
+ if (typeof code === 'string') {
+ const codeElement = document.createElement('code');
+ codeElement.textContent = code;
+ nodes.push(codeElement);
+ } else if (typeof emphasis === 'string') {
+ const emphasisElement = document.createElement('em');
+ emphasisElement.append(...renderInline(emphasis));
+ nodes.push(emphasisElement);
+ } else {
+ nodes.push(escaped);
+ }
+ lastIndex = m.index + m[0].length;
+ }
+ if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
+ return nodes;
+}
+
+/**
+ * Renders "doc markdown", the simplified markdown that the SQL docs in src/editor-languages/dsql-docs.ts are written in (see
+ * sanitizeMarkdown in script/create-sql-docs.mjs):
+ * - `code` spans
+ * - *emphasis*
+ * - line breaks (\n)
+ * - list items (lines that start with "- ")
+ * - a backslash escapes the next character
+ */
+export function renderDocMarkdown(markdown: string): DocumentFragment {
+ const fragment = document.createDocumentFragment();
+ let list: HTMLUListElement | undefined;
+ let afterText = false;
+ for (const line of markdown.split('\n')) {
+ if (line.startsWith('- ')) {
+ if (!list) {
+ list = document.createElement('ul');
+ fragment.append(list);
+ }
+ const item = document.createElement('li');
+ item.append(...renderInline(line.slice(2)));
+ list.append(item);
+ afterText = false;
+ } else {
+ list = undefined;
+ if (afterText) fragment.append(document.createElement('br'));
+ fragment.append(...renderInline(line));
+ afterText = true;
+ }
+ }
+ return fragment;
+}
diff --git a/web-console/src/components/code-editor/error-mark.spec.ts b/web-console/src/components/code-editor/error-mark.spec.ts
new file mode 100644
index 00000000000..dde3980fdd7
--- /dev/null
+++ b/web-console/src/components/code-editor/error-mark.spec.ts
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import Hjson from 'hjson';
+
+import { hjson } from '../../editor-languages/hjson';
+import { getHjsonEditorError } from '../json-input/json-input';
+
+import { editorErrorField, getErrorRange, showEditorError } from './error-mark';
+
+// The text that would be marked for the error that Hjson.parse gives
+function markedText(text: string): string | undefined {
+ let message: string | undefined;
+ try {
+ Hjson.parse(text);
+ } catch (e) {
+ message = e.message;
+ }
+ const error = getHjsonEditorError(message!);
+ if (!error) return;
+ const state = EditorState.create({ doc: text, extensions: hjson() });
+ const range = getErrorRange(state, error.position);
+ return range && state.sliceDoc(range.from, range.to);
+}
+
+describe('error mark', () => {
+ it('marks the token at the position of an Hjson error', () => {
+ expect(markedText('{\n "a": 1\n "b" 2\n}')).toEqual('2');
+ expect(markedText('{\n "a": [1, 2\n}')).toEqual('}');
+ expect(markedText('{\n "a": {}}}')).toEqual('}');
+ expect(markedText('{\n "a": 1,\n }\n}')).toEqual('}');
+ });
+
+ it('marks the rest of the line, or the character before the end of a line', () => {
+ // The key that the error recovery made of the text
+ expect(markedText('{\n a b: 1\n}')).toEqual('a ');
+ // Without a language there is no syntax node
+ const plainState = EditorState.create({ doc: 'abc def\nghi' });
+ const range = getErrorRange(plainState, { line: 1, column: 3 });
+ expect(range && plainState.sliceDoc(range.from, range.to)).toEqual('c def');
+ // The position is past the end of the text, the last character is marked
+ expect(markedText('{\n "a": 1\n')).toEqual('1');
+ expect(markedText('')).toBeUndefined();
+ });
+
+ it('describes Hjson errors without their position', () => {
+ expect(
+ getHjsonEditorError(`Expected ':' instead of '2' at line 3,7 >>> "b" 2\n} ...`),
+ ).toEqual({ position: { line: 3, column: 7 }, message: `Expected ':' instead of '2'` });
+ expect(getHjsonEditorError('No position')).toBeUndefined();
+ });
+
+ it('shows the message as a tooltip until the text changes', () => {
+ const view = new EditorView({
+ state: EditorState.create({ doc: 'abc def', extensions: editorErrorField }),
+ });
+ showEditorError(view, { position: { line: 1, column: 5 }, message: 'Bad' });
+ const mark = view.dom.querySelector('.cm-errorMark');
+ expect(mark?.textContent).toEqual('def');
+ expect(mark?.getAttribute('data-tooltip')).toEqual('Bad');
+
+ view.dispatch({ changes: { from: 0, insert: 'x' } });
+ expect(view.dom.querySelector('.cm-errorMark')).toBeNull();
+
+ showEditorError(view, { position: { line: 1, column: 1 }, message: 'Bad' });
+ showEditorError(view, undefined);
+ expect(view.dom.querySelector('.cm-errorMark')).toBeNull();
+ view.destroy();
+ });
+});
diff --git a/web-console/src/components/code-editor/error-mark.ts b/web-console/src/components/code-editor/error-mark.ts
new file mode 100644
index 00000000000..dde9146e2a4
--- /dev/null
+++ b/web-console/src/components/code-editor/error-mark.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { syntaxTree } from '@codemirror/language';
+import type { EditorState, Text } from '@codemirror/state';
+import { StateEffect, StateField } from '@codemirror/state';
+import type { DecorationSet } from '@codemirror/view';
+import { Decoration, EditorView } from '@codemirror/view';
+
+import type { LineColumn } from '../../utils';
+
+/**
+ * An error to mark in the text, like a parse error
+ */
+export interface EditorError {
+ /** Where the error is (1-based), as reported by Druid and Hjson errors */
+ position: LineColumn;
+ /** Shown in a tooltip when hovering the mark */
+ message: string;
+}
+
+/**
+ * The offset of a (1-based) line and column. Positions past the end of the line or the text are clamped.
+ */
+export function lineColumnToOffset(doc: Text, { line, column }: LineColumn): number {
+ const docLine = doc.line(Math.min(Math.max(line, 1), doc.lines));
+ return Math.min(docLine.from + Math.max(column - 1, 0), docLine.to);
+}
+
+/**
+ * The text to mark for an error at the given position: the syntax node there (if it is on one line), otherwise the
+ * rest of the line. At the end of a line it is the last character before the position that is not a space.
+ */
+export function getErrorRange(
+ state: EditorState,
+ position: LineColumn,
+): { from: number; to: number } | undefined {
+ const { doc } = state;
+ const pos = lineColumnToOffset(doc, position);
+ const line = doc.lineAt(pos);
+
+ const node = syntaxTree(state).resolveInner(pos, 1);
+ if (!node.type.isTop && node.from <= pos && pos < node.to && node.to <= line.to) {
+ return { from: node.from, to: node.to };
+ }
+
+ if (pos < line.to) return { from: pos, to: line.to };
+
+ let before = pos;
+ while (before > 0 && /\s/.test(doc.sliceString(before - 1, before))) before--;
+ if (!before) return;
+ return { from: before - 1, to: before };
+}
+
+const setEditorError = StateEffect.define<EditorError | undefined>();
+
+/**
+ * The mark of the error (if any), it goes away on the next change to the text
+ */
+export const editorErrorField = StateField.define<DecorationSet>({
+ create: () => Decoration.none,
+ update(marks, tr) {
+ for (const effect of tr.effects) {
+ if (!effect.is(setEditorError)) continue;
+ const error = effect.value;
+ const range = error && getErrorRange(tr.state, error.position);
+ if (!range) return Decoration.none;
+ return Decoration.set(
+ Decoration.mark({
+ class: 'cm-errorMark',
+ attributes: { 'data-tooltip': error.message },
+ }).range(range.from, range.to),
+ );
+ }
+ return tr.docChanged ? Decoration.none : marks;
+ },
+ provide: field => EditorView.decorations.from(field),
+});
+
+/**
+ * Underlines the given error in the editor (until the text changes), or removes the mark if there is no error
+ */
+export function showEditorError(view: EditorView, error: EditorError | undefined): void {
+ view.dispatch({ effects: setEditorError.of(error) });
+}
diff --git a/web-console/src/components/code-editor/search-panel.tsx b/web-console/src/components/code-editor/search-panel.tsx
new file mode 100644
index 00000000000..3fd39830730
--- /dev/null
+++ b/web-console/src/components/code-editor/search-panel.tsx
@@ -0,0 +1,201 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Button, ButtonGroup, Checkbox, InputGroup } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import {
+ closeSearchPanel,
+ findNext,
+ findPrevious,
+ getSearchQuery,
+ replaceAll,
+ replaceNext,
+ SearchQuery,
+ selectMatches,
+ setSearchQuery,
+} from '@codemirror/search';
+import type { EditorView, Panel, ViewUpdate } from '@codemirror/view';
+import { runScopeHandlers } from '@codemirror/view';
+import type React from 'react';
+import { flushSync } from 'react-dom';
+import type { Root } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
+
+interface SearchPanelProps {
+ view: EditorView;
+ query: SearchQuery;
+ readOnly: boolean;
+}
+
+/**
+ * The find (and replace) panel, it does what CodeMirror's own panel does but is made out of Blueprint components
+ */
+function SearchPanel(props: SearchPanelProps) {
+ const { view, query, readOnly } = props;
+
+ function changeQuery(
+ change: Partial<
+ Pick<SearchQuery, 'search' | 'caseSensitive' | 'regexp' | 'wholeWord' | 'replace'>
+ >,
+ ) {
+ const { search, caseSensitive, literal, regexp, wholeWord, replace } = query;
+ const newQuery = new SearchQuery({
+ search,
+ caseSensitive,
+ literal,
+ regexp,
+ wholeWord,
+ replace,
+ ...change,
+ });
+ if (newQuery.eq(query)) return;
+ view.dispatch({ effects: setSearchQuery.of(newQuery) });
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent<HTMLElement>) {
+ // The search keymap: Escape closes the panel, Mod-f selects the search field, Mod-g finds the next match, etc.
+ if (runScopeHandlers(view, e.nativeEvent, 'search-panel')) {
+ e.preventDefault();
+ } else if (e.key === 'Enter' && e.target instanceof HTMLInputElement) {
+ e.preventDefault();
+ if (e.target.name === 'search') {
+ (e.shiftKey ? findPrevious : findNext)(view);
+ } else if (e.target.name === 'replace') {
+ replaceNext(view);
+ }
+ }
+ }
+
+ return (
+ <div className="code-editor-search-panel" onKeyDown={handleKeyDown}>
+ <div className="search-row">
+ <InputGroup
+ className="search-field"
+ size="small"
+ name="search"
+ placeholder="Find"
+ aria-label="Find"
+ leftIcon={IconNames.SEARCH}
+ value={query.search}
+ onChange={e => changeQuery({ search: e.target.value })}
+ // openSearchPanel focuses this field when the panel is already open
+ {...{ 'main-field': 'true' }}
+ />
+ <ButtonGroup size="small">
+ <Button
+ icon={IconNames.ARROW_UP}
+ title="Previous match (Shift+Enter)"
+ disabled={!query.valid}
+ onClick={() => findPrevious(view)}
+ />
+ <Button
+ icon={IconNames.ARROW_DOWN}
+ title="Next match (Enter)"
+ disabled={!query.valid}
+ onClick={() => findNext(view)}
+ />
+ <Button text="Select all" disabled={!query.valid} onClick={() => selectMatches(view)} />
+ </ButtonGroup>
+ <Checkbox
+ inline
+ label="Match case"
+ checked={query.caseSensitive}
+ onChange={e => changeQuery({ caseSensitive: e.currentTarget.checked })}
+ />
+ <Checkbox
+ inline
+ label="Regexp"
+ checked={query.regexp}
+ onChange={e => changeQuery({ regexp: e.currentTarget.checked })}
+ />
+ <Checkbox
+ inline
+ label="By word"
+ checked={query.wholeWord}
+ onChange={e => changeQuery({ wholeWord: e.currentTarget.checked })}
+ />
+ <Button
+ className="close-button"
+ icon={IconNames.CROSS}
+ size="small"
+ variant="minimal"
+ title="Close (Escape)"
+ aria-label="Close"
+ onClick={() => closeSearchPanel(view)}
+ />
+ </div>
+ {!readOnly && (
+ <div className="replace-row">
+ <InputGroup
+ className="search-field"
+ size="small"
+ name="replace"
+ placeholder="Replace"
+ aria-label="Replace"
+ leftIcon={IconNames.EXCHANGE}
+ value={query.replace}
+ onChange={e => changeQuery({ replace: e.target.value })}
+ />
+ <ButtonGroup size="small">
+ <Button text="Replace" disabled={!query.valid} onClick={() => replaceNext(view)} />
+ <Button text="Replace all" disabled={!query.valid} onClick={() => replaceAll(view)} />
+ </ButtonGroup>
+ </div>
+ )}
+ </div>
+ );
+}
+
+/**
+ * For search({ createPanel }): renders SearchPanel into the panel's element and keeps it in sync with the search query
+ */
+export function createSearchPanel(view: EditorView): Panel {
+ const dom = document.createElement('div');
+ const root: Root = createRoot(dom);
+
+ function render(): void {
+ const { state } = view;
+ root.render(
+ <SearchPanel view={view} query={getSearchQuery(state)} readOnly={state.readOnly} />,
+ );
+ }
+
+ // Render right away so that the search field is there to be focused when the panel is mounted
+ flushSync(render);
+
+ return {
+ dom,
+ mount() {
+ const searchField = dom.querySelector<HTMLInputElement>('[main-field]');
+ searchField?.focus();
+ searchField?.select();
+ },
+ update(update: ViewUpdate) {
+ if (
+ getSearchQuery(update.state) !== getSearchQuery(update.startState) ||
+ update.state.readOnly !== update.startState.readOnly
+ ) {
+ render();
+ }
+ },
+ destroy() {
+ // The panel can be destroyed while React is rendering (when the editor unmounts)
+ setTimeout(() => root.unmount(), 0);
+ },
+ };
+}
diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts
index 0ce78f769de..f87058760d2 100644
--- a/web-console/src/components/index.ts
+++ b/web-console/src/components/index.ts
@@ -25,6 +25,7 @@ export * from './braced-text/braced-text';
export * from './center-message/center-message';
export * from './clearable-input/clearable-input';
export * from './click-to-copy/click-to-copy';
+export * from './code-editor/code-editor';
export * from './console-table';
export * from './deferred/deferred';
export * from './druid-logo/druid-logo';
diff --git a/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap b/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap
index 880ddb2c2e2..2a832f2202e 100644
--- a/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap
+++ b/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap
@@ -5,11 +5,14 @@ exports[`JsonInput matches snapshot (null) 1`] = `
class="json-input"
>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark"
- id="ace-editor"
- style="width: 100%; height: 8vh; font-size: 12px;"
+ class="code-editor"
+ style="height: 8vh;"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "" -->
+ </div>
</div>
</div>
`;
@@ -19,11 +22,14 @@ exports[`JsonInput matches snapshot (value) 1`] = `
class="json-input"
>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark"
- id="ace-editor"
- style="width: 100%; height: 8vh; font-size: 12px;"
+ class="code-editor"
+ style="height: 8vh;"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "{\\n \\"hello\\": [\\n \\"world\\",\\n {\\n \\"a\\": 1,\\n \\"b\\": 2\\n }\\n ]\\n}" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "{\\n \\"hello\\": [\\n \\"world\\",\\n {\\n \\"a\\": 1,\\n \\"b\\": 2\\n }\\n ]\\n}" -->
+ </div>
</div>
</div>
`;
diff --git a/web-console/src/components/json-input/json-input.scss b/web-console/src/components/json-input/json-input.scss
index 26b005060f3..765f5428dfb 100644
--- a/web-console/src/components/json-input/json-input.scss
+++ b/web-console/src/components/json-input/json-input.scss
@@ -20,6 +20,10 @@
height: 100%;
position: relative;
+ & > .code-editor {
+ width: 100%;
+ }
+
&.invalid::after {
content: '';
position: absolute;
diff --git a/web-console/src/components/json-input/json-input.spec.tsx b/web-console/src/components/json-input/json-input.spec.tsx
index 1e064f37e5c..fd153f4f385 100644
--- a/web-console/src/components/json-input/json-input.spec.tsx
+++ b/web-console/src/components/json-input/json-input.spec.tsx
@@ -16,10 +16,11 @@
* limitations under the License.
*/
-import { render } from '@testing-library/react';
+import { EditorView } from '@codemirror/view';
+import { act, fireEvent, render } from '@testing-library/react';
import Hjson from 'hjson';
-import { extractRowColumnFromHjsonError, JsonInput } from './json-input';
+import { extractLineColumnFromHjsonError, JsonInput } from './json-input';
describe('JsonInput', () => {
it('matches snapshot (null)', () => {
@@ -37,11 +38,11 @@ describe('JsonInput', () => {
expect(container.firstChild).toMatchSnapshot();
});
- it('extractRowColumnFromHjsonError is ok with non matching error', () => {
- expect(extractRowColumnFromHjsonError(new Error('blah blah'))).toBeUndefined();
+ it('extractLineColumnFromHjsonError is ok with non matching error', () => {
+ expect(extractLineColumnFromHjsonError(new Error('blah blah'))).toBeUndefined();
});
- it('extractRowColumnFromHjsonError works with real error', () => {
+ it('extractLineColumnFromHjsonError works with real error', () => {
let error: Error | undefined;
try {
Hjson.parse(`{\n"Hello" "World"\n}`);
@@ -51,10 +52,25 @@ describe('JsonInput', () => {
expect(error).toBeDefined();
- const rc = extractRowColumnFromHjsonError(error!);
- expect(rc).toEqual({
- column: 8,
- row: 1,
+ expect(extractLineColumnFromHjsonError(error!)).toEqual({
+ line: 2,
+ column: 9,
});
});
+
+ it('underlines the error once the editor loses focus', () => {
+ const { container } = render(<JsonInput onChange={() => {}} value={{ a: 1 }} />);
+ const view = EditorView.findFromDOM(container.querySelector<HTMLElement>('.cm-editor')!)!;
+ act(() => {
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: '{\n"a" 1\n}' } });
+ });
+ expect(container.querySelector('.cm-errorMark')).toBeNull();
+
+ act(() => {
+ fireEvent.blur(view.contentDOM);
+ });
+ const mark = container.querySelector('.cm-errorMark');
+ expect(mark?.textContent).toEqual('1');
+ expect(mark?.getAttribute('data-tooltip')).toEqual(`Expected ':' instead of '1'`);
+ });
});
diff --git a/web-console/src/components/json-input/json-input.tsx b/web-console/src/components/json-input/json-input.tsx
index 7a73c075650..48714714480 100644
--- a/web-console/src/components/json-input/json-input.tsx
+++ b/web-console/src/components/json-input/json-input.tsx
@@ -16,16 +16,16 @@
* limitations under the License.
*/
-import type { Ace } from 'ace-builds';
+import type { EditorView } from '@codemirror/view';
import classNames from 'classnames';
import Hjson from 'hjson';
import * as JSONBig from 'json-bigint-native';
-import React, { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
-import AceEditor from 'react-ace';
+import React, { useEffect, useEffectEvent, useRef, useState } from 'react';
-import { getHjsonCompletions } from '../../ace-completions/hjson-completions';
-import { usePermanentCallback } from '../../hooks';
-import type { JsonCompletionRule } from '../../utils';
+import { hjson } from '../../editor-languages/hjson';
+import type { JsonCompletionRule, LineColumn } from '../../utils';
+import type { EditorError } from '../code-editor/code-editor';
+import { CodeEditor, focusEditorAt, showEditorError } from '../code-editor/code-editor';
import './json-input.scss';
@@ -34,16 +34,33 @@ function parseHjson(str: string): any {
return Hjson.parse(str);
}
-export function extractRowColumnFromHjsonError(
- error: Error,
-): { row: number; column: number } | undefined {
+function lineColumnFromHjsonMessage(message: string): LineColumn | undefined {
// Message would be something like:
// `Found '}' where a key name was expected at line 26,7`
- // Use this to extract the row and column (subtract 1) and jump the cursor to the right place on click
- const m = /line (\d+),(\d+)/.exec(error.message);
+ const m = /line (\d+),(\d+)/.exec(message);
if (!m) return;
- return { row: Number(m[1]) - 1, column: Number(m[2]) - 1 };
+ return { line: Number(m[1]), column: Number(m[2]) };
+}
+
+export function extractLineColumnFromHjsonError(error: Error): LineColumn | undefined {
+ // Use this to extract the line and column and jump the cursor to the right place on click
+ return lineColumnFromHjsonMessage(error.message);
+}
+
+/**
+ * The error to mark in the editor for an Hjson error message, undefined if the message has no position
+ */
+export function getHjsonEditorError(message: string): EditorError | undefined {
+ const position = lineColumnFromHjsonMessage(message);
+ if (!position) return;
+
+ // The mark shows where the error is, so the position (and the text after it, following ">>>") is left out
+ const positionIndex = message.search(/\sat line \d+,\d+/);
+ return {
+ position,
+ message: positionIndex === -1 ? message : message.slice(0, positionIndex).trimEnd(),
+ };
}
function stringifyJson(item: any): string {
@@ -70,10 +87,11 @@ interface InternalValue {
interface JsonInputProps {
value: any;
onChange?: (value: any) => void;
+ readOnly?: boolean;
setError?: (error: Error | undefined) => void;
placeholder?: string;
- focus?: boolean;
- width?: string;
+ autoFocus?: boolean;
+ /** A CSS height, defaults to 8vh */
height?: string;
showLineNumbers?: boolean;
issueWithValue?: (value: any) => string | undefined;
@@ -83,10 +101,10 @@ interface JsonInputProps {
export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) {
const {
onChange,
+ readOnly,
setError,
placeholder,
- focus,
- width,
+ autoFocus,
height,
showLineNumbers,
value,
@@ -98,7 +116,7 @@ export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) {
stringified: stringifyJson(value),
}));
const [showErrorIfNeeded, setShowErrorIfNeeded] = useState(false);
- const aceEditor = useRef<Ace.Editor | undefined>(undefined);
+ const editorViewRef = useRef<EditorView | undefined>(undefined);
const showValue = useEffectEvent((value: any) => {
if (deepEqual(value, internalValue.lastShownValue)) return;
@@ -112,105 +130,73 @@ export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) {
showValue(value);
}, [value]);
- // Ace reads the completers once, when autocompletion is enabled, so they must not change. The callback always sees
- // the latest props.
- const getCompletions = usePermanentCallback<Ace.Completer['getCompletions']>(
- (_editor, session, pos, prefix, callback) => {
- if (!jsonCompletions) {
- callback(null, []);
- return;
+ const handleInputChange = (inputJson: string) => {
+ let value: any;
+ let error: Error | undefined;
+ try {
+ value = parseHjson(inputJson);
+ } catch (e) {
+ error = e;
+ }
+
+ if (!error && issueWithValue) {
+ const issue = issueWithValue(value);
+ if (issue) {
+ value = undefined;
+ error = new Error(issue);
}
- const allText = session.getValue();
- const line = session.getLine(pos.row);
- const charBeforePrefix = line[pos.column - prefix.length - 1];
-
- const lines = allText.split('\n').slice(0, pos.row + 1);
- const lastLineIndex = lines.length - 1;
- lines[lastLineIndex] = lines[lastLineIndex].slice(0, pos.column - prefix.length - 1);
- callback(
- null,
- getHjsonCompletions({
- jsonCompletions,
- textBefore: lines.join('\n'),
- charBeforePrefix,
- prefix,
- }),
- );
- },
- );
- const completers = useMemo<Ace.Completer[]>(() => [{ getCompletions }], [getCompletions]);
+ }
+
+ setInternalValue({
+ lastShownValue: value ?? internalValue.lastShownValue,
+ error,
+ stringified: inputJson,
+ });
+
+ setError?.(error);
+ if (!error) {
+ onChange?.(value);
+ }
+
+ if (showErrorIfNeeded) {
+ setShowErrorIfNeeded(false);
+ }
+ };
const internalValueError = internalValue.error;
+ const shownError = showErrorIfNeeded ? internalValueError : undefined;
+ useEffect(() => {
+ if (!editorViewRef.current) return;
+ showEditorError(
+ editorViewRef.current,
+ shownError ? getHjsonEditorError(shownError.message) : undefined,
+ );
+ }, [shownError]);
+
return (
<div className={classNames('json-input', { invalid: showErrorIfNeeded && internalValueError })}>
- <AceEditor
- mode="hjson"
- theme="solarized_dark"
- onChange={(inputJson: string) => {
- let value: any;
- let error: Error | undefined;
- try {
- value = parseHjson(inputJson);
- } catch (e) {
- error = e;
- }
-
- if (!error && issueWithValue) {
- const issue = issueWithValue(value);
- if (issue) {
- value = undefined;
- error = new Error(issue);
- }
- }
-
- setInternalValue({
- lastShownValue: value ?? internalValue.lastShownValue,
- error,
- stringified: inputJson,
- });
-
- setError?.(error);
- if (!error) {
- onChange?.(value);
- }
-
- if (showErrorIfNeeded) {
- setShowErrorIfNeeded(false);
- }
- }}
+ <CodeEditor
+ ref={editorViewRef}
+ language={hjson({ jsonCompletions })}
+ onChange={handleInputChange}
+ readOnly={readOnly}
onBlur={() => setShowErrorIfNeeded(true)}
- readOnly={!onChange}
- focus={focus}
- fontSize={12}
- width={width || '100%'}
- height={height || '8vh'}
- showPrintMargin={false}
- showGutter={Boolean(showLineNumbers)}
+ autoFocus={autoFocus}
+ style={{ height: height || '8vh' }}
+ showLineNumbers={showLineNumbers}
value={internalValue.stringified}
placeholder={placeholder}
- editorProps={{ completers }}
- enableBasicAutocompletion={Boolean(jsonCompletions)}
- enableLiveAutocompletion={Boolean(jsonCompletions)}
- setOptions={{
- showLineNumbers: Boolean(showLineNumbers),
- tabSize: 2,
- newLineMode: 'unix',
- }}
- onLoad={editor => {
- aceEditor.current = editor;
- }}
/>
{showErrorIfNeeded && internalValueError && (
<div
className="json-error"
onClick={() => {
- if (!aceEditor.current || !internalValueError) return;
+ if (!editorViewRef.current || !internalValueError) return;
- const rc = extractRowColumnFromHjsonError(internalValueError);
- if (!rc) return;
+ const position = extractLineColumnFromHjsonError(internalValueError);
+ if (!position) return;
- aceEditor.current.focus(); // Grab the focus
- aceEditor.current.getSelection().moveCursorTo(rc.row, rc.column);
+ focusEditorAt(editorViewRef.current, position);
}}
>
{internalValueError.message}
diff --git a/web-console/src/components/query-error-pane/query-error-pane.tsx b/web-console/src/components/query-error-pane/query-error-pane.tsx
index 72e0b59521d..fd8ff66c47f 100644
--- a/web-console/src/components/query-error-pane/query-error-pane.tsx
+++ b/web-console/src/components/query-error-pane/query-error-pane.tsx
@@ -19,14 +19,14 @@
import type { JSX } from 'react';
import React, { useState } from 'react';
-import type { DruidError, RowColumn } from '../../utils';
+import type { DruidError, LineColumn } from '../../utils';
import { HighlightText } from '../highlight-text/highlight-text';
import './query-error-pane.scss';
export interface QueryErrorPaneProps {
error: DruidError;
- moveCursorTo: (rowColumn: RowColumn) => void;
+ moveCursorTo: (position: LineColumn) => void;
queryString?: string;
onQueryStringChange?: (newQueryString: string, run?: boolean) => void;
}
@@ -39,7 +39,7 @@ export const QueryErrorPane = React.memo(function QueryErrorPane(props: QueryErr
return <div className="query-error-pane">{error.message}</div>;
}
- const { startRowColumn, suggestion } = error;
+ const { startLineColumn, suggestion } = error;
let suggestionElement: JSX.Element | undefined;
if (suggestion && queryString && onQueryStringChange) {
const newQuery = suggestion.fn(queryString);
@@ -69,14 +69,14 @@ export const QueryErrorPane = React.memo(function QueryErrorPane(props: QueryErr
)}
{error.errorMessageWithoutExpectation && (
<p>
- {startRowColumn ? (
+ {startLineColumn ? (
<HighlightText
text={error.errorMessageWithoutExpectation}
find={/\(line \[\d+], column \[\d+]\)/}
replace={found => (
<a
onClick={() => {
- moveCursorTo(startRowColumn);
+ moveCursorTo(startLineColumn);
}}
>
{found}
diff --git a/web-console/src/components/show-json-or-stages/show-json-or-stages.scss b/web-console/src/components/show-json-or-stages/show-json-or-stages.scss
index f4c7c60e34c..b72dc718290 100644
--- a/web-console/src/components/show-json-or-stages/show-json-or-stages.scss
+++ b/web-console/src/components/show-json-or-stages/show-json-or-stages.scss
@@ -41,6 +41,10 @@
resize: none;
}
+ & > .code-editor {
+ height: 100%;
+ }
+
.loader {
position: relative;
}
diff --git a/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx b/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx
index 23295073de1..88095343e50 100644
--- a/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx
+++ b/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx
@@ -19,13 +19,14 @@
import { Button, ButtonGroup, Intent } from '@blueprintjs/core';
import * as JSONBig from 'json-bigint-native';
import React from 'react';
-import AceEditor from 'react-ace';
import { Execution } from '../../druid-models';
+import { hjson } from '../../editor-languages/hjson';
import { useQueryManager } from '../../hooks';
import { Api, AppToaster, UrlBaser } from '../../singletons';
import { copyToClipboard, downloadFile } from '../../utils';
import { ExecutionStagesPane } from '../../views/workbench-view/execution-stages-pane/execution-stages-pane';
+import { CodeEditor } from '../code-editor/code-editor';
import { Loader } from '../loader/loader';
import './show-json-or-stages.scss';
@@ -108,16 +109,10 @@ export const ShowJsonOrStages = React.memo(function ShowJsonOrStages(props: Show
) : execution ? (
<ExecutionStagesPane execution={execution} />
) : (
- <AceEditor
- mode="hjson"
- theme="solarized_dark"
+ <CodeEditor
+ language={hjson()}
readOnly
- fontSize={12}
- width="100%"
- height="100%"
- showPrintMargin={false}
- showGutter={false}
- value={!jsonState.error ? jsonValue : jsonState.getErrorMessage()}
+ value={(!jsonState.error ? jsonValue : jsonState.getErrorMessage()) ?? ''}
/>
)}
</div>
diff --git a/web-console/src/components/show-json/show-json.scss b/web-console/src/components/show-json/show-json.scss
index b5b960093cb..91c162d3ced 100644
--- a/web-console/src/components/show-json/show-json.scss
+++ b/web-console/src/components/show-json/show-json.scss
@@ -41,6 +41,10 @@
resize: none;
}
+ & > .code-editor {
+ height: 100%;
+ }
+
.loader {
position: relative;
}
diff --git a/web-console/src/components/show-json/show-json.tsx b/web-console/src/components/show-json/show-json.tsx
index f9fa6f6a31c..b0b3dd76f41 100644
--- a/web-console/src/components/show-json/show-json.tsx
+++ b/web-console/src/components/show-json/show-json.tsx
@@ -19,11 +19,12 @@
import { Button, ButtonGroup, Intent } from '@blueprintjs/core';
import * as JSONBig from 'json-bigint-native';
import React from 'react';
-import AceEditor from 'react-ace';
+import { hjson } from '../../editor-languages/hjson';
import { useQueryManager } from '../../hooks';
import { Api, AppToaster, UrlBaser } from '../../singletons';
import { copyToClipboard, downloadFile } from '../../utils';
+import { CodeEditor } from '../code-editor/code-editor';
import { Loader } from '../loader/loader';
import './show-json.scss';
@@ -90,16 +91,10 @@ export const ShowJson = React.memo(function ShowJson(props: ShowJsonProps) {
{jsonState.loading ? (
<Loader />
) : (
- <AceEditor
- mode="hjson"
- theme="solarized_dark"
+ <CodeEditor
+ language={hjson()}
readOnly
- fontSize={12}
- width="100%"
- height="100%"
- showPrintMargin={false}
- showGutter={false}
- value={!jsonState.error ? jsonValue : jsonState.getErrorMessage()}
+ value={(!jsonState.error ? jsonValue : jsonState.getErrorMessage()) ?? ''}
/>
)}
</div>
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx
index a47c71f704e..999ca8b8f72 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -22,7 +22,6 @@ import classNames from 'classnames';
import type { JSX } from 'react';
import React from 'react';
-import { initAceDsqlMode } from './ace-modes/dsql';
import { HeaderBar, Loader } from './components';
import { SqlFunctionsProvider } from './contexts/sql-functions-context';
import type { ConsoleViewId, QueryContext, QueryWithContext } from './druid-models';
@@ -154,7 +153,6 @@ export class ConsoleApplication extends React.PureComponent<
}
const capabilities = data?.[0] || Capabilities.FULL;
const availableSqlFunctions = data?.[1];
- initAceDsqlMode(availableSqlFunctions);
this.setState({
capabilities,
availableSqlFunctions,
diff --git a/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap b/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap
index 7588c8ec88a..1c08bc5651a 100644
--- a/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap
@@ -53,11 +53,14 @@ exports[`EditContextDialog matches snapshot 1`] = `
class="json-input"
>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark"
- id="ace-editor"
- style="width: 100%; height: 100%; font-size: 12px;"
+ class="code-editor"
+ style="height: 100%;"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "{\\n\\n}" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "{\\n\\n}" -->
+ </div>
</div>
</div>
<div
diff --git a/web-console/src/dialogs/lookup-edit-dialog/__snapshots__/lookup-edit-dialog.spec.tsx.snap b/web-console/src/dialogs/lookup-edit-dialog/__snapshots__/lookup-edit-dialog.spec.tsx.snap
index 6b398284684..5871eab6ccc 100644
--- a/web-console/src/dialogs/lookup-edit-dialog/__snapshots__/lookup-edit-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/lookup-edit-dialog/__snapshots__/lookup-edit-dialog.spec.tsx.snap
@@ -272,11 +272,14 @@ exports[`LookupEditDialog matches snapshot 1`] = `
class="json-input"
>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark"
- id="ace-editor"
- style="width: 100%; height: 60vh; font-size: 12px;"
+ class="code-editor"
+ style="height: 60vh;"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "{\\n \\"a\\": 1\\n}" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "{\\n \\"a\\": 1\\n}" -->
+ </div>
</div>
</div>
</div>
diff --git a/web-console/src/dialogs/retention-dialog/retention-dialog.tsx b/web-console/src/dialogs/retention-dialog/retention-dialog.tsx
index 37ccb62bde0..1bf2510e7d5 100644
--- a/web-console/src/dialogs/retention-dialog/retention-dialog.tsx
+++ b/web-console/src/dialogs/retention-dialog/retention-dialog.tsx
@@ -133,7 +133,7 @@ ORDER BY 1`,
{currentTab === 'form' ? (
defaultRules.map((rule, index) => <RuleEditor key={index} rule={rule} tiers={tiers} />)
) : (
- <JsonInput value={defaultRules} jsonCompletions={RETENTION_RULE_COMPLETIONS} />
+ <JsonInput value={defaultRules} readOnly jsonCompletions={RETENTION_RULE_COMPLETIONS} />
)}
</FormGroup>
) : undefined;
diff --git a/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss b/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss
index 6ba7ebdc529..5784a93b529 100644
--- a/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss
+++ b/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss
@@ -32,8 +32,9 @@
display: flex;
flex-direction: column;
- .ace-editor {
+ .code-editor {
flex: 1;
+ height: 100%;
}
.#{$bp-ns}-input {
diff --git a/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx b/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx
index 2f7a19307ae..45d6d520b7f 100644
--- a/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx
+++ b/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx
@@ -29,8 +29,9 @@ import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import * as JSONBig from 'json-bigint-native';
import React, { useMemo, useState } from 'react';
-import AceEditor from 'react-ace';
+import { CodeEditor } from '../../components';
+import { hjson } from '../../editor-languages/hjson';
import { AppToaster } from '../../singletons';
import { copyToClipboard } from '../../utils';
@@ -84,17 +85,12 @@ export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowVa
</FormGroup>
)}
{hasParsed && tab === 'formatted' && (
- <AceEditor
- mode="hjson"
- theme="solarized_dark"
+ <CodeEditor
+ language={hjson()}
className="query-string"
- fontSize={12}
- width="100%"
- height="100%"
- showGutter
- showPrintMargin={false}
- value={JSONBig.stringify(parsed, undefined, 2)}
readOnly
+ showLineNumbers
+ value={JSONBig.stringify(parsed, undefined, 2)}
/>
)}
{(!hasParsed || tab === 'raw') && <TextArea value={str} spellCheck={false} />}
diff --git a/web-console/src/dialogs/spec-dialog/__snapshots__/spec-dialog.spec.tsx.snap b/web-console/src/dialogs/spec-dialog/__snapshots__/spec-dialog.spec.tsx.snap
index 9137f5495fe..f7c5faf429a 100644
--- a/web-console/src/dialogs/spec-dialog/__snapshots__/spec-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/spec-dialog/__snapshots__/spec-dialog.spec.tsx.snap
@@ -50,11 +50,13 @@ exports[`SpecDialog matches snapshot no initSpec 1`] = `
</button>
</div>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark spec-dialog-textarea placeholder-padding"
- id="ace-editor"
- style="width: 100%; height: 500px; font-size: 12px;"
+ class="code-editor spec-dialog-textarea"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "", placeholder: "{ JSON spec... }" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "", placeholder: "{ JSON spec... }" -->
+ </div>
</div>
<div
class="bp6-dialog-footer"
@@ -146,11 +148,13 @@ exports[`SpecDialog matches snapshot with initSpec 1`] = `
</button>
</div>
<div
- class=" ace_editor ace_hidpi ace-solarized-dark ace_dark spec-dialog-textarea placeholder-padding"
- id="ace-editor"
- style="width: 100%; height: 500px; font-size: 12px;"
+ class="code-editor spec-dialog-textarea"
>
- <!-- Ace editor, mode: ace/mode/hjson, value: "{\\n \\"type\\": \\"some-spec\\"\\n}" -->
+ <div
+ class="cm-editor"
+ >
+ <!-- Code editor, language: hjson, value: "{\\n \\"type\\": \\"some-spec\\"\\n}" -->
+ </div>
</div>
<div
class="bp6-dialog-footer"
diff --git a/web-console/src/dialogs/spec-dialog/spec-dialog.scss b/web-console/src/dialogs/spec-dialog/spec-dialog.scss
index 267d5c2799a..91b41b6ba1d 100644
--- a/web-console/src/dialogs/spec-dialog/spec-dialog.scss
+++ b/web-console/src/dialogs/spec-dialog/spec-dialog.scss
@@ -25,6 +25,8 @@
}
.spec-dialog-textarea {
+ width: 100%;
+ height: 500px;
margin-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
diff --git a/web-console/src/dialogs/spec-dialog/spec-dialog.tsx b/web-console/src/dialogs/spec-dialog/spec-dialog.tsx
index 5b6129b9368..92b6ade1ab8 100644
--- a/web-console/src/dialogs/spec-dialog/spec-dialog.tsx
+++ b/web-console/src/dialogs/spec-dialog/spec-dialog.tsx
@@ -19,10 +19,11 @@
import { Button, Classes, Dialog, Intent } from '@blueprintjs/core';
import * as JSONBig from 'json-bigint-native';
import React, { useState } from 'react';
-import AceEditor from 'react-ace';
+import { CodeEditor } from '../../components';
+import { hjson } from '../../editor-languages/hjson';
import { AppToaster } from '../../singletons';
-import { offsetToRowColumn } from '../../utils';
+import { offsetToLineColumn } from '../../utils';
import './spec-dialog.scss';
@@ -44,11 +45,11 @@ export const SpecDialog = React.memo(function SpecDialog(props: SpecDialogProps)
try {
parsed = JSONBig.parse(spec);
} catch (e) {
- const rowColumn = typeof e.at === 'number' ? offsetToRowColumn(spec, e.at) : undefined;
+ const position = typeof e.at === 'number' ? offsetToLineColumn(spec, e.at) : undefined;
AppToaster.show({
intent: Intent.DANGER,
message: `Could not parse JSON: ${e.message}${
- rowColumn ? ` (at line ${rowColumn.row + 1}, column ${rowColumn.column + 1})` : ''
+ position ? ` (at line ${position.line}, column ${position.column})` : ''
}`,
timeout: 5000,
});
@@ -67,27 +68,14 @@ export const SpecDialog = React.memo(function SpecDialog(props: SpecDialogProps)
title={title}
canOutsideClickClose={false}
>
- <AceEditor
- mode="hjson"
- theme="solarized_dark"
- className="spec-dialog-textarea placeholder-padding"
+ <CodeEditor
+ language={hjson()}
+ className="spec-dialog-textarea"
onChange={setSpec}
- fontSize={12}
- showPrintMargin={false}
- showGutter
- highlightActiveLine
+ showLineNumbers
+ padding={10}
value={spec}
- width="100%"
- setOptions={{
- showLineNumbers: true,
- tabSize: 2,
- newLineMode: 'unix',
- }}
placeholder="{ JSON spec... }"
- onLoad={editor => {
- editor.renderer.setPadding(10);
- editor.renderer.setScrollMargin(10, 10, 0, 0);
- }}
/>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
diff --git a/web-console/src/druid-models/workbench-query/workbench-query.spec.ts b/web-console/src/druid-models/workbench-query/workbench-query.spec.ts
index 13908bbf2cf..364c8a08705 100644
--- a/web-console/src/druid-models/workbench-query/workbench-query.spec.ts
+++ b/web-console/src/druid-models/workbench-query/workbench-query.spec.ts
@@ -120,19 +120,19 @@ describe('WorkbenchQuery', () => {
expect(String(WorkbenchQuery.fromString(tabString))).toEqual(tabString);
});
- describe('.getRowColumnFromIssue', () => {
+ describe('.getLineColumnFromIssue', () => {
it('works when it can not find at line', () => {
- expect(WorkbenchQuery.getRowColumnFromIssue(`lol`)).toBeUndefined();
+ expect(WorkbenchQuery.getLineColumnFromIssue(`lol`)).toBeUndefined();
});
it('works when it can find at line', () => {
expect(
- WorkbenchQuery.getRowColumnFromIssue(
+ WorkbenchQuery.getLineColumnFromIssue(
`End of input while parsing an object (missing '}') at line 40,2 >>>} ...`,
),
).toEqual({
- row: 39,
- column: 1,
+ line: 40,
+ column: 2,
});
});
});
diff --git a/web-console/src/druid-models/workbench-query/workbench-query.ts b/web-console/src/druid-models/workbench-query/workbench-query.ts
index 7072d41f729..fa0e05f0bc4 100644
--- a/web-console/src/druid-models/workbench-query/workbench-query.ts
+++ b/web-console/src/druid-models/workbench-query/workbench-query.ts
@@ -36,7 +36,7 @@ import Hjson from 'hjson';
import * as JSONBig from 'json-bigint-native';
import { v4 as uuidv4 } from 'uuid';
-import type { RowColumn } from '../../utils';
+import type { LineColumn } from '../../utils';
import { caseInsensitiveEquals, deleteKeys } from '../../utils';
import type { ArrayIngestMode } from '../array-ingest-mode/array-ingest-mode';
import type { DruidEngine } from '../druid-engine/druid-engine';
@@ -212,10 +212,10 @@ export class WorkbenchQuery {
return orderByExpressions.length ? SqlOrderByClause.create(orderByExpressions) : undefined;
}
- static getRowColumnFromIssue(issue: string): RowColumn | undefined {
+ static getLineColumnFromIssue(issue: string): LineColumn | undefined {
const m = /at line (\d+),(\d+)/.exec(issue);
if (!m) return;
- return { row: Number(m[1]) - 1, column: Number(m[2]) - 1 };
+ return { line: Number(m[1]), column: Number(m[2]) };
}
static isTaskEngineNeeded(queryString: string): boolean {
diff --git a/web-console/src/ace-completions/make-doc-html.ts b/web-console/src/editor-completions/editor-completion.ts
similarity index 51%
rename from web-console/src/ace-completions/make-doc-html.ts
rename to web-console/src/editor-completions/editor-completion.ts
index f9d96cce490..7d5132b238d 100644
--- a/web-console/src/ace-completions/make-doc-html.ts
+++ b/web-console/src/editor-completions/editor-completion.ts
@@ -16,20 +16,24 @@
* limitations under the License.
*/
-import escape from 'lodash.escape';
+import type { Completion } from '@codemirror/autocomplete';
-import { assemble } from '../utils';
-
-export interface ItemDescription {
+/**
+ * Documentation shown next to the completion list when the completion is selected
+ */
+export interface CompletionDoc {
+ /** The title */
name: string;
+ /** Plain text shown (in monospace) under the title, like a function signature (one per line if there are several) */
syntax?: string;
+ /** Plain text description */
description?: string;
+ /** Description in "doc markdown", the simplified markdown of the docs in src/editor-languages/dsql-docs.ts */
+ descriptionMarkdown?: string;
}
-export function makeDocHtml(item: ItemDescription) {
- return assemble(
- `<div class="doc-name">${item.name}</div>`,
- item.syntax ? `<div class="doc-syntax">${escape(item.syntax)}</div>` : undefined,
- item.description ? `<div class="doc-description">${item.description}</div>` : undefined,
- ).join('\n');
-}
+/**
+ * A completion suggestion: CodeMirror's `Completion` (`label`, `detail`, `boost`, ...) with the documentation as data
+ * (`doc`) rather than an `info` that renders it. `makeCompletionSource` turns the `doc` into the `info`.
+ */
+export type EditorCompletion = Omit<Completion, 'info'> & { doc?: CompletionDoc };
diff --git a/web-console/src/ace-completions/hjson-completions.spec.ts b/web-console/src/editor-completions/hjson-completions.spec.ts
similarity index 56%
rename from web-console/src/ace-completions/hjson-completions.spec.ts
rename to web-console/src/editor-completions/hjson-completions.spec.ts
index 57202c8eec0..fd253aedc53 100644
--- a/web-console/src/ace-completions/hjson-completions.spec.ts
+++ b/web-console/src/editor-completions/hjson-completions.spec.ts
@@ -17,58 +17,53 @@
*/
import { NATIVE_JSON_QUERY_COMPLETIONS } from '../druid-models';
+import { hjson } from '../editor-languages/hjson';
+import { completionContextAt } from '../test-utils/completion-context';
-import type { GetHjsonCompletionsOptions } from './hjson-completions';
import { getHjsonCompletions } from './hjson-completions';
describe('getHjsonCompletions', () => {
- const baseOptions: GetHjsonCompletionsOptions = {
- jsonCompletions: NATIVE_JSON_QUERY_COMPLETIONS,
- textBefore: '',
- charBeforePrefix: '',
- prefix: '',
- };
+ // The completions with the cursor at the |
+ function completionsAt(textWithCursor: string) {
+ const [context, word] = completionContextAt(hjson(), textWithCursor);
+ return getHjsonCompletions(context, word, NATIVE_JSON_QUERY_COMPLETIONS);
+ }
it('returns empty array for comments', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": "scan", // This is a comment with ',
- prefix: 'S',
- });
- expect(completions).toEqual([]);
+ expect(completionsAt('{ "queryType": "scan", // This is a comment with S|')).toEqual([]);
+ expect(completionsAt('{ "queryType": "scan", /* S| */')).toEqual([]);
});
it('returns root level property suggestions', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{',
- });
+ const completions = completionsAt('{|');
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('queryType');
expect(completionValues).toContain('dataSource');
});
it('returns queryType value suggestions', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": ',
- });
+ const completions = completionsAt('{ "queryType": |');
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('timeseries');
expect(completionValues).toContain('topN');
expect(completionValues).toContain('groupBy');
expect(completionValues).toContain('scan');
});
+ it('sees the properties after the cursor', () => {
+ const completionValues = completionsAt('{\n |\n "queryType": "timeseries"\n}').map(
+ c => c.label,
+ );
+ expect(completionValues).toContain('granularity');
+ expect(completionValues).not.toContain('queryType');
+ });
+
it('returns query-specific properties based on queryType', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": "timeseries", ',
- });
+ const completions = completionsAt('{ "queryType": "timeseries", |');
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('granularity');
expect(completionValues).toContain('aggregations');
expect(completionValues).toContain('intervals');
@@ -78,47 +73,37 @@ describe('getHjsonCompletions', () => {
});
it('returns granularity value suggestions', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": "timeseries", "granularity": ',
- });
+ const completions = completionsAt('{ "queryType": "timeseries", "granularity": |');
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('hour');
expect(completionValues).toContain('day');
expect(completionValues).toContain('all');
});
it('returns aggregation properties in array context', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": "timeseries", "aggregations": [{ ',
- });
+ const completions = completionsAt('{ "queryType": "timeseries", "aggregations": [{ |');
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('type');
expect(completionValues).toContain('name');
});
it('returns aggregation type values', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": "timeseries", "aggregations": [{ "type": ',
- });
+ const completions = completionsAt('{ "queryType": "timeseries", "aggregations": [{ "type": |');
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('count');
expect(completionValues).toContain('longSum');
expect(completionValues).toContain('doubleSum');
});
it('returns fieldName for field-based aggregators', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "queryType": "timeseries", "aggregations": [{ "type": "longSum", ',
- });
+ const completions = completionsAt(
+ '{ "queryType": "timeseries", "aggregations": [{ "type": "longSum", |',
+ );
- const completionValues = completions.map(c => c.value);
+ const completionValues = completions.map(c => c.label);
expect(completionValues).toContain('fieldName');
expect(completionValues).toContain('name');
@@ -127,36 +112,24 @@ describe('getHjsonCompletions', () => {
});
it('handles quoted vs unquoted completions', () => {
- const unquotedCompletions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ ',
- charBeforePrefix: '',
- });
-
- const quotedCompletions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{ "',
- charBeforePrefix: '"',
- });
+ const unquotedCompletions = completionsAt('{ |');
+ const quotedCompletions = completionsAt('{ "|');
// Unquoted should include quotes for property names
- const unquotedValues = unquotedCompletions.map(c => c.value);
+ const unquotedValues = unquotedCompletions.map(c => c.label);
expect(unquotedValues.some(v => v.includes('"'))).toBe(false); // Simple property names don't need quotes
// Quoted completions should not add extra quotes
- const quotedValues = quotedCompletions.map(c => c.value);
+ const quotedValues = quotedCompletions.map(c => c.label);
expect(quotedValues).toContain('queryType');
expect(quotedValues).toContain('dataSource');
});
it('provides documentation for completions', () => {
- const completions = getHjsonCompletions({
- ...baseOptions,
- textBefore: '{',
- });
+ const completions = completionsAt('{|');
- const queryTypeCompletion = completions.find(c => c.value === 'queryType');
+ const queryTypeCompletion = completions.find(c => c.label === 'queryType');
expect(queryTypeCompletion).toBeDefined();
- expect(queryTypeCompletion?.meta).toBe('property');
+ expect(queryTypeCompletion?.detail).toBe('property');
});
});
diff --git a/web-console/src/ace-completions/hjson-completions.ts b/web-console/src/editor-completions/hjson-completions.ts
similarity index 65%
rename from web-console/src/ace-completions/hjson-completions.ts
rename to web-console/src/editor-completions/hjson-completions.ts
index f7f48d4c98b..0f81d244a8e 100644
--- a/web-console/src/ace-completions/hjson-completions.ts
+++ b/web-console/src/editor-completions/hjson-completions.ts
@@ -16,34 +16,35 @@
* limitations under the License.
*/
-import type { Ace } from 'ace-builds';
+import type { CompletionContext } from '@codemirror/autocomplete';
+import type { CompletionWord } from '../components/code-editor/completion-source';
+import { tokenBefore } from '../components/code-editor/completion-source';
import type { JsonCompletionItem, JsonCompletionRule } from '../utils';
-import { getCompletionsForPath, getHjsonContext } from '../utils';
+import { getCompletionsForPath } from '../utils';
-import { makeDocHtml } from './make-doc-html';
-
-export interface GetHjsonCompletionsOptions {
- jsonCompletions: JsonCompletionRule[];
- textBefore: string;
- charBeforePrefix: string;
- prefix: string;
-}
-
-export function getHjsonCompletions({
- jsonCompletions,
- textBefore,
- charBeforePrefix,
- prefix,
-}: GetHjsonCompletionsOptions): Ace.ValueCompletion[] {
- // Get the context of where we are in the JSON structure
- const hjsonContext = getHjsonContext(textBefore + charBeforePrefix + prefix);
+import type { EditorCompletion } from './editor-completion';
+import type { HjsonContext } from './hjson-context';
+import { getHjsonContext } from './hjson-context';
+/**
+ * The completions for the word being typed in Hjson. Needs the hjson language for the syntax tree.
+ */
+export function getHjsonCompletions(
+ { state, pos }: CompletionContext,
+ { from }: CompletionWord,
+ jsonCompletions: JsonCompletionRule[],
+): EditorCompletion[] {
// Don't provide completions if we're in a comment
- if (hjsonContext.isEditingComment) {
+ const tokenName = tokenBefore(state, pos).name;
+ if (tokenName === 'LineComment' || tokenName === 'BlockComment') {
return [];
}
+ // Get the context of where we are in the JSON structure
+ const hjsonContext = getHjsonContext(state, pos);
+ const quote = state.sliceDoc(from - 1, from) === '"';
+
// Get completions based on the current path and object context
let pathForCompletions = hjsonContext.path;
@@ -60,15 +61,10 @@ export function getHjsonCompletions({
);
// Filter completions based on whether we're editing a key or value
- const filteredCompletions = filterCompletionsByContext(
- completionItems,
- hjsonContext,
- charBeforePrefix,
- );
+ const filteredCompletions = filterCompletionsByContext(completionItems, hjsonContext, quote);
- // Convert to Ace completions format
return filteredCompletions.map(item =>
- convertToAceCompletion(item, charBeforePrefix, hjsonContext.isEditingKey),
+ convertToEditorCompletion(item, hjsonContext.isEditingKey),
);
}
@@ -77,11 +73,9 @@ export function getHjsonCompletions({
*/
function filterCompletionsByContext(
completions: JsonCompletionItem[],
- hjsonContext: { isEditingKey: boolean; currentKey?: string; currentObject: any },
- charBeforePrefix: string,
+ hjsonContext: HjsonContext,
+ quote: boolean,
): JsonCompletionItem[] {
- const quote = charBeforePrefix === '"';
-
if (hjsonContext.isEditingKey) {
// We're editing a key - only show property completions
// Filter out properties that already exist in the current object
@@ -104,28 +98,24 @@ function filterCompletionsByContext(
}
/**
- * Convert a CompletionItem to an Ace Completion
+ * Convert a CompletionItem to an EditorCompletion
*/
-function convertToAceCompletion(
+function convertToEditorCompletion(
item: JsonCompletionItem,
- _charBeforePrefix: string,
isEditingKey: boolean,
-): Ace.ValueCompletion {
- // const quote = charBeforePrefix === '"';
-
- const completion: Ace.ValueCompletion = {
- value: item.value,
- score: 5,
- meta: isEditingKey ? 'property' : 'value',
+): EditorCompletion {
+ const completion: EditorCompletion = {
+ label: item.value,
+ boost: 5,
+ detail: isEditingKey ? 'property' : 'value',
};
// Add documentation if available
if (item.documentation) {
- completion.docText = item.documentation;
- completion.docHTML = makeDocHtml({
+ completion.doc = {
name: item.value,
description: item.documentation,
- });
+ };
}
return completion;
diff --git a/web-console/src/utils/hjson-context.spec.ts b/web-console/src/editor-completions/hjson-context.spec.ts
similarity index 51%
rename from web-console/src/utils/hjson-context.spec.ts
rename to web-console/src/editor-completions/hjson-context.spec.ts
index e2b239dcad0..7bc09f3bd3f 100644
--- a/web-console/src/utils/hjson-context.spec.ts
+++ b/web-console/src/editor-completions/hjson-context.spec.ts
@@ -15,113 +15,155 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
+import { EditorState } from '@codemirror/state';
import { sane } from 'druid-query-toolkit';
+import { hjson } from '../editor-languages/hjson';
+
import { getHjsonContext } from './hjson-context';
+// The context with the cursor at the |
+function contextAt(textWithCursor: string) {
+ const pos = textWithCursor.indexOf('|');
+ const state = EditorState.create({
+ doc: textWithCursor.slice(0, pos) + textWithCursor.slice(pos + 1),
+ extensions: hjson(),
+ });
+ return getHjsonContext(state, pos);
+}
+
describe('getHjsonContext', () => {
describe('root level', () => {
it('detects cursor at empty object', () => {
- const result = getHjsonContext('{');
- expect(result).toEqual({
+ expect(contextAt('{|')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
- });
-
- it('detects cursor after opening brace with whitespace', () => {
- const result = getHjsonContext('{ ');
- expect(result).toEqual({
+ expect(contextAt('{ |')).toEqual({
+ path: [],
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: {},
+ });
+ expect(contextAt('{|}')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
});
- it('detects cursor at empty array treated as object', () => {
- const result = getHjsonContext('[');
- expect(result).toEqual({
+ it('detects cursor at empty array', () => {
+ expect(contextAt('[|')).toEqual({
path: [],
isEditingKey: false,
currentKey: '0',
- isEditingComment: false,
currentObject: {},
});
});
+
+ it('detects a key at the root without braces', () => {
+ expect(contextAt('que|')).toEqual({
+ path: [],
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: {},
+ });
+ expect(contextAt('queryType: scan\nda|')).toEqual({
+ path: [],
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: { queryType: 'scan' },
+ });
+ expect(contextAt('queryType: scan\ndataSource: |')).toEqual({
+ path: [],
+ isEditingKey: false,
+ currentKey: 'dataSource',
+ currentObject: { queryType: 'scan' },
+ });
+ });
});
describe('key editing', () => {
it('detects cursor while typing a key', () => {
- const result = getHjsonContext('{ que');
- expect(result).toEqual({
+ expect(contextAt('{ que|')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor while typing a quoted key', () => {
- const result = getHjsonContext('{ "que');
- expect(result).toEqual({
+ expect(contextAt('{ "que|')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor after comma expecting new key', () => {
- const result = getHjsonContext('{ "queryType": "scan", ');
- expect(result).toEqual({
+ expect(contextAt('{ "queryType": "scan", |')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
- currentObject: {
- queryType: 'scan',
- },
+ currentObject: { queryType: 'scan' },
+ });
+ });
+
+ it('detects cursor in the middle of a key', () => {
+ expect(contextAt('{ "que|ryType": "scan" }')).toEqual({
+ path: [],
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: {},
});
});
});
describe('value editing', () => {
it('detects cursor after colon expecting value', () => {
- const result = getHjsonContext('{ "queryType": ');
- expect(result).toEqual({
+ expect(contextAt('{ "queryType": |')).toEqual({
path: [],
isEditingKey: false,
currentKey: 'queryType',
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor while typing a string value', () => {
- const result = getHjsonContext('{ "queryType": "sc');
- expect(result).toEqual({
+ expect(contextAt('{ "queryType": "sc|')).toEqual({
path: [],
isEditingKey: false,
currentKey: 'queryType',
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor in unquoted value (Hjson feature)', () => {
- const result = getHjsonContext('{ queryType: sc');
- expect(result).toEqual({
+ expect(contextAt('{ queryType: sc|')).toEqual({
+ path: [],
+ isEditingKey: false,
+ currentKey: 'queryType',
+ currentObject: {},
+ });
+ });
+
+ it('detects cursor right after a value', () => {
+ expect(contextAt('{ "queryType": "scan"|')).toEqual({
path: [],
isEditingKey: false,
currentKey: 'queryType',
- isEditingComment: false,
+ currentObject: {},
+ });
+ expect(contextAt('{ "filter": {}|')).toEqual({
+ path: [],
+ isEditingKey: false,
+ currentKey: 'filter',
currentObject: {},
});
});
@@ -129,216 +171,248 @@ describe('getHjsonContext', () => {
describe('nested objects', () => {
it('detects cursor in nested object key position', () => {
- const result = getHjsonContext('{ "query": { ');
- expect(result).toEqual({
+ expect(contextAt('{ "query": { |')).toEqual({
path: ['query'],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor in deeply nested value position', () => {
- const result = getHjsonContext('{ "query": { "dataSource": { "type": ');
- expect(result).toEqual({
+ expect(contextAt('{ "query": { "dataSource": { "type": |')).toEqual({
path: ['query', 'dataSource'],
isEditingKey: false,
currentKey: 'type',
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor in nested object after comma', () => {
- const result = getHjsonContext('{ "query": { "dataSource": "wikipedia", "queryType": ');
- expect(result).toEqual({
+ expect(contextAt('{ "query": { "dataSource": "wikipedia", "queryType": |')).toEqual({
path: ['query'],
isEditingKey: false,
currentKey: 'queryType',
- isEditingComment: false,
currentObject: { dataSource: 'wikipedia' },
});
});
});
- describe('arrays as objects', () => {
+ describe('arrays', () => {
it('detects cursor in array first position', () => {
- const result = getHjsonContext('{ "dimensions": [');
- expect(result).toEqual({
+ expect(contextAt('{ "dimensions": [|')).toEqual({
path: ['dimensions'],
isEditingKey: false,
currentKey: '0',
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor in array after first element', () => {
- const result = getHjsonContext('{ "dimensions": ["page", ');
- expect(result).toEqual({
+ expect(contextAt('{ "dimensions": ["page", |')).toEqual({
path: ['dimensions'],
isEditingKey: false,
currentKey: '1',
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor in nested object within array', () => {
- const result = getHjsonContext('{ "filters": [{ "type": ');
- expect(result).toEqual({
+ expect(contextAt('{ "filters": [{ "type": |')).toEqual({
path: ['filters', '0'],
isEditingKey: false,
currentKey: 'type',
- isEditingComment: false,
currentObject: {},
});
});
it('detects cursor in array element object key position', () => {
- const result = getHjsonContext('{ "filters": [{ "type": "selector", ');
- expect(result).toEqual({
+ expect(contextAt('{ "filters": [{ "type": "selector", |')).toEqual({
path: ['filters', '0'],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: { type: 'selector' },
});
});
+
+ it('detects cursor in a later object within array', () => {
+ expect(contextAt('{ "filters": [{ "type": "selector" }, { "type": "in", |')).toEqual({
+ path: ['filters', '1'],
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: { type: 'in' },
+ });
+ });
+
+ it('gives the object that the array is in', () => {
+ expect(contextAt('{ "queryType": "scan", "columns": [|], "limit": 10 }')).toEqual({
+ path: ['columns'],
+ isEditingKey: false,
+ currentKey: '0',
+ currentObject: { queryType: 'scan', limit: 10 },
+ });
+ });
+ });
+
+ describe('the text after the cursor', () => {
+ it('includes the properties after the cursor', () => {
+ expect(
+ contextAt(sane`
+ {
+ |
+ "queryType": "topN",
+ "threshold": 10
+ }
+ `),
+ ).toEqual({
+ path: [],
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: { queryType: 'topN', threshold: 10 },
+ });
+ });
+
+ it('detects a value that is being typed before the next property', () => {
+ expect(
+ contextAt(sane`
+ {
+ "queryType": "scan",
+ "dataSource": |
+ "limit": 10
+ }
+ `),
+ ).toEqual({
+ path: [],
+ isEditingKey: false,
+ currentKey: 'dataSource',
+ currentObject: { queryType: 'scan' },
+ });
+ });
});
describe('complex scenarios', () => {
it('handles multiline Hjson', () => {
- const hjson = `{
+ expect(
+ contextAt(`{
"queryType": "groupBy",
"dataSource": "wikipedia",
"dimensions": [
{
"type": "default",
- "dimension": `;
- const result = getHjsonContext(hjson);
- expect(result).toEqual({
+ "dimension": |`),
+ ).toEqual({
path: ['dimensions', '0'],
isEditingKey: false,
currentKey: 'dimension',
- isEditingComment: false,
currentObject: { type: 'default' },
});
});
- it('handles Hjson comments', () => {
- const result = getHjsonContext(sane`
- {
- // This is a comment
- "queryType": "scan",
- # This is also a comment
- interval:
- '''
- Hello
- World
- '''
- /* Multi-line
- comment */
- "dataSource":
- `);
- expect(result).toEqual({
+ it('handles Hjson comments and multiline strings', () => {
+ expect(
+ contextAt(sane`
+ {
+ // This is a comment
+ "queryType": "scan",
+ # This is also a comment
+ interval:
+ '''
+ Hello
+ World
+ '''
+ /* Multi-line
+ comment */
+ "dataSource": |
+ `),
+ ).toEqual({
path: [],
isEditingKey: false,
currentKey: 'dataSource',
- isEditingComment: false,
currentObject: { queryType: 'scan', interval: 'Hello\nWorld' },
});
});
- it('handles trailing commas (Hjson feature)', () => {
- const result = getHjsonContext(sane`
- {
- "queryType": "scan",
- "dataSource": "wikipedia",
- m
- `);
- expect(result).toEqual({
+ it('handles keys after a comma or a new line (Hjson feature)', () => {
+ expect(
+ contextAt(sane`
+ {
+ "queryType": "scan",
+ "dataSource": "wikipedia",
+ m|
+ `),
+ ).toEqual({
path: [],
isEditingKey: true,
- currentKey: 'm',
- isEditingComment: false,
+ currentKey: undefined,
currentObject: { queryType: 'scan', dataSource: 'wikipedia' },
});
- });
- it('handles no trailing commas (Hjson feature)', () => {
- const result = getHjsonContext(sane`
- {
- "queryType": "scan",
- "dataSource": "wikipedia"
- m
- `);
- expect(result).toEqual({
+ expect(
+ contextAt(sane`
+ {
+ "queryType": "scan",
+ "dataSource": "wikipedia"
+ m|
+ `),
+ ).toEqual({
path: [],
isEditingKey: true,
- currentKey: 'm',
- isEditingComment: false,
+ currentKey: undefined,
currentObject: { queryType: 'scan', dataSource: 'wikipedia' },
});
});
- it('custom', () => {
- const result = getHjsonContext(
- sane`
+ it('handles quoteless keys and values', () => {
+ expect(
+ contextAt(sane`
{
- queryType: "topN"
- intervals: "..."
- dataSource: "sdsds"
+ queryType: topN
+ intervals: ...
+ dataSource: sdsds
filter: {
- t`,
- );
- expect(result).toEqual({
- currentObject: {},
- isEditingComment: false,
- isEditingKey: true,
+ t|
+ `),
+ ).toEqual({
path: ['filter'],
- currentKey: 't',
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: {},
});
});
it('handles incomplete nested structure', () => {
- const result = getHjsonContext(sane`
- {
- "queryType": "scan",
- "dataSource": {
- "type": "restrict",
- "base": {
- "type": "table",
- "name": "wikipedia"
+ expect(
+ contextAt(sane`
+ {
+ "queryType": "scan",
+ "dataSource": {
+ "type": "restrict",
+ "base": {
+ "type": "table",
+ "name": "wikipedia"
+ },
+ "policy": {
+ "type": "noRestriction"
+ }
},
- "policy": {
- "type": "noRestriction"
- }
- },
- "intervals": {
- "type": "intervals",
- "intervals": [
- "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z"
- ]
- },
- `);
- expect(result).toEqual({
+ "intervals": {
+ "type": "intervals",
+ "intervals": [
+ "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z"
+ ]
+ },
+ |
+ `),
+ ).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {
queryType: 'scan',
dataSource: {
type: 'restrict',
- base: {
- type: 'table',
- name: 'wikipedia',
- },
- policy: {
- type: 'noRestriction',
- },
+ base: { type: 'table', name: 'wikipedia' },
+ policy: { type: 'noRestriction' },
},
intervals: {
type: 'intervals',
@@ -347,91 +421,33 @@ describe('getHjsonContext', () => {
},
});
});
+
+ it('reads the values of the other properties', () => {
+ expect(
+ contextAt(
+ '{ "s": "a\\"b", "n": -1.5e3, "t": true, "f": false, "z": null, "q": hello world\n |',
+ ).currentObject,
+ ).toEqual({ s: 'a"b', n: -1500, t: true, f: false, z: null, q: 'hello world' });
+ });
});
describe('edge cases', () => {
it('handles empty string', () => {
- const result = getHjsonContext('');
- expect(result).toEqual({
+ expect(contextAt('|')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
});
it('handles just whitespace', () => {
- const result = getHjsonContext(' ');
- expect(result).toEqual({
- path: [],
- isEditingKey: true,
- currentKey: undefined,
- isEditingComment: false,
- currentObject: {},
- });
- });
-
- it('handles unclosed string', () => {
- const result = getHjsonContext('{ "queryType": "scan');
- expect(result).toEqual({
- path: [],
- isEditingKey: false,
- currentKey: 'queryType',
- isEditingComment: false,
- currentObject: {},
- });
- });
-
- it('handles cursor in middle of key', () => {
- // | represents cursor position conceptually
- // Assuming we only get text up to cursor, so:
- const actualInput = '{ "query';
- const actualResult = getHjsonContext(actualInput);
- expect(actualResult).toEqual({
+ expect(contextAt(' |')).toEqual({
path: [],
isEditingKey: true,
currentKey: undefined,
- isEditingComment: false,
currentObject: {},
});
});
});
-
- describe('comment detection', () => {
- it('detects cursor inside single-line comment', () => {
- const result = getHjsonContext('{ "queryType": "scan", // This is a com');
- expect(result).toEqual({
- path: [],
- isEditingKey: true,
- currentKey: undefined,
- isEditingComment: true,
- currentObject: { queryType: 'scan' },
- });
- });
-
- it('detects cursor inside multi-line comment', () => {
- const result = getHjsonContext('{ "queryType": "scan", /* This is a multi-line\n com');
- expect(result).toEqual({
- path: [],
- isEditingKey: true,
- currentKey: undefined,
- isEditingComment: true,
- currentObject: { queryType: 'scan' },
- });
- });
- });
-
- describe('currentObject property', () => {
- it('returns empty object placeholder for now', () => {
- const result = getHjsonContext('{ "queryType": "timeseries", "granularity": ');
- expect(result.currentObject).toEqual({ queryType: 'timeseries' });
- });
-
- it('includes currentObject in all contexts', () => {
- const result = getHjsonContext('{ "filter": { "type": ');
- expect(result).toHaveProperty('currentObject');
- expect(result.currentObject).toEqual({});
- });
- });
});
diff --git a/web-console/src/editor-completions/hjson-context.ts b/web-console/src/editor-completions/hjson-context.ts
new file mode 100644
index 00000000000..a7e703590ac
--- /dev/null
+++ b/web-console/src/editor-completions/hjson-context.ts
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { EditorState } from '@codemirror/state';
+import type { SyntaxNode } from '@lezer/common';
+import Hjson from 'hjson';
+
+import { completeSyntaxTree } from '../components/code-editor/completion-source';
+
+/**
+ * Where the cursor is in an Hjson document
+ */
+export interface HjsonContext {
+ /**
+ * The path of keys leading to the object or array that the cursor is in, e.g. ["query", "dataSource"]. For arrays,
+ * includes the index as a string key, e.g. ["filters", "0", "dimension"]. Empty at the root.
+ */
+ path: string[];
+
+ /** Whether the cursor is where a key goes (true) or where a value goes (false) */
+ isEditingKey: boolean;
+
+ /** When editing a value, the key of that value (the index as a string in an array). Undefined when editing a key. */
+ currentKey?: string;
+
+ /**
+ * The object that the cursor is in (for an array, the object that the array is in), without the property that is
+ * being edited. It includes the properties after the cursor.
+ */
+ currentObject: Record<string, unknown>;
+}
+
+const VALUE_NODES = new Set([
+ 'True',
+ 'False',
+ 'Null',
+ 'Number',
+ 'String',
+ 'MultilineString',
+ 'QuotelessString',
+ 'Object',
+ 'Array',
+]);
+
+function valueChildren(node: SyntaxNode): SyntaxNode[] {
+ const children: SyntaxNode[] = [];
+ for (let child = node.firstChild; child; child = child.nextSibling) {
+ if (VALUE_NODES.has(child.name)) children.push(child);
+ }
+ return children;
+}
+
+function isSameNode(a: SyntaxNode | undefined, b: SyntaxNode): boolean {
+ return Boolean(a && a.from === b.from && a.to === b.to && a.name === b.name);
+}
+
+function stringValue(text: string): string {
+ try {
+ return JSON.parse(text);
+ } catch {
+ // Not closed yet (it ends at the end of the line), or with a bad escape
+ return text.replace(/^"/, '').replace(/(?:"|\r?\n)$/, '');
+ }
+}
+
+function propertyKey(state: EditorState, property: SyntaxNode): string | undefined {
+ const name = property.getChild('PropertyName');
+ // An empty name is what the error recovery makes of a missing key
+ if (!name || name.from === name.to) return;
+ const text = state.sliceDoc(name.from, name.to);
+ return name.firstChild?.name === 'String' ? stringValue(text) : text;
+}
+
+/**
+ * The value of a property, if it has a colon and a value
+ */
+function propertyValue(property: SyntaxNode): SyntaxNode | undefined {
+ if (!property.getChild(':')) return;
+ return valueChildren(property)[0];
+}
+
+/**
+ * The value of a node, close enough for the completion rules to look at
+ */
+function nodeValue(state: EditorState, node: SyntaxNode): unknown {
+ const text = state.sliceDoc(node.from, node.to);
+ switch (node.name) {
+ case 'Object':
+ return objectValue(state, node);
+
+ case 'Array':
+ return valueChildren(node).map(child => nodeValue(state, child));
+
+ case 'True':
+ return true;
+
+ case 'False':
+ return false;
+
+ case 'Null':
+ return null;
+
+ case 'Number':
+ return Number(text);
+
+ case 'String':
+ return stringValue(text);
+
+ case 'MultilineString': {
+ // Hjson removes the indentation of the column that the string starts at
+ const column = node.from - state.doc.lineAt(node.from).from;
+ try {
+ return Hjson.parse(`v:\n${' '.repeat(column)}${text}`).v;
+ } catch {
+ return text.replace(/^'''/, ''); // Not closed yet
+ }
+ }
+
+ default:
+ return text.trim(); // QuotelessString
+ }
+}
+
+/**
+ * The properties (that have a key and a value) of an object, or of the document without the root braces
+ */
+function objectValue(
+ state: EditorState,
+ node: SyntaxNode,
+ skipProperty?: SyntaxNode,
+): Record<string, unknown> {
+ const value: Record<string, unknown> = {};
+ for (const property of node.getChildren('Property')) {
+ if (isSameNode(skipProperty, property)) continue;
+ const key = propertyKey(state, property);
+ const propertyValueNode = propertyValue(property);
+ if (key === undefined || !propertyValueNode) continue;
+ value[key] = nodeValue(state, propertyValueNode);
+ }
+ return value;
+}
+
+function isClosed(node: SyntaxNode): boolean {
+ const last = node.lastChild?.name;
+ return node.name === 'Object' ? last === '}' : last === ']';
+}
+
+function pathTo(state: EditorState, container: SyntaxNode): string[] {
+ const path: string[] = [];
+ for (let node = container; node.parent; node = node.parent) {
+ const { parent } = node;
+ if (parent.name === 'Property') {
+ path.unshift(propertyKey(state, parent) ?? '');
+ } else if (parent.name === 'Array') {
+ path.unshift(String(valueChildren(parent).findIndex(child => isSameNode(child, node))));
+ }
+ }
+ return path;
+}
+
+/**
+ * Finds where the cursor is in an Hjson document. Needs the hjson language for the syntax tree.
+ */
+export function getHjsonContext(state: EditorState, pos: number): HjsonContext {
+ // Go up from the cursor to the object or array that it is in (or the document, at the root without braces). A closed
+ // object or array that ends at the cursor is a value that was just typed.
+ let cursorProperty: SyntaxNode | undefined;
+ let container: SyntaxNode = completeSyntaxTree(state).resolveInner(pos, -1);
+ while (
+ container.parent &&
+ !(
+ (container.name === 'Object' || container.name === 'Array') &&
+ (pos < container.to || !isClosed(container))
+ )
+ ) {
+ if (container.name === 'Property') cursorProperty ??= container;
+ container = container.parent;
+ }
+
+ const path = pathTo(state, container);
+
+ if (container.name === 'Array') {
+ const arrayProperty = container.parent?.name === 'Property' ? container.parent : undefined;
+ const outerObject = arrayProperty?.parent;
+ return {
+ path,
+ isEditingKey: false,
+ currentKey: String(valueChildren(container).filter(child => child.to < pos).length),
+ currentObject: outerObject ? objectValue(state, outerObject, arrayProperty) : {},
+ };
+ }
+
+ // The property that the cursor is in, or else the one before the cursor (the cursor can be after its colon)
+ const property =
+ cursorProperty ??
+ container
+ .getChildren('Property')
+ .filter(child => child.to <= pos)
+ .at(-1);
+ const colon = property?.getChild(':');
+ if (property && colon && colon.to <= pos) {
+ const value = valueChildren(property)[0];
+ // A value that starts after the cursor is on a line below (it is what is typed next)
+ if (!value || pos <= value.to) {
+ return {
+ path,
+ isEditingKey: false,
+ currentKey: propertyKey(state, property),
+ currentObject: objectValue(state, container, property),
+ };
+ }
+ }
+
+ return {
+ path,
+ isEditingKey: true,
+ currentKey: undefined,
+ currentObject: objectValue(state, container, cursorProperty),
+ };
+}
diff --git a/web-console/src/editor-completions/sql-completions.spec.ts b/web-console/src/editor-completions/sql-completions.spec.ts
new file mode 100644
index 00000000000..ac0bc8d1f36
--- /dev/null
+++ b/web-console/src/editor-completions/sql-completions.spec.ts
@@ -0,0 +1,358 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EditorState } from '@codemirror/state';
+
+import { dsql } from '../editor-languages/dsql';
+import { completionContextAt } from '../test-utils/completion-context';
+import type { ColumnMetadata } from '../utils';
+
+import type { SqlCompletionOptions } from './sql-completions';
+import { getPossibleSqlReferences, getSqlCompletions, getSqlLiterals } from './sql-completions';
+
+// A state with the dsql language (for the syntax tree)
+function dsqlState(sqlText: string): EditorState {
+ return EditorState.create({ doc: sqlText, extensions: dsql() });
+}
+
+describe('sql-completions', () => {
+ describe('getSqlCompletions', () => {
+ // The completions with the cursor at the |
+ function completionsAt(textWithCursor: string, options?: SqlCompletionOptions) {
+ const [context, word] = completionContextAt(dsql(), textWithCursor);
+ return getSqlCompletions(context, word, options);
+ }
+
+ const columnMetadata: ColumnMetadata[] = [
+ {
+ TABLE_SCHEMA: 'druid',
+ TABLE_NAME: 'wikipedia',
+ COLUMN_NAME: 'page',
+ DATA_TYPE: 'VARCHAR',
+ },
+ {
+ TABLE_SCHEMA: 'druid',
+ TABLE_NAME: 'wikipedia',
+ COLUMN_NAME: 'user',
+ DATA_TYPE: 'VARCHAR',
+ },
+ ];
+
+ it('returns empty array when in a single line comment', () => {
+ expect(completionsAt('-- This is a comment SEL|')).toEqual([]);
+ });
+
+ it('returns empty array when typing after comment marker', () => {
+ expect(completionsAt('SELECT * FROM table -- com|')).toEqual([]);
+ });
+
+ it('returns empty array when comment is in middle of line', () => {
+ expect(completionsAt('SELECT -- some comment FR|')).toEqual([]);
+ });
+
+ it('returns empty array in block comments', () => {
+ expect(completionsAt('SELECT /* FR| */ 1')).toEqual([]);
+ expect(completionsAt('SELECT /*\n FR|')).toEqual([]);
+ });
+
+ it('returns completions after a comment marker inside a literal', () => {
+ const completions = completionsAt("SELECT 'a -- b' FR|");
+ expect(completions.filter(c => c.detail === 'keyword').length).toBeGreaterThan(0);
+ });
+
+ it('returns empty array when prefix is a number', () => {
+ expect(completionsAt('SELECT 123|')).toEqual([]);
+ });
+
+ it('returns completions before comment marker on same line', () => {
+ const completions = completionsAt('SELECT FR| -- comment');
+ expect(completions.length).toBeGreaterThan(0);
+ const keywordCompletions = completions.filter(c => c.detail === 'keyword');
+ expect(keywordCompletions.length).toBeGreaterThan(0);
+ });
+
+ it('returns empty array even when comment contains SQL keywords', () => {
+ expect(completionsAt('-- TODO: need to SELECT * FROM WHE|')).toEqual([]);
+ });
+
+ it('returns empty array for indented comments', () => {
+ expect(completionsAt(' -- indented comment FRO|')).toEqual([]);
+ });
+
+ it('returns only literals right after a single quote', () => {
+ const completions = completionsAt(
+ "SELECT * FROM table WHERE country = 'France' OR country = 'Germany' OR country = 'Fr|",
+ );
+
+ expect(completions).toEqual([
+ { label: 'France', boost: 1, detail: 'local' },
+ { label: 'Germany', boost: 1, detail: 'local' },
+ ]);
+ });
+
+ it('does not return only literals right after a closed literal', () => {
+ const completions = completionsAt("SELECT * FROM t WHERE country = 'France'|");
+ expect(completions.some(c => c.detail === 'keyword')).toBe(true);
+ });
+
+ it('returns only literals anywhere inside a literal', () => {
+ const completions = completionsAt(
+ "SELECT * FROM table WHERE country = 'France' OR country = 'not Fra|'",
+ );
+
+ expect(completions.map(c => c.label)).toContain('France');
+ expect(completions.every(c => c.detail === 'local')).toBe(true);
+ });
+
+ it('returns keyword suggestions after SELECT', () => {
+ const completions = completionsAt('SELECT |');
+
+ const keywordCompletions = completions.filter(c => c.detail === 'keyword');
+ const keywordValues = keywordCompletions.map(c => c.label);
+
+ expect(keywordValues).toContain('DISTINCT');
+ expect(keywordValues).toContain('ALL');
+ expect(keywordValues).not.toContain('SELECT');
+ });
+
+ it('looks at the keyword on an earlier line and past comments', () => {
+ for (const sql of ['SELECT * FROM t GROUP\n |', 'SELECT * FROM t GROUP /* by what */ |']) {
+ expect(
+ completionsAt(sql)
+ .filter(c => c.detail === 'keyword')
+ .map(c => c.label),
+ ).toEqual(['BY']);
+ }
+ });
+
+ it('looks at the keyword before a quote', () => {
+ const completions = completionsAt('SELECT * FROM t LIMIT "|', { columns: ['page'] });
+ expect(completions.filter(c => c.detail === 'column')).toHaveLength(0);
+ });
+
+ it('does not include functions after keywords that cannot be followed by functions', () => {
+ const completions = completionsAt('SELECT * FROM t LIMIT |');
+
+ const functionCompletions = completions.filter(c => c.detail === 'function');
+ expect(functionCompletions).toHaveLength(0);
+
+ const dynamicCompletions = completions.filter(c => c.detail === 'dynamic');
+ expect(dynamicCompletions).toHaveLength(0);
+ });
+
+ it('includes functions after keywords that can be followed by functions', () => {
+ const completions = completionsAt('SELECT * FROM t WHERE |');
+
+ const functionCompletions = completions.filter(c => c.detail === 'function');
+ expect(functionCompletions.length).toBeGreaterThan(0);
+
+ const dynamicCompletions = completions.filter(c => c.detail === 'dynamic');
+ expect(dynamicCompletions.length).toBeGreaterThan(0);
+ });
+
+ it('does not include column references after keywords that cannot be followed by refs', () => {
+ const completions = completionsAt('SELECT * FROM "wikipedia" ORDER BY page DESC |', {
+ columnMetadata,
+ });
+
+ const columnCompletions = completions.filter(c => c.detail === 'column');
+ expect(columnCompletions).toHaveLength(0);
+
+ const tableCompletions = completions.filter(c => c.detail === 'table');
+ expect(tableCompletions).toHaveLength(0);
+ });
+
+ it('includes column references after keywords that can be followed by refs', () => {
+ const completions = completionsAt('SELECT * FROM "wikipedia" WHERE |', { columnMetadata });
+
+ const columnCompletions = completions.filter(c => c.detail === 'column');
+ expect(columnCompletions.length).toBeGreaterThan(0);
+ expect(columnCompletions.map(c => c.label)).toContain('"page"');
+ expect(columnCompletions.map(c => c.label)).toContain('"user"');
+ });
+
+ it('includes context keys after SET keyword', () => {
+ const completions = completionsAt('SET |');
+
+ const contextCompletions = completions.filter(c => c.detail === 'context');
+ expect(contextCompletions.length).toBeGreaterThan(0);
+ });
+
+ it('handles column names correctly with quotes', () => {
+ const columns = ['column name with spaces', 'normal_column'];
+
+ const completionsWithoutQuote = completionsAt('SELECT |', { columns });
+ const completionsWithQuote = completionsAt('SELECT "|"', { columns });
+
+ const columnWithoutQuote = completionsWithoutQuote.find(c =>
+ c.label.includes('column name with spaces'),
+ );
+ expect(columnWithoutQuote?.label).toBe('"column name with spaces"');
+
+ const columnWithQuote = completionsWithQuote.find(c => c.label === 'column name with spaces');
+ expect(columnWithQuote?.label).toBe('column name with spaces');
+ });
+
+ it('includes constants and data types in completions', () => {
+ const completions = completionsAt('|');
+
+ const constantCompletions = completions.filter(c => c.detail === 'constant');
+ expect(constantCompletions.map(c => c.label)).toContain('NULL');
+ expect(constantCompletions.map(c => c.label)).toContain('TRUE');
+ expect(constantCompletions.map(c => c.label)).toContain('FALSE');
+
+ const typeCompletions = completions.filter(c => c.detail === 'type');
+ expect(typeCompletions.length).toBeGreaterThan(0);
+ });
+
+ it('includes local references from the query text', () => {
+ const completions = completionsAt('|SELECT my_column FROM my_table WHERE other_column = 1');
+
+ const localCompletions = completions.filter(c => c.detail === 'local');
+ const localValues = localCompletions.map(c => c.label);
+
+ expect(localValues).toContain('my_column');
+ expect(localValues).toContain('my_table');
+ expect(localValues).toContain('other_column');
+ });
+
+ it('lists a function with several signatures once, with all of them in the doc', () => {
+ const availableSqlFunctions = new Map([
+ ['BIG_MAX', { args: ['<ANY>', 'column, size'], isAggregate: true }],
+ ['MAX', { args: ['<COMPARABLE_TYPE>'], isAggregate: true }],
+ ]);
+
+ const functions = completionsAt('SELECT MAX|', { availableSqlFunctions }).filter(
+ c => c.detail === 'aggregate',
+ );
+
+ expect(functions).toEqual([
+ {
+ label: 'BIG_MAX',
+ boost: 30,
+ detail: 'aggregate',
+ doc: {
+ name: 'BIG_MAX',
+ syntax: 'BIG_MAX(<ANY>)\nBIG_MAX(column, size)',
+ descriptionMarkdown: undefined,
+ },
+ },
+ expect.objectContaining({
+ label: 'MAX',
+ doc: expect.objectContaining({ syntax: 'MAX(<COMPARABLE_TYPE>)' }),
+ }),
+ ]);
+ });
+
+ it('does not suggest the word that is being typed', () => {
+ const references = completionsAt('SELECT my_column, my| FROM my_table')
+ .filter(c => c.detail === 'local')
+ .map(c => c.label);
+
+ expect(references).toContain('my_column');
+ expect(references).not.toContain('my');
+
+ const literals = completionsAt(
+ "SELECT * FROM t WHERE country = 'France' OR country = 'Fr|'",
+ ).map(c => c.label);
+
+ expect(literals).toEqual(['France']);
+ });
+ });
+
+ describe('getSqlLiterals', () => {
+ it('extracts string literals from SQL text', () => {
+ const sqlText =
+ "SELECT * FROM table WHERE country = 'France' OR city = 'Paris' OR code = 'FR'";
+ const literals = getSqlLiterals(dsqlState(sqlText), 10);
+
+ expect(literals).toEqual(['France', 'Paris', 'FR']);
+ });
+
+ it('respects maxWords limit', () => {
+ const sqlText = "SELECT 'one', 'two', 'three', 'four', 'five'";
+ const literals = getSqlLiterals(dsqlState(sqlText), 3);
+
+ expect(literals).toHaveLength(3);
+ });
+ });
+
+ describe('getPossibleSqlReferences', () => {
+ it('extracts quoted identifiers', () => {
+ const sqlText = 'SELECT "myColumn" FROM "myTable"';
+ const references = getPossibleSqlReferences(dsqlState(sqlText), 10);
+
+ expect(references).toContain('myColumn');
+ expect(references).toContain('myTable');
+ });
+
+ it('extracts unquoted identifiers', () => {
+ const sqlText = 'SELECT column1, column2 FROM table1 WHERE column3 = 1';
+ const references = getPossibleSqlReferences(dsqlState(sqlText), 10);
+
+ expect(references).toContain('column1');
+ expect(references).toContain('column2');
+ expect(references).toContain('table1');
+ expect(references).toContain('column3');
+ });
+
+ it('filters out known SQL keywords', () => {
+ const sqlText = 'SELECT column1 FROM table1 WHERE column2 = 1 ORDER BY column3';
+ const references = getPossibleSqlReferences(dsqlState(sqlText), 20);
+
+ expect(references).not.toContain('SELECT');
+ expect(references).not.toContain('FROM');
+ expect(references).not.toContain('WHERE');
+ expect(references).not.toContain('ORDER');
+ expect(references).not.toContain('BY');
+ });
+
+ it('finds identifiers next to any operator or punctuation', () => {
+ const references = getPossibleSqlReferences(
+ dsqlState('SELECT t.col1 FROM t WHERE col2=1 AND col3<>col4'),
+ 10,
+ );
+
+ expect(references).toEqual(['col1', 'col2', 'col3', 'col4']);
+ });
+
+ it('filters out keywords and functions in any case', () => {
+ const references = getPossibleSqlReferences(
+ dsqlState('select count(*) from table1 where column1 is not null'),
+ 10,
+ );
+
+ expect(references).toEqual(['table1', 'column1']);
+ });
+
+ it('skips quotes that are not closed', () => {
+ expect(getPossibleSqlReferences(dsqlState('SELECT "col1", "col2'), 10)).toEqual(['col1']);
+ expect(getSqlLiterals(dsqlState("SELECT 'one', 'two"), 10)).toEqual(['one']);
+ });
+
+ it('handles identifiers in expressions', () => {
+ const sqlText = 'SELECT (column1 + column2) * column3 FROM table1';
+ const references = getPossibleSqlReferences(dsqlState(sqlText), 10);
+
+ expect(references).toContain('column1');
+ expect(references).toContain('column2');
+ expect(references).toContain('column3');
+ expect(references).toContain('table1');
+ });
+ });
+});
diff --git a/web-console/src/editor-completions/sql-completions.ts b/web-console/src/editor-completions/sql-completions.ts
new file mode 100644
index 00000000000..8ce3859fb83
--- /dev/null
+++ b/web-console/src/editor-completions/sql-completions.ts
@@ -0,0 +1,417 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { CompletionContext } from '@codemirror/autocomplete';
+import type { EditorState } from '@codemirror/state';
+import type { SyntaxNode } from '@lezer/common';
+import { C, filterMap, N, T } from 'druid-query-toolkit';
+
+import type { CompletionWord } from '../components/code-editor/completion-source';
+import { completeSyntaxTree, tokenBefore } from '../components/code-editor/completion-source';
+import { DEFAULT_SERVER_QUERY_CONTEXT } from '../druid-models';
+import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../editor-languages/dsql-docs';
+import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from '../editor-languages/dsql-keywords';
+import type { AvailableFunctions } from '../helpers';
+import type { ColumnMetadata } from '../utils';
+import { lookupBy, uniq } from '../utils';
+
+import type { EditorCompletion } from './editor-completion';
+
+const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_FUNCTION = [
+ 'AS',
+ 'ASC',
+ 'DESC',
+ 'LIMIT',
+ 'OFFSET',
+ 'RETURNING',
+ 'SET',
+ 'VALUES',
+ 'FETCH',
+ 'FIRST',
+ 'NEXT',
+ 'ONLY',
+ 'PRECEDING',
+ 'FOLLOWING',
+ 'UNBOUNDED',
+];
+
+const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_REF = [
+ 'ASC',
+ 'DESC',
+ 'LIMIT',
+ 'OFFSET',
+ 'FIRST',
+ 'NEXT',
+ 'ONLY',
+ 'PRECEDING',
+ 'FOLLOWING',
+ 'UNBOUNDED',
+ 'CUBE',
+ 'ROLLUP',
+ 'FOR',
+ 'OUTER',
+ 'CROSS',
+ 'INNER',
+ 'LEFT',
+ 'RIGHT',
+ 'FULL',
+ 'NATURAL',
+ 'UNION',
+ 'INTERSECT',
+ 'EXCEPT',
+];
+
+const SQL_KEYWORD_FOLLOW_SUGGESTIONS: Record<string, string[]> = {
+ // Keywords that can be followed by specific keywords
+ SELECT: ['DISTINCT', 'ALL'],
+ GROUP: ['BY'],
+ ORDER: ['BY'],
+ PARTITION: ['BY'],
+ PARTITIONED: ['BY'],
+ CLUSTERED: ['BY'],
+ UNION: ['ALL'],
+ INSERT: ['INTO'],
+ REPLACE: ['INTO'],
+ MERGE: ['INTO'],
+ UPDATE: ['SET'],
+ LEFT: ['JOIN', 'OUTER'],
+ RIGHT: ['JOIN', 'OUTER'],
+ INNER: ['JOIN'],
+ FULL: ['JOIN', 'OUTER'],
+ CROSS: ['JOIN'],
+ NATURAL: ['JOIN', 'LEFT', 'RIGHT', 'INNER', 'FULL'],
+ EXPLAIN: ['PLAN'],
+ PLAN: ['FOR'],
+ GROUPING: ['SETS'],
+ FETCH: ['FIRST', 'NEXT'],
+ FIRST: ['ROW', 'ROWS'],
+ NEXT: ['ROW', 'ROWS'],
+ ROWS: ['ONLY'],
+ ROW: ['ONLY'],
+ IS: ['NOT', 'DISTINCT'],
+ DISTINCT: ['FROM'],
+ BY: ['GROUPING', 'ROLLUP', 'CUBE', 'ALL', 'HOUR', 'DAY', 'MONTH', 'YEAR'],
+ ALL: ['TIME'],
+
+ // Keywords that cannot be followed by any other keyword
+ AS: [],
+ ASC: [],
+ DESC: [],
+ LIMIT: [],
+ OFFSET: [],
+ RETURNING: [],
+ SET: [],
+ VALUES: [],
+ ONLY: [],
+ WHERE: [],
+ HAVING: [],
+ USING: [],
+ ON: [],
+ CUBE: [],
+ ROLLUP: [],
+ OVERWRITE: [],
+ PIVOT: [],
+ UNPIVOT: [],
+ MATCHED: [],
+ PRECEDING: [],
+ FOLLOWING: [],
+ UNBOUNDED: [],
+ CURRENT: [],
+ EXTEND: [],
+ WINDOW: [],
+ RANGE: [],
+ KEY: [],
+ VALUE: [],
+ TIME: [],
+ EPOCH: [],
+ MILLISECOND: [],
+ SECOND: [],
+ MINUTE: [],
+ HOUR: [],
+ DAY: [],
+ DOW: [],
+ ISODOW: [],
+ DOY: [],
+ WEEK: [],
+ MONTH: [],
+ QUARTER: [],
+ YEAR: [],
+ ISOYEAR: [],
+ DECADE: [],
+ CENTURY: [],
+ MILLENNIUM: [],
+};
+
+const COMMENT_NODES = new Set(['LineComment', 'BlockComment', 'Issue']);
+
+// The tokens that are words, the specializer in dsql-tokens.ts tells them apart
+const WORD_NODES = new Set(['Keyword', 'FunctionName', 'Constant', 'TypeName', 'Identifier']);
+
+/**
+ * Whether a string literal or quoted identifier (the text of its token) is closed, an unterminated one lasts until the
+ * end of the line
+ */
+function isClosed(tokenText: string): boolean {
+ return tokenText.length >= 2 && tokenText.endsWith(tokenText[0]);
+}
+
+/**
+ * The last token (that is not a comment) that ends before the given position, it can be on an earlier line
+ */
+function tokenEndingBefore(state: EditorState, pos: number): SyntaxNode | undefined {
+ const cursor = completeSyntaxTree(state).cursorAt(pos, -1);
+ do {
+ const { node } = cursor;
+ if (
+ node.to <= pos &&
+ node.from < node.to &&
+ !node.firstChild &&
+ !node.type.isTop &&
+ !COMMENT_NODES.has(node.name)
+ ) {
+ return node;
+ }
+ } while (cursor.prev());
+ return;
+}
+
+export interface SqlCompletionOptions {
+ columnMetadata?: readonly ColumnMetadata[];
+ columns?: readonly string[];
+ availableSqlFunctions?: AvailableFunctions;
+ skipAggregates?: boolean;
+}
+
+/**
+ * The completions for the word being typed in DruidSQL. Needs the dsql language for the syntax tree.
+ */
+export function getSqlCompletions(
+ { state, pos }: CompletionContext,
+ { from, text: prefix }: CompletionWord,
+ { columnMetadata, columns, availableSqlFunctions, skipAggregates }: SqlCompletionOptions = {},
+): EditorCompletion[] {
+ const token = tokenBefore(state, pos);
+
+ // We are in a comment
+ if (COMMENT_NODES.has(token.name)) {
+ return [];
+ }
+
+ // If we are autocompleting inside a literal, then don't do any of the standard suggestions.
+ // Only autocomplete other literals. The imagined use-case for this is if you have `country = 'France'` or `TIMESTAMP '2024-03-02 O1:00:00'` you might want to reuse the literals
+ // Right after a closed literal is not inside it
+ if (
+ token.name === 'String' &&
+ (pos < token.to || !isClosed(state.sliceDoc(token.from, token.to)))
+ ) {
+ return getSqlLiterals(state, 100, prefix).map(label => ({
+ label,
+ boost: 1,
+ detail: 'local',
+ }));
+ }
+
+ // The word before the word being typed (before its quote if it is quoted), it can be on an earlier line
+ const quote = state.sliceDoc(from - 1, from) === '"';
+ const tokenBeforePrefix = tokenEndingBefore(state, quote ? from - 1 : from);
+ const keywordBeforePrefix =
+ tokenBeforePrefix && WORD_NODES.has(tokenBeforePrefix.name)
+ ? state.sliceDoc(tokenBeforePrefix.from, tokenBeforePrefix.to).toUpperCase()
+ : undefined;
+
+ // Other than literals, do not autocomplete numbers
+ if (/^\d+$/.test(prefix)) {
+ return []; // Don't start completing if the user is typing a number
+ }
+
+ const possibleReferences = getPossibleSqlReferences(state, 100, prefix);
+
+ let completions: EditorCompletion[] = possibleReferences.map(label => ({
+ label,
+ boost: 1,
+ detail: 'local',
+ }));
+
+ if (!quote) {
+ completions = completions.concat(
+ (SQL_KEYWORD_FOLLOW_SUGGESTIONS[keywordBeforePrefix || ''] || SQL_KEYWORDS).map(v => ({
+ label: v,
+ boost: 10,
+ detail: 'keyword',
+ })),
+ SQL_CONSTANTS.map(v => ({ label: v, boost: 11, detail: 'constant' })),
+ Array.from(SQL_DATA_TYPES.entries()).map(([name, [runtime, description]]) => {
+ return {
+ label: name,
+ boost: 31,
+ detail: 'type',
+ doc: {
+ name,
+ syntax: `Druid runtime type: ${runtime}`,
+ descriptionMarkdown: description,
+ },
+ };
+ }),
+ );
+
+ if (
+ !keywordBeforePrefix ||
+ !SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_FUNCTION.includes(keywordBeforePrefix)
+ ) {
+ completions = completions.concat(
+ SQL_DYNAMICS.map(v => ({ label: v, boost: 20, detail: 'dynamic' })),
+ );
+
+ // If availableSqlFunctions map is provided, use it; otherwise fall back to static SQL_FUNCTIONS
+ if (availableSqlFunctions) {
+ completions = completions.concat(
+ filterMap(Array.from(availableSqlFunctions.entries()), ([name, funcDef]) => {
+ if (skipAggregates && funcDef.isAggregate) return;
+ const description = SQL_FUNCTIONS.get(name)?.[1];
+ return {
+ label: name,
+ boost: 30,
+ detail: funcDef.isAggregate ? 'aggregate' : 'function',
+ doc: {
+ name,
+ // A function with several signatures shows them all, one per line
+ syntax: funcDef.args.map(args => `${name}(${args})`).join('\n'),
+ descriptionMarkdown: description,
+ },
+ };
+ }),
+ );
+ } else {
+ completions = completions.concat(
+ Array.from(SQL_FUNCTIONS.entries()).map(([name, argDesc]) => {
+ const [args, description] = argDesc;
+ return {
+ label: name,
+ boost: 30,
+ detail: 'function',
+ doc: { name, syntax: `${name}(${args})`, descriptionMarkdown: description },
+ };
+ }),
+ );
+ }
+ }
+ }
+
+ if (
+ !keywordBeforePrefix ||
+ !SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_REF.includes(keywordBeforePrefix)
+ ) {
+ if (columnMetadata?.length) {
+ const possibleReferencesLookup = lookupBy(possibleReferences, String, () => true);
+
+ completions = completions.concat(
+ uniq(columnMetadata.map(({ TABLE_SCHEMA }) => TABLE_SCHEMA)).map(schema => ({
+ label: quote ? schema : String(N(schema)),
+ boost: 30,
+ detail: 'schema',
+ })),
+ uniq(
+ filterMap(columnMetadata, ({ TABLE_SCHEMA, TABLE_NAME }) =>
+ TABLE_SCHEMA === 'druid' || possibleReferencesLookup[TABLE_SCHEMA]
+ ? TABLE_NAME
+ : undefined,
+ ),
+ ).map(table => ({
+ label: quote ? table : String(T(table)),
+ boost: 40,
+ detail: 'table',
+ })),
+ uniq(
+ filterMap(columnMetadata, d =>
+ possibleReferencesLookup[d.TABLE_NAME] ? d.COLUMN_NAME : undefined,
+ ),
+ ).map(v => ({
+ label: quote ? v : String(C(v)),
+ boost: 50,
+ detail: 'column',
+ })),
+ );
+ }
+
+ if (columns?.length) {
+ completions = completions.concat(
+ columns.map(column => ({
+ label: quote ? column : String(C(column)),
+ boost: 50,
+ detail: 'column',
+ })),
+ );
+ }
+ }
+
+ if (keywordBeforePrefix === 'SET') {
+ completions = completions.concat(
+ Object.keys(DEFAULT_SERVER_QUERY_CONTEXT).map(key => ({
+ label: quote ? key : String(C(key)),
+ boost: 50,
+ detail: 'context',
+ })),
+ );
+ }
+
+ return completions;
+}
+
+/**
+ * The (closed) string literals in the text. The prefix (the word being typed) is left out since it shows up as a
+ * literal itself. Needs the dsql language for the syntax tree.
+ */
+export function getSqlLiterals(state: EditorState, maxWords: number, prefix?: string): string[] {
+ const literals: string[] = [];
+ completeSyntaxTree(state).iterate({
+ enter: ({ name, from, to }) => {
+ if (name !== 'String') return;
+ const text = state.sliceDoc(from, to);
+ if (isClosed(text) && text.length >= 4) literals.push(text.slice(1, -1));
+ },
+ });
+
+ return uniq(literals.filter(m => m !== prefix)).slice(0, maxWords);
+}
+
+/**
+ * The identifiers in the text that could be references (quoted ones first), at least two characters long. The prefix
+ * (the word being typed) is left out since it shows up as a reference itself. Needs the dsql language for the syntax
+ * tree.
+ */
+export function getPossibleSqlReferences(
+ state: EditorState,
+ maxWords: number,
+ prefix?: string,
+): string[] {
+ const quoted: string[] = [];
+ const naked: string[] = [];
+ completeSyntaxTree(state).iterate({
+ enter: ({ name, from, to }) => {
+ const text = state.sliceDoc(from, to);
+ if (name === 'QuotedIdentifier') {
+ if (isClosed(text) && /^"\w{2,}"$/.test(text)) quoted.push(text.slice(1, -1));
+ } else if (name === 'Identifier') {
+ // Keywords, functions, constants and types are not identifiers
+ if (/^[a-zA-Z]\w+$/.test(text)) naked.push(text);
+ }
+ },
+ });
+
+ return uniq([...quoted, ...naked].filter(v => v !== prefix)).slice(0, maxWords);
+}
diff --git a/web-console/lib/keywords.ts b/web-console/src/editor-languages/dsql-keywords.ts
similarity index 100%
rename from web-console/lib/keywords.ts
rename to web-console/src/editor-languages/dsql-keywords.ts
diff --git a/web-console/src/editor-languages/dsql-tokens.ts b/web-console/src/editor-languages/dsql-tokens.ts
new file mode 100644
index 00000000000..191f3b98503
--- /dev/null
+++ b/web-console/src/editor-languages/dsql-tokens.ts
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Stack } from '@lezer/lr';
+import { dedupe } from 'druid-query-toolkit';
+
+import type { AvailableFunctions } from '../helpers';
+
+import { Constant, FunctionName, Keyword, TypeName } from './dsql.parser.terms';
+import { SQL_DATA_TYPES, SQL_FUNCTIONS } from './dsql-docs';
+import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from './dsql-keywords';
+
+/**
+ * Turns an identifier into a keyword, function, constant or type (by giving the id of that term), or leaves it as an
+ * identifier (-1). This is the signature of a Lezer external specializer, the parse stack is not needed.
+ */
+export type IdentifierSpecializer = (value: string, stack?: Stack) => number;
+
+/**
+ * Makes the specializer that knows the documented words and the given functions that the cluster has (matched
+ * ignoring case)
+ */
+export function makeIdentifierSpecializer(
+ availableSqlFunctions: AvailableFunctions | undefined,
+): IdentifierSpecializer {
+ let words: Map<string, number> | undefined;
+ const getWords = () => {
+ if (words) return words;
+
+ // A word that is in several lists is the term of the last one
+ const termLists: [number, string[]][] = [
+ [
+ FunctionName,
+ dedupe([
+ ...SQL_DYNAMICS,
+ ...Array.from(SQL_FUNCTIONS.keys()),
+ ...(availableSqlFunctions?.keys() || []),
+ ]),
+ ],
+ [Keyword, SQL_KEYWORDS.flatMap(k => k.split(/\s/g))], // Some keywords are like "EXPLAIN PLAN FOR"
+ [Constant, SQL_CONSTANTS],
+ [TypeName, Array.from(SQL_DATA_TYPES.keys())],
+ ];
+
+ words = new Map();
+ for (const [term, termWords] of termLists) {
+ for (const word of termWords) words.set(word.toLowerCase(), term);
+ }
+ return words;
+ };
+
+ return value => getWords().get(value.toLowerCase()) ?? -1;
+}
+
+/**
+ * The specializer that the grammar refers to, it knows the documented words. `getDsqlLanguage` replaces it with one
+ * that also knows the functions that the cluster has.
+ */
+export const specializeIdentifier: IdentifierSpecializer = makeIdentifierSpecializer(undefined);
diff --git a/web-console/src/editor-languages/dsql.grammar b/web-console/src/editor-languages/dsql.grammar
new file mode 100644
index 00000000000..ac61c42b3db
--- /dev/null
+++ b/web-console/src/editor-languages/dsql.grammar
@@ -0,0 +1,96 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The Lezer grammar for DruidSQL. It describes the tokens (and how parentheses nest), which is what the highlighting,
+// the bracket matching and the completions need. It does not try to parse the SQL statements.
+//
+// Run `script/build-grammars.mjs` (part of `script/build`) after changing it.
+
+@top Script { element* }
+
+element {
+ Keyword |
+ FunctionName |
+ Constant |
+ TypeName |
+ Identifier |
+ QuotedIdentifier |
+ String |
+ Number |
+ Operator |
+ Parens |
+ Punctuation
+}
+
+Parens { "(" element* ")" }
+
+@skip { space | Issue | LineComment | BlockComment }
+
+// Which words are keywords, functions, constants or types is decided by a function (rather than with @specialize in
+// here) because the functions that the cluster has are only known at runtime
+@external specialize { Identifier } specializeIdentifier from "./dsql-tokens" {
+ Keyword,
+ FunctionName,
+ Constant,
+ TypeName
+}
+
+@tokens {
+ space { $[ \t\n\r]+ }
+
+ // The web console marks the parts of a query that have an issue with a comment like this
+ Issue { "--:ISSUE:" ![\n]* }
+
+ LineComment { "--" ![\n]* }
+
+ // An unterminated comment lasts until the end of the text
+ BlockComment { "/*" blockCommentRest }
+ blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar | @eof }
+ blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest | @eof }
+
+ // Quoted identifiers and string literals do not span lines, an unterminated one lasts until the end of the line
+ QuotedIdentifier { '"' ![\n"]* '"'? }
+ String { "'" ![\n']* "'"? }
+
+ // A sign is an operator
+ Number { @digit+ ("." @digit*)? ($[eE] $[+\-]? @digit+)? }
+
+ Identifier { $[a-zA-Z_$] $[a-zA-Z0-9_$]* }
+
+ Operator { $[+\-*/%&|^~<>=!@] }
+
+ "(" ")"
+
+ // Any other character, so that no text is ever a syntax error
+ Punctuation { ![] }
+
+ @precedence {
+ Issue,
+ LineComment,
+ BlockComment,
+ QuotedIdentifier,
+ String,
+ Number,
+ Identifier,
+ Operator,
+ "(",
+ ")",
+ space,
+ Punctuation
+ }
+}
+
+@detectDelim
diff --git a/web-console/src/editor-languages/dsql.ts b/web-console/src/editor-languages/dsql.ts
new file mode 100644
index 00000000000..65c86d3def0
--- /dev/null
+++ b/web-console/src/editor-languages/dsql.ts
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
+import { indentNodeProp, LanguageSupport, LRLanguage } from '@codemirror/language';
+import { keymap } from '@codemirror/view';
+import { styleTags, tags } from '@lezer/highlight';
+
+import { makeCompletionSource } from '../components/code-editor/completion-source';
+import type { SqlCompletionOptions } from '../editor-completions/sql-completions';
+import { getSqlCompletions } from '../editor-completions/sql-completions';
+import type { AvailableFunctions } from '../helpers';
+
+import { parser } from './dsql.parser';
+import { makeIdentifierSpecializer, specializeIdentifier } from './dsql-tokens';
+import { editorTags } from './editor-tags';
+
+const dsqlHighlighting = styleTags({
+ 'Keyword': tags.keyword,
+ 'FunctionName': tags.function(tags.variableName),
+ 'Constant': tags.atom,
+ 'TypeName': tags.typeName,
+ 'QuotedIdentifier': editorTags.column,
+ 'String': tags.string,
+ 'Number': tags.number,
+ 'Issue': editorTags.issue,
+ 'LineComment': tags.lineComment,
+ 'BlockComment': tags.blockComment,
+ 'Operator': tags.operator,
+ '( )': tags.paren,
+});
+
+function makeDsqlLanguage(availableSqlFunctions: AvailableFunctions | undefined): LRLanguage {
+ return LRLanguage.define({
+ name: 'dsql',
+ parser: parser.configure({
+ props: [
+ dsqlHighlighting,
+ // A new line keeps the indentation of the line before (rather than indenting inside parentheses)
+ indentNodeProp.add({ 'Script Parens': () => null }),
+ ],
+ specializers: availableSqlFunctions
+ ? [{ from: specializeIdentifier, to: makeIdentifierSpecializer(availableSqlFunctions) }]
+ : [],
+ }),
+ languageData: {
+ commentTokens: { line: '--' },
+ // Identifiers can contain $ (letters, digits and _ are word characters anyway)
+ wordChars: '$',
+ // Do not auto close braces (they are rare in SQL and typing one usually means the start of a JSON query)
+ closeBrackets: { brackets: ['(', '[', "'", '"'] },
+ },
+ });
+}
+
+const defaultDsqlLanguage = makeDsqlLanguage(undefined);
+const dsqlLanguages = new WeakMap<AvailableFunctions, LRLanguage>();
+
+/**
+ * The DruidSQL language. The functions that the cluster has (in addition to the documented ones) are highlighted as
+ * functions. The same functions always give the same language so that reconfiguring an editor does not re-parse it.
+ */
+export function getDsqlLanguage(availableSqlFunctions?: AvailableFunctions): LRLanguage {
+ if (!availableSqlFunctions) return defaultDsqlLanguage;
+ let language = dsqlLanguages.get(availableSqlFunctions);
+ if (!language) {
+ language = makeDsqlLanguage(availableSqlFunctions);
+ dsqlLanguages.set(availableSqlFunctions, language);
+ }
+ return language;
+}
+
+export type DsqlOptions = SqlCompletionOptions;
+
+/**
+ * DruidSQL support for a CodeEditor: highlighting, completions and bracket closing
+ */
+export function dsql(options: DsqlOptions = {}): LanguageSupport {
+ const language = getDsqlLanguage(options.availableSqlFunctions);
+ return new LanguageSupport(language, [
+ language.data.of({
+ autocomplete: makeCompletionSource((context, word) =>
+ getSqlCompletions(context, word, options),
+ ),
+ }),
+ closeBrackets(),
+ keymap.of(closeBracketsKeymap),
+ ]);
+}
diff --git a/web-console/src/editor-languages/editor-languages.spec.ts b/web-console/src/editor-languages/editor-languages.spec.ts
new file mode 100644
index 00000000000..b80064e3e8e
--- /dev/null
+++ b/web-console/src/editor-languages/editor-languages.spec.ts
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { CompletionResult, CompletionSource } from '@codemirror/autocomplete';
+import { CompletionContext } from '@codemirror/autocomplete';
+import type { Language } from '@codemirror/language';
+import type { Extension } from '@codemirror/state';
+import { EditorState } from '@codemirror/state';
+import { highlightTree, tags } from '@lezer/highlight';
+
+import { codeEditorHighlightStyle } from '../components/code-editor/code-editor-theme';
+
+import { dsql, getDsqlLanguage } from './dsql';
+import { hjson, hjsonLanguage } from './hjson';
+
+const dsqlLanguage = getDsqlLanguage();
+
+function tokenize(language: Language, text: string): [type: string, value: string][] {
+ const tokens: [string, string][] = [];
+ language.parser.parse(text).iterate({
+ enter(node) {
+ if (node.type.isTop) return;
+ tokens.push([node.name, text.slice(node.from, node.to)]);
+ },
+ });
+ return tokens;
+}
+
+function tokenOf(language: Language, text: string, value: string): string | undefined {
+ return tokenize(language, text).find(t => t[1] === value)?.[0];
+}
+
+describe('editor languages', () => {
+ it('highlights DruidSQL', () => {
+ const sql = `--:ISSUE: bad
+SELECT COUNT(*), "col", CAST(x AS VARCHAR), TRUE FROM t WHERE y <> 'lit' AND z = 3.5 -- comment
+/* multi
+line */ SELECT`;
+ expect(tokenOf(dsqlLanguage, sql, '--:ISSUE: bad')).toEqual('Issue');
+ expect(tokenOf(dsqlLanguage, sql, 'SELECT')).toEqual('Keyword');
+ expect(tokenOf(dsqlLanguage, sql, 'COUNT')).toEqual('FunctionName');
+ expect(tokenOf(dsqlLanguage, sql, '"col"')).toEqual('QuotedIdentifier');
+ expect(tokenOf(dsqlLanguage, sql, 'VARCHAR')).toEqual('TypeName');
+ expect(tokenOf(dsqlLanguage, sql, 'TRUE')).toEqual('Constant');
+ expect(tokenOf(dsqlLanguage, sql, "'lit'")).toEqual('String');
+ expect(tokenOf(dsqlLanguage, sql, '3.5')).toEqual('Number');
+ expect(tokenOf(dsqlLanguage, sql, '<')).toEqual('Operator');
+ expect(tokenOf(dsqlLanguage, sql, '-- comment')).toEqual('LineComment');
+ expect(tokenOf(dsqlLanguage, sql, '/* multi\nline */')).toEqual('BlockComment');
+ expect(tokenOf(dsqlLanguage, sql, 'x')).toEqual('Identifier');
+ expect(tokenOf(dsqlLanguage, sql, '(x AS VARCHAR)')).toEqual('Parens');
+ });
+
+ it('highlights unterminated DruidSQL tokens', () => {
+ expect(tokenOf(dsqlLanguage, 'SELECT /* not closed', '/* not closed')).toEqual('BlockComment');
+ // An unterminated literal or quoted identifier lasts until the end of the line
+ expect(tokenize(dsqlLanguage, `SELECT 'x AND y\nFROM "t`)).toEqual([
+ ['Keyword', 'SELECT'],
+ ['String', "'x AND y"],
+ ['Keyword', 'FROM'],
+ ['QuotedIdentifier', '"t'],
+ ]);
+ // A sign is an operator
+ expect(tokenize(dsqlLanguage, 'SELECT a-1')).toEqual([
+ ['Keyword', 'SELECT'],
+ ['Identifier', 'a'],
+ ['Operator', '-'],
+ ['Number', '1'],
+ ]);
+ });
+
+ it('highlights the available functions', () => {
+ const availableSqlFunctions = new Map([['MY_FN', { args: ['x'], isAggregate: false }]]);
+ expect(tokenOf(dsqlLanguage, 'SELECT MY_FN(x)', 'MY_FN')).toEqual('Identifier');
+ expect(tokenOf(getDsqlLanguage(availableSqlFunctions), 'SELECT MY_FN(x)', 'MY_FN')).toEqual(
+ 'FunctionName',
+ );
+ expect(tokenOf(getDsqlLanguage(availableSqlFunctions), 'SELECT my_fn(x)', 'my_fn')).toEqual(
+ 'FunctionName',
+ );
+ expect(getDsqlLanguage(availableSqlFunctions)).toBe(getDsqlLanguage(availableSqlFunctions));
+ });
+
+ it('highlights Hjson', () => {
+ const hjson = `{
+ # comment
+ key: "value\\n"
+ n: -1.5e3
+ flag: true
+ list: [1, "two"]
+ unquoted: some text
+ multi: '''
+ lines
+ '''
+}`;
+ expect(tokenOf(hjsonLanguage, hjson, '# comment')).toEqual('LineComment');
+ expect(tokenOf(hjsonLanguage, hjson, 'key')).toEqual('PropertyName');
+ expect(tokenOf(hjsonLanguage, hjson, '"value\\n"')).toEqual('String');
+ expect(tokenOf(hjsonLanguage, hjson, '\\n')).toEqual('Escape');
+ expect(tokenOf(hjsonLanguage, hjson, '-1.5e3')).toEqual('Number');
+ expect(tokenOf(hjsonLanguage, hjson, 'true')).toEqual('True');
+ expect(tokenOf(hjsonLanguage, hjson, '[1, "two"]')).toEqual('Array');
+ expect(tokenOf(hjsonLanguage, hjson, '"two"')).toEqual('String');
+ expect(tokenOf(hjsonLanguage, hjson, 'some text')).toEqual('QuotelessString');
+ expect(tokenOf(hjsonLanguage, hjson, "'''\n lines\n '''")).toEqual('MultilineString');
+ });
+
+ it('ends an unterminated Hjson string at the end of the line', () => {
+ const hjson = '{\n "a": "not closed\n "b": 1\n}';
+ expect(tokenOf(hjsonLanguage, hjson, '"not closed\n')).toEqual('String');
+ expect(tokenOf(hjsonLanguage, hjson, '"b"')).toEqual('PropertyName');
+ expect(tokenOf(hjsonLanguage, hjson, '1')).toEqual('Number');
+ });
+
+ it('highlights JSON', () => {
+ const json = `{"auditTime": "2023-07-31T18:15:19.302Z", "url": "http://x:80", "n": 1, "z": null}`;
+ expect(tokenOf(hjsonLanguage, json, '"auditTime"')).toEqual('PropertyName');
+ expect(tokenOf(hjsonLanguage, json, '"2023-07-31T18:15:19.302Z"')).toEqual('String');
+ expect(tokenOf(hjsonLanguage, json, '"http://x:80"')).toEqual('String');
+ expect(tokenOf(hjsonLanguage, json, '1')).toEqual('Number');
+ expect(tokenOf(hjsonLanguage, json, 'null')).toEqual('Null');
+ expect(tokenize(hjsonLanguage, json).some(([type]) => type === '⚠')).toBe(false);
+ });
+
+ it('highlights quoteless Hjson values as a whole', () => {
+ const hjson = `ip: 127.0.0.1
+time: 2023-07-31T18:15:19.302Z
+host: localhost:8091
+url: http://example.com
+count: 3
+count2: 3 apples
+flag: true, other: 1
+page: Talk:Main`;
+ for (const value of [
+ '127.0.0.1',
+ '2023-07-31T18:15:19.302Z',
+ 'localhost:8091',
+ 'http://example.com',
+ '3 apples',
+ 'Talk:Main',
+ ]) {
+ expect(tokenOf(hjsonLanguage, hjson, value)).toEqual('QuotelessString');
+ }
+ expect(tokenOf(hjsonLanguage, hjson, '3')).toEqual('Number');
+ expect(tokenOf(hjsonLanguage, hjson, 'true')).toEqual('True');
+ });
+
+ it('highlights Hjson without the root braces', () => {
+ const hjson = `queryType: scan
+dataSource: wikipedia`;
+ expect(tokenOf(hjsonLanguage, hjson, 'queryType')).toEqual('PropertyName');
+ expect(tokenOf(hjsonLanguage, hjson, 'dataSource')).toEqual('PropertyName');
+ expect(tokenOf(hjsonLanguage, hjson, 'wikipedia')).toEqual('QuotelessString');
+ expect(tokenOf(hjsonLanguage, 'just text', 'just text')).toEqual('QuotelessString');
+ });
+
+ it('styles Hjson keys like keywords and leaves the literals plain', () => {
+ const json = '{"key": null, "flag": true, "s": "x"}';
+ const classes = new Map<string, string>();
+ highlightTree(hjsonLanguage.parser.parse(json), codeEditorHighlightStyle, (from, to, cls) => {
+ classes.set(json.slice(from, to), cls);
+ });
+ expect(classes.get('"key"')).toEqual(codeEditorHighlightStyle.style([tags.keyword]));
+ expect(classes.get('"x"')).toEqual(codeEditorHighlightStyle.style([tags.string]));
+ expect(classes.has('null')).toBe(false);
+ expect(classes.has('true')).toBe(false);
+ });
+
+ it('highlights a key that is being typed', () => {
+ expect(tokenize(hjsonLanguage, '{\n "a": 1\n que')).toContainEqual(['PropertyName', 'que']);
+ });
+
+ describe('completions', () => {
+ // Completes at the end of the text (marked with a |) the way the editor would
+ function complete(
+ extension: Extension,
+ textWithCursor: string,
+ readOnly = false,
+ ): string[] | undefined {
+ const pos = textWithCursor.indexOf('|');
+ const state = EditorState.create({
+ doc: textWithCursor.slice(0, pos) + textWithCursor.slice(pos + 1),
+ extensions: [extension, EditorState.readOnly.of(readOnly)],
+ });
+ const sources = state.languageDataAt<CompletionSource>('autocomplete', pos);
+ if (!sources.length) return;
+ const result = sources[0](
+ new CompletionContext(state, pos, false),
+ ) as CompletionResult | null;
+ return result?.options.map(option => option.label);
+ }
+
+ it('comes with dsql', () => {
+ expect(complete(dsql({ columns: ['my_column'] }), 'SELECT my_c|')).toContain('"my_column"');
+ expect(complete(dsql(), 'SELECT COU|')).toContain('COUNT');
+ });
+
+ it('comes with hjson when given jsonCompletions', () => {
+ const jsonCompletions = [
+ { path: '$', isObject: true, completions: [{ value: 'queryType' }] },
+ ];
+ expect(complete(hjson({ jsonCompletions }), '{\n que|')).toEqual(['queryType']);
+ expect(hjson({ jsonCompletions })).toBe(hjson({ jsonCompletions }));
+ expect(complete(hjson(), '{\n que|')).toBeUndefined();
+ });
+
+ it('is off in read only editors', () => {
+ expect(complete(dsql(), 'SELECT COU|', true)).toBeUndefined();
+ });
+ });
+});
diff --git a/web-console/src/ace-completions/make-doc-html.spec.ts b/web-console/src/editor-languages/editor-tags.ts
similarity index 72%
rename from web-console/src/ace-completions/make-doc-html.spec.ts
rename to web-console/src/editor-languages/editor-tags.ts
index 35172d0f5b9..82b477de920 100644
--- a/web-console/src/ace-completions/make-doc-html.spec.ts
+++ b/web-console/src/editor-languages/editor-tags.ts
@@ -16,16 +16,12 @@
* limitations under the License.
*/
-import { makeDocHtml } from './make-doc-html';
+import { Tag, tags } from '@lezer/highlight';
-describe('makeDocHtml', () => {
- it('correctly formats helper HTML', () => {
- expect(
- makeDocHtml({
- name: 'COUNT',
- syntax: 'COUNT(*)',
- description: 'Counts the number of things',
- }),
- ).toMatchSnapshot();
- });
-});
+/**
+ * Highlighting tags for the tokens that are styled in a way that does not fit any of the standard tags
+ */
+export const editorTags = {
+ issue: Tag.define(tags.comment),
+ column: Tag.define(tags.variableName),
+};
diff --git a/web-console/src/editor-languages/hjson-tokens.ts b/web-console/src/editor-languages/hjson-tokens.ts
new file mode 100644
index 00000000000..1a60d829b60
--- /dev/null
+++ b/web-console/src/editor-languages/hjson-tokens.ts
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { InputStream } from '@lezer/lr';
+import { ExternalTokenizer } from '@lezer/lr';
+
+import { quotelessKey, QuotelessString } from './hjson.parser.terms';
+
+const TAB = 9;
+const NEWLINE = 10;
+const CARRIAGE_RETURN = 13;
+const SPACE = 32;
+const DOUBLE_QUOTE = 34;
+const HASH = 35;
+const SINGLE_QUOTE = 39;
+const STAR = 42;
+const MINUS = 45;
+const DOT = 46;
+const SLASH = 47;
+const ZERO = 48;
+const NINE = 57;
+const COLON = 58;
+const UPPER_E = 69;
+const LOWER_E = 101;
+const PLUS = 43;
+
+// The characters that end a quoteless key and that a quoteless string can not start with
+const PUNCTUATORS = new Set(Array.from(',:[]{}', c => c.charCodeAt(0)));
+
+// The characters that can follow a number or a literal (true, false, null) that is not part of a quoteless string
+const VALUE_ENDS = new Set(Array.from(',]}', c => c.charCodeAt(0)));
+
+const LITERALS = ['true', 'false', 'null'];
+
+function isInlineSpace(c: number): boolean {
+ return c === SPACE || c === TAB;
+}
+
+function isSpace(c: number): boolean {
+ return isInlineSpace(c) || c === NEWLINE || c === CARRIAGE_RETURN;
+}
+
+function isLineEnd(c: number): boolean {
+ return c < 0 || c === NEWLINE || c === CARRIAGE_RETURN;
+}
+
+function isDigit(c: number): boolean {
+ return c >= ZERO && c <= NINE;
+}
+
+function startsComment(input: InputStream, offset: number): boolean {
+ const c = input.peek(offset);
+ if (c === HASH) return true;
+ const next = input.peek(offset + 1);
+ return c === SLASH && (next === SLASH || next === STAR);
+}
+
+function startsMultilineString(input: InputStream): boolean {
+ return (
+ input.peek(0) === SINGLE_QUOTE &&
+ input.peek(1) === SINGLE_QUOTE &&
+ input.peek(2) === SINGLE_QUOTE
+ );
+}
+
+function skipDigits(input: InputStream, offset: number): number {
+ while (isDigit(input.peek(offset))) offset++;
+ return offset;
+}
+
+/**
+ * The length of the number (as the grammar's Number token matches it) or the literal at the current position, 0 if
+ * there is none
+ */
+function numberOrLiteralLength(input: InputStream): number {
+ for (const literal of LITERALS) {
+ if (Array.from(literal).every((c, i) => input.peek(i) === c.charCodeAt(0))) {
+ return literal.length;
+ }
+ }
+
+ let offset = input.peek(0) === MINUS ? 1 : 0;
+ const first = input.peek(offset);
+ if (first === ZERO) {
+ offset++;
+ } else if (isDigit(first)) {
+ offset = skipDigits(input, offset);
+ } else {
+ return 0;
+ }
+
+ if (input.peek(offset) === DOT && isDigit(input.peek(offset + 1))) {
+ offset = skipDigits(input, offset + 1);
+ }
+
+ const e = input.peek(offset);
+ if (e === LOWER_E || e === UPPER_E) {
+ let exponent = offset + 1;
+ const sign = input.peek(exponent);
+ if (sign === PLUS || sign === MINUS) exponent++;
+ if (isDigit(input.peek(exponent))) offset = skipDigits(input, exponent);
+ }
+
+ return offset;
+}
+
+/**
+ * Whether the text at the given offset ends a number or a literal: the end of the line, a comma, a closing bracket or a
+ * comment (possibly after some spaces)
+ */
+function endsValue(input: InputStream, offset: number): boolean {
+ while (isInlineSpace(input.peek(offset))) offset++;
+ const c = input.peek(offset);
+ return isLineEnd(c) || VALUE_ENDS.has(c) || startsComment(input, offset);
+}
+
+/**
+ * Produces the tokens of Hjson that depend on the context:
+ *
+ * - quotelessKey: where a property can start, a word that is followed by a colon on the same line (or any word inside
+ * an object, where a value can not start)
+ * - QuotelessString: where a value can start, the rest of the line, unless it is just a number or a literal (`1`,
+ * `true, ...`), those are left to the grammar's tokens. A quoteless string can not start with a punctuator (`,:[]{}`),
+ * a double quote or a comment, but it can contain them.
+ */
+export const quoteless = new ExternalTokenizer(
+ (input, stack) => {
+ const first = input.next;
+ if (
+ first < 0 ||
+ isSpace(first) ||
+ PUNCTUATORS.has(first) ||
+ first === DOUBLE_QUOTE ||
+ startsComment(input, 0) ||
+ startsMultilineString(input)
+ ) {
+ return;
+ }
+
+ if (stack.canShift(quotelessKey)) {
+ let keyEnd = 0;
+ while (
+ !isSpace(input.peek(keyEnd)) &&
+ !PUNCTUATORS.has(input.peek(keyEnd)) &&
+ input.peek(keyEnd) >= 0
+ ) {
+ keyEnd++;
+ }
+ let colon = keyEnd;
+ while (isInlineSpace(input.peek(colon))) colon++;
+ // Inside an object (where no value can start) a word is a key even before its colon is typed
+ if (input.peek(colon) === COLON || (keyEnd && !stack.canShift(QuotelessString))) {
+ input.acceptToken(quotelessKey, keyEnd);
+ return;
+ }
+ }
+
+ if (stack.canShift(QuotelessString)) {
+ const valueLength = numberOrLiteralLength(input);
+ if (valueLength && endsValue(input, valueLength)) return;
+
+ let end = 0;
+ while (!isLineEnd(input.peek(end))) end++;
+ input.acceptToken(QuotelessString, end);
+ }
+ },
+ { contextual: true },
+);
diff --git a/web-console/src/editor-languages/hjson.grammar b/web-console/src/editor-languages/hjson.grammar
new file mode 100644
index 00000000000..129c058e590
--- /dev/null
+++ b/web-console/src/editor-languages/hjson.grammar
@@ -0,0 +1,95 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The Lezer grammar for Hjson (https://hjson.github.io/syntax.html), which includes JSON.
+//
+// Run `script/build-grammars.mjs` (part of `script/build`) after changing it.
+
+// The braces around the root object can be left out
+@top Document { value | rootProperties }
+
+rootProperties { (Property | ",")* }
+
+value {
+ True |
+ False |
+ Null |
+ Number |
+ String |
+ MultilineString |
+ QuotelessString |
+ Object |
+ Array
+}
+
+// Commas are optional between the properties and the items that are on separate lines
+Object { "{" (Property | ",")* "}" }
+
+Property { PropertyName ":" value }
+
+PropertyName { quotelessKey | String }
+
+Array { "[" (value | ",")* "]" }
+
+@skip { space | LineComment | BlockComment }
+
+// A quoteless key (only before a colon on the same line) and a quoteless string (the rest of the line, unless that is a
+// number or a literal) depend on the context, see hjson-tokens.ts
+@external tokens quoteless from "./hjson-tokens" { quotelessKey, QuotelessString }
+
+@skip {} {
+ // A string does not span lines, an unterminated one ends with (and includes) the end of the line
+ String { stringStart (stringContent | Escape | InvalidEscape)* stringEnd }
+}
+
+@local tokens {
+ stringEnd { '"' | "\n" }
+ Escape { "\\" ($["\\/bfnrt] | "u" hex hex hex hex) }
+ InvalidEscape { "\\" _ }
+ hex { $[0-9a-fA-F] }
+ @precedence { Escape, InvalidEscape }
+ @else stringContent
+}
+
+@tokens {
+ space { $[ \t\n\r]+ }
+
+ // The quotes are not nodes of their own so that they are highlighted as part of the string
+ stringStart { '"' }
+
+ LineComment { ("#" | "//") ![\n]* }
+
+ // An unterminated comment lasts until the end of the text
+ BlockComment { "/*" blockCommentRest }
+ blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar | @eof }
+ blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest | @eof }
+
+ // An unterminated multiline string lasts until the end of the text
+ MultilineString { "'''" multilineRest }
+ multilineRest { !['] multilineRest | "'" multilineQuote | @eof }
+ multilineQuote { !['] multilineRest | "'" multilineQuotes | @eof }
+ multilineQuotes { "'" | !['] multilineRest | @eof }
+
+ Number { "-"? ("0" | $[1-9] @digit*) ("." @digit+)? ($[eE] $[+\-]? @digit+)? }
+
+ True { "true" }
+ False { "false" }
+ Null { "null" }
+
+ "{" "}" "[" "]" "," ":"
+}
+
+@detectDelim
diff --git a/web-console/src/editor-languages/hjson.ts b/web-console/src/editor-languages/hjson.ts
new file mode 100644
index 00000000000..463c5cc5d41
--- /dev/null
+++ b/web-console/src/editor-languages/hjson.ts
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { indentNodeProp, LanguageSupport, LRLanguage } from '@codemirror/language';
+import { styleTags, tags } from '@lezer/highlight';
+
+import { makeCompletionSource } from '../components/code-editor/completion-source';
+import { getHjsonCompletions } from '../editor-completions/hjson-completions';
+import type { JsonCompletionRule } from '../utils';
+
+import { parser } from './hjson.parser';
+
+export const hjsonLanguage = LRLanguage.define({
+ name: 'hjson',
+ parser: parser.configure({
+ props: [
+ styleTags({
+ // The keys (quoted or not) are styled as a whole, including any escapes
+ 'PropertyName PropertyName/String PropertyName/String/Escape PropertyName/String/InvalidEscape':
+ tags.propertyName,
+ 'String MultilineString QuotelessString': tags.string,
+ 'Escape': tags.escape,
+ 'InvalidEscape': tags.invalid,
+ 'Number': tags.number,
+ // Not styled (tags.null would be, as a keyword)
+ 'True False': tags.bool,
+ 'Null': tags.literal,
+ 'LineComment': tags.lineComment,
+ 'BlockComment': tags.blockComment,
+ '{ }': tags.brace,
+ '[ ]': tags.squareBracket,
+ ', :': tags.separator,
+ }),
+ // A new line keeps the indentation of the line before (rather than indenting inside brackets)
+ indentNodeProp.add({ 'Document Object Array': () => null }),
+ ],
+ }),
+ languageData: {
+ commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
+ // Keys and values can contain - (like mv-filtered) and $ (letters, digits and _ are word characters anyway)
+ wordChars: '$-',
+ },
+});
+
+const plainHjson = new LanguageSupport(hjsonLanguage);
+const hjsonWithCompletions = new WeakMap<JsonCompletionRule[], LanguageSupport>();
+
+export interface HjsonOptions {
+ /** The rules for the property and value completions */
+ jsonCompletions?: JsonCompletionRule[];
+}
+
+/**
+ * Hjson support for a CodeEditor: highlighting and, given jsonCompletions, completions. The same options always give
+ * the same LanguageSupport so that it can be created while rendering.
+ */
+export function hjson({ jsonCompletions }: HjsonOptions = {}): LanguageSupport {
+ if (!jsonCompletions) return plainHjson;
+ let support = hjsonWithCompletions.get(jsonCompletions);
+ if (!support) {
+ support = new LanguageSupport(
+ hjsonLanguage,
+ hjsonLanguage.data.of({
+ autocomplete: makeCompletionSource((context, word) =>
+ getHjsonCompletions(context, word, jsonCompletions),
+ ),
+ }),
+ );
+ hjsonWithCompletions.set(jsonCompletions, support);
+ }
+ return support;
+}
diff --git a/web-console/src/entry.tsx b/web-console/src/entry.tsx
index 3017ae8e7b8..2ff22d2ae82 100644
--- a/web-console/src/entry.tsx
+++ b/web-console/src/entry.tsx
@@ -16,8 +16,6 @@
* limitations under the License.
*/
-import './bootstrap/ace';
-
import { OverlaysProvider } from '@blueprintjs/core';
import { QueryRunner } from 'druid-query-toolkit';
import { createRoot } from 'react-dom/client';
diff --git a/web-console/src/helpers/capabilities.ts b/web-console/src/helpers/capabilities.ts
index e31466e65a3..e5032c5e5eb 100644
--- a/web-console/src/helpers/capabilities.ts
+++ b/web-console/src/helpers/capabilities.ts
@@ -56,7 +56,7 @@ function functionRowsToMap(functionRows: FunctionRow[]): AvailableFunctions {
});
if (!args.length) return;
return [
- ROUTINE_NAME,
+ ROUTINE_NAME.toUpperCase(),
{
args,
isAggregate: IS_AGGREGATOR === 'YES',
diff --git a/web-console/src/setup-tests.ts b/web-console/src/setup-tests.ts
index e1ac5493ee2..1b7d9507cc8 100644
--- a/web-console/src/setup-tests.ts
+++ b/web-console/src/setup-tests.ts
@@ -16,8 +16,6 @@
* limitations under the License.
*/
-import './bootstrap/ace';
-
import { Icons } from '@blueprintjs/icons';
import { UrlBaser } from './singletons';
diff --git a/web-console/src/singletons/ace-editor-state-cache.ts b/web-console/src/singletons/ace-editor-state-cache.ts
deleted file mode 100644
index 62a440ad52d..00000000000
--- a/web-console/src/singletons/ace-editor-state-cache.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import type { Ace } from 'ace-builds';
-
-interface EditorState {
- undoManager: Ace.UndoManager;
-}
-
-export class AceEditorStateCache {
- static states = new Map<string, EditorState>();
-
- static saveState(id: string, editor: Ace.Editor): void {
- const session = editor.getSession();
- const undoManager: any = session.getUndoManager();
- AceEditorStateCache.states.set(id, {
- undoManager,
- });
- }
-
- static applyState(id: string, editor: Ace.Editor): void {
- const state = AceEditorStateCache.states.get(id);
- if (!state) return;
- const session = editor.getSession();
- session.setUndoManager(state.undoManager);
- }
-
- static deleteState(id: string): void {
- AceEditorStateCache.states.delete(id);
- }
-}
diff --git a/web-console/src/test-utils/completion-context.ts b/web-console/src/test-utils/completion-context.ts
new file mode 100644
index 00000000000..41e2bd4d556
--- /dev/null
+++ b/web-console/src/test-utils/completion-context.ts
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { CompletionContext } from '@codemirror/autocomplete';
+import type { Extension } from '@codemirror/state';
+import { EditorState } from '@codemirror/state';
+
+import type { CompletionWord } from '../components/code-editor/completion-source';
+import { matchCompletionWord } from '../components/code-editor/completion-source';
+
+/**
+ * The completion context and word that the editor would have with the cursor at the | in the text, as if the completion
+ * was asked for explicitly (Ctrl-Space)
+ */
+export function completionContextAt(
+ extension: Extension,
+ textWithCursor: string,
+): [CompletionContext, CompletionWord] {
+ const pos = textWithCursor.indexOf('|');
+ if (pos < 0) throw new Error(`no | in ${textWithCursor}`);
+ const state = EditorState.create({
+ doc: textWithCursor.slice(0, pos) + textWithCursor.slice(pos + 1),
+ extensions: extension,
+ });
+ const context = new CompletionContext(state, pos, true);
+ return [context, matchCompletionWord(context)!];
+}
diff --git a/web-console/src/test-utils/snapshot-serializer.ts b/web-console/src/test-utils/snapshot-serializer.ts
index 68a7079189c..4b4d03b0978 100644
--- a/web-console/src/test-utils/snapshot-serializer.ts
+++ b/web-console/src/test-utils/snapshot-serializer.ts
@@ -16,33 +16,33 @@
* limitations under the License.
*/
-import type { Ace } from 'ace-builds';
+import { language } from '@codemirror/language';
+import { EditorView } from '@codemirror/view';
// Removes noise from DOM snapshots so that they stay small and a change shows up as a small diff:
// - the empty padding rows that ConsoleTable always renders are collapsed into a single comment
// - icon <svg>s are reduced to their icon name (the path data changes whenever an icon is redrawn)
-// - the inside of Ace editors (Ace's own DOM, which changes with every Ace version) is replaced with a comment
-// describing what the console configured: the mode, the value and the placeholder
+// - the inside of code editors (CodeMirror's own DOM, which changes with every CodeMirror version) is replaced with a
+// comment describing what the console configured: the language, the value and the placeholder
const cleaned = new WeakSet<Node>();
-function describeAceEditor(editor: Ace.Editor): string {
- const parts = [`mode: ${(editor.session.getMode() as any).$id}`];
- parts.push(`value: ${JSON.stringify(editor.getValue())}`);
- const placeholder: string | undefined = (editor.renderer as any).placeholderNode?.textContent;
+function describeEditor(view: EditorView): string {
+ const parts = [`language: ${view.state.facet(language)?.name || 'text'}`];
+ parts.push(`value: ${JSON.stringify(view.state.doc.toString())}`);
+ const placeholder = view.dom.querySelector('.cm-placeholder')?.textContent;
if (placeholder) parts.push(`placeholder: ${JSON.stringify(placeholder)}`);
- if (editor.getReadOnly()) parts.push('read only');
- return ` Ace editor, ${parts.join(', ')} `;
+ if (view.state.readOnly) parts.push('read only');
+ return ` Code editor, ${parts.join(', ')} `;
}
function simplifyDom(root: Element, original: Element): void {
- const originalEditors = Array.from(original.querySelectorAll('.ace_editor'));
- Array.from(root.querySelectorAll('.ace_editor')).forEach((editorElement, i) => {
- const editor: Ace.Editor | undefined = (originalEditors[i] as any)?.env?.editor;
- if (!editor) return;
- editorElement.replaceChildren(
- editorElement.ownerDocument.createComment(describeAceEditor(editor)),
- );
+ const originalEditors = Array.from(original.querySelectorAll('.cm-editor'));
+ Array.from(root.querySelectorAll('.cm-editor')).forEach((editorElement, i) => {
+ const view = originalEditors[i] && EditorView.findFromDOM(originalEditors[i] as HTMLElement);
+ if (!view) return;
+ editorElement.className = 'cm-editor'; // Drop the generated theme classes
+ editorElement.replaceChildren(editorElement.ownerDocument.createComment(describeEditor(view)));
});
for (const tbody of Array.from(root.querySelectorAll('.ct-tbody'))) {
diff --git a/web-console/src/utils/base64-url.ts b/web-console/src/utils/base64-url.ts
index 93202ed8c1a..f0082aec281 100644
--- a/web-console/src/utils/base64-url.ts
+++ b/web-console/src/utils/base64-url.ts
@@ -20,7 +20,7 @@ export function base64UrlEncode(input: string): string {
return base64Encode(input) // Encode to base64
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_') // Replace '/' with '_'
- .replace(/=+$/, ''); // Remove any trailing '='
+ .replace(/={1,2}$/, ''); // Remove the trailing '=' padding
}
export function base64UrlDecode(input: string): string {
diff --git a/web-console/src/utils/druid-query.spec.ts b/web-console/src/utils/druid-query.spec.ts
index bf0f6558f1b..1b61c51ba94 100644
--- a/web-console/src/utils/druid-query.spec.ts
+++ b/web-console/src/utils/druid-query.spec.ts
@@ -21,10 +21,10 @@ import { sane } from 'druid-query-toolkit';
import { DruidError, getDruidErrorMessage } from './druid-query';
describe('DruidQuery', () => {
- describe('DruidError.extractStartRowColumn', () => {
+ describe('DruidError.extractStartLineColumn', () => {
it('works for single error 1', () => {
expect(
- DruidError.extractStartRowColumn({
+ DruidError.extractStartLineColumn({
sourceType: 'sql',
line: '2',
column: '12',
@@ -32,14 +32,14 @@ describe('DruidQuery', () => {
expected: '...',
}),
).toEqual({
- row: 1,
- column: 11,
+ line: 2,
+ column: 12,
});
});
it('works for range', () => {
expect(
- DruidError.extractStartRowColumn({
+ DruidError.extractStartLineColumn({
sourceType: 'sql',
line: '1',
column: '16',
@@ -49,16 +49,16 @@ describe('DruidQuery', () => {
expected: '...',
}),
).toEqual({
- row: 0,
- column: 15,
+ line: 1,
+ column: 16,
});
});
});
- describe('DruidError.extractEndRowColumn', () => {
+ describe('DruidError.extractEndLineColumn', () => {
it('works for single error 1', () => {
expect(
- DruidError.extractEndRowColumn({
+ DruidError.extractEndLineColumn({
sourceType: 'sql',
line: '2',
column: '12',
@@ -70,7 +70,7 @@ describe('DruidQuery', () => {
it('works for range', () => {
expect(
- DruidError.extractEndRowColumn({
+ DruidError.extractEndLineColumn({
sourceType: 'sql',
line: '1',
column: '16',
@@ -80,8 +80,8 @@ describe('DruidQuery', () => {
expected: '...',
}),
).toEqual({
- row: 0,
- column: 16,
+ line: 1,
+ column: 17,
});
});
});
diff --git a/web-console/src/utils/druid-query.ts b/web-console/src/utils/druid-query.ts
index 8a64e4fd80f..d929bf83084 100644
--- a/web-console/src/utils/druid-query.ts
+++ b/web-console/src/utils/druid-query.ts
@@ -22,7 +22,7 @@ import { C } from 'druid-query-toolkit';
import { Api } from '../singletons';
-import type { RowColumn } from './general';
+import type { LineColumn } from './general';
import { assemble, lookupBy } from './general';
const CANCELED_MESSAGE = 'Query canceled by user.';
@@ -109,27 +109,27 @@ export function getDruidErrorMessage(e: any): string {
}
export class DruidError extends Error {
- static extractStartRowColumn(
+ static extractStartLineColumn(
context: Record<string, any> | undefined,
offsetLines = 0,
- ): RowColumn | undefined {
+ ): LineColumn | undefined {
if (context?.sourceType !== 'sql' || !context.line || !context.column) return;
return {
- row: Number(context.line) - 1 + offsetLines,
- column: Number(context.column) - 1,
+ line: Number(context.line) + offsetLines,
+ column: Number(context.column),
};
}
- static extractEndRowColumn(
+ static extractEndLineColumn(
context: Record<string, any> | undefined,
offsetLines = 0,
- ): RowColumn | undefined {
+ ): LineColumn | undefined {
if (context?.sourceType !== 'sql' || !context.endLine || !context.endColumn) return;
return {
- row: Number(context.endLine) - 1 + offsetLines,
- column: Number(context.endColumn) - 1,
+ line: Number(context.endLine) + offsetLines,
+ column: Number(context.endColumn),
};
}
@@ -267,8 +267,8 @@ export class DruidError extends Error {
public errorMessage?: string;
public errorMessageWithoutExpectation?: string;
public expectation?: string;
- public startRowColumn?: RowColumn;
- public endRowColumn?: RowColumn;
+ public startLineColumn?: LineColumn;
+ public endLineColumn?: LineColumn;
public suggestion?: QuerySuggestion;
public queryDuration?: number;
@@ -306,8 +306,8 @@ export class DruidError extends Error {
);
}
- this.startRowColumn = DruidError.extractStartRowColumn(this.context, offsetLines);
- this.endRowColumn = DruidError.extractEndRowColumn(this.context, offsetLines);
+ this.startLineColumn = DruidError.extractStartLineColumn(this.context, offsetLines);
+ this.endLineColumn = DruidError.extractEndLineColumn(this.context, offsetLines);
this.suggestion = DruidError.getSuggestion(this.errorMessage);
const expectationIndex = this.errorMessage.indexOf('Was expecting one of');
... 2733 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@druid.apache.org
For additional commands, e-mail: commits-help@druid.apache.org