From 9b1e7b074da0e8a50f4c8c4233567aeeedd5ddf9 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 4 Feb 2026 12:26:30 -0600 Subject: [PATCH 1/2] feat: codemirror-json-schema pkg --- packages/codemirror-json-schema/.eslintrc.js | 8 + packages/codemirror-json-schema/CHANGELOG.md | 258 ++++ packages/codemirror-json-schema/LICENSE | 21 + packages/codemirror-json-schema/README.md | 285 ++++ packages/codemirror-json-schema/package.json | 128 ++ .../src/__tests__/dynamic-schema.spec.ts | 84 ++ .../codemirror-json-schema/src/constants.ts | 59 + .../__tests__/__fixtures__/schemas.ts | 254 ++++ .../__tests__/__helpers__/completion.ts | 92 ++ .../features/__tests__/__helpers__/index.ts | 17 + .../__tests__/json-completion.spec.ts | 990 +++++++++++++ .../src/features/__tests__/json-hover.spec.ts | 221 +++ .../__tests__/json-validation.spec.ts | 429 ++++++ .../src/features/__tests__/state.spec.ts | 55 + .../src/features/completion.ts | 1261 +++++++++++++++++ .../src/features/hover.ts | 245 ++++ .../src/features/state.ts | 31 + .../src/features/validation.ts | 186 +++ packages/codemirror-json-schema/src/index.ts | 33 + .../src/json/bundled.ts | 28 + .../src/json5/bundled.ts | 29 + .../src/json5/completion.ts | 16 + .../codemirror-json-schema/src/json5/hover.ts | 22 + .../codemirror-json-schema/src/json5/index.ts | 11 + .../src/json5/validation.ts | 22 + .../src/parsers/__tests__/json-parser.spec.ts | 29 + .../src/parsers/__tests__/yaml-parser.spec.ts | 24 + .../src/parsers/index.ts | 22 + .../src/parsers/json-parser.ts | 35 + .../src/parsers/json5-parser.ts | 44 + .../src/parsers/yaml-parser.ts | 24 + packages/codemirror-json-schema/src/types.ts | 24 + .../src/types/codemirror-json5.d.ts | 8 + .../src/utils/__tests__/json-pointers.spec.ts | 291 ++++ .../src/utils/__tests__/node.spec.ts | 184 +++ .../codemirror-json-schema/src/utils/debug.ts | 4 + .../codemirror-json-schema/src/utils/dom.ts | 22 + .../src/utils/formatting.ts | 18 + .../src/utils/json-pointers.ts | 165 +++ .../src/utils/markdown.ts | 52 + .../codemirror-json-schema/src/utils/node.ts | 150 ++ .../src/utils/recordUtil.ts | 103 ++ .../src/yaml/bundled.ts | 28 + .../src/yaml/completion.ts | 14 + .../codemirror-json-schema/src/yaml/hover.ts | 22 + .../codemirror-json-schema/src/yaml/index.ts | 11 + .../src/yaml/validation.ts | 22 + packages/codemirror-json-schema/tsconfig.json | 8 + .../codemirror-json-schema/vite.config.mts | 70 + yarn.lock | 940 +++++++++++- 50 files changed, 7092 insertions(+), 7 deletions(-) create mode 100644 packages/codemirror-json-schema/.eslintrc.js create mode 100644 packages/codemirror-json-schema/CHANGELOG.md create mode 100644 packages/codemirror-json-schema/LICENSE create mode 100644 packages/codemirror-json-schema/README.md create mode 100644 packages/codemirror-json-schema/package.json create mode 100644 packages/codemirror-json-schema/src/__tests__/dynamic-schema.spec.ts create mode 100644 packages/codemirror-json-schema/src/constants.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/__fixtures__/schemas.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/__helpers__/completion.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/__helpers__/index.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/json-completion.spec.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/json-hover.spec.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/json-validation.spec.ts create mode 100644 packages/codemirror-json-schema/src/features/__tests__/state.spec.ts create mode 100644 packages/codemirror-json-schema/src/features/completion.ts create mode 100644 packages/codemirror-json-schema/src/features/hover.ts create mode 100644 packages/codemirror-json-schema/src/features/state.ts create mode 100644 packages/codemirror-json-schema/src/features/validation.ts create mode 100644 packages/codemirror-json-schema/src/index.ts create mode 100644 packages/codemirror-json-schema/src/json/bundled.ts create mode 100644 packages/codemirror-json-schema/src/json5/bundled.ts create mode 100644 packages/codemirror-json-schema/src/json5/completion.ts create mode 100644 packages/codemirror-json-schema/src/json5/hover.ts create mode 100644 packages/codemirror-json-schema/src/json5/index.ts create mode 100644 packages/codemirror-json-schema/src/json5/validation.ts create mode 100644 packages/codemirror-json-schema/src/parsers/__tests__/json-parser.spec.ts create mode 100644 packages/codemirror-json-schema/src/parsers/__tests__/yaml-parser.spec.ts create mode 100644 packages/codemirror-json-schema/src/parsers/index.ts create mode 100644 packages/codemirror-json-schema/src/parsers/json-parser.ts create mode 100644 packages/codemirror-json-schema/src/parsers/json5-parser.ts create mode 100644 packages/codemirror-json-schema/src/parsers/yaml-parser.ts create mode 100644 packages/codemirror-json-schema/src/types.ts create mode 100644 packages/codemirror-json-schema/src/types/codemirror-json5.d.ts create mode 100644 packages/codemirror-json-schema/src/utils/__tests__/json-pointers.spec.ts create mode 100644 packages/codemirror-json-schema/src/utils/__tests__/node.spec.ts create mode 100644 packages/codemirror-json-schema/src/utils/debug.ts create mode 100644 packages/codemirror-json-schema/src/utils/dom.ts create mode 100644 packages/codemirror-json-schema/src/utils/formatting.ts create mode 100644 packages/codemirror-json-schema/src/utils/json-pointers.ts create mode 100644 packages/codemirror-json-schema/src/utils/markdown.ts create mode 100644 packages/codemirror-json-schema/src/utils/node.ts create mode 100644 packages/codemirror-json-schema/src/utils/recordUtil.ts create mode 100644 packages/codemirror-json-schema/src/yaml/bundled.ts create mode 100644 packages/codemirror-json-schema/src/yaml/completion.ts create mode 100644 packages/codemirror-json-schema/src/yaml/hover.ts create mode 100644 packages/codemirror-json-schema/src/yaml/index.ts create mode 100644 packages/codemirror-json-schema/src/yaml/validation.ts create mode 100644 packages/codemirror-json-schema/tsconfig.json create mode 100644 packages/codemirror-json-schema/vite.config.mts diff --git a/packages/codemirror-json-schema/.eslintrc.js b/packages/codemirror-json-schema/.eslintrc.js new file mode 100644 index 00000000..e685d3b0 --- /dev/null +++ b/packages/codemirror-json-schema/.eslintrc.js @@ -0,0 +1,8 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@knocklabs/eslint-config/library.js"], + parserOptions: { + projects: ["tsconfig.json"], + }, +}; diff --git a/packages/codemirror-json-schema/CHANGELOG.md b/packages/codemirror-json-schema/CHANGELOG.md new file mode 100644 index 00000000..62be7e4b --- /dev/null +++ b/packages/codemirror-json-schema/CHANGELOG.md @@ -0,0 +1,258 @@ +# codemirror-json-schema + +## 0.8.1 + +### Patch Changes + +- [#151](https://github.com/jsonnext/codemirror-json-schema/pull/151) [`d360a86`](https://github.com/jsonnext/codemirror-json-schema/commit/d360a86b90aef61fb1a112ea3298a9ebed1c9012) Thanks [@skrabe](https://github.com/skrabe)! - Fixed validation bugs: single objects incorrectly passed array schemas, invalid YAML caused errors after root-level change(now skipped if unparseable), and added tests ensuring non-array values(object, boolean, string, number) are correctly rejected. + +## 0.8.0 + +### Minor Changes + +- [#138](https://github.com/jsonnext/codemirror-json-schema/pull/138) [`aa27ad7`](https://github.com/jsonnext/codemirror-json-schema/commit/aa27ad740fec447069bacd1d817e1d32fbbf8d90) Thanks [@thomasjahoda](https://github.com/thomasjahoda)! - More robust conditional types support (thanks @thomasjahoda!) + +## 0.7.9 + +### Patch Changes + +- [#133](https://github.com/jsonnext/codemirror-json-schema/pull/133) [`4fd7cc6`](https://github.com/jsonnext/codemirror-json-schema/commit/4fd7cc69084bdad3c8c93d9d0f0a936fa120cbae) Thanks [@imolorhe](https://github.com/imolorhe)! - Get sub schema using parsed data for additional context + +- [#137](https://github.com/jsonnext/codemirror-json-schema/pull/137) [`29e2da5`](https://github.com/jsonnext/codemirror-json-schema/commit/29e2da5b5a16f8b076186e9623fe93ce6d086c30) Thanks [@xdavidwu](https://github.com/xdavidwu)! - Fix description markdown rendering in completion + +- [#144](https://github.com/jsonnext/codemirror-json-schema/pull/144) [`ef7f336`](https://github.com/jsonnext/codemirror-json-schema/commit/ef7f336f0d79397b19a37e76228f8a9b25070c89) Thanks [@imolorhe](https://github.com/imolorhe)! - updated to use fine grained shiki bundle + +- [#139](https://github.com/jsonnext/codemirror-json-schema/pull/139) [`bfbe613`](https://github.com/jsonnext/codemirror-json-schema/commit/bfbe613c81ba43e079e454b9c59a48a2f3887810) Thanks [@NickTomlin](https://github.com/NickTomlin)! - Move non essential packages to devDependencies + +- [#140](https://github.com/jsonnext/codemirror-json-schema/pull/140) [`bceace2`](https://github.com/jsonnext/codemirror-json-schema/commit/bceace285c137a53f3773219ee008ad1d856c770) Thanks [@NickTomlin](https://github.com/NickTomlin)! - Add CONTRIBUTING.md file + +## 0.7.8 + +### Patch Changes + +- [#122](https://github.com/jsonnext/codemirror-json-schema/pull/122) [`c2dfcc1`](https://github.com/jsonnext/codemirror-json-schema/commit/c2dfcc154abfc0dea0d3c0646a8b681565acf0f3) Thanks [@imolorhe](https://github.com/imolorhe)! - fix demo highlighting + +- [#123](https://github.com/jsonnext/codemirror-json-schema/pull/123) [`2356a94`](https://github.com/jsonnext/codemirror-json-schema/commit/2356a94de080c00869a8b1b41763dbc577530894) Thanks [@imolorhe](https://github.com/imolorhe)! - updated json-schema-library to get upstream fixes + +## 0.7.7 + +### Patch Changes + +- [#117](https://github.com/jsonnext/codemirror-json-schema/pull/117) [`edafa8f`](https://github.com/jsonnext/codemirror-json-schema/commit/edafa8f6993a4004c9ffe6aa7cde58c9da704d6b) Thanks [@acao](https://github.com/acao)! - validate all strings with length > 1 + +## 0.7.6 + +### Patch Changes + +- [#115](https://github.com/jsonnext/codemirror-json-schema/pull/115) [`c8d2594`](https://github.com/jsonnext/codemirror-json-schema/commit/c8d259443ffdc5eb792dd373dac64e1d4895c876) Thanks [@acao](https://github.com/acao)! - set @codemirror/autocomplete as an optional peer, at a fix version for a bug with curly braces + +## 0.7.5 + +### Patch Changes + +- [#112](https://github.com/jsonnext/codemirror-json-schema/pull/112) [`ccffa61`](https://github.com/jsonnext/codemirror-json-schema/commit/ccffa6195e45d0eb52ed2253831eb396e930a1cc) Thanks [@acao](https://github.com/acao)! - fixes bundling - remove .js imports and remains as moduleResolution: 'Node' to match cm6 + +## 0.7.4 + +### Patch Changes + +- [#102](https://github.com/acao/codemirror-json-schema/pull/102) [`296617f`](https://github.com/acao/codemirror-json-schema/commit/296617f4800d875ddd579cbb544240e8a6985bc1) Thanks [@imolorhe](https://github.com/imolorhe)! - Improvements to completion logic (mainly for top level) + +## 0.7.3 + +### Patch Changes + +- [#103](https://github.com/acao/codemirror-json-schema/pull/103) [`da7f368`](https://github.com/acao/codemirror-json-schema/commit/da7f36888c5efa31b5b32becdf9f839e476eed85) Thanks [@imolorhe](https://github.com/imolorhe)! - Handle generic validation error + +## 0.7.2 + +### Patch Changes + +- [#94](https://github.com/acao/codemirror-json-schema/pull/94) [`0dc3749`](https://github.com/acao/codemirror-json-schema/commit/0dc37498d7276becceb48d92dc367648f4676415) Thanks [@xdavidwu](https://github.com/xdavidwu)! - Add support for YAML flow sequences and flow mappings + +## 0.7.1 + +### Patch Changes + +- [`8b311fe`](https://github.com/acao/codemirror-json-schema/commit/8b311fe1fe48ba5cf209ae5d9524f0df6d0fba55) Thanks [@acao](https://github.com/acao)! - Add MIT license via @imolorhe + +## 0.7.0 + +### Minor Changes + +- [#85](https://github.com/acao/codemirror-json-schema/pull/85) [`c694451`](https://github.com/acao/codemirror-json-schema/commit/c6944518e72aef0a2b81952dff6bc0114b8c6be0) Thanks [@imolorhe](https://github.com/imolorhe)! - Added YAML support, switched back to markdown for messages, provide markdown rendering, and fix some autocompletion issues + +## 0.6.1 + +### Patch Changes + +- [#81](https://github.com/acao/codemirror-json-schema/pull/81) [`ed534d7`](https://github.com/acao/codemirror-json-schema/commit/ed534d703801d174779e099891a2905e6b60a6af) Thanks [@acao](https://github.com/acao)! - export `handleRefresh` + +- [#83](https://github.com/acao/codemirror-json-schema/pull/83) [`efd54f0`](https://github.com/acao/codemirror-json-schema/commit/efd54f022cad7ba924b444356ffa6f0f6c704916) Thanks [@acao](https://github.com/acao)! - fix undefined position bug with json-schema-library upgrade + +## 0.6.0 + +### Minor Changes + +- [#64](https://github.com/acao/codemirror-json-schema/pull/64) [`0aaf308`](https://github.com/acao/codemirror-json-schema/commit/0aaf3080f9451bdbdc45f5a812ce50c25f354c57) Thanks [@acao](https://github.com/acao)! - **Breaking Change**: replaces backticks with `` blocks in hover and completion! This just seemed to make more sense. + + - upgrade `json-schema-library` to the latest 8.x with patch fixes, remove "forked" pointer step logic + - after autocompleting a property, when there is empty value, provide full autocomplete options + - as noted in the breaking change notice, all psuedo-markdown backtick \`\`delimiters are replaced with`` + +## 0.5.1 + +### Patch Changes + +- [`7ed9e3e`](https://github.com/acao/codemirror-json-schema/commit/7ed9e3e206ec7a47f8f7dde7d2a50a75228ae0be) Thanks [@acao](https://github.com/acao)! - fix required fields validation + +## 0.5.0 + +### Minor Changes + +- [#63](https://github.com/acao/codemirror-json-schema/pull/63) [`a73c517`](https://github.com/acao/codemirror-json-schema/commit/a73c517722bbe9d37124993117c091e259eb6998) Thanks [@acao](https://github.com/acao)! + +- **breaking change**: only impacts those following the "custom usage" approach, it _does not_ effect users using the high level, "bundled" `jsonSchema()` or `json5Schema()` modes. + + Previously, we ask you to pass schema to each of the linter, completion and hover extensions. + + Now, we ask you to use these new exports to instantiate your schema like this, with `stateExtensions(schema)` as a new extension, and the only one that you pass schema to, like so: + + ```ts + import type { JSONSchema7 } from "json-schema"; + import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json"; + import { hoverTooltip } from "@codemirror/view"; + import { linter } from "@codemirror/lint"; + + import { + jsonCompletion, + handleRefresh, + jsonSchemaLinter, + jsonSchemaHover, + stateExtensions, + } from "codemirror-json-schema"; + + import schema from "./myschema.json"; + + // ... + extensions: [ + json(), + linter(jsonParseLinter()), + linter(jsonSchemaLinter(), { + needsRefresh: handleRefresh, + }), + jsonLanguage.data.of({ + autocomplete: jsonCompletion(), + }), + hoverTooltip(jsonSchemaHover()), + // this is where we pass the schema! + // very important!!!! + stateExtensions(schema), + ]; + ``` + +- upgrade to use full `.js` import paths for `NodeNext` compatibility, however not all of our dependencies are compatible with this mode, thus we continue using the legacy `nodeResolution` strategy. + +## 0.4.5 + +### Patch Changes + +- [#70](https://github.com/acao/codemirror-json-schema/pull/70) [`4c9ca0a`](https://github.com/acao/codemirror-json-schema/commit/4c9ca0a2cab4806d1107a64e96a60c3c6c46edfa) Thanks [@acao](https://github.com/acao)! - Fix vulnerability message for json-schema type dependency + +## 0.4.4 + +### Patch Changes + +- [#60](https://github.com/acao/codemirror-json-schema/pull/60) [`161a2df`](https://github.com/acao/codemirror-json-schema/commit/161a2dfa7e7e7f35253818c6f47395575b7b7baa) Thanks [@imolorhe](https://github.com/imolorhe)! - Added generated cjs directory to files list + +## 0.4.3 + +### Patch Changes + +- [#58](https://github.com/acao/codemirror-json-schema/pull/58) [`eb3e09d`](https://github.com/acao/codemirror-json-schema/commit/eb3e09d1b2e1280ba295aac9fa8ba9493a0d385d) Thanks [@acao](https://github.com/acao)! - Add main/cjs exports for webpack + +## 0.4.2 + +### Patch Changes + +- [`14a26f8`](https://github.com/acao/codemirror-json-schema/commit/14a26f829f04972080eed822bd14e2e29d907be4) Thanks [@acao](https://github.com/acao)! - fix nested json4 completion bug (#55) + + - fix #54, expand properties inside nested objects as expected in json4 + - always advance cursor after property completions + - add more test coverage + +## 0.4.1 + +### Patch Changes + +- [#49](https://github.com/acao/codemirror-json-schema/pull/49) [`8d7fa57`](https://github.com/acao/codemirror-json-schema/commit/8d7fa578d74e31d3ec0d6bde6dd55fdbd570c586) Thanks [@imolorhe](https://github.com/imolorhe)! - expand property schema when inserting text + +## 0.4.0 + +### Minor Changes + +- [`b227106`](https://github.com/acao/codemirror-json-schema/commit/b2271065dc9d2273094d0d193ceef2ad4248d62d) Thanks [@imolorhe](https://github.com/imolorhe)! - Applied `snippetCompletion` to property completions + +## 0.3.2 + +### Patch Changes + +- [#42](https://github.com/acao/codemirror-json-schema/pull/42) [`a08101a`](https://github.com/acao/codemirror-json-schema/commit/a08101a9fbae0979bc0cf11307102ce8bddd2572) Thanks [@acao](https://github.com/acao)! - simpler export patterns + +## 0.3.1 + +### Patch Changes + +- [#37](https://github.com/acao/codemirror-json-schema/pull/37) [`1220706`](https://github.com/acao/codemirror-json-schema/commit/12207063b8243caae814ec87b0c2dbb0ba7cddf6) Thanks [@acao](https://github.com/acao)! - - fix hover on undefined schema props + + - configure `above: true` for the hover tooltip, to have vscode-like behavior, and prevent z-index clash with completion on smaller viewports + +- [#36](https://github.com/acao/codemirror-json-schema/pull/36) [`23e5721`](https://github.com/acao/codemirror-json-schema/commit/23e572147a3b8d718d52761ee431186a8b297b9d) Thanks [@imolorhe](https://github.com/imolorhe)! - fixed autocompletion in object roots, etc, for json4 and json5 + +## 0.3.0 + +### Minor Changes + +- d4cfe11: improve autocompletion with support for allOf, anyOf, oneOf + +## 0.2.3 + +### Patch Changes + +- 69ab7be: Fix bug on p/npm/yarn install with postinstall + +## 0.2.2 + +### Patch Changes + +- 4e80f37: hover bugs with complex types #26 + +## 0.2.1 + +### Patch Changes + +- 0b34915: fix: hover format for anyOf + +## 0.2.0 + +### Minor Changes + +- 3a578e9: move everything codemirror related to a peer dependency. see readme for new install instructions + +## 0.1.2 + +### Patch Changes + +- d17f63f: fix readme + +## 0.1.1 + +### Patch Changes + +- 7f5af9d: Add formatting for complex types - oneOf, anyOf, allOf on hover + +## 0.1.0 + +### Minor Changes + +- 26bda14: add json5 support, simpler exports diff --git a/packages/codemirror-json-schema/LICENSE b/packages/codemirror-json-schema/LICENSE new file mode 100644 index 00000000..303bf944 --- /dev/null +++ b/packages/codemirror-json-schema/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Rikki Schulte + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +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 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. diff --git a/packages/codemirror-json-schema/README.md b/packages/codemirror-json-schema/README.md new file mode 100644 index 00000000..416c7ed4 --- /dev/null +++ b/packages/codemirror-json-schema/README.md @@ -0,0 +1,285 @@ +Codemirror 6 extensions that provide full [JSON Schema](https://json-schema.org/) support for `@codemirror/lang-json` & `codemirror-json5` language modes + + +npm + + +![screenshot of the examples with json4 and json5 support enabled](./dev/public/example.png) + +## Features + +This is now a full-featured library for json schema for `json`, `json5` and `yaml` as cm6 extensions! + +- ✅ lint validation messages from json schema +- ✅ autocompletion with insert text from json schema +- ✅ hover tooltips +- ✅ dynamic, per-editor-instance schemas using codemirror `StateField` and linting refresh +- ✅ markdown rendering for `schema.description` and custom `formatHover` and `formatError` configuration + +## Resources + +- [Changelog](./CHANGELOG.md) +- [Comprehensive example](https://github.com/acao/cm6-json-schema/blob/main/dev/index.ts) +- [API Docs](./docs/) + +## Usage + +To give you as much flexibility as possible, everything codemirror related is a peer or optional dependency + +Based on whether you want to support json4, json5 or both, you will need to install the relevant language mode for our library to use. + +### Breaking Changes: + +- 0.7.0 - this version introduces markdown rendering in place of returning html strings, so any usage of `formatHover` and/or `formatError` configuration will be passed to `markdown-it` which doesn't handle html by default. +- 0.5.0 - this breaking change only impacts those following the "custom usage" approach, it _does not_ effect users using the high level, "bundled" `jsonSchema()` or `json5Schema()` modes. See the custom usages below to learn how to use the new `stateExtensions` and `handleRefresh` exports. + +### json4 + +with `auto-install-peers true` or similar: + +``` +npm install --save @codemirror/lang-json codemirror-json-schema +``` + +without `auto-install-peers true`: + +``` +npm install --save @codemirror/lang-json codemirror-json-schema @codemirror/language @codemirror/lint @codemirror/view @codemirror/state @lezer/common +``` + +#### Minimal Usage + +This sets up `@codemirror/lang-json` and our extension for you. +If you'd like to have more control over the related configurations, see custom usage below + +```ts +import { EditorState } from "@codemirror/state"; +import { jsonSchema } from "codemirror-json-schema"; + +const schema = { + type: "object", + properties: { + example: { + type: "boolean", + }, + }, +}; + +const json5State = EditorState.create({ + doc: "{ example: true }", + extensions: [jsonSchema(schema)], +}); +``` + +#### Custom Usage + +This approach allows you to configure the json mode and parse linter, as well as our linter, hovers, etc more specifically. + +```ts +import { EditorState } from "@codemirror/state"; +import { linter } from "@codemirror/lint"; +import { hoverTooltip } from "@codemirror/view"; +import { json, jsonParseLinter, jsonLanguage } from "@codemirror/lang-json"; + +import { + jsonSchemaLinter, + jsonSchemaHover, + jsonCompletion, + stateExtensions, + handleRefresh +} from "codemirror-json-schema"; + +const schema = { + type: "object", + properties: { + example: { + type: "boolean", + }, + }, +}; + +const state = EditorState.create({ + doc: `{ "example": true }`, + extensions: [ + json(), + linter(jsonParseLinter(), { + // default is 750ms + delay: 300 + }), + linter(jsonSchemaLinter(), { + needsRefresh: handleRefresh, + }), + jsonLanguage.data.of({ + autocomplete: jsonCompletion(), + }), + hoverTooltip(jsonSchemaHover()), + stateExtensions(schema) + ]; +}) +``` + +### json5 + +with `auto-install-peers true` or similar: + +``` +npm install --save codemirror-json5 codemirror-json-schema +``` + +without `auto-install-peers true`: + +``` +npm install --save codemirror-json5 codemirror-json-schema @codemirror/language @codemirror/lint @codemirror/view @codemirror/state @lezer/common +``` + +#### Minimal Usage + +This sets up `codemirror-json5` mode for you. +If you'd like to have more control over the related configurations, see custom usage below + +```ts +import { EditorState } from "@codemirror/state"; +import { json5Schema } from "codemirror-json-schema/json5"; + +const schema = { + type: "object", + properties: { + example: { + type: "boolean", + }, + }, +}; + +const json5State = EditorState.create({ + doc: `{ + example: true, + // json5 is awesome! + }`, + extensions: [json5Schema(schema)], +}); +``` + +#### Custom Usage + +This approach allows you to configure the json5 mode and parse linter, as well as our linter, hovers, etc more specifically. + +```ts +import { EditorState } from "@codemirror/state"; +import { linter } from "@codemirror/lint"; +import { json5, json5ParseLinter, json5Language } from "codemirror-json5"; +import { + json5SchemaLinter, + json5SchemaHover, + json5Completion, +} from "codemirror-json-schema/json5"; +import { stateExtensions, handleRefresh } from "codemirror-json-schema"; + +const schema = { + type: "object", + properties: { + example: { + type: "boolean", + }, + }, +}; + +const json5State = EditorState.create({ + doc: `{ + example: true, + // json5 is awesome! + }`, + extensions: [ + json5(), + linter(json5ParseLinter(), { + // the default linting delay is 750ms + delay: 300, + }), + linter( + json5SchemaLinter({ + needsRefresh: handleRefresh, + }) + ), + hoverTooltip(json5SchemaHover()), + json5Language.data.of({ + autocomplete: json5Completion(), + }), + stateExtensions(schema), + ], +}); +``` + +### Dynamic Schema + +If you want to, you can provide schema dynamically, in several ways. +This works the same for either json or json5, using the underlying codemirror 6 StateFields, via the `updateSchema` method export. + +In this example + +- the initial schema state is empty +- schema is loaded dynamically based on user input +- the linting refresh will be handled automatically, because it's built into our bundled `jsonSchema()` and `json5Schema()` modes + +```ts +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +import { json5Schema } from "codemirror-json-schema/json5"; + +import { updateSchema } from "codemirror-json-schema"; + +const json5State = EditorState.create({ + doc: `{ + example: true, + // json5 is awesome! + }`, + // note: you can still provide initial + // schema when creating state + extensions: [json5Schema()], +}); + +const editor = new EditorView({ state: json5State }); + +const schemaSelect = document.getElementById("schema-selection"); + +schemaSelect!.onchange = async (e) => { + const val = e.target!.value!; + if (!val) { + return; + } + // parse the remote schema spec to json + const data = await ( + await fetch(`https://json.schemastore.org/${val}`) + ).json(); + // this will update the schema state field, in an editor specific way + updateSchema(editor, data); +}; +``` + +if you are using the "custom path" with this approach, you will need to configure linting refresh as well: + +```ts +import { linter } from "@codemirror/lint"; +import { json5SchemaLinter } from "codemirror-json-schema/json5"; +import { handleRefresh } from "codemirror-json-schema"; + +const state = EditorState.create({ + // ... + extensions: [ + linter(json5SchemaLinter(), { + needsRefresh: handleRefresh, + }) + ]; +} +``` + +## Current Constraints: + +- currently only tested with standard schemas using json4 spec. results may vary +- doesn't place cursor inside known insert text yet +- currently you can only override the texts and rendering of a hover. we plan to add the same for validation errors and autocomplete + +## Inspiration + +`monaco-json` and `monaco-yaml` both provide json schema features for json, cson and yaml, and we want the nascent codemirror 6 to have them as well! + +Also, json5 is slowly growing in usage, and it needs full language support for the browser! diff --git a/packages/codemirror-json-schema/package.json b/packages/codemirror-json-schema/package.json new file mode 100644 index 00000000..aa8b1fc9 --- /dev/null +++ b/packages/codemirror-json-schema/package.json @@ -0,0 +1,128 @@ +{ + "name": "@knocklabs/codemirror-json-schema", + "version": "0.8.1", + "description": "Codemirror 6 extensions that provide full JSONSchema support for @codemirror/lang-json and codemirror-json5", + "license": "MIT", + "author": "@knocklabs", + "contributors": [ + { + "name": "Samuel Imolorhe", + "url": "https://xkoji.dev/" + }, + { + "name": "Rikki Schulte", + "url": "https://rikki.dev" + } + ], + "keywords": [ + "codemirror", + "codemirror6", + "jsonschema", + "jsonschema-validation", + "json5", + "json", + "editor" + ], + "main": "dist/cjs/index.js", + "module": "dist/esm/index.mjs", + "types": "dist/types/index.d.ts", + "typings": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.mjs" + }, + "./json5": { + "types": "./dist/types/json5/index.d.ts", + "import": "./dist/esm/json5/index.mjs", + "require": "./dist/cjs/json5/index.js", + "default": "./dist/esm/json5/index.mjs" + }, + "./yaml": { + "types": "./dist/types/yaml/index.d.ts", + "import": "./dist/esm/yaml/index.mjs", + "require": "./dist/cjs/yaml/index.js", + "default": "./dist/esm/yaml/index.mjs" + } + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "clean": "rimraf dist", + "dev": "tsc && vite build --watch --emptyOutDir false", + "build": "yarn clean && yarn build:esm && yarn build:cjs", + "build:esm": "BUILD_TARGET=esm; vite build", + "build:cjs": "BUILD_TARGET=cjs; vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "format": "prettier \"src/**/*.{js,ts,tsx}\" --write", + "format:check": "prettier \"src/**/*.{js,ts,tsx}\" --check", + "type:check": "tsc --noEmit", + "coverage": "vitest run --coverage" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/knocklabs/javascript.git" + }, + "bugs": { + "url": "https://github.com/knocklabs/javascript/issues" + }, + "dependencies": { + "@sagold/json-pointer": "^5.1.1", + "@shikijs/markdown-it": "^1.22.2", + "best-effort-json-parser": "^1.1.2", + "json-schema": "^0.4.0", + "json-schema-library": "^9.3.5", + "loglevel": "^1.9.1", + "markdown-it": "^14.1.0", + "shiki": "^1.22.2", + "yaml": "^2.3.4" + }, + "optionalDependencies": { + "@codemirror/autocomplete": "^6.16.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-yaml": "^6.1.1", + "codemirror-json5": "^1.0.3", + "json5": "^2.2.3" + }, + "peerDependencies": { + "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.0", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.2.1" + }, + "devDependencies": { + "@codecov/vite-plugin": "^1.9.1", + "@codemirror/autocomplete": "^6.16.2", + "@codemirror/commands": "^6.6.0", + "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.0", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.2.1", + "@types/json-schema": "^7.0.12", + "@types/markdown-it": "^13.0.7", + "@types/node": "^24", + "@typescript-eslint/eslint-plugin": "^8.32.0", + "@typescript-eslint/parser": "^8.39.1", + "@vitest/coverage-v8": "^3.2.4", + "codemirror-json5": "^1.0.3", + "eslint": "^8.56.0", + "happy-dom": "^10.3.2", + "jsdom": "^27.1.0", + "json5": "^2.2.3", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vite-plugin-dts": "^4.5.0", + "vite-plugin-no-bundle": "^4.0.0", + "vitest": "^3.1.1" + } +} diff --git a/packages/codemirror-json-schema/src/__tests__/dynamic-schema.spec.ts b/packages/codemirror-json-schema/src/__tests__/dynamic-schema.spec.ts new file mode 100644 index 00000000..74012b7d --- /dev/null +++ b/packages/codemirror-json-schema/src/__tests__/dynamic-schema.spec.ts @@ -0,0 +1,84 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { JSONSchema7 } from "json-schema"; +import { describe, expect, it } from "vitest"; + +import { updateSchema } from "../features/state"; +import { jsonSchemaLinter } from "../features/validation"; +import { json5Schema } from "../json5/bundled"; +import { json5SchemaLinter } from "../json5/validation"; +import { jsonSchema } from "../json/bundled"; +import { yamlSchema } from "../yaml/bundled"; +import { yamlSchemaLinter } from "../yaml/validation"; + +type DynamicSchemaFixture = { + name: string; + extensions: (schema?: JSONSchema7) => unknown[]; + linter: () => (view: EditorView) => Array<{ message: string }>; + doc: string; +}; + +const schemaFoo: JSONSchema7 = { + type: "object", + required: ["foo"], + properties: { + foo: { type: "string" }, + }, +}; + +const schemaBar: JSONSchema7 = { + type: "object", + required: ["bar"], + properties: { + bar: { type: "number" }, + }, +}; + +const fixtures: DynamicSchemaFixture[] = [ + { + name: "json", + extensions: (schema) => jsonSchema(schema), + linter: () => jsonSchemaLinter(), + doc: "{}", + }, + { + name: "json5", + extensions: (schema) => json5Schema(schema), + linter: () => json5SchemaLinter(), + doc: "{ }", + }, + { + name: "yaml", + extensions: (schema) => yamlSchema(schema), + linter: () => yamlSchemaLinter(), + doc: "---\n{}\n", + }, +]; + +describe("dynamic schema updates (bundled modes)", () => { + it.each(fixtures)( + "$name: schema changes affect validation results", + ({ extensions, linter, doc }) => { + const state = EditorState.create({ + doc, + extensions: extensions(schemaFoo), + }); + + const view = new EditorView({ state }); + try { + const lint = linter(); + + const initial = lint(view); + expect(initial.length).toBeGreaterThan(0); + expect(initial.some((d) => d.message.includes("foo"))).toBe(true); + + updateSchema(view, schemaBar); + const afterUpdate = lint(view); + expect(afterUpdate.length).toBeGreaterThan(0); + expect(afterUpdate.some((d) => d.message.includes("bar"))).toBe(true); + } finally { + view.destroy(); + } + }, + ); +}); diff --git a/packages/codemirror-json-schema/src/constants.ts b/packages/codemirror-json-schema/src/constants.ts new file mode 100644 index 00000000..08881dc9 --- /dev/null +++ b/packages/codemirror-json-schema/src/constants.ts @@ -0,0 +1,59 @@ +export const TOKENS = { + STRING: "String", + NUMBER: "Number", + TRUE: "True", + FALSE: "False", + NULL: "Null", + OBJECT: "Object", + ARRAY: "Array", + PROPERTY: "Property", + PROPERTY_NAME: "PropertyName", + PROPERTY_COLON: "PropertyColon", // used in json5 grammar + ITEM: "Item", // used in yaml grammar + JSON_TEXT: "JsonText", + INVALID: "⚠", +} as const; + +// TODO: To ensure that the YAML tokens are always mapped correctly, +// we should change the TOKENS values to some other values and also create +// mappings for the JSON tokens, which will force us to update all the token mappings whenever there is a change. +export const YAML_TOKENS_MAPPING: Record< + string, + (typeof TOKENS)[keyof typeof TOKENS] +> = { + Pair: TOKENS.PROPERTY, + Key: TOKENS.PROPERTY_NAME, + BlockSequence: TOKENS.ARRAY, + BlockMapping: TOKENS.OBJECT, + FlowSequence: TOKENS.ARRAY, + FlowMapping: TOKENS.OBJECT, + QuotedLiteral: TOKENS.STRING, + Literal: TOKENS.STRING, // best guess + Stream: TOKENS.JSON_TEXT, + Document: TOKENS.OBJECT, +}; +export const JSON5_TOKENS_MAPPING: Record< + string, + (typeof TOKENS)[keyof typeof TOKENS] +> = { + File: TOKENS.JSON_TEXT, +}; + +export const PRIMITIVE_TYPES: string[] = [ + TOKENS.STRING, + TOKENS.NUMBER, + TOKENS.TRUE, + TOKENS.FALSE, + TOKENS.NULL, +]; +export const COMPLEX_TYPES: string[] = [ + TOKENS.OBJECT, + TOKENS.ARRAY, + TOKENS.ITEM, +]; + +export const MODES = { + JSON5: "json5", + JSON: "json4", + YAML: "yaml", +} as const; diff --git a/packages/codemirror-json-schema/src/features/__tests__/__fixtures__/schemas.ts b/packages/codemirror-json-schema/src/features/__tests__/__fixtures__/schemas.ts new file mode 100644 index 00000000..14fbc65e --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/__fixtures__/schemas.ts @@ -0,0 +1,254 @@ +import { JSONSchema7 } from "json-schema"; + +export const testSchema = { + type: "object", + properties: { + foo: { + type: "string", + }, + }, + required: ["foo"], + additionalProperties: false, +} as JSONSchema7; + +export const testSchema2 = { + type: "object", + properties: { + foo: { + type: "string", + }, + stringWithDefault: { + type: "string", + description: "a string with a default value", + default: "defaultString", + }, + bracedStringDefault: { + type: "string", + description: "a string with a default value containing braces", + default: "✨ A message from %{whom}: ✨", + }, + object: { + type: "object", + properties: { + foo: { + type: "string", + description: "an elegant string", + }, + }, + required: ["foo"], + additionalProperties: false, + }, + objectWithRef: { + $ref: "#/definitions/fancyObject", + }, + oneOfEg: { + description: "an example oneOf", + title: "oneOfEg", + oneOf: [{ type: "string" }, { type: "array" }, { type: "boolean" }], + }, + oneOfEg2: { + oneOf: [{ type: "string" }, { type: "array" }], + }, + oneOfObject: { + oneOf: [ + { $ref: "#/definitions/fancyObject" }, + { $ref: "#/definitions/fancyObject2" }, + ], + }, + arrayOfObjects: { + type: "array", + items: { + $ref: "#/definitions/fancyObject", + }, + }, + arrayOfOneOf: { + type: "array", + items: { + oneOf: [ + { $ref: "#/definitions/fancyObject" }, + { $ref: "#/definitions/fancyObject2" }, + ], + }, + }, + enum1: { + description: "an example enum with default bar", + enum: ["foo", "bar"], + default: "bar", + }, + enum2: { + description: "an example enum without default", + enum: ["foo", "bar"], + }, + booleanWithDefault: { + description: "an example boolean with default", + default: true, + type: "boolean", + }, + }, + required: ["foo", "object"], + additionalProperties: false, + definitions: { + fancyObject: { + type: "object", + properties: { + foo: { type: "string" }, + bar: { type: "number" }, + }, + additionalProperties: false, + }, + fancyObject2: { + type: "object", + properties: { + apple: { type: "string" }, + banana: { type: "number" }, + }, + additionalProperties: false, + }, + }, +} as JSONSchema7; + +export const testSchema3 = { + $ref: "#/definitions/fancyObject", + definitions: { + fancyObject: { + type: "object", + properties: { + foo: { type: "string" }, + bar: { type: "number" }, + }, + }, + }, +} as JSONSchema7; + +export const testSchema4 = { + allOf: [ + { + $ref: "#/definitions/fancyObject", + }, + ], + definitions: { + fancyObject: { + type: "object", + properties: { + foo: { type: "string" }, + bar: { type: "number" }, + }, + }, + }, +} as JSONSchema7; + +export const testSchemaConditionalProperties = { + type: "object", + properties: { + type: { + type: "string", + enum: ["Test_1", "Test_2"], + }, + props: { + type: "object", + }, + }, + allOf: [ + { + if: { + properties: { + type: { const: "Test_1" }, + }, + }, + then: { + properties: { + props: { + properties: { + test1Props: { type: "string" }, + }, + additionalProperties: false, + }, + }, + }, + }, + { + if: { + properties: { + type: { const: "Test_2" }, + }, + }, + then: { + properties: { + props: { + properties: { + test2Props: { type: "number" }, + }, + additionalProperties: false, + }, + }, + }, + }, + ], +} as JSONSchema7; + +export const testSchemaConditionalPropertiesOnSameObject = { + type: "object", + properties: { + type: { + type: "string", + enum: ["type1", "type2"], + }, + }, + allOf: [ + { + if: { + properties: { + type: { const: "type1" }, + }, + }, + then: { + properties: { + type1Prop: { type: "string" }, + commonEnum: { + enum: ["common1", "common2"], + }, + commonEnumWithDifferentValues: { + enum: ["type1Specific", "common"], + }, + }, + required: ["type1Prop", "commonEnum", "commonEnumWithDifferentValues"], + }, + }, + { + if: { + properties: { + type: { const: "type2" }, + }, + }, + then: { + properties: { + type2Prop: { type: "string" }, + commonEnum: { + enum: ["common1", "common2"], + }, + commonEnumWithDifferentValues: { + enum: ["type2Specific", "common"], + }, + }, + required: ["type2Prop", "commonEnum", "commonEnumWithDifferentValues"], + }, + }, + ], + unevaluatedProperties: false, + required: ["type"], +} as JSONSchema7; + +export const wrappedTestSchemaConditionalPropertiesOnSameObject = { + type: "object", + properties: { + original: testSchemaConditionalPropertiesOnSameObject, + }, + required: ["original"], +} as JSONSchema7; + +export const testSchemaArrayOfObjects = { + type: "array", + items: { + type: "object", + }, +} as JSONSchema7; diff --git a/packages/codemirror-json-schema/src/features/__tests__/__helpers__/completion.ts b/packages/codemirror-json-schema/src/features/__tests__/__helpers__/completion.ts new file mode 100644 index 00000000..ab85bc37 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/__helpers__/completion.ts @@ -0,0 +1,92 @@ +import { + Completion, + CompletionContext, + CompletionResult, + CompletionSource, +} from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { JSONSchema7 } from "json-schema"; +import { expect, vitest } from "vitest"; + +import { MODES } from "../../../constants"; +import { JSONMode } from "../../../types"; +import { testSchema2 } from "../__fixtures__/schemas"; + +import { getExtensions } from "./index"; + +vitest.mock("@codemirror/autocomplete", async () => { + const mod = await vitest.importActual< + typeof import("@codemirror/autocomplete") + >("@codemirror/autocomplete"); + return { + ...mod, + snippetCompletion(template: string, completion: Completion) { + const c = { + ...completion, + // pass the snippet template to the completion result + // to make it easier to test + TESTONLY_template: template, + }; + return mod.snippetCompletion(template, c); + }, + }; +}); + +type MockedCompletionResult = CompletionResult["options"][0] & { + template?: string; +}; + +export async function expectCompletion( + doc: string, + results: MockedCompletionResult[], + + conf: { + explicit?: boolean; + schema?: JSONSchema7; + mode?: JSONMode; + } = {}, +) { + const cur = doc.indexOf("|"); + const currentSchema = conf?.schema ?? testSchema2; + doc = doc.slice(0, cur) + doc.slice(cur + 1); + + const state = EditorState.create({ + doc, + selection: { anchor: cur }, + extensions: getExtensions(conf.mode ?? MODES.JSON, currentSchema), + }); + const _view = new EditorView({ state }); + + const result = await state.languageDataAt( + "autocomplete", + cur, + )[0](new CompletionContext(state, cur, !!conf.explicit)); + if (!result) { + return expect(result).toEqual(results); + } + const filteredResults = result.options.map((item) => { + const infoValue = + typeof item.info === "function" + ? ((item.info(item) as HTMLElement).textContent ?? "") + : item.info; + + const info = + infoValue === undefined && item.type === "property" ? "" : infoValue; + + const apply = typeof item.apply === "string" ? item.apply : undefined; + + // @ts-expect-error -- set by our vitest mock for easier assertions + const template = item?.TESTONLY_template as string | undefined; + + return { + label: item.label, + type: item.type, + detail: item.detail, + ...(info !== undefined ? { info } : {}), + ...(apply !== undefined ? { apply } : {}), + ...(template !== undefined ? { template } : {}), + }; + }); + expect(filteredResults).toEqual(results); +} diff --git a/packages/codemirror-json-schema/src/features/__tests__/__helpers__/index.ts b/packages/codemirror-json-schema/src/features/__tests__/__helpers__/index.ts new file mode 100644 index 00000000..ec535752 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/__helpers__/index.ts @@ -0,0 +1,17 @@ +import { JSONMode } from "../../../types"; +import { MODES } from "../../../constants"; +import { JSONSchema7 } from "json-schema"; +import { jsonSchema } from "../../../json/bundled"; +import { json5Schema } from "../../../json5/bundled"; +import { yamlSchema } from "../../../yaml/bundled"; + +export const getExtensions = (mode: JSONMode, schema?: JSONSchema7) => { + switch (mode) { + case MODES.JSON: + return jsonSchema(schema); + case MODES.JSON5: + return json5Schema(schema); + case MODES.YAML: + return yamlSchema(schema); + } +}; diff --git a/packages/codemirror-json-schema/src/features/__tests__/json-completion.spec.ts b/packages/codemirror-json-schema/src/features/__tests__/json-completion.spec.ts new file mode 100644 index 00000000..03ea3d85 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/json-completion.spec.ts @@ -0,0 +1,990 @@ +import { describe, it } from "vitest"; + +import { expectCompletion } from "./__helpers__/completion"; +import { MODES } from "../../constants"; +import { + testSchema3, + testSchema4, + testSchemaConditionalProperties, + testSchemaConditionalPropertiesOnSameObject, + wrappedTestSchemaConditionalPropertiesOnSameObject, +} from "./__fixtures__/schemas"; + +describe.each([ + { + name: "return completion data for simple types", + mode: MODES.JSON, + docs: ['{ "f| }', "{ f| }"], + expectedResults: [ + { + label: "foo", + type: "property", + detail: "string", + info: "", + template: '"foo": "#{}"', + }, + ], + }, + { + name: "return completion data for simple types (multiple)", + mode: MODES.JSON, + docs: ['{ "one| }'], + expectedResults: [ + { + label: "oneOfEg", + type: "property", + detail: "", + info: "an example oneOf", + template: '"oneOfEg": #{}', + }, + { + label: "oneOfEg2", + type: "property", + detail: "", + info: "", + template: '"oneOfEg2": #{}', + }, + { + detail: "", + info: "", + label: "oneOfObject", + template: '"oneOfObject": #{}', + type: "property", + }, + ], + }, + { + name: "include defaults for string when available", + mode: MODES.JSON, + docs: ['{ "stringWithDefault| }'], + expectedResults: [ + { + label: "stringWithDefault", + type: "property", + detail: "string", + info: "a string with a default value", + template: '"stringWithDefault": "${defaultString}"', + }, + ], + }, + { + name: "include defaults for string with braces", + mode: MODES.JSON, + docs: ['{ "bracedStringDefault| }'], + expectedResults: [ + { + label: "bracedStringDefault", + type: "property", + detail: "string", + info: "a string with a default value containing braces", + template: '"bracedStringDefault": "${✨ A message from %{whom\\}: ✨}"', + }, + ], + }, + { + name: "include defaults for enum when available", + mode: MODES.JSON, + docs: ['{ "en| }'], + expectedResults: [ + { + label: "enum1", + type: "property", + detail: "", + info: "an example enum with default bar", + template: '"enum1": "${bar}"', + }, + { + label: "enum2", + type: "property", + detail: "", + info: "an example enum without default", + template: '"enum2": #{}', + }, + ], + }, + { + name: "include value completions for enum", + mode: MODES.JSON, + docs: ['{ "enum1": "|" }'], + expectedResults: [ + { + label: "foo", + apply: '"foo"', + info: "an example enum with default bar", + }, + { + label: "bar", + apply: '"bar"', + detail: "Default value", + }, + ], + }, + { + name: "include value completions for enum with filter", + mode: MODES.JSON, + docs: ['{ "enum1": "f|" }'], + expectedResults: [ + { + label: "foo", + apply: '"foo"', + info: "an example enum with default bar", + }, + ], + }, + { + name: "include defaults for boolean when available", + mode: MODES.JSON, + docs: ['{ "booleanW| }'], + expectedResults: [ + { + type: "property", + detail: "boolean", + info: "an example boolean with default", + label: "booleanWithDefault", + template: '"booleanWithDefault": ${true}', + }, + ], + }, + // TODO: should provide true/false completions. Issue is the detected node is the Property node, which contains the property name and value. The prefix for the autocompletion therefore contains the property name, so it never matches the results + // { + // name: "include value completions for boolean", + // mode: MODES.JSON, + // docs: ['{ "booleanWithDefault": | }'], + // expectedResults: [ + // { + // detail: "boolean", + // label: "true", + // }, + // { + // detail: "boolean", + // label: "false", + // }, + // ], + // }, + { + name: "include insert text for objects", + mode: MODES.JSON, + docs: ['{ "ob| }'], + expectedResults: [ + { + type: "property", + detail: "object", + info: "", + label: "object", + template: '"object": {#{}}', + }, + { + template: '"objectWithRef": {#{}}', + label: "objectWithRef", + detail: "", + info: "", + type: "property", + }, + ], + }, + // this has regressed for json4 only for some reason + { + name: "include insert text for nested object properties", + mode: MODES.JSON, + docs: ['{ "object": { "|" } }', '{ "object": { "| } }'], + expectedResults: [ + { + detail: "string", + info: "an elegant string", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + ], + }, + { + name: "include insert text for nested object properties with filter", + mode: MODES.JSON, + docs: ['{ "object": { "f|" } }'], + expectedResults: [ + { + detail: "string", + info: "an elegant string", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + ], + }, + { + name: "autocomplete for oneOf with nested definitions and filter", + mode: MODES.JSON, + docs: ['{ "oneOfObject": { "f|" } }'], + expectedResults: [ + { + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + ], + }, + { + name: "autocomplete for oneOf with nested definitions", + mode: MODES.JSON, + docs: ['{ "oneOfObject": { "|" } }'], + expectedResults: [ + { + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + { + detail: "number", + info: "", + label: "bar", + template: '"bar": #{0}', + type: "property", + }, + { + detail: "string", + info: "", + label: "apple", + template: '"apple": "#{}"', + type: "property", + }, + { + detail: "number", + info: "", + label: "banana", + template: '"banana": #{0}', + type: "property", + }, + ], + }, + // TODO: completion for array of objects should enhance the template + { + name: "autocomplete for array of objects with filter", + mode: MODES.JSON, + docs: ['{ "arrayOfObjects": [ { "f|" } ] }'], + expectedResults: [ + { + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + ], + }, + { + name: "autocomplete for array of objects with items", + mode: MODES.JSON, + docs: ['{ "array| }'], + expectedResults: [ + { + type: "property", + detail: "array", + info: "", + label: "arrayOfObjects", + template: '"arrayOfObjects": [#{}]', + }, + { + type: "property", + detail: "array", + info: "", + label: "arrayOfOneOf", + template: '"arrayOfOneOf": [#{}]', + }, + ], + }, + { + name: "autocomplete for array of objects with items (array of objects)", + mode: MODES.JSON, + docs: ['{ "arrayOfObjects": [ { "|" } ] }'], + expectedResults: [ + { + detail: "string", + info: "", + label: "foo", + template: `"foo": "#{}"`, + type: "property", + }, + { + detail: "number", + info: "", + label: "bar", + template: '"bar": #{0}', + type: "property", + }, + ], + }, + { + name: "autocomplete for array of objects with items (array of oneOf)", + mode: MODES.JSON, + docs: ['{ "arrayOfOneOf": [ { "|" } ] }'], + expectedResults: [ + { + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + { + detail: "number", + info: "", + label: "bar", + template: '"bar": #{0}', + type: "property", + }, + { + detail: "string", + info: "", + label: "apple", + template: '"apple": "#{}"', + type: "property", + }, + { + detail: "number", + info: "", + label: "banana", + template: '"banana": #{0}', + type: "property", + }, + ], + }, + { + name: "autocomplete for a schema with top level $ref", + mode: MODES.JSON, + docs: ['{ "| }'], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + }, + { + type: "property", + detail: "number", + info: "", + label: "bar", + template: '"bar": #{0}', + }, + ], + schema: testSchema3, + }, + { + name: "autocomplete for a schema with top level complex type", + mode: MODES.JSON, + docs: ['{ "| }'], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + }, + { + type: "property", + detail: "number", + info: "", + label: "bar", + template: '"bar": #{0}', + }, + ], + schema: testSchema4, + }, + { + name: "autocomplete for a schema with conditional properties", + mode: MODES.JSON, + docs: ['{ "type": "Test_1", "props": { t| }}'], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "test1Props", + template: '"test1Props": "#{}"', + }, + ], + schema: testSchemaConditionalProperties, + }, + // JSON5 + { + name: "return bare property key when no quotes are used", + mode: MODES.JSON5, + docs: ["{ f| }", "{ f| }"], + expectedResults: [ + { + label: "foo", + type: "property", + detail: "string", + info: "", + template: "foo: '#{}'", + }, + ], + }, + { + name: "return template for '", + mode: MODES.JSON5, + docs: ["{ 'one|' }"], + expectedResults: [ + { + label: "oneOfEg", + type: "property", + detail: "", + info: "an example oneOf", + template: "'oneOfEg': #{}", + }, + { + label: "oneOfEg2", + type: "property", + detail: "", + info: "", + template: "'oneOfEg2': #{}", + }, + { + detail: "", + info: "", + label: "oneOfObject", + template: "'oneOfObject': #{}", + type: "property", + }, + ], + }, + { + name: "include defaults for enum when available", + mode: MODES.JSON5, + docs: ["{ en| }"], + expectedResults: [ + { + label: "enum1", + type: "property", + detail: "", + info: "an example enum with default bar", + template: "enum1: '${bar}'", + }, + { + label: "enum2", + type: "property", + detail: "", + info: "an example enum without default", + template: "enum2: #{}", + }, + ], + }, + // TODO: should autocomplete for boolean values + // { + // name: "include value completions for boolean", + // mode: MODES.JSON5, + // docs: ['{ "booleanWithDefault": | }'], + // expectedResults: [ + // { + // detail: "boolean", + // label: "true", + // }, + // { + // detail: "boolean", + // label: "false", + // }, + // ], + // }, + { + name: "provide enum values on completion", + mode: MODES.JSON5, + docs: ["{ enum1: '| }"], + expectedResults: [ + { + label: "foo", + apply: "'foo'", + info: "an example enum with default bar", + }, + { + label: "bar", + apply: "'bar'", + detail: "Default value", + }, + ], + }, + { + name: "include defaults for boolean when available", + mode: MODES.JSON5, + docs: ["{ booleanW| }"], + expectedResults: [ + { + type: "property", + detail: "boolean", + info: "an example boolean with default", + label: "booleanWithDefault", + template: "booleanWithDefault: ${true}", + }, + ], + }, + { + name: "include insert text for nested object properties", + mode: MODES.JSON5, + docs: ["{ object: { f| }"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "an elegant string", + label: "foo", + template: "foo: '#{}'", + }, + ], + }, + { + name: "include insert text for nested oneOf object properties with a single quote", + mode: MODES.JSON5, + docs: ["{ oneOfObject: { '| }"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "foo", + template: "'foo': '#{}'", + }, + { + type: "property", + detail: "number", + info: "", + label: "bar", + template: "'bar': #{0}", + }, + { + type: "property", + detail: "string", + info: "", + label: "apple", + template: "'apple': '#{}'", + }, + { + type: "property", + detail: "number", + info: "", + label: "banana", + template: "'banana': #{0}", + }, + ], + }, + { + name: "autocomplete for a schema with conditional properties", + mode: MODES.JSON5, + docs: ["{ type: 'Test_1', props: { t| }}"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "test1Props", + template: "test1Props: '#{}'", + }, + ], + schema: testSchemaConditionalProperties, + }, + // YAML + { + name: "return completion data for simple types", + mode: MODES.YAML, + docs: ["f|"], + expectedResults: [ + { + label: "foo", + type: "property", + detail: "string", + info: "", + template: "foo: #{}", + }, + ], + }, + { + name: "return completion data for simple types (multiple)", + mode: MODES.YAML, + docs: ["one|"], + expectedResults: [ + { + label: "oneOfEg", + type: "property", + detail: "", + info: "an example oneOf", + template: "oneOfEg: #{}", + }, + { + label: "oneOfEg2", + type: "property", + detail: "", + info: "", + template: "oneOfEg2: #{}", + }, + { + detail: "", + info: "", + label: "oneOfObject", + template: "oneOfObject: #{}", + type: "property", + }, + ], + }, + { + name: "include defaults for enum when available", + mode: MODES.YAML, + docs: ["en|"], + expectedResults: [ + { + label: "enum1", + type: "property", + detail: "", + info: "an example enum with default bar", + template: "enum1: ${bar}", + }, + { + label: "enum2", + type: "property", + detail: "", + info: "an example enum without default", + template: "enum2: #{}", + }, + ], + }, + { + name: "include value completions for enum", + mode: MODES.YAML, + docs: ["enum1: f|"], + expectedResults: [ + { + label: "foo", + apply: "foo", + info: "an example enum with default bar", + }, + ], + }, + { + name: "include defaults for boolean when available", + mode: MODES.YAML, + docs: ["booleanW|"], + expectedResults: [ + { + type: "property", + detail: "boolean", + info: "an example boolean with default", + label: "booleanWithDefault", + template: "booleanWithDefault: ${true}", + }, + ], + }, + { + name: "include insert text for objects", + mode: MODES.YAML, + docs: ["ob|"], + expectedResults: [ + { + type: "property", + detail: "object", + info: "", + label: "object", + template: "object: #{}", + }, + { + template: "objectWithRef: #{}", + label: "objectWithRef", + detail: "", + info: "", + type: "property", + }, + ], + }, + { + name: "include insert text for nested object properties", + mode: MODES.YAML, + docs: ["object: { f| }"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "an elegant string", + label: "foo", + template: "foo: #{}", + }, + ], + }, + { + name: "include insert text for nested oneOf object properties", + mode: MODES.YAML, + docs: ["oneOfObject: { b| }"], + expectedResults: [ + { + type: "property", + detail: "number", + info: "", + label: "bar", + template: "bar: #{0}", + }, + { + type: "property", + detail: "number", + info: "", + label: "banana", + template: "banana: #{0}", + }, + ], + }, + { + name: "autocomplete for array of objects with items", + mode: MODES.YAML, + docs: ["array|"], + expectedResults: [ + { + type: "property", + detail: "array", + info: "", + label: "arrayOfObjects", + template: "arrayOfObjects: [#{}]", + }, + { + type: "property", + detail: "array", + info: "", + label: "arrayOfOneOf", + template: "arrayOfOneOf: [#{}]", + }, + ], + }, + { + name: "autocomplete for array of objects with items (array of objects)", + mode: MODES.YAML, + docs: ["arrayOfObjects: [ { f| } ]"], + expectedResults: [ + { + detail: "string", + info: "", + label: "foo", + template: "foo: #{}", + type: "property", + }, + ], + }, + { + name: "autocomplete for array of objects with items (array of oneOf)", + mode: MODES.YAML, + docs: ["arrayOfOneOf: [ { b| } ]"], + expectedResults: [ + { + detail: "number", + info: "", + label: "bar", + template: "bar: #{0}", + type: "property", + }, + { + detail: "number", + info: "", + label: "banana", + template: "banana: #{0}", + type: "property", + }, + ], + }, + { + name: "autocomplete for a schema with conditional properties", + mode: MODES.YAML, + docs: ["type: Test_1\nprops: { t| }"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "test1Props", + template: "test1Props: #{}", + }, + ], + schema: testSchemaConditionalProperties, + }, +])("jsonCompletion", ({ name, docs, mode, expectedResults, schema }) => { + it.each(docs)(`${name} (mode: ${mode})`, async (doc) => { + await expectCompletion(doc, expectedResults, { mode, schema }); + }); +}); + +describe.each([ + { + name: "newPartialProp for specific type", + mode: MODES.JSON5, + docs: ["{ type: 'type1', t| }"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "type1Prop", + template: "type1Prop: '#{}'", + }, + ], + schema: testSchemaConditionalPropertiesOnSameObject, + }, + { + name: "newEmptyPropInQuotes", + mode: MODES.JSON5, + docs: [`{ type: 'type1', "|" }`], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "type1Prop", + template: `"type1Prop": '#{}'`, + }, + { + type: "property", + detail: "", + info: "", + label: "commonEnum", + template: `"commonEnum": #{}`, + }, + { + type: "property", + detail: "", + info: "", + label: "commonEnumWithDifferentValues", + template: `"commonEnumWithDifferentValues": #{}`, + }, + ], + schema: testSchemaConditionalPropertiesOnSameObject, + }, + { + name: "type-specific enum values", + mode: MODES.JSON5, + docs: [`{ type: 'type1', "commonEnumWithDifferentValues": "|" }`], + expectedResults: [ + { + label: "type1Specific", + apply: `'type1Specific'`, + // info: "", + }, + { + label: "common", + apply: `'common'`, + // info: "", + }, + ], + schema: testSchemaConditionalPropertiesOnSameObject, + }, + { + name: "type-specific enum values - type2", + mode: MODES.JSON5, + docs: [`{ type: 'type2', "commonEnumWithDifferentValues": "|" }`], + expectedResults: [ + { + label: "type2Specific", + apply: `'type2Specific'`, + // info: "", + }, + { + label: "common", + apply: `'common'`, + // info: "", + }, + ], + schema: testSchemaConditionalPropertiesOnSameObject, + }, + { + name: "allow changing type afterwards to anything", + mode: MODES.JSON5, + docs: ["{ type: '|', type1Prop: 'bla' }"], + expectedResults: [ + { + label: "type1", + apply: "'type1'", + type: "string", + }, + { + label: "type2", + apply: "'type2'", + type: "string", + }, + ], + schema: testSchemaConditionalPropertiesOnSameObject, + }, + { + name: "suggests all possible properties if discriminator is not specified yet", + mode: MODES.JSON5, + docs: [`{ "|" }`], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "type", + template: `"type": #{}`, + }, + { + type: "property", + detail: "string", + info: "", + label: "type1Prop", + template: `"type1Prop": '#{}'`, + }, + { + type: "property", + detail: "", + info: "", + label: "commonEnum", + template: `"commonEnum": #{}`, + }, + { + type: "property", + detail: "", + info: "", + label: "commonEnumWithDifferentValues", + template: `"commonEnumWithDifferentValues": #{}`, + }, + { + type: "property", + detail: "string", + info: "", + label: "type2Prop", + template: `"type2Prop": '#{}'`, + }, + ], + schema: testSchemaConditionalPropertiesOnSameObject, + }, +])( + "jsonCompletionFor-testSchemaConditionalPropertiesOnSameObject", + ({ name, docs, mode, expectedResults, schema }) => { + it.each(docs)(`${name} (mode: ${mode})`, async (doc) => { + // if (name === 'autocomplete for array of objects with items (array of objects)') { + await expectCompletion(doc, expectedResults, { mode, schema }); + // } + }); + }, +); + +describe.each([ + { + name: "newProp", + mode: MODES.JSON5, + docs: ["{ original: { type: 'type1', t| }, }"], + expectedResults: [ + { + type: "property", + detail: "string", + info: "", + label: "type1Prop", + template: "type1Prop: '#{}'", + }, + ], + schema: wrappedTestSchemaConditionalPropertiesOnSameObject, + }, +])( + "jsonCompletionFor-wrappedTestSchemaConditionalPropertiesOnSameObject", + ({ name, docs, mode, expectedResults, schema }) => { + it.each(docs)(`${name} (mode: ${mode})`, async (doc) => { + // if (name === 'autocomplete for array of objects with filter') { + await expectCompletion(doc, expectedResults, { mode, schema }); + // } + }); + }, +); diff --git a/packages/codemirror-json-schema/src/features/__tests__/json-hover.spec.ts b/packages/codemirror-json-schema/src/features/__tests__/json-hover.spec.ts new file mode 100644 index 00000000..437fb00f --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/json-hover.spec.ts @@ -0,0 +1,221 @@ +import { EditorView } from "@codemirror/view"; +import { JSONSchema7 } from "json-schema"; +import { Draft07 } from "json-schema-library"; +import { describe, expect, it } from "vitest"; + +import { MODES } from "../../constants"; +import { JSONMode } from "../../types"; +import { FoundCursorData, JSONHover } from "../hover"; + +import { testSchema, testSchema2 } from "./__fixtures__/schemas"; +import { getExtensions } from "./__helpers__/index"; + +const getHoverData = ( + jsonString: string, + pos: number, + mode: JSONMode, + schema?: JSONSchema7, +) => { + const view = new EditorView({ + doc: jsonString, + extensions: [getExtensions(mode, schema ?? testSchema)], + }); + return new JSONHover({ mode }).getDataForCursor(view, pos, 1); +}; + +const getHoverResult = async ( + jsonString: string, + pos: number, + mode: JSONMode, + schema?: JSONSchema7, +) => { + const view = new EditorView({ + doc: jsonString, + extensions: [getExtensions(mode, schema ?? testSchema)], + }); + const hoverResult = await new JSONHover({ mode }).doHover(view, pos, 1); + return hoverResult; +}; + +const getHoverTexts = async ( + jsonString: string, + pos: number, + mode: JSONMode, + schema?: JSONSchema7, +) => { + const view = new EditorView({ + doc: jsonString, + extensions: [getExtensions(mode, schema ?? testSchema)], + }); + const hover = new JSONHover({ mode }); + const data = hover.getDataForCursor(view, pos, 1) as FoundCursorData; + const hoverResult = hover.getHoverTexts( + data, + new Draft07({ schema: schema ?? testSchema }), + ); + return hoverResult; +}; + +describe("JSONHover#getDataForCursor", () => { + it.each([ + { + mode: MODES.JSON, + doc: '{"object": { "foo": true }, "bar": 123}', + pos: 14, + schema: testSchema2, + expected: { + pointer: "/object/foo", + schema: { + description: "an elegant string", + type: "string", + }, + }, + }, + { + mode: MODES.JSON5, + doc: "{object: { foo: true }, bar: 123}", + pos: 11, + schema: testSchema2, + expected: { + pointer: "/object/foo", + schema: { + description: "an elegant string", + type: "string", + }, + }, + }, + { + mode: MODES.YAML, + doc: `--- +object: + foo: true +bar: 123 +`, + pos: 14, + schema: testSchema2, + expected: { + pointer: "/object/foo", + schema: { + description: "an elegant string", + type: "string", + }, + }, + }, + ])( + "should return schema descriptions as expected (mode: $mode)", + ({ mode, doc, pos, schema, expected }) => { + expect(getHoverData(doc, pos, mode, schema)).toEqual(expected); + }, + ); +}); + +describe("JSONHover#getHoverTexts", () => { + it.each([ + { + name: "oneOf despite invalid values", + mode: MODES.JSON, + doc: '{"oneOfEg": { "foo": true }, "bar": 123}', + pos: 3, + schema: testSchema2, + expected: { + message: "an example oneOf", + typeInfo: "oneOf: `string`, `array`, or `boolean`", + }, + }, + { + name: "oneOf with valid values", + mode: MODES.JSON, + doc: '{"oneOfEg": { "foo": "example" }, "bar": 123}', + pos: 3, + schema: testSchema2, + expected: { + message: "an example oneOf", + typeInfo: "oneOf: `string`, `array`, or `boolean`", + }, + }, + { + name: "oneOf with refs", + mode: MODES.JSON, + doc: '{ "oneOfObject": }', + pos: 5, + schema: testSchema2, + expected: { + message: null, + typeInfo: + "oneOf: `#/definitions/fancyObject` or `#/definitions/fancyObject2`", + }, + }, + { + name: "single object with ref", + mode: MODES.JSON, + doc: '{ "objectWithRef": }', + pos: 5, + schema: testSchema2, + expected: { + message: null, + typeInfo: "object", + }, + }, + ])( + "should return hover texts as expected ($name, mode: $mode)", + async ({ mode, doc, pos, schema, expected }) => { + expect(await getHoverTexts(doc, pos, mode, schema)).toEqual(expected); + }, + ); +}); + +describe("JSONHover#doHover", () => { + it.each([ + { + name: "", + mode: MODES.JSON, + doc: '{"object": { "foo": true }, "bar": 123}', + pos: 14, + schema: testSchema2, + expected: { + arrow: true, + end: 14, + pos: 14, + above: true, + create: expect.any(Function), + }, + expectedHTMLContents: [ + `cm6-json-schema-hover--description`, + `

an elegant string

`, + `cm6-json-schema-hover--code-wrapper`, + `cm6-json-schema-hover--code`, + `

string

`, + ], + }, + { + name: "with no description", + mode: MODES.JSON, + doc: '{ "foo": true }', + pos: 4, + schema: testSchema, + expected: { + arrow: true, + end: 4, + pos: 4, + above: true, + create: expect.any(Function), + }, + expectedHTMLContents: [ + "cm6-json-schema-hover--code-wrapper", + "cm6-json-schema-hover--code", + "string

", + ], + }, + ])( + "should return Tooltip data ($name, mode: $mode)", + async ({ mode, doc, pos, schema, expected, expectedHTMLContents }) => { + const hoverResult = await getHoverResult(doc, pos, mode, schema); + expect(hoverResult).toEqual(expected); + const hoverEl = hoverResult?.create(new EditorView({})).dom; + const html = hoverEl?.outerHTML ?? ""; + expectedHTMLContents.forEach((content) => { + expect(html).toContain(content); + }); + }, + ); +}); diff --git a/packages/codemirror-json-schema/src/features/__tests__/json-validation.spec.ts b/packages/codemirror-json-schema/src/features/__tests__/json-validation.spec.ts new file mode 100644 index 00000000..16577953 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/json-validation.spec.ts @@ -0,0 +1,429 @@ +import { JSONSchema7 } from "json-schema"; +import { JSONValidation } from "../validation"; +import type { Diagnostic } from "@codemirror/lint"; +import { describe, it, expect } from "vitest"; +import { EditorView } from "@codemirror/view"; + +import { + testSchema, + testSchema2, + testSchemaArrayOfObjects, +} from "./__fixtures__/schemas"; +import { JSONMode } from "../../types"; +import { getExtensions } from "./__helpers__/index"; +import { MODES } from "../../constants"; + +const getErrors = ( + jsonString: string, + mode: JSONMode, + schema?: JSONSchema7, +) => { + const view = new EditorView({ + doc: jsonString, + extensions: [getExtensions(mode, schema ?? testSchema)], + }); + return new JSONValidation({ mode }).doValidation(view); +}; + +const common = { + severity: "error" as Diagnostic["severity"], + source: "json-schema", +}; + +const expectErrors = ( + jsonString: string, + errors: [from: number | undefined, to: number | undefined, message: string][], + mode: JSONMode, + schema?: JSONSchema7, +) => { + const filteredErrors = getErrors(jsonString, mode, schema).map( + ({ renderMessage, ...error }) => error, + ); + expect(filteredErrors).toEqual( + errors.map(([from, to, message]) => ({ ...common, from, to, message })), + ); +}; + +describe("json-validation", () => { + const jsonSuite = [ + { + name: "provide range for a value error", + mode: MODES.JSON, + doc: '{"foo": 123}', + errors: [ + { + from: 8, + to: 11, + message: "Expected `string` but received `number`", + }, + ], + }, + { + name: "provide range for an unknown key error", + mode: MODES.JSON, + doc: '{"foo": "example", "bar": 123}', + errors: [ + { + from: 19, + to: 24, + message: "Additional property `bar` is not allowed", + }, + ], + }, + { + name: "can handle invalid json", + mode: MODES.JSON, + doc: '{"foo": "example" "bar": 123}', + // TODO: we don't have a best effort parser for YAML yet so this test will fail + skipYaml: true, + errors: [ + { + from: 18, + message: "Additional property `bar` is not allowed", + to: 23, + }, + ], + }, + { + name: "provide range for invalid multiline json", + mode: MODES.JSON, + doc: `{ + "foo": "example", + "bar": "something else" + }`, + errors: [ + { + from: 32, + to: 37, + message: "Additional property `bar` is not allowed", + }, + ], + }, + { + name: "provide formatted error message when required fields are missing", + mode: MODES.JSON, + doc: `{ + "foo": "example", + "object": {} + }`, + errors: [ + { + from: 46, + to: 48, + message: "The required property `foo` is missing at `object`", + }, + ], + schema: testSchema2, + }, + { + name: "provide formatted error message for oneOf fields with more than 2 items", + mode: MODES.JSON, + doc: `{ + "foo": "example", + "object": { "foo": "true" }, + "oneOfEg": 123 + }`, + errors: [ + { + from: 80, + to: 83, + message: "Expected one of `string`, `array`, or `boolean`", + }, + ], + schema: testSchema2, + }, + { + name: "provide formatted error message for oneOf fields with less than 2 items", + mode: MODES.JSON, + doc: `{ + "foo": "example", + "object": { "foo": "true" }, + "oneOfEg2": 123 + }`, + errors: [ + { + from: 81, + to: 84, + message: "Expected one of `string` or `array`", + }, + ], + schema: testSchema2, + }, + { + name: "reject a single object when schema expects an array", + mode: MODES.JSON, + doc: '{ "name": "John" }', + errors: [ + { + from: 0, + to: 0, + message: "Expected `array` but received `object`", + }, + ], + schema: testSchemaArrayOfObjects, + }, + { + name: "reject a boolean when schema expects an array", + mode: MODES.JSON, + doc: "true", + errors: [ + { + from: 0, + to: 0, + message: "Expected `array` but received `boolean`", + }, + ], + schema: testSchemaArrayOfObjects, + }, + { + name: "reject a string when schema expects an array", + mode: MODES.JSON, + doc: '"example"', + errors: [ + { + from: 0, + to: 0, + message: "Expected `array` but received `string`", + }, + ], + schema: testSchemaArrayOfObjects, + }, + { + name: "reject a number when schema expects an array", + mode: MODES.JSON, + doc: "123", + errors: [ + { + from: 0, + to: 0, + message: "Expected `array` but received `number`", + }, + ], + schema: testSchemaArrayOfObjects, + }, + { + name: "can handle an array of objects", + mode: MODES.JSON, + doc: '[{"name": "John"}, {"name": "Jane"}]', + errors: [], + schema: testSchemaArrayOfObjects, + }, + ]; + it.each([ + ...jsonSuite, + // JSON5 + { + name: "provide range for a value error", + mode: MODES.JSON5, + doc: "{foo: 123}", + errors: [ + { + from: 6, + to: 9, + message: "Expected `string` but received `number`", + }, + ], + }, + { + name: "provide range for an unknown key error", + mode: MODES.JSON5, + doc: "{foo: 'example', bar: 123}", + errors: [ + { + from: 17, + to: 20, + message: "Additional property `bar` is not allowed", + }, + ], + }, + { + name: "can handle invalid json", + mode: MODES.JSON5, + doc: "{foo: 'example' 'bar': 123}", + errors: [ + { + from: 16, + message: "Additional property `bar` is not allowed", + to: 21, + }, + ], + }, + { + name: "provide range for invalid multiline json", + mode: MODES.JSON5, + doc: `{ + foo: 'example', + bar: 'something else' + }`, + errors: [ + { + from: 30, + to: 33, + message: "Additional property `bar` is not allowed", + }, + ], + }, + { + name: "provide formatted error message when required fields are missing", + mode: MODES.JSON5, + doc: `{ + foo: 'example', + object: {} + }`, + errors: [ + { + from: 42, + to: 44, + message: "The required property `foo` is missing at `object`", + }, + ], + schema: testSchema2, + }, + { + name: "provide formatted error message for oneOf fields with more than 2 items", + mode: MODES.JSON5, + doc: `{ + foo: 'example', + object: { foo: 'true' }, + oneOfEg: 123 + }`, + errors: [ + { + from: 72, + to: 75, + message: "Expected one of `string`, `array`, or `boolean`", + }, + ], + schema: testSchema2, + }, + { + name: "provide formatted error message for oneOf fields with less than 2 items", + mode: MODES.JSON5, + doc: `{ + foo: 'example', + object: { foo: 'true' }, + oneOfEg2: 123 + }`, + errors: [ + { + from: 73, + to: 76, + message: "Expected one of `string` or `array`", + }, + ], + schema: testSchema2, + }, + // YAML + ...jsonSuite + .map((t) => (!t.skipYaml ? { ...t, mode: MODES.YAML } : null)) + .filter((x): x is Exclude => !!x), + { + name: "provide range for a value error", + mode: MODES.YAML, + doc: "foo: 123", + errors: [ + { + from: 5, + to: 8, + message: "Expected `string` but received `number`", + }, + ], + }, + { + name: "provide range for an unknown key error", + mode: MODES.YAML, + doc: "foo: example\nbar: 123", + errors: [ + { + from: 13, + to: 16, + message: "Additional property `bar` is not allowed", + }, + ], + }, + { + name: "not handle invalid yaml", + mode: MODES.YAML, + doc: "foo: example\n- - -bar: 123", + errors: [], + }, + { + name: "provide range for invalid multiline yaml", + mode: MODES.YAML, + doc: `foo: example +bar: something else + `, + errors: [ + { + from: 13, + to: 16, + message: "Additional property `bar` is not allowed", + }, + ], + }, + { + name: "provide formatted error message when required fields are missing", + mode: MODES.YAML, + doc: `foo: example +object: {} + `, + errors: [ + { + from: 21, + to: 23, + message: "The required property `foo` is missing at `object`", + }, + ], + schema: testSchema2, + }, + { + name: "provide formatted error message for oneOf fields with more than 2 items", + mode: MODES.YAML, + doc: `foo: example +object: { foo: true } +oneOfEg: 123 + `, + errors: [ + { + from: 28, + message: "Expected `string` but received `boolean`", + to: 32, + }, + { + from: 44, + message: "Expected one of `string`, `array`, or `boolean`", + to: 47, + }, + ], + schema: testSchema2, + }, + { + name: "provide formatted error message for oneOf fields with less than 2 items", + mode: MODES.YAML, + doc: `foo: example +object: { foo: true } +oneOfEg2: 123 + `, + errors: [ + { + from: 28, + message: "Expected `string` but received `boolean`", + to: 32, + }, + { + from: 45, + message: "Expected one of `string` or `array`", + to: 48, + }, + ], + schema: testSchema2, + }, + ])("$name (mode: $mode)", ({ doc, mode, errors, schema }) => { + expectErrors( + doc, + errors.map((error) => [error.from, error.to, error.message]), + mode, + schema, + ); + }); +}); diff --git a/packages/codemirror-json-schema/src/features/__tests__/state.spec.ts b/packages/codemirror-json-schema/src/features/__tests__/state.spec.ts new file mode 100644 index 00000000..bf70e230 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/__tests__/state.spec.ts @@ -0,0 +1,55 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { JSONSchema7 } from "json-schema"; +import { describe, expect, it } from "vitest"; + +import { getJSONSchema, stateExtensions, updateSchema } from "../state"; + +describe("schema state", () => { + it("initializes schema via stateExtensions()", () => { + const schema: JSONSchema7 = { + type: "object", + properties: { + foo: { type: "string" }, + }, + }; + + const state = EditorState.create({ + doc: "{}", + extensions: [stateExtensions(schema)], + }); + + expect(getJSONSchema(state)).toEqual(schema); + }); + + it("updates schema per-editor via updateSchema()", () => { + const schema1: JSONSchema7 = { + type: "object", + required: ["foo"], + properties: { foo: { type: "string" } }, + }; + const schema2: JSONSchema7 = { + type: "object", + required: ["bar"], + properties: { bar: { type: "number" } }, + }; + + const state = EditorState.create({ + doc: "{}", + extensions: [stateExtensions(schema1)], + }); + + const view = new EditorView({ state }); + try { + expect(getJSONSchema(view.state)).toEqual(schema1); + + updateSchema(view, schema2); + expect(getJSONSchema(view.state)).toEqual(schema2); + + updateSchema(view, undefined); + expect(getJSONSchema(view.state)).toBeUndefined(); + } finally { + view.destroy(); + } + }); +}); diff --git a/packages/codemirror-json-schema/src/features/completion.ts b/packages/codemirror-json-schema/src/features/completion.ts new file mode 100644 index 00000000..d0edf0ce --- /dev/null +++ b/packages/codemirror-json-schema/src/features/completion.ts @@ -0,0 +1,1261 @@ +import { + Completion, + CompletionContext, + CompletionResult, + snippetCompletion, +} from "@codemirror/autocomplete"; +import { syntaxTree } from "@codemirror/language"; +import { SyntaxNode } from "@lezer/common"; +import { JSONSchema7, JSONSchema7Definition } from "json-schema"; +import type { JsonError, JsonSchema } from "json-schema-library"; +import { Draft07, isJsonError } from "json-schema-library"; + +import { MODES, TOKENS } from "../constants"; +import { DocumentParser, getDefaultParser } from "../parsers"; +import { JSONMode } from "../types"; +import { debug } from "../utils/debug"; +import { el } from "../utils/dom"; +import { + jsonPointerForPosition, + resolveTokenName, +} from "../utils/json-pointers"; +import { renderMarkdown } from "../utils/markdown"; +import { + findNodeIndexInArrayNode, + getChildValueNode, + getChildrenNodes, + getClosestNode, + getMatchingChildNode, + getMatchingChildrenNodes, + getNodeAtPosition, + getWord, + isPrimitiveValueNode, + isPropertyNameNode, + stripSurroundingQuotes, + surroundingDoubleQuotesToSingle, +} from "../utils/node"; +import { replacePropertiesDeeply } from "../utils/recordUtil"; + +import { getJSONSchema } from "./state"; + +class CompletionCollector { + completions = new Map(); + reservedKeys = new Set(); + + reserve(key: string) { + this.reservedKeys.add(key); + } + + add(completion: Completion) { + if (this.reservedKeys.has(completion.label)) { + return; + } + this.completions.set(completion.label, completion); + } +} + +export interface JSONCompletionOptions { + mode?: JSONMode; + jsonParser?: DocumentParser; +} + +function isRealSchema( + subSchema: JsonSchema | JsonError | undefined, +): subSchema is JsonSchema { + return !( + !subSchema || + isJsonError(subSchema) || + subSchema.name === "UnknownPropertyError" || + subSchema.type === "undefined" + ); +} + +export class JSONCompletion { + private originalSchema: JSONSchema7 | null = null; + /** + * Inlined (expanded) top-level $ref if present. + */ + private schema: JSONSchema7 | null = null; + /** + * Inlined (expanded) top-level $ref if present. + * Does not contain any required properties and allows any additional properties everywhere. + */ + private laxSchema: JSONSchema7 | null = null; + private mode: JSONMode = MODES.JSON; + private parser: DocumentParser; + + // private lastKnownValidData: object | null = null; + + constructor(private opts: JSONCompletionOptions) { + this.mode = opts.mode ?? MODES.JSON; + this.parser = this.opts?.jsonParser ?? getDefaultParser(this.mode); + } + + public doComplete(ctx: CompletionContext) { + const schemaFromState = getJSONSchema(ctx.state)!; + if (this.originalSchema !== schemaFromState) { + // only process schema when it changed (could be huge) + this.schema = + expandSchemaProperty(schemaFromState, schemaFromState) ?? + schemaFromState; + this.laxSchema = makeSchemaLax(this.schema); + } + if (!this.schema || !this.laxSchema) { + // todo: should we even do anything without schema + // without taking over the existing mode responsibilties? + return []; + } + + // first attempt to complete with the original schema + debug.log("xxx", "trying with original schema"); + const completionResultForOriginalSchema = this.doCompleteForSchema( + ctx, + this.schema, + ); + if (completionResultForOriginalSchema.options.length !== 0) { + return completionResultForOriginalSchema; + } + // if there are no completions, try with the lax schema (because json-schema-library would otherwise not provide schemas if invalid properties are present) + debug.log( + "xxx", + "no completions with original schema, trying with lax schema", + ); + return this.doCompleteForSchema(ctx, this.laxSchema); + } + + private doCompleteForSchema(ctx: CompletionContext, rootSchema: JSONSchema7) { + const result: CompletionResult = { + from: ctx.pos, + to: ctx.pos, + options: [], + filter: false, // will be handled manually + }; + + const text = ctx.state.doc.sliceString(0); + let node: SyntaxNode | null = getNodeAtPosition(ctx.state, ctx.pos); + + // position node word prefix (without quotes) for matching + const prefix = ctx.state + .sliceDoc(node.from, ctx.pos) + .replace(/^(["'])/, ""); + + debug.log("xxx", "node", node, "prefix", prefix, "ctx", ctx); + + // Only show completions if we are filling out a word or right after the starting quote, or if explicitly requested + if ( + !( + isPrimitiveValueNode(node, this.mode) || + isPropertyNameNode(node, this.mode) + ) && + !ctx.explicit + ) { + debug.log("xxx", "no completions for non-word/primitive", node); + return result; + } + + const currentWord = getWord(ctx.state.doc, node); + const rawWord = getWord(ctx.state.doc, node, false); + // Calculate overwrite range + if ( + node && + (isPrimitiveValueNode(node, this.mode) || + isPropertyNameNode(node, this.mode)) + ) { + result.from = node.from; + result.to = node.to; + } else { + const word = ctx.matchBefore(/[A-Za-z0-9._]*/); + const overwriteStart = ctx.pos - currentWord.length; + debug.log( + "xxx", + "overwriteStart after", + overwriteStart, + "ctx.pos", + ctx.pos, + "word", + word, + "currentWord", + currentWord, + "=>", + text[overwriteStart - 1], + "..", + text[overwriteStart], + "..", + text, + ); + result.from = + node.name === TOKENS.INVALID ? (word?.from ?? ctx.pos) : overwriteStart; + result.to = ctx.pos; + } + + const collector = new CompletionCollector(); + + let addValue = true; + + const closestPropertyNameNode = getClosestNode( + node, + TOKENS.PROPERTY_NAME, + this.mode, + ); + // if we are inside a property name node, we need to get the parent property name node + // The only reason we would be inside a property name node is if the current node is invalid or a literal/primitive node + if (closestPropertyNameNode) { + debug.log( + "xxx", + "closestPropertyNameNode", + closestPropertyNameNode, + "node", + node, + ); + node = closestPropertyNameNode; + } + if (isPropertyNameNode(node, this.mode)) { + debug.log("xxx", "isPropertyNameNode", node); + const parent = node.parent; + if (parent) { + // get value node from parent + const valueNode = getChildValueNode(parent, this.mode); + addValue = + !valueNode || + (valueNode.name === TOKENS.INVALID && + valueNode.from - valueNode.to === 0) || + // TODO: Verify this doesn't break anything else + (valueNode.parent + ? getChildrenNodes(valueNode.parent).length <= 1 + : false); + debug.log( + "xxx", + "addValue", + addValue, + getChildValueNode(parent, this.mode), + node, + ); + // find object node + node = getClosestNode(parent, TOKENS.OBJECT, this.mode) ?? null; + } + } + + debug.log( + "xxx", + node, + currentWord, + ctx, + "node at pos", + getNodeAtPosition(ctx.state, ctx.pos), + ); + + // proposals for properties + if ( + node && + (() => { + const resolvedName = resolveTokenName(node.name, this.mode); + return ( + resolvedName === TOKENS.OBJECT || resolvedName === TOKENS.JSON_TEXT + ); + })() && + (isPropertyNameNode(getNodeAtPosition(ctx.state, ctx.pos), this.mode) || + closestPropertyNameNode) + ) { + // don't suggest keys when the cursor is just before the opening curly brace + if (node.from === ctx.pos) { + debug.log("xxx", "no completions for just before opening brace"); + return result; + } + + // property proposals with schema + this.getPropertyCompletions( + rootSchema, + ctx, + node, + collector, + addValue, + rawWord, + ); + } else { + // proposals for values + const types: { [type: string]: boolean } = {}; + + // value proposals with schema + const res = this.getValueCompletions(rootSchema, ctx, types, collector); + debug.log("xxx", "getValueCompletions res", res); + if (res) { + // TODO: While this works, we also need to handle the completion from and to positions to use it + // // use the value node to calculate the prefix + // prefix = res.valuePrefix; + // debug.log("xxx", "using valueNode prefix", prefix); + } + } + + // handle filtering + result.options = Array.from(collector.completions.values()).filter((v) => + stripSurroundingQuotes(v.label).startsWith(prefix), + ); + + debug.log( + "xxx", + "result", + result, + "prefix", + prefix, + "collector.completions", + collector.completions, + "reservedKeys", + collector.reservedKeys, + ); + return result; + } + + private applySnippetCompletion(completion: Completion) { + return snippetCompletion( + typeof completion.apply !== "string" + ? completion.label + : completion.apply, + completion, + ); + } + + private getPropertyCompletions( + rootSchema: JSONSchema7, + ctx: CompletionContext, + node: SyntaxNode, + collector: CompletionCollector, + addValue: boolean, + rawWord: string, + ) { + // don't suggest properties that are already present + const properties = getMatchingChildrenNodes( + node, + TOKENS.PROPERTY, + this.mode, + ); + debug.log("xxx", "getPropertyCompletions", node, ctx, properties); + properties.forEach((p) => { + const key = getWord( + ctx.state.doc, + getMatchingChildNode(p, TOKENS.PROPERTY_NAME, this.mode), + ); + collector.reserve(stripSurroundingQuotes(key)); + }); + + // TODO: Handle separatorAfter + + // Get matching schemas + const schemas = this.getSchemas(rootSchema, ctx); + debug.log("xxx", "propertyCompletion schemas", schemas); + + schemas.forEach((s) => { + if (typeof s !== "object") { + return; + } + + const properties = s.properties; + if (properties) { + Object.entries(properties).forEach(([key, value]) => { + if (typeof value === "object") { + const description = value.description ?? ""; + const type = value.type ?? ""; + const typeStr = Array.isArray(type) ? type.toString() : type; + const completion: Completion = { + // label is the unquoted key which will be displayed. + label: key, + apply: this.getInsertTextForProperty( + key, + addValue, + rawWord, + rootSchema, + value, + ), + type: "property", + detail: typeStr, + info: () => + el("div", { + inner: renderMarkdown(description), + }), + }; + collector.add(this.applySnippetCompletion(completion)); + } + }); + } + const propertyNames = s.propertyNames; + if (typeof propertyNames === "object") { + if (propertyNames.enum) { + propertyNames.enum.forEach((v) => { + const label = v?.toString(); + if (label) { + const completion: Completion = { + label, + apply: this.getInsertTextForProperty( + label, + addValue, + rawWord, + rootSchema, + ), + type: "property", + }; + collector.add(this.applySnippetCompletion(completion)); + } + }); + } + + if (propertyNames.const) { + const label = propertyNames.const.toString(); + const completion: Completion = { + label, + apply: this.getInsertTextForProperty( + label, + addValue, + rawWord, + rootSchema, + ), + type: "property", + }; + collector.add(this.applySnippetCompletion(completion)); + } + } + }); + } + + // apply is the quoted key which will be applied. + // Normally the label needs to match the token + // prefix i.e. if the token begins with `"to`, then the + // label needs to have the quotes as well for it to match. + // However we are manually filtering the results so we can + // just use the unquoted key as the label, which is nicer + // and gives us more control. + // If no property value is present, then we add the colon as well. + // Use snippetCompletion to handle insert value + position cursor e.g. "key": "#{}" + // doc: https://codemirror.net/docs/ref/#autocomplete.snippetCompletion + // idea: https://discuss.codemirror.net/t/autocomplete-cursor-position-in-apply-function/4088/3 + private getInsertTextForProperty( + key: string, + addValue: boolean, + rawWord: string, + rootSchema: JSONSchema7, + propertySchema?: JSONSchema7Definition, + ) { + // expand schema property if it is a reference + propertySchema = propertySchema + ? expandSchemaProperty(propertySchema, rootSchema) + : propertySchema; + + let resultText = this.getInsertTextForPropertyName(key, rawWord); + + if (!addValue) { + return resultText; + } + resultText += ": "; + + let value; + let nValueProposals = 0; + if (typeof propertySchema === "object") { + if (typeof propertySchema.default !== "undefined") { + if (!value) { + value = this.getInsertTextForGuessedValue(propertySchema.default, ""); + } + nValueProposals++; + } else { + if (propertySchema.enum) { + if (!value && propertySchema.enum.length === 1) { + value = this.getInsertTextForGuessedValue( + propertySchema.enum[0], + "", + ); + } + nValueProposals += propertySchema.enum.length; + } + if (typeof propertySchema.const !== "undefined") { + if (!value) { + value = this.getInsertTextForGuessedValue(propertySchema.const, ""); + } + nValueProposals++; + } + if ( + Array.isArray(propertySchema.examples) && + propertySchema.examples.length + ) { + if (!value) { + value = this.getInsertTextForGuessedValue( + propertySchema.examples[0], + "", + ); + } + nValueProposals += propertySchema.examples.length; + } + if (value === undefined && nValueProposals === 0) { + let type = Array.isArray(propertySchema.type) + ? propertySchema.type[0] + : propertySchema.type; + if (!type) { + if (propertySchema.properties) { + type = "object"; + } else if (propertySchema.items) { + type = "array"; + } + } + switch (type) { + case "boolean": + value = "#{}"; + break; + case "string": + value = this.getInsertTextForString(""); + break; + case "object": + switch (this.mode) { + case MODES.JSON5: + value = "{#{}}"; + break; + case MODES.YAML: + value = "#{}"; + break; + default: + value = "{#{}}"; + break; + } + break; + case "array": + value = "[#{}]"; + break; + case "number": + case "integer": + value = "#{0}"; + break; + case "null": + value = "#{null}"; + break; + default: + // always advance the cursor after completing a property + value = "#{}"; + break; + } + } + } + } + if (!value || nValueProposals > 1) { + debug.log( + "xxx", + "value", + value, + "nValueProposals", + nValueProposals, + propertySchema, + ); + value = "#{}"; + } + + return resultText + value; + } + + private getInsertTextForPropertyName(key: string, rawWord: string) { + switch (this.mode) { + case MODES.JSON5: + case MODES.YAML: { + if (rawWord.startsWith('"')) { + return `"${key}"`; + } + if (rawWord.startsWith("'")) { + return `'${key}'`; + } + return key; + } + default: + return `"${key}"`; + } + } + + private getInsertTextForString(value: string, prf = "#") { + switch (this.mode) { + case MODES.JSON5: + return `'${prf}{${value}}'`; + case MODES.YAML: + return `${prf}{${value}}`; + default: + return `"${prf}{${value}}"`; + } + } + + // TODO: Is this actually working? + private getInsertTextForGuessedValue( + value: unknown, + separatorAfter = "", + ): string { + switch (typeof value) { + case "object": + if (value === null) { + return "${null}" + separatorAfter; + } + return this.getInsertTextForValue(value, separatorAfter); + case "string": { + let snippetValue = JSON.stringify(value); + snippetValue = snippetValue.substr(1, snippetValue.length - 2); // remove quotes + snippetValue = this.getInsertTextForPlainText(snippetValue); // escape \ and } + return this.getInsertTextForString(snippetValue, "$") + separatorAfter; + } + case "number": + case "boolean": + return "${" + JSON.stringify(value) + "}" + separatorAfter; + } + return this.getInsertTextForValue(value, separatorAfter); + } + + private getInsertTextForPlainText(text: string): string { + return text.replace(/[\\$}]/g, "\\$&"); // escape $, \ and } + } + + private getInsertTextForValue( + value: unknown, + separatorAfter: string, + ): string { + const text = JSON.stringify(value, null, "\t"); + if (text === "{}") { + return "{#{}}" + separatorAfter; + } else if (text === "[]") { + return "[#{}]" + separatorAfter; + } + return this.getInsertTextForPlainText(text + separatorAfter); + } + + private getValueCompletions( + rootSchema: JSONSchema7, + ctx: CompletionContext, + types: { [type: string]: boolean }, + collector: CompletionCollector, + ) { + let node: SyntaxNode | null = syntaxTree(ctx.state).resolveInner( + ctx.pos, + -1, + ); + let valueNode: SyntaxNode | null = null; + let parentKey: string | undefined = undefined; + + debug.log("xxx", "getValueCompletions", node, ctx); + + if (node && isPrimitiveValueNode(node, this.mode)) { + valueNode = node; + node = node.parent; + } + + if (!node) { + this.addSchemaValueCompletions(rootSchema, types, collector); + return; + } + + if (resolveTokenName(node.name, this.mode) === TOKENS.PROPERTY) { + const keyNode = getMatchingChildNode( + node, + TOKENS.PROPERTY_NAME, + this.mode, + ); + if (keyNode) { + parentKey = getWord(ctx.state.doc, keyNode); + node = node.parent; + } + } + + debug.log("xxx", "node", node, "parentKey", parentKey); + if ( + node && + (parentKey !== undefined || + resolveTokenName(node.name, this.mode) === TOKENS.ARRAY) + ) { + // Get matching schemas + const schemas = this.getSchemas(rootSchema, ctx); + for (const s of schemas) { + if (typeof s !== "object") { + return; + } + + if ( + resolveTokenName(node.name, this.mode) === TOKENS.ARRAY && + s.items + ) { + let c = collector; + if (s.uniqueItems) { + c = { + ...c, + add(completion) { + if (!c.completions.has(completion.label)) { + collector.add(completion); + } + }, + reserve(key) { + collector.reserve(key); + }, + }; + } + if (Array.isArray(s.items)) { + let arrayIndex = 0; + if (valueNode) { + // get index of next node in array + const foundIdx = findNodeIndexInArrayNode( + node, + valueNode, + this.mode, + ); + + if (foundIdx >= 0) { + arrayIndex = foundIdx; + } + } + const itemSchema = s.items[arrayIndex]; + if (itemSchema) { + this.addSchemaValueCompletions(itemSchema, types, c); + } + } else { + this.addSchemaValueCompletions(s.items, types, c); + } + } + + if (s.type == null || s.type !== "object") { + this.addSchemaValueCompletions(s, types, collector); + } + + if (parentKey !== undefined) { + let propertyMatched = false; + if (s.properties) { + const propertySchema = s.properties[parentKey]; + if (propertySchema) { + propertyMatched = true; + this.addSchemaValueCompletions(propertySchema, types, collector); + } + } + if (s.patternProperties && !propertyMatched) { + for (const pattern of Object.keys(s.patternProperties)) { + const regex = this.extendedRegExp(pattern); + if (regex?.test(parentKey)) { + propertyMatched = true; + const propertySchema = s.patternProperties[pattern]; + if (propertySchema) { + this.addSchemaValueCompletions( + propertySchema, + types, + collector, + ); + } + } + } + } + if (s.additionalProperties && !propertyMatched) { + const propertySchema = s.additionalProperties; + this.addSchemaValueCompletions(propertySchema, types, collector); + } + } + if (types["boolean"]) { + this.addBooleanValueCompletion(true, collector); + this.addBooleanValueCompletion(false, collector); + } + if (types["null"]) { + this.addNullValueCompletion(collector); + } + } + } + + // TODO: We need to pass the from and to for the value node as well + // TODO: What should be the from and to when the value node is null? + // TODO: (NOTE: if we pass a prefix but no from and to, it will autocomplete the value but replace + // TODO: the entire property nodewhich isn't what we want). Instead we need to change the from and to + // TODO: based on the corresponding (relevant) value node + const valuePrefix = valueNode + ? getWord(ctx.state.doc, valueNode, true, false) + : ""; + + return { + valuePrefix, + }; + } + + private addSchemaValueCompletions( + schema: JSONSchema7Definition, + // TODO this is buggy because it does not resolve refs, should hand down rootSchema and expand each ref + // rootSchema: JSONSchema7, + types: { [type: string]: boolean }, + collector: CompletionCollector, + ) { + if (typeof schema === "object") { + this.addEnumValueCompletions(schema, collector); + this.addDefaultValueCompletions(schema, collector); + this.collectTypes(schema, types); + if (Array.isArray(schema.allOf)) { + schema.allOf.forEach((s) => + this.addSchemaValueCompletions(s, types, collector), + ); + } + if (Array.isArray(schema.anyOf)) { + schema.anyOf.forEach((s) => + this.addSchemaValueCompletions(s, types, collector), + ); + } + if (Array.isArray(schema.oneOf)) { + schema.oneOf.forEach((s) => + this.addSchemaValueCompletions(s, types, collector), + ); + } + } + } + + private addDefaultValueCompletions( + schema: JSONSchema7, + collector: CompletionCollector, + arrayDepth = 0, + ): void { + let hasProposals = false; + if (typeof schema.default !== "undefined") { + let type = schema.type; + let value = schema.default; + for (let i = arrayDepth; i > 0; i--) { + value = [value]; + type = "array"; + } + const completionItem: Completion = { + type: type?.toString(), + ...this.getAppliedValue(value), + detail: "Default value", + }; + collector.add(completionItem); + hasProposals = true; + } + if (Array.isArray(schema.examples)) { + schema.examples.forEach((example) => { + let type = schema.type; + let value = example; + for (let i = arrayDepth; i > 0; i--) { + value = [value]; + type = "array"; + } + collector.add({ + type: type?.toString(), + ...this.getAppliedValue(value), + }); + hasProposals = true; + }); + } + if ( + !hasProposals && + typeof schema.items === "object" && + !Array.isArray(schema.items) && + arrayDepth < 5 /* beware of recursion */ + ) { + this.addDefaultValueCompletions(schema.items, collector, arrayDepth + 1); + } + } + + private addEnumValueCompletions( + schema: JSONSchema7, + collector: CompletionCollector, + ): void { + if (typeof schema.const !== "undefined") { + collector.add({ + type: schema.type?.toString(), + ...this.getAppliedValue(schema.const), + + info: schema.description, + }); + } + + if (Array.isArray(schema.enum)) { + for (let i = 0, length = schema.enum.length; i < length; i++) { + const enm = schema.enum[i]; + collector.add({ + type: schema.type?.toString(), + ...this.getAppliedValue(enm), + info: schema.description, + }); + } + } + } + + private addBooleanValueCompletion( + value: boolean, + collector: CompletionCollector, + ): void { + collector.add({ + type: "boolean", + label: value ? "true" : "false", + }); + } + + private addNullValueCompletion(collector: CompletionCollector): void { + collector.add({ + type: "null", + label: "null", + }); + } + + private collectTypes( + schema: JSONSchema7, + types: { [type: string]: boolean }, + ) { + if (Array.isArray(schema.enum) || typeof schema.const !== "undefined") { + return; + } + const type = schema.type; + if (Array.isArray(type)) { + type.forEach((t) => (types[t] = true)); + } else if (type) { + types[type] = true; + } + } + + private getSchemas( + rootSchema: JSONSchema7, + ctx: CompletionContext, + ): JSONSchema7Definition[] { + const { data: documentData } = this.parser(ctx.state); + + const draft = new Draft07(rootSchema); + let pointer: string | undefined = jsonPointerForPosition( + ctx.state, + ctx.pos, + -1, + this.mode, + ); + // TODO make jsonPointer consistent and compatible with json-schema-library by default (root path '/' or ' ' or undefined or '#', idk) + if (pointer === "") pointer = undefined; + + if (pointer != null && pointer.endsWith("/")) { + // opening new property under pointer + // the property name is empty but json-schema-library would puke itself with a trailing slash, so we shouldn't even call it with that + pointer = pointer.substring(0, pointer.length - 1); + + // when adding a new property, we just wanna return the possible properties if possible + const effectiveSchemaOfPointer = getEffectiveObjectWithPropertiesSchema( + rootSchema, + documentData, + pointer, + ); + if (effectiveSchemaOfPointer != null) { + return [effectiveSchemaOfPointer]; + } + } + + let parentPointer: string | undefined = + pointer != null ? pointer.replace(/\/[^/]*$/, "") : undefined; + if (parentPointer === "") parentPointer = undefined; + + // Pass parsed data to getSchema to get the correct schema based on the data context (e.g. for anyOf or if-then) + const effectiveSchemaOfParent = getEffectiveObjectWithPropertiesSchema( + rootSchema, + documentData, + parentPointer, + ); + const deepestPropertyKey = pointer?.split("/").pop(); + const pointerPointsToKnownProperty = + deepestPropertyKey == null || + deepestPropertyKey in (effectiveSchemaOfParent?.properties ?? {}); + + // TODO upgrade json-schema-library, so this actually returns undefined if data and schema are incompatible (currently it sometimes pukes itself with invalid data and imagines schemas on-the-fly) + let subSchema = draft.getSchema({ + pointer, + data: documentData ?? undefined, + }); + if ( + !pointerPointsToKnownProperty && + subSchema?.type === "null" && + this.mode === "yaml" + ) { + // TODO describe YAML special-case where null is given the value and json-schema-library simply makes up a new schema based on that null value for whatever reason + subSchema = undefined; + } + + debug.log( + "xxxx", + "draft.getSchema", + subSchema, + "data", + documentData, + "pointer", + pointer, + "pointerPointsToKnownProperty", + pointerPointsToKnownProperty, + ); + if (isJsonError(subSchema)) { + subSchema = subSchema.data?.schema; + } + + // if we don't have a schema for the current pointer, try the parent pointer with data to get a list of possible properties + if (!isRealSchema(subSchema)) { + if (effectiveSchemaOfParent) { + return [effectiveSchemaOfParent]; + } + } + + // then try the parent pointer without data + if (!isRealSchema(subSchema)) { + subSchema = draft.getSchema({ pointer: parentPointer }); + // TODO should probably only change pointer if it actually found a schema there, but i left it as-is + pointer = parentPointer; + } + + debug.log("xxx", "pointer..", JSON.stringify(pointer)); + + // For some reason, it returns undefined schema for the root pointer + // We use the root schema in that case as the relevant (sub)schema + if (!isRealSchema(subSchema) && (!pointer || pointer === "/")) { + subSchema = expandSchemaProperty(rootSchema, rootSchema) ?? rootSchema; + } + // const subSchema = new Draft07(this.dirtyCtx.rootSchema).getSchema(pointer); + debug.log("xxx", "subSchema..", subSchema); + if (!subSchema) { + return []; + } + + if (Array.isArray(subSchema.allOf)) { + return [ + subSchema, + ...subSchema.allOf.map((s) => expandSchemaProperty(s, rootSchema)), + ]; + } + if (Array.isArray(subSchema.oneOf)) { + return [ + subSchema, + ...subSchema.oneOf.map((s) => expandSchemaProperty(s, rootSchema)), + ]; + } + if (Array.isArray(subSchema.anyOf)) { + return [ + subSchema, + ...subSchema.anyOf.map((s) => expandSchemaProperty(s, rootSchema)), + ]; + } + + return [subSchema as JSONSchema7]; + } + + private getAppliedValue(value: unknown): { label: string; apply: string } { + const stripped = stripSurroundingQuotes(JSON.stringify(value)); + switch (this.mode) { + case MODES.JSON5: + return { + label: stripped, + apply: surroundingDoubleQuotesToSingle(JSON.stringify(value)), + }; + case MODES.YAML: + return { + label: stripped, + apply: stripped, + }; + default: + return { + label: stripped, + apply: JSON.stringify(value), + }; + } + } + + private getValueFromLabel(value: string): unknown { + return JSON.parse(value); + } + + private extendedRegExp(pattern: string): RegExp | undefined { + let flags = ""; + if (pattern.startsWith("(?i)")) { + pattern = pattern.substring(4); + flags = "i"; + } + try { + return new RegExp(pattern, flags + "u"); + } catch (_e) { + // could be an exception due to the 'u ' flag + try { + return new RegExp(pattern, flags); + } catch (_e2) { + // invalid pattern + return undefined; + } + } + } +} + +/** + * provides a JSON schema enabled autocomplete extension for codemirror + * @group Codemirror Extensions + */ +export function jsonCompletion(opts: JSONCompletionOptions = {}) { + const completion = new JSONCompletion(opts); + return function jsonDoCompletion(ctx: CompletionContext) { + return completion.doComplete(ctx); + }; +} + +/** + * removes required properties and allows additional properties everywhere + * @param schema + */ +function makeSchemaLax(schema: T): T { + return replacePropertiesDeeply(schema, (key, value) => { + if (key === "additionalProperties" && value === false) { + return []; + } + if (key === "required" && Array.isArray(value)) { + return []; + } + if (key === "unevaluatedProperties" && value === false) { + return []; + } + if (key === "unevaluatedItems" && value === false) { + return []; + } + // TODO remove dependencies and other restrictions + // if (key === 'dependencies' && typeof value === 'object') { + // return Object.keys(value).reduce((acc: any, depKey) => { + // const depValue = value[depKey]; + // if (Array.isArray(depValue)) { + // return acc; + // } + // return { ...acc, [depKey]: depValue }; + // }, {}); + // } + return [key, value]; + }); +} + +/** + * determines effective object schema for given data + * TODO support patternProperties, etc. + * @param schema + * @param data + * @param pointer + */ +function getEffectiveObjectWithPropertiesSchema( + schema: JSONSchema7, + data: unknown, + pointer: string | undefined, +): JSONSchema7 | undefined { + // TODO (unimportant): [performance] cache Draft07 in case it does some pre-processing? but does not seem to be significant + const draft = new Draft07(schema); + const subSchema = draft.getSchema({ + pointer, + data: data ?? undefined, + }); + if (!isRealSchema(subSchema)) { + return undefined; + } + + const possibleDirectPropertyNames = getAllPossibleDirectStaticPropertyNames( + draft, + subSchema as JSONSchema7, + ); + const effectiveProperties: Exclude = {}; + for (const possibleDirectPropertyName of possibleDirectPropertyNames) { + const propertyPointer = extendJsonPointer( + pointer, + possibleDirectPropertyName, + ); + const subSchemaForPropertyConsideringData = draft.getSchema({ + // TODO [performance] use subSchema and only check it's sub-properties + pointer: propertyPointer, + data: data ?? undefined, + // pointer: `/${possibleDirectPropertyName}`, + // schema: subSchema + }); + if (isRealSchema(subSchemaForPropertyConsideringData)) { + Object.assign(effectiveProperties, { + [possibleDirectPropertyName]: subSchemaForPropertyConsideringData, + }); + } + } + + if ( + possibleDirectPropertyNames.length === 0 || + Object.keys(effectiveProperties).length === 0 + ) { + // in case json-schema-library behaves too weirdly and returns nothing, just return no schema too to let other cases handle this edge-case + return undefined; + } + + // TODO also resolve patternProperties of allOf, anyOf, oneOf + const { allOf, anyOf, oneOf, ...subSchemaRest } = subSchema as JSONSchema7; + + return { + ...subSchemaRest, + properties: effectiveProperties, + }; +} + +/** + * static means not from patternProperties + * @param rootDraft + * @param schema + */ +function getAllPossibleDirectStaticPropertyNames( + rootDraft: Draft07, + schema: JSONSchema7, +): string[] { + schema = expandSchemaProperty(schema, rootDraft.rootSchema); + if (typeof schema !== "object" || schema == null) { + return []; + } + + const possiblePropertyNames = []; + + function addFrom(subSchema: JSONSchema7) { + const possiblePropertyNamesOfSubSchema = + getAllPossibleDirectStaticPropertyNames(rootDraft, subSchema); + possiblePropertyNames.push(...possiblePropertyNamesOfSubSchema); + } + + if (typeof schema.properties === "object" && schema.properties != null) { + possiblePropertyNames.push(...Object.keys(schema.properties)); + } + if (typeof schema.then === "object" && schema.then != null) { + addFrom(schema.then); + } + if (Array.isArray(schema.allOf)) { + for (const subSchema of schema.allOf) { + addFrom(subSchema as JSONSchema7); + } + } + if (Array.isArray(schema.anyOf)) { + for (const subSchema of schema.anyOf) { + addFrom(subSchema as JSONSchema7); + } + } + if (Array.isArray(schema.oneOf)) { + for (const subSchema of schema.oneOf) { + addFrom(subSchema as JSONSchema7); + } + } + return possiblePropertyNames; +} + +function expandSchemaProperty( + propertySchema: T, + rootSchema: JSONSchema7, +) { + if (typeof propertySchema === "object" && propertySchema.$ref) { + const refSchema = getReferenceSchema(rootSchema, propertySchema.$ref); + if (typeof refSchema === "object") { + const dereferenced = { + ...propertySchema, + ...refSchema, + }; + Reflect.deleteProperty(dereferenced, "$ref"); + + return dereferenced; + } + } + return propertySchema; +} + +function getReferenceSchema(schema: JSONSchema7, ref: string) { + const refPath = ref.split("/"); + let curReference: Record | undefined = + schema as unknown as Record; + refPath.forEach((cur) => { + if (!cur) { + return; + } + if (cur === "#") { + curReference = schema as unknown as Record; + return; + } + if (typeof curReference === "object") { + curReference = curReference[cur] as Record | undefined; + } + }); + + return curReference; +} + +function extendJsonPointer(pointer: string | undefined, key: string) { + return pointer === undefined ? `/${key}` : `${pointer}/${key}`; +} diff --git a/packages/codemirror-json-schema/src/features/hover.ts b/packages/codemirror-json-schema/src/features/hover.ts new file mode 100644 index 00000000..fc223d86 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/hover.ts @@ -0,0 +1,245 @@ +import { type EditorView, Tooltip } from "@codemirror/view"; +import { JSONSchema7Type } from "json-schema"; +import { + type Draft, + Draft04, + JsonSchema, + isJsonError, +} from "json-schema-library"; + +import { MODES } from "../constants"; +import { JSONMode, Side } from "../types"; +import { debug } from "../utils/debug"; +import { el } from "../utils/dom"; +import { joinWithOr } from "../utils/formatting"; +import { jsonPointerForPosition } from "../utils/json-pointers"; +import { renderMarkdown } from "../utils/markdown"; + +import { getJSONSchema } from "./state"; + +export type CursorData = { schema?: JsonSchema; pointer: string }; + +export type FoundCursorData = Required; + +export type HoverTexts = { message: string; typeInfo: string }; + +export type HoverOptions = { + mode?: JSONMode; + /** + * Generate the text to display in the hover tooltip + */ + getHoverTexts?: (data: FoundCursorData) => HoverTexts; + /** + * Generate the hover tooltip HTML + */ + formatHover?: (data: HoverTexts) => HTMLElement; + /** + * Provide a custom parser for the document + * @default JSON.parse + */ + parser?: (text: string) => unknown; +}; + +/** + * provides a JSON schema enabled tooltip extension for codemirror + * @group Codemirror Extensions + */ +export function jsonSchemaHover(options?: HoverOptions) { + const hover = new JSONHover(options); + return async function jsonDoHover(view: EditorView, pos: number, side: Side) { + return hover.doHover(view, pos, side); + }; +} + +function formatType(data: { type?: JSONSchema7Type; $ref?: string }) { + if (data.type) { + if (data.$ref) { + return `${data.$ref} (${data.type})`; + } + return data.type; + } + if (data.$ref) { + return `${data.$ref}`; + } +} + +function formatComplexType( + schema: JsonSchema, + complexType: "oneOf" | "anyOf" | "allOf", + draft: Draft, +) { + return `${complexType}: ${joinWithOr( + schema[complexType].map((s: JsonSchema) => { + try { + const { data } = draft.resolveRef({ data: s, pointer: s.$ref }); + if (data) { + return formatType(data); + } + return formatType(s); + } catch (_err) { + return s.type; + } + }), + )}`; +} + +export class JSONHover { + private schema: Draft | null = null; + private mode: JSONMode = MODES.JSON; + public constructor(private opts?: HoverOptions) { + this.opts = { + parser: JSON.parse, + ...this.opts, + }; + this.mode = this.opts?.mode ?? MODES.JSON; + } + public getDataForCursor( + view: EditorView, + pos: number, + side: Side, + ): CursorData | null { + const schema = getJSONSchema(view.state)!; + if (!schema) { + // todo: should we even do anything without schema + // without taking over the existing mode responsibilties? + return null; + } + this.schema = new Draft04(schema); + + const pointer = jsonPointerForPosition(view.state, pos, side, this.mode); + + let data: unknown = undefined; + // TODO: use the AST tree to return the right hand, data so that we don't have to parse the doc + try { + data = this.opts!.parser!(view.state.doc.toString()); + } catch { + // noop + } + + if (!pointer) { + return null; + } + // if the data is valid, we can infer a type for complex types + let subSchema = this.schema.getSchema({ + pointer, + data, + withSchemaWarning: true, + }); + if (isJsonError(subSchema)) { + if (subSchema?.data.schema["$ref"]) { + subSchema = this.schema.resolveRef(subSchema); + } else { + subSchema = subSchema?.data.schema; + } + } + + return { schema: subSchema, pointer }; + } + + private formatMessage(texts: HoverTexts): HTMLElement { + const { message, typeInfo } = texts; + if (message) { + return el("div", { class: "cm6-json-schema-hover" }, [ + el("div", { + class: "cm6-json-schema-hover--description", + inner: renderMarkdown(message, false), + }), + el("div", { class: "cm6-json-schema-hover--code-wrapper" }, [ + el("div", { + class: "cm6-json-schema-hover--code", + inner: renderMarkdown(typeInfo, false), + }), + ]), + ]); + } + return el("div", { class: "cm6-json-schema-hover" }, [ + el("div", { class: "cm6-json-schema-hover--code-wrapper" }, [ + el("code", { + class: "cm6-json-schema-hover--code", + inner: renderMarkdown(typeInfo, false), + }), + ]), + ]); + } + + public getHoverTexts(data: FoundCursorData, draft: Draft): HoverTexts { + let typeInfo = ""; + let message = null; + + const { schema } = data; + + if (schema.oneOf) { + typeInfo = formatComplexType(schema, "oneOf", draft); + } + if (schema.anyOf) { + typeInfo = formatComplexType(schema, "anyOf", draft); + } + if (schema.allOf) { + typeInfo = formatComplexType(schema, "allOf", draft); + } + + if (schema.type) { + typeInfo = Array.isArray(schema.type) + ? joinWithOr(schema.type) + : schema.type; + } + if (schema.$ref) { + typeInfo = ` Reference: ${schema.$ref}`; + } + if (schema.enum) { + typeInfo = `\`enum\`: ${joinWithOr(schema.enum)}`; + } + if (schema.format) { + typeInfo += `\`format\`: ${schema.format}`; + } + if (schema.pattern) { + typeInfo += `\`pattern\`: ${schema.pattern}`; + } + + if (schema.description) { + message = schema.description; + } + return { message, typeInfo }; + } + + // return hover state for the current json schema property + public async doHover( + view: EditorView, + pos: number, + side: Side, + ): Promise { + const start = pos, + end = pos; + try { + const cursorData = this.getDataForCursor(view, pos, side); + debug.log("cursorData", cursorData); + // if we don't have a (sub)schema, we can't show anything + if (!cursorData?.schema) return null; + + const getHoverTexts = this.opts?.getHoverTexts ?? this.getHoverTexts; + const hoverTexts = getHoverTexts( + cursorData as FoundCursorData, + this.schema!, + ); + // allow users to override the hover + const formatter = this.opts?.formatHover ?? this.formatMessage; + const formattedDom = formatter(hoverTexts); + return { + pos: start, + end, + arrow: true, + // to mimic similar modes for other editors + // otherwise, it gets into a z-index battle with completion/etc + above: true, + create: (_view) => { + return { + dom: formattedDom, + }; + }, + }; + } catch (err) { + debug.log(err); + return null; + } + } +} diff --git a/packages/codemirror-json-schema/src/features/state.ts b/packages/codemirror-json-schema/src/features/state.ts new file mode 100644 index 00000000..1ae71f38 --- /dev/null +++ b/packages/codemirror-json-schema/src/features/state.ts @@ -0,0 +1,31 @@ +import { type EditorState, StateEffect, StateField } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import type { JSONSchema7 } from "json-schema"; +const schemaEffect = StateEffect.define(); + +export const schemaStateField = StateField.define({ + create() {}, + update(schema, tr) { + for (const e of tr.effects) { + if (e.is(schemaEffect)) { + return e.value; + } + } + + return schema; + }, +}); + +export const updateSchema = (view: EditorView, schema?: JSONSchema7) => { + view.dispatch({ + effects: schemaEffect.of(schema), + }); +}; + +export const getJSONSchema = (state: EditorState) => { + return state.field(schemaStateField); +}; + +export const stateExtensions = (schema?: JSONSchema7) => [ + schemaStateField.init(() => schema), +]; diff --git a/packages/codemirror-json-schema/src/features/validation.ts b/packages/codemirror-json-schema/src/features/validation.ts new file mode 100644 index 00000000..acdf500e --- /dev/null +++ b/packages/codemirror-json-schema/src/features/validation.ts @@ -0,0 +1,186 @@ +import { type Diagnostic } from "@codemirror/lint"; +import type { EditorView, ViewUpdate } from "@codemirror/view"; +import { type Draft, Draft04, type JsonError } from "json-schema-library"; + +import { MODES } from "../constants"; +import { DocumentParser, getDefaultParser } from "../parsers"; +import { JSONMode, JSONPointerData } from "../types"; +import { debug } from "../utils/debug"; +import { el } from "../utils/dom"; +import { joinWithOr } from "../utils/formatting"; +import { renderMarkdown } from "../utils/markdown"; + +import { getJSONSchema, schemaStateField } from "./state"; + +// return an object path that matches with the json-source-map pointer +const getErrorPath = (error: JsonError): string => { + // if a pointer is present, return without # + if (error?.data?.pointer && error?.data?.pointer !== "#") { + return error.data.pointer.slice(1); + } + // return plain data.property if present + if (error?.data?.property) { + return `/${error.data.property}`; + } + // else, return the empty pointer to represent the whole document + return ""; +}; + +export interface JSONValidationOptions { + mode?: JSONMode; + formatError?: (error: JsonError) => string; + jsonParser?: DocumentParser; +} + +export const handleRefresh = (vu: ViewUpdate) => { + return ( + vu.startState.field(schemaStateField) !== vu.state.field(schemaStateField) + ); +}; + +/** + * Helper for simpler class instantiaton + * @group Codemirror Extensions + */ +export function jsonSchemaLinter(options?: JSONValidationOptions) { + const validation = new JSONValidation(options); + return (view: EditorView) => { + return validation.doValidation(view); + }; +} + +// all the error types that apply to a specific key or value +const positionalErrors = [ + "NoAdditionalPropertiesError", + "RequiredPropertyError", + "InvalidPropertyNameError", + "ForbiddenPropertyError", + "UndefinedValueError", +]; + +export class JSONValidation { + private schema: Draft | null = null; + + private mode: JSONMode = MODES.JSON; + private parser: DocumentParser; + public constructor(private options?: JSONValidationOptions) { + this.mode = this.options?.mode ?? MODES.JSON; + this.parser = this.options?.jsonParser ?? getDefaultParser(this.mode); + + // TODO: support other versions of json schema. + // most standard schemas are draft 4 for some reason, probably + // backwards compatibility + // + // ajv did not support draft 4, so I used json-schema-library + } + private get schemaTitle() { + return this.schema?.getSchema()?.title ?? "json-schema"; + } + + // rewrite the error message to be more human readable + private rewriteError = (error: JsonError): string => { + const errorData = error?.data; + const errors = (errorData?.errors ?? []) as unknown as Array<{ + data: { expected: string }; + }>; + if (error.code === "one-of-error" && errors?.length) { + return `Expected one of ${joinWithOr( + errors, + (err) => err.data.expected, + )}`; + } + if (error.code === "type-error") { + return `Expected \`${ + error?.data?.expected && Array.isArray(error?.data?.expected) + ? joinWithOr(error?.data?.expected) + : error?.data?.expected + }\` but received \`${error?.data?.received}\``; + } + const message = error.message + // don't mention root object + .replaceAll("in `#` ", "") + .replaceAll("at `#`", "") + .replaceAll("/", ".") + .replaceAll("#.", ""); + return message; + }; + + // validate using view as the linter extension signature requires + public doValidation(view: EditorView) { + const schema = getJSONSchema(view.state); + if (!schema) { + return []; + } + this.schema = new Draft04(schema); + + if (!this.schema) return []; + const text = view.state.doc.toString(); + + // ignore blank json strings + if (!text?.length) return []; + + const json = this.parser(view.state); + // skip validation if parsing fails + if (json.data == null) return []; + + let errors: JsonError[] = []; + try { + errors = this.schema.validate(json.data); + } catch { + // noop + } + debug.log("xxx", "validation errors", errors, json.data); + if (!errors.length) return []; + // reduce() because we want to filter out errors that don't have a pointer + return errors.reduce((acc, error) => { + const pushRoot = () => { + const errorString = this.rewriteError(error); + acc.push({ + from: 0, + to: 0, + message: errorString, + severity: "error", + source: this.schemaTitle, + renderMessage: () => { + const dom = el("div", {}); + dom.innerHTML = renderMarkdown(errorString); + return dom; + }, + }); + }; + const errorPath = getErrorPath(error); + const pointer = json.pointers.get(errorPath) as JSONPointerData; + if ( + error.name === "MaxPropertiesError" || + error.name === "MinPropertiesError" || + errorPath === "" // root level type errors + ) { + pushRoot(); + } else if (pointer) { + // if the error is a property error, use the key position + const isKeyError = positionalErrors.includes(error.name); + const errorString = this.rewriteError(error); + const from = isKeyError ? pointer.keyFrom : pointer.valueFrom; + const to = isKeyError ? pointer.keyTo : pointer.valueTo; + // skip error if no from/to value is found + if (to !== undefined && from !== undefined) { + acc.push({ + from, + to, + message: errorString, + renderMessage: () => { + const dom = el("div", {}); + dom.innerHTML = renderMarkdown(errorString); + return dom; + }, + severity: "error", + source: this.schemaTitle, + }); + } + } else { + pushRoot(); + } + return acc; + }, []); + } +} diff --git a/packages/codemirror-json-schema/src/index.ts b/packages/codemirror-json-schema/src/index.ts new file mode 100644 index 00000000..8713564d --- /dev/null +++ b/packages/codemirror-json-schema/src/index.ts @@ -0,0 +1,33 @@ +export { + jsonCompletion, + JSONCompletion, + type JSONCompletionOptions, +} from "./features/completion"; + +export { + jsonSchemaLinter, + JSONValidation, + type JSONValidationOptions, + handleRefresh, +} from "./features/validation"; + +export { + jsonSchemaHover, + JSONHover, + type HoverOptions, + type FoundCursorData, + type CursorData, +} from "./features/hover"; + +export { jsonSchema } from "./json/bundled"; + +export type { + JSONPointersMap, + JSONPointerData, + JSONPartialPointerData, +} from "./types"; + +export * from "./parsers/json-parser"; +export * from "./utils/json-pointers"; + +export * from "./features/state"; diff --git a/packages/codemirror-json-schema/src/json/bundled.ts b/packages/codemirror-json-schema/src/json/bundled.ts new file mode 100644 index 00000000..a1a29fa0 --- /dev/null +++ b/packages/codemirror-json-schema/src/json/bundled.ts @@ -0,0 +1,28 @@ +import { JSONSchema7 } from "json-schema"; +import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json"; +import { hoverTooltip } from "@codemirror/view"; +import { jsonCompletion } from "../features/completion"; +import { handleRefresh, jsonSchemaLinter } from "../features/validation"; +import { jsonSchemaHover } from "../features/hover"; +import { stateExtensions } from "../features/state"; + +import { linter } from "@codemirror/lint"; + +/** + * Full featured cm6 extension for json, including `@codemirror/lang-json` + * @group Bundled Codemirror Extensions + */ +export function jsonSchema(schema?: JSONSchema7) { + return [ + json(), + linter(jsonParseLinter()), + linter(jsonSchemaLinter(), { + needsRefresh: handleRefresh, + }), + jsonLanguage.data.of({ + autocomplete: jsonCompletion(), + }), + hoverTooltip(jsonSchemaHover()), + stateExtensions(schema), + ]; +} diff --git a/packages/codemirror-json-schema/src/json5/bundled.ts b/packages/codemirror-json-schema/src/json5/bundled.ts new file mode 100644 index 00000000..b68942cc --- /dev/null +++ b/packages/codemirror-json-schema/src/json5/bundled.ts @@ -0,0 +1,29 @@ +import { JSONSchema7 } from "json-schema"; +import { json5, json5Language, json5ParseLinter } from "codemirror-json5"; +import { hoverTooltip } from "@codemirror/view"; +import { json5Completion } from "./completion"; +import { json5SchemaLinter } from "./validation"; +import { json5SchemaHover } from "./hover"; + +import { linter } from "@codemirror/lint"; +import { handleRefresh } from "../features/validation"; +import { stateExtensions } from "../features/state"; + +/** + * Full featured cm6 extension for json5, including `codemirror-json5` + * @group Bundled Codemirror Extensions + */ +export function json5Schema(schema?: JSONSchema7) { + return [ + json5(), + linter(json5ParseLinter()), + linter(json5SchemaLinter(), { + needsRefresh: handleRefresh, + }), + json5Language.data.of({ + autocomplete: json5Completion(), + }), + hoverTooltip(json5SchemaHover()), + stateExtensions(schema), + ]; +} diff --git a/packages/codemirror-json-schema/src/json5/completion.ts b/packages/codemirror-json-schema/src/json5/completion.ts new file mode 100644 index 00000000..6ec40cd7 --- /dev/null +++ b/packages/codemirror-json-schema/src/json5/completion.ts @@ -0,0 +1,16 @@ +import { CompletionContext } from "@codemirror/autocomplete"; +import { MODES } from "../constants"; +import { JSONCompletion, JSONCompletionOptions } from "../features/completion"; + +/** + * provides a JSON schema enabled autocomplete extension for codemirror and json5 + * @group Codemirror Extensions + */ +export function json5Completion( + opts: Omit = {} +) { + const completion = new JSONCompletion({ ...opts, mode: MODES.JSON5 }); + return function jsonDoCompletion(ctx: CompletionContext) { + return completion.doComplete(ctx); + }; +} diff --git a/packages/codemirror-json-schema/src/json5/hover.ts b/packages/codemirror-json-schema/src/json5/hover.ts new file mode 100644 index 00000000..80b33bbd --- /dev/null +++ b/packages/codemirror-json-schema/src/json5/hover.ts @@ -0,0 +1,22 @@ +import { type EditorView } from "@codemirror/view"; +import { type HoverOptions, JSONHover } from "../features/hover"; +import json5 from "json5"; +import { Side } from "../types"; +import { MODES } from "../constants"; + +export type JSON5HoverOptions = Exclude; + +/** + * Instantiates a JSONHover instance with the JSON5 mode + * @group Codemirror Extensions + */ +export function json5SchemaHover(options?: JSON5HoverOptions) { + const hover = new JSONHover({ + ...options, + parser: json5.parse, + mode: MODES.JSON5, + }); + return async function jsonDoHover(view: EditorView, pos: number, side: Side) { + return hover.doHover(view, pos, side); + }; +} diff --git a/packages/codemirror-json-schema/src/json5/index.ts b/packages/codemirror-json-schema/src/json5/index.ts new file mode 100644 index 00000000..3d79c573 --- /dev/null +++ b/packages/codemirror-json-schema/src/json5/index.ts @@ -0,0 +1,11 @@ +// json5 +export { json5SchemaLinter } from "./validation"; +export { json5SchemaHover } from "./hover"; +export { json5Completion } from "./completion"; + +/** + * @group Bundled Codemirror Extensions + */ +export { json5Schema } from "./bundled"; + +export * from "../parsers/json5-parser"; diff --git a/packages/codemirror-json-schema/src/json5/validation.ts b/packages/codemirror-json-schema/src/json5/validation.ts new file mode 100644 index 00000000..8d3774ba --- /dev/null +++ b/packages/codemirror-json-schema/src/json5/validation.ts @@ -0,0 +1,22 @@ +import { EditorView } from "@codemirror/view"; +import { + JSONValidation, + type JSONValidationOptions, +} from "../features/validation"; +import { MODES } from "../constants"; +import { parseJSON5DocumentState } from "../parsers/json5-parser"; + +/** + * Instantiates a JSONValidation instance with the JSON5 mode + * @group Codemirror Extensions + */ +export function json5SchemaLinter(options?: JSONValidationOptions) { + const validation = new JSONValidation({ + jsonParser: parseJSON5DocumentState, + mode: MODES.JSON5, + ...options, + }); + return (view: EditorView) => { + return validation.doValidation(view); + }; +} diff --git a/packages/codemirror-json-schema/src/parsers/__tests__/json-parser.spec.ts b/packages/codemirror-json-schema/src/parsers/__tests__/json-parser.spec.ts new file mode 100644 index 00000000..2a94f119 --- /dev/null +++ b/packages/codemirror-json-schema/src/parsers/__tests__/json-parser.spec.ts @@ -0,0 +1,29 @@ +import { it, describe, expect } from "vitest"; +import { parseJSONDocument } from "../json-parser"; +import { parseJSON5Document } from "../json5-parser"; + +describe("parseJSONDocument", () => { + it("should return a map of all pointers for a json4 document", () => { + const doc = parseJSONDocument(`{"object": { "foo": true }, "bar": 123}`); + expect(doc.data).toEqual({ object: { foo: true }, bar: 123 }); + expect(Array.from(doc.pointers.keys())).toEqual([ + "", + "/object", + "/object/foo", + "/bar", + ]); + }); +}); + +describe("parseJSON5Document", () => { + it("should return a map of all pointers for a json5 document", () => { + const doc = parseJSON5Document(`{'obj"ect': { foo: true }, "bar": 123}`); + expect(doc.data).toEqual({ ['obj"ect']: { foo: true }, bar: 123 }); + expect(Array.from(doc.pointers.keys())).toEqual([ + "", + '/obj"ect', + '/obj"ect/foo', + "/bar", + ]); + }); +}); diff --git a/packages/codemirror-json-schema/src/parsers/__tests__/yaml-parser.spec.ts b/packages/codemirror-json-schema/src/parsers/__tests__/yaml-parser.spec.ts new file mode 100644 index 00000000..49935ad9 --- /dev/null +++ b/packages/codemirror-json-schema/src/parsers/__tests__/yaml-parser.spec.ts @@ -0,0 +1,24 @@ +import { it, describe, expect } from "vitest"; + +import { yaml } from "@codemirror/lang-yaml"; +import { EditorState } from "@codemirror/state"; +import { parseYAMLDocumentState } from "../yaml-parser"; + +const testDoc = `--- +object: + foo: true +bar: 123`; + +describe("parseYAMLDocumentState", () => { + it("should return a map of all pointers for a json4 document", () => { + const state = EditorState.create({ doc: testDoc, extensions: [yaml()] }); + const doc = parseYAMLDocumentState(state); + expect(doc.data).toEqual({ object: { foo: true }, bar: 123 }); + expect(Array.from(doc.pointers.keys())).toEqual([ + "", + "/object", + "/object/foo", + "/bar", + ]); + }); +}); diff --git a/packages/codemirror-json-schema/src/parsers/index.ts b/packages/codemirror-json-schema/src/parsers/index.ts new file mode 100644 index 00000000..98eaf413 --- /dev/null +++ b/packages/codemirror-json-schema/src/parsers/index.ts @@ -0,0 +1,22 @@ +import { JSONMode, JSONPointersMap } from "../types"; +import { MODES } from "../constants"; +import { EditorState } from "@codemirror/state"; +import { parseJSONDocumentState } from "./json-parser"; +import { parseJSON5DocumentState } from "./json5-parser"; +import { parseYAMLDocumentState } from "./yaml-parser"; + +export const getDefaultParser = (mode: JSONMode): DocumentParser => { + switch (mode) { + case MODES.JSON: + return parseJSONDocumentState; + case MODES.JSON5: + return parseJSON5DocumentState; + case MODES.YAML: + return parseYAMLDocumentState; + } +}; + +export type DocumentParser = (state: EditorState) => { + data: unknown; + pointers: JSONPointersMap; +}; diff --git a/packages/codemirror-json-schema/src/parsers/json-parser.ts b/packages/codemirror-json-schema/src/parsers/json-parser.ts new file mode 100644 index 00000000..471bffc8 --- /dev/null +++ b/packages/codemirror-json-schema/src/parsers/json-parser.ts @@ -0,0 +1,35 @@ +import { json } from "@codemirror/lang-json"; +import { EditorState } from "@codemirror/state"; +import { parse } from "best-effort-json-parser"; + +import { MODES } from "../constants"; +import { getJsonPointers } from "../utils/json-pointers"; + +/** + * Return parsed data and json pointers for a given codemirror EditorState + * @group Utilities + */ +export function parseJSONDocumentState(state: EditorState) { + let data = null; + try { + data = JSON.parse(state.doc.toString()); + // return pointers regardless of whether JSON.parse succeeds + } catch { + try { + data = parse(state.doc.toString()); + } catch { + // noop + } + } + const pointers = getJsonPointers(state, MODES.JSON); + return { data, pointers }; +} + +/** + * Mimics the behavior of `json-source-map`'s `parseJSONDocument` function using codemirror EditorState + * @group Utilities + */ +export function parseJSONDocument(jsonString: string) { + const state = EditorState.create({ doc: jsonString, extensions: [json()] }); + return parseJSONDocumentState(state); +} diff --git a/packages/codemirror-json-schema/src/parsers/json5-parser.ts b/packages/codemirror-json-schema/src/parsers/json5-parser.ts new file mode 100644 index 00000000..0a934c8b --- /dev/null +++ b/packages/codemirror-json-schema/src/parsers/json5-parser.ts @@ -0,0 +1,44 @@ +/** + * Mimics the behavior of `json-source-map`'s `parseJSONDocument` function using codemirror EditorState... for json5 + */ + +import { json5 as json5mode } from "codemirror-json5"; +import json5 from "json5"; +import { EditorState } from "@codemirror/state"; +import { parse as bestEffortParse } from "best-effort-json-parser"; +import { getJsonPointers } from "../utils/json-pointers"; +import { MODES } from "../constants"; + +/** + * Return parsed data and json5 pointers for a given codemirror EditorState + * @group Utilities + */ +export function parseJSON5DocumentState(state: EditorState) { + const stateDoc = state.doc.toString(); + + let data = null; + try { + data = json5.parse(stateDoc); + } catch { + // try again with best-effort strategy + try { + data = bestEffortParse(stateDoc); + } catch { + // return pointers regardless of whether JSON.parse succeeds + } + } + const pointers = getJsonPointers(state, MODES.JSON5); + return { data, pointers }; +} + +/** + * Mimics the behavior of `json-source-map`'s `parseJSONDocument` function, for json5! + * @group Utilities + */ +export function parseJSON5Document(jsonString: string) { + const state = EditorState.create({ + doc: jsonString, + extensions: [json5mode()], + }); + return parseJSON5DocumentState(state); +} diff --git a/packages/codemirror-json-schema/src/parsers/yaml-parser.ts b/packages/codemirror-json-schema/src/parsers/yaml-parser.ts new file mode 100644 index 00000000..1b66fc2c --- /dev/null +++ b/packages/codemirror-json-schema/src/parsers/yaml-parser.ts @@ -0,0 +1,24 @@ +/** + * Mimics the behavior of `json-source-map`'s `parseJSONDocument` function using codemirror EditorState... for YAML + */ +import { EditorState } from "@codemirror/state"; +import YAML from "yaml"; + +import { MODES } from "../constants"; +import { getJsonPointers } from "../utils/json-pointers"; + +/** + * Return parsed data and YAML pointers for a given codemirror EditorState + * @group Utilities + */ +export function parseYAMLDocumentState(state: EditorState) { + let data = null; + try { + data = YAML.parse(state.doc.toString()); + // return pointers regardless of whether YAML.parse succeeds + } catch { + // noop + } + const pointers = getJsonPointers(state, MODES.YAML); + return { data, pointers }; +} diff --git a/packages/codemirror-json-schema/src/types.ts b/packages/codemirror-json-schema/src/types.ts new file mode 100644 index 00000000..26161e36 --- /dev/null +++ b/packages/codemirror-json-schema/src/types.ts @@ -0,0 +1,24 @@ +import { MODES } from "./constants"; + +export type RequiredPick = Omit & + Required>; + +export type JSONPartialPointerData = { + keyFrom: number; + keyTo: number; +}; + +export type JSONPointerData = { + keyFrom: number; + keyTo: number; + valueFrom: number; + valueTo: number; +}; + +export type Side = -1 | 1 | 0 | undefined; + +export type JSONPointersMap = Map< + string, + JSONPointerData | JSONPartialPointerData +>; +export type JSONMode = (typeof MODES)[keyof typeof MODES]; diff --git a/packages/codemirror-json-schema/src/types/codemirror-json5.d.ts b/packages/codemirror-json-schema/src/types/codemirror-json5.d.ts new file mode 100644 index 00000000..d095ce18 --- /dev/null +++ b/packages/codemirror-json-schema/src/types/codemirror-json5.d.ts @@ -0,0 +1,8 @@ +declare module "codemirror-json5" { + import type { LanguageSupport, LRLanguage } from "@codemirror/language"; + import type { Linter } from "@codemirror/lint"; + + export function json5(): LanguageSupport; + export const json5Language: LRLanguage; + export function json5ParseLinter(): Linter; +} diff --git a/packages/codemirror-json-schema/src/utils/__tests__/json-pointers.spec.ts b/packages/codemirror-json-schema/src/utils/__tests__/json-pointers.spec.ts new file mode 100644 index 00000000..99b34668 --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/__tests__/json-pointers.spec.ts @@ -0,0 +1,291 @@ +import { EditorState } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; + +import { MODES } from "../../constants"; +import { getExtensions } from "../../features/__tests__/__helpers__/index"; +import { getJsonPointers, jsonPointerForPosition } from "../json-pointers"; + +describe("jsonPointerForPosition", () => { + it.each([ + { + name: "simple", + doc: '{"object": { "foo": true }, "bar": 123}', + pos: 14, + expected: "/object/foo", + mode: MODES.JSON, + }, + { + name: "associative array", + doc: '[{"object": [{ "foo": true }], "bar": 123}]', + pos: 16, + expected: "/0/object/0/foo", + mode: MODES.JSON, + }, + { + name: "deep associative array", + doc: '[{"object": [{ "foo": { "example": true } }], "bar": 123}]', + pos: 27, + expected: "/0/object/0/foo/example", + mode: MODES.JSON, + }, + { + name: "simple - json5", + doc: '{"object": { "foo": true }, "bar": 123}', + pos: 14, + expected: "/object/foo", + mode: MODES.JSON5, + }, + { + name: "associative array - json5", + doc: '[{"object": [{ "foo": true }], "bar": 123}]', + pos: 16, + expected: "/0/object/0/foo", + mode: MODES.JSON5, + }, + { + name: "deep associative array - json5", + doc: '[{"object": [{ "foo": { example: true } }], "bar": 123}]', + pos: 25, + expected: "/0/object/0/foo/example", + mode: MODES.JSON5, + }, + // ... + { + name: "simple - yaml", + doc: `--- +object: + foo: true +bar: 123 +`, + pos: 14, + expected: "/object/foo", + mode: MODES.YAML, + }, + { + name: "associative array - yaml", + doc: `--- +- object: + - foo: true + bar: 123 +`, + pos: 20, + expected: "/0/object/0/foo", + mode: MODES.YAML, + }, + { + name: "deep associative array - yaml", + doc: `--- +- object: + - foo: + example: true + bar: 123 +`, + pos: 34, + expected: "/0/object/0/foo/example", + mode: MODES.YAML, + }, + ])( + "should return full pointer path for a position for $name, mode: $mode", + ({ doc, mode, pos, expected }) => { + const state = EditorState.create({ + doc, + extensions: [getExtensions(mode)], + }); + const pointer = jsonPointerForPosition(state, pos, 1, mode); + expect(pointer).toEqual(expected); + }, + ); +}); + +describe("getJsonPointers", () => { + it.each([ + { + doc: '{"object": { "foo": true }, "bar": 123, "baz": [1,2,3], "boop": [{"foo": true}]}', + mode: MODES.JSON, + expected: { + "": { + keyFrom: 0, + keyTo: 80, + }, + "/bar": { + keyFrom: 28, + keyTo: 33, + valueFrom: 35, + valueTo: 38, + }, + "/baz": { + keyFrom: 40, + keyTo: 45, + valueFrom: 47, + valueTo: 54, + }, + // TODO: return pointers for all array indexes, not just objects + // "/baz/0": { + // keyFrom: 40, + // keyTo: 45, + // valueFrom: 47, + // valueTo: 55, + // }, + "/boop": { + keyFrom: 56, + keyTo: 62, + valueFrom: 64, + valueTo: 79, + }, + "/boop/0": { + keyFrom: 65, + keyTo: 78, + // TODO: These look erroneous. There is no key-value pair for array items + valueFrom: 78, + valueTo: 79, + }, + "/boop/0/foo": { + keyFrom: 66, + keyTo: 71, + valueFrom: 73, + valueTo: 77, + }, + "/object": { + keyFrom: 11, + keyTo: 26, + }, + "/object/foo": { + keyFrom: 13, + keyTo: 18, + valueFrom: 20, + valueTo: 24, + }, + }, + }, + { + doc: `{"object": { foo: true }, bar: 123, 'baz': [1,2,3], boop: [{foo: true}]}`, + mode: MODES.JSON5, + expected: { + "": { + keyFrom: 0, + keyTo: 72, + }, + "/bar": { + keyFrom: 26, + keyTo: 29, + valueFrom: 31, + valueTo: 34, + }, + "/baz": { + keyFrom: 36, + keyTo: 41, + valueFrom: 43, + valueTo: 50, + }, + // TODO: return pointers for all array indexes, not just objects + // "/baz/0": { + // keyFrom: 40, + // keyTo: 45, + // valueFrom: 47, + // valueTo: 55, + // }, + "/boop": { + keyFrom: 52, + keyTo: 56, + valueFrom: 58, + valueTo: 71, + }, + "/boop/0": { + keyFrom: 59, + keyTo: 70, + }, + "/boop/0/foo": { + keyFrom: 60, + keyTo: 63, + valueFrom: 65, + valueTo: 69, + }, + "/object": { + keyFrom: 11, + keyTo: 24, + }, + "/object/foo": { + keyFrom: 13, + keyTo: 16, + valueFrom: 18, + valueTo: 22, + }, + }, + }, + { + doc: `--- +object: + foo: true +bar: 123 +baz: + - 1 + - 2 + - 3 +boop: + - foo: true +`, + mode: MODES.YAML, + expected: { + "": { + keyFrom: 3, + keyTo: 75, + }, + "/bar": { + keyFrom: 24, + keyTo: 27, + valueFrom: 29, + valueTo: 32, + }, + "/baz": { + keyFrom: 33, + keyTo: 36, + valueFrom: 40, + valueTo: 55, + }, + // TODO: return pointers for all array indexes, not just objects + // "/baz/0": { + // keyFrom: 40, + // keyTo: 45, + // valueFrom: 47, + // valueTo: 55, + // }, + "/boop": { + keyFrom: 56, + keyTo: 60, + valueFrom: 64, + valueTo: 75, + }, + "/boop/0": { + keyFrom: 65, + keyTo: 75, + }, + "/boop/0/foo": { + keyFrom: 66, + keyTo: 69, + valueFrom: 71, + valueTo: 75, + }, + "/object": { + keyFrom: 11, + keyTo: 23, + }, + "/object/foo": { + keyFrom: 14, + keyTo: 17, + valueFrom: 19, + valueTo: 23, + }, + }, + }, + ])( + "should return a map of all pointers for a document (mode: $mode)", + ({ doc, mode, expected }) => { + const state = EditorState.create({ + doc, + extensions: [getExtensions(mode)], + }); + const pointers = getJsonPointers(state, mode); + expect(Object.fromEntries(pointers.entries())).toEqual(expected); + }, + ); +}); diff --git a/packages/codemirror-json-schema/src/utils/__tests__/node.spec.ts b/packages/codemirror-json-schema/src/utils/__tests__/node.spec.ts new file mode 100644 index 00000000..4534e61c --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/__tests__/node.spec.ts @@ -0,0 +1,184 @@ +import { EditorState } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; + +import { MODES } from "../../constants"; +import { getExtensions } from "../../features/__tests__/__helpers__"; +import { JSONMode } from "../../types"; +import { getNodeAtPosition } from "../node"; + +// complex data structure for testing. Keep these in sync. +const testJsonData = ` +{ + "bog": { + "array": [ + { + "foo": true + }, + { + "bar": 123 + } + ] + }, + "bar": 123, + "baz": [1, 2, 3] +} +`; + +const testJson5Data = ` +{ + bog: { + array: [ + { + 'foo': true + }, + { + 'bar': 123 + } + ] + }, + 'bar': 123, + 'baz': [1, 2, 3] +} +`; + +const testYamlData = `--- +bog: + array: + - foo: true + - bar: 123 +bar: 123 +baz: [1, 2, 3] +`; + +const getTestData = (mode: JSONMode) => { + switch (mode) { + case MODES.JSON: + return testJsonData; + case MODES.JSON5: + return testJson5Data; + case MODES.YAML: + return testYamlData; + } +}; + +describe("getNodeAtPosition", () => { + it.each([ + { + mode: MODES.JSON, + pos: 1, + expectedName: "JsonText", + }, + { + mode: MODES.JSON, + pos: 6, + expectedName: "PropertyName", + }, + { + mode: MODES.JSON, + pos: 13, + expectedName: "{", + }, + { + mode: MODES.JSON, + pos: 28, + expectedName: "[", + }, + { + mode: MODES.JSON, + pos: 53, + expectedName: "True", + }, + { + mode: MODES.JSON, + pos: 121, + expectedName: "Property", + }, + // JSON5 + { + mode: MODES.JSON5, + pos: 1, + expectedName: "File", + }, + { + mode: MODES.JSON5, + pos: 6, + expectedName: "PropertyName", + }, + { + mode: MODES.JSON5, + pos: 11, + expectedName: "{", + }, + { + mode: MODES.JSON5, + pos: 24, + expectedName: "[", + }, + { + mode: MODES.JSON5, + pos: 49, + expectedName: "True", + }, + { + mode: MODES.JSON5, + pos: 85, + expectedName: "Property", + }, + // YAML + { + mode: MODES.YAML, + pos: 1, + expectedName: "DirectiveEnd", + }, + { + mode: MODES.YAML, + pos: 4, + expectedName: "BlockMapping", + }, + { + mode: MODES.YAML, + pos: 5, + expectedName: "Literal", + }, + { + mode: MODES.YAML, + pos: 11, + expectedName: "BlockMapping", + }, + { + mode: MODES.YAML, + pos: 16, + expectedName: "Literal", + }, + { + mode: MODES.YAML, + pos: 22, + expectedName: "Pair", + }, + { + mode: MODES.YAML, + pos: 30, + expectedName: "Literal", + }, + { + mode: MODES.YAML, + pos: 38, + expectedName: "BlockSequence", + }, + { + mode: MODES.YAML, + pos: 54, + expectedName: "Pair", + }, + ])( + "should return node at position $pos (mode: $mode)", + ({ mode, expectedName, pos }) => { + const state = EditorState.create({ + doc: getTestData(mode), + extensions: [getExtensions(mode)], + }); + const node = getNodeAtPosition(state, pos); + expect(node.name).toBe(expectedName); + }, + ); +}); diff --git a/packages/codemirror-json-schema/src/utils/debug.ts b/packages/codemirror-json-schema/src/utils/debug.ts new file mode 100644 index 00000000..afa6a0d0 --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/debug.ts @@ -0,0 +1,4 @@ +import log from "loglevel"; + +log.setLevel(process.env.NODE_ENV !== "development" ? "silent" : "debug"); +export const debug = log; diff --git a/packages/codemirror-json-schema/src/utils/dom.ts b/packages/codemirror-json-schema/src/utils/dom.ts new file mode 100644 index 00000000..9e154e35 --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/dom.ts @@ -0,0 +1,22 @@ +type Attributes = "class" | "text" | "id" | "role" | "aria-label" | "inner"; + +export function el( + tagName: string, + attributes: Partial>, + children: HTMLElement[] = [] +) { + const e = document.createElement(tagName); + Object.entries(attributes).forEach(([k, v]) => { + if (k === "text") { + e.innerText = v; + return; + } + if (k === "inner") { + e.innerHTML = v; + return; + } + e.setAttribute(k, v); + }); + children.forEach((c) => e.appendChild(c)); + return e; +} diff --git a/packages/codemirror-json-schema/src/utils/formatting.ts b/packages/codemirror-json-schema/src/utils/formatting.ts new file mode 100644 index 00000000..6d640eae --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/formatting.ts @@ -0,0 +1,18 @@ +// a little english-centric utility +// to join members of an array with commas and "or" +export const joinWithOr = ( + arr: T[], + getPath?: (item: T) => string, +): string => { + const needsComma = arr.length > 2; + const data = arr.map((item, i) => { + const value = getPath ? getPath(item) : String(item); + const result = `\`${value}\``; + if (i === arr.length - 1) return "or " + result; + return result; + }); + if (needsComma) { + return data.join(", "); + } + return data.join(" "); +}; diff --git a/packages/codemirror-json-schema/src/utils/json-pointers.ts b/packages/codemirror-json-schema/src/utils/json-pointers.ts new file mode 100644 index 00000000..3ef8aa0e --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/json-pointers.ts @@ -0,0 +1,165 @@ +import { syntaxTree } from "@codemirror/language"; +import { EditorState, Text } from "@codemirror/state"; +import { SyntaxNode, SyntaxNodeRef } from "@lezer/common"; + +import { + JSON5_TOKENS_MAPPING, + MODES, + TOKENS, + YAML_TOKENS_MAPPING, +} from "../constants"; +import { JSONMode, JSONPointersMap, Side } from "../types"; + +import { + findNodeIndexInArrayNode, + getMatchingChildNode, + getWord, + isValueNode, +} from "./node"; + +export const resolveTokenName = (nodeName: string, mode: JSONMode) => { + switch (mode) { + case MODES.YAML: + return YAML_TOKENS_MAPPING[nodeName] ?? nodeName; + case MODES.JSON5: + return JSON5_TOKENS_MAPPING[nodeName] ?? nodeName; + default: + return nodeName; + } +}; + +// adapted from https://discuss.codemirror.net/t/json-pointer-at-cursor-seeking-implementation-critique/4793/3 +// this could be useful for other things later! +export function getJsonPointerAt( + docText: Text, + node: SyntaxNode, + mode: JSONMode, +): string { + const path: string[] = []; + for (let n: SyntaxNode | null = node; n?.parent; n = n.parent) { + switch (resolveTokenName(n.parent.name, mode)) { + case TOKENS.PROPERTY: { + const name = getMatchingChildNode(n.parent, TOKENS.PROPERTY_NAME, mode); + if (name) { + const word = getWord(docText, name).replace(/[/~]/g, (v: string) => + v === "~" ? "~0" : "~1", + ); + // TODO generally filter out pointers to objects being started? + // if (word !== '') { + path.unshift(word); + // } + } + break; + } + case TOKENS.ARRAY: { + if (isValueNode(n, mode)) { + const index = findNodeIndexInArrayNode(n.parent, n, mode); + path.unshift(`${index}`); + } + break; + } + } + } + if (path.length === 0) { + // TODO json-schema-library does not seem to like / as root pointer (it probably just uses split and it will return two empty strings). So is this fine? And why is it not prefixed with #? + return ""; + } + return "/" + path.join("/"); +} + +/** + * retrieve a JSON pointer for a given position in the editor + * @group Utilities + */ +export const jsonPointerForPosition = ( + state: EditorState, + pos: number, + side: Side = -1, + mode: JSONMode, +) => { + return getJsonPointerAt( + state.doc, + syntaxTree(state).resolve(pos, side), + mode, + ); +}; + +/** + * retrieve a Map of all the json pointers in a document + * @group Utilities + */ +export const getJsonPointers = ( + state: EditorState, + mode: JSONMode, +): JSONPointersMap => { + const tree = syntaxTree(state); + const pointers: JSONPointersMap = new Map(); + tree.iterate({ + enter: (type: SyntaxNodeRef) => { + const resolvedName = resolveTokenName(type.name, mode); + if ( + resolvedName === TOKENS.PROPERTY_NAME || + resolvedName === TOKENS.OBJECT + ) { + const pointer = getJsonPointerAt(state.doc, type.node, mode); + + const { from: keyFrom, to: keyTo } = type.node; + + if (resolvedName === TOKENS.OBJECT) { + // For object nodes, use the "next sibling" heuristic (this matches the original + // library behavior and keeps existing pointer-range expectations stable). + const nextNode = + mode === MODES.JSON + ? type.node?.nextSibling?.node + : type.node?.nextSibling?.node?.nextSibling?.node; + + if (!nextNode) { + pointers.set(pointer, { keyFrom, keyTo }); + return true; + } + + const { from: valueFrom, to: valueTo } = nextNode as SyntaxNode; + pointers.set(pointer, { keyFrom, keyTo, valueFrom, valueTo }); + return true; + } + + // PropertyName: find the next node that represents a value (skipping ":" tokens, etc). + let valueNode: SyntaxNode | undefined; + for ( + let sib = type.node.nextSibling; + sib != null; + sib = sib.nextSibling + ) { + if (isValueNode(sib.node, mode)) { + valueNode = sib.node; + break; + } + } + + // Fallback: search within parent node (grammar-dependent). + if (!valueNode && type.node.parent) { + for ( + let child = type.node.parent.firstChild; + child != null; + child = child.nextSibling + ) { + if (isValueNode(child, mode)) { + valueNode = child; + break; + } + } + } + + if (!valueNode) { + pointers.set(pointer, { keyFrom, keyTo }); + return true; + } + + const { from: valueFrom, to: valueTo } = valueNode; + pointers.set(pointer, { keyFrom, keyTo, valueFrom, valueTo }); + return true; + } + }, + }); + return pointers; +}; diff --git a/packages/codemirror-json-schema/src/utils/markdown.ts b/packages/codemirror-json-schema/src/utils/markdown.ts new file mode 100644 index 00000000..c2234ce6 --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/markdown.ts @@ -0,0 +1,52 @@ +import { fromHighlighter } from "@shikijs/markdown-it/core"; +import md from "markdown-it"; +import { HighlighterGeneric, createHighlighterCore } from "shiki/core"; + +// const defaultPlugins = [ +// "markdown-it-abbr", +// "markdown-it-deflist", +// "markdown-it-emoji", +// "markdown-it-footnote", +// "markdown-it-ins", +// "markdown-it-mark", +// "markdown-it-sub", +// "markdown-it-sup", +// "markdown-it-task-lists", +// "markdown-it-toc", +// "markdown-it-attrs", +// "markdown-it-katex", +// "markdown-it-external-links", +// "markdown-it-table-of-contents", +// "markdown-it-anchor", +// "markdown-it-implicit-figures", +// "markdown-it-video", +// "markdown-it-highlightjs", +// ]; + +const renderer = md({ + linkify: true, + typographer: true, +}); + +(async () => { + const highlighter = (await createHighlighterCore({ + themes: [ + import("shiki/themes/vitesse-light.mjs"), + import("shiki/themes/vitesse-dark.mjs"), + ], + langs: [import("shiki/langs/javascript.mjs")], + })) as HighlighterGeneric; + renderer.use( + fromHighlighter(highlighter, { + themes: { + light: "vitesse-light", + dark: "vitesse-dark", + }, + }), + ); +})(); + +export function renderMarkdown(markdown: string, inline: boolean = true) { + if (!inline) return renderer.render(markdown); + return renderer.renderInline(markdown); +} diff --git a/packages/codemirror-json-schema/src/utils/node.ts b/packages/codemirror-json-schema/src/utils/node.ts new file mode 100644 index 00000000..b292fb30 --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/node.ts @@ -0,0 +1,150 @@ +import { syntaxTree } from "@codemirror/language"; +import { EditorState, Text } from "@codemirror/state"; +import { SyntaxNode } from "@lezer/common"; + +import { COMPLEX_TYPES, MODES, PRIMITIVE_TYPES, TOKENS } from "../constants"; +import { JSONMode, Side } from "../types"; + +import { resolveTokenName } from "./json-pointers"; + +export const getNodeAtPosition = ( + state: EditorState, + pos: number, + side: Side = -1, +) => { + return syntaxTree(state).resolveInner(pos, side); +}; + +export const stripSurroundingQuotes = (str: string) => { + return str.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); +}; +export const surroundingDoubleQuotesToSingle = (str: string) => { + return str.replace(/^"(.*)"$/, "'$1'"); +}; + +export const getWord = ( + doc: Text, + node: SyntaxNode | null, + stripQuotes = true, + onlyEvenQuotes = true, +) => { + const word = node ? doc.sliceString(node.from, node.to) : ""; + if (!stripQuotes) { + return word; + } + if (onlyEvenQuotes) { + return stripSurroundingQuotes(word); + } + return word.replace(/(^["'])|(["']$)/g, ""); +}; + +export const isInvalidValueNode = (node: SyntaxNode, mode: JSONMode) => { + return ( + resolveTokenName(node.name, mode) === TOKENS.INVALID && + (resolveTokenName(node.prevSibling?.name ?? "", mode) === + TOKENS.PROPERTY_NAME || + resolveTokenName(node.prevSibling?.name ?? "", mode) === + TOKENS.PROPERTY_COLON) + ); +}; + +export const isPrimitiveValueNode = (node: SyntaxNode, mode: JSONMode) => { + return ( + PRIMITIVE_TYPES.includes(resolveTokenName(node.name, mode)) || + isInvalidValueNode(node, mode) + ); +}; + +export const isValueNode = (node: SyntaxNode, mode: JSONMode) => { + return ( + [...PRIMITIVE_TYPES, ...COMPLEX_TYPES].includes( + resolveTokenName(node.name, mode), + ) || isInvalidValueNode(node, mode) + ); +}; + +export const isPropertyNameNode = (node: SyntaxNode, mode: JSONMode) => { + return ( + resolveTokenName(node.name, mode) === TOKENS.PROPERTY_NAME || + (resolveTokenName(node.name, mode) === TOKENS.INVALID && + (resolveTokenName(node.prevSibling?.name ?? "", mode) === + TOKENS.PROPERTY || + resolveTokenName(node.prevSibling?.name ?? "", mode) === "{")) || + // TODO: Can we make this work without checking for the mode? + (mode === MODES.YAML && + resolveTokenName(node.parent?.name ?? "", mode) === TOKENS.OBJECT) + ); +}; + +export const getChildrenNodes = (node: SyntaxNode) => { + const children = []; + let child = node.firstChild; + while (child) { + if (child) { + children.push(child); + } + child = child?.nextSibling; + } + + return children; +}; + +export const getMatchingChildrenNodes = ( + node: SyntaxNode, + nodeName: string, + mode: JSONMode, +) => { + return getChildrenNodes(node).filter( + (n) => resolveTokenName(n.name, mode) === nodeName, + ); +}; + +export const getMatchingChildNode = ( + node: SyntaxNode, + nodeName: string, + mode: JSONMode, +) => { + return ( + getChildrenNodes(node).find( + (n) => resolveTokenName(n.name, mode) === nodeName, + ) ?? null + ); +}; + +export const getChildValueNode = (node: SyntaxNode, mode: JSONMode) => { + return getChildrenNodes(node).find((n) => isPrimitiveValueNode(n, mode)); +}; + +const getArrayNodeChildren = (node: SyntaxNode, mode: JSONMode) => { + return getChildrenNodes(node).filter( + (n) => + PRIMITIVE_TYPES.includes(resolveTokenName(n.name, mode)) || + COMPLEX_TYPES.includes(resolveTokenName(n.name, mode)), + ); +}; +export const findNodeIndexInArrayNode = ( + arrayNode: SyntaxNode, + valueNode: SyntaxNode, + mode: JSONMode, +) => { + return getArrayNodeChildren(arrayNode, mode).findIndex( + (nd) => nd.from === valueNode.from && nd.to === valueNode.to, + ); +}; + +export const getClosestNode = ( + node: SyntaxNode, + nodeName: string, + mode: JSONMode, + depth = Infinity, +) => { + let n: SyntaxNode | null = node; + while (n && depth > 0) { + if (resolveTokenName(n.name, mode) === nodeName) { + return n; + } + n = n.parent; + depth--; + } + return null; +}; diff --git a/packages/codemirror-json-schema/src/utils/recordUtil.ts b/packages/codemirror-json-schema/src/utils/recordUtil.ts new file mode 100644 index 00000000..7a0f7c61 --- /dev/null +++ b/packages/codemirror-json-schema/src/utils/recordUtil.ts @@ -0,0 +1,103 @@ +export function getRecordEntries( + record: Record, +): [K, V][] { + return Object.entries(record) as unknown as [K, V][]; +} + +export type PropertyReplacer = ( + key: string | symbol, + value: unknown, +) => [string | symbol, unknown] | [string | symbol, unknown][]; + +export function replacePropertiesDeeply( + object: T, + getReplacement: PropertyReplacer, +): T { + return replacePropertiesDeeplyInternal( + object as unknown, + getReplacement, + ) as T; +} + +function replacePropertiesDeeplyInternal( + object: unknown, + getReplacement: PropertyReplacer, +): unknown { + if (typeof object === "string") { + return object; + } + if (typeof object !== "object" || object === null) { + return object; + } + if (Array.isArray(object)) { + return object.map((element) => + replacePropertiesDeeplyInternal(element, getReplacement), + ); + } + if (object instanceof Map) { + const newMap = new Map(); + for (const [key, value] of object) { + const newValue = replacePropertiesDeeplyInternal(value, getReplacement); + newMap.set(key, newValue); + } + return newMap; + } + if (object instanceof Set) { + const newSet = new Set(); + for (const value of object) { + const newValue = replacePropertiesDeeplyInternal(value, getReplacement); + newSet.add(newValue); + } + return newSet; + } + + const newObject: Record = {}; + const recordEntries = getRecordEntries(object as Record); + + function handleReplacementEntry( + oldKey: string | symbol, + oldValue: unknown, + newKey: string | symbol, + newValue: unknown, + ) { + if (newKey === oldKey && newValue === oldValue) { + newObject[newKey] = replacePropertiesDeeplyInternal( + oldValue, + getReplacement, + ); + } else { + newObject[newKey] = newValue; + } + } + + for (const [key, value] of recordEntries) { + const replacement = getReplacement(key, value); + + // PropertyReplacer can return a single entry tuple or an array of tuples + if (Array.isArray(replacement[0])) { + for (const [newKey, newValue] of replacement as Array< + [string | symbol, unknown] + >) { + handleReplacementEntry(key, value, newKey, newValue); + } + } else { + const [newKey, newValue] = replacement as [string | symbol, unknown]; + handleReplacementEntry(key, value, newKey, newValue); + } + } + + return newObject; +} + +export function removeUndefinedValuesOnRecord( + record: Record, +): Record { + const newRecord = {} as Record; + for (const [key, value] of getRecordEntries(record)) { + if (value === undefined) { + continue; + } + newRecord[key] = value; + } + return newRecord; +} diff --git a/packages/codemirror-json-schema/src/yaml/bundled.ts b/packages/codemirror-json-schema/src/yaml/bundled.ts new file mode 100644 index 00000000..a5d0d257 --- /dev/null +++ b/packages/codemirror-json-schema/src/yaml/bundled.ts @@ -0,0 +1,28 @@ +import { JSONSchema7 } from "json-schema"; +import { yaml, yamlLanguage } from "@codemirror/lang-yaml"; +import { hoverTooltip } from "@codemirror/view"; +import { handleRefresh } from "../features/validation"; +import { stateExtensions } from "../features/state"; + +import { linter } from "@codemirror/lint"; +import { yamlSchemaLinter } from "./validation"; +import { yamlCompletion } from "./completion"; +import { yamlSchemaHover } from "./hover"; + +/** + * Full featured cm6 extension for json, including `@codemirror/lang-json` + * @group Bundled Codemirror Extensions + */ +export function yamlSchema(schema?: JSONSchema7) { + return [ + yaml(), + linter(yamlSchemaLinter(), { + needsRefresh: handleRefresh, + }), + yamlLanguage.data.of({ + autocomplete: yamlCompletion(), + }), + hoverTooltip(yamlSchemaHover()), + stateExtensions(schema), + ]; +} diff --git a/packages/codemirror-json-schema/src/yaml/completion.ts b/packages/codemirror-json-schema/src/yaml/completion.ts new file mode 100644 index 00000000..dbf5a30f --- /dev/null +++ b/packages/codemirror-json-schema/src/yaml/completion.ts @@ -0,0 +1,14 @@ +import { CompletionContext } from "@codemirror/autocomplete"; +import { MODES } from "../constants"; +import { JSONCompletion, JSONCompletionOptions } from "../features/completion"; + +/** + * provides a JSON schema enabled autocomplete extension for codemirror and yaml + * @group Codemirror Extensions + */ +export function yamlCompletion(opts: Omit = {}) { + const completion = new JSONCompletion({ ...opts, mode: MODES.YAML }); + return function jsonDoCompletion(ctx: CompletionContext) { + return completion.doComplete(ctx); + }; +} diff --git a/packages/codemirror-json-schema/src/yaml/hover.ts b/packages/codemirror-json-schema/src/yaml/hover.ts new file mode 100644 index 00000000..82dd476c --- /dev/null +++ b/packages/codemirror-json-schema/src/yaml/hover.ts @@ -0,0 +1,22 @@ +import { type EditorView } from "@codemirror/view"; +import { type HoverOptions, JSONHover } from "../features/hover"; +import YAML from "yaml"; +import { Side } from "../types"; +import { MODES } from "../constants"; + +export type YAMLHoverOptions = Exclude; + +/** + * Instantiates a JSONHover instance with the YAML mode + * @group Codemirror Extensions + */ +export function yamlSchemaHover(options?: YAMLHoverOptions) { + const hover = new JSONHover({ + ...options, + parser: YAML.parse, + mode: MODES.YAML, + }); + return async function jsonDoHover(view: EditorView, pos: number, side: Side) { + return hover.doHover(view, pos, side); + }; +} diff --git a/packages/codemirror-json-schema/src/yaml/index.ts b/packages/codemirror-json-schema/src/yaml/index.ts new file mode 100644 index 00000000..23d0fa89 --- /dev/null +++ b/packages/codemirror-json-schema/src/yaml/index.ts @@ -0,0 +1,11 @@ +// yaml +export { yamlSchemaLinter } from "./validation"; +export { yamlSchemaHover } from "./hover"; +export { yamlCompletion } from "./completion"; + +/** + * @group Bundled Codemirror Extensions + */ +export { yamlSchema } from "./bundled"; + +export * from "../parsers/yaml-parser"; diff --git a/packages/codemirror-json-schema/src/yaml/validation.ts b/packages/codemirror-json-schema/src/yaml/validation.ts new file mode 100644 index 00000000..80213be1 --- /dev/null +++ b/packages/codemirror-json-schema/src/yaml/validation.ts @@ -0,0 +1,22 @@ +import { EditorView } from "@codemirror/view"; +import { + JSONValidation, + type JSONValidationOptions, +} from "../features/validation"; +import { MODES } from "../constants"; +import { parseYAMLDocumentState } from "../parsers/yaml-parser"; + +/** + * Instantiates a JSONValidation instance with the YAML mode + * @group Codemirror Extensions + */ +export function yamlSchemaLinter(options?: JSONValidationOptions) { + const validation = new JSONValidation({ + jsonParser: parseYAMLDocumentState, + mode: MODES.YAML, + ...options, + }); + return (view: EditorView) => { + return validation.doValidation(view); + }; +} diff --git a/packages/codemirror-json-schema/tsconfig.json b/packages/codemirror-json-schema/tsconfig.json new file mode 100644 index 00000000..73742c33 --- /dev/null +++ b/packages/codemirror-json-schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@knocklabs/typescript-config/base.json", + "include": ["src"], + "exclude": ["**/__tests__/**"], + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/packages/codemirror-json-schema/vite.config.mts b/packages/codemirror-json-schema/vite.config.mts new file mode 100644 index 00000000..c3d4fb59 --- /dev/null +++ b/packages/codemirror-json-schema/vite.config.mts @@ -0,0 +1,70 @@ +/// +import { codecovVitePlugin } from "@codecov/vite-plugin"; +import { resolve } from "path"; +import { LibraryFormats, defineConfig, loadEnv } from "vite"; +import dts from "vite-plugin-dts"; +import noBundlePlugin from "vite-plugin-no-bundle"; + +const ENTRYPOINTS = { + index: resolve(__dirname, "src/index.ts"), + "json5/index": resolve(__dirname, "src/json5/index.ts"), + "yaml/index": resolve(__dirname, "src/yaml/index.ts"), +}; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const CJS = env.BUILD_TARGET?.toLocaleLowerCase()?.match("cjs"); + const formats: LibraryFormats[] = CJS ? ["cjs"] : ["es"]; + + return { + plugins: [ + dts({ + outDir: "dist/types", + }), + noBundlePlugin({ root: "./src" }), + codecovVitePlugin({ + enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, + bundleName: "@knocklabs/codemirror-json-schema", + uploadToken: process.env.CODECOV_TOKEN, + }), + ], + build: { + outDir: CJS ? "dist/cjs" : "dist/esm", + sourcemap: true, + lib: { + entry: ENTRYPOINTS, + fileName: `[name]`, + name: "codemirror-json-schema", + formats, + }, + rollupOptions: { + // External packages that should not be bundled + external: [ + "@codemirror/autocomplete", + "@codemirror/lang-json", + "@codemirror/lang-yaml", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "codemirror-json5", + "json5", + ], + output: { + interop: "compat", + entryFileNames: () => { + return `[name].${CJS ? "js" : "mjs"}`; + }, + // Override to allow named and default exports in the same file + exports: "named", + }, + }, + }, + test: { + globals: true, + environment: "jsdom", + }, + }; +}); diff --git a/yarn.lock b/yarn.lock index 9ec27914..bd982914 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2317,6 +2317,113 @@ __metadata: languageName: node linkType: hard +"@codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.16.2": + version: 6.20.0 + resolution: "@codemirror/autocomplete@npm:6.20.0" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.17.0" + "@lezer/common": "npm:^1.0.0" + checksum: 10c0/d0d1cf3eca6269811eb66edcf742ffa0a5423d7d115ab82b0d62a24d6cfcfb2a4c3779333b2cb68e3004af46556ac6203049f581d35785c46ffd1b852f6e8076 + languageName: node + linkType: hard + +"@codemirror/commands@npm:^6.6.0": + version: 6.10.1 + resolution: "@codemirror/commands@npm:6.10.1" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.4.0" + "@codemirror/view": "npm:^6.27.0" + "@lezer/common": "npm:^1.1.0" + checksum: 10c0/1841d8ad6751f0d10d10200e81333c5c9b0d6afb55692e41df85992a3737abc8c2ee97e14816ce624276381fbb0261e7aaf8474e170b74f796c3ba62500be3da + languageName: node + linkType: hard + +"@codemirror/lang-json@npm:^6.0.1": + version: 6.0.2 + resolution: "@codemirror/lang-json@npm:6.0.2" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@lezer/json": "npm:^1.0.0" + checksum: 10c0/4a36022226557d0571c143f907638eb2d46c0f7cf96c6d9a86dac397a789efa2b387e3dd3df94bac21e27692892443b24f8129c044c9012df66e68f5080745b0 + languageName: node + linkType: hard + +"@codemirror/lang-yaml@npm:^6.1.1": + version: 6.1.2 + resolution: "@codemirror/lang-yaml@npm:6.1.2" + dependencies: + "@codemirror/autocomplete": "npm:^6.0.0" + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.2.0" + "@lezer/lr": "npm:^1.0.0" + "@lezer/yaml": "npm:^1.0.0" + checksum: 10c0/fc993c5e24baee0212d587c652ee7633792533c1b1e5b708d5e4f6c29e6164a3563958fd6a3bb402a64f565f7bab7edbda6c8b8cd8bfecfd0b7294f0dcf998a8 + languageName: node + linkType: hard + +"@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.2": + version: 6.12.1 + resolution: "@codemirror/language@npm:6.12.1" + dependencies: + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.23.0" + "@lezer/common": "npm:^1.5.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.0.0" + style-mod: "npm:^4.0.0" + checksum: 10c0/d37e526a839f571f767372c49e28649c4e79a539c73845a74117ee408ad31c29d60a32b5e1bad439637b1456d18154d672eb225e9b4482d3e00eca150461bc6a + languageName: node + linkType: hard + +"@codemirror/lint@npm:^6.8.0": + version: 6.9.3 + resolution: "@codemirror/lint@npm:6.9.3" + dependencies: + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.35.0" + crelt: "npm:^1.0.5" + checksum: 10c0/729af1fc39ced59edb5ad73ef95a71df8e4a7ed7bccac53bac3e6232a4f018f5d8b2b1c320eb014f5ba07a1a0e53fbc094907679e017dc5f3b5707765b2c6541 + languageName: node + linkType: hard + +"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.4.1, @codemirror/state@npm:^6.5.0": + version: 6.5.4 + resolution: "@codemirror/state@npm:6.5.4" + dependencies: + "@marijn/find-cluster-break": "npm:^1.0.0" + checksum: 10c0/8f40e1a22b84752fc44637e586cb3d804f775c0cf9c8083a79eed5cb18fbdfb30b83c112d8b6d819046526d1f9e49bf1198bdca4c4c3427bdf2c657a96df7adf + languageName: node + linkType: hard + +"@codemirror/theme-one-dark@npm:^6.1.2": + version: 6.1.3 + resolution: "@codemirror/theme-one-dark@npm:6.1.3" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.0.0" + "@lezer/highlight": "npm:^1.0.0" + checksum: 10c0/de8483c69911bcd61a19679384de663ced9c8bed3c776f08581a8b724e9f456a17053b1cf6e9d1f2a475fa6bc42e905ec8ba1ee0a8b55213d18087d9d9150317 + languageName: node + linkType: hard + +"@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.35.0": + version: 6.39.12 + resolution: "@codemirror/view@npm:6.39.12" + dependencies: + "@codemirror/state": "npm:^6.5.0" + crelt: "npm:^1.0.6" + style-mod: "npm:^4.1.0" + w3c-keyname: "npm:^2.2.4" + checksum: 10c0/b5584fbe3f642fb3c5b35a7e1e36be9432b2967b5a937d31f89d311e83b3f9190c2bbdacfedeb6c10b294b2d9d8c3c54d49eb23790e5ff9193b438e6d21f1a2b + languageName: node + linkType: hard + "@csstools/color-helpers@npm:^5.1.0": version: 5.1.0 resolution: "@csstools/color-helpers@npm:5.1.0" @@ -4023,6 +4130,68 @@ __metadata: languageName: unknown linkType: soft +"@knocklabs/codemirror-json-schema@workspace:packages/codemirror-json-schema": + version: 0.0.0-use.local + resolution: "@knocklabs/codemirror-json-schema@workspace:packages/codemirror-json-schema" + dependencies: + "@codecov/vite-plugin": "npm:^1.9.1" + "@codemirror/autocomplete": "npm:^6.16.2" + "@codemirror/commands": "npm:^6.6.0" + "@codemirror/lang-json": "npm:^6.0.1" + "@codemirror/lang-yaml": "npm:^6.1.1" + "@codemirror/language": "npm:^6.10.2" + "@codemirror/lint": "npm:^6.8.0" + "@codemirror/state": "npm:^6.4.1" + "@codemirror/theme-one-dark": "npm:^6.1.2" + "@codemirror/view": "npm:^6.27.0" + "@lezer/common": "npm:^1.2.1" + "@sagold/json-pointer": "npm:^5.1.1" + "@shikijs/markdown-it": "npm:^1.22.2" + "@types/json-schema": "npm:^7.0.12" + "@types/markdown-it": "npm:^13.0.7" + "@types/node": "npm:^24" + "@typescript-eslint/eslint-plugin": "npm:^8.32.0" + "@typescript-eslint/parser": "npm:^8.39.1" + "@vitest/coverage-v8": "npm:^3.2.4" + best-effort-json-parser: "npm:^1.1.2" + codemirror-json5: "npm:^1.0.3" + eslint: "npm:^8.56.0" + happy-dom: "npm:^10.3.2" + jsdom: "npm:^27.1.0" + json-schema: "npm:^0.4.0" + json-schema-library: "npm:^9.3.5" + json5: "npm:^2.2.3" + loglevel: "npm:^1.9.1" + markdown-it: "npm:^14.1.0" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + shiki: "npm:^1.22.2" + typescript: "npm:^5.8.3" + vite: "npm:^5.4.19" + vite-plugin-dts: "npm:^4.5.0" + vite-plugin-no-bundle: "npm:^4.0.0" + vitest: "npm:^3.1.1" + yaml: "npm:^2.3.4" + peerDependencies: + "@codemirror/language": ^6.10.2 + "@codemirror/lint": ^6.8.0 + "@codemirror/state": ^6.4.1 + "@codemirror/view": ^6.27.0 + "@lezer/common": ^1.2.1 + dependenciesMeta: + "@codemirror/autocomplete": + optional: true + "@codemirror/lang-json": + optional: true + "@codemirror/lang-yaml": + optional: true + codemirror-json5: + optional: true + json5: + optional: true + languageName: unknown + linkType: soft + "@knocklabs/eslint-config@workspace:^, @knocklabs/eslint-config@workspace:packages/eslint-config": version: 0.0.0-use.local resolution: "@knocklabs/eslint-config@workspace:packages/eslint-config" @@ -4308,6 +4477,53 @@ __metadata: languageName: unknown linkType: soft +"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1, @lezer/common@npm:^1.3.0, @lezer/common@npm:^1.5.0": + version: 1.5.1 + resolution: "@lezer/common@npm:1.5.1" + checksum: 10c0/49baefdfc6f2244ad4f7d4a318149729fbecfd634fe1f7769883b5098ab9b35429140851e524c3a97614594004d8a3ad08fdd91221a63438be8c31ff2431fb54 + languageName: node + linkType: hard + +"@lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.2.0": + version: 1.2.3 + resolution: "@lezer/highlight@npm:1.2.3" + dependencies: + "@lezer/common": "npm:^1.3.0" + checksum: 10c0/3bcb4fce7a1a45b5973895d7cb2be47970a0098700f2a0970aef9878ffd37f540285a2d7388ec1f524726ec90cc5196b5701bbb9764b7e7300786d772b7d2ce2 + languageName: node + linkType: hard + +"@lezer/json@npm:^1.0.0": + version: 1.0.3 + resolution: "@lezer/json@npm:1.0.3" + dependencies: + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.0.0" + checksum: 10c0/e91c957cc0825e927b55fbcd233d7ee0b39f9c2a89d9475489f394b7eba2b59e5f480d157a12d5cd6ae6f14bc99f9ccd8e8113baad498199ef1b13c49105f546 + languageName: node + linkType: hard + +"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.4.0": + version: 1.4.8 + resolution: "@lezer/lr@npm:1.4.8" + dependencies: + "@lezer/common": "npm:^1.0.0" + checksum: 10c0/8bd2228a316a5ef8da01908e3e22aca95fa9695211ffe56f3e8be756b37d0810d5aa91fbbdd274b198a343051d8637e130e26f51161161f089244af242b653c9 + languageName: node + linkType: hard + +"@lezer/yaml@npm:^1.0.0": + version: 1.0.4 + resolution: "@lezer/yaml@npm:1.0.4" + dependencies: + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.4.0" + checksum: 10c0/3b93d05b00bca843954752487019b3a8a97c46756987e7867612cc52772b220b70c102c3ce59e57b318683383f3734fdd657f9f86bdd4689314e9a194aa63367 + languageName: node + linkType: hard + "@manypkg/cli@npm:^0.25.0": version: 0.25.0 resolution: "@manypkg/cli@npm:0.25.0" @@ -4385,6 +4601,13 @@ __metadata: languageName: node linkType: hard +"@marijn/find-cluster-break@npm:^1.0.0": + version: 1.0.2 + resolution: "@marijn/find-cluster-break@npm:1.0.2" + checksum: 10c0/1a17a60b16083cc5f7ce89d7b7d8aa87ce4099723e3e9e34e229ef2cd8a980e69d481ca8ee90ffedfec5119af1aed581642fb60ed0365e7e90634c81ea6b630f + languageName: node + linkType: hard + "@microsoft/api-extractor-model@npm:7.30.6": version: 7.30.6 resolution: "@microsoft/api-extractor-model@npm:7.30.6" @@ -6204,6 +6427,103 @@ __metadata: languageName: node linkType: hard +"@sagold/json-pointer@npm:^5.1.1, @sagold/json-pointer@npm:^5.1.2": + version: 5.1.2 + resolution: "@sagold/json-pointer@npm:5.1.2" + checksum: 10c0/6e82162852c824ecd5f41a4252a4514565f6e1d154488bb85b9ab1b7c4a483ef64fcbb9b0776762ae4cde63a65e232f5293583721e5742650ebaeb220f795245 + languageName: node + linkType: hard + +"@sagold/json-query@npm:^6.1.3": + version: 6.2.0 + resolution: "@sagold/json-query@npm:6.2.0" + dependencies: + "@sagold/json-pointer": "npm:^5.1.2" + ebnf: "npm:^1.9.1" + checksum: 10c0/64d03526ee81cf762eba564994420027b83c91ce012776ffb1bb12dc866da21ec5752ff6074fa91810ac20723cf5310598ae7a1da672c207f6350483332f68cc + languageName: node + linkType: hard + +"@shikijs/core@npm:1.29.2": + version: 1.29.2 + resolution: "@shikijs/core@npm:1.29.2" + dependencies: + "@shikijs/engine-javascript": "npm:1.29.2" + "@shikijs/engine-oniguruma": "npm:1.29.2" + "@shikijs/types": "npm:1.29.2" + "@shikijs/vscode-textmate": "npm:^10.0.1" + "@types/hast": "npm:^3.0.4" + hast-util-to-html: "npm:^9.0.4" + checksum: 10c0/b1bb0567babcee64608224d652ceb4076d387b409fb8ee767f7684c68f03cfaab0e17f42d0a3372fc7be1fe165af9a3a349efc188f6e7c720d4df1108c1ab78c + languageName: node + linkType: hard + +"@shikijs/engine-javascript@npm:1.29.2": + version: 1.29.2 + resolution: "@shikijs/engine-javascript@npm:1.29.2" + dependencies: + "@shikijs/types": "npm:1.29.2" + "@shikijs/vscode-textmate": "npm:^10.0.1" + oniguruma-to-es: "npm:^2.2.0" + checksum: 10c0/b61f9e9079493c19419ff64af6454c4360a32785d47f49b41e87752e66ddbf7466dd9cce67f4d5d4a8447e31d96b4f0a39330e9f26e8bd2bc2f076644e78dff7 + languageName: node + linkType: hard + +"@shikijs/engine-oniguruma@npm:1.29.2": + version: 1.29.2 + resolution: "@shikijs/engine-oniguruma@npm:1.29.2" + dependencies: + "@shikijs/types": "npm:1.29.2" + "@shikijs/vscode-textmate": "npm:^10.0.1" + checksum: 10c0/87d77e05af7fe862df40899a7034cbbd48d3635e27706873025e5035be578584d012f850208e97ca484d5e876bf802d4e23d0394d25026adb678eeb1d1f340ff + languageName: node + linkType: hard + +"@shikijs/langs@npm:1.29.2": + version: 1.29.2 + resolution: "@shikijs/langs@npm:1.29.2" + dependencies: + "@shikijs/types": "npm:1.29.2" + checksum: 10c0/137af52ec19ab10bb167ec67e2dc6888d77dedddb3be37708569cb8e8d54c057d09df335261276012d11ac38366ba57b9eae121cc0b7045859638c25648b0563 + languageName: node + linkType: hard + +"@shikijs/markdown-it@npm:^1.22.2": + version: 1.29.2 + resolution: "@shikijs/markdown-it@npm:1.29.2" + dependencies: + markdown-it: "npm:^14.1.0" + shiki: "npm:1.29.2" + checksum: 10c0/76bb213e87e11d6f8f0b19b135d8a395e88ae0ab9a021b5c815669edcd2869c0a2b354a7d5840a25ce26a0f5f7437d9af5ebbd38d92a3186d189a8fae0389897 + languageName: node + linkType: hard + +"@shikijs/themes@npm:1.29.2": + version: 1.29.2 + resolution: "@shikijs/themes@npm:1.29.2" + dependencies: + "@shikijs/types": "npm:1.29.2" + checksum: 10c0/1f7d3fc8615890d83b50c73c13e5182438dee579dd9a121d605bbdcc2dc877cafc9f7e23a3e1342345cd0b9161e3af6425b0fbfac949843f22b2a60527a8fb69 + languageName: node + linkType: hard + +"@shikijs/types@npm:1.29.2": + version: 1.29.2 + resolution: "@shikijs/types@npm:1.29.2" + dependencies: + "@shikijs/vscode-textmate": "npm:^10.0.1" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/37b4ac315effc03e7185aca1da0c2631ac55bdf613897476bd1d879105c41f86ccce6ebd0b78779513d88cc2ee371039f7efd95d604f77f21f180791978822b3 + languageName: node + linkType: hard + +"@shikijs/vscode-textmate@npm:^10.0.1": + version: 10.0.2 + resolution: "@shikijs/vscode-textmate@npm:10.0.2" + checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -7105,6 +7425,15 @@ __metadata: languageName: node linkType: hard +"@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4": + version: 3.0.4 + resolution: "@types/hast@npm:3.0.4" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/3249781a511b38f1d330fd1e3344eed3c4e7ea8eff82e835d35da78e637480d36fad37a78be5a7aed8465d237ad0446abc1150859d0fde395354ea634decf9f7 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" @@ -7130,7 +7459,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.12, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -7154,6 +7483,13 @@ __metadata: languageName: node linkType: hard +"@types/linkify-it@npm:^3": + version: 3.0.5 + resolution: "@types/linkify-it@npm:3.0.5" + checksum: 10c0/696e09975991c649ba37c5585714929fdebf5c64a8bfb99910613ef838337dbbba6c608fccdfa03d6347432586ef12e139bc0e947ae6fec569096fef5cc1c550 + languageName: node + linkType: hard + "@types/lodash.debounce@npm:^4.0.9": version: 4.0.9 resolution: "@types/lodash.debounce@npm:4.0.9" @@ -7170,6 +7506,32 @@ __metadata: languageName: node linkType: hard +"@types/markdown-it@npm:^13.0.7": + version: 13.0.9 + resolution: "@types/markdown-it@npm:13.0.9" + dependencies: + "@types/linkify-it": "npm:^3" + "@types/mdurl": "npm:^1" + checksum: 10c0/d974b1edc52236b0ec6de20e37be9051bcb38fa87b7f36ed5d1b0f0d521918b6e0164a72d57a453ce72976002d6909ceb0c66826e0690cb5283e18a13cd56d66 + languageName: node + linkType: hard + +"@types/mdast@npm:^4.0.0": + version: 4.0.4 + resolution: "@types/mdast@npm:4.0.4" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/84f403dbe582ee508fd9c7643ac781ad8597fcbfc9ccb8d4715a2c92e4545e5772cbd0dbdf18eda65789386d81b009967fdef01b24faf6640f817287f54d9c82 + languageName: node + linkType: hard + +"@types/mdurl@npm:^1": + version: 1.0.5 + resolution: "@types/mdurl@npm:1.0.5" + checksum: 10c0/8991c781eb94fb3621e48e191251a94057908fc14be60f52bdd7c48684af923ffa77559ea979450a0475f85c08f8a472f99ff9c2ca4308961b9b9d35fd7584f7 + languageName: node + linkType: hard + "@types/ms@npm:*": version: 2.1.0 resolution: "@types/ms@npm:2.1.0" @@ -7290,6 +7652,13 @@ __metadata: languageName: node linkType: hard +"@types/unist@npm:*, @types/unist@npm:^3.0.0": + version: 3.0.3 + resolution: "@types/unist@npm:3.0.3" + checksum: 10c0/2b1e4adcab78388e088fcc3c0ae8700f76619dbcb4741d7d201f87e2cb346bfc29a89003cfea2d76c996e1061452e14fcd737e8b25aacf949c1f2d6b2bc3dd60 + languageName: node + linkType: hard + "@types/urijs@npm:^1.19.15": version: 1.19.25 resolution: "@types/urijs@npm:1.19.25" @@ -7969,7 +8338,7 @@ __metadata: languageName: node linkType: hard -"@ungap/structured-clone@npm:^1.2.0": +"@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.2.0": version: 1.3.0 resolution: "@ungap/structured-clone@npm:1.3.0" checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a @@ -9304,6 +9673,13 @@ __metadata: languageName: node linkType: hard +"best-effort-json-parser@npm:^1.1.2": + version: 1.2.1 + resolution: "best-effort-json-parser@npm:1.2.1" + checksum: 10c0/2d032d83064317b88f4419827ddfb1826576296211b49103132931253081f8f568a730204f111aafcd4adbdff02ca6feb3016adcbf3fa4f23ea7133fa1b8ba3c + languageName: node + linkType: hard + "better-opn@npm:~3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -9617,6 +9993,13 @@ __metadata: languageName: node linkType: hard +"ccount@npm:^2.0.0": + version: 2.0.1 + resolution: "ccount@npm:2.0.1" + checksum: 10c0/3939b1664390174484322bc3f45b798462e6c07ee6384cb3d645e0aa2f318502d174845198c1561930e1d431087f74cf1fe291ae9a4722821a9f4ba67e574350 + languageName: node + linkType: hard + "chai@npm:^5.2.0": version: 5.2.0 resolution: "chai@npm:5.2.0" @@ -9665,6 +10048,13 @@ __metadata: languageName: node linkType: hard +"character-entities-html4@npm:^2.0.0": + version: 2.1.0 + resolution: "character-entities-html4@npm:2.1.0" + checksum: 10c0/fe61b553f083400c20c0b0fd65095df30a0b445d960f3bbf271536ae6c3ba676f39cb7af0b4bf2755812f08ab9b88f2feed68f9aebb73bb153f7a115fe5c6e40 + languageName: node + linkType: hard + "character-entities-legacy@npm:^1.0.0": version: 1.1.4 resolution: "character-entities-legacy@npm:1.1.4" @@ -9672,6 +10062,13 @@ __metadata: languageName: node linkType: hard +"character-entities-legacy@npm:^3.0.0": + version: 3.0.0 + resolution: "character-entities-legacy@npm:3.0.0" + checksum: 10c0/ec4b430af873661aa754a896a2b55af089b4e938d3d010fad5219299a6b6d32ab175142699ee250640678cd64bdecd6db3c9af0b8759ab7b155d970d84c4c7d1 + languageName: node + linkType: hard + "chardet@npm:^0.7.0": version: 0.7.0 resolution: "chardet@npm:0.7.0" @@ -9838,6 +10235,21 @@ __metadata: languageName: node linkType: hard +"codemirror-json5@npm:^1.0.3": + version: 1.0.3 + resolution: "codemirror-json5@npm:1.0.3" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.0.0" + "@lezer/common": "npm:^1.0.0" + "@lezer/highlight": "npm:^1.0.0" + json5: "npm:^2.2.1" + lezer-json5: "npm:^2.0.2" + checksum: 10c0/a842f3f34d3286abeb86879b89b555ca68b08447303f4e8fac9abfb71e6b6df30ab3815ecfae0cf790976749e32ef8cd7baae1db6dd7a4a8e6e40696a14d275f + languageName: node + linkType: hard + "color-convert@npm:^1.9.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -9899,6 +10311,13 @@ __metadata: languageName: node linkType: hard +"comma-separated-tokens@npm:^2.0.0": + version: 2.0.3 + resolution: "comma-separated-tokens@npm:2.0.3" + checksum: 10c0/91f90f1aae320f1755d6957ef0b864fe4f54737f3313bd95e0802686ee2ca38bff1dd381964d00ae5db42912dd1f4ae5c2709644e82706ffc6f6842a813cdd67 + languageName: node + linkType: hard + "commander@npm:^12.0.0": version: 12.1.0 resolution: "commander@npm:12.1.0" @@ -9906,7 +10325,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^2.20.0": +"commander@npm:^2.19.0, commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" checksum: 10c0/74c781a5248c2402a0a3e966a0a2bba3c054aad144f5c023364be83265e796b20565aa9feff624132ff629aa64e16999fa40a743c10c12f7c61e96a794b99288 @@ -10079,6 +10498,13 @@ __metadata: languageName: node linkType: hard +"crelt@npm:^1.0.5, crelt@npm:^1.0.6": + version: 1.0.6 + resolution: "crelt@npm:1.0.6" + checksum: 10c0/e0fb76dff50c5eb47f2ea9b786c17f9425c66276025adee80876bdbf4a84ab72e899e56d3928431ab0cb057a105ef704df80fe5726ef0f7b1658f815521bdf09 + languageName: node + linkType: hard + "cross-env@npm:^7.0.3": version: 7.0.3 resolution: "cross-env@npm:7.0.3" @@ -10424,7 +10850,7 @@ __metadata: languageName: node linkType: hard -"dequal@npm:^2.0.3": +"dequal@npm:^2.0.0, dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 @@ -10482,6 +10908,15 @@ __metadata: languageName: node linkType: hard +"devlop@npm:^1.0.0": + version: 1.1.0 + resolution: "devlop@npm:1.1.0" + dependencies: + dequal: "npm:^2.0.0" + checksum: 10c0/e0928ab8f94c59417a2b8389c45c55ce0a02d9ac7fd74ef62d01ba48060129e1d594501b77de01f3eeafc7cb00773819b0df74d96251cf20b31c5b3071f45c0e + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -10491,6 +10926,13 @@ __metadata: languageName: node linkType: hard +"discontinuous-range@npm:1.0.0": + version: 1.0.0 + resolution: "discontinuous-range@npm:1.0.0" + checksum: 10c0/487b105f83c1cc528e25e65d3c4b73958ec79769b7bd0e264414702a23a7e2b282c72982b4bef4af29fcab53f47816c3f0a5c40d85a99a490f4bc35b83dc00f8 + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -10640,6 +11082,15 @@ __metadata: languageName: node linkType: hard +"ebnf@npm:^1.9.1": + version: 1.9.1 + resolution: "ebnf@npm:1.9.1" + bin: + ebnf: dist/bin.js + checksum: 10c0/289a99edaabd15054a0c20da563cd378c3e3e22eec969ff86ae38b10e38a9ad0377c369b208eb7a3e287c1a3c5cb15b33e21d706d492c5f619e8fee2fea4f578 + languageName: node + linkType: hard + "ecdsa-sig-formatter@npm:1.0.11": version: 1.0.11 resolution: "ecdsa-sig-formatter@npm:1.0.11" @@ -10670,6 +11121,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex-xs@npm:^1.0.0": + version: 1.0.0 + resolution: "emoji-regex-xs@npm:1.0.0" + checksum: 10c0/1082de006991eb05a3324ef0efe1950c7cdf66efc01d4578de82b0d0d62add4e55e97695a8a7eeda826c305081562dc79b477ddf18d886da77f3ba08c4b940a0 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -10741,7 +11199,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.2.0, entities@npm:^4.5.0": +"entities@npm:^4.2.0, entities@npm:^4.4.0, entities@npm:^4.5.0": version: 4.5.0 resolution: "entities@npm:4.5.0" checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 @@ -12326,6 +12784,13 @@ __metadata: languageName: node linkType: hard +"fast-copy@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-copy@npm:3.0.2" + checksum: 10c0/02e8b9fd03c8c024d2987760ce126456a0e17470850b51e11a1c3254eed6832e4733ded2d93316c82bc0b36aeb991ad1ff48d1ba95effe7add7c3ab8d8eb554a + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -13040,6 +13505,20 @@ __metadata: languageName: unknown linkType: soft +"happy-dom@npm:^10.3.2": + version: 10.11.2 + resolution: "happy-dom@npm:10.11.2" + dependencies: + css.escape: "npm:^1.5.1" + entities: "npm:^4.5.0" + iconv-lite: "npm:^0.6.3" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + checksum: 10c0/28db881f0c6eb637a3aef6c1ba92d369806d5cd57ea2cf5c6a269b5952f330f1d02a6118e22eecca29921eb3634fc4038012d775bb1a22795e8b23ed11c89dee + languageName: node + linkType: hard + "has-bigints@npm:^1.0.2": version: 1.1.0 resolution: "has-bigints@npm:1.1.0" @@ -13104,6 +13583,34 @@ __metadata: languageName: node linkType: hard +"hast-util-to-html@npm:^9.0.4": + version: 9.0.5 + resolution: "hast-util-to-html@npm:9.0.5" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + ccount: "npm:^2.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-whitespace: "npm:^3.0.0" + html-void-elements: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + property-information: "npm:^7.0.0" + space-separated-tokens: "npm:^2.0.0" + stringify-entities: "npm:^4.0.0" + zwitch: "npm:^2.0.4" + checksum: 10c0/b7a08c30bab4371fc9b4a620965c40b270e5ae7a8e94cf885f43b21705179e28c8e43b39c72885d1647965fb3738654e6962eb8b58b0c2a84271655b4d748836 + languageName: node + linkType: hard + +"hast-util-whitespace@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-whitespace@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/b898bc9fe27884b272580d15260b6bbdabe239973a147e97fa98c45fa0ffec967a481aaa42291ec34fb56530dc2d484d473d7e2bae79f39c83f3762307edfea8 + languageName: node + linkType: hard + "he@npm:^1.2.0": version: 1.2.0 resolution: "he@npm:1.2.0" @@ -13186,6 +13693,13 @@ __metadata: languageName: node linkType: hard +"html-void-elements@npm:^3.0.0": + version: 3.0.0 + resolution: "html-void-elements@npm:3.0.0" + checksum: 10c0/a8b9ec5db23b7c8053876dad73a0336183e6162bf6d2677376d8b38d654fdc59ba74fdd12f8812688f7db6fad451210c91b300e472afc0909224e0a44c8610d2 + languageName: node + linkType: hard + "htmlparser2@npm:^7.1.2": version: 7.2.0 resolution: "htmlparser2@npm:7.2.0" @@ -13254,7 +13768,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -14188,6 +14702,21 @@ __metadata: languageName: node linkType: hard +"json-schema-library@npm:^9.3.5": + version: 9.3.5 + resolution: "json-schema-library@npm:9.3.5" + dependencies: + "@sagold/json-pointer": "npm:^5.1.2" + "@sagold/json-query": "npm:^6.1.3" + deepmerge: "npm:^4.3.1" + fast-copy: "npm:^3.0.2" + fast-deep-equal: "npm:^3.1.3" + smtp-address-parser: "npm:1.0.10" + valid-url: "npm:^1.0.9" + checksum: 10c0/3268b7f6620faac347fc18d1e1e5b516869676b5317f470ca157b68704603fac9aadee6b6840a5086a04054fc2ec8e223a6cfe962ab09d5198f93631946548e1 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -14202,6 +14731,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:^0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -14220,7 +14756,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.3": +"json5@npm:^2.2.1, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -14384,6 +14920,15 @@ __metadata: languageName: node linkType: hard +"lezer-json5@npm:^2.0.2": + version: 2.0.2 + resolution: "lezer-json5@npm:2.0.2" + dependencies: + "@lezer/lr": "npm:^1.0.0" + checksum: 10c0/3c40a419a618e3722bd93271cedfbbc9c15c02a4f2310f793f7812f021a0ce46e99b2c30a80aabad699a9a7eae787325f511710f03fd2d480e36c2f639cb9de2 + languageName: node + linkType: hard + "lighthouse-logger@npm:^1.0.0": version: 1.4.2 resolution: "lighthouse-logger@npm:1.4.2" @@ -14631,6 +15176,15 @@ __metadata: languageName: node linkType: hard +"linkify-it@npm:^5.0.0": + version: 5.0.0 + resolution: "linkify-it@npm:5.0.0" + dependencies: + uc.micro: "npm:^2.0.0" + checksum: 10c0/ff4abbcdfa2003472fc3eb4b8e60905ec97718e11e33cca52059919a4c80cc0e0c2a14d23e23d8c00e5402bc5a885cdba8ca053a11483ab3cc8b3c7a52f88e2d + languageName: node + linkType: hard + "local-pkg@npm:^1.0.0": version: 1.1.1 resolution: "local-pkg@npm:1.1.1" @@ -14760,6 +15314,13 @@ __metadata: languageName: node linkType: hard +"loglevel@npm:^1.9.1": + version: 1.9.2 + resolution: "loglevel@npm:1.9.2" + checksum: 10c0/1e317fa4648fe0b4a4cffef6de037340592cee8547b07d4ce97a487abe9153e704b98451100c799b032c72bb89c9366d71c9fb8192ada8703269263ae77acdc7 + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -14911,6 +15472,22 @@ __metadata: languageName: node linkType: hard +"markdown-it@npm:^14.1.0": + version: 14.1.0 + resolution: "markdown-it@npm:14.1.0" + dependencies: + argparse: "npm:^2.0.1" + entities: "npm:^4.4.0" + linkify-it: "npm:^5.0.0" + mdurl: "npm:^2.0.0" + punycode.js: "npm:^2.3.1" + uc.micro: "npm:^2.1.0" + bin: + markdown-it: bin/markdown-it.mjs + checksum: 10c0/9a6bb444181d2db7016a4173ae56a95a62c84d4cbfb6916a399b11d3e6581bf1cc2e4e1d07a2f022ae72c25f56db90fbe1e529fca16fbf9541659dc53480d4b4 + languageName: node + linkType: hard + "marky@npm:^1.2.2": version: 1.3.0 resolution: "marky@npm:1.3.0" @@ -14925,6 +15502,23 @@ __metadata: languageName: node linkType: hard +"mdast-util-to-hast@npm:^13.0.0": + version: 13.2.1 + resolution: "mdast-util-to-hast@npm:13.2.1" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + "@ungap/structured-clone": "npm:^1.0.0" + devlop: "npm:^1.0.0" + micromark-util-sanitize-uri: "npm:^2.0.0" + trim-lines: "npm:^3.0.0" + unist-util-position: "npm:^5.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/3eeaf28a5e84e1e08e6d54a1a8a06c0fca88cb5d36f4cf8086f0177248d1ce6e4e751f4ad0da19a3dea1c6ea61bd80784acc3ae021e44ceeb21aa5413a375e43 + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -14939,6 +15533,13 @@ __metadata: languageName: node linkType: hard +"mdurl@npm:^2.0.0": + version: 2.0.0 + resolution: "mdurl@npm:2.0.0" + checksum: 10c0/633db522272f75ce4788440669137c77540d74a83e9015666a9557a152c02e245b192edc20bc90ae953bbab727503994a53b236b4d9c99bdaee594d0e7dd2ce0 + languageName: node + linkType: hard + "memoize-one@npm:^5.0.0": version: 5.2.1 resolution: "memoize-one@npm:5.2.1" @@ -15192,6 +15793,48 @@ __metadata: languageName: node linkType: hard +"micromark-util-character@npm:^2.0.0": + version: 2.1.1 + resolution: "micromark-util-character@npm:2.1.1" + dependencies: + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/d3fe7a5e2c4060fc2a076f9ce699c82a2e87190a3946e1e5eea77f563869b504961f5668d9c9c014724db28ac32fa909070ea8b30c3a39bd0483cc6c04cc76a1 + languageName: node + linkType: hard + +"micromark-util-encode@npm:^2.0.0": + version: 2.0.1 + resolution: "micromark-util-encode@npm:2.0.1" + checksum: 10c0/b2b29f901093845da8a1bf997ea8b7f5e061ffdba85070dfe14b0197c48fda64ffcf82bfe53c90cf9dc185e69eef8c5d41cae3ba918b96bc279326921b59008a + languageName: node + linkType: hard + +"micromark-util-sanitize-uri@npm:^2.0.0": + version: 2.0.1 + resolution: "micromark-util-sanitize-uri@npm:2.0.1" + dependencies: + micromark-util-character: "npm:^2.0.0" + micromark-util-encode: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + checksum: 10c0/60e92166e1870fd4f1961468c2651013ff760617342918e0e0c3c4e872433aa2e60c1e5a672bfe5d89dc98f742d6b33897585cf86ae002cda23e905a3c02527c + languageName: node + linkType: hard + +"micromark-util-symbol@npm:^2.0.0": + version: 2.0.1 + resolution: "micromark-util-symbol@npm:2.0.1" + checksum: 10c0/f2d1b207771e573232436618e78c5e46cd4b5c560dd4a6d63863d58018abbf49cb96ec69f7007471e51434c60de3c9268ef2bf46852f26ff4aacd10f9da16fe9 + languageName: node + linkType: hard + +"micromark-util-types@npm:^2.0.0": + version: 2.0.2 + resolution: "micromark-util-types@npm:2.0.2" + checksum: 10c0/c8c15b96c858db781c4393f55feec10004bf7df95487636c9a9f7209e51002a5cca6a047c5d2a5dc669ff92da20e57aaa881e81a268d9ccadb647f9dce305298 + languageName: node + linkType: hard + "micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" @@ -15397,6 +16040,13 @@ __metadata: languageName: node linkType: hard +"moo@npm:^0.5.0": + version: 0.5.2 + resolution: "moo@npm:0.5.2" + checksum: 10c0/a9d9ad8198a51fe35d297f6e9fdd718298ca0b39a412e868a0ebd92286379ab4533cfc1f1f34516177f5129988ab25fe598f78e77c84e3bfe0d4a877b56525a8 + languageName: node + linkType: hard + "motion-dom@npm:^12.23.12": version: 12.23.12 resolution: "motion-dom@npm:12.23.12" @@ -15542,6 +16192,23 @@ __metadata: languageName: node linkType: hard +"nearley@npm:^2.20.1": + version: 2.20.1 + resolution: "nearley@npm:2.20.1" + dependencies: + commander: "npm:^2.19.0" + moo: "npm:^0.5.0" + railroad-diagrams: "npm:^1.0.0" + randexp: "npm:0.4.6" + bin: + nearley-railroad: bin/nearley-railroad.js + nearley-test: bin/nearley-test.js + nearley-unparse: bin/nearley-unparse.js + nearleyc: bin/nearleyc.js + checksum: 10c0/d25e1fd40b19c53a0ada6a688670f4a39063fd9553ab62885e81a82927d51572ce47193b946afa3d85efa608ba2c68f433c421f69b854bfb7f599eacb5fae37e + languageName: node + linkType: hard + "negotiator@npm:0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" @@ -15979,6 +16646,17 @@ __metadata: languageName: node linkType: hard +"oniguruma-to-es@npm:^2.2.0": + version: 2.3.0 + resolution: "oniguruma-to-es@npm:2.3.0" + dependencies: + emoji-regex-xs: "npm:^1.0.0" + regex: "npm:^5.1.1" + regex-recursion: "npm:^5.1.1" + checksum: 10c0/57ad95f3e9a50be75e7d54e582d8d4da4003f983fd04d99ccc9d17d2dc04e30ea64126782f2e758566bcef2c4c55db0d6a3d344f35ca179dd92ea5ca92fc0313 + languageName: node + linkType: hard + "open@npm:^7.0.3": version: 7.4.2 resolution: "open@npm:7.4.2" @@ -16611,6 +17289,13 @@ __metadata: languageName: node linkType: hard +"property-information@npm:^7.0.0": + version: 7.1.0 + resolution: "property-information@npm:7.1.0" + checksum: 10c0/e0fe22cff26103260ad0e82959229106563fa115a54c4d6c183f49d88054e489cc9f23452d3ad584179dc13a8b7b37411a5df873746b5e4086c865874bfa968e + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -16625,6 +17310,13 @@ __metadata: languageName: node linkType: hard +"punycode.js@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode.js@npm:2.3.1" + checksum: 10c0/1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -16676,6 +17368,13 @@ __metadata: languageName: node linkType: hard +"railroad-diagrams@npm:^1.0.0": + version: 1.0.0 + resolution: "railroad-diagrams@npm:1.0.0" + checksum: 10c0/81bf8f86870a69fb9ed243102db9ad6416d09c4cb83964490d44717690e07dd982f671503236a1f8af28f4cb79d5d7a87613930f10ac08defa845ceb6764e364 + languageName: node + linkType: hard + "ramda@npm:^0.27.2": version: 0.27.2 resolution: "ramda@npm:0.27.2" @@ -16683,6 +17382,16 @@ __metadata: languageName: node linkType: hard +"randexp@npm:0.4.6": + version: 0.4.6 + resolution: "randexp@npm:0.4.6" + dependencies: + discontinuous-range: "npm:1.0.0" + ret: "npm:~0.1.10" + checksum: 10c0/14ee14b6d7f5ce69609b51cc914fb7a7c82ad337820a141c5f762c5ad1fe868f5191ea6e82359aee019b625ee1359486628fa833909d12c3b5dd9571908c3345 + languageName: node + linkType: hard + "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -17232,6 +17941,32 @@ __metadata: languageName: node linkType: hard +"regex-recursion@npm:^5.1.1": + version: 5.1.1 + resolution: "regex-recursion@npm:5.1.1" + dependencies: + regex: "npm:^5.1.1" + regex-utilities: "npm:^2.3.0" + checksum: 10c0/c61c284bc41f2b271dfa0549d657a5a26397108b860d7cdb15b43080196681c0092bf8cf920a8836213e239d1195c4ccf6db9be9298bce4e68c9daab1febeab9 + languageName: node + linkType: hard + +"regex-utilities@npm:^2.3.0": + version: 2.3.0 + resolution: "regex-utilities@npm:2.3.0" + checksum: 10c0/78c550a80a0af75223244fff006743922591bd8f61d91fef7c86b9b56cf9bbf8ee5d7adb6d8991b5e304c57c90103fc4818cf1e357b11c6c669b782839bd7893 + languageName: node + linkType: hard + +"regex@npm:^5.1.1": + version: 5.1.1 + resolution: "regex@npm:5.1.1" + dependencies: + regex-utilities: "npm:^2.3.0" + checksum: 10c0/314e032f0fe09497ce7a160b99675c4a16c7524f0a24833f567cbbf3a2bebc26bf59737dc5c23f32af7c74aa7a6bd3f809fc72c90c49a05faf8be45677db508a + languageName: node + linkType: hard + "regexp-tree@npm:^0.1.27": version: 0.1.27 resolution: "regexp-tree@npm:0.1.27" @@ -17483,6 +18218,13 @@ __metadata: languageName: node linkType: hard +"ret@npm:~0.1.10": + version: 0.1.15 + resolution: "ret@npm:0.1.15" + checksum: 10c0/01f77cad0f7ea4f955852c03d66982609893edc1240c0c964b4c9251d0f9fb6705150634060d169939b096d3b77f4c84d6b6098a5b5d340160898c8581f1f63f + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -18094,6 +18836,22 @@ __metadata: languageName: node linkType: hard +"shiki@npm:1.29.2, shiki@npm:^1.22.2": + version: 1.29.2 + resolution: "shiki@npm:1.29.2" + dependencies: + "@shikijs/core": "npm:1.29.2" + "@shikijs/engine-javascript": "npm:1.29.2" + "@shikijs/engine-oniguruma": "npm:1.29.2" + "@shikijs/langs": "npm:1.29.2" + "@shikijs/themes": "npm:1.29.2" + "@shikijs/types": "npm:1.29.2" + "@shikijs/vscode-textmate": "npm:^10.0.1" + "@types/hast": "npm:^3.0.4" + checksum: 10c0/9ef452021582c405501077082c4ae8d877027dca6488d2c7a1963ed661567f121b4cc5dea9dfab26689504b612b8a961f3767805cbeaaae3c1d6faa5e6f37eb0 + languageName: node + linkType: hard + "side-channel-list@npm:^1.0.0": version: 1.0.0 resolution: "side-channel-list@npm:1.0.0" @@ -18259,6 +19017,15 @@ __metadata: languageName: node linkType: hard +"smtp-address-parser@npm:1.0.10": + version: 1.0.10 + resolution: "smtp-address-parser@npm:1.0.10" + dependencies: + nearley: "npm:^2.20.1" + checksum: 10c0/946a06d81721e8fb0ea7cb26c3726523b2a82389aee523a28ace4e913a406da63e66b2fd27d946f0cff676cc2f2f58e822783d5ec4721786a7224be3f0211b62 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^8.0.3": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" @@ -18345,6 +19112,13 @@ __metadata: languageName: node linkType: hard +"space-separated-tokens@npm:^2.0.0": + version: 2.0.2 + resolution: "space-separated-tokens@npm:2.0.2" + checksum: 10c0/6173e1d903dca41dcab6a2deed8b4caf61bd13b6d7af8374713500570aa929ff9414ae09a0519f4f8772df993300305a395d4871f35bc4ca72b6db57e1f30af8 + languageName: node + linkType: hard + "spawndamnit@npm:^3.0.1": version: 3.0.1 resolution: "spawndamnit@npm:3.0.1" @@ -18637,6 +19411,16 @@ __metadata: languageName: node linkType: hard +"stringify-entities@npm:^4.0.0": + version: 4.0.4 + resolution: "stringify-entities@npm:4.0.4" + dependencies: + character-entities-html4: "npm:^2.0.0" + character-entities-legacy: "npm:^3.0.0" + checksum: 10c0/537c7e656354192406bdd08157d759cd615724e9d0873602d2c9b2f6a5c0a8d0b1d73a0a08677848105c5eebac6db037b57c0b3a4ec86331117fa7319ed50448 + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -18701,6 +19485,13 @@ __metadata: languageName: node linkType: hard +"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0": + version: 4.1.3 + resolution: "style-mod@npm:4.1.3" + checksum: 10c0/36059006ea73cd96242ca8be06b625522d488bf8caca9c18436edf77092183381f08109577a4b3d35482f3395231099f195dbc854a46ce507fbf75c484f2cfcc + languageName: node + linkType: hard + "styled-jsx@npm:5.1.6": version: 5.1.6 resolution: "styled-jsx@npm:5.1.6" @@ -19118,6 +19909,13 @@ __metadata: languageName: node linkType: hard +"trim-lines@npm:^3.0.0": + version: 3.0.1 + resolution: "trim-lines@npm:3.0.1" + checksum: 10c0/3a1611fa9e52aa56a94c69951a9ea15b8aaad760eaa26c56a65330dc8adf99cb282fc07cc9d94968b7d4d88003beba220a7278bbe2063328eb23fb56f9509e94 + languageName: node + linkType: hard + "ts-api-utils@npm:^1.3.0": version: 1.4.3 resolution: "ts-api-utils@npm:1.4.3" @@ -19451,6 +20249,13 @@ __metadata: languageName: node linkType: hard +"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0": + version: 2.1.0 + resolution: "uc.micro@npm:2.1.0" + checksum: 10c0/8862eddb412dda76f15db8ad1c640ccc2f47cdf8252a4a30be908d535602c8d33f9855dfcccb8b8837855c1ce1eaa563f7fa7ebe3c98fd0794351aab9b9c55fa + languageName: node + linkType: hard + "ufo@npm:^1.5.4": version: 1.6.1 resolution: "ufo@npm:1.6.1" @@ -19565,6 +20370,54 @@ __metadata: languageName: node linkType: hard +"unist-util-is@npm:^6.0.0": + version: 6.0.1 + resolution: "unist-util-is@npm:6.0.1" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/5a487d390193811d37a68264e204dbc7c15c40b8fc29b5515a535d921d071134f571d7b5cbd59bcd58d5ce1c0ab08f20fc4a1f0df2287a249c979267fc32ce06 + languageName: node + linkType: hard + +"unist-util-position@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-position@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/dde3b31e314c98f12b4dc6402f9722b2bf35e96a4f2d463233dd90d7cde2d4928074a7a11eff0a5eb1f4e200f27fc1557e0a64a7e8e4da6558542f251b1b7400 + languageName: node + linkType: hard + +"unist-util-stringify-position@npm:^4.0.0": + version: 4.0.0 + resolution: "unist-util-stringify-position@npm:4.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/dfe1dbe79ba31f589108cb35e523f14029b6675d741a79dea7e5f3d098785045d556d5650ec6a8338af11e9e78d2a30df12b1ee86529cded1098da3f17ee999e + languageName: node + linkType: hard + +"unist-util-visit-parents@npm:^6.0.0": + version: 6.0.2 + resolution: "unist-util-visit-parents@npm:6.0.2" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/f1e4019dbd930301825895e3737b1ee0cd682f7622ddd915062135cbb39f8c090aaece3a3b5eae1f2ea52ec33f0931abb8f8a8b5c48a511a4203e3d360a8cd49 + languageName: node + linkType: hard + +"unist-util-visit@npm:^5.0.0": + version: 5.1.0 + resolution: "unist-util-visit@npm:5.1.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + unist-util-visit-parents: "npm:^6.0.0" + checksum: 10c0/a56e1bbbf63fcb55abe379e660b9a3367787e8be1e2473bdb7e86cfa6f32b6c1fa0092432d7040b8a30b2fc674bbbe024ffe6d03c3d6bf4839b064f584463a4e + languageName: node + linkType: hard + "universal-user-agent@npm:^6.0.0": version: 6.0.1 resolution: "universal-user-agent@npm:6.0.1" @@ -19864,6 +20717,13 @@ __metadata: languageName: node linkType: hard +"valid-url@npm:^1.0.9": + version: 1.0.9 + resolution: "valid-url@npm:1.0.9" + checksum: 10c0/3995e65f9942dbcb1621754c0f9790335cec61e9e9310c0a809e9ae0e2ae91bb7fc6a471fba788e979db0418d9806639f681ecebacc869bc8c3de88efa562ee6 + languageName: node + linkType: hard + "validate-npm-package-license@npm:^3.0.1": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4" @@ -19895,6 +20755,26 @@ __metadata: languageName: node linkType: hard +"vfile-message@npm:^4.0.0": + version: 4.0.3 + resolution: "vfile-message@npm:4.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-stringify-position: "npm:^4.0.0" + checksum: 10c0/33d9f219610d27987689bb14fa5573d2daa146941d1a05416dd7702c4215b23f44ed81d059e70d0e4e24f9a57d5f4dc9f18d35a993f04cf9446a7abe6d72d0c0 + languageName: node + linkType: hard + +"vfile@npm:^6.0.0": + version: 6.0.3 + resolution: "vfile@npm:6.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/e5d9eb4810623f23758cfc2205323e33552fb5972e5c2e6587babe08fe4d24859866277404fb9e2a20afb71013860d96ec806cb257536ae463c87d70022ab9ef + languageName: node + linkType: hard + "vite-node@npm:3.1.4": version: 3.1.4 resolution: "vite-node@npm:3.1.4" @@ -20125,6 +21005,13 @@ __metadata: languageName: node linkType: hard +"w3c-keyname@npm:^2.2.4": + version: 2.2.8 + resolution: "w3c-keyname@npm:2.2.8" + checksum: 10c0/37cf335c90efff31672ebb345577d681e2177f7ff9006a9ad47c68c5a9d265ba4a7b39d6c2599ceea639ca9315584ce4bd9c9fbf7a7217bfb7a599e71943c4c4 + languageName: node + linkType: hard + "w3c-xmlserializer@npm:^5.0.0": version: 5.0.0 resolution: "w3c-xmlserializer@npm:5.0.0" @@ -20173,6 +21060,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + "webidl-conversions@npm:^8.0.0": version: 8.0.0 resolution: "webidl-conversions@npm:8.0.0" @@ -20187,6 +21081,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/91b90a49f312dc751496fd23a7e68981e62f33afe938b97281ad766235c4872fc4e66319f925c5e9001502b3040dd25a33b02a9c693b73a4cbbfdc4ad10c3e3e + languageName: node + linkType: hard + "whatwg-encoding@npm:^3.1.1": version: 3.1.1 resolution: "whatwg-encoding@npm:3.1.1" @@ -20203,6 +21106,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + languageName: node + linkType: hard + "whatwg-mimetype@npm:^4.0.0": version: 4.0.0 resolution: "whatwg-mimetype@npm:4.0.0" @@ -20533,6 +21443,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.3.4": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -20575,3 +21494,10 @@ __metadata: checksum: 10c0/ccb251859609e6eed04b83f96ad7b2b7a189ca78b47176cde2c368102a5416b9c472e91b3fd96ceaa5043b2e513b3aec39fd99c36686ad2ad84f6c440afca53a languageName: node linkType: hard + +"zwitch@npm:^2.0.4": + version: 2.0.4 + resolution: "zwitch@npm:2.0.4" + checksum: 10c0/3c7830cdd3378667e058ffdb4cf2bb78ac5711214e2725900873accb23f3dfe5f9e7e5a06dcdc5f29605da976fc45c26d9a13ca334d6eea2245a15e77b8fc06e + languageName: node + linkType: hard From 6bd4fb39a7304d0cad706cfcfb9c3627c37995fe Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 4 Feb 2026 12:28:18 -0600 Subject: [PATCH 2/2] chore: apply upstream fixes from kylemcd branch --- packages/codemirror-json-schema/src/index.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/codemirror-json-schema/src/index.ts b/packages/codemirror-json-schema/src/index.ts index 8713564d..a660559d 100644 --- a/packages/codemirror-json-schema/src/index.ts +++ b/packages/codemirror-json-schema/src/index.ts @@ -27,7 +27,20 @@ export type { JSONPartialPointerData, } from "./types"; -export * from "./parsers/json-parser"; -export * from "./utils/json-pointers"; +export { + parseJSONDocumentState, + parseJSONDocument, +} from "./parsers/json-parser"; + +export { + getJsonPointerAt, + jsonPointerForPosition, + getJsonPointers, +} from "./utils/json-pointers"; -export * from "./features/state"; +export { + schemaStateField, + updateSchema, + getJSONSchema, + stateExtensions, +} from "./features/state";