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.

## 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]