diff --git a/Cargo.lock b/Cargo.lock
index cf53090..8a281b6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -116,6 +116,48 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -141,12 +183,45 @@ dependencies = [
"quote",
]
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -159,6 +234,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -177,6 +258,15 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "roxmltree"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -196,15 +286,72 @@ dependencies = [
[[package]]
name = "schema-explorer-core"
version = "0.1.0"
+dependencies = [
+ "roxmltree",
+ "serde",
+ "serde_json",
+]
[[package]]
name = "schema-explorer-wasm"
version = "0.1.0"
dependencies = [
+ "js-sys",
"schema-explorer-core",
"wasm-bindgen",
]
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "foldhash",
+ "indexmap",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -293,3 +440,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/README.md b/README.md
index c58c788..301ef85 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,27 @@ Website:
- A command-line tool (`schema-explorer serve` / `index`), a Docker image and
an embeddable `` web component (`@matdata/schema-explorer`)
+## 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
+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.
+
+```sh
+schema-explorer index path/to/NeTEx_publication.xsd -o bundle.json
+schema-explorer index schemas/ --compact -o bundle.json # directories are read recursively
+```
+
+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
+bundle. The bundle format is versioned (`format`, see
+`crates/core/src/bundle.rs`); `--compact` leaves out the parts the viewer
+can recompute. In the browser, the WebAssembly build exposes
+`index(names, contents, compact)` with the same output.
+
## Layout
- `crates/core`: Rust library that reads schema sets into an indexed model
@@ -41,7 +62,9 @@ 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)
-schemas there. Schemas whose licence forbids redistribution (such as railML)
+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)
must never be committed.
## Licence
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index d77bf0f..3636171 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -3,11 +3,14 @@
//!
//! Early development: the commands are stubs.
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+use std::time::Instant;
-use anyhow::{Result, bail};
+use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir};
+use schema_explorer_core::bundle::Level;
+use schema_explorer_core::{Options, Source};
/// The web viewer, embedded at build time (see build.rs).
static WEB: Dir<'static> = include_dir!("$OUT_DIR/web");
@@ -38,12 +41,23 @@ enum Command {
no_open: bool,
},
/// Index schema files into a bundle for the viewer.
+ ///
+ /// Directories are read recursively (every .xsd file); imports and
+ /// includes are followed from disk.
Index {
/// Schema files or directories.
+ #[arg(required = true)]
files: Vec,
/// Output file.
#[arg(short, long, default_value = "bundle.json")]
output: PathBuf,
+ /// Write the compact form (no effective content models or search
+ /// index; the viewer recomputes them).
+ #[arg(long)]
+ compact: bool,
+ /// Indent the JSON.
+ #[arg(long)]
+ pretty: bool,
},
}
@@ -65,16 +79,93 @@ fn main() -> Result<()> {
WEB.entries().len()
)
}
- Some(Command::Index { files, output }) => {
- let _ = files;
- bail!(
- "index is not implemented yet (would write {})",
- output.display()
- )
- }
+ Some(Command::Index {
+ files,
+ output,
+ compact,
+ pretty,
+ }) => index(&files, &output, compact, pretty),
None => {
println!("schema-explorer {}", schema_explorer_core::version());
Ok(())
}
}
}
+
+/// Every .xsd file under `path` (or `path` itself when it is a file).
+fn collect(path: &Path, out: &mut Vec) -> Result<()> {
+ if path.is_dir() {
+ let mut entries: Vec = std::fs::read_dir(path)
+ .with_context(|| format!("reading {}", path.display()))?
+ .map(|e| e.map(|e| e.path()))
+ .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)?;
+ }
+ }
+ } else if path.is_file() {
+ out.push(path.to_path_buf());
+ } else {
+ bail!("{} not found", path.display());
+ }
+ Ok(())
+}
+
+fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Result<()> {
+ let start = Instant::now();
+ let mut paths = Vec::new();
+ for p in inputs {
+ collect(p, &mut paths)?;
+ }
+ let sources = paths
+ .iter()
+ .map(|p| {
+ let bytes = std::fs::read(p).with_context(|| format!("reading {}", p.display()))?;
+ Ok(Source::new(p.to_string_lossy(), bytes))
+ })
+ .collect::>>()?;
+ let mut resolver = |path: &str| -> Option> {
+ if path.contains("://") {
+ return None;
+ }
+ std::fs::read(path).ok()
+ };
+ let opts = Options {
+ generator: format!("schema-explorer {}", env!("CARGO_PKG_VERSION")),
+ compact,
+ };
+ let bundle = schema_explorer_core::index(sources, Some(&mut resolver), &opts);
+ let json = if pretty {
+ schema_explorer_core::to_json_pretty(&bundle)?
+ } else {
+ schema_explorer_core::to_json(&bundle)?
+ };
+ std::fs::write(output, &json).with_context(|| format!("writing {}", output.display()))?;
+ for d in &bundle.diagnostics {
+ if d.level == Level::Info {
+ continue;
+ }
+ 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 s = &bundle.stats;
+ eprintln!(
+ "{} files, {} global and {} local components, {} unresolved imports, {} unresolved references; wrote {} ({} KB) in {:.2?}",
+ s.files,
+ s.global.values().sum::(),
+ s.local.values().sum::(),
+ s.unresolved_imports,
+ s.unresolved_refs,
+ output.display(),
+ json.len() / 1024,
+ start.elapsed()
+ );
+ Ok(())
+}
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 80a72d0..1c47249 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -11,3 +11,6 @@ keywords = ["xsd", "xml-schema", "json-schema", "schema", "uml"]
categories = ["parser-implementations", "visualization"]
[dependencies]
+roxmltree = "0.21.1"
+serde.workspace = true
+serde_json.workspace = true
diff --git a/crates/core/src/bundle.rs b/crates/core/src/bundle.rs
new file mode 100644
index 0000000..bf22096
--- /dev/null
+++ b/crates/core/src/bundle.rs
@@ -0,0 +1,710 @@
+//! The bundle: one XSD 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.
+
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+/// Bundle format version. Bump when the viewer must change to read it.
+pub const FORMAT: u32 = 1;
+
+/// Index into [`Bundle::components`].
+pub type Id = u32;
+/// Index into [`Bundle::files`].
+pub type FileId = u32;
+/// Index into [`Bundle::namespaces`].
+pub type NsId = u32;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Bundle {
+ pub format: u32,
+ pub generator: String,
+ /// True when the bundle was written in the compact form: no
+ /// `effective` content models and no `search` index (the viewer
+ /// recomputes them).
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub compact: bool,
+ pub files: Vec,
+ pub namespaces: Vec,
+ /// Component ids are indexes into this vector.
+ pub components: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub diagnostics: Vec,
+ pub stats: Stats,
+ /// Lower-case search terms (names, name parts, words of the
+ /// documentation and enumeration values) to the components they occur in.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub search: BTreeMap>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FileInfo {
+ /// Path of the file, relative to the common directory of all files.
+ pub name: String,
+ /// The `targetNamespace` attribute.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_namespace: Option,
+ /// The namespace the components of the file are in: the target
+ /// namespace, or that of the including schema for a chameleon include.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub namespace: Option,
+ /// Prefixes declared on the `xs:schema` element.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub prefixes: BTreeMap,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub version: Option,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub element_form_qualified: bool,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub attribute_form_qualified: bool,
+ /// True when the file was given as input, false when it was pulled in
+ /// through an import or include.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub input: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub directives: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub docs: Vec,
+ /// Number of global components declared in the file.
+ pub components: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum DirectiveKind {
+ Import,
+ Include,
+ Redefine,
+ Override,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Directive {
+ pub kind: DirectiveKind,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub namespace: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub location: Option,
+ /// The file it resolved to.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub file: Option,
+ /// How it was resolved when not by its location: `namespace` (another
+ /// input file has the imported namespace), `filename` (an input file has
+ /// the same file name) or `builtin` (the XML namespace).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub via: Option,
+ pub line: u32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Namespace {
+ pub uri: String,
+ /// Prefix for display, unique in the bundle.
+ pub prefix: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Kind {
+ Element,
+ ComplexType,
+ SimpleType,
+ Attribute,
+ AttributeGroup,
+ Group,
+ Key,
+ Keyref,
+ Unique,
+}
+
+impl Kind {
+ pub fn is_type(self) -> bool {
+ matches!(self, Kind::ComplexType | Kind::SimpleType)
+ }
+ pub fn is_identity(self) -> bool {
+ matches!(self, Kind::Key | Kind::Keyref | Kind::Unique)
+ }
+}
+
+/// A reference to a component: its id, or `{ns}local` when it was not
+/// found (the diagnostics say why).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Ref {
+ Id(Id),
+ Missing(String),
+}
+
+impl Ref {
+ pub fn id(&self) -> Option {
+ match self {
+ Ref::Id(id) => Some(*id),
+ Ref::Missing(_) => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Component {
+ pub kind: Kind,
+ /// Local name; empty for an anonymous type.
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ns: Option,
+ /// The enclosing component of a local declaration or anonymous type;
+ /// none for a global one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub parent: Option,
+ /// None for the built-in types of XML Schema and the XML namespace.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub file: Option,
+ #[serde(default, skip_serializing_if = "is_zero")]
+ pub line: u32,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub docs: Vec,
+ /// Content of `xs:appinfo`, as XML text.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub appinfo: Vec,
+ /// The global component this one redefines (`xs:redefine` / `xs:override`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub redefines: Option,
+ /// Who references this component.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub used_by: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub element: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub complex_type: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub simple_type: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub attribute: Option,
+ /// Model group definition: its particle.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub group: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub attribute_group: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub identity: Option,
+ /// A built-in type or attribute (no source file).
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub builtin: bool,
+}
+
+impl Component {
+ pub fn new(kind: Kind, name: String) -> Component {
+ Component {
+ kind,
+ name,
+ ns: None,
+ parent: None,
+ file: None,
+ line: 0,
+ docs: Vec::new(),
+ appinfo: Vec::new(),
+ redefines: None,
+ used_by: Vec::new(),
+ element: None,
+ complex_type: None,
+ simple_type: None,
+ attribute: None,
+ group: None,
+ attribute_group: None,
+ identity: None,
+ builtin: false,
+ }
+ }
+ pub fn is_global(&self) -> bool {
+ self.parent.is_none()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Doc {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lang: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ pub text: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum UseKind {
+ /// `type=` of an element or attribute.
+ Type,
+ /// `base=` of an extension or restriction.
+ Base,
+ /// `itemType=` of a list.
+ Item,
+ /// A member type of a union.
+ Member,
+ /// `ref=` to a global element.
+ Element,
+ /// `ref=` to a global attribute.
+ Attribute,
+ /// `ref=` to a model group.
+ Group,
+ /// `ref=` to an attribute group.
+ AttributeGroup,
+ /// `substitutionGroup=` naming the head element.
+ Substitution,
+ /// `refer=` of a keyref.
+ Refer,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Usage {
+ /// The referencing component (a local one has a `parent` chain up to
+ /// the global component).
+ pub by: Id,
+ pub how: UseKind,
+}
+
+fn is_zero(n: &u32) -> bool {
+ *n == 0
+}
+fn one() -> u32 {
+ 1
+}
+fn is_one(n: &u32) -> bool {
+ *n == 1
+}
+fn some_one() -> Option {
+ Some(1)
+}
+fn is_some_one(n: &Option) -> bool {
+ *n == Some(1)
+}
+
+/// `minOccurs`/`maxOccurs`; `max: null` means unbounded, absent means 1.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Occurs {
+ #[serde(default = "one", skip_serializing_if = "is_one")]
+ pub min: u32,
+ #[serde(default = "some_one", skip_serializing_if = "is_some_one")]
+ pub max: Option,
+}
+
+impl Default for Occurs {
+ fn default() -> Self {
+ Occurs {
+ min: 1,
+ max: Some(1),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "t", rename_all = "camelCase")]
+pub enum Particle {
+ /// A local element declaration or a reference to a global one.
+ Element {
+ el: Ref,
+ #[serde(flatten)]
+ occurs: Occurs,
+ },
+ /// A reference to a model group.
+ Group {
+ group: Ref,
+ #[serde(flatten)]
+ occurs: Occurs,
+ },
+ Sequence {
+ items: Vec,
+ #[serde(flatten)]
+ occurs: Occurs,
+ },
+ Choice {
+ items: Vec,
+ #[serde(flatten)]
+ occurs: Occurs,
+ },
+ All {
+ items: Vec,
+ #[serde(flatten)]
+ occurs: Occurs,
+ },
+ Any {
+ #[serde(flatten)]
+ wildcard: Wildcard,
+ #[serde(flatten)]
+ occurs: Occurs,
+ },
+}
+
+impl Particle {
+ pub fn occurs(&self) -> Occurs {
+ match self {
+ Particle::Element { occurs, .. }
+ | Particle::Group { occurs, .. }
+ | Particle::Sequence { occurs, .. }
+ | Particle::Choice { occurs, .. }
+ | Particle::All { occurs, .. }
+ | Particle::Any { occurs, .. } => *occurs,
+ }
+ }
+
+ /// Calls `f` on this particle and every nested one.
+ pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Particle)) {
+ f(self);
+ if let Particle::Sequence { items, .. }
+ | Particle::Choice { items, .. }
+ | Particle::All { items, .. } = self
+ {
+ for p in items {
+ p.walk(f);
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Wildcard {
+ /// The `namespace` attribute (`##any` when absent).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub namespace: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub not_namespace: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub process_contents: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Constraint {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub default: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fixed: Option,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ElementInfo {
+ /// The declared type (named, built-in or anonymous). None when the
+ /// declaration has neither: the type is that of the substitution group
+ /// head, or `xs:anyType`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub r#type: Option[,
+ /// Substitution group heads (one in XSD 1.0).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub substitution_group: Vec][,
+ /// Elements whose substitution group names this one (direct members).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub members: Vec,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub r#abstract: bool,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub nillable: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub default: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fixed: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub r#final: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub block: Option,
+ /// Identity constraints (key, keyref, unique) declared on the element.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub identity: Vec,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Method {
+ Extension,
+ Restriction,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Derivation {
+ pub method: Method,
+ pub base: Ref,
+ /// True for `xs:simpleContent`.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub simple: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ComplexInfo {
+ /// None for a type that restricts `xs:anyType` implicitly.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub derivation: Option,
+ /// The particle declared by this type itself (for an extension: only
+ /// what it adds to the base).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub content: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub attributes: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub any_attribute: Option,
+ /// Facets of a `xs:simpleContent` restriction.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub facets: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub enumerations: Vec,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub mixed: bool,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub r#abstract: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub r#final: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub block: Option,
+ /// XSD 1.1 `xs:assert` tests.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub asserts: Vec,
+ /// Types that name this one as their base (both complex and simple).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub derived: Vec,
+ /// The effective content model, inheritance included.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effective: Option,
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum AttrUse {
+ #[default]
+ Optional,
+ Required,
+ Prohibited,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "t", rename_all = "camelCase")]
+pub enum AttrItem {
+ /// A local attribute declaration or a reference to a global one.
+ Attribute {
+ attr: Ref,
+ #[serde(default, skip_serializing_if = "is_optional")]
+ r#use: AttrUse,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ default: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ fixed: Option,
+ },
+ /// A reference to an attribute group.
+ Group { group: Ref },
+}
+
+fn is_optional(u: &AttrUse) -> bool {
+ *u == AttrUse::Optional
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Effective {
+ /// The types whose own `content` particles, in this order, make up the
+ /// content model (a sequence of them). Empty for empty or simple content.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub content: Vec,
+ /// All attributes, inherited and from attribute groups (expanded).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub attributes: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub any_attribute: Option,
+ /// The simple type of the text content, for simple content.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub simple: Option][,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub mixed: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EffectiveAttr {
+ pub attr: Ref,
+ #[serde(default, skip_serializing_if = "is_optional")]
+ pub r#use: AttrUse,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub default: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fixed: Option,
+ /// The type that declares it.
+ pub from: Id,
+ /// The attribute group it comes through, if any (the outermost one).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub via: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Variety {
+ Atomic,
+ List,
+ Union,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SimpleInfo {
+ pub variety: Variety,
+ /// Restriction base.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub base: Option][,
+ /// List item type.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub item: Option][,
+ /// Union member types.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub members: Vec][,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub facets: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub enumerations: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub r#final: Option,
+ /// Types that name this one as their base.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub derived: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Facet {
+ /// Facet element name: `pattern`, `maxLength`, `minInclusive`, ...
+ pub kind: String,
+ pub value: String,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub fixed: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Enumeration {
+ pub value: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub docs: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub appinfo: Vec,
+ #[serde(default, skip_serializing_if = "is_zero")]
+ pub line: u32,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AttributeInfo {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub r#type: Option][,
+ /// Value constraint on the declaration (global ones; local uses carry
+ /// theirs in [`AttrItem`]).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub default: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fixed: Option,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub inheritable: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AttributeGroupInfo {
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub attributes: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub any_attribute: Option,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct IdentityInfo {
+ pub selector: String,
+ pub fields: Vec,
+ /// For a keyref: the key or unique constraint it refers to.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub refer: Option][,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Level {
+ Info,
+ Warning,
+ Error,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Diagnostic {
+ pub level: Level,
+ /// Stable machine-readable code: `parse-error`, `unresolved-import`,
+ /// `unresolved-ref`, `duplicate`, `cycle`, ...
+ pub code: String,
+ pub message: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub file: Option,
+ #[serde(default, skip_serializing_if = "is_zero")]
+ pub line: u32,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Stats {
+ pub files: u32,
+ /// Global components per kind.
+ pub global: BTreeMap,
+ /// Local declarations and anonymous types per kind.
+ pub local: BTreeMap,
+ pub enumerations: u32,
+ pub unresolved_imports: u32,
+ pub unresolved_refs: u32,
+}
+
+impl Bundle {
+ /// The display name `prefix:name` of a component.
+ pub fn qname(&self, id: Id) -> String {
+ let c = &self.components[id as usize];
+ match c.ns {
+ Some(ns) => format!("{}:{}", self.namespaces[ns as usize].prefix, c.name),
+ None => c.name.clone(),
+ }
+ }
+
+ /// The global component of `kind` with display name `qname` (`prefix:name`,
+ /// or a bare name for no namespace).
+ pub fn find(&self, kind: Kind, qname: &str) -> Option {
+ let (prefix, name) = match qname.split_once(':') {
+ Some((p, n)) => (Some(p), n),
+ None => (None, qname),
+ };
+ let ns = match prefix {
+ Some(p) => Some(self.namespaces.iter().position(|n| n.prefix == p)? as NsId),
+ None => None,
+ };
+ self.components
+ .iter()
+ .position(|c| c.kind == kind && c.parent.is_none() && c.ns == ns && c.name == name)
+ .map(|i| i as Id)
+ }
+
+ /// Strip the parts the viewer can recompute (effective content models
+ /// and the search index) to make big bundles smaller.
+ pub fn make_compact(&mut self) {
+ self.compact = true;
+ self.search.clear();
+ for c in &mut self.components {
+ if let Some(ct) = &mut c.complex_type {
+ ct.effective = None;
+ }
+ }
+ }
+
+ pub fn unresolved_imports(&self) -> impl Iterator]- {
+ self.diagnostics
+ .iter()
+ .filter(|d| d.code == "unresolved-import")
+ }
+}
diff --git a/crates/core/src/index.rs b/crates/core/src/index.rs
new file mode 100644
index 0000000..b60b284
--- /dev/null
+++ b/crates/core/src/index.rs
@@ -0,0 +1,381 @@
+//! Derived indexes: where used, derived types, substitution group members,
+//! effective content models and the search index.
+
+use crate::bundle::*;
+use std::collections::{BTreeMap, HashSet};
+
+/// Fill `used_by`, `derived` and `members`.
+pub(crate) fn links(components: &mut [Component], usages: Vec<(Id, Usage)>) {
+ for (target, u) in usages {
+ let list = &mut components[target as usize].used_by;
+ if list.last() != Some(&u) {
+ list.push(u);
+ }
+ }
+ let mut derived: Vec<(Id, Id)> = Vec::new();
+ let mut members: Vec<(Id, Id)> = Vec::new();
+ for (i, c) in components.iter().enumerate() {
+ let i = i as Id;
+ let base = c
+ .complex_type
+ .as_ref()
+ .and_then(|ct| ct.derivation.as_ref())
+ .and_then(|d| d.base.id())
+ .or_else(|| {
+ c.simple_type
+ .as_ref()
+ .and_then(|st| st.base.as_ref())
+ .and_then(Ref::id)
+ });
+ if let Some(b) = base {
+ derived.push((b, i));
+ }
+ if let Some(e) = &c.element {
+ for h in e.substitution_group.iter().filter_map(Ref::id) {
+ members.push((h, i));
+ }
+ }
+ }
+ for (b, d) in derived {
+ let c = &mut components[b as usize];
+ if let Some(ct) = &mut c.complex_type {
+ ct.derived.push(d);
+ } else if let Some(st) = &mut c.simple_type {
+ st.derived.push(d);
+ }
+ }
+ for (h, m) in members {
+ if let Some(e) = &mut components[h as usize].element {
+ e.members.push(m);
+ }
+ }
+}
+
+#[derive(Clone)]
+enum State {
+ Todo,
+ Busy,
+ Done(Box),
+}
+
+/// Compute the effective content model of every complex type.
+pub(crate) fn effective(components: &mut [Component], diags: &mut Vec) {
+ let mut memo: Vec = vec![State::Todo; components.len()];
+ for i in 0..components.len() {
+ if components[i].complex_type.is_some() {
+ compute(components, i as Id, &mut memo, diags);
+ }
+ }
+ for (i, s) in memo.into_iter().enumerate() {
+ if let (State::Done(e), Some(ct)) = (s, &mut components[i].complex_type) {
+ ct.effective = Some(*e);
+ }
+ }
+}
+
+fn attr_key(components: &[Component], r: &Ref) -> (Option, String) {
+ match r {
+ Ref::Id(a) => {
+ let c = &components[*a as usize];
+ (c.ns, c.name.clone())
+ }
+ Ref::Missing(s) => (None, s.clone()),
+ }
+}
+
+/// Expand attribute items (attribute groups inlined) declared by `from`.
+fn expand(
+ components: &[Component],
+ items: &[AttrItem],
+ from: Id,
+ via: Option,
+ seen: &mut HashSet,
+ out: &mut Vec,
+ any: &mut Option,
+) {
+ for it in items {
+ match it {
+ AttrItem::Attribute {
+ attr,
+ r#use,
+ default,
+ fixed,
+ } => {
+ let (default, fixed) = match (default, fixed, attr.id()) {
+ (None, None, Some(a)) => components[a as usize]
+ .attribute
+ .as_ref()
+ .map_or((None, None), |i| (i.default.clone(), i.fixed.clone())),
+ _ => (default.clone(), fixed.clone()),
+ };
+ out.push(EffectiveAttr {
+ attr: attr.clone(),
+ r#use: *r#use,
+ default,
+ fixed,
+ from,
+ via,
+ });
+ }
+ AttrItem::Group { group } => {
+ let Some(g) = group.id() else { continue };
+ if !seen.insert(g) {
+ continue;
+ }
+ if let Some(info) = &components[g as usize].attribute_group {
+ expand(
+ components,
+ &info.attributes,
+ from,
+ via.or(Some(g)),
+ seen,
+ out,
+ any,
+ );
+ if any.is_none() {
+ any.clone_from(&info.any_attribute);
+ }
+ }
+ seen.remove(&g);
+ }
+ }
+ }
+}
+
+fn compute(
+ components: &[Component],
+ id: Id,
+ memo: &mut Vec,
+ diags: &mut Vec,
+) -> Option {
+ match &memo[id as usize] {
+ State::Done(e) => return Some((**e).clone()),
+ State::Busy => {
+ let c = &components[id as usize];
+ diags.push(Diagnostic {
+ level: Level::Warning,
+ code: "cycle".into(),
+ message: format!("type {} derives from itself", c.name),
+ file: c.file,
+ line: c.line,
+ });
+ return None;
+ }
+ State::Todo => {}
+ }
+ let ct = components[id as usize].complex_type.as_ref()?;
+ memo[id as usize] = State::Busy;
+ let mut own = Vec::new();
+ let mut own_any = None;
+ expand(
+ components,
+ &ct.attributes,
+ id,
+ None,
+ &mut HashSet::new(),
+ &mut own,
+ &mut own_any,
+ );
+ if ct.any_attribute.is_some() {
+ own_any.clone_from(&ct.any_attribute);
+ }
+ let base = ct.derivation.as_ref().and_then(|d| d.base.id());
+ let base_eff = base
+ .filter(|b| components[*b as usize].complex_type.is_some())
+ .and_then(|b| compute(components, b, memo, diags))
+ .unwrap_or(Effective {
+ content: Vec::new(),
+ attributes: Vec::new(),
+ any_attribute: None,
+ simple: None,
+ mixed: false,
+ });
+ let own_content = ct.content.is_some().then_some(id);
+ let mut e = Effective {
+ content: Vec::new(),
+ attributes: base_eff.attributes,
+ any_attribute: None,
+ simple: None,
+ mixed: ct.mixed,
+ };
+ match &ct.derivation {
+ None => {
+ e.content.extend(own_content);
+ }
+ Some(d) if d.simple => {
+ e.simple = match d.base.id() {
+ Some(b) if components[b as usize].simple_type.is_some() => Some(d.base.clone()),
+ _ => base_eff.simple,
+ };
+ if d.method == Method::Restriction
+ && !(ct.facets.is_empty() && ct.enumerations.is_empty())
+ {
+ e.simple = Some(Ref::Id(id));
+ }
+ e.any_attribute = base_eff.any_attribute.clone();
+ }
+ Some(d) if d.method == Method::Extension => {
+ e.content = base_eff.content;
+ e.content.extend(own_content);
+ e.mixed |= base_eff.mixed;
+ e.any_attribute = base_eff.any_attribute.clone();
+ }
+ Some(_) => {
+ e.content.extend(own_content);
+ }
+ }
+ for a in own {
+ let key = attr_key(components, &a.attr);
+ let pos = e
+ .attributes
+ .iter()
+ .position(|x| attr_key(components, &x.attr) == key);
+ match (pos, a.r#use) {
+ (Some(p), AttrUse::Prohibited) => {
+ e.attributes.remove(p);
+ }
+ (None, AttrUse::Prohibited) => {}
+ (Some(p), _) => e.attributes[p] = a,
+ (None, _) => e.attributes.push(a),
+ }
+ }
+ if own_any.is_some() {
+ e.any_attribute = own_any;
+ }
+ memo[id as usize] = State::Done(Box::new(e.clone()));
+ Some(e)
+}
+
+const STOP: &[&str] = &[
+ "the", "and", "for", "are", "this", "that", "with", "from", "which", "not", "can", "may",
+ "has", "have", "its", "was", "were", "will", "shall", "into", "all", "any", "but", "one",
+ "used", "use", "type", "element", "value", "values", "see", "also", "other", "only", "when",
+ "where", "should", "must", "than", "then", "there", "these", "those", "such", "their", "been",
+ "being", "each", "does", "per",
+];
+
+/// Lower-case parts of a name: the name itself and its camelCase, `_`,
+/// `-` and `.` separated words.
+pub fn name_terms(name: &str) -> Vec {
+ let mut out = vec![name.to_lowercase()];
+ let mut word = String::new();
+ let chars: Vec = name.chars().collect();
+ for (i, &ch) in chars.iter().enumerate() {
+ let boundary = !ch.is_alphanumeric()
+ || (ch.is_uppercase()
+ && i > 0
+ && (chars[i - 1].is_lowercase()
+ || chars.get(i + 1).is_some_and(|n| n.is_lowercase())))
+ || (ch.is_ascii_digit()
+ != chars
+ .get(i.wrapping_sub(1))
+ .is_some_and(|p| p.is_ascii_digit())
+ && i > 0);
+ if boundary && !word.is_empty() {
+ out.push(std::mem::take(&mut word).to_lowercase());
+ }
+ if ch.is_alphanumeric() {
+ word.push(ch);
+ }
+ }
+ if !word.is_empty() {
+ out.push(word.to_lowercase());
+ }
+ out.retain(|w| w.chars().count() >= 2);
+ out.sort();
+ out.dedup();
+ out
+}
+
+fn doc_terms(text: &str, out: &mut Vec) {
+ for w in text.split(|c: char| !c.is_alphanumeric()) {
+ if w.chars().count() < 3 || w.chars().all(|c| c.is_ascii_digit()) {
+ continue;
+ }
+ let w = w.to_lowercase();
+ if !STOP.contains(&w.as_str()) {
+ out.push(w);
+ }
+ }
+}
+
+/// Build the search index over names, documentation and enumerations.
+pub(crate) fn search(components: &[Component]) -> BTreeMap> {
+ let mut index: BTreeMap> = BTreeMap::new();
+ let mut terms = Vec::new();
+ for (i, c) in components.iter().enumerate() {
+ if c.builtin {
+ continue;
+ }
+ terms.clear();
+ if !c.name.is_empty() {
+ terms.extend(name_terms(&c.name));
+ }
+ for d in &c.docs {
+ doc_terms(&d.text, &mut terms);
+ }
+ let enums = c
+ .simple_type
+ .as_ref()
+ .map(|s| &s.enumerations)
+ .or(c.complex_type.as_ref().map(|s| &s.enumerations));
+ for e in enums.into_iter().flatten() {
+ terms.extend(name_terms(&e.value));
+ for d in &e.docs {
+ doc_terms(&d.text, &mut terms);
+ }
+ }
+ terms.sort();
+ terms.dedup();
+ for t in &terms {
+ index.entry(t.clone()).or_default().push(i as Id);
+ }
+ }
+ index
+}
+
+pub(crate) fn stats(b: &Bundle, unresolved_refs: u32) -> Stats {
+ let mut s = Stats {
+ files: b.files.len() as u32,
+ unresolved_refs,
+ unresolved_imports: b.unresolved_imports().count() as u32,
+ ..Default::default()
+ };
+ for c in &b.components {
+ if c.builtin {
+ continue;
+ }
+ let map = if c.is_global() && !c.kind.is_identity() {
+ &mut s.global
+ } else {
+ &mut s.local
+ };
+ *map.entry(c.kind).or_default() += 1;
+ s.enumerations += c
+ .simple_type
+ .as_ref()
+ .map_or(0, |st| st.enumerations.len() as u32);
+ }
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use super::name_terms;
+
+ #[test]
+ fn terms() {
+ assert_eq!(
+ name_terms("StopPlaceRef_VersionStructure"),
+ vec![
+ "place",
+ "ref",
+ "stop",
+ "stopplaceref_versionstructure",
+ "structure",
+ "version"
+ ]
+ );
+ assert_eq!(name_terms("XMLSchema"), vec!["schema", "xml", "xmlschema"]);
+ }
+}
diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs
new file mode 100644
index 0000000..a6262b1
--- /dev/null
+++ b/crates/core/src/input.rs
@@ -0,0 +1,446 @@
+//! Loading an XSD set from in-memory files: decoding, path handling and
+//! resolving `xs:import` / `xs:include` / `xs:redefine` / `xs:override`
+//! locations, with an optional callback for files that were not given.
+
+use crate::bundle::{Diagnostic, DirectiveKind, Level};
+use std::collections::{HashMap, VecDeque};
+
+pub(crate) const XS: &str = "http://www.w3.org/2001/XMLSchema";
+pub(crate) const XML: &str = "http://www.w3.org/XML/1998/namespace";
+
+/// One input file: its name (a path, used to resolve relative schema
+/// locations) and its bytes.
+#[derive(Debug, Clone)]
+pub struct Source {
+ pub name: String,
+ pub bytes: Vec,
+}
+
+impl Source {
+ pub fn new(name: impl Into, bytes: impl Into>) -> Source {
+ Source {
+ name: name.into(),
+ bytes: bytes.into(),
+ }
+ }
+}
+
+/// Supplies the bytes of a file that an import or include names but that
+/// was not among the inputs; gets the normalized path (or URL).
+pub type Resolver<'r> = dyn FnMut(&str) -> Option> + 'r;
+
+pub(crate) struct RawDirective {
+ pub kind: DirectiveKind,
+ pub namespace: Option,
+ pub location: Option,
+ pub line: u32,
+ pub file: Option,
+ pub via: Option<&'static str>,
+}
+
+pub(crate) struct LoadedFile {
+ /// Normalized path.
+ pub name: String,
+ /// Path relative to the common directory of all files.
+ pub display: String,
+ pub text: String,
+ pub input: bool,
+ pub ok: bool,
+ pub target_namespace: Option,
+ pub directives: Vec,
+ pub lines: LineIndex,
+}
+
+/// Byte offset to line number.
+pub(crate) struct LineIndex(Vec);
+
+impl LineIndex {
+ pub fn new(text: &str) -> LineIndex {
+ LineIndex(
+ text.bytes()
+ .enumerate()
+ .filter(|(_, b)| *b == b'\n')
+ .map(|(i, _)| i)
+ .collect(),
+ )
+ }
+ /// 1-based line of byte offset `pos`.
+ pub fn line(&self, pos: usize) -> u32 {
+ (self.0.partition_point(|&nl| nl < pos) + 1) as u32
+ }
+}
+
+pub(crate) fn parse_options<'a>() -> roxmltree::ParsingOptions<'a> {
+ roxmltree::ParsingOptions {
+ allow_dtd: true,
+ ..Default::default()
+ }
+}
+
+/// Decode XML bytes: UTF-8 (with or without BOM), UTF-16 with a BOM, else
+/// Latin-1.
+pub(crate) fn decode(bytes: &[u8]) -> (String, bool) {
+ if let Some(b) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
+ return (String::from_utf8_lossy(b).into_owned(), true);
+ }
+ let utf16 = |b: &[u8], le: bool| {
+ let units: Vec = b
+ .as_chunks::<2>()
+ .0
+ .iter()
+ .map(|&c| {
+ if le {
+ u16::from_le_bytes(c)
+ } else {
+ u16::from_be_bytes(c)
+ }
+ })
+ .collect();
+ String::from_utf16_lossy(&units)
+ };
+ if let Some(b) = bytes.strip_prefix(&[0xFF, 0xFE]) {
+ return (utf16(b, true), true);
+ }
+ if let Some(b) = bytes.strip_prefix(&[0xFE, 0xFF]) {
+ return (utf16(b, false), true);
+ }
+ match std::str::from_utf8(bytes) {
+ Ok(s) => (s.to_string(), true),
+ Err(_) => (bytes.iter().map(|&b| b as char).collect(), false),
+ }
+}
+
+pub(crate) fn is_url(s: &str) -> bool {
+ s.split_once("://")
+ .is_some_and(|(scheme, _)| scheme.chars().all(|c| c.is_ascii_alphanumeric()))
+}
+
+/// Normalize a path: `\` to `/`, drop `.` segments and fold `..`.
+pub fn normalize(path: &str) -> String {
+ if is_url(path) {
+ return path.to_string();
+ }
+ let path = path.replace('\\', "/");
+ let absolute = path.starts_with('/');
+ let mut out: Vec<&str> = Vec::new();
+ for seg in path.split('/') {
+ match seg {
+ "" | "." => {}
+ ".." => {
+ if out.last().is_some_and(|s| *s != "..") {
+ out.pop();
+ } else if !absolute {
+ out.push("..");
+ }
+ }
+ s => out.push(s),
+ }
+ }
+ let joined = out.join("/");
+ if absolute {
+ format!("/{joined}")
+ } else {
+ joined
+ }
+}
+
+/// Resolve `location` against the file `base`.
+pub fn join(base: &str, location: &str) -> String {
+ let location = location.trim();
+ if is_url(location) || location.starts_with('/') {
+ return normalize(location);
+ }
+ if is_url(base) {
+ let dir = &base[..base.rfind('/').map_or(base.len(), |i| i + 1)];
+ return format!("{dir}{location}");
+ }
+ let dir = match base.rfind(['/', '\\']) {
+ Some(i) => &base[..=i],
+ None => "",
+ };
+ normalize(&format!("{dir}{location}"))
+}
+
+fn basename(path: &str) -> &str {
+ path.rsplit(['/', '\\']).next().unwrap_or(path)
+}
+
+fn file_diag(level: Level, code: &str, message: String, file: usize, line: u32) -> Diagnostic {
+ Diagnostic {
+ level,
+ code: code.into(),
+ message,
+ file: Some(file as u32),
+ line,
+ }
+}
+
+/// Read the root element of one file: target namespace and directives.
+fn scan(f: &mut LoadedFile, idx: usize, diags: &mut Vec) {
+ let doc = match roxmltree::Document::parse_with_options(&f.text, parse_options()) {
+ Ok(d) => d,
+ Err(e) => {
+ let line = e.pos().row;
+ diags.push(file_diag(
+ Level::Error,
+ "parse-error",
+ format!("not well-formed XML: {e}"),
+ idx,
+ line,
+ ));
+ return;
+ }
+ };
+ let root = doc.root_element();
+ if root.tag_name().namespace() != Some(XS) || root.tag_name().name() != "schema" {
+ diags.push(file_diag(
+ Level::Warning,
+ "not-a-schema",
+ "the root element is not xs:schema".to_string(),
+ idx,
+ 1,
+ ));
+ return;
+ }
+ f.ok = true;
+ f.target_namespace = root
+ .attribute("targetNamespace")
+ .filter(|s| !s.is_empty())
+ .map(str::to_string);
+ for c in root
+ .children()
+ .filter(|c| c.tag_name().namespace() == Some(XS))
+ {
+ let kind = match c.tag_name().name() {
+ "import" => DirectiveKind::Import,
+ "include" => DirectiveKind::Include,
+ "redefine" => DirectiveKind::Redefine,
+ "override" => DirectiveKind::Override,
+ _ => continue,
+ };
+ f.directives.push(RawDirective {
+ kind,
+ namespace: c.attribute("namespace").map(str::to_string),
+ location: c
+ .attribute("schemaLocation")
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string),
+ line: f.lines.line(c.range().start),
+ file: None,
+ via: None,
+ });
+ }
+}
+
+/// Load the sources and everything they import or include.
+pub(crate) fn load(
+ sources: Vec,
+ mut resolver: Option<&mut Resolver<'_>>,
+ diags: &mut Vec,
+) -> Vec {
+ let mut files: Vec = Vec::new();
+ let mut by_name: HashMap = HashMap::new();
+ let add = |files: &mut Vec,
+ by_name: &mut HashMap,
+ diags: &mut Vec,
+ name: String,
+ bytes: &[u8],
+ input: bool| {
+ let (text, utf8) = decode(bytes);
+ let idx = files.len();
+ if !utf8 {
+ diags.push(file_diag(
+ Level::Info,
+ "encoding",
+ "not UTF-8, read as Latin-1".to_string(),
+ idx,
+ 0,
+ ));
+ }
+ by_name.insert(name.clone(), idx);
+ files.push(LoadedFile {
+ display: String::new(),
+ name,
+ lines: LineIndex::new(&text),
+ text,
+ input,
+ ok: false,
+ target_namespace: None,
+ directives: Vec::new(),
+ });
+ idx
+ };
+ for s in sources {
+ let name = normalize(&s.name);
+ if by_name.contains_key(&name) {
+ continue;
+ }
+ add(&mut files, &mut by_name, diags, name, &s.bytes, true);
+ }
+ let mut queue: VecDeque = (0..files.len()).collect();
+ while let Some(i) = queue.pop_front() {
+ let mut f = std::mem::replace(
+ &mut files[i],
+ LoadedFile {
+ display: String::new(),
+ name: String::new(),
+ text: String::new(),
+ input: false,
+ ok: false,
+ target_namespace: None,
+ directives: Vec::new(),
+ lines: LineIndex(Vec::new()),
+ },
+ );
+ scan(&mut f, i, diags);
+ for d in &mut f.directives {
+ let Some(loc) = &d.location else { continue };
+ let target = join(&f.name, loc);
+ if let Some(&j) = by_name.get(&target) {
+ d.file = Some(j);
+ continue;
+ }
+ if let Some(r) = resolver.as_deref_mut()
+ && let Some(bytes) = r(&target)
+ {
+ let j = add(&mut files, &mut by_name, diags, target, &bytes, false);
+ queue.push_back(j);
+ d.file = Some(j);
+ }
+ }
+ files[i] = f;
+ }
+ fallback(&mut files, diags);
+ let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
+ let root = common_dir(&names);
+ for f in &mut files {
+ f.display = f.name.strip_prefix(&root).unwrap_or(&f.name).to_string();
+ }
+ files
+}
+
+/// Resolve the directives whose location was not found: by namespace, by
+/// file name, or as the built-in XML namespace; report the rest.
+fn fallback(files: &mut [LoadedFile], diags: &mut Vec) {
+ let mut by_ns: HashMap<&str, Vec> = HashMap::new();
+ let mut by_base: HashMap<&str, Vec> = HashMap::new();
+ for (i, f) in files.iter().enumerate() {
+ if let Some(ns) = &f.target_namespace {
+ by_ns.entry(ns.as_str()).or_default().push(i);
+ }
+ by_base.entry(basename(&f.name)).or_default().push(i);
+ }
+ let mut found: Vec<(usize, usize, Option, Option<&'static str>)> = Vec::new();
+ for (i, f) in files.iter().enumerate() {
+ for (k, d) in f.directives.iter().enumerate() {
+ if d.file.is_some() {
+ continue;
+ }
+ let ns = d.namespace.as_deref();
+ let by_namespace = (d.kind == DirectiveKind::Import)
+ .then(|| ns.and_then(|ns| by_ns.get(ns)))
+ .flatten()
+ .and_then(|v| v.iter().copied().find(|&j| j != i));
+ let by_file = d
+ .location
+ .as_deref()
+ .and_then(|l| by_base.get(basename(l)))
+ .filter(|v| v.len() == 1)
+ .map(|v| v[0])
+ .filter(|&j| j != i)
+ .filter(|&j| {
+ d.kind != DirectiveKind::Import || files[j].target_namespace.as_deref() == ns
+ });
+ let r = if let Some(j) = by_namespace {
+ (Some(j), Some("namespace"))
+ } else if let Some(j) = by_file {
+ (Some(j), Some("filename"))
+ } else if d.kind == DirectiveKind::Import && ns == Some(XML) {
+ (None, Some("builtin"))
+ } else if d.kind == DirectiveKind::Import && d.location.is_none() {
+ // An import without a location only declares the namespace;
+ // components from it show up as unresolved references.
+ (None, None)
+ } else {
+ diags.push(file_diag(
+ Level::Warning,
+ "unresolved-import",
+ format!(
+ "cannot find the {} {}",
+ match d.kind {
+ DirectiveKind::Import => "import",
+ DirectiveKind::Include => "include",
+ DirectiveKind::Redefine => "redefine",
+ DirectiveKind::Override => "override",
+ },
+ d.location.as_deref().or(ns).unwrap_or("?")
+ ),
+ i,
+ d.line,
+ ));
+ continue;
+ };
+ found.push((i, k, r.0, r.1));
+ }
+ }
+ for (i, k, file, via) in found {
+ let d = &mut files[i].directives[k];
+ d.file = file;
+ d.via = via;
+ }
+}
+
+/// The longest common directory of the file names, with a trailing `/`.
+pub(crate) fn common_dir(names: &[&str]) -> String {
+ let local: Vec<&str> = names.iter().copied().filter(|n| !is_url(n)).collect();
+ let Some(first) = local.first() else {
+ return String::new();
+ };
+ let mut prefix: &str = match first.rfind('/') {
+ Some(i) => &first[..=i],
+ None => return String::new(),
+ };
+ for n in &local[1..] {
+ while !n.starts_with(prefix) {
+ prefix = match prefix[..prefix.len() - 1].rfind('/') {
+ Some(i) => &prefix[..=i],
+ None => return String::new(),
+ };
+ }
+ }
+ prefix.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn paths() {
+ assert_eq!(normalize("a/./b/../c.xsd"), "a/c.xsd");
+ assert_eq!(normalize("../x.xsd"), "../x.xsd");
+ assert_eq!(normalize("/r/a/../../b.xsd"), "/b.xsd");
+ assert_eq!(join("a/b/c.xsd", "../../d/e.xsd"), "d/e.xsd");
+ assert_eq!(join("c.xsd", "e.xsd"), "e.xsd");
+ assert_eq!(join("a\\c.xsd", "e.xsd"), "a/e.xsd");
+ assert_eq!(join("a/c.xsd", "http://x.org/y.xsd"), "http://x.org/y.xsd");
+ assert_eq!(common_dir(&["/a/b/c.xsd", "/a/b/d/e.xsd"]), "/a/b/");
+ assert_eq!(common_dir(&["a.xsd"]), "");
+ }
+
+ #[test]
+ fn lines() {
+ let l = LineIndex::new("a\nb\nc");
+ assert_eq!(l.line(0), 1);
+ assert_eq!(l.line(2), 2);
+ assert_eq!(l.line(4), 3);
+ }
+
+ #[test]
+ fn decoding() {
+ assert_eq!(decode(b"\xEF\xBB\xBFab").0, "ab");
+ assert_eq!(decode(b"\xFF\xFEa\0").0, "a");
+ assert_eq!(decode(b"\xE9").0, "\u{e9}");
+ }
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index ead12b5..bc1d457 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,13 +1,129 @@
//! Core of schema-explorer: reads XML Schema (XSD) sets, and later JSON Schema,
//! into one indexed model for the viewer.
//!
-//! Early development: the XSD reader is not implemented yet.
+//! 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
+//! browser. Imports and includes are resolved relative to the including
+//! file, among the given files or through an optional [`Resolver`].
+//! Problems (unparsable files, unresolved imports or references) become
+//! [diagnostics](bundle::Diagnostic) in the [`Bundle`]; indexing never fails.
+
+pub mod bundle;
+pub mod index;
+pub mod input;
+mod xsd;
+
+pub use bundle::{Bundle, FORMAT, Id, Kind};
+pub use input::{Resolver, Source};
+
+/// Indexing options.
+#[derive(Debug, Clone, Default)]
+pub struct Options {
+ /// Written to [`Bundle::generator`].
+ pub generator: String,
+ /// Write the compact form (see [`Bundle::make_compact`]).
+ pub compact: bool,
+}
/// The version of this crate.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
+/// Read an XSD set into a bundle.
+pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &Options) -> Bundle {
+ let mut diagnostics = Vec::new();
+ let files = input::load(sources, resolver, &mut diagnostics);
+ let docs: Vec