diff --git a/.github/actions/build-web/action.yml b/.github/actions/build-web/action.yml index 5df3f64..16ce036 100644 --- a/.github/actions/build-web/action.yml +++ b/.github/actions/build-web/action.yml @@ -20,7 +20,7 @@ runs: if [ "$(wasm-bindgen --version 2>/dev/null | awk '{print $2}')" != "$V" ]; then cargo install wasm-bindgen-cli --version "$V" --locked --force fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cd4db7..75bebf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Fetch the schema fixtures (NeTEx) + - name: Fetch the schema fixtures (NeTEx, OSDM) shell: bash run: scripts/fetch-fixtures.sh - name: Build @@ -35,7 +35,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -49,7 +49,7 @@ jobs: name: Web viewer runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ./.github/actions/build-web with: lib: "true" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3ba3153..9868623 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,7 +20,7 @@ jobs: name: Build image runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Use the release version if: github.event_name == 'release' @@ -37,13 +37,13 @@ jobs: # requests and main build amd64 only. - name: Set up QEMU if: github.event_name == 'release' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -51,7 +51,7 @@ jobs: - name: Image tags id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.IMAGE }} tags: | @@ -61,7 +61,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ github.event_name == 'release' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 04c9cd6..cef7c62 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -27,10 +27,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ./.github/actions/build-web - name: Cache the example downloads - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: tests/fixtures/downloads key: examples-${{ hashFiles('scripts/build-examples.sh') }} @@ -42,7 +42,7 @@ jobs: run: | cp THIRD_PARTY_NOTICES.md web/dist/ touch web/dist/.nojekyll - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: web/dist @@ -54,4 +54,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d12d13d..bb37e83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,17 +22,17 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Use the release version run: scripts/set-version.sh "${GITHUB_REF#refs/tags/v}" - uses: ./.github/actions/build-web with: lib: "true" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: web-dist path: web/dist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: web-dist-lib path: web/dist-lib @@ -46,12 +46,12 @@ jobs: env: HAS_CARGO_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Use the release version run: scripts/set-version.sh "${GITHUB_REF#refs/tags/v}" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: web-dist path: crates/cli/web-dist @@ -86,7 +86,7 @@ jobs: - { target: aarch64-apple-darwin, os: macos-latest, archive: tar.gz } - { target: x86_64-pc-windows-msvc, os: windows-latest, archive: zip } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -100,7 +100,7 @@ jobs: sed -i.bak -E "s/^version = \"[^\"]+\"/version = \"$V\"/" Cargo.toml sed -i.bak -E "s/(schema-explorer-core = \{ path = \"[^\"]+\", version = \")[^\"]+\"/\1$V\"/" Cargo.toml crates/wasm/Cargo.toml rm -f Cargo.toml.bak crates/wasm/Cargo.toml.bak - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: web-dist path: web/dist @@ -116,7 +116,7 @@ jobs: if [ "${{ matrix.archive }}" = zip ]; then 7z a "$NAME.zip" "$NAME" >/dev/null; echo "ASSET=$NAME.zip" >> "$GITHUB_ENV" else tar czf "$NAME.tar.gz" "$NAME"; echo "ASSET=$NAME.tar.gz" >> "$GITHUB_ENV"; fi - name: Attach to the release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: ${{ env.ASSET }} @@ -127,14 +127,14 @@ jobs: permissions: id-token: write # npm trusted publishing and provenance steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 registry-url: https://registry.npmjs.org - name: Trusted publishing needs npm 11.5 or later run: npm install -g npm@latest - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: web-dist-lib path: web/dist-lib diff --git a/Cargo.lock b/Cargo.lock index f45b704..389b080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,6 +738,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -768,6 +774,7 @@ dependencies = [ "roxmltree", "serde", "serde_json", + "serde_yaml_ng", ] [[package]] @@ -831,6 +838,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1019,6 +1039,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.8" diff --git a/README.md b/README.md index 03f6e85..3f9df04 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # schema-explorer -XML Schema (XSD) as UML-like diagrams, readable type pages and version diffs. +XML Schema (XSD) and JSON Schema (also inside OpenAPI and AsyncAPI) as +UML-like diagrams, readable type pages and version diffs. -Large schema sets such as NeTEx or railML are hard to read as hundreds of -`.xsd` files. schema-explorer reads a whole set, follows its imports and -includes, and shows it the way people are used to: class diagrams, content +Large schema sets such as NeTEx, railML or an OpenAPI specification are hard +to read as hundreds of `.xsd`, `.json` or `.yaml` files. schema-explorer +reads a whole set, follows its imports, includes and `$ref`s, and shows it the way people are used to: class diagrams, content trees, and one page per type and element with every child, attribute, cardinality and allowed value. It also compares two versions of a set and says what changed and what breaks. It is the schema counterpart of @@ -12,17 +13,30 @@ says what changed and what breaks. It is the schema counterpart of It runs entirely in the browser, so it can be published on GitHub Pages, and a small command-line tool pre-indexes large sets, serves the viewer locally -and writes diff reports. JSON Schema support is planned. +and writes diff reports. **Try it:** with NeTEx, -ERA TAF and TAP TSI, OASIS UBL 2.4 and the W3C schema for schemas, or drop -your own files on it. +ERA TAF and TAP TSI, OASIS UBL 2.4, the W3C schema for schemas, the UIC OSDM +OpenAPI specification and the JSON Schema 2020-12 meta-schemas, or drop your +own files on it. ![The page of a NeTEx complex type: derivation chain and class diagram](docs/screenshot-type.png) ## Features -- **Open** `.xsd` files or whole folders by drag and drop or the file picker: +- **XML Schema** 1.0 (tolerating 1.1 constructs) and **JSON Schema** + draft-04, -06, -07, 2019-09 and 2020-12 (picked by `$schema`), in JSON or + YAML, including the schemas of **OpenAPI** 3.0/3.1 (`components.schemas`) + and **AsyncAPI** 2/3 documents (schemas and message payloads). JSON Schema + maps onto the same model and is shown in JSON terms: named schemas + (`$defs`, `definitions`, components) are object or scalar definitions, + properties have a cardinality from `required` and `minItems`/`maxItems`, + a `$ref` in `allOf` is a generalisation, `oneOf`/`anyOf` are choices, + `enum`/`const` are enumerations, `format`, `pattern`, `minimum` and the + other validation keywords are facets, and `patternProperties` and + `additionalProperties` are listed. `$ref` is resolved across files, JSON + pointers, `$id` and `$anchor`. +- **Open** `.xsd`, `.json`, `.yaml` files or whole folders by drag and drop or the file picker: they are indexed in the browser (WebAssembly, in a worker), nothing is uploaded. Or open a pre-built bundle by its address (`?bundle=`), or pick an example. @@ -35,7 +49,9 @@ your own files on it. `sequence` / `choice` / `all` and groups, inherited rows grouped per base type and foldable), attributes (type, use, default or fixed), facets and enumerations with their documentation, identity constraints, where used, - the source file and line, and the XSD of the component with linked names. + the source file and line, and the XSD of the component with linked names + (for JSON Schema: the component's schema as JSON, with `$ref`s linked, + its examples and deprecated/read-only flags). - **Element pages** (global and local): type, substitution group head and members, nillable, abstract, default or fixed, where used. - **Simple type pages**: restriction facets, enumeration table, list item and @@ -64,6 +80,7 @@ your own files on it. ```sh schema-explorer serve path/to/schemas/ # every .xsd file in the folder +schema-explorer serve openapi.yaml # JSON Schema, OpenAPI, AsyncAPI schema-explorer serve main.xsd --bind 0.0.0.0:8080 --no-open ``` @@ -78,8 +95,8 @@ release. Until then, build from source with `cargo build --release -p schema-exp ## Indexing a schema set -The core reads an XSD set (XSD 1.0, tolerating 1.1 constructs) into one JSON -bundle for the viewer: every global and local component keyed by qualified +The core reads an XSD set (XSD 1.0, tolerating 1.1 constructs) or a JSON +Schema set (JSON Schema, OpenAPI or AsyncAPI files) into one JSON bundle for the viewer: every global and local component keyed by qualified name and source file and line, with documentation, references resolved to ids, "where used" back-references, derived types, substitution group members, effective content models and a search index. @@ -87,8 +104,16 @@ members, effective content models and a search index. ```sh schema-explorer index path/to/NeTEx_publication.xsd -o bundle.json schema-explorer index schemas/ --compact -o bundle.json # directories are read recursively +schema-explorer index OSDM-online-api.yml -o bundle.json # OpenAPI with its schema files ``` +A directory gives its `.xsd` files, or every `.json`, `.yaml` and `.yml` +file when it has no `.xsd` file. JSON Schema bundles say +`"language": "jsonSchema"` and carry a `json` block per component (its JSON +kind, JSON pointer, title, examples, flags); `$ref`s to other files are +followed from disk, and a `$ref` that cannot be resolved becomes an +`unresolved-ref` diagnostic. + Imports and includes are followed from disk; a location that cannot be found falls back to an input file with the imported namespace or the same file name, and otherwise becomes an `unresolved-import` diagnostic in the @@ -116,8 +141,14 @@ turns that off). schema-explorer diff railml-3.2/schema/ railml-3.3/schema/ --format md -o CHANGES.md schema-explorer diff old.json new.json -o diff.json # bundles work too schema-explorer diff a.xsd,b.xsd c.xsd,d.xsd --format html -o diff.html --fail-on-breaking +schema-explorer diff api-v1.yaml api-v2.yaml --format md # JSON Schema / OpenAPI ``` +For JSON Schema, a property that becomes required, a removed property or +enum value, a new `additionalProperties: false`, a tighter `maximum` or +`maxItems`, or a dropped `nullable` are breaking; property order is not +compared. + Several paths for one side are joined with commas. The JSON diff format is versioned (`format`, see `crates/core/src/diff.rs`). In the viewer, "Compare two versions" on the open page takes two schema sets; a link @@ -139,14 +170,17 @@ and changed (~, amber). `scripts/build-examples.sh ` shows how the website is built: it pre-indexes a schema set into `bundle.json` and puts its `.xsd` files next to it (for the source view). Any static host works: -`index.html?bundle=examples/netex/bundle.json&sources=examples/netex/xsd/`. +`index.html?bundle=examples/netex/bundle.json&sources=examples/netex/xsd/` +(JSON Schema examples keep their files under `schema/`). Only publish schemas whose licence allows it. The website's examples are fetched from their publishers at build time and keep their own terms: NeTEx (GPL-3.0, NeTEx-CEN); TAF TSI and TAP TSI B.10 schemas, source: European Union Agency for Railways (reproduction authorised, source acknowledged); UBL 2.4, copyright © OASIS Open 2024 (OASIS IPR -Policy); XMLSchema.xsd and xml.xsd, copyright © W3C (W3C Document License). +Policy); XMLSchema.xsd and xml.xsd, copyright © W3C (W3C Document License); +OSDM, UIC (Apache-2.0); JSON Schema 2020-12 meta-schemas, copyright © 2022 +JSON Schema Specification Authors (BSD-3-Clause). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). ## Development @@ -163,6 +197,7 @@ cd web && npm ci && npm run build Tests on real-world schemas read them from the directory in `SCHEMA_FIXTURES` (default `tests/fixtures`, gitignored) and are skipped when absent. `scripts/fetch-fixtures.sh` fetches the public [NeTEx](https://github.com/NeTEx-CEN/NeTEx) +and [OSDM](https://github.com/UnionInternationalCheminsdeFer/OSDM) (OpenAPI) schemas there; the tests also look for `railml//{schema,codelists}` and `telematics/{netex,taf,taf-v3.5.1,b5,b10}`. For timings, run `cargo test --release --test fixtures -- --nocapture`. Schemas whose licence forbids redistribution (such as railML) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index bad4f41..c45f518 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -41,3 +41,9 @@ they are not part of this repository or the binary: with . Copyright © World Wide Web Consortium. Used under the [W3C Document License](https://www.w3.org/copyright/document-license-2023/). +- **OSDM**, the UIC Open Sales and Distribution Model specification (OpenAPI + and JSON schemas), from + , Apache-2.0. +- **JSON Schema 2020-12** meta-schemas, . + Copyright © 2022 JSON Schema Specification Authors. BSD-3-Clause + (). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0d2df63..9bc2686 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-explorer" -description = "Visual explorer for XML Schema (XSD) and, later, JSON Schema: UML-like diagrams, content-model trees and a readable page per type, in a browser" +description = "Visual explorer for XML Schema (XSD) and JSON Schema (also OpenAPI and AsyncAPI): UML-like diagrams, content-model trees and a readable page per type, in a browser" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index fd41988..ccf8bbb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -35,7 +35,8 @@ enum Command { /// shows their source. Without files, the viewer opens empty and indexes /// what is dropped on it in the browser. Serve { - /// Schema files or directories (every .xsd file in them). + /// Schema files or directories (every .xsd file in them, or every + /// .json/.yaml/.yml file when there is no .xsd file). files: Vec, /// Address to listen on. #[arg(long, env = "SCHEMA_EXPLORER_BIND", default_value = "127.0.0.1:8080")] @@ -46,8 +47,10 @@ enum Command { }, /// Index schema files into a bundle for the viewer. /// - /// Directories are read recursively (every .xsd file); imports and - /// includes are followed from disk. + /// XML Schema (.xsd) or JSON Schema, OpenAPI and AsyncAPI (.json, + /// .yaml, .yml). Directories are read recursively (every .xsd file, or + /// every JSON and YAML file when there is no .xsd file); imports, + /// includes and `$ref`s to other files are followed from disk. Index { /// Schema files or directories. #[arg(required = true)] @@ -65,8 +68,8 @@ enum Command { }, /// Compare two versions of a schema set. /// - /// Each side is a schema file, a directory (every .xsd file in it) or a - /// bundle made with `index`; join several paths for one side with + /// Each side is a schema file (XSD, JSON Schema, OpenAPI or AsyncAPI), + /// a directory (every schema file in it) or a bundle made with `index`; join several paths for one side with /// commas. Components are matched by qualified name, and by kind and /// local name when the target namespace changed between the versions. Diff { @@ -149,8 +152,21 @@ fn main() -> Result<()> { } } -/// Every .xsd file under `path` (or `path` itself when it is a file). -fn collect(path: &Path, out: &mut Vec) -> Result<()> { +fn is_schema_file(p: &Path) -> bool { + p.extension().is_some_and(|x| { + ["xsd", "json", "yaml", "yml"] + .iter() + .any(|e| x.eq_ignore_ascii_case(e)) + }) +} + +fn is_xsd(p: &Path) -> bool { + p.extension().is_some_and(|x| x.eq_ignore_ascii_case("xsd")) +} + +/// Every schema file under `path` (or `path` itself when it is a file): +/// .xsd, .json, .yaml and .yml. `explicit` marks the files named directly. +fn collect(path: &Path, explicit: bool, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { if path.is_dir() { let mut entries: Vec = std::fs::read_dir(path) .with_context(|| format!("reading {}", path.display()))? @@ -158,12 +174,12 @@ fn collect(path: &Path, out: &mut Vec) -> Result<()> { .collect::>()?; entries.sort(); for p in entries { - if p.is_dir() || p.extension().is_some_and(|x| x.eq_ignore_ascii_case("xsd")) { - collect(&p, out)?; + if p.is_dir() || is_schema_file(&p) { + collect(&p, false, out)?; } } } else if path.is_file() { - out.push(path.to_path_buf()); + out.push((path.to_path_buf(), explicit)); } else { bail!("{} not found", path.display()); } @@ -172,10 +188,17 @@ fn collect(path: &Path, out: &mut Vec) -> Result<()> { /// Read and index the schema files; also returns every file read from disk. pub(crate) fn build(inputs: &[PathBuf], compact: bool) -> Result<(Bundle, Vec)> { - let mut paths = Vec::new(); + let mut found = Vec::new(); for p in inputs { - collect(p, &mut paths)?; + collect(p, true, &mut found)?; } + // In directories with XSD files, the JSON and YAML files are not schemas. + let xsd = found.iter().any(|(p, _)| is_xsd(p)); + let paths: Vec = found + .into_iter() + .filter(|(p, explicit)| *explicit || !xsd || is_xsd(p)) + .map(|(p, _)| p) + .collect(); let sources = paths .iter() .map(|p| { @@ -209,10 +232,12 @@ pub(crate) fn report(bundle: &Bundle) { let file = d .file .map_or("", |f| bundle.files[f as usize].name.as_str()); - eprintln!( - "{:?}: {file}:{}: {} [{}]", - d.level, d.line, d.message, d.code - ); + let line = if d.line > 0 { + format!(":{}", d.line) + } else { + String::new() + }; + eprintln!("{:?}: {file}{line}: {} [{}]", d.level, d.message, d.code); } } @@ -255,10 +280,15 @@ fn load_side(spec: &str) -> Result { { let text = std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?; - let mut b: Bundle = serde_json::from_str(&text) - .with_context(|| format!("{} is not a schema-explorer bundle", p.display()))?; - schema_explorer_core::expand(&mut b); - return Ok(b); + // A bundle has `format` and `components`; any other JSON is a schema. + let value: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("{} is not valid JSON", p.display()))?; + if value.get("format").is_some() && value.get("components").is_some_and(|c| c.is_array()) { + let mut b: Bundle = serde_json::from_value(value) + .with_context(|| format!("{} is not a schema-explorer bundle", p.display()))?; + schema_explorer_core::expand(&mut b); + return Ok(b); + } } let (b, _) = build(&paths, false)?; report(&b); diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs index 0eaca51..553d97d 100644 --- a/crates/cli/src/serve.rs +++ b/crates/cli/src/serve.rs @@ -127,7 +127,17 @@ async fn source(State(s): State>, Path(file): Path) -> Respon return (StatusCode::NOT_FOUND, "no such file").into_response(); }; match tokio::fs::read(path).await { - Ok(bytes) => with_type(bytes.into_response(), "application/xml", "no-cache"), + Ok(bytes) => { + let xml = path + .extension() + .is_some_and(|x| x.eq_ignore_ascii_case("xsd")); + let mime = if xml { + "application/xml" + } else { + "text/plain; charset=utf-8" + }; + with_type(bytes.into_response(), mime, "no-cache") + } Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1c47249..c92e4cc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-explorer-core" -description = "Reads XML Schema (XSD) sets into one indexed model for the schema-explorer viewer" +description = "Reads XML Schema (XSD) and JSON Schema (also OpenAPI and AsyncAPI) sets into one indexed model for the schema-explorer viewer" version.workspace = true edition.workspace = true authors.workspace = true @@ -12,5 +12,6 @@ categories = ["parser-implementations", "visualization"] [dependencies] roxmltree = "0.21.1" +serde_yaml_ng = "0.10" serde.workspace = true serde_json.workspace = true diff --git a/crates/core/src/bundle.rs b/crates/core/src/bundle.rs index bf22096..aa961a5 100644 --- a/crates/core/src/bundle.rs +++ b/crates/core/src/bundle.rs @@ -1,4 +1,4 @@ -//! The bundle: one XSD set read into components with small integer ids, plus +//! The bundle: one XSD or JSON Schema set read into components with small integer ids, plus //! the indexes the viewer needs ("where used", derived types, substitution //! group members, effective content models, search), as serde JSON. @@ -20,6 +20,9 @@ pub type NsId = u32; pub struct Bundle { pub format: u32, pub generator: String, + /// The schema language the set was read from. + #[serde(default, skip_serializing_if = "Language::is_xsd")] + pub language: Language, /// True when the bundle was written in the compact form: no /// `effective` content models and no `search` index (the viewer /// recomputes them). @@ -38,6 +41,25 @@ pub struct Bundle { pub search: BTreeMap>, } +/// The schema language of a bundle. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Language { + /// XML Schema (XSD). + #[default] + Xsd, + /// JSON Schema, including the schemas of OpenAPI and AsyncAPI documents. + /// Named schemas are complex or simple types, properties are local + /// elements; [`Component::json`] says what they are in JSON terms. + JsonSchema, +} + +impl Language { + pub fn is_xsd(&self) -> bool { + *self == Language::Xsd + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileInfo { @@ -55,6 +77,10 @@ pub struct FileInfo { pub prefixes: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, + /// The schema language and version of a JSON file: `JSON Schema + /// 2020-12`, `OpenAPI 3.1.0`, `AsyncAPI 3.0.0`, ... + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dialect: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub element_form_qualified: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -195,6 +221,39 @@ pub struct Component { /// A built-in type or attribute (no source file). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub builtin: bool, + /// JSON Schema details, for a bundle read from JSON Schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json: Option>, +} + +/// What a component is in JSON Schema terms. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JsonInfo { + /// For a type: the JSON value it describes (`object`, `array`, + /// `string`, `number`, `integer`, `boolean`, `null`, `any`) or how it + /// combines schemas (`oneOf`, `anyOf`, `union`). For a local element: + /// its role (`property`, `patternProperty`, `additionalProperties`, + /// `items`, `option`). + pub kind: String, + /// JSON pointer of the schema in its file. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub pointer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// `examples` and `example`, as JSON text. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub examples: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub deprecated: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub read_only: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub write_only: bool, + /// A property whose value is an array: the element has the item type, + /// and its cardinality comes from `minItems`/`maxItems`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub array: bool, } impl Component { @@ -218,6 +277,7 @@ impl Component { attribute_group: None, identity: None, builtin: false, + json: None, } } pub fn is_global(&self) -> bool { diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs index 58122fe..1074ba6 100644 --- a/crates/core/src/diff.rs +++ b/crates/core/src/diff.rs @@ -287,6 +287,9 @@ pub struct SetInfo { pub struct SchemaDiff { pub format: u32, pub generator: String, + /// The schema language of the new version. + #[serde(default, skip_serializing_if = "Language::is_xsd")] + pub language: Language, pub options: DiffOptions, pub old: SetInfo, pub new: SetInfo, @@ -465,6 +468,7 @@ pub fn diff(old: &Bundle, new: &Bundle, opts: &DiffOptions) -> SchemaDiff { .collect(); SchemaDiff { format: DIFF_FORMAT, + language: new.language, generator: format!("schema-explorer-core {}", crate::version()), options: opts.clone(), old: set_info(old, ga.len()), @@ -1216,7 +1220,12 @@ impl<'a> Cx<'a> { (Some(a), None) => self.push( out, ChangeKind::FacetRemoved, - Impact::NonBreaking, + // JSON Schema `nullable`: no longer allowing null is breaking. + if k == "nullable" { + Impact::Breaking + } else { + Impact::NonBreaking + }, path, k, Some(a.join(" | ")), @@ -1225,7 +1234,11 @@ impl<'a> Cx<'a> { (None, Some(b)) => self.push( out, ChangeKind::FacetAdded, - Impact::Breaking, + if k == "nullable" { + Impact::NonBreaking + } else { + Impact::Breaking + }, path, k, None, @@ -2132,6 +2145,9 @@ fn facet_impact(kind: &str, a: &[String], b: &[String]) -> Impact { let tighter = match kind { "minLength" | "minInclusive" | "minExclusive" => y > x, "maxLength" | "maxInclusive" | "maxExclusive" | "totalDigits" | "fractionDigits" => y < x, + // JSON Schema. + "minimum" | "exclusiveMinimum" | "minItems" | "minProperties" | "minContains" => y > x, + "maximum" | "exclusiveMaximum" | "maxItems" | "maxProperties" | "maxContains" => y < x, _ => true, }; if tighter { @@ -2186,6 +2202,17 @@ pub fn kind_name(k: Kind) -> &'static str { } } +/// The name of a kind in the terms of a schema language: `complexType` +/// in XSD, `object` in JSON Schema, ... +pub fn kind_name_in(lang: Language, k: Kind) -> &'static str { + match (lang, k) { + (Language::JsonSchema, Kind::Element) => "property", + (Language::JsonSchema, Kind::ComplexType) => "object", + (Language::JsonSchema, Kind::SimpleType) => "scalar", + _ => kind_name(k), + } +} + /// A readable label of a change kind: "child added", ... pub fn change_label(k: ChangeKind) -> &'static str { use ChangeKind::*; diff --git a/crates/core/src/json.rs b/crates/core/src/json.rs new file mode 100644 index 0000000..ab8b084 --- /dev/null +++ b/crates/core/src/json.rs @@ -0,0 +1,1703 @@ +//! JSON Schema input: draft-04, -06, -07, 2019-09 and 2020-12 (picked by +//! `$schema`, 2020-12 by default), and the schemas of OpenAPI 3.x and +//! AsyncAPI 2/3 documents, in JSON or YAML. +//! +//! The schemas are mapped onto the XSD model so that the viewer, search, +//! where-used and the version comparison work unchanged: named schemas +//! (`$defs`, `definitions`, `components.schemas`, message payloads, the root +//! schema of a file and every other `$ref` target) become global complex or +//! simple types; object properties become local elements with a cardinality +//! from `required` and `minItems`/`maxItems`; a `$ref` in `allOf` is the base +//! type (extension); `oneOf`/`anyOf` are choices (or unions of simple +//! types); `enum`/`const` are enumerations and the validation keywords are +//! facets. [`JsonInfo`] keeps what each component is in JSON terms. + +use crate::bundle::*; +use crate::input::{self, Resolver, Source}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Flavor { + Schema, + OpenApi, + AsyncApi, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Draft { + D4, + D6, + D7, + D2019, + D2020, +} + +pub(crate) struct JDoc { + pub name: String, + pub display: String, + pub input: bool, + pub value: Option, + flavor: Flavor, + draft: Draft, + /// `JSON Schema 2020-12`, `OpenAPI 3.1.0`, ... + pub label: String, + /// Other files named by a `$ref`: location and the file it was found as. + pub refs: Vec<(String, Option)>, +} + +/// A file name that the JSON reader takes: `.json`, `.yaml`, `.yml`. +pub fn is_json_name(name: &str) -> bool { + let l = name.to_ascii_lowercase(); + l.ends_with(".json") || l.ends_with(".yaml") || l.ends_with(".yml") +} + +/// Keywords whose values are data, not schemas: never searched for `$ref`, +/// `$id` or `$defs`. +const DATA: &[&str] = &["enum", "const", "default", "examples", "example"]; + +/// Keywords that only annotate: a `$ref` next to them is still a plain reference. +const ANNOTATIONS: &[&str] = &[ + "description", + "title", + "$comment", + "examples", + "example", + "deprecated", + "readOnly", + "writeOnly", + "default", + "nullable", + "$id", + "$schema", + "$anchor", + "id", + "externalDocs", + "xml", +]; + +fn is_annotation(k: &str) -> bool { + ANNOTATIONS.contains(&k) || k.starts_with("x-") +} + +/// Keywords that make a schema an object or array (a complex type). +const STRUCTURAL: &[&str] = &[ + "properties", + "patternProperties", + "additionalProperties", + "unevaluatedProperties", + "items", + "prefixItems", + "additionalItems", + "required", + "minProperties", + "maxProperties", + "dependentSchemas", + "propertyNames", + "discriminator", +]; + +fn parse(name: &str, bytes: &[u8]) -> Result { + let (text, _) = input::decode(bytes); + let t = text.trim_start_matches('\u{feff}'); + let l = name.to_ascii_lowercase(); + let yaml = l.ends_with(".yaml") || l.ends_with(".yml"); + if !yaml || t.trim_start().starts_with('{') { + match serde_json::from_str::(t) { + Ok(v) => return Ok(v), + Err(e) if !yaml => return Err((e.to_string(), e.line() as u32)), + Err(_) => {} + } + } + let mut y: serde_yaml_ng::Value = serde_yaml_ng::from_str(t).map_err(|e| { + let line = e.location().map_or(0, |l| l.line() as u32); + (e.to_string(), line) + })?; + y.apply_merge().map_err(|e| (e.to_string(), 0))?; + Ok(from_yaml(y)) +} + +fn from_yaml(y: serde_yaml_ng::Value) -> Value { + use serde_yaml_ng::Value as Y; + match y { + Y::Null => Value::Null, + Y::Bool(b) => Value::Bool(b), + Y::Number(n) => { + if let Some(i) = n.as_i64() { + Value::from(i) + } else if let Some(u) = n.as_u64() { + Value::from(u) + } else { + n.as_f64() + .and_then(serde_json::Number::from_f64) + .map_or(Value::Null, Value::Number) + } + } + Y::String(s) => Value::String(s), + Y::Sequence(s) => Value::Array(s.into_iter().map(from_yaml).collect()), + Y::Mapping(m) => Value::Object( + m.into_iter() + .map(|(k, v)| { + let k = match k { + Y::String(s) => s, + Y::Bool(b) => b.to_string(), + Y::Number(n) => n.to_string(), + Y::Null => "null".to_string(), + other => serde_yaml_ng::to_string(&other) + .unwrap_or_default() + .trim() + .to_string(), + }; + (k, from_yaml(v)) + }) + .collect(), + ), + Y::Tagged(t) => from_yaml(t.value), + } +} + +fn detect(v: &Value) -> (Flavor, Draft, String) { + let s = |k: &str| v.get(k).and_then(Value::as_str); + if let Some(ver) = s("openapi") { + let d = if ver.starts_with("3.0") { + Draft::D4 + } else { + Draft::D2020 + }; + return (Flavor::OpenApi, d, format!("OpenAPI {ver}")); + } + if let Some(ver) = s("asyncapi") { + return (Flavor::AsyncApi, Draft::D7, format!("AsyncAPI {ver}")); + } + if let Some(ver) = s("swagger") { + return (Flavor::Schema, Draft::D4, format!("Swagger {ver}")); + } + let d = match s("$schema") { + Some(u) if u.contains("draft-04") || u.contains("draft-03") => Draft::D4, + Some(u) if u.contains("draft-06") => Draft::D6, + Some(u) if u.contains("draft-07") => Draft::D7, + Some(u) if u.contains("2019-09") => Draft::D2019, + _ => Draft::D2020, + }; + let name = match d { + Draft::D4 => "draft-04", + Draft::D6 => "draft-06", + Draft::D7 => "draft-07", + Draft::D2019 => "2019-09", + Draft::D2020 => "2020-12", + }; + (Flavor::Schema, d, format!("JSON Schema {name}")) +} + +fn escape(seg: &str) -> String { + seg.replace('~', "~0").replace('/', "~1") +} + +fn unescape(seg: &str) -> String { + seg.replace("~1", "/").replace("~0", "~") +} + +fn child(ptr: &str, seg: &str) -> String { + format!("{ptr}/{}", escape(seg)) +} + +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + if b[i] == b'%' + && i + 2 < b.len() + && let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) + { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + out.push(b[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn split_frag(r: &str) -> (&str, Option<&str>) { + match r.split_once('#') { + Some((u, f)) => (u, Some(f)), + None => (r, None), + } +} + +/// A URI with a scheme (`https:`, `urn:`, ...). +fn has_scheme(s: &str) -> bool { + match s.find(':') { + Some(i) if i > 1 => s[..i] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')), + _ => false, + } +} + +fn dot_segments(path: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for seg in path.split('/') { + match seg { + "." => {} + ".." => { + if out.len() > 1 { + out.pop(); + } + } + s => out.push(s), + } + } + out.join("/") +} + +/// Resolve `rel` against the base URI or file path `base`. +fn resolve_uri(base: &str, rel: &str) -> String { + if rel.is_empty() { + return base.to_string(); + } + if has_scheme(rel) { + return rel.to_string(); + } + if input::is_url(base) { + let start = base.find("://").map_or(0, |i| i + 3); + let host_end = base[start..].find('/').map_or(base.len(), |i| start + i); + if rel.starts_with('/') { + return format!("{}{}", &base[..host_end], dot_segments(rel)); + } + let dir = &base[..base + .rfind('/') + .filter(|&i| i >= host_end) + .map_or(base.len(), |i| i + 1)]; + let path = format!("{}{rel}", &dir[host_end..]); + let path = if path.starts_with('/') { + path + } else { + format!("/{path}") + }; + return format!("{}{}", &base[..host_end], dot_segments(&path)); + } + if has_scheme(base) { + return rel.to_string(); + } + input::join(base, rel) +} + +/// Every `$ref` string in `v` (not in data keywords), with the pointer of +/// the object that holds it. +fn refs<'v>(v: &'v Value, ptr: &str, out: &mut Vec<(String, &'v str)>) { + match v { + Value::Object(m) => { + if let Some(Value::String(r)) = refkw(m) { + out.push((ptr.to_string(), r)); + } + for (k, c) in m { + if !DATA.contains(&k.as_str()) && (c.is_object() || c.is_array()) { + refs(c, &child(ptr, k), out); + } + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + refs(c, &child(ptr, &i.to_string()), out); + } + } + _ => {} + } +} + +pub(crate) fn load( + sources: Vec, + mut resolver: Option<&mut Resolver<'_>>, + diags: &mut Vec, +) -> Vec { + let mut docs: Vec = Vec::new(); + let mut by_name: HashMap = HashMap::new(); + let add = |docs: &mut Vec, + by_name: &mut HashMap, + diags: &mut Vec, + name: String, + bytes: &[u8], + input: bool| { + let idx = docs.len(); + let value = match parse(&name, bytes) { + Ok(v) => Some(v), + Err((msg, line)) => { + diags.push(Diagnostic { + level: Level::Error, + code: "parse-error".into(), + message: msg, + file: Some(idx as FileId), + line, + }); + None + } + }; + let (flavor, draft, label) = value + .as_ref() + .map_or_else(|| (Flavor::Schema, Draft::D2020, String::new()), detect); + by_name.insert(name.clone(), idx); + docs.push(JDoc { + name, + display: String::new(), + input, + value, + flavor, + draft, + label, + refs: Vec::new(), + }); + idx + }; + for s in sources { + let name = input::normalize(&s.name); + if by_name.contains_key(&name) { + continue; + } + add(&mut docs, &mut by_name, diags, name, &s.bytes, true); + } + let mut queue: VecDeque = (0..docs.len()).collect(); + while let Some(i) = queue.pop_front() { + let Some(v) = &docs[i].value else { continue }; + let mut found = Vec::new(); + refs(v, "", &mut found); + let mut locations: Vec = Vec::new(); + for (_, r) in found { + let (u, _) = split_frag(r); + if !u.is_empty() && !locations.iter().any(|l| l == u) { + locations.push(u.to_string()); + } + } + let mut out = Vec::new(); + for loc in locations { + let target = if has_scheme(&loc) { + loc.clone() + } else { + input::join(&docs[i].name, &loc) + }; + let mut file = by_name.get(&target).copied(); + if file.is_none() + && let Some(r) = resolver.as_deref_mut() + && let Some(bytes) = r(&target) + { + let j = add(&mut docs, &mut by_name, diags, target, &bytes, false); + // A fragment file without `$schema` takes the dialect of + // the document that refers to it (OpenAPI 3.0, ...). + if docs[j].flavor == Flavor::Schema + && docs[j] + .value + .as_ref() + .is_some_and(|v| v.get("$schema").is_none() && v.get("swagger").is_none()) + { + docs[j].draft = docs[i].draft; + docs[j].label = docs[i].label.clone(); + } + queue.push_back(j); + file = Some(j); + } + out.push((loc, file)); + } + docs[i].refs = out; + } + let names: Vec<&str> = docs.iter().map(|f| f.name.as_str()).collect(); + let root = input::common_dir(&names); + for f in &mut docs { + f.display = f.name.strip_prefix(&root).unwrap_or(&f.name).to_string(); + } + docs +} + +/// `$id`s and anchors of all files. +#[derive(Default)] +struct Registry { + /// Absolute URI (or file name) of a schema resource to its location. + resources: HashMap, + /// `uri#anchor` to its location. + anchors: HashMap, + /// The base URI of the objects that set one with `$id`. + bases: HashMap<(usize, String), String>, +} + +impl Registry { + fn new(docs: &[JDoc]) -> Registry { + let mut reg = Registry::default(); + for (d, doc) in docs.iter().enumerate() { + reg.resources + .entry(doc.name.clone()) + .or_insert((d, String::new())); + if let Some(v) = &doc.value { + reg.walk(d, v, "", &doc.name, doc.draft); + } + } + reg + } + + fn walk(&mut self, d: usize, v: &Value, ptr: &str, base: &str, draft: Draft) { + match v { + Value::Object(m) => { + let mut base = base.to_string(); + let id = m.get("$id").or(if draft == Draft::D4 { + m.get("id") + } else { + None + }); + if let Some(Value::String(id)) = id { + let (u, frag) = split_frag(id); + if !u.is_empty() { + base = resolve_uri(&base, u); + self.resources + .entry(base.clone()) + .or_insert((d, ptr.to_string())); + self.bases.insert((d, ptr.to_string()), base.clone()); + } + if let Some(f) = frag.filter(|f| !f.is_empty()) { + self.anchors + .insert(format!("{base}#{f}"), (d, ptr.to_string())); + } + } + for k in ["$anchor", "$dynamicAnchor"] { + if let Some(Value::String(a)) = m.get(k) { + self.anchors + .entry(format!("{base}#{a}")) + .or_insert((d, ptr.to_string())); + } + } + for (k, c) in m { + if !DATA.contains(&k.as_str()) && (c.is_object() || c.is_array()) { + self.walk(d, c, &child(ptr, k), &base, draft); + } + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + self.walk(d, c, &child(ptr, &i.to_string()), base, draft); + } + } + _ => {} + } + } + + fn base_of(&self, docs: &[JDoc], d: usize, ptr: &str) -> String { + let mut p = ptr; + loop { + if let Some(b) = self.bases.get(&(d, p.to_string())) { + return b.clone(); + } + if p.is_empty() { + return docs[d].name.clone(); + } + p = &p[..p.rfind('/').unwrap_or(0)]; + } + } + + /// The location a `$ref` in the object at `ptr` of file `d` points to. + fn resolve(&self, docs: &[JDoc], d: usize, ptr: &str, r: &str) -> Option<(usize, String)> { + let base = self.base_of(docs, d, ptr); + let (u, frag) = split_frag(r); + let root = if u.is_empty() { + self.resources + .get(&base) + .cloned() + .unwrap_or((d, String::new())) + } else { + let abs = resolve_uri(&base, u); + self.resources + .get(&abs) + .or_else(|| self.resources.get(&input::join(&docs[d].name, u))) + .cloned()? + }; + let frag = percent_decode(frag.unwrap_or("")); + let loc = if frag.is_empty() { + root + } else if frag.starts_with('/') { + (root.0, format!("{}{frag}", root.1)) + } else { + let b = self.base_of(docs, root.0, &root.1); + self.anchors.get(&format!("{b}#{frag}")).cloned()? + }; + docs[loc.0].value.as_ref()?.pointer(&loc.1)?; + Some(loc) + } +} + +fn stem(name: &str) -> String { + let base = name.rsplit('/').next().unwrap_or(name); + let mut s = base; + for ext in [".json", ".yaml", ".yml"] { + if s.len() > ext.len() && s[s.len() - ext.len()..].eq_ignore_ascii_case(ext) { + s = &s[..s.len() - ext.len()]; + break; + } + } + s.strip_suffix(".schema").unwrap_or(s).to_string() +} + +const KEYWORD_SEGMENTS: &[&str] = &[ + "schema", + "items", + "additionalProperties", + "payload", + "content", + "properties", + "allOf", + "anyOf", + "oneOf", + "not", + "$defs", + "definitions", + "patternProperties", + "prefixItems", + "schemas", + "components", +]; + +/// A name for the schema at `ptr` that is not a named definition. +fn name_hint(ptr: &str) -> Option { + let segs: Vec = ptr.split('/').skip(1).map(unescape).collect(); + segs.into_iter().rev().find(|s| { + !s.is_empty() + && !s.contains('/') + && !s.chars().all(|c| c.is_ascii_digit()) + && !KEYWORD_SEGMENTS.contains(&s.as_str()) + }) +} + +fn text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +fn types(m: &Map) -> Vec { + match m.get("type") { + Some(Value::String(t)) => vec![t.clone()], + Some(Value::Array(a)) => a + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +const REF_KEYWORDS: &[&str] = &["$ref", "$dynamicRef", "$recursiveRef"]; + +/// The `$ref` of a schema, or its `$dynamicRef` / `$recursiveRef` (taken +/// as a plain reference to the anchor or resource it names). +fn refkw(m: &Map) -> Option<&Value> { + REF_KEYWORDS.iter().find_map(|k| m.get(*k)) +} + +fn is_ref_only(m: &Map) -> bool { + refkw(m).is_some() + && m.keys() + .all(|k| REF_KEYWORDS.contains(&k.as_str()) || is_annotation(k)) +} + +/// A schema that only names a JSON type: `{"type": "string"}`. +fn plain_type(m: &Map) -> Option { + if !m.keys().all(|k| k == "type" || is_annotation(k)) + || m.get("nullable") == Some(&Value::Bool(true)) + { + return None; + } + match m.get("type") { + None => Some("any".into()), + Some(Value::String(t)) => Some(t.clone()), + _ => None, + } +} + +fn is_array_schema(m: &Map) -> bool { + let t = types(m); + t.iter().any(|t| t == "array") + || (t.is_empty() && (m.contains_key("items") || m.contains_key("prefixItems"))) +} + +/// What a complex type is: `object`, `array`, `oneOf`, `anyOf`. +fn shape(m: &Map) -> &'static str { + let t = types(m); + if t.iter().any(|t| t == "object") || m.contains_key("properties") { + "object" + } else if is_array_schema(m) { + "array" + } else if m.contains_key("oneOf") { + "oneOf" + } else if m.contains_key("anyOf") { + "anyOf" + } else { + "object" + } +} + +fn facets(m: &Map, draft: Draft) -> Vec { + let mut out = Vec::new(); + let f = |kind: &str, value: String| Facet { + kind: kind.to_string(), + value, + fixed: false, + }; + let excl = |k: &str| draft == Draft::D4 && m.get(k) == Some(&Value::Bool(true)); + for k in [ + "format", + "pattern", + "minLength", + "maxLength", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minItems", + "maxItems", + "uniqueItems", + "minContains", + "maxContains", + "minProperties", + "maxProperties", + "contentEncoding", + "contentMediaType", + ] { + let Some(v) = m.get(k) else { continue }; + match (k, v) { + ("exclusiveMinimum" | "exclusiveMaximum" | "uniqueItems", Value::Bool(_)) => { + if k == "uniqueItems" && v == &Value::Bool(true) { + out.push(f(k, "true".into())); + } + } + ("minimum", _) if excl("exclusiveMinimum") => out.push(f("exclusiveMinimum", text(v))), + ("maximum", _) if excl("exclusiveMaximum") => out.push(f("exclusiveMaximum", text(v))), + _ => out.push(f(k, text(v))), + } + } + if m.get("nullable") == Some(&Value::Bool(true)) + || types(m).iter().any(|t| t == "null") && types(m).len() > 1 + { + out.push(f("nullable", "true".into())); + } + out +} + +fn enumerations(m: &Map) -> Vec { + let mut out = Vec::new(); + let descs = m + .get("x-enum-descriptions") + .or_else(|| m.get("x-enumDescriptions")); + if let Some(Value::Array(vals)) = m.get("enum") { + for (i, v) in vals.iter().enumerate() { + let value = text(v); + let doc = match descs { + Some(Value::Array(a)) => a.get(i).and_then(Value::as_str), + Some(Value::Object(o)) => o.get(&value).and_then(Value::as_str), + _ => None, + }; + out.push(Enumeration { + value, + docs: doc + .map(|t| Doc { + lang: None, + source: None, + text: t.to_string(), + }) + .into_iter() + .collect(), + appinfo: Vec::new(), + line: 0, + }); + } + } + if let Some(v) = m.get("const") { + out.push(Enumeration { + value: text(v), + docs: Vec::new(), + appinfo: Vec::new(), + line: 0, + }); + } + out +} + +fn doc(text: &str) -> Doc { + Doc { + lang: None, + source: None, + text: text.to_string(), + } +} + +/// What the properties of an object and its inline `allOf` parts add up to. +#[derive(Default)] +struct Parts { + base: Option, + mixins: Vec, + props: Vec<(String, String)>, + required: Vec, + pattern: Vec<(String, String)>, + additional: Option, + open: Option, + closed_unevaluated: bool, + choices: Vec<(bool, String)>, + facets: Vec, + enums: Vec, +} + +pub(crate) struct Builder<'a> { + docs: &'a [JDoc], + reg: Registry, + pub components: Vec, + pub usages: Vec<(Id, Usage)>, + pub diagnostics: Vec, + pub namespaces: Vec, + pub file_ns: Vec>, + pub file_globals: Vec, + pub unresolved_refs: u32, + globals: HashMap<(usize, String), Id>, + taken: HashSet<(Option, String)>, + builtins: HashMap, + queue: VecDeque<(Id, usize, String)>, + simple_memo: HashMap<(usize, String), bool>, + mixins: Vec<(Id, Id)>, +} + +impl<'a> Builder<'a> { + pub fn new(docs: &'a [JDoc]) -> Builder<'a> { + Builder { + docs, + reg: Registry::new(docs), + components: Vec::new(), + usages: Vec::new(), + diagnostics: Vec::new(), + namespaces: Vec::new(), + file_ns: vec![None; docs.len()], + file_globals: vec![0; docs.len()], + unresolved_refs: 0, + globals: HashMap::new(), + taken: HashSet::new(), + builtins: HashMap::new(), + queue: VecDeque::new(), + simple_memo: HashMap::new(), + mixins: Vec::new(), + } + } + + fn value(&self, d: usize, ptr: &str) -> Option<&'a Value> { + self.docs[d].value.as_ref()?.pointer(ptr) + } + + /// The named schemas of a file: pointer and name. + fn entries(&self, d: usize) -> Vec<(String, String)> { + let doc = &self.docs[d]; + let Some(v) = &doc.value else { + return Vec::new(); + }; + let mut out = Vec::new(); + let map = |ptr: String, out: &mut Vec<(String, String)>| { + if let Some(Value::Object(m)) = v.pointer(&ptr) { + for k in m.keys() { + out.push((child(&ptr, k), k.clone())); + } + } + }; + match doc.flavor { + Flavor::Schema => { + if let Value::Object(m) = v + && m.keys().any(|k| { + STRUCTURAL.contains(&k.as_str()) + || matches!( + k.as_str(), + "type" | "allOf" | "anyOf" | "oneOf" | "$ref" | "enum" | "const" + ) + }) + && !m.contains_key("swagger") + { + out.push((String::new(), stem(&doc.name))); + } + } + Flavor::OpenApi => map("/components/schemas".into(), &mut out), + Flavor::AsyncApi => { + map("/components/schemas".into(), &mut out); + if let Some(Value::Object(msgs)) = v.pointer("/components/messages") { + for (k, msg) in msgs { + let Some(p) = msg.get("payload") else { + continue; + }; + let ptr = format!("{}/payload", child("/components/messages", k)); + if p.get("schemaFormat").is_some() { + if p.get("schema").is_some() + && p["schemaFormat"].as_str().is_some_and(|f| { + f.contains("json") + || f.contains("asyncapi") + || f.contains("openapi") + }) + { + out.push((format!("{ptr}/schema"), k.clone())); + } + } else { + out.push((ptr, k.clone())); + } + } + } + } + } + defs(v, "", "", &mut out); + out + } + + pub fn run(&mut self) { + let entries: Vec> = + (0..self.docs.len()).map(|d| self.entries(d)).collect(); + // One namespace per file only when names would clash between files. + let mut seen: HashMap<&str, usize> = HashMap::new(); + let mut clash = false; + for (d, list) in entries.iter().enumerate() { + for (_, n) in list { + if *seen.entry(n.as_str()).or_insert(d) != d { + clash = true; + } + } + } + if clash { + let mut prefixes: HashSet = HashSet::new(); + for (d, doc) in self.docs.iter().enumerate() { + if doc.value.is_none() { + continue; + } + let uri = doc + .value + .as_ref() + .and_then(|v| v.get("$id").or_else(|| v.get("id"))) + .and_then(Value::as_str) + .map_or_else(|| doc.display.clone(), str::to_string); + let mut p: String = stem(&doc.display) + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + if p.is_empty() { + p = "ns".into(); + } + let mut q = p.clone(); + let mut n = 2; + while !prefixes.insert(q.clone()) { + q = format!("{p}{n}"); + n += 1; + } + self.file_ns[d] = Some(self.namespaces.len() as NsId); + self.namespaces.push(Namespace { uri, prefix: q }); + } + } + for (d, list) in entries.into_iter().enumerate() { + for (ptr, name) in list { + let Some(v) = self.value(d, &ptr) else { + continue; + }; + // A named schema that is only a `$ref` (OpenAPI components + // pointing into other files) names its target instead. + if let Value::Object(m) = v + && let Some(Value::String(r)) = refkw(m) + && is_ref_only(m) + && let Some(t) = self.reg.resolve(self.docs, d, &ptr, r) + && t.0 != d + && self.value(t.0, &t.1).is_some_and(Value::is_object) + { + let id = self.global(t.0, &t.1, &name); + self.globals.insert((d, ptr), id); + continue; + } + if v.is_object() || v.is_boolean() { + self.global(d, &ptr, &name); + } + } + } + while let Some((id, d, ptr)) = self.queue.pop_front() { + self.fill(id, d, &ptr); + } + // Mixins: the properties of the other `allOf` references. + for (id, from) in std::mem::take(&mut self.mixins) { + let extra = self.components[from as usize] + .complex_type + .as_ref() + .and_then(|c| c.content.clone()); + let Some(extra) = extra else { continue }; + let Some(ct) = self.components[id as usize].complex_type.as_mut() else { + continue; + }; + let mine = ct.content.take(); + let mut items = match extra { + Particle::All { items, .. } => items, + p => vec![p], + }; + match mine { + Some(Particle::All { items: own, .. }) => items.extend(own), + Some(p) => items.push(p), + None => {} + } + ct.content = Some(Particle::All { + items, + occurs: Occurs::default(), + }); + } + // External files that nothing was found for. + for (d, doc) in self.docs.iter().enumerate() { + for (loc, file) in &doc.refs { + if file.is_some() { + continue; + } + let base = self.reg.base_of(self.docs, d, ""); + if self.reg.resources.contains_key(&resolve_uri(&base, loc)) { + continue; + } + self.diagnostics.push(Diagnostic { + level: Level::Warning, + code: "unresolved-import".into(), + message: format!("schema {loc} referenced with $ref was not found"), + file: Some(d as FileId), + line: 0, + }); + } + } + } + + fn unique(&mut self, ns: Option, name: &str) -> String { + let mut n = name.to_string(); + let mut i = 2; + while !self.taken.insert((ns, n.clone())) { + n = format!("{name}_{i}"); + i += 1; + } + n + } + + fn global(&mut self, d: usize, ptr: &str, name: &str) -> Id { + if let Some(&id) = self.globals.get(&(d, ptr.to_string())) { + return id; + } + let simple = self.is_simple(d, ptr, 0); + let ns = self.file_ns[d]; + let name = self.unique(ns, name); + let mut c = Component::new( + if simple { + Kind::SimpleType + } else { + Kind::ComplexType + }, + name, + ); + c.ns = ns; + c.file = Some(d as FileId); + c.json = Some(Box::new(JsonInfo { + pointer: ptr.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + self.globals.insert((d, ptr.to_string()), id); + self.file_globals[d] += 1; + self.queue.push_back((id, d, ptr.to_string())); + id + } + + fn is_simple(&mut self, d: usize, ptr: &str, depth: u32) -> bool { + let Some(Value::Object(m)) = self.value(d, ptr) else { + return true; + }; + if depth > 12 { + return false; + } + if m.keys().any(|k| STRUCTURAL.contains(&k.as_str())) { + return false; + } + if types(m).iter().any(|t| t == "object" || t == "array") { + return false; + } + if let Some(Value::String(r)) = refkw(m) + && let Some(t) = self.reg.resolve(self.docs, d, ptr, r) + { + if let Some(&s) = self.simple_memo.get(&t) { + if !s { + return false; + } + } else { + self.simple_memo.insert(t.clone(), true); + let s = self.is_simple(t.0, &t.1, depth + 1); + self.simple_memo.insert(t, s); + if !s { + return false; + } + } + } + for k in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(a)) = m.get(k) { + for i in 0..a.len() { + let p = child(&child(ptr, k), &i.to_string()); + if !self.is_simple(d, &p, depth + 1) { + return false; + } + } + } + } + true + } + + fn builtin(&mut self, name: &str) -> Ref { + if let Some(&id) = self.builtins.get(name) { + return Ref::Id(id); + } + let mut c = Component::new(Kind::SimpleType, name.to_string()); + c.builtin = true; + c.simple_type = Some(SimpleInfo { + variety: Variety::Atomic, + base: None, + item: None, + members: Vec::new(), + facets: Vec::new(), + enumerations: Vec::new(), + r#final: None, + derived: Vec::new(), + }); + c.json = Some(Box::new(JsonInfo { + kind: name.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + self.builtins.insert(name.to_string(), id); + Ref::Id(id) + } + + fn uses(&mut self, r: &Ref, by: Id, how: UseKind) { + if let Ref::Id(t) = r + && !self.components[*t as usize].builtin + { + self.usages.push((*t, Usage { by, how })); + } + } + + /// Follow a `$ref` (held by the object at `ptr`) to its global type. + fn ref_to(&mut self, d: usize, ptr: &str, r: &str, by: Id, how: UseKind) -> Ref { + let target = self.reg.resolve(self.docs, d, ptr, r); + match target { + Some((td, tptr)) + if self + .value(td, &tptr) + .is_some_and(|v| v.is_object() || v.is_boolean()) => + { + let name = if tptr.is_empty() { + stem(&self.docs[td].name) + } else { + name_hint(&tptr).unwrap_or_else(|| stem(&self.docs[td].name)) + }; + let g = self.global(td, &tptr, &name); + let r = Ref::Id(g); + self.uses(&r, by, how); + r + } + _ => { + self.unresolved_refs += 1; + self.diagnostics.push(Diagnostic { + level: Level::Warning, + code: "unresolved-ref".into(), + message: format!( + "$ref {r} at {} not resolved", + if ptr.is_empty() { "/" } else { ptr } + ), + file: Some(d as FileId), + line: 0, + }); + Ref::Missing(r.to_string()) + } + } + } + + /// The type of the schema at `ptr`: a global, a built-in or a new + /// anonymous type owned by `owner`. + fn type_ref(&mut self, d: usize, ptr: &str, owner: Id, how: UseKind) -> Option { + if let Some(&g) = self.globals.get(&(d, ptr.to_string())) { + let r = Ref::Id(g); + self.uses(&r, owner, how); + return Some(r); + } + match self.value(d, ptr)? { + Value::Bool(_) => Some(self.builtin("any")), + Value::Object(m) => { + if let Some(Value::String(r)) = refkw(m) + && is_ref_only(m) + { + return Some(self.ref_to(d, ptr, r, owner, how)); + } + // `allOf: [{$ref}]` next to annotations only: a plain reference too. + if let Some(Value::Array(all)) = m.get("allOf") + && let [Value::Object(one)] = all.as_slice() + && let Some(Value::String(r)) = refkw(one) + && is_ref_only(one) + && m.keys().all(|k| k == "allOf" || is_annotation(k)) + { + let holder = child(&child(ptr, "allOf"), "0"); + return Some(self.ref_to(d, &holder, r, owner, how)); + } + if let Some(t) = plain_type(m) { + return Some(self.builtin(&t)); + } + let simple = self.is_simple(d, ptr, 0); + let mut c = Component::new( + if simple { + Kind::SimpleType + } else { + Kind::ComplexType + }, + String::new(), + ); + c.parent = Some(owner); + c.file = Some(d as FileId); + c.json = Some(Box::new(JsonInfo { + pointer: ptr.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + self.fill(id, d, ptr); + Some(Ref::Id(id)) + } + _ => None, + } + } + + fn annotate(&mut self, id: Id, v: &Value) { + let Value::Object(m) = v else { return }; + let c = &mut self.components[id as usize]; + let j = c.json.get_or_insert_with(Default::default); + let desc = m.get("description").and_then(Value::as_str); + let title = m.get("title").and_then(Value::as_str); + if let Some(t) = desc.or(title) + && !t.trim().is_empty() + { + c.docs.push(doc(t)); + } + if desc.is_some() { + j.title = title.map(str::to_string); + } + if let Some(Value::Array(a)) = m.get("examples") { + j.examples.extend(a.iter().map(Value::to_string)); + } + if let Some(e) = m.get("example") { + j.examples.push(e.to_string()); + } + j.deprecated = m.get("deprecated") == Some(&Value::Bool(true)); + j.read_only = m.get("readOnly") == Some(&Value::Bool(true)); + j.write_only = m.get("writeOnly") == Some(&Value::Bool(true)); + } + + fn fill(&mut self, id: Id, d: usize, ptr: &str) { + let Some(v) = self.value(d, ptr) else { return }; + self.annotate(id, v); + if self.components[id as usize].kind == Kind::SimpleType { + self.simple(id, d, ptr, v); + } else { + self.complex(id, d, ptr, v); + } + } + + fn simple(&mut self, id: Id, d: usize, ptr: &str, v: &'a Value) { + let mut info = SimpleInfo { + variety: Variety::Atomic, + base: None, + item: None, + members: Vec::new(), + facets: Vec::new(), + enumerations: Vec::new(), + r#final: None, + derived: Vec::new(), + }; + let mut kind = String::from("any"); + if let Value::Object(m) = v { + let draft = self.docs[d].draft; + let mut parts: Vec<(String, &Map)> = vec![(ptr.to_string(), m)]; + if let Some(Value::Array(a)) = m.get("allOf") { + for (i, p) in a.iter().enumerate() { + if let Value::Object(pm) = p { + parts.push((child(&child(ptr, "allOf"), &i.to_string()), pm)); + } + } + } + for (pp, pm) in &parts { + if let Some(Value::String(r)) = refkw(pm) { + let r = self.ref_to(d, pp, r, id, UseKind::Base); + if info.base.is_none() { + info.base = Some(r); + } + } + info.facets.extend(facets(pm, draft)); + info.enumerations.extend(enumerations(pm)); + } + let t: Vec = types(m).into_iter().filter(|t| t != "null").collect(); + if let Some(first) = t.first() { + kind = first.clone(); + } + if info.base.is_none() { + if t.len() == 1 { + info.base = Some(self.builtin(&t[0])); + } else if t.len() > 1 { + info.variety = Variety::Union; + kind = "union".into(); + for x in &t { + let r = self.builtin(x); + info.members.push(r); + } + } + } + for k in ["oneOf", "anyOf"] { + let Some(Value::Array(alts)) = m.get(k) else { + continue; + }; + if alts.iter().all(|a| a.get("const").is_some()) { + for a in alts { + let mut e = Enumeration { + value: text(&a["const"]), + docs: Vec::new(), + appinfo: Vec::new(), + line: 0, + }; + if let Some(t) = a + .get("description") + .or_else(|| a.get("title")) + .and_then(Value::as_str) + { + e.docs.push(doc(t)); + } + info.enumerations.push(e); + } + continue; + } + info.variety = Variety::Union; + kind = "union".into(); + for i in 0..alts.len() { + let ap = child(&child(ptr, k), &i.to_string()); + if let Some(r) = self.type_ref(d, &ap, id, UseKind::Member) { + info.members.push(r); + } + } + } + if info.base.is_none() && info.members.is_empty() { + let mut vals: Vec<&Value> = m + .get("enum") + .and_then(Value::as_array) + .map(|a| a.iter().collect()) + .unwrap_or_default(); + for k in ["oneOf", "anyOf"] { + if let Some(Value::Array(alts)) = m.get(k) { + vals.extend(alts.iter().filter_map(|a| a.get("const"))); + } + } + let guess = if !vals.is_empty() && vals.iter().all(|v| v.is_string()) { + "string" + } else if !vals.is_empty() && vals.iter().all(|v| v.is_number()) { + "number" + } else { + "any" + }; + if guess != "any" { + kind = guess.into(); + } + info.base = Some(self.builtin(guess)); + } + if kind == "any" + && let Some(Ref::Id(b)) = &info.base + && let Some(j) = &self.components[*b as usize].json + { + kind = j.kind.clone(); + } + } else { + info.base = Some(self.builtin("any")); + } + let c = &mut self.components[id as usize]; + if let Some(j) = c.json.as_mut() { + j.kind = kind; + } + c.simple_type = Some(info); + } + + fn gather( + &mut self, + id: Id, + d: usize, + ptr: &str, + m: &'a Map, + parts: &mut Parts, + depth: u32, + ) { + let draft = self.docs[d].draft; + if let Some(Value::String(r)) = refkw(m) { + let r = self.ref_to(d, ptr, r, id, UseKind::Base); + if parts.base.is_none() { + parts.base = Some(r); + } else if let Ref::Id(t) = r { + parts.mixins.push(t); + } + } + if let Some(Value::Object(props)) = m.get("properties") { + for k in props.keys() { + if !parts.props.iter().any(|(n, _)| n == k) { + parts + .props + .push((k.clone(), child(&child(ptr, "properties"), k))); + } + } + } + if let Some(Value::Array(req)) = m.get("required") { + for r in req.iter().filter_map(Value::as_str) { + if !parts.required.iter().any(|x| x == r) { + parts.required.push(r.to_string()); + } + } + } + if let Some(Value::Object(pp)) = m.get("patternProperties") { + for k in pp.keys() { + parts + .pattern + .push((k.clone(), child(&child(ptr, "patternProperties"), k))); + } + } + match m.get("additionalProperties") { + Some(Value::Bool(b)) => parts.open = Some(*b), + Some(Value::Object(_)) => parts.additional = Some(child(ptr, "additionalProperties")), + _ => {} + } + if m.get("unevaluatedProperties") == Some(&Value::Bool(false)) { + parts.closed_unevaluated = true; + } + for (k, any) in [("oneOf", false), ("anyOf", true)] { + if let Some(Value::Array(a)) = m.get(k) + && !a.is_empty() + { + parts.choices.push((any, child(ptr, k))); + } + } + parts.facets.extend(facets(m, draft)); + parts.enums.extend(enumerations(m)); + if let Some(Value::Array(all)) = m.get("allOf") + && depth < 8 + { + for (i, p) in all.iter().enumerate() { + let pp = child(&child(ptr, "allOf"), &i.to_string()); + if let Value::Object(pm) = p { + self.gather(id, d, &pp, pm, parts, depth + 1); + } + } + } + } + + fn new_element(&mut self, owner: Id, d: usize, ptr: &str, name: &str, role: &str) -> Id { + let mut c = Component::new(Kind::Element, name.to_string()); + c.parent = Some(owner); + c.file = Some(d as FileId); + c.json = Some(Box::new(JsonInfo { + kind: role.to_string(), + pointer: ptr.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + id + } + + /// A local element for the property (or other subschema) at `ptr`. + fn property( + &mut self, + owner: Id, + d: usize, + ptr: &str, + name: &str, + role: &str, + occurs: Occurs, + ) -> Particle { + let el = self.new_element(owner, d, ptr, name, role); + let v = self.value(d, ptr); + let mut info = ElementInfo::default(); + let mut occurs = occurs; + let mut tptr = ptr.to_string(); + if let Some(v) = v { + self.annotate(el, v); + if let Value::Object(m) = v { + if role == "property" + && refkw(m).is_none() + && !self.globals.contains_key(&(d, ptr.to_string())) + && is_array_schema(m) + && m.get("items") + .is_some_and(|i| i.is_object() || i.is_boolean()) + && !m.contains_key("prefixItems") + && !m + .keys() + .any(|k| matches!(k.as_str(), "properties" | "oneOf" | "anyOf" | "allOf")) + { + let n = |k: &str| { + m.get(k) + .and_then(Value::as_u64) + .map(|x| x.min(u32::MAX as u64) as u32) + }; + let min = n("minItems").unwrap_or(0); + occurs = Occurs { + min: if occurs.min > 0 { min.max(1) } else { 0 }, + max: n("maxItems"), + }; + tptr = child(ptr, "items"); + if let Some(j) = self.components[el as usize].json.as_mut() { + j.array = true; + } + } + info.default = m.get("default").map(text); + info.fixed = m.get("const").map(text); + } + } + info.r#type = self.type_ref(d, &tptr, el, UseKind::Type); + self.components[el as usize].element = Some(info); + Particle::Element { + el: Ref::Id(el), + occurs, + } + } + + fn complex(&mut self, id: Id, d: usize, ptr: &str, v: &'a Value) { + let mut ct = ComplexInfo::default(); + let mut kind = "object"; + if let Value::Object(m) = v { + kind = shape(m); + let mut parts = Parts::default(); + self.gather(id, d, ptr, m, &mut parts, 0); + if let Some(base) = parts.base.take() { + ct.derivation = Some(Derivation { + method: Method::Extension, + base, + simple: false, + }); + } + for t in &parts.mixins { + self.mixins.push((id, *t)); + } + let mut items: Vec = Vec::new(); + for (name, pp) in std::mem::take(&mut parts.props) { + let req = parts.required.contains(&name); + let occ = Occurs { + min: req as u32, + max: Some(1), + }; + items.push(self.property(id, d, &pp, &name, "property", occ)); + } + let many = Occurs { min: 0, max: None }; + for (name, pp) in std::mem::take(&mut parts.pattern) { + items.push(self.property(id, d, &pp, &name, "patternProperty", many)); + } + if let Some(pp) = parts.additional.take() { + items.push(self.property(id, d, &pp, "*", "additionalProperties", many)); + } + if parts.open == Some(true) { + items.push(Particle::Any { + wildcard: Wildcard::default(), + occurs: many, + }); + } + let f = |k: &str, v: &str| Facet { + kind: k.into(), + value: v.into(), + fixed: false, + }; + if parts.open == Some(false) { + parts.facets.push(f("additionalProperties", "false")); + } + if parts.closed_unevaluated { + parts.facets.push(f("unevaluatedProperties", "false")); + } + let own: HashSet = items + .iter() + .filter_map(|p| match p { + Particle::Element { el: Ref::Id(e), .. } => { + Some(self.components[*e as usize].name.clone()) + } + _ => None, + }) + .collect(); + for r in &parts.required { + if !own.contains(r) { + parts.facets.push(f("required", r)); + } + } + // Arrays: tuple items, then the item schema. + if is_array_schema(m) { + let tuple = if self.docs[d].draft >= Draft::D2020 || m.contains_key("prefixItems") { + "prefixItems" + } else { + "items" + }; + if let Some(Value::Array(a)) = m.get(tuple) { + for i in 0..a.len() { + let pp = child(&child(ptr, tuple), &i.to_string()); + items.push(self.property( + id, + d, + &pp, + &format!("[{i}]"), + "items", + Occurs::default(), + )); + } + } + let rest = if tuple == "items" && m.get("items").is_some_and(Value::is_array) { + "additionalItems" + } else { + "items" + }; + if m.get(rest) + .is_some_and(|x| x.is_object() || x == &Value::Bool(true)) + { + let n = |k: &str| { + m.get(k) + .and_then(Value::as_u64) + .map(|x| x.min(u32::MAX as u64) as u32) + }; + let occ = Occurs { + min: n("minItems").unwrap_or(0), + max: n("maxItems"), + }; + let pp = child(ptr, rest); + items.push(self.property(id, d, &pp, "items", "items", occ)); + } + } + for (any, cp) in std::mem::take(&mut parts.choices) { + let n = self + .value(d, &cp) + .and_then(Value::as_array) + .map_or(0, Vec::len); + let mut alts = Vec::new(); + for i in 0..n { + let ap = child(&cp, &i.to_string()); + let name = self.option_name(d, &ap, i); + alts.push(self.property(id, d, &ap, &name, "option", Occurs::default())); + } + items.push(Particle::Choice { + items: alts, + occurs: Occurs { + min: 1, + max: if any { None } else { Some(1) }, + }, + }); + } + ct.facets = parts.facets; + ct.enumerations = parts.enums; + ct.content = match items.len() { + 0 => None, + 1 if matches!(items[0], Particle::Choice { .. }) => items.pop(), + _ => Some(Particle::All { + items, + occurs: Occurs::default(), + }), + }; + } + let c = &mut self.components[id as usize]; + if let Some(j) = c.json.as_mut() { + j.kind = kind.to_string(); + } + c.complex_type = Some(ct); + } + + /// The name of a `oneOf`/`anyOf` alternative: the type it refers to, + /// its title, its JSON type or its position. + fn option_name(&mut self, d: usize, ptr: &str, i: usize) -> String { + let Some(Value::Object(m)) = self.value(d, ptr) else { + return format!("option{}", i + 1); + }; + if let Some(Value::String(r)) = refkw(m) + && let Some((td, tp)) = self.reg.resolve(self.docs, d, ptr, r) + { + return if tp.is_empty() { + stem(&self.docs[td].name) + } else if let Some(&g) = self.globals.get(&(td, tp.clone())) { + self.components[g as usize].name.clone() + } else { + name_hint(&tp).unwrap_or_else(|| stem(&self.docs[td].name)) + }; + } + if let Some(t) = m.get("title").and_then(Value::as_str) { + return t.to_string(); + } + if let Some(t) = types(m).first() { + return t.clone(); + } + format!("option{}", i + 1) + } +} + +/// Named definitions (`$defs`, `definitions`) anywhere in a file. +fn defs(v: &Value, ptr: &str, parent: &str, out: &mut Vec<(String, String)>) { + match v { + Value::Object(m) => { + if !matches!(parent, "properties" | "patternProperties") { + for k in ["$defs", "definitions"] { + if let Some(Value::Object(d)) = m.get(k) { + let dp = child(ptr, k); + for name in d.keys() { + let p = child(&dp, name); + if !out.iter().any(|(x, _)| *x == p) { + out.push((p, name.clone())); + } + } + } + } + } + for (k, c) in m { + if !DATA.contains(&k.as_str()) && (c.is_object() || c.is_array()) { + defs(c, &child(ptr, k), k, out); + } + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + defs(c, &child(ptr, &i.to_string()), "", out); + } + } + _ => {} + } +} + +/// The schema at `pointer` in a JSON or YAML file, as indented JSON (for +/// the source view of the viewer). +pub fn source(name: &str, bytes: &[u8], pointer: &str) -> Option { + let v = parse(name, bytes).ok()?; + serde_json::to_string_pretty(v.pointer(pointer)?).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uris() { + assert_eq!( + resolve_uri("https://x.org/a/b.json", "c.json"), + "https://x.org/a/c.json" + ); + assert_eq!( + resolve_uri("https://x.org/a/b.json", "../c.json"), + "https://x.org/c.json" + ); + assert_eq!( + resolve_uri("https://x.org/a/b.json", "/d/e.json"), + "https://x.org/d/e.json" + ); + assert_eq!(resolve_uri("dir/a.json", "b.yaml"), "dir/b.yaml"); + assert_eq!(resolve_uri("dir/a.json", "urn:x:y"), "urn:x:y"); + assert_eq!(percent_decode("/a%20b"), "/a b"); + assert_eq!( + name_hint("/components/responses/Err/content/application~1json/schema").as_deref(), + Some("Err") + ); + assert_eq!(stem("x/address.schema.json"), "address"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c7544d2..e56cb55 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,5 +1,6 @@ -//! Core of schema-explorer: reads XML Schema (XSD) sets, and later JSON Schema, -//! into one indexed model for the viewer. +//! Core of schema-explorer: reads XML Schema (XSD) sets and JSON Schema +//! (draft-04 to 2020-12, also inside OpenAPI 3.x and AsyncAPI 2/3 +//! documents, in JSON or YAML) into one indexed model for the viewer. //! //! The input is a set of in-memory files (name and bytes), so the same code //! runs in the command line tool and, compiled to WebAssembly, in the @@ -12,6 +13,7 @@ pub mod bundle; pub mod diff; pub mod index; pub mod input; +pub mod json; pub mod report; mod xsd; @@ -33,8 +35,139 @@ pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") } -/// Read an XSD set into a bundle. +/// True when the files are a JSON Schema set (JSON or YAML files, none +/// of them XML). +pub fn is_json_schema_set(sources: &[Source]) -> bool { + !sources.is_empty() + && sources.iter().all(|s| { + json::is_json_name(&s.name) && { + let start = s.bytes.iter().find(|b| !b.is_ascii_whitespace()); + start != Some(&b'<') && !s.bytes.starts_with(&[0xEF, 0xBB, 0xBF, b'<']) + } + }) +} + +fn generator(opts: &Options) -> String { + if opts.generator.is_empty() { + format!("schema-explorer-core {}", version()) + } else { + opts.generator.clone() + } +} + +/// Read a JSON Schema set (or OpenAPI / AsyncAPI documents) into a bundle. +pub fn index_json( + sources: Vec, + resolver: Option<&mut Resolver<'_>>, + opts: &Options, +) -> Bundle { + let mut diagnostics = Vec::new(); + let docs = json::load(sources, resolver, &mut diagnostics); + let mut b = json::Builder::new(&docs); + b.run(); + let file_infos = docs + .iter() + .enumerate() + .map(|(i, d)| bundle::FileInfo { + name: d.display.clone(), + target_namespace: b.file_ns[i].map(|n| b.namespaces[n as usize].uri.clone()), + namespace: b.file_ns[i], + prefixes: Default::default(), + version: d + .value + .as_ref() + .and_then(|v| v.pointer("/info/version")) + .and_then(|v| v.as_str()) + .map(str::to_string), + dialect: (!d.label.is_empty()).then(|| d.label.clone()), + element_form_qualified: false, + attribute_form_qualified: false, + input: d.input, + directives: d + .refs + .iter() + .map(|(loc, file)| bundle::Directive { + kind: bundle::DirectiveKind::Import, + namespace: None, + location: Some(loc.clone()), + file: file.map(|j| j as bundle::FileId), + via: None, + line: 0, + }) + .collect(), + docs: d + .value + .as_ref() + .and_then(|v| { + v.get("description") + .or_else(|| v.pointer("/info/description")) + }) + .and_then(|v| v.as_str()) + .map(|t| { + vec![bundle::Doc { + lang: None, + source: None, + text: t.to_string(), + }] + }) + .unwrap_or_default(), + components: b.file_globals[i], + }) + .collect(); + diagnostics.append(&mut b.diagnostics); + let mut components = std::mem::take(&mut b.components); + index::links(&mut components, std::mem::take(&mut b.usages)); + index::effective(&mut components, &mut diagnostics); + finish( + bundle::Language::JsonSchema, + file_infos, + std::mem::take(&mut b.namespaces), + components, + diagnostics, + b.unresolved_refs, + opts, + ) +} + +fn finish( + language: bundle::Language, + files: Vec, + namespaces: Vec, + components: Vec, + diagnostics: Vec, + unresolved_refs: u32, + opts: &Options, +) -> Bundle { + let search = if opts.compact { + Default::default() + } else { + index::search(&components) + }; + let mut bundle = Bundle { + format: FORMAT, + language, + generator: generator(opts), + compact: false, + files, + namespaces, + components, + diagnostics, + stats: Default::default(), + search, + }; + bundle.stats = index::stats(&bundle, unresolved_refs); + if opts.compact { + bundle.make_compact(); + } + bundle +} + +/// Read a schema set into a bundle: XSD, or JSON Schema when every file +/// is JSON or YAML (see [`is_json_schema_set`]). pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &Options) -> Bundle { + if is_json_schema_set(&sources) { + return index_json(sources, resolver, opts); + } let mut diagnostics = Vec::new(); let files = input::load(sources, resolver, &mut diagnostics); let docs: Vec> = files @@ -64,6 +197,7 @@ pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &O version: root_el .and_then(|r| r.attribute("version")) .map(str::to_string), + dialect: None, element_form_qualified: root_el .is_some_and(|r| r.attribute("elementFormDefault") == Some("qualified")), attribute_form_qualified: root_el @@ -90,31 +224,15 @@ pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &O let mut components = std::mem::take(&mut b.components); index::links(&mut components, std::mem::take(&mut b.usages)); index::effective(&mut components, &mut diagnostics); - let search = if opts.compact { - Default::default() - } else { - index::search(&components) - }; - let mut bundle = Bundle { - format: FORMAT, - generator: if opts.generator.is_empty() { - format!("schema-explorer-core {}", version()) - } else { - opts.generator.clone() - }, - compact: false, - files: file_infos, - namespaces: std::mem::take(&mut b.namespaces), + finish( + bundle::Language::Xsd, + file_infos, + std::mem::take(&mut b.namespaces), components, diagnostics, - stats: Default::default(), - search, - }; - bundle.stats = index::stats(&bundle, b.unresolved_refs); - if opts.compact { - bundle.make_compact(); - } - bundle + b.unresolved_refs, + opts, + ) } /// Restore what [`Bundle::make_compact`] stripped: the effective content diff --git a/crates/core/src/report.rs b/crates/core/src/report.rs index 08e1259..708859d 100644 --- a/crates/core/src/report.rs +++ b/crates/core/src/report.rs @@ -4,9 +4,9 @@ use std::collections::BTreeMap; use std::fmt::Write; -use crate::bundle::Kind; +use crate::bundle::{Kind, Language}; use crate::diff::{ - ChangeKind, ComponentDiff, Counts, Impact, SchemaDiff, Status, change_label, kind_name, + ChangeKind, ComponentDiff, Counts, Impact, SchemaDiff, Status, change_label, kind_name_in, }; /// Inline text: `code` and **bold** spans are rendered by each backend. @@ -39,7 +39,15 @@ pub fn marker(k: ChangeKind) -> &'static str { } } -fn plural(k: Kind) -> &'static str { +fn plural(lang: Language, k: Kind) -> &'static str { + if lang == Language::JsonSchema { + match k { + Kind::ComplexType => return "Objects", + Kind::SimpleType => return "Scalars", + Kind::Element => return "Properties", + _ => {} + } + } match k { Kind::ComplexType => "Complex types", Kind::SimpleType => "Simple types", @@ -96,8 +104,8 @@ fn change_line(c: &crate::diff::Change) -> String { s } -fn heading(d: &ComponentDiff) -> String { - let mut s = format!("{} {}", kind_name(d.kind), code(d.qname())); +fn heading(lang: Language, d: &ComponentDiff) -> String { + let mut s = format!("{} {}", kind_name_in(lang, d.kind), code(d.qname())); if let Some(o) = &d.old && d.new.is_some() && o.qname != d.qname() @@ -168,7 +176,7 @@ fn blocks(d: &SchemaDiff) -> Vec { .summary .by_kind .iter() - .map(|(k, c)| counts_row(plural(*k).into(), c)) + .map(|(k, c)| counts_row(plural(d.language, *k).into(), c)) .collect(); rows.push(counts_row("**Total**".into(), t)); out.push(Block::Table(head(), rows)); @@ -237,14 +245,14 @@ fn blocks(d: &SchemaDiff) -> Vec { for (k, list) in &removed { out.push(Block::P(format!( "**{}** ({}): {}", - plural(*k), + plural(d.language, *k), list.len(), list.join(", ") ))); } } for c in &breaking { - out.push(Block::H(3, heading(c))); + out.push(Block::H(3, heading(d.language, c))); out.push(Block::List( c.changes .iter() @@ -268,7 +276,7 @@ fn blocks(d: &SchemaDiff) -> Vec { for (k, list) in &added { out.push(Block::P(format!( "**{}** ({}): {}", - plural(*k), + plural(d.language, *k), list.len(), list.join(", ") ))); @@ -289,7 +297,7 @@ fn blocks(d: &SchemaDiff) -> Vec { .iter() .map(|r| { vec![ - kind_name(r.kind).into(), + kind_name_in(d.language, r.kind).into(), code(&r.old.qname), code(&r.new.qname), format!("{}%", r.score), @@ -305,7 +313,7 @@ fn blocks(d: &SchemaDiff) -> Vec { .collect(); out.push(Block::H(2, format!("Other changes ({})", other.len()))); for c in &other { - out.push(Block::H(3, heading(c))); + out.push(Block::H(3, heading(d.language, c))); out.push(Block::List( c.changes .iter() diff --git a/crates/core/tests/data/json/asyncapi.yaml b/crates/core/tests/data/json/asyncapi.yaml new file mode 100644 index 0000000..576fd17 --- /dev/null +++ b/crates/core/tests/data/json/asyncapi.yaml @@ -0,0 +1,21 @@ +asyncapi: 2.6.0 +info: + title: Train events + version: 1.0.0 +channels: + train/position: + subscribe: + message: + $ref: '#/components/messages/TrainPosition' +components: + messages: + TrainPosition: + payload: + type: object + properties: + train: { $ref: '#/components/schemas/TrainId' } + lat: { type: number, minimum: -90, maximum: 90 } + schemas: + TrainId: + type: string + pattern: '^[0-9]{1,6}$' diff --git a/crates/core/tests/data/json/legacy-draft04.json b/crates/core/tests/data/json/legacy-draft04.json new file mode 100644 index 0000000..6118aa8 --- /dev/null +++ b/crates/core/tests/data/json/legacy-draft04.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://example.com/legacy.json", + "definitions": { + "Price": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "Product": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "price": { "$ref": "#/definitions/Price" }, + "kind": { "enum": ["book", "music"] }, + "dimensions": { + "type": "object", + "properties": { "width": { "type": "number" } } + } + }, + "required": ["name", "price"] + }, + "Book": { + "allOf": [{ "$ref": "#/definitions/Product" }, { "properties": { "isbn": { "type": "string" } } }] + } + } +} diff --git a/crates/core/tests/data/json/openapi.yaml b/crates/core/tests/data/json/openapi.yaml new file mode 100644 index 0000000..394381b --- /dev/null +++ b/crates/core/tests/data/json/openapi.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.3 +info: + title: Pets + version: 1.0.0 + description: A small pet store. +paths: + /pets: + get: + responses: + 200: + description: The pets. + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/Pet' } +components: + schemas: + Pet: + type: object + required: [id, name] + properties: + id: { type: integer, format: int64, readOnly: true } + name: { type: string, nullable: true, example: Rex } + tag: { type: string } + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + bark: { type: boolean } + Pets: + type: array + items: { $ref: '#/components/schemas/Pet' } + maxItems: 100 diff --git a/crates/core/tests/data/json/v1/customer.yaml b/crates/core/tests/data/json/v1/customer.yaml new file mode 100644 index 0000000..2f42b9d --- /dev/null +++ b/crates/core/tests/data/json/v1/customer.yaml @@ -0,0 +1,23 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/customer.yaml +title: Customer +type: object +properties: + name: + type: string + maxLength: 80 + address: + $ref: '#/$defs/Address' + kind: + oneOf: + - const: person + description: A natural person. + - const: company + description: A legal entity. +required: [name] +$defs: + Address: + type: object + properties: + street: { type: string } + country: { type: string, pattern: '^[A-Z]{2}$' } diff --git a/crates/core/tests/data/json/v1/order.schema.json b/crates/core/tests/data/json/v1/order.schema.json new file mode 100644 index 0000000..2abd1b0 --- /dev/null +++ b/crates/core/tests/data/json/v1/order.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/order.schema.json", + "title": "Order", + "description": "A customer order.", + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid", "description": "Order identifier." }, + "customer": { "$ref": "customer.yaml" }, + "lines": { + "type": "array", + "items": { "$ref": "#/$defs/LineItem" }, + "minItems": 1, + "maxItems": 50 + }, + "status": { "$ref": "#/$defs/Status" }, + "payment": { + "oneOf": [{ "$ref": "#/$defs/CardPayment" }, { "$ref": "#/$defs/BankPayment" }] + }, + "note": { "type": "string", "deprecated": true }, + "tags": { "$ref": "#tag" }, + "extra": { "$ref": "#/$defs/Missing" } + }, + "required": ["id", "customer", "lines"], + "additionalProperties": false, + "$defs": { + "LineItem": { + "type": "object", + "properties": { + "sku": { "type": "string", "pattern": "^[A-Z]{3}-[0-9]+$" }, + "quantity": { "type": "integer", "minimum": 1 } + }, + "required": ["sku", "quantity"] + }, + "Status": { + "description": "Where the order is.", + "type": "string", + "enum": ["open", "paid", "cancelled"] + }, + "Payment": { + "type": "object", + "properties": { "amount": { "type": "number", "exclusiveMinimum": 0 } }, + "required": ["amount"] + }, + "CardPayment": { + "allOf": [ + { "$ref": "#/$defs/Payment" }, + { "properties": { "cardNumber": { "type": "string", "minLength": 12 } }, "required": ["cardNumber"] } + ] + }, + "BankPayment": { + "allOf": [ + { "$ref": "#/$defs/Payment" }, + { "properties": { "iban": { "type": "string" } } } + ] + }, + "Tags": { + "$anchor": "tag", + "type": "object", + "patternProperties": { "^x-": { "type": "string" } }, + "additionalProperties": { "type": "integer" } + } + } +} diff --git a/crates/core/tests/data/json/v2/customer.yaml b/crates/core/tests/data/json/v2/customer.yaml new file mode 100644 index 0000000..2f42b9d --- /dev/null +++ b/crates/core/tests/data/json/v2/customer.yaml @@ -0,0 +1,23 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/customer.yaml +title: Customer +type: object +properties: + name: + type: string + maxLength: 80 + address: + $ref: '#/$defs/Address' + kind: + oneOf: + - const: person + description: A natural person. + - const: company + description: A legal entity. +required: [name] +$defs: + Address: + type: object + properties: + street: { type: string } + country: { type: string, pattern: '^[A-Z]{2}$' } diff --git a/crates/core/tests/data/json/v2/order.schema.json b/crates/core/tests/data/json/v2/order.schema.json new file mode 100644 index 0000000..bf1d6e1 --- /dev/null +++ b/crates/core/tests/data/json/v2/order.schema.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/order.schema.json", + "title": "Order", + "description": "A customer order.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Order identifier." + }, + "customer": { + "$ref": "customer.yaml" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/$defs/LineItem" + }, + "minItems": 1, + "maxItems": 50 + }, + "status": { + "$ref": "#/$defs/Status" + }, + "payment": { + "oneOf": [ + { + "$ref": "#/$defs/CardPayment" + }, + { + "$ref": "#/$defs/BankPayment" + } + ] + }, + "note": { + "type": "string", + "deprecated": true + }, + "tags": { + "$ref": "#tag" + }, + "extra": { + "$ref": "#/$defs/Missing" + }, + "currency": { + "type": "string", + "default": "EUR" + } + }, + "required": [ + "id", + "customer", + "lines", + "status" + ], + "additionalProperties": false, + "$defs": { + "LineItem": { + "type": "object", + "properties": { + "sku": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]+$" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "sku", + "quantity" + ] + }, + "Status": { + "description": "Where the order is.", + "type": "string", + "enum": [ + "open", + "paid", + "refunded" + ] + }, + "Payment": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "amount" + ] + }, + "CardPayment": { + "allOf": [ + { + "$ref": "#/$defs/Payment" + }, + { + "properties": { + "cardNumber": { + "type": "string", + "minLength": 12 + } + }, + "required": [ + "cardNumber" + ] + } + ] + }, + "BankPayment": { + "allOf": [ + { + "$ref": "#/$defs/Payment" + }, + { + "properties": { + "iban": { + "type": "string" + } + } + } + ] + }, + "Tags": { + "$anchor": "tag", + "type": "object", + "patternProperties": { + "^x-": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + } +} \ No newline at end of file diff --git a/crates/core/tests/fixtures.rs b/crates/core/tests/fixtures.rs index c8c864f..884a9f0 100644 --- a/crates/core/tests/fixtures.rs +++ b/crates/core/tests/fixtures.rs @@ -5,7 +5,8 @@ //! repository root) and are skipped when a schema set is absent. Fetch the //! public ones with `scripts/fetch-fixtures.sh`; the railML and telematics //! sets are expected under `railml//{schema,codelists}` and -//! `telematics/{netex,taf,taf-v3.5.1,b5,b10}`. +//! `telematics/{netex,taf,taf-v3.5.1,b5,b10}`; OSDM (OpenAPI with JSON +//! Schema) under `OSDM`. //! //! Run with `--nocapture` (in release mode for meaningful timings) to see //! the counts and indexing times. @@ -366,3 +367,32 @@ fn diff_taf() { let hb = find(&d, Kind::Element, "HandBrake").expect("HandBrake"); assert!(hb.breaking()); } + +/// UIC OSDM: an OpenAPI 3.0 document whose schemas live in YAML files it +/// refers to (fetched by scripts/fetch-fixtures.sh). +#[test] +fn osdm_openapi() { + let Some(r) = run( + "OSDM (OpenAPI)", + &["OSDM/specification/OSDM-online-api.yml"], + ) else { + return; + }; + let b = &r.bundle; + assert_eq!( + b.language, + schema_explorer_core::bundle::Language::JsonSchema + ); + no_unresolved_imports(b); + assert_eq!(b.stats.unresolved_refs, 0); + at_least(b, 300, 80, 0); + let offer = b.find(Kind::ComplexType, "Offer").expect("Offer"); + assert!( + b.components[offer as usize] + .complex_type + .as_ref() + .unwrap() + .content + .is_some() + ); +} diff --git a/crates/core/tests/json.rs b/crates/core/tests/json.rs new file mode 100644 index 0000000..f0e1ca6 --- /dev/null +++ b/crates/core/tests/json.rs @@ -0,0 +1,347 @@ +//! Tests of the JSON Schema reader on the hand-written sets in +//! tests/data/json: cross-file `$ref` (JSON to YAML), `$anchor`, `allOf` +//! inheritance, `oneOf` choices, enumerations, drafts 04 and 2020-12, +//! OpenAPI and AsyncAPI documents, and a comparison of two versions. + +use schema_explorer_core::bundle::{Bundle, Kind, Language, Particle, Ref, Variety}; +use schema_explorer_core::diff::*; +use schema_explorer_core::{Options, Source, index}; +use std::path::Path; + +fn load(files: &[&str]) -> Bundle { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/json"); + index( + files + .iter() + .map(|f| Source::new(*f, std::fs::read(dir.join(f)).unwrap())) + .collect(), + None, + &Options::default(), + ) +} + +fn order(version: &str) -> Bundle { + load(&[ + &format!("{version}/order.schema.json"), + &format!("{version}/customer.yaml"), + ]) +} + +/// The names and cardinalities (`name min..max`) of the elements of a content model. +fn children(b: &Bundle, p: &Particle) -> Vec { + let mut out = Vec::new(); + p.walk(&mut |p| { + if let Particle::Element { + el: Ref::Id(e), + occurs, + } = p + { + out.push(format!( + "{} {}..{}", + b.components[*e as usize].name, + occurs.min, + occurs.max.map_or("*".to_string(), |m| m.to_string()) + )); + } + }); + out +} + +fn type_name(b: &Bundle, el: u32) -> String { + match b.components[el as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + { + Ref::Id(t) => b.qname(*t), + Ref::Missing(m) => format!("missing {m}"), + } +} + +fn child(b: &Bundle, ty: u32, name: &str) -> u32 { + let mut found = None; + b.components[ty as usize] + .complex_type + .as_ref() + .unwrap() + .content + .as_ref() + .unwrap() + .walk(&mut |p| { + if let Particle::Element { el: Ref::Id(e), .. } = p + && b.components[*e as usize].name == name + { + found = Some(*e); + } + }); + found.unwrap_or_else(|| panic!("no {name}")) +} + +#[test] +fn draft_2020_set() { + let b = order("v1"); + assert_eq!(b.language, Language::JsonSchema); + assert_eq!(b.files[0].dialect.as_deref(), Some("JSON Schema 2020-12")); + // Root schemas and $defs are global types; no namespaces when names are unique. + assert!(b.namespaces.is_empty()); + let o = b.find(Kind::ComplexType, "order").expect("root type"); + let ct = b.components[o as usize].complex_type.as_ref().unwrap(); + assert_eq!( + children(&b, ct.content.as_ref().unwrap()), + [ + "id 1..1", + "customer 1..1", + "lines 1..50", + "status 0..1", + "payment 0..1", + "note 0..1", + "tags 0..1", + "extra 0..1" + ] + ); + assert!( + ct.facets + .iter() + .any(|f| f.kind == "additionalProperties" && f.value == "false") + ); + assert_eq!(b.components[o as usize].docs[0].text, "A customer order."); + // Cross-file $ref into YAML, array items, $anchor. + assert_eq!(type_name(&b, child(&b, o, "customer")), "customer"); + let lines = child(&b, o, "lines"); + assert_eq!(type_name(&b, lines), "LineItem"); + assert!(b.components[lines as usize].json.as_ref().unwrap().array); + assert_eq!(type_name(&b, child(&b, o, "tags")), "Tags"); + assert!( + b.components[child(&b, o, "note") as usize] + .json + .as_ref() + .unwrap() + .deprecated + ); + // Unresolved $ref: a diagnostic and a missing reference, no panic. + assert_eq!( + type_name(&b, child(&b, o, "extra")), + "missing #/$defs/Missing" + ); + assert_eq!(b.stats.unresolved_refs, 1); + assert!(b.diagnostics.iter().any(|d| d.code == "unresolved-ref")); + // allOf with a $ref: extension of the base. + let card = b.find(Kind::ComplexType, "CardPayment").unwrap(); + let ct = b.components[card as usize].complex_type.as_ref().unwrap(); + let base = ct.derivation.as_ref().unwrap().base.id().unwrap(); + assert_eq!(b.qname(base), "Payment"); + assert_eq!( + children(&b, ct.content.as_ref().unwrap()), + ["cardNumber 1..1"] + ); + assert!( + b.components[base as usize] + .complex_type + .as_ref() + .unwrap() + .derived + .contains(&card) + ); + // oneOf: a choice. + let pay = child(&b, o, "payment"); + let anon = b.components[pay as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + .id() + .unwrap(); + assert!(matches!( + b.components[anon as usize] + .complex_type + .as_ref() + .unwrap() + .content, + Some(Particle::Choice { .. }) + )); + // Enumerations and facets. + let st = b.find(Kind::SimpleType, "Status").unwrap(); + let si = b.components[st as usize].simple_type.as_ref().unwrap(); + let vals: Vec<&str> = si.enumerations.iter().map(|e| e.value.as_str()).collect(); + assert_eq!(vals, ["open", "paid", "cancelled"]); + assert_eq!(b.qname(si.base.as_ref().unwrap().id().unwrap()), "string"); + // oneOf of consts: enumerations with documentation. + let cust = b.find(Kind::ComplexType, "customer").unwrap(); + let kind = child(&b, cust, "kind"); + let kt = b.components[kind as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + .id() + .unwrap(); + let e = &b.components[kt as usize] + .simple_type + .as_ref() + .unwrap() + .enumerations; + assert_eq!(e[1].value, "company"); + assert_eq!(e[1].docs[0].text, "A legal entity."); + // patternProperties and additionalProperties. + let tags = b.find(Kind::ComplexType, "Tags").unwrap(); + let ct = b.components[tags as usize].complex_type.as_ref().unwrap(); + assert_eq!( + children(&b, ct.content.as_ref().unwrap()), + ["^x- 0..*", "* 0..*"] + ); + // Where used. + let li = b.find(Kind::ComplexType, "LineItem").unwrap(); + assert!( + b.components[li as usize] + .used_by + .iter() + .any(|u| u.by == lines) + ); + // Search. + assert!( + b.search + .get("lineitem") + .is_some_and(|ids| ids.contains(&li)) + ); +} + +#[test] +fn draft_04() { + let b = load(&["legacy-draft04.json"]); + assert_eq!(b.files[0].dialect.as_deref(), Some("JSON Schema draft-04")); + let price = b.find(Kind::SimpleType, "Price").unwrap(); + let f = &b.components[price as usize] + .simple_type + .as_ref() + .unwrap() + .facets; + assert!( + f.iter() + .any(|f| f.kind == "exclusiveMinimum" && f.value == "0") + ); + assert!(!f.iter().any(|f| f.kind == "minimum")); + let book = b.find(Kind::ComplexType, "Book").unwrap(); + let ct = b.components[book as usize].complex_type.as_ref().unwrap(); + assert_eq!( + b.qname(ct.derivation.as_ref().unwrap().base.id().unwrap()), + "Product" + ); + let product = b.find(Kind::ComplexType, "Product").unwrap(); + assert_eq!(type_name(&b, child(&b, product, "price")), "Price"); + assert!(b.diagnostics.is_empty(), "{:?}", b.diagnostics); +} + +#[test] +fn openapi_and_asyncapi() { + let b = load(&["openapi.yaml"]); + assert_eq!(b.files[0].dialect.as_deref(), Some("OpenAPI 3.0.3")); + assert_eq!(b.files[0].version.as_deref(), Some("1.0.0")); + let pet = b.find(Kind::ComplexType, "Pet").unwrap(); + let name = child(&b, pet, "name"); + let nt = b.components[name as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + .id() + .unwrap(); + assert!( + b.components[nt as usize] + .simple_type + .as_ref() + .unwrap() + .facets + .iter() + .any(|f| f.kind == "nullable") + ); + let dog = b.find(Kind::ComplexType, "Dog").unwrap(); + assert_eq!( + b.qname( + b.components[dog as usize] + .complex_type + .as_ref() + .unwrap() + .derivation + .as_ref() + .unwrap() + .base + .id() + .unwrap() + ), + "Pet" + ); + let pets = b.find(Kind::ComplexType, "Pets").unwrap(); + let ct = b.components[pets as usize].complex_type.as_ref().unwrap(); + assert_eq!(children(&b, ct.content.as_ref().unwrap()), ["items 0..100"]); + + let b = load(&["asyncapi.yaml"]); + assert_eq!(b.files[0].dialect.as_deref(), Some("AsyncAPI 2.6.0")); + let msg = b + .find(Kind::ComplexType, "TrainPosition") + .expect("message payload"); + assert_eq!(type_name(&b, child(&b, msg, "train")), "TrainId"); + let id = b.find(Kind::SimpleType, "TrainId").unwrap(); + assert_eq!( + b.components[id as usize] + .simple_type + .as_ref() + .unwrap() + .variety, + Variety::Atomic + ); +} + +#[test] +fn files_named_alike_get_namespaces() { + let b = load(&[ + "v1/order.schema.json", + "v1/customer.yaml", + "v2/order.schema.json", + ]); + assert_eq!(b.namespaces.len(), 3); + assert!(b.find(Kind::ComplexType, "order:order").is_some()); +} + +fn has(c: &ComponentDiff, line: &str) { + let all: Vec = c + .changes + .iter() + .map(|x| format!("{:?} {} {:?}", x.kind, x.subject, x.impact)) + .collect(); + assert!(all.iter().any(|l| l == line), "{line} not in {all:#?}"); +} + +fn comp<'a>(d: &'a SchemaDiff, kind: Kind, name: &str) -> &'a ComponentDiff { + d.components + .iter() + .find(|c| c.kind == kind && c.name == name) + .unwrap_or_else(|| panic!("{kind:?} {name} not in the diff")) +} + +#[test] +fn diff_versions() { + let d = diff(&order("v1"), &order("v2"), &DiffOptions::default()); + let o = comp(&d, Kind::ComplexType, "order"); + // A property that becomes required is breaking; a new optional one is not. + has(o, "ChildCardinalityChanged status Breaking"); + has(o, "ChildAdded currency NonBreaking"); + let st = comp(&d, Kind::SimpleType, "Status"); + has(st, "EnumerationRemoved cancelled Breaking"); + has(st, "EnumerationAdded refunded NonBreaking"); + assert!(d.summary.total.breaking > 0); + // Property order does not matter in JSON. + assert!( + !o.changes + .iter() + .any(|c| c.kind == ChangeKind::ChildrenReordered) + ); +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 4b850f3..bdba242 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -3,7 +3,8 @@ use schema_explorer_core::{Options, Source}; use wasm_bindgen::prelude::*; -/// Index an XSD set and return the bundle as JSON. +/// Index a schema set (XSD, or JSON Schema / OpenAPI / AsyncAPI in JSON +/// or YAML) and return the bundle as JSON. /// /// `names` and `contents` are parallel arrays: file paths (relative paths /// let imports and includes resolve; bare file names resolve by name) and @@ -76,6 +77,14 @@ pub fn diff_report(diff: &str, format: &str) -> Result { } } +/// The schema at a JSON pointer of a JSON or YAML file, as indented JSON +/// (the source view of a JSON Schema component). +#[wasm_bindgen] +pub fn json_source(name: &str, text: &str, pointer: &str) -> Result { + schema_explorer_core::json::source(name, text.as_bytes(), pointer) + .ok_or_else(|| err(format!("{pointer} not found in {name}"))) +} + /// The version of the core library. #[wasm_bindgen] pub fn version() -> String { diff --git a/publiccode.yml b/publiccode.yml index bffccf8..4d77d26 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -25,15 +25,16 @@ intendedAudience: - transportation description: en: - shortDescription: Shows XML Schema (XSD) sets as UML-like diagrams, content-model trees and readable + shortDescription: Shows XML Schema (XSD) and JSON Schema sets as UML-like diagrams, content-model trees and readable pages, in the browser. longDescription: | Large XML Schema sets, such as the ones used for data exchange in public transport and railways, are hard to read for most people. schema-explorer reads a set of XSD files, follows includes, imports and references across files and namespaces, and shows the result as UML-like diagrams, content-model trees and one readable page per type and element. - It runs entirely in the browser, can be published on GitHub Pages, embeds in other web applications as a web component, and comes with a command-line tool and Docker image. Support for JSON Schema and for comparing schema versions is planned. + It runs entirely in the browser, can be published on GitHub Pages, embeds in other web applications as a web component, and comes with a command-line tool and Docker image. It reads XML Schema and JSON Schema (also the schemas of OpenAPI and AsyncAPI documents) and compares schema versions. documentation: https://github.com/Matdata-eu/schema-explorer#readme features: - - UML-like diagrams of XML Schema types and elements + - UML-like diagrams of XML Schema and JSON Schema (OpenAPI, AsyncAPI) types + - JSON Schema draft-04 to 2020-12, OpenAPI 3.x and AsyncAPI 2/3 input - A page per type and element with its content model - Search and cross-file reference following - Version comparison diff --git a/scripts/build-examples.sh b/scripts/build-examples.sh index d0614bb..4ff8c46 100755 --- a/scripts/build-examples.sh +++ b/scripts/build-examples.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Builds the examples catalog of the Pages site into /examples/: -# a pre-indexed bundle per schema set, its .xsd files (for the source view) -# and examples.json, which the open page lists. +# a pre-indexed bundle per schema set, its schema files (for the source +# view) and examples.json, which the open page lists. # # Only freely redistributable schema sets go here, each fetched from its # publisher at build time (pinned URLs, cached in $WORK/downloads): @@ -10,6 +10,8 @@ # source acknowledged) # - OASIS UBL 2.4 (OASIS IPR Policy) # - W3C XML Schema for schemas (W3C document licence) +# - UIC OSDM, OpenAPI with JSON schemas (Apache-2.0), from its public repository +# - JSON Schema 2020-12 meta-schemas (BSD-3-Clause) # Never add railML (CC BY-NC-ND) or other schemas whose licence forbids # redistribution. An example whose download fails is left out with a warning. # @@ -29,11 +31,11 @@ fetch() { curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors -o "$DL/$2.part" "$1" && mv "$DL/$2.part" "$DL/$2" } -# entry <description> <version> <publisher> <licence> <link> [focus] +# entry <id> <title> <description> <version> <publisher> <licence> <link> [focus] [sources dir] entry() { local focus="" [ -n "${8:-}" ] && focus=$(printf ',\n "focus": "%s"' "$8") - ENTRIES+=("$(printf ' {\n "id": "%s",\n "title": "%s",\n "description": "%s",\n "version": "%s",\n "publisher": "%s",\n "licence": "%s",\n "link": "%s",\n "src": "%s/bundle.json",\n "sources": "%s/xsd/"%s\n }' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$1" "$1" "$focus")") + ENTRIES+=("$(printf ' {\n "id": "%s",\n "title": "%s",\n "description": "%s",\n "version": "%s",\n "publisher": "%s",\n "licence": "%s",\n "link": "%s",\n "src": "%s/bundle.json",\n "sources": "%s/%s/"%s\n }' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$1" "$1" "${9:-xsd}" "$focus")") } # start <id>: a fresh output directory for an example. @@ -116,6 +118,43 @@ TXT "W3C" "W3C Document License" "https://www.w3.org/TR/xmlschema-1/" "ct/xs:complexType" else echo "warning: W3C XMLSchema.xsd not downloaded, example left out" >&2; fi +# UIC OSDM (Open Sales and Distribution Model): the OpenAPI 3.0 document of +# the online API and the YAML files with its JSON schemas. +if [ -d "$WORK/OSDM/.git" ] || git clone --depth 1 https://github.com/UnionInternationalCheminsdeFer/OSDM "$WORK/OSDM"; then + SPEC="$WORK/OSDM/specification" + OSDM_VERSION=$(sed -n 's/^ version: *//p' "$SPEC/OSDM-online-api.yml" | head -n 1) + start osdm + rmdir "$OUT/xsd" + mkdir -p "$OUT/schema" + cp -r "$SPEC/OSDM-online-api.yml" "$SPEC/schemas" "$SPEC/components" "$SPEC/paths" "$OUT/schema/" + cp "$WORK/OSDM/LICENSE" "$OUT/LICENSE" + "$CLI" index "$OUT/schema/OSDM-online-api.yml" -o "$OUT/bundle.json" + entry osdm "OSDM" "UIC Open Sales and Distribution Model: the OpenAPI 3.0 specification of the online API and its JSON schemas (OSDM-online-api.yml)" "${OSDM_VERSION:-latest}" \ + "UIC · OSDM working group" "Apache-2.0" "https://github.com/UnionInternationalCheminsdeFer/OSDM" "ct/Offer" schema +else echo "warning: OSDM not cloned, example left out" >&2; fi + +# JSON Schema 2020-12: the meta-schema and its vocabulary meta-schemas. +JS=https://json-schema.org/draft/2020-12 +META="core applicator unevaluated validation meta-data format-annotation content" +ok=1 +fetch $JS/schema jsonschema-2020-12-schema.json || ok=0 +for v in $META; do fetch "$JS/meta/$v" "jsonschema-2020-12-meta-$v.json" || ok=0; done +if [ "$ok" = 1 ]; then + start jsonschema + rmdir "$OUT/xsd" + mkdir -p "$OUT/schema/meta" + cp "$DL/jsonschema-2020-12-schema.json" "$OUT/schema/schema.json" + for v in $META; do cp "$DL/jsonschema-2020-12-meta-$v.json" "$OUT/schema/meta/$v.json"; done + if fetch https://raw.githubusercontent.com/json-schema-org/json-schema-spec/main/LICENSE jsonschema-LICENSE; then + cp "$DL/jsonschema-LICENSE" "$OUT/LICENSE" + else + echo "Copyright (c) 2022 JSON Schema Specification Authors. BSD-3-Clause, https://github.com/json-schema-org/json-schema-spec" > "$OUT/LICENSE" + fi + "$CLI" index "$OUT/schema" -o "$OUT/bundle.json" + entry jsonschema "JSON Schema 2020-12 (meta-schemas)" "The JSON Schema 2020-12 meta-schema that describes JSON Schema itself, with its vocabulary meta-schemas (core, applicator, validation, ...)" "2020-12" \ + "JSON Schema Specification Authors" "BSD-3-Clause" "https://json-schema.org/draft/2020-12" "ct/schema" schema +else echo "warning: JSON Schema meta-schemas not downloaded, example left out" >&2; fi + { echo "[" for i in "${!ENTRIES[@]}"; do diff --git a/scripts/fetch-fixtures.sh b/scripts/fetch-fixtures.sh index fcb2092..43ae7e8 100755 --- a/scripts/fetch-fixtures.sh +++ b/scripts/fetch-fixtures.sh @@ -12,4 +12,9 @@ if [ ! -d "$DIR/NeTEx/.git" ]; then else git -C "$DIR/NeTEx" pull --ff-only --depth 1 fi +if [ ! -d "$DIR/OSDM/.git" ]; then + git clone --depth 1 https://github.com/UnionInternationalCheminsdeFer/OSDM "$DIR/OSDM" +else + git -C "$DIR/OSDM" pull --ff-only --depth 1 +fi echo "fixtures ready in $DIR" diff --git a/web/package.json b/web/package.json index 85fddcd..9e587a3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "@matdata/schema-explorer", "version": "0.1.0", - "description": "Visual explorer for XML Schema (XSD) and, later, JSON Schema: UML-like diagrams, content-model trees and readable pages. Web app and <schema-explorer> web component.", + "description": "Visual explorer for XML Schema (XSD) and JSON Schema (also OpenAPI and AsyncAPI): UML-like diagrams, content-model trees and readable pages. Web app and <schema-explorer> web component.", "license": "Apache-2.0", "author": "Mathias Vanden Auweele <mathias@matdata.eu>", "homepage": "https://github.com/Matdata-eu/schema-explorer", diff --git a/web/scripts/build-wasm.mjs b/web/scripts/build-wasm.mjs index 87597fd..381771e 100644 --- a/web/scripts/build-wasm.mjs +++ b/web/scripts/build-wasm.mjs @@ -51,6 +51,7 @@ export function index(names: string[], contents: Uint8Array[], compact: boolean) export function expand(json: string): string; export function diff(old: string, cur: string, options: string): string; export function diff_report(diff: string, format: string): string; +export function json_source(name: string, text: string, pointer: string): string; export function version(): string; export function format(): number; ` diff --git a/web/src/app.ts b/web/src/app.ts index a9f4201..b0dc3c8 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -2,8 +2,8 @@ import type { AppCtx, Prefs, Route } from "./ctx"; import { button, copyText, h, icon, plural, type Child } from "./dom"; import { Compare } from "./diff"; -import { diffBundles, fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; -import { KIND_SLUG, Model, type Bundle, type Id } from "./model"; +import { bundleFile, diffBundles, fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, schemaFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; +import { KIND_SLUG, Model, TERMS, type Bundle, type Id } from "./model"; import { matdataMark } from "./logo"; import { openAbout, openHelp } from "./views/dialogs"; import { homePage } from "./views/home"; @@ -347,18 +347,17 @@ export class App implements AppCtx { for (const k of ["src", "bundle", "sources"]) u.searchParams.delete(k); history.replaceState(history.state, "", u.toString()); } - if (named.length === 0) throw new Error("none of the files is an XML Schema (.xsd) or a bundle (.json)"); + if (named.length === 0) throw new Error("none of the files is a schema (.xsd, .json, .yaml) or a bundle (.json)"); // A single bundle file loads directly. - if (named.length === 1 && /\.json$/i.test(named[0].name)) { - const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; - if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); + const single = bundleFile(named); + if (single) { this.source = { kind: "files" }; this.sources = null; - this.setBundle(b); + this.setBundle(single); return; } - const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); - if (xsd.length === 0) throw new Error("no .xsd files among the files"); + const xsd = schemaFiles(named); + if (xsd.length === 0) throw new Error("no schema files (.xsd, .json, .yaml) among the files"); // Folders keep their relative paths, so imports and includes resolve. this.showProgress({ stage: "index", detail: xsd.length === 1 ? xsd[0].name : `${xsd.length} files` }); const started = performance.now(); @@ -376,15 +375,12 @@ export class App implements AppCtx { } } - /** Read picked or dropped files into a bundle: one bundle file, or .xsd files indexed here. */ + /** Read picked or dropped files into a bundle: one bundle file, or schema files indexed here. */ private async bundleFromFiles(named: NamedBytes[]): Promise<Bundle> { - if (named.length === 1 && /\.json$/i.test(named[0].name)) { - const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; - if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); - return b; - } - const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); - if (xsd.length === 0) throw new Error("no .xsd files among the files"); + const single = bundleFile(named); + if (single) return single; + const xsd = schemaFiles(named); + if (xsd.length === 0) throw new Error("no schema files (.xsd, .json, .yaml) among the files"); const b = await indexFiles(xsd, (p) => this.showProgress(p)); const top = new Set(xsd.map((f) => (f.name.includes("/") ? f.name.split("/")[0] : ""))); if (top.size === 1 && !top.has("")) b.title = [...top][0]; @@ -414,7 +410,7 @@ export class App implements AppCtx { this.sources = urlSources(newer.sources ? new URL(newer.sources, document.baseURI).href : abs); } else { this.source = { kind: "files" }; - this.sources = memorySources(newer.files.filter((f) => /\.xsd$/i.test(f.name))); + this.sources = memorySources(schemaFiles(newer.files)); } const oldUrl = "url" in older ? older.url : undefined; if (this.opts.routing) { @@ -450,9 +446,9 @@ export class App implements AppCtx { go.disabled = !(sides[0] && sides[1]); }; const pick = (files: FileList | null) => { - if (files?.length) void readFiles(files).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + if (files?.length) void readFiles(files).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no schema files")); }; - const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json,.yaml,.yml", hidden: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; const dirInput = h("input", { type: "file", multiple: true, hidden: true, webkitdirectory: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; const url = h("input", { class: "input", type: "url", placeholder: "or the address of a bundle", "aria-label": `${title}: address of a bundle or an XSD file`, onInput: (ev: Event) => { const v = (ev.target as HTMLInputElement).value.trim(); @@ -479,7 +475,7 @@ export class App implements AppCtx { ev.stopPropagation(); z.classList.remove("over"); status.textContent = "reading…"; - void readDataTransfer(ev.dataTransfer).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + void readDataTransfer(ev.dataTransfer).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no schema files")); }); return z; }; @@ -585,10 +581,10 @@ export class App implements AppCtx { ev.preventDefault(); if (this.model) this.goRoute({ view: "home" }); else this.showOpen(); - } }, matdataMark(), h("span", { class: "title-text" }, m?.title ?? "schema-explorer"), m ? h("small", { class: "hide-mobile" }, `${(m.b.stats.global.complexType ?? 0).toLocaleString()} complex types`) : null)); + } }, matdataMark(), h("span", { class: "title-text" }, m?.title ?? "schema-explorer"), m ? h("small", { class: "hide-mobile" }, `${(m.b.stats.global.complexType ?? 0).toLocaleString()} ${TERMS.complexTypes}`) : null)); t.appendChild(h("span", { class: "spacer" })); if (m && this._cmp) t.appendChild(button("Changes", () => this.goRoute({ view: "diff" }), { icon: "table", cls: "small", title: `What changed since ${this._cmp.oldTitle}` })); - if (m) t.appendChild(button("Map", () => this.goRoute({ view: "overview" }), { icon: "map", cls: "small hide-mobile", title: "Overview map of the complex types" })); + if (m) t.appendChild(button("Map", () => this.goRoute({ view: "overview" }), { icon: "map", cls: "small hide-mobile", title: `Overview map of the ${TERMS.complexTypes}` })); if (m) t.appendChild(button("Share", () => void this.share(), { icon: "share", cls: "small share-btn", title: "Copy a link to exactly this view" })); if (m) t.appendChild(button("Open…", () => this.showOpen(), { icon: "folder", cls: "small hide-mobile", title: "Load other schemas" })); const dark = this.isDark(); @@ -617,7 +613,7 @@ export class App implements AppCtx { // ---- open / progress / errors -------------------------------------------- showOpen(): void { - const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => { + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json,.yaml,.yml", hidden: true, onChange: (ev: Event) => { const files = (ev.target as HTMLInputElement).files; if (files?.length) void this.openFiles(files); } }) as HTMLInputElement; @@ -629,7 +625,7 @@ export class App implements AppCtx { "div", { class: "dropzone" }, icon("folder", "drop-icon"), - h("p", null, h("strong", null, "Drop .xsd files or folders here"), h("br"), "or ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "choose a folder")), + h("p", null, h("strong", null, "Drop .xsd, .json or .yaml files or folders here"), h("br"), "or ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "choose a folder")), h("p", { class: "muted small" }, "Include the files the schema imports and includes. A bundle made with ", h("code", null, "schema-explorer index"), " (.json) works too."), fileInput, dirInput @@ -652,7 +648,7 @@ export class App implements AppCtx { "div", { class: "open-grid" }, h("section", { class: "card open-card" }, h("h2", null, "From your computer"), drop), - h("section", { class: "card open-card" }, h("h2", null, "From the web"), h("p", { class: "muted" }, "The address of a bundle made with ", h("code", null, "schema-explorer index"), ", or of a single .xsd file."), form, h("p", { class: "muted small" }, "Add ", h("code", null, "?bundle="), " and the address to this page's link to open it straight away. The site must allow cross-origin requests.")) + h("section", { class: "card open-card" }, h("h2", null, "From the web"), h("p", { class: "muted" }, "The address of a bundle made with ", h("code", null, "schema-explorer index"), ", or of a single .xsd, JSON Schema, OpenAPI or AsyncAPI file."), form, h("p", { class: "muted small" }, "Add ", h("code", null, "?bundle="), " and the address to this page's link to open it straight away. The site must allow cross-origin requests.")) ), this.compareCard(), examples diff --git a/web/src/diff.ts b/web/src/diff.ts index 422f343..9653b99 100644 --- a/web/src/diff.ts +++ b/web/src/diff.ts @@ -1,7 +1,7 @@ // Comparison of two versions of a schema set: the diff made by the core // (see crates/core/src/diff.rs) and lookups for the views that highlight it. import type { GraphSpec } from "./graph"; -import { KIND_SLUG, type Id, type Kind, type Model } from "./model"; +import { KIND_SLUG, LANG, type Id, type Kind, type Model } from "./model"; export type Status = "added" | "removed" | "changed"; export type Impact = "breaking" | "nonBreaking" | "info"; @@ -119,7 +119,16 @@ const LABELS: Record<string, string> = { docsChanged: "documentation changed", }; -export const changeLabel = (k: string): string => LABELS[k] ?? k; +const JSON_LABELS: Record<string, string> = { + childAdded: "property added", + childRemoved: "property removed", + childRetyped: "property retyped", + childMoved: "property moved", + childrenReordered: "properties reordered", + substitutionMemberAdded: "substitution member added", + memberTypeAdded: "union member added", +}; +export const changeLabel = (k: string): string => (LANG.json ? JSON_LABELS[k] : undefined) ?? LABELS[k] ?? k; /** The marker of a change: + for additions, − for removals, ~ otherwise. */ export function changeMark(k: string): Status { diff --git a/web/src/element.ts b/web/src/element.ts index 0e83858..945528a 100644 --- a/web/src/element.ts +++ b/web/src/element.ts @@ -15,8 +15,8 @@ function styles(): CSSStyleSheet { /** * Attributes: - * - `src` URL of a bundle.json (or of a single .xsd file) - * - `sources` base URL of the .xsd files, for the source view (default: next to the bundle) + * - `src` URL of a bundle.json (or of a single .xsd, JSON Schema, OpenAPI or AsyncAPI file) + * - `sources` base URL of the schema files, for the source view (default: next to the bundle) * - `focus` component to open first: `prefix:Name` or `ct/prefix:Name` * - `theme` light | dark | auto * - `routing` present: use location.hash for navigation (the standalone app) diff --git a/web/src/graph.ts b/web/src/graph.ts index 2c9d9d8..a487edc 100644 --- a/web/src/graph.ts +++ b/web/src/graph.ts @@ -123,7 +123,7 @@ function stereo(m: Model, id: Id): string | undefined { const c = m.comp(id); const s: string[] = []; if (c.complexType?.abstract) s.push("abstract"); - if (!c.name) s.push("anonymous"); + if (!c.name) s.push(c.json ? "inline" : "anonymous"); if (c.complexType?.mixed) s.push("mixed"); return s.length ? `«${s.join(", ")}»` : undefined; } diff --git a/web/src/loader.ts b/web/src/loader.ts index cf77cf7..3dade0e 100644 --- a/web/src/loader.ts +++ b/web/src/loader.ts @@ -1,4 +1,4 @@ -// Getting a bundle from somewhere: a bundle URL, dropped .xsd files and +// Getting a bundle from somewhere: a bundle URL, dropped schema files and // folders (indexed in a WebAssembly worker), or the local `serve` server. import type { SchemaDiff } from "./diff"; import type { Bundle } from "./model"; @@ -44,7 +44,7 @@ export async function fetchBundleFromServer(progress?: (p: LoadProgress) => void return checkBundle(JSON.parse(new TextDecoder().decode(await readWithProgress(r, progress))), progress); } -/** Load a URL: a bundle (JSON) or a single .xsd file. */ +/** Load a URL: a bundle (JSON) or a single schema file (.xsd, JSON Schema, OpenAPI, AsyncAPI). */ export async function loadUrl(url: string, progress?: (p: LoadProgress) => void): Promise<Bundle> { const abs = new URL(url, document.baseURI).href; progress?.({ stage: "download", detail: abs }); @@ -60,12 +60,14 @@ export async function loadUrl(url: string, progress?: (p: LoadProgress) => void) if (/\.xsd$/i.test(name) || bytes[0] === 0x3c || (bytes[0] === 0xef && bytes[3] === 0x3c)) { return indexFiles([{ name, bytes }], progress); } + if (/\.ya?ml$/i.test(name)) return indexFiles([{ name, bytes }], progress); let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(bytes)); } catch { - throw new Error(`${abs} is neither a schema-explorer bundle (JSON) nor an XSD file`); + throw new Error(`${abs} is neither a schema-explorer bundle (JSON) nor a schema file`); } + if (!isBundle(parsed)) return indexFiles([{ name: /\.json$/i.test(name) ? name : "schema.json", bytes }], progress); return checkBundle(parsed, progress); } @@ -122,7 +124,7 @@ function call(msg: Record<string, unknown>, transfer: Transferable[] = []): Prom }); } -/** Index XSD files in the browser (WebAssembly, in a worker). */ +/** Index schema files in the browser (WebAssembly, in a worker). */ export async function indexFiles(files: NamedBytes[], progress?: (p: LoadProgress) => void): Promise<Bundle> { progress?.({ stage: "index", detail: files.length === 1 ? files[0].name : `${files.length} files` }); // Copies go to the worker; the originals stay here for the source view. @@ -142,7 +144,37 @@ export function diffReport(d: SchemaDiff, format: "md" | "html"): Promise<string return call({ op: "report", json: JSON.stringify(d), format }); } -/** Read dropped or picked files; directories are walked for .xsd files. */ +/** Schema file names: XSD, and JSON or YAML (JSON Schema, OpenAPI, AsyncAPI, or a bundle). */ +export const SCHEMA_FILE = /\.(xsd|json|ya?ml)$/i; + +/** A bundle made by `schema-explorer index`, as opposed to a JSON Schema. */ +export function isBundle(x: unknown): x is Bundle { + return !!x && typeof x === "object" && typeof (x as Bundle).format === "number" && Array.isArray((x as Bundle).components); +} + +/** The single dropped file when it is a bundle. */ +export function bundleFile(named: NamedBytes[]): Bundle | null { + if (named.length !== 1 || !/\.json$/i.test(named[0].name)) return null; + try { + const x: unknown = JSON.parse(new TextDecoder().decode(named[0].bytes)); + return isBundle(x) ? x : null; + } catch { + return null; + } +} + +/** The files to index: the .xsd files, or else the JSON and YAML files. */ +export function schemaFiles(named: NamedBytes[]): NamedBytes[] { + const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); + return xsd.length ? xsd : named.filter((f) => /\.(json|ya?ml)$/i.test(f.name)); +} + +/** The schema at a JSON pointer of a JSON or YAML file, as indented JSON. */ +export function jsonSource(name: string, text: string, pointer: string): Promise<string> { + return call({ op: "source", name, text, pointer }); +} + +/** Read dropped or picked files; directories are walked for schema files. */ export async function readDataTransfer(dt: DataTransfer): Promise<NamedBytes[]> { const entries = Array.from(dt.items) .map((i) => (i.kind === "file" ? i.webkitGetAsEntry?.() : null)) @@ -151,7 +183,7 @@ export async function readDataTransfer(dt: DataTransfer): Promise<NamedBytes[]> const out: NamedBytes[] = []; const walk = async (e: FileSystemEntry, path: string): Promise<void> => { if (e.isFile) { - if (!/\.(xsd|json)$/i.test(e.name)) return; + if (!SCHEMA_FILE.test(e.name)) return; const file = await new Promise<File>((res, rej) => (e as FileSystemFileEntry).file(res, rej)); out.push({ name: path + e.name, bytes: new Uint8Array(await file.arrayBuffer()) }); } else if (e.isDirectory) { @@ -170,7 +202,7 @@ export async function readDataTransfer(dt: DataTransfer): Promise<NamedBytes[]> export async function readFiles(list: FileList | File[]): Promise<NamedBytes[]> { const out: NamedBytes[] = []; for (const f of Array.from(list)) { - if (!/\.(xsd|json)$/i.test(f.name)) continue; + if (!SCHEMA_FILE.test(f.name)) continue; out.push({ name: f.webkitRelativePath || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); } return out; @@ -204,7 +236,9 @@ export function urlSources(base: string): SourceProvider { const r = await fetch(new URL(name.split("/").map(encodeURIComponent).join("/"), base)); if (!r.ok) return null; const text = await r.text(); - return text.trimStart().startsWith("<") ? text : null; + // A web server answering with its HTML page is not the file. + const html = /^<(!doctype html|html)/i.test(text.trimStart()); + return text.trimStart().startsWith("<") || /\.(json|ya?ml)$/i.test(name) ? (html ? null : text) : null; } catch { return null; } diff --git a/web/src/model.ts b/web/src/model.ts index 639a853..5d3811f 100644 --- a/web/src/model.ts +++ b/web/src/model.ts @@ -139,6 +139,22 @@ export interface Component { attributeGroup?: { attributes?: AttrItem[]; anyAttribute?: Wildcard }; identity?: { selector: string; fields: string[]; refer?: Ref }; builtin?: boolean; + /** JSON Schema details (bundles with language "jsonSchema"). */ + json?: JsonInfo; +} + +export interface JsonInfo { + /** A type: object, array, string, ..., oneOf, anyOf, union. An element: property, patternProperty, additionalProperties, items, option. */ + kind: string; + /** JSON pointer in the file; absent for the root. */ + pointer?: string; + title?: string; + examples?: string[]; + deprecated?: boolean; + readOnly?: boolean; + writeOnly?: boolean; + /** A property whose value is an array of the element's type. */ + array?: boolean; } export interface Directive { @@ -156,6 +172,8 @@ export interface FileInfo { namespace?: number; prefixes?: Record<string, string>; version?: string; + /** JSON files: "JSON Schema 2020-12", "OpenAPI 3.1.0", ... */ + dialect?: string; elementFormQualified?: boolean; attributeFormQualified?: boolean; input?: boolean; @@ -175,6 +193,8 @@ export interface Diagnostic { export interface Bundle { format: number; generator: string; + /** Absent for XML Schema. */ + language?: "jsonSchema"; compact?: boolean; files: FileInfo[]; namespaces: { uri: string; prefix: string }[]; @@ -205,6 +225,42 @@ export const KIND_LABEL: Record<Kind, string> = { unique: "Unique", }; +const XSD_LABEL = { ...KIND_LABEL }; +const JSON_LABEL: Partial<Record<Kind, string>> = { element: "Property", complexType: "Object", simpleType: "Scalar" }; + +/** Words that differ between XML Schema and JSON Schema bundles. */ +export const TERMS = { + complexTypes: "complex types", + simpleTypes: "simple types", + elements: "elements", + typesTab: "Types", + simpleTab: "Simple", +}; +const XSD_TERMS = { ...TERMS }; +const JSON_TERMS: typeof TERMS = { complexTypes: "object schemas", simpleTypes: "scalar schemas", elements: "properties", typesTab: "Objects", simpleTab: "Scalars" }; + +/** The schema language of the bundle being shown. */ +export const LANG = { json: false }; + +/** Switch the labels to the terms of the bundle's schema language. */ +export function applyLanguage(b: Bundle): void { + LANG.json = b.language === "jsonSchema"; + Object.assign(KIND_LABEL, XSD_LABEL, b.language === "jsonSchema" ? JSON_LABEL : {}); + Object.assign(TERMS, b.language === "jsonSchema" ? JSON_TERMS : XSD_TERMS); +} + +const ROLE: Record<string, string> = { property: "Property", patternProperty: "Pattern property", additionalProperties: "Additional properties", items: "Array items", option: "Option" }; + +/** The badge text of a JSON Schema component: "Object definition", "Property", ... */ +export function jsonLabel(c: Component): string | undefined { + const j = c.json; + if (!j) return undefined; + if (c.kind === "element") return ROLE[j.kind] ?? "Property"; + if (c.builtin) return "JSON type"; + const what = j.kind === "oneOf" || j.kind === "anyOf" ? j.kind : j.kind ? j.kind[0].toUpperCase() + j.kind.slice(1) : "Schema"; + return c.parent === undefined ? `${what} definition` : `Inline ${j.kind || "schema"}`; +} + /** Short kind names used in links (#/ct/prefix:Name). */ export const KIND_SLUG: Record<Kind, string> = { element: "el", @@ -257,6 +313,7 @@ export class Model { readonly xsNs: number | undefined; constructor(b: Bundle) { + applyLanguage(b); this.b = b; this.c = b.components; this.xsNs = b.namespaces.findIndex((n) => n.uri === XS); @@ -284,7 +341,7 @@ export class Model { get title(): string { if (this.b.title) return this.b.title; const input = this.b.files.find((f) => f.input); - return input ? input.name.split("/").pop()! : "XML Schema"; + return input ? input.name.split("/").pop()! : this.b.language === "jsonSchema" ? "JSON Schema" : "XML Schema"; } comp(id: Id): Component { @@ -307,8 +364,9 @@ export class Model { label(id: Id): string { const c = this.c[id]; if (c.name) return c.name; - if (c.parent !== undefined) return `${this.label(c.parent)} (anonymous)`; - return "(anonymous)"; + const anon = c.json ? "inline" : "anonymous"; + if (c.parent !== undefined) return `${this.label(c.parent)} (${anon})`; + return `(${anon})`; } /** The text of a reference (resolved or not). */ @@ -328,7 +386,7 @@ export class Model { if (isId(r)) { const c = this.c[r]; if (c.builtin || (c.ns !== undefined && c.ns === this.xsNs)) return c.name ?? ""; - if (!c.name) return "(anonymous)"; + if (!c.name) return c.json?.kind || "(anonymous)"; return c.name; } return this.refName(r); diff --git a/web/src/source.ts b/web/src/source.ts index 2b92d29..6984dab 100644 --- a/web/src/source.ts +++ b/web/src/source.ts @@ -129,3 +129,78 @@ function resolve(m: Model, prefixes: Record<string, string>, qname: string, kind } return undefined; } + +const JSON_TOKEN = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b/g; +const byPointer = new WeakMap<Model, Map<string, Id>>(); + +/** The component a `$ref` written in `file` points to, by file and JSON pointer. */ +export function refTarget(m: Model, file: number, ref: string): Id | undefined { + let map = byPointer.get(m); + if (!map) { + map = new Map(); + m.c.forEach((c, i) => { + if (c.json && c.file !== undefined && !map!.has(`${c.file}#${c.json.pointer ?? ""}`)) map!.set(`${c.file}#${c.json.pointer ?? ""}`, i); + }); + byPointer.set(m, map); + } + const hash = ref.indexOf("#"); + const path = hash < 0 ? ref : ref.slice(0, hash); + let ptr = hash < 0 ? "" : ref.slice(hash + 1); + try { + ptr = decodeURIComponent(ptr); + } catch { + /* keep as written */ + } + let f = file; + if (path) { + if (/^[a-z][\w+.-]+:/i.test(path)) { + const tail = path.split("/").pop(); + f = m.b.files.findIndex((x) => x.targetNamespace === path || (!!tail && x.name.split("/").pop() === tail)); + } else { + const parts = (m.b.files[file]?.name ?? "").split("/").slice(0, -1); + for (const s of path.split("/")) { + if (s === "..") parts.pop(); + else if (s && s !== ".") parts.push(s); + } + const want = parts.join("/"); + f = m.b.files.findIndex((x) => x.name === want); + } + if (f < 0) return undefined; + } + return map.get(`${f}#${ptr}`); +} + +/** Highlighted JSON with `$ref` values linked to their pages. */ +export function highlightJson(m: Model, file: number, text: string, link: (id: Id, text: string) => HTMLElement): HTMLElement { + const pre = document.createElement("pre"); + pre.className = "xml json"; + const span = (cls: string, t: string) => { + const s = document.createElement("span"); + s.className = cls; + s.textContent = t; + return s; + }; + let at = 0; + let lastKey = ""; + for (const t of text.matchAll(JSON_TOKEN)) { + if (t.index > at) pre.append(text.slice(at, t.index)); + at = t.index + t[0].length; + if (t[1] && t[2]) { + lastKey = t[1]; + pre.append(span("t-attr", t[1]), t[2]); + } else if (t[1]) { + const target = /^"\$(ref|dynamicRef|recursiveRef)"$/.test(lastKey) ? refTarget(m, file, JSON.parse(t[1]) as string) : undefined; + if (target !== undefined) { + const a = link(target, t[1]); + a.classList.add("t-iri"); + pre.append(a); + } else pre.append(span("t-str", t[1])); + lastKey = ""; + } else { + pre.append(span("t-kw", t[0])); + lastKey = ""; + } + } + pre.append(text.slice(at)); + return pre; +} diff --git a/web/src/views/common.ts b/web/src/views/common.ts index e3aedb6..8b6a939 100644 --- a/web/src/views/common.ts +++ b/web/src/views/common.ts @@ -1,11 +1,14 @@ // Shared building blocks of the detail pages. import type { AppCtx } from "../ctx"; import { button, copyText, h, type Child } from "../dom"; -import { KIND_LABEL, docsText, isId, type Doc, type Id, type Ref } from "../model"; -import { highlight, snippet } from "../source"; +import { jsonSource } from "../loader"; +import { KIND_LABEL, docsText, isId, jsonLabel, type Doc, type Id, type JsonInfo, type Ref } from "../model"; +import { highlight, highlightJson, snippet } from "../source"; export function kindBadge(ctx: AppCtx, id: Id): HTMLElement { const c = ctx.m.comp(id); + const json = jsonLabel(c); + if (json) return h("span", { class: `badge k-${c.kind}` }, json); return h("span", { class: `badge k-${c.kind}` }, `${c.parent !== undefined && c.kind !== "complexType" && c.kind !== "simpleType" ? "Local " + KIND_LABEL[c.kind].toLowerCase() : c.parent !== undefined ? "Anonymous " + KIND_LABEL[c.kind].toLowerCase() : KIND_LABEL[c.kind]}`); } @@ -87,7 +90,7 @@ export function pageHead(ctx: AppCtx, id: Id, badges: Child[] = []): HTMLElement "div", null, ...crumbs, - h("div", { class: "page-head" }, h("h1", { class: c.complexType?.abstract || c.element?.abstract ? "abstract" : "" }, m.label(id)), kindBadge(ctx, id), ...badges), + h("div", { class: "page-head" }, h("h1", { class: c.complexType?.abstract || c.element?.abstract ? "abstract" : "" }, m.label(id)), kindBadge(ctx, id), ...jsonBadges(c.json), ...badges), h( "div", { class: "iri-line" }, @@ -172,6 +175,7 @@ export function localPath(ctx: AppCtx, id: Id, owner: Id): HTMLElement { /** The component's XSD, cut from its file, loaded when opened. */ export function sourceSection(ctx: AppCtx, id: Id): HTMLElement | null { const c = ctx.m.comp(id); + if (c.json) return jsonSourceSection(ctx, id); if (c.file === undefined || !c.line) return null; const file = c.file; const body = h("div", { class: "xsd-body" }, h("p", { class: "faint small" }, "Loading…")); @@ -201,6 +205,58 @@ export function sourceSection(ctx: AppCtx, id: Id): HTMLElement | null { return det; } +function jsonBadges(j: JsonInfo | undefined): Child[] { + if (!j) return []; + return [j.deprecated ? h("span", { class: "badge", title: "deprecated: true" }, "deprecated") : null, j.readOnly ? h("span", { class: "badge" }, "read-only") : null, j.writeOnly ? h("span", { class: "badge" }, "write-only") : null, j.title ? h("span", { class: "muted small" }, j.title) : null]; +} + +/** The component's schema as JSON (also for YAML files), loaded when opened. */ +function jsonSourceSection(ctx: AppCtx, id: Id): HTMLElement | null { + const c = ctx.m.comp(id); + if (c.file === undefined || !c.json) return null; + const file = c.file; + const name = ctx.m.b.files[file].name; + const pointer = c.json.pointer ?? ""; + const body = h("div", { class: "xsd-body" }, h("p", { class: "faint small" }, "Loading…")); + const det = h("details", { class: "xsd-src", open: ctx.route.params.src === "1" || undefined }, h("summary", null, h("span", null, "JSON source"), h("span", { class: "faint small" }, ` ${name}#${pointer}`)), body); + let loaded = false; + const load = async () => { + if (loaded) return; + loaded = true; + const text = await ctx.sourceText(file); + if (text === null) { + body.replaceChildren(h("p", { class: "muted small" }, "The schema file is not available here: the bundle was loaded without its schema files.")); + return; + } + let json: string; + try { + json = await jsonSource(name, text, pointer); + } catch { + body.replaceChildren(h("p", { class: "muted small" }, "Could not find the schema in the file.")); + return; + } + const yaml = /\.ya?ml$/i.test(name); + const copy = button("Copy", () => copyText(json).then((ok) => ctx.toast(ok ? "JSON copied" : "Could not copy")), { icon: "copy", cls: "small" }); + body.replaceChildren(h("div", { class: "xsd-tools" }, h("span", { class: "faint small" }, yaml ? "shown as JSON" : `${json.split("\n").length} lines`), copy), highlightJson(ctx.m, file, json, (target, text) => link(ctx, target, text))); + }; + det.addEventListener("toggle", () => { + ctx.setParams({ src: det.open ? "1" : undefined }); + if (det.open) void load(); + }); + if (det.open) void load(); + const examples = c.json.examples ?? []; + if (!examples.length) return det; + return h("div", null, h("details", { class: "xsd-src" }, h("summary", null, h("span", null, examples.length === 1 ? "Example" : `Examples (${examples.length})`)), h("div", { class: "xsd-body" }, examples.map((e) => highlightJson(ctx.m, file, pretty(e), (target, text) => link(ctx, target, text))))), det); +} + +function pretty(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } +} + /** Facets as a definition list. */ export function facetList(facets: { kind: string; value: string; fixed?: boolean }[]): HTMLElement { const dl = h("dl", { class: "kv" }); diff --git a/web/src/views/compare.ts b/web/src/views/compare.ts index 3c7a64b..da74a2b 100644 --- a/web/src/views/compare.ts +++ b/web/src/views/compare.ts @@ -210,7 +210,7 @@ function rowsToLines(m: Model, rows: ReturnType<Model["contentRows"]>, own: Id): let name: string; let detail: string; if (r.kind === "compositor") { - name = `${indent}${r.compositor}`; + name = `${indent}${m.b.language === "jsonSchema" ? (r.compositor === "choice" ? (r.max === null ? "anyOf" : "oneOf") : "properties") : r.compositor}`; detail = occursText(r.min, r.max); } else if (r.kind === "any") { name = `${indent}any`; diff --git a/web/src/views/dialogs.ts b/web/src/views/dialogs.ts index 55925c3..b9a900f 100644 --- a/web/src/views/dialogs.ts +++ b/web/src/views/dialogs.ts @@ -63,7 +63,7 @@ export function openHelp(host: HTMLElement): void { section( "Getting started", true, - ["Drop ", code(".xsd"), " files or whole folders on the open page, or ", b("choose files"), " or ", b("choose a folder"), ". Give every file the schema imports or includes: they are found by their relative path, or else by file name or namespace."], + ["Drop ", code(".xsd"), " files, JSON Schema, OpenAPI or AsyncAPI files (", code(".json"), ", ", code(".yaml"), ") or whole folders on the open page, or ", b("choose files"), " or ", b("choose a folder"), ". Give every file the schema imports, includes or names in a ", code("$ref"), ": they are found by their relative path, or else by file name or namespace."], ["Files are indexed in your browser (WebAssembly); nothing is uploaded. A bundle made with ", code("schema-explorer index"), " opens by its address, or with ", code("?bundle=<url>"), " added to this page's link."], [code("schema-explorer serve <files or folders>"), " opens the viewer on your own computer with the schemas already loaded."] ), @@ -79,7 +79,7 @@ export function openHelp(host: HTMLElement): void { ["The top card shows the ", b("derivation"), " chain (extension or restriction) up to the root, the derived types and the global elements of this type."], [b("Content model"), " lists the child elements in order with their type and cardinality. ", code("sequence"), ", ", code("choice"), " and ", code("all"), " appear as indented rows; group references are expanded under their name. Rows inherited from base types are grouped per base type and can be folded; ", b("inherited"), " shows or hides them."], [b("Attributes"), " lists every attribute, inherited and from attribute groups, with its type, use and default or fixed value."], - [b("Where used"), " lists the types, elements and groups that refer to the type, and ", b("XSD source"), " shows the declaration as written, with links on the type names."] + [b("Where used"), " lists the types, elements and groups that refer to the type, and ", b("XSD source"), " (or ", b("JSON source"), ") shows the declaration as written, with links on the type names."] ), section( "Diagrams", diff --git a/web/src/views/home.ts b/web/src/views/home.ts index fb65109..b536dd6 100644 --- a/web/src/views/home.ts +++ b/web/src/views/home.ts @@ -1,7 +1,7 @@ // The start page of a schema set: size, namespaces, where to begin, problems. import type { AppCtx } from "../ctx"; import { button, h, plural } from "../dom"; -import type { Id } from "../model"; +import { TERMS, type Id } from "../model"; import { chips, section } from "./common"; export function homePage(ctx: AppCtx): HTMLElement { @@ -9,7 +9,8 @@ export function homePage(ctx: AppCtx): HTMLElement { const b = m.b; const st = b.stats; const page = h("div"); - page.appendChild(h("div", { class: "page-head" }, h("h1", null, m.title), h("span", { class: "badge" }, "XML Schema"))); + const json = b.language === "jsonSchema"; + page.appendChild(h("div", { class: "page-head" }, h("h1", null, m.title), h("span", { class: "badge" }, json ? dialects(b.files) : "XML Schema"))); const inputs = b.files.filter((f) => f.input).map((f) => f.name); page.appendChild(h("p", { class: "muted small" }, `${plural(b.files.length, "file")}${inputs.length && inputs.length < b.files.length ? `, from ${inputs.slice(0, 3).join(", ")}${inputs.length > 3 ? ` and ${inputs.length - 3} more` : ""}` : ""} · indexed by ${b.generator}`)); const g = st.global; @@ -18,16 +19,21 @@ export function homePage(ctx: AppCtx): HTMLElement { h( "div", { class: "stats" }, - stat(g.complexType, "complex types", () => ctx.goRoute({ view: "overview" })), - stat(g.simpleType, "simple types"), + stat(g.complexType, TERMS.complexTypes, () => ctx.goRoute({ view: "overview" })), + stat(g.simpleType, TERMS.simpleTypes), stat(g.element, "global elements"), stat((g.group ?? 0) + (g.attributeGroup ?? 0), "groups"), - stat(st.local.element, "local elements"), + stat(st.local.element, json ? "properties" : "local elements"), stat(st.enumerations, "enumeration values"), stat(b.files.length, "files") ) ); page.appendChild(section("Start here", { tools: [button("Overview map", () => ctx.goRoute({ view: "overview" }), { icon: "map", cls: "small" })] })); + const fileRoots = json ? m.lists.complexType.concat(m.lists.simpleType).filter((id) => !m.c[id].json?.pointer) : []; + if (fileRoots.length) { + page.appendChild(h("p", { class: "muted small" }, "The root schemas of the files.")); + page.appendChild(chips(ctx, fileRoots, { max: 24 })); + } const roots = m.rootElements(); if (roots.length) { page.appendChild(h("p", { class: "muted small" }, "Global elements nothing else refers to: the likely document roots.")); @@ -39,7 +45,7 @@ export function homePage(ctx: AppCtx): HTMLElement { .slice(0, 24) .map((x) => x.id as Id); if (popular.length) { - page.appendChild(h("p", { class: "muted small" }, "The most used and most extended complex types.")); + page.appendChild(h("p", { class: "muted small" }, `The most used and most extended ${TERMS.complexTypes}.`)); page.appendChild(chips(ctx, popular, { max: 24 })); } // Namespaces. @@ -54,7 +60,7 @@ export function homePage(ctx: AppCtx): HTMLElement { h( "table", { class: "ptable compact" }, - h("thead", null, h("tr", null, h("th", null, "Prefix"), h("th", null, "Namespace"), h("th", null, "Complex"), h("th", null, "Simple"), h("th", null, "Elements"))), + h("thead", null, h("tr", null, h("th", null, "Prefix"), h("th", null, "Namespace"), h("th", null, json ? "Objects" : "Complex"), h("th", null, json ? "Scalars" : "Simple"), h("th", null, json ? "" : "Elements"))), h("tbody", null, nsRows.map((x) => h("tr", null, h("td", { class: "mono" }, x.n.prefix), h("td", { class: "mono small" }, x.n.uri), h("td", null, h("a", { href: ctx.hrefRoute({ view: "overview", params: { ns: String(x.i) } }), onClick: (ev: Event) => { ev.preventDefault(); ctx.goRoute({ view: "overview", params: { ns: String(x.i) } }); @@ -76,3 +82,10 @@ export function homePage(ctx: AppCtx): HTMLElement { } return page; } + +/** The schema languages of JSON files: "JSON Schema 2020-12", "OpenAPI 3.0.3", ... */ +function dialects(files: { dialect?: string; input?: boolean }[]): string { + const set = new Set(files.filter((f) => f.input && f.dialect).map((f) => f.dialect!)); + if (set.size === 0) for (const f of files) if (f.dialect) set.add(f.dialect); + return [...set].slice(0, 2).join(", ") || "JSON Schema"; +} diff --git a/web/src/views/overview.ts b/web/src/views/overview.ts index 8ca9286..edb6b7b 100644 --- a/web/src/views/overview.ts +++ b/web/src/views/overview.ts @@ -3,6 +3,7 @@ import type { AppCtx } from "../ctx"; import { Diagram } from "../diagram"; import { button, h } from "../dom"; import { buildOverview, folders, toMermaid, toPlantUml, type GraphSpec, type OverviewOptions } from "../graph"; +import { TERMS } from "../model"; import { boxActions, exportControls, fullscreenButton, parseView, viewParam } from "./diagramPanel"; export function overviewPage(ctx: AppCtx): HTMLElement { @@ -16,7 +17,7 @@ export function overviewPage(ctx: AppCtx): HTMLElement { let view = parseView(P.view); let restoreView = view !== undefined; const sync = () => ctx.setParams({ ns: o.ns === undefined ? "all" : String(o.ns), folder: o.folder, assoc: o.associations ? "1" : undefined, max: o.maxNodes !== 250 ? String(o.maxNodes) : undefined, view: viewParam(view ?? null) }); - page.appendChild(h("div", { class: "page-head" }, h("h1", null, "Overview map"), h("span", { class: "muted" }, "complex types and how they derive from each other"))); + page.appendChild(h("div", { class: "page-head" }, h("h1", null, "Overview map"), h("span", { class: "muted" }, `${TERMS.complexTypes} and how they derive from each other`))); const wrap = h("div", { class: "diagram tall" }); const statusText = h("span"); const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, h("span", { class: "gen" }, "extends"), h("span", { class: "restr" }, "restricts"), h("span", { class: "comp" }, "contains"))); diff --git a/web/src/views/pages.ts b/web/src/views/pages.ts index 93144ed..b44ec15 100644 --- a/web/src/views/pages.ts +++ b/web/src/views/pages.ts @@ -66,6 +66,7 @@ function typeBody(ctx: AppCtx, tid: Id, page: HTMLElement): void { } const attrs = m.attributes(tid); const ownA = attrs.filter((a) => a.from === tid).length; + if (m.b.language === "jsonSchema" && !attrs.length) return; page.append(section("Attributes", { count: attrs.length, tools: attrs.length > ownA ? [h("span", { class: "faint small" }, `${attrs.length - ownA} inherited`)] : [] })); page.append(attrs.length || ct.effective?.anyAttribute || ct.anyAttribute ? attrTable(ctx, tid, attrs, ct.effective?.anyAttribute ?? ct.anyAttribute) : emptyNote("No attributes.")); if (ct.asserts?.length) page.append(section("Assertions", { count: ct.asserts.length }), h("ul", { class: "cons" }, ct.asserts.map((a) => h("li", null, h("code", null, a))))); @@ -79,7 +80,7 @@ function typeBody(ctx: AppCtx, tid: Id, page: HTMLElement): void { export function contentTable(ctx: AppCtx, tid: Id, rows: ContentRow[], showInherited: boolean): HTMLElement { const m = ctx.m; const table = h("table", { class: "ptable cm" }); - table.appendChild(h("thead", null, h("tr", null, h("th", null, "Element"), h("th", null, "Type"), h("th", null, "Card."), h("th", null, "Documentation")))); + table.appendChild(h("thead", null, h("tr", null, h("th", null, KIND_LABEL.element), h("th", null, "Type"), h("th", null, "Card."), h("th", null, "Documentation")))); let body = h("tbody"); table.appendChild(body); let from: Id | undefined; @@ -96,7 +97,7 @@ export function contentTable(ctx: AppCtx, tid: Id, rows: ContentRow[], showInher const t = (ev.currentTarget as HTMLElement).closest("tbody")!; const shut = t.classList.toggle("folded"); (ev.currentTarget as HTMLElement).setAttribute("aria-expanded", String(!shut)); - } }, "▾ ") : null, inherited ? "Inherited from " : "Declared in ", link(ctx, r.from), h("span", { class: "faint" }, ` · ${plural(n, "element")}`))); + } }, "▾ ") : null, inherited ? "Inherited from " : "Declared in ", link(ctx, r.from), h("span", { class: "faint" }, ` · ${plural(n, KIND_LABEL.element.toLowerCase())}`))); body.appendChild(head); table.appendChild(body); } @@ -112,14 +113,18 @@ function contentRow(ctx: AppCtx, r: ContentRow, inherited: boolean): HTMLElement const pad = { paddingLeft: `${10 + r.depth * 18}px` }; const card = occursText(r.min, r.max); const cls = `${inherited ? "inherited" : ""}${r.inChoice ? " in-choice" : ""}`; + const json = m.b.language === "jsonSchema"; if (r.kind === "compositor") { - return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: `compositor c-${r.compositor}` }, r.compositor)), h("td", { class: "mult" }, card), h("td", { class: "faint small" }, r.compositor === "choice" ? "one of the following" : r.compositor === "all" ? "all, in any order" : "")); + const name = json ? (r.compositor === "choice" ? (r.max === null ? "anyOf" : "oneOf") : "properties") : r.compositor; + const note = r.compositor === "choice" ? (json && r.max === null ? "one or more of the following" : "one of the following") : r.compositor === "all" ? (json ? "in any order" : "all, in any order") : ""; + return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: `compositor c-${r.compositor}` }, name)), h("td", { class: "mult" }, json && r.compositor === "choice" ? "" : card), h("td", { class: "faint small" }, note)); } if (r.kind === "group") { return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: "compositor c-group" }, "group "), r.id !== undefined ? link(ctx, r.id, m.qname(r.id)) : h("span", { class: "missing" }, m.refName(r.missing))), h("td", { class: "mult" }, card), h("td", null, r.id !== undefined ? shortDoc(m.comp(r.id).docs) : null)); } if (r.kind === "any") { const w = r.wildcard!; + if (json) return h("tr", { class: cls }, h("td", { style: pad }, h("span", { class: "mono" }, "*")), h("td", { class: "mono small" }, "any"), h("td", { class: "mult" }, card), h("td", { class: "faint small" }, "additional properties")); return h("tr", { class: cls }, h("td", { style: pad }, h("span", { class: "mono" }, "any")), h("td", { class: "mono small" }, w.namespace ?? "##any", w.processContents ? h("span", { class: "faint" }, ` (${w.processContents})`) : null), h("td", { class: "mult" }, card), h("td", null)); } const id = r.id; @@ -133,7 +138,7 @@ function contentRow(ctx: AppCtx, r: ContentRow, inherited: boolean): HTMLElement "tr", { class: cls }, h("td", { class: "pname", style: pad }, link(ctx, id, el.name ?? ""), isRef ? h("span", { class: "role", title: "a reference to a global element" }, "ref") : null, el.element?.abstract ? h("span", { class: "role" }, "abstract") : null, (el.element?.members?.length ?? 0) > 0 ? h("span", { class: "role", title: "elements of its substitution group may appear instead" }, `+${el.element!.members!.length} substitutes`) : null), - h("td", { class: "rng" }, t === undefined ? h("span", { class: "faint" }, "anyType") : isId(t) && !m.comp(t).name ? link(ctx, t, "anonymous") : refLink(ctx, t, true)), + h("td", { class: "rng" }, el.json?.array ? h("span", { class: "faint" }, "array of ") : null, t === undefined ? h("span", { class: "faint" }, json ? "any" : "anyType") : isId(t) && !m.comp(t).name ? link(ctx, t, json ? `inline ${m.comp(t).json?.kind || "schema"}` : "anonymous") : refLink(ctx, t, true), el.json?.deprecated ? h("span", { class: "role" }, "deprecated") : null, el.json?.readOnly ? h("span", { class: "role" }, "read-only") : null, el.json?.writeOnly ? h("span", { class: "role" }, "write-only") : null), h("td", { class: "mult" }, card, el.element?.nillable ? h("div", { class: "faint small" }, "nillable") : null), h("td", null, docs ? shortDoc(docs) : typeDoc ? h("div", { class: "faint" }, shortDoc(typeDoc)) : null) ); diff --git a/web/src/views/sidebar.ts b/web/src/views/sidebar.ts index 9922053..2359220 100644 --- a/web/src/views/sidebar.ts +++ b/web/src/views/sidebar.ts @@ -3,7 +3,7 @@ // types (NeTEx has about 2,600 complex types) scroll smoothly. import type { AppCtx } from "../ctx"; import { debounce, h, icon, plural } from "../dom"; -import { KIND_LABEL, type Id } from "../model"; +import { KIND_LABEL, TERMS, type Id } from "../model"; type Tab = "complexType" | "simpleType" | "element" | "groups" | "files"; @@ -132,8 +132,8 @@ export function sidebar(ctx: AppCtx): Sidebar { const groupsCount = m.lists.group.length + m.lists.attributeGroup.length + m.lists.attribute.length; const tabDefs: { id: Tab; label: string; count: number; title: string }[] = [ - { id: "complexType", label: "Types", count: m.lists.complexType.length, title: "Complex types" }, - { id: "simpleType", label: "Simple", count: m.lists.simpleType.length, title: "Simple types" }, + { id: "complexType", label: TERMS.typesTab, count: m.lists.complexType.length, title: TERMS.complexTypes[0].toUpperCase() + TERMS.complexTypes.slice(1) }, + { id: "simpleType", label: TERMS.simpleTab, count: m.lists.simpleType.length, title: TERMS.simpleTypes[0].toUpperCase() + TERMS.simpleTypes.slice(1) }, { id: "element", label: "Elements", count: m.lists.element.length, title: "Global elements" }, { id: "groups", label: "Groups", count: groupsCount, title: "Groups, attribute groups and global attributes" }, { id: "files", label: "Files", count: m.b.files.length, title: "Schema files" }, diff --git a/web/src/wasm.worker.ts b/web/src/wasm.worker.ts index f5fdb71..438ac4e 100644 --- a/web/src/wasm.worker.ts +++ b/web/src/wasm.worker.ts @@ -1,12 +1,13 @@ -// Indexes XSD files with the Rust core compiled to WebAssembly, off the main thread. -import init, { diff, diff_report, expand, index } from "./wasm/schema_explorer_wasm.js"; +// Indexes schema files with the Rust core compiled to WebAssembly, off the main thread. +import init, { diff, diff_report, expand, index, json_source } from "./wasm/schema_explorer_wasm.js"; import wasmUrl from "./wasm/schema_explorer_wasm_bg.wasm?url"; type Req = | { id: number; op: "index"; names: string[]; contents: Uint8Array[] } | { id: number; op: "expand"; json: string } | { id: number; op: "diff"; old: string; new: string; options: string } - | { id: number; op: "report"; json: string; format: string }; + | { id: number; op: "report"; json: string; format: string } + | { id: number; op: "source"; name: string; text: string; pointer: string }; let ready: Promise<unknown> | null = null; @@ -17,7 +18,15 @@ self.onmessage = async (ev: MessageEvent<Req>) => { await ready; const started = performance.now(); const json = - req.op === "index" ? index(req.names, req.contents, false) : req.op === "expand" ? expand(req.json) : req.op === "diff" ? diff(req.old, req.new, req.options) : diff_report(req.json, req.format); + req.op === "index" + ? index(req.names, req.contents, false) + : req.op === "expand" + ? expand(req.json) + : req.op === "diff" + ? diff(req.old, req.new, req.options) + : req.op === "source" + ? json_source(req.name, req.text, req.pointer) + : diff_report(req.json, req.format); (self as unknown as Worker).postMessage({ id: req.id, json, ms: performance.now() - started }); } catch (e) { (self as unknown as Worker).postMessage({ id: req.id, error: String(e) });