From 1587cbc880951b1bde461ede82b1f678ab39d447 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 16:45:57 +0000 Subject: [PATCH 1/2] Add version comparison to the core, the CLI and the wasm bindings diff(old, new) matches global components by qualified name, falling back to kind and local name when the target namespace changed, and reports fine-grained changes with their impact (breaking, non-breaking, info), possible renames and summaries per kind, namespace and file. The CLI's diff command writes the diff as JSON, Markdown or HTML. --- crates/cli/src/main.rs | 125 ++ crates/core/src/diff.rs | 2233 +++++++++++++++++++++ crates/core/src/lib.rs | 3 + crates/core/src/report.rs | 483 +++++ crates/core/tests/data/diff/v1/common.xsd | 19 + crates/core/tests/data/diff/v1/shop.xsd | 71 + crates/core/tests/data/diff/v2/common.xsd | 20 + crates/core/tests/data/diff/v2/shop.xsd | 85 + crates/core/tests/diff.rs | 225 +++ crates/core/tests/fixtures.rs | 97 + crates/wasm/src/lib.rs | 32 + 11 files changed, 3393 insertions(+) create mode 100644 crates/core/src/diff.rs create mode 100644 crates/core/src/report.rs create mode 100644 crates/core/tests/data/diff/v1/common.xsd create mode 100644 crates/core/tests/data/diff/v1/shop.xsd create mode 100644 crates/core/tests/data/diff/v2/common.xsd create mode 100644 crates/core/tests/data/diff/v2/shop.xsd create mode 100644 crates/core/tests/diff.rs diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 86c731f..fd41988 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -63,6 +63,44 @@ enum Command { #[arg(long)] pretty: bool, }, + /// 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 + /// commas. Components are matched by qualified name, and by kind and + /// local name when the target namespace changed between the versions. + Diff { + /// The earlier version. + old: String, + /// The later version. + new: String, + /// Output file (default: standard output). + #[arg(short, long)] + output: Option, + /// Report format: json (the diff model), md (release notes) or html. + #[arg(long, value_enum, default_value_t = Format::Json)] + format: Format, + /// Match components only by qualified name (no fallback to local + /// names when the namespace changed). + #[arg(long)] + strict_namespaces: bool, + /// Leave out documentation changes. + #[arg(long)] + no_docs: bool, + /// Titles of the two versions in reports (default: from the schemas' version attributes). + #[arg(long, num_args = 2, value_names = ["OLD", "NEW"])] + titles: Option>, + /// Exit with status 2 when there are breaking changes. + #[arg(long)] + fail_on_breaking: bool, + }, +} + +#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +enum Format { + Json, + Md, + Html, } fn main() -> Result<()> { @@ -83,6 +121,27 @@ fn main() -> Result<()> { compact, pretty, }) => index(&files, &output, compact, pretty), + Some(Command::Diff { + old, + new, + output, + format, + strict_namespaces, + no_docs, + titles, + fail_on_breaking, + }) => { + let opts = schema_explorer_core::DiffOptions { + match_local_names: !strict_namespaces, + docs: !no_docs, + ..Default::default() + }; + let breaking = diff(&old, &new, output.as_deref(), format, &opts, titles)?; + if fail_on_breaking && breaking { + std::process::exit(2); + } + Ok(()) + } None => { println!("schema-explorer {}", schema_explorer_core::version()); Ok(()) @@ -181,3 +240,69 @@ fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Resu ); Ok(()) } + +/// Load one side of a comparison: a bundle (.json) or schema paths. +fn load_side(spec: &str) -> Result { + let paths: Vec = spec + .split(',') + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + if let [p] = paths.as_slice() + && p.is_file() + && p.extension() + .is_some_and(|x| x.eq_ignore_ascii_case("json")) + { + 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); + } + let (b, _) = build(&paths, false)?; + report(&b); + Ok(b) +} + +/// Compare two schema sets; true when there are breaking changes. +fn diff( + old: &str, + new: &str, + output: Option<&Path>, + format: Format, + opts: &schema_explorer_core::DiffOptions, + titles: Option>, +) -> Result { + let start = Instant::now(); + let a = load_side(old)?; + let b = load_side(new)?; + let mut d = schema_explorer_core::diff(&a, &b, opts); + d.generator = format!("schema-explorer {}", env!("CARGO_PKG_VERSION")); + if let Some(t) = titles { + d.old.title = t.first().cloned(); + d.new.title = t.get(1).cloned(); + } + let text = match format { + Format::Json => serde_json::to_string_pretty(&d)?, + Format::Md => schema_explorer_core::report::markdown(&d), + Format::Html => schema_explorer_core::report::html(&d), + }; + match output { + Some(p) => std::fs::write(p, &text).with_context(|| format!("writing {}", p.display()))?, + None => println!("{text}"), + } + let t = &d.summary.total; + eprintln!( + "{} added, {} removed, {} changed ({} breaking, {} documentation only), {} possibly renamed; {} breaking changes in all, in {:.2?}", + t.added, + t.removed, + t.changed, + t.breaking, + t.docs_only, + d.summary.possibly_renamed, + d.summary.breaking_changes, + start.elapsed() + ); + Ok(t.breaking > 0) +} diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs new file mode 100644 index 0000000..58122fe --- /dev/null +++ b/crates/core/src/diff.rs @@ -0,0 +1,2233 @@ +//! Comparison of two versions of a schema set. +//! +//! [`diff`] matches the global components of two bundles (complex and +//! simple types, elements, attributes, model groups and attribute groups) by +//! qualified name and, when a version moved to another target namespace (as +//! railML does with every minor version), by kind and local name. It then +//! compares each matched pair and lists fine-grained changes, each with its +//! impact on instance documents: breaking (documents valid against the old +//! version may be invalid against the new one), non-breaking, or +//! informational (documentation). +//! +//! The result is a [`SchemaDiff`], serialized with serde like the bundle and +//! versioned by [`DIFF_FORMAT`]. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::bundle::*; + +/// Version of the diff format; changes when the JSON changes incompatibly. +pub const DIFF_FORMAT: u32 = 1; + +/// How deep anonymous types nested in anonymous types are compared. +const MAX_DEPTH: u32 = 24; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DiffOptions { + /// Match components that have no counterpart with the same qualified + /// name by kind and local name, when their target namespaces differ + /// (a namespace that changes between versions). + pub match_local_names: bool, + /// Report documentation changes. + pub docs: bool, + /// Pair removed and added components with near-identical content as + /// possible renames. + pub renames: bool, +} + +impl Default for DiffOptions { + fn default() -> Self { + DiffOptions { + match_local_names: true, + docs: true, + renames: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Status { + Added, + Removed, + Changed, +} + +/// The effect of a change on instance documents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Impact { + /// Documents valid against the old version may be invalid against the new one. + Breaking, + /// Documents valid against the old version stay valid. + NonBreaking, + /// No effect on validation (documentation). + Info, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// A complex type became a simple type or the reverse. + KindChanged, + /// Base type of a derivation. + BaseChanged, + /// Extension / restriction, simple / complex content. + DerivationChanged, + AbstractChanged, + FinalChanged, + BlockChanged, + MixedChanged, + NillableChanged, + /// Type of an element or attribute declaration. + TypeChanged, + /// `default` / `fixed` of an element declaration. + ValueConstraintChanged, + /// The head(s) an element substitutes for. + SubstitutionGroupChanged, + SubstitutionMemberAdded, + SubstitutionMemberRemoved, + /// A child particle (element, group reference, wildcard) of a content model. + ChildAdded, + ChildRemoved, + ChildRetyped, + ChildCardinalityChanged, + /// Children of a sequence in another order. + ChildrenReordered, + /// A child moved into another compositor (e.g. from a sequence into a choice). + ChildMoved, + /// The compositors (sequence, choice, all) of a content model. + StructureChanged, + AttributeAdded, + AttributeRemoved, + AttributeRetyped, + AttributeUseChanged, + AttributeDefaultChanged, + AttributeFixedChanged, + AnyAttributeChanged, + EnumerationAdded, + EnumerationRemoved, + FacetAdded, + FacetRemoved, + FacetChanged, + /// Atomic / list / union. + VarietyChanged, + ItemTypeChanged, + MemberTypeAdded, + MemberTypeRemoved, + AssertAdded, + AssertRemoved, + DocsChanged, +} + +/// One change in a component. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + pub impact: Impact, + /// Where in the component: the names of the nested local declarations + /// leading to it, `/`-separated (`@name` for attributes); empty for the + /// component itself. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, + /// What changed: the child, attribute, enumeration value or facet. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new: Option, +} + +/// A component in one of the two bundles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Side { + pub id: Id, + /// `prefix:name` in that bundle. + pub qname: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub line: u32, +} + +fn is_zero(n: &u32) -> bool { + *n == 0 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentDiff { + /// The kind in the new version (old for a removed component). + pub kind: Kind, + pub status: Status, + pub name: String, + /// Target namespace URI (new version's, old for a removed component). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new: Option, + /// Matched by kind and local name only (the namespace differs). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub by_local_name: bool, + /// The worst impact: removed is breaking, added non-breaking. + pub impact: Impact, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub changes: Vec, + /// For a removed or added component that looks like a rename: the + /// qualified name of the other one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub possibly_renamed: Option, +} + +impl ComponentDiff { + pub fn breaking(&self) -> bool { + self.impact == Impact::Breaking + } + /// The qualified name: the new one, or the old one for a removed component. + pub fn qname(&self) -> &str { + self.new + .as_ref() + .or(self.old.as_ref()) + .map_or("", |s| s.qname.as_str()) + } + pub fn file(&self) -> Option<&str> { + self.new + .as_ref() + .or(self.old.as_ref()) + .and_then(|s| s.file.as_deref()) + } +} + +/// A removed and an added component with near-identical content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Rename { + pub kind: Kind, + pub old: Side, + pub new: Side, + /// Similarity of the content, in percent. + pub score: u32, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Counts { + pub added: u32, + pub removed: u32, + pub changed: u32, + /// Added, removed or changed components with a breaking change. + pub breaking: u32, + /// Changed components whose only changes are documentation. + pub docs_only: u32, +} + +impl Counts { + fn add(&mut self, d: &ComponentDiff) { + match d.status { + Status::Added => self.added += 1, + Status::Removed => self.removed += 1, + Status::Changed => self.changed += 1, + } + if d.breaking() { + self.breaking += 1; + } + if d.status == Status::Changed && d.impact == Impact::Info { + self.docs_only += 1; + } + } + pub fn total(&self) -> u32 { + self.added + self.removed + self.changed + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Summary { + pub total: Counts, + pub by_kind: BTreeMap, + /// By target namespace URI (empty string: no namespace); removed + /// components count under the new URI of a namespace that changed. + pub by_namespace: BTreeMap, + pub by_file: BTreeMap, + /// Number of changes of each kind. + pub changes: BTreeMap, + pub breaking_changes: u32, + pub possibly_renamed: u32, +} + +/// What was compared on one side. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + pub files: u32, + /// Target namespaces of the files. + pub namespaces: Vec, + /// The `version` attribute of the schema file with the most components. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// The `version` attributes of the schema files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub versions: Vec, + /// Global components compared. + pub components: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaDiff { + pub format: u32, + pub generator: String, + pub options: DiffOptions, + pub old: SetInfo, + pub new: SetInfo, + /// Namespaces that changed between the versions, old URI to new URI. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub namespace_map: BTreeMap, + pub summary: Summary, + /// Added, removed and changed global components (unchanged ones are left out). + pub components: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub renames: Vec, +} + +/// Compare two bundles: `old` is the earlier version. Compact bundles are +/// fine (the effective content models are not used). +pub fn diff(old: &Bundle, new: &Bundle, opts: &DiffOptions) -> SchemaDiff { + let mut cx = Cx { + a: old, + b: new, + fwd: HashMap::new(), + ns_map: HashMap::new(), + opts, + }; + let ga = globals(old); + let gb = globals(new); + let pairs = cx.match_globals(&ga, &gb); + let matched_b: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = Vec::new(); + for &(o, n, by_local) in &pairs { + let changes = cx.compare(o, n); + if changes.is_empty() { + continue; + } + let impact = changes.iter().map(|c| c.impact).min().unwrap(); + let c = &new.components[n as usize]; + out.push(ComponentDiff { + kind: c.kind, + status: Status::Changed, + name: c.name.clone(), + namespace: ns_uri(new, c.ns).map(str::to_string), + old: Some(side(old, o)), + new: Some(side(new, n)), + by_local_name: by_local, + impact, + changes, + possibly_renamed: None, + }); + } + let mut removed = Vec::new(); + for &o in &ga { + if !cx.fwd.contains_key(&o) { + removed.push(o); + let c = &old.components[o as usize]; + out.push(ComponentDiff { + kind: c.kind, + status: Status::Removed, + name: c.name.clone(), + namespace: ns_uri(old, c.ns).map(str::to_string), + old: Some(side(old, o)), + new: None, + by_local_name: false, + impact: Impact::Breaking, + changes: Vec::new(), + possibly_renamed: None, + }); + } + } + let mut added = Vec::new(); + for &n in &gb { + if !matched_b.contains(&n) { + added.push(n); + let c = &new.components[n as usize]; + out.push(ComponentDiff { + kind: c.kind, + status: Status::Added, + name: c.name.clone(), + namespace: ns_uri(new, c.ns).map(str::to_string), + old: None, + new: Some(side(new, n)), + by_local_name: false, + impact: Impact::NonBreaking, + changes: Vec::new(), + possibly_renamed: None, + }); + } + } + let renames = if opts.renames { + cx.renames(&removed, &added) + } else { + Vec::new() + }; + let old_names: HashMap = renames + .iter() + .map(|r| (r.old.id, r.new.qname.as_str())) + .collect(); + let new_names: HashMap = renames + .iter() + .map(|r| (r.new.id, r.old.qname.as_str())) + .collect(); + for d in &mut out { + let found = match d.status { + Status::Removed => old_names.get(&d.old.as_ref().unwrap().id), + Status::Added => new_names.get(&d.new.as_ref().unwrap().id), + Status::Changed => None, + }; + d.possibly_renamed = found.map(|s| s.to_string()); + } + out.sort_by(|x, y| { + (x.qname().to_lowercase(), x.kind, x.status).cmp(&( + y.qname().to_lowercase(), + y.kind, + y.status, + )) + }); + // A reference to a type that was renamed does not change documents. + let pairs: HashSet<(&str, &str)> = renames + .iter() + .map(|r| (r.old.qname.as_str(), r.new.qname.as_str())) + .collect(); + for d in &mut out { + let mut any = false; + for c in &mut d.changes { + if matches!( + c.kind, + ChangeKind::TypeChanged + | ChangeKind::ChildRetyped + | ChangeKind::AttributeRetyped + | ChangeKind::BaseChanged + | ChangeKind::ItemTypeChanged + ) && let (Some(a), Some(b)) = (&c.old, &c.new) + && pairs.contains(&(a.as_str(), b.as_str())) + { + c.impact = Impact::NonBreaking; + any = true; + } + } + if any { + d.impact = d.changes.iter().map(|c| c.impact).min().unwrap(); + } + } + let mut summary = Summary::default(); + for d in &out { + summary.total.add(d); + summary.by_kind.entry(d.kind).or_default().add(d); + summary + .by_namespace + .entry( + d.namespace + .as_ref() + .map(|u| cx.ns_map.get(u).unwrap_or(u).clone()) + .unwrap_or_default(), + ) + .or_default() + .add(d); + summary + .by_file + .entry(d.file().unwrap_or("").to_string()) + .or_default() + .add(d); + for c in &d.changes { + *summary.changes.entry(c.kind).or_default() += 1; + if c.impact == Impact::Breaking { + summary.breaking_changes += 1; + } + } + if d.status == Status::Removed { + summary.breaking_changes += 1; + } + } + summary.possibly_renamed = renames.len() as u32; + let namespace_map = cx + .ns_map + .iter() + .filter(|(k, v)| k != v) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + SchemaDiff { + format: DIFF_FORMAT, + generator: format!("schema-explorer-core {}", crate::version()), + options: opts.clone(), + old: set_info(old, ga.len()), + new: set_info(new, gb.len()), + namespace_map, + summary, + components: out, + renames, + } +} + +const COMPARED: [Kind; 6] = [ + Kind::ComplexType, + Kind::SimpleType, + Kind::Element, + Kind::Attribute, + Kind::Group, + Kind::AttributeGroup, +]; + +fn globals(b: &Bundle) -> Vec { + b.components + .iter() + .enumerate() + .filter(|(_, c)| { + c.parent.is_none() && !c.builtin && c.file.is_some() && COMPARED.contains(&c.kind) + }) + .map(|(i, _)| i as Id) + .collect() +} + +fn ns_uri(b: &Bundle, ns: Option) -> Option<&str> { + ns.map(|n| b.namespaces[n as usize].uri.as_str()) +} + +fn side(b: &Bundle, id: Id) -> Side { + let c = &b.components[id as usize]; + Side { + id, + qname: b.qname(id), + file: c.file.map(|f| b.files[f as usize].name.clone()), + line: c.line, + } +} + +fn set_info(b: &Bundle, components: usize) -> SetInfo { + let mut namespaces = BTreeSet::new(); + let mut versions = BTreeSet::new(); + let version = b + .files + .iter() + .filter(|f| f.version.is_some()) + .max_by_key(|f| f.components) + .and_then(|f| f.version.clone()); + for f in &b.files { + if let Some(ns) = &f.target_namespace { + namespaces.insert(ns.clone()); + } + if let Some(v) = &f.version { + versions.insert(v.clone()); + } + } + SetInfo { + title: None, + version, + files: b.files.len() as u32, + namespaces: namespaces.into_iter().collect(), + versions: versions.into_iter().collect(), + components: components as u32, + } +} + +/// Types share one symbol space. +fn space(k: Kind) -> u8 { + match k { + Kind::ComplexType | Kind::SimpleType => 0, + Kind::Element => 1, + Kind::Attribute => 2, + Kind::Group => 3, + Kind::AttributeGroup => 4, + _ => 5, + } +} + +fn docs_text(docs: &[Doc]) -> String { + docs.iter() + .map(|d| d.text.trim()) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n\n") +} + +fn normalize(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +fn occurs_str(o: Occurs) -> String { + match o.max { + Some(m) => format!("{}..{m}", o.min), + None => format!("{}..*", o.min), + } +} + +/// The occurrence range of a particle inside a compositor that occurs `outer` times. +fn times(outer: Occurs, inner: Occurs) -> Occurs { + Occurs { + min: outer.min.saturating_mul(inner.min), + max: match (outer.max, inner.max) { + (Some(a), Some(b)) => Some(a.saturating_mul(b)), + _ => None, + }, + } +} + +/// True when `new` admits fewer occurrences than `old`. +fn tighter(old: Occurs, new: Occurs) -> bool { + let max_lower = match (old.max, new.max) { + (None, Some(_)) => true, + (Some(a), Some(b)) => b < a, + _ => false, + }; + new.min > old.min || max_lower +} + +struct Cx<'a> { + a: &'a Bundle, + b: &'a Bundle, + /// Matched global components, old id to new id. + fwd: HashMap, + /// Old namespace URI to new namespace URI. + ns_map: HashMap, + opts: &'a DiffOptions, +} + +/// One child particle of a flattened content model. +struct Slot { + key: String, + label: String, + el: Option, + /// In effect: multiplied by the compositors around it. + occurs: Occurs, + /// Optional in effect: itself, a compositor around it, or a choice. + optional: bool, + /// The compositors from the top, e.g. `sequence/choice`. + cpath: String, +} + +#[derive(Clone)] +struct AttrSlot { + label: String, + decl: Option, + group: bool, + r#use: AttrUse, + default: Option, + fixed: Option, +} + +impl<'a> Cx<'a> { + fn bundle(&self, old: bool) -> &'a Bundle { + if old { self.a } else { self.b } + } + + fn comp(&self, old: bool, id: Id) -> &'a Component { + &self.bundle(old).components[id as usize] + } + + /// The namespace URI as the new version names it. + fn uri(&self, old: bool, ns: Option) -> String { + let u = ns_uri(self.bundle(old), ns).unwrap_or(""); + if old { + self.ns_map.get(u).cloned().unwrap_or_else(|| u.to_string()) + } else { + u.to_string() + } + } + + fn match_globals(&mut self, ga: &[Id], gb: &[Id]) -> Vec<(Id, Id, bool)> { + let mut by_qname: HashMap<(u8, &str, &str), Id> = HashMap::new(); + for &n in gb { + let c = self.comp(false, n); + by_qname.insert( + (space(c.kind), ns_uri(self.b, c.ns).unwrap_or(""), &c.name), + n, + ); + } + let mut pairs = Vec::new(); + let mut used: HashSet = HashSet::new(); + for &o in ga { + let c = self.comp(true, o); + let k = ( + space(c.kind), + ns_uri(self.a, c.ns).unwrap_or(""), + c.name.as_str(), + ); + if let Some(&n) = by_qname.get(&k) + && used.insert(n) + { + pairs.push((o, n, false)); + } + } + for &(o, n, _) in &pairs { + self.fwd.insert(o, n); + } + if self.opts.match_local_names { + // Unique local names among the unmatched components on both sides. + let mut left: HashMap<(u8, &str), Vec> = HashMap::new(); + for &o in ga { + if !self.fwd.contains_key(&o) { + let c = self.comp(true, o); + left.entry((space(c.kind), &c.name)).or_default().push(o); + } + } + let mut right: HashMap<(u8, &str), Vec> = HashMap::new(); + for &n in gb { + if !used.contains(&n) { + let c = self.comp(false, n); + right.entry((space(c.kind), &c.name)).or_default().push(n); + } + } + let mut extra = Vec::new(); + for (k, os) in &left { + if let (Some(ns), [o]) = (right.get(k), os.as_slice()) + && let [n] = ns.as_slice() + && self + .comp(true, *o) + .ns + .map(|x| &self.a.namespaces[x as usize].uri) + != self + .comp(false, *n) + .ns + .map(|x| &self.b.namespaces[x as usize].uri) + { + extra.push((*o, *n, true)); + } + } + extra.sort(); + // The namespace map: the most frequent counterpart of each old namespace. + let mut votes: HashMap<(String, String), u32> = HashMap::new(); + for &(o, n, _) in &extra { + let ou = ns_uri(self.a, self.comp(true, o).ns) + .unwrap_or("") + .to_string(); + let nu = ns_uri(self.b, self.comp(false, n).ns) + .unwrap_or("") + .to_string(); + *votes.entry((ou, nu)).or_default() += 1; + } + let mut best: HashMap = HashMap::new(); + for ((ou, nu), v) in votes { + let e = best.entry(ou).or_insert((0, String::new())); + if v > e.0 || (v == e.0 && nu < e.1) { + *e = (v, nu); + } + } + self.ns_map = best.into_iter().map(|(k, (_, v))| (k, v)).collect(); + for &(o, n, _) in &extra { + self.fwd.insert(o, n); + } + pairs.extend(extra); + } + pairs + } + + /// A key for a reference, the same on both sides when it names the same + /// (matched) component. Anonymous types give `None`. + fn ref_key(&self, old: bool, r: &Ref) -> Option { + match r { + Ref::Id(id) => { + let c = self.comp(old, *id); + if c.parent.is_some() { + return None; + } + if old && let Some(&n) = self.fwd.get(id) { + return self.ref_key(false, &Ref::Id(n)); + } + Some(format!("{{{}}}{}", self.uri(old, c.ns), c.name)) + } + Ref::Missing(s) => { + if old + && let Some(rest) = s.strip_prefix('{') + && let Some((u, local)) = rest.split_once('}') + && let Some(m) = self.ns_map.get(u) + { + return Some(format!("{{{m}}}{local}")); + } + Some(s.clone()) + } + } + } + + fn label(&self, old: bool, r: &Ref) -> String { + match r { + Ref::Id(id) => { + let c = self.comp(old, *id); + if c.parent.is_some() && c.kind.is_type() { + "(anonymous type)".to_string() + } else { + self.bundle(old).qname(*id) + } + } + Ref::Missing(s) => s.clone(), + } + } + + /// The key of an element or attribute declaration (local or global) by + /// its name and namespace. + fn decl_key(&self, old: bool, id: Id) -> String { + let c = self.comp(old, id); + if c.parent.is_none() { + return self.ref_key(old, &Ref::Id(id)).unwrap_or_default(); + } + format!("{{{}}}{}", self.uri(old, c.ns), c.name) + } + + fn decl_label(&self, old: bool, id: Id) -> String { + let c = self.comp(old, id); + if c.parent.is_none() { + self.bundle(old).qname(id) + } else { + c.name.clone() + } + } + + fn compare(&self, o: Id, n: Id) -> Vec { + let mut out = Vec::new(); + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + self.docs(&co.docs, &cn.docs, "", "", &mut out); + match cn.kind { + Kind::ComplexType | Kind::SimpleType => self.types(o, n, "", 0, &mut out), + Kind::Element => self.element(o, n, "", 0, &mut out), + Kind::Attribute => self.attribute(o, n, "", 0, &mut out), + Kind::Group => self.particles(co.group.as_ref(), cn.group.as_ref(), "", 0, &mut out), + Kind::AttributeGroup => { + let (x, y) = ( + co.attribute_group.clone().unwrap_or_default(), + cn.attribute_group.clone().unwrap_or_default(), + ); + self.attrs( + (&x.attributes, &x.any_attribute), + (&y.attributes, &y.any_attribute), + "", + 0, + &mut out, + ); + } + _ => {} + } + out + } + + fn docs(&self, o: &[Doc], n: &[Doc], path: &str, subject: &str, out: &mut Vec) { + if !self.opts.docs { + return; + } + let (x, y) = (docs_text(o), docs_text(n)); + if normalize(&x) != normalize(&y) { + out.push(Change { + kind: ChangeKind::DocsChanged, + impact: Impact::Info, + path: path.to_string(), + subject: subject.to_string(), + old: (!x.is_empty()).then_some(x), + new: (!y.is_empty()).then_some(y), + }); + } + } + + #[allow(clippy::too_many_arguments)] + fn push( + &self, + out: &mut Vec, + kind: ChangeKind, + impact: Impact, + path: &str, + subject: &str, + old: Option, + new: Option, + ) { + out.push(Change { + kind, + impact, + path: path.to_string(), + subject: subject.to_string(), + old, + new, + }); + } + + /// Compare type references; anonymous types on both sides are compared + /// in depth under `path`. + #[allow(clippy::too_many_arguments)] + fn type_ref( + &self, + o: Option<&Ref>, + n: Option<&Ref>, + kind: ChangeKind, + path: &str, + subject: &str, + depth: u32, + out: &mut Vec, + ) { + let any = Ref::Missing("{http://www.w3.org/2001/XMLSchema}anyType".into()); + let (o, n) = (o.unwrap_or(&any), n.unwrap_or(&any)); + let (ko, kn) = (self.ref_key(true, o), self.ref_key(false, n)); + match (ko, kn) { + (None, None) => { + if let (Some(a), Some(b)) = (o.id(), n.id()) { + let sub = if subject.is_empty() { + path.to_string() + } else if path.is_empty() { + subject.to_string() + } else { + format!("{path}/{subject}") + }; + self.types(a, b, &sub, depth + 1, out); + } + } + (x, y) if x == y => {} + _ => self.push( + out, + kind, + Impact::Breaking, + path, + subject, + Some(self.label(true, o)), + Some(self.label(false, n)), + ), + } + } + + fn types(&self, o: Id, n: Id, path: &str, depth: u32, out: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + if depth > 0 { + self.docs(&co.docs, &cn.docs, path, "", out); + } + match ( + &co.complex_type, + &cn.complex_type, + &co.simple_type, + &cn.simple_type, + ) { + (Some(x), Some(y), _, _) => self.complex(x, y, path, depth, out), + (_, _, Some(x), Some(y)) => self.simple(x, y, path, depth, out), + _ => self.push( + out, + ChangeKind::KindChanged, + Impact::Breaking, + path, + "", + Some(kind_name(co.kind).into()), + Some(kind_name(cn.kind).into()), + ), + } + } + + fn complex( + &self, + x: &ComplexInfo, + y: &ComplexInfo, + path: &str, + depth: u32, + out: &mut Vec, + ) { + match (&x.derivation, &y.derivation) { + (None, None) => {} + (Some(a), Some(b)) => { + if (a.method, a.simple) != (b.method, b.simple) { + self.push( + out, + ChangeKind::DerivationChanged, + Impact::Breaking, + path, + "", + Some(derivation_name(a)), + Some(derivation_name(b)), + ); + } + if self.ref_key(true, &a.base) != self.ref_key(false, &b.base) { + self.push( + out, + ChangeKind::BaseChanged, + Impact::Breaking, + path, + "", + Some(self.label(true, &a.base)), + Some(self.label(false, &b.base)), + ); + } + } + (a, b) => self.push( + out, + ChangeKind::BaseChanged, + Impact::Breaking, + path, + "", + a.as_ref().map(|d| self.label(true, &d.base)), + b.as_ref().map(|d| self.label(false, &d.base)), + ), + } + self.flag( + out, + ChangeKind::AbstractChanged, + path, + x.r#abstract, + y.r#abstract, + true, + ); + self.flag(out, ChangeKind::MixedChanged, path, x.mixed, y.mixed, false); + self.final_block(out, ChangeKind::FinalChanged, path, &x.r#final, &y.r#final); + self.final_block(out, ChangeKind::BlockChanged, path, &x.block, &y.block); + self.particles(x.content.as_ref(), y.content.as_ref(), path, depth, out); + self.attrs( + (&x.attributes, &x.any_attribute), + (&y.attributes, &y.any_attribute), + path, + depth, + out, + ); + self.facets(&x.facets, &y.facets, path, out); + self.enums(&x.enumerations, &y.enumerations, path, out); + for a in x.asserts.iter().filter(|a| !y.asserts.contains(a)) { + self.push( + out, + ChangeKind::AssertRemoved, + Impact::NonBreaking, + path, + a, + None, + None, + ); + } + for a in y.asserts.iter().filter(|a| !x.asserts.contains(a)) { + self.push( + out, + ChangeKind::AssertAdded, + Impact::Breaking, + path, + a, + None, + None, + ); + } + } + + /// A boolean flag; `breaking_on` is the new value that breaks documents. + fn flag( + &self, + out: &mut Vec, + kind: ChangeKind, + path: &str, + x: bool, + y: bool, + breaking_on: bool, + ) { + if x != y { + let impact = if y == breaking_on { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + kind, + impact, + path, + "", + Some(x.to_string()), + Some(y.to_string()), + ); + } + } + + fn final_block( + &self, + out: &mut Vec, + kind: ChangeKind, + path: &str, + x: &Option, + y: &Option, + ) { + if x != y { + self.push( + out, + kind, + Impact::NonBreaking, + path, + "", + x.clone(), + y.clone(), + ); + } + } + + fn simple( + &self, + x: &SimpleInfo, + y: &SimpleInfo, + path: &str, + depth: u32, + out: &mut Vec, + ) { + if x.variety != y.variety { + self.push( + out, + ChangeKind::VarietyChanged, + Impact::Breaking, + path, + "", + Some(format!("{:?}", x.variety).to_lowercase()), + Some(format!("{:?}", y.variety).to_lowercase()), + ); + return; + } + if x.base.is_some() || y.base.is_some() { + self.type_ref( + x.base.as_ref(), + y.base.as_ref(), + ChangeKind::BaseChanged, + path, + "", + depth, + out, + ); + } + if x.item.is_some() || y.item.is_some() { + self.type_ref( + x.item.as_ref(), + y.item.as_ref(), + ChangeKind::ItemTypeChanged, + path, + "", + depth, + out, + ); + } + let ko: Vec<(Option, &Ref)> = x + .members + .iter() + .map(|r| (self.ref_key(true, r), r)) + .collect(); + let kn: Vec<(Option, &Ref)> = y + .members + .iter() + .map(|r| (self.ref_key(false, r), r)) + .collect(); + for (k, r) in &ko { + if k.is_some() && !kn.iter().any(|(j, _)| j == k) { + self.push( + out, + ChangeKind::MemberTypeRemoved, + Impact::Breaking, + path, + &self.label(true, r), + None, + None, + ); + } + } + for (k, r) in &kn { + if k.is_some() && !ko.iter().any(|(j, _)| j == k) { + self.push( + out, + ChangeKind::MemberTypeAdded, + Impact::NonBreaking, + path, + &self.label(false, r), + None, + None, + ); + } + } + let anon_o: Vec = ko + .iter() + .filter(|(k, _)| k.is_none()) + .filter_map(|(_, r)| r.id()) + .collect(); + let anon_n: Vec = kn + .iter() + .filter(|(k, _)| k.is_none()) + .filter_map(|(_, r)| r.id()) + .collect(); + for (i, (a, b)) in anon_o.iter().zip(&anon_n).enumerate() { + self.types( + *a, + *b, + &join(path, &format!("member {}", i + 1)), + depth + 1, + out, + ); + } + self.facets(&x.facets, &y.facets, path, out); + self.enums(&x.enumerations, &y.enumerations, path, out); + self.final_block(out, ChangeKind::FinalChanged, path, &x.r#final, &y.r#final); + } + + fn enums(&self, x: &[Enumeration], y: &[Enumeration], path: &str, out: &mut Vec) { + let nv: HashMap<&str, &Enumeration> = y.iter().map(|e| (e.value.as_str(), e)).collect(); + let ov: HashMap<&str, &Enumeration> = x.iter().map(|e| (e.value.as_str(), e)).collect(); + for e in x { + match nv.get(e.value.as_str()) { + None => self.push( + out, + ChangeKind::EnumerationRemoved, + Impact::Breaking, + path, + &e.value, + None, + None, + ), + Some(f) => self.docs(&e.docs, &f.docs, path, &e.value, out), + } + } + for e in y { + if !ov.contains_key(e.value.as_str()) { + // Adding values to an unrestricted type would restrict it. + let impact = if x.is_empty() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::EnumerationAdded, + impact, + path, + &e.value, + None, + None, + ); + } + } + } + + fn facets(&self, x: &[Facet], y: &[Facet], path: &str, out: &mut Vec) { + let group = |fs: &[Facet]| { + let mut m: BTreeMap> = BTreeMap::new(); + for f in fs { + m.entry(f.kind.clone()).or_default().push(f.value.clone()); + } + m + }; + let (gx, gy) = (group(x), group(y)); + let kinds: BTreeSet<&String> = gx.keys().chain(gy.keys()).collect(); + for k in kinds { + match (gx.get(k), gy.get(k)) { + (Some(a), None) => self.push( + out, + ChangeKind::FacetRemoved, + Impact::NonBreaking, + path, + k, + Some(a.join(" | ")), + None, + ), + (None, Some(b)) => self.push( + out, + ChangeKind::FacetAdded, + Impact::Breaking, + path, + k, + None, + Some(b.join(" | ")), + ), + (Some(a), Some(b)) if a != b => { + let impact = facet_impact(k, a, b); + self.push( + out, + ChangeKind::FacetChanged, + impact, + path, + k, + Some(a.join(" | ")), + Some(b.join(" | ")), + ); + } + _ => {} + } + } + } + + fn element(&self, o: Id, n: Id, path: &str, depth: u32, out: &mut Vec) { + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + let (Some(x), Some(y)) = (&co.element, &cn.element) else { + return; + }; + self.type_ref( + x.r#type.as_ref(), + y.r#type.as_ref(), + ChangeKind::TypeChanged, + path, + "", + depth, + out, + ); + self.flag( + out, + ChangeKind::AbstractChanged, + path, + x.r#abstract, + y.r#abstract, + true, + ); + self.flag( + out, + ChangeKind::NillableChanged, + path, + x.nillable, + y.nillable, + false, + ); + if x.default != y.default { + self.push( + out, + ChangeKind::ValueConstraintChanged, + Impact::NonBreaking, + path, + "default", + x.default.clone(), + y.default.clone(), + ); + } + if x.fixed != y.fixed { + let impact = if y.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::ValueConstraintChanged, + impact, + path, + "fixed", + x.fixed.clone(), + y.fixed.clone(), + ); + } + self.final_block(out, ChangeKind::FinalChanged, path, &x.r#final, &y.r#final); + self.final_block(out, ChangeKind::BlockChanged, path, &x.block, &y.block); + if co.parent.is_some() { + return; + } + // Substitution groups. + let heads = |old: bool, e: &ElementInfo| -> BTreeMap { + e.substitution_group + .iter() + .map(|r| (self.ref_key(old, r).unwrap_or_default(), self.label(old, r))) + .collect() + }; + let (ho, hn) = (heads(true, x), heads(false, y)); + if ho.keys().ne(hn.keys()) { + let lost = ho.keys().any(|k| !hn.contains_key(k)); + let impact = if lost { + Impact::Breaking + } else { + Impact::NonBreaking + }; + let j = |m: &BTreeMap| { + (!m.is_empty()).then(|| m.values().cloned().collect::>().join(", ")) + }; + self.push( + out, + ChangeKind::SubstitutionGroupChanged, + impact, + path, + "", + j(&ho), + j(&hn), + ); + } + let members = |old: bool, e: &ElementInfo| -> BTreeMap { + e.members + .iter() + .map(|&m| (self.decl_key(old, m), self.bundle(old).qname(m))) + .collect() + }; + let (mo, mn) = (members(true, x), members(false, y)); + for (k, l) in &mo { + if !mn.contains_key(k) { + self.push( + out, + ChangeKind::SubstitutionMemberRemoved, + Impact::Breaking, + path, + l, + None, + None, + ); + } + } + for (k, l) in &mn { + if !mo.contains_key(k) { + self.push( + out, + ChangeKind::SubstitutionMemberAdded, + Impact::NonBreaking, + path, + l, + None, + None, + ); + } + } + } + + fn attribute(&self, o: Id, n: Id, path: &str, depth: u32, out: &mut Vec) { + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + let x = co.attribute.clone().unwrap_or_default(); + let y = cn.attribute.clone().unwrap_or_default(); + self.type_ref( + x.r#type.as_ref(), + y.r#type.as_ref(), + ChangeKind::TypeChanged, + path, + "", + depth, + out, + ); + if x.default != y.default { + self.push( + out, + ChangeKind::AttributeDefaultChanged, + Impact::NonBreaking, + path, + "", + x.default, + y.default, + ); + } + if x.fixed != y.fixed { + let impact = if y.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::AttributeFixedChanged, + impact, + path, + "", + x.fixed, + y.fixed, + ); + } + } + + fn flatten( + &self, + old: bool, + p: &Particle, + cpath: &str, + mul: Occurs, + out: &mut Vec, + seen: &mut HashMap, + ) { + let mut slot = |key: String, label: String, el: Option, occurs: Occurs| { + let n = seen.entry(key.clone()).or_default(); + *n += 1; + let key = if *n > 1 { format!("{key}#{n}") } else { key }; + out.push(Slot { + key, + label, + el, + occurs: times(mul, occurs), + optional: mul.min == 0 || occurs.min == 0, + cpath: cpath.to_string(), + }); + }; + match p { + Particle::Element { el, occurs } => { + let (key, label, id) = match el { + Ref::Id(id) => ( + self.decl_key(old, *id), + self.decl_label(old, *id), + Some(*id), + ), + Ref::Missing(_) => ( + self.ref_key(old, el).unwrap_or_default(), + self.label(old, el), + None, + ), + }; + slot(key, label, id, *occurs); + } + Particle::Group { group, occurs } => { + let key = format!("group {}", self.ref_key(old, group).unwrap_or_default()); + slot( + key, + format!("group {}", self.label(old, group)), + None, + *occurs, + ); + } + Particle::Any { wildcard, occurs } => { + let ns = wildcard.namespace.clone().unwrap_or_else(|| "##any".into()); + slot(format!("any {ns}"), format!("any ({ns})"), None, *occurs); + } + Particle::Sequence { items, occurs } + | Particle::Choice { items, occurs } + | Particle::All { items, occurs } => { + let name = compositor(p); + let sub = if cpath.is_empty() { + name.to_string() + } else { + format!("{cpath}/{name}") + }; + let mut m = times(mul, *occurs); + if matches!(p, Particle::Choice { .. }) && items.len() > 1 { + m.min = 0; + } + for i in items { + self.flatten(old, i, &sub, m, out, seen); + } + } + } + } + + fn particles( + &self, + x: Option<&Particle>, + y: Option<&Particle>, + path: &str, + depth: u32, + out: &mut Vec, + ) { + let mut so = Vec::new(); + let mut sn = Vec::new(); + if let Some(p) = x { + self.flatten(true, p, "", Occurs::default(), &mut so, &mut HashMap::new()); + } + if let Some(p) = y { + self.flatten( + false, + p, + "", + Occurs::default(), + &mut sn, + &mut HashMap::new(), + ); + } + let (sko, skn) = ( + x.map(skeleton).unwrap_or_default(), + y.map(skeleton).unwrap_or_default(), + ); + let mn: HashMap<&str, &Slot> = sn.iter().map(|s| (s.key.as_str(), s)).collect(); + let mo: HashMap<&str, &Slot> = so.iter().map(|s| (s.key.as_str(), s)).collect(); + let mut moved = false; + let mut items = Vec::new(); + for s in &so { + let Some(t) = mn.get(s.key.as_str()) else { + items.push(Change { + kind: ChangeKind::ChildRemoved, + impact: Impact::Breaking, + path: path.to_string(), + subject: s.label.clone(), + old: Some(occurs_str(s.occurs)), + new: None, + }); + continue; + }; + if s.cpath != t.cpath { + moved = true; + self.push( + &mut items, + ChangeKind::ChildMoved, + Impact::Breaking, + path, + &t.label, + Some(s.cpath.clone()), + Some(t.cpath.clone()), + ); + } + if s.occurs != t.occurs { + let impact = if tighter(s.occurs, t.occurs) { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + &mut items, + ChangeKind::ChildCardinalityChanged, + impact, + path, + &t.label, + Some(occurs_str(s.occurs)), + Some(occurs_str(t.occurs)), + ); + } + if let (Some(a), Some(b)) = (s.el, t.el) { + self.child(a, b, path, &t.label, depth, &mut items); + } + } + for t in &sn { + if !mo.contains_key(t.key.as_str()) { + let impact = if t.optional { + Impact::NonBreaking + } else { + Impact::Breaking + }; + items.push(Change { + kind: ChangeKind::ChildAdded, + impact, + path: path.to_string(), + subject: t.label.clone(), + old: None, + new: Some(occurs_str(t.occurs)), + }); + } + } + // Order within each sequence. + let mut seqs: BTreeMap<&str, (Vec<&str>, Vec<&str>)> = BTreeMap::new(); + for s in &so { + if let Some(t) = mn.get(s.key.as_str()) + && t.cpath == s.cpath + && s.cpath.ends_with("sequence") + { + seqs.entry(&s.cpath).or_default().0.push(&s.key); + } + } + for t in &sn { + if let Some(s) = mo.get(t.key.as_str()) + && t.cpath == s.cpath + && t.cpath.ends_with("sequence") + { + seqs.entry(&t.cpath).or_default().1.push(&t.key); + } + } + for (a, b) in seqs.values() { + if a != b { + let keep = lcs(a, b); + let label = |k: &str| mn[k].label.clone(); + let off: Vec = b + .iter() + .filter(|k| !keep.contains(*k)) + .map(|k| label(k)) + .collect(); + let lo: Vec = a.iter().map(|k| label(k)).collect(); + let ln: Vec = b.iter().map(|k| label(k)).collect(); + self.push( + &mut items, + ChangeKind::ChildrenReordered, + Impact::Breaking, + path, + &off.join(", "), + Some(lo.join(", ")), + Some(ln.join(", ")), + ); + } + } + if sko != skn { + let impact = if moved + || top_compositor(&sko) != top_compositor(&skn) && !so.is_empty() && !sn.is_empty() + { + Impact::Breaking + } else { + Impact::NonBreaking + }; + out.push(Change { + kind: ChangeKind::StructureChanged, + impact, + path: path.to_string(), + subject: String::new(), + old: (!sko.is_empty()).then_some(sko), + new: (!skn.is_empty()).then_some(skn), + }); + } + out.extend(items); + } + + /// Compare the declarations behind a child present in both versions. + fn child(&self, a: Id, b: Id, path: &str, label: &str, depth: u32, out: &mut Vec) { + let (ca, cb) = (self.comp(true, a), self.comp(false, b)); + // A reference to a global element on both sides: compared on its own. + if ca.parent.is_none() && cb.parent.is_none() { + return; + } + let sub = join(path, label); + if ca.parent.is_some() && cb.parent.is_some() { + self.docs(&ca.docs, &cb.docs, path, label, out); + } + let (Some(x), Some(y)) = (&ca.element, &cb.element) else { + return; + }; + self.type_ref( + x.r#type.as_ref(), + y.r#type.as_ref(), + ChangeKind::ChildRetyped, + path, + label, + depth, + out, + ); + self.flag( + out, + ChangeKind::NillableChanged, + &sub, + x.nillable, + y.nillable, + false, + ); + if x.fixed != y.fixed { + let impact = if y.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::ValueConstraintChanged, + impact, + &sub, + "fixed", + x.fixed.clone(), + y.fixed.clone(), + ); + } + if x.default != y.default { + self.push( + out, + ChangeKind::ValueConstraintChanged, + Impact::NonBreaking, + &sub, + "default", + x.default.clone(), + y.default.clone(), + ); + } + } + + fn attr_slots(&self, old: bool, items: &[AttrItem]) -> Vec<(String, AttrSlot)> { + items + .iter() + .map(|i| match i { + AttrItem::Attribute { + attr, + r#use, + default, + fixed, + } => { + let (key, label, decl) = match attr { + Ref::Id(id) => ( + format!("@{}", self.decl_key(old, *id)), + format!("@{}", self.decl_label(old, *id)), + Some(*id), + ), + Ref::Missing(_) => ( + format!("@{}", self.ref_key(old, attr).unwrap_or_default()), + format!("@{}", self.label(old, attr)), + None, + ), + }; + let dd = decl.and_then(|d| self.comp(old, d).attribute.as_ref()); + ( + key, + AttrSlot { + label, + decl, + group: false, + r#use: *r#use, + default: default + .clone() + .or_else(|| dd.and_then(|a| a.default.clone())), + fixed: fixed.clone().or_else(|| dd.and_then(|a| a.fixed.clone())), + }, + ) + } + AttrItem::Group { group } => ( + format!("group {}", self.ref_key(old, group).unwrap_or_default()), + AttrSlot { + label: format!("attributeGroup {}", self.label(old, group)), + decl: group.id(), + group: true, + r#use: AttrUse::Optional, + default: None, + fixed: None, + }, + ), + }) + .collect() + } + + /// Whether an attribute group (new version) has a required attribute. + fn group_required(&self, id: Id) -> bool { + self.comp(false, id) + .attribute_group + .as_ref() + .is_some_and(|g| { + g.attributes.iter().any(|a| { + matches!( + a, + AttrItem::Attribute { + r#use: AttrUse::Required, + .. + } + ) + }) + }) + } + + #[allow(clippy::type_complexity)] + fn attrs( + &self, + x: (&[AttrItem], &Option), + y: (&[AttrItem], &Option), + path: &str, + depth: u32, + out: &mut Vec, + ) { + let so = self.attr_slots(true, x.0); + let sn = self.attr_slots(false, y.0); + let mn: HashMap<&str, &AttrSlot> = sn.iter().map(|(k, s)| (k.as_str(), s)).collect(); + let mo: HashMap<&str, &AttrSlot> = so.iter().map(|(k, s)| (k.as_str(), s)).collect(); + for (k, s) in &so { + let Some(t) = mn.get(k.as_str()) else { + self.push( + out, + ChangeKind::AttributeRemoved, + Impact::Breaking, + path, + &s.label, + Some(use_name(s.r#use).into()), + None, + ); + continue; + }; + if s.group { + continue; + } + let label = &t.label; + if s.r#use != t.r#use { + let impact = if t.r#use == AttrUse::Optional { + Impact::NonBreaking + } else { + Impact::Breaking + }; + self.push( + out, + ChangeKind::AttributeUseChanged, + impact, + path, + label, + Some(use_name(s.r#use).into()), + Some(use_name(t.r#use).into()), + ); + } + if s.default != t.default { + self.push( + out, + ChangeKind::AttributeDefaultChanged, + Impact::NonBreaking, + path, + label, + s.default.clone(), + t.default.clone(), + ); + } + if s.fixed != t.fixed { + let impact = if t.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::AttributeFixedChanged, + impact, + path, + label, + s.fixed.clone(), + t.fixed.clone(), + ); + } + if let (Some(a), Some(b)) = (s.decl, t.decl) { + let (ca, cb) = (self.comp(true, a), self.comp(false, b)); + if ca.parent.is_some() || cb.parent.is_some() { + if ca.parent.is_some() && cb.parent.is_some() { + self.docs(&ca.docs, &cb.docs, path, label, out); + } + let (ta, tb) = ( + ca.attribute.as_ref().and_then(|i| i.r#type.as_ref()), + cb.attribute.as_ref().and_then(|i| i.r#type.as_ref()), + ); + self.type_ref( + ta, + tb, + ChangeKind::AttributeRetyped, + path, + label, + depth, + out, + ); + } + } + } + for (k, t) in &sn { + if !mo.contains_key(k.as_str()) { + let required = if t.group { + t.decl.is_some_and(|g| self.group_required(g)) + } else { + t.r#use == AttrUse::Required + }; + let impact = if required { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::AttributeAdded, + impact, + path, + &t.label, + None, + Some(use_name(t.r#use).into()), + ); + } + } + if x.1 != y.1 { + let impact = if y.1.is_none() { + Impact::Breaking + } else if x.1.is_none() { + Impact::NonBreaking + } else { + Impact::Breaking + }; + let w = |w: &Option| w.as_ref().map(wildcard_str); + self.push( + out, + ChangeKind::AnyAttributeChanged, + impact, + path, + "", + w(x.1), + w(y.1), + ); + } + } + + /// Pair removed and added components with near-identical content. + fn renames(&self, removed: &[Id], added: &[Id]) -> Vec { + let tokens = |old: bool, id: Id| -> (Kind, BTreeSet, String) { + let c = self.comp(old, id); + let mut t = BTreeSet::new(); + let local = |r: &Ref, pre: &str| { + let s = self.label(old, r); + let n = s.rsplit(':').next().unwrap_or(&s).to_string(); + format!("{pre}{n}") + }; + if let Some(ct) = &c.complex_type { + if let Some(d) = &ct.derivation { + t.insert(local(&d.base, "base ")); + } + let mut slots = Vec::new(); + if let Some(p) = &ct.content { + self.flatten( + old, + p, + "", + Occurs::default(), + &mut slots, + &mut HashMap::new(), + ); + } + for s in slots { + t.insert(format!( + "child {}", + s.label.rsplit(':').next().unwrap_or(&s.label) + )); + } + for (_, s) in self.attr_slots(old, &ct.attributes) { + t.insert(s.label.rsplit(':').next().unwrap_or(&s.label).to_string()); + } + for e in &ct.enumerations { + t.insert(format!("enum {}", e.value)); + } + } + if let Some(st) = &c.simple_type { + if let Some(b) = &st.base { + t.insert(local(b, "base ")); + } + for e in &st.enumerations { + t.insert(format!("enum {}", e.value)); + } + for f in &st.facets { + t.insert(format!("{} {}", f.kind, f.value)); + } + } + if let Some(g) = &c.group { + let mut slots = Vec::new(); + self.flatten( + old, + g, + "", + Occurs::default(), + &mut slots, + &mut HashMap::new(), + ); + for s in slots { + t.insert(format!( + "child {}", + s.label.rsplit(':').next().unwrap_or(&s.label) + )); + } + } + if let Some(ag) = &c.attribute_group { + for (_, s) in self.attr_slots(old, &ag.attributes) { + t.insert(s.label.rsplit(':').next().unwrap_or(&s.label).to_string()); + } + } + if let Some(e) = &c.element + && let Some(r) = &e.r#type + { + t.insert(local(r, "type ")); + } + if let Some(a) = &c.attribute + && let Some(r) = &a.r#type + { + t.insert(local(r, "type ")); + } + (c.kind, t, normalize(&docs_text(&c.docs))) + }; + let ra: Vec<(Id, Tokens)> = removed.iter().map(|&o| (o, tokens(true, o))).collect(); + let rb: Vec<(Id, Tokens)> = added.iter().map(|&n| (n, tokens(false, n))).collect(); + let mut cands = Vec::new(); + for (o, (ko, to, dox)) in &ra { + for (n, (kn, tn, doy)) in &rb { + if ko != kn { + continue; + } + let inter = to.intersection(tn).count(); + let uni = to.union(tn).count(); + let score = (100 * inter).checked_div(uni).unwrap_or(0) as u32; + let same_docs = !dox.is_empty() && dox == doy; + let ok = (score >= 80 && to.len().min(tn.len()) >= 3) + || (same_docs && score >= 50) + || (same_docs && uni == 0); + if ok { + let s = if same_docs { score.max(90) } else { score }; + cands.push((s, *o, *n)); + } + } + } + cands.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); + let (mut uo, mut un) = (HashSet::new(), HashSet::new()); + let mut out = Vec::new(); + for (score, o, n) in cands { + if uo.contains(&o) || un.contains(&n) { + continue; + } + uo.insert(o); + un.insert(n); + out.push(Rename { + kind: self.comp(false, n).kind, + old: side(self.a, o), + new: side(self.b, n), + score, + }); + } + out.sort_by(|a, b| a.old.qname.cmp(&b.old.qname)); + out + } +} + +/// Kind, content tokens and documentation of a component, to spot renames. +type Tokens = (Kind, BTreeSet, String); + +fn join(path: &str, name: &str) -> String { + if path.is_empty() { + name.to_string() + } else { + format!("{path}/{name}") + } +} + +fn compositor(p: &Particle) -> &'static str { + match p { + Particle::Sequence { .. } => "sequence", + Particle::Choice { .. } => "choice", + Particle::All { .. } => "all", + _ => "", + } +} + +/// The compositors of a content model, leaves left out: +/// `sequence(choice[0..1](), sequence())`. +fn skeleton(p: &Particle) -> String { + match p { + Particle::Sequence { items, occurs } + | Particle::Choice { items, occurs } + | Particle::All { items, occurs } => { + let mut leaves = 0; + p.walk(&mut |q| { + if matches!( + q, + Particle::Element { .. } | Particle::Group { .. } | Particle::Any { .. } + ) { + leaves += 1; + } + }); + if leaves == 0 { + return String::new(); + } + let inner: Vec = items + .iter() + .map(skeleton) + .filter(|s| !s.is_empty()) + .collect(); + let occ = if *occurs == Occurs::default() { + String::new() + } else { + format!("[{}]", occurs_str(*occurs)) + }; + format!("{}{occ}({})", compositor(p), inner.join(", ")) + } + _ => String::new(), + } +} + +fn top_compositor(s: &str) -> &str { + s.split(['(', '[']).next().unwrap_or("") +} + +fn lcs<'a>(a: &[&'a str], b: &[&'a str]) -> HashSet<&'a str> { + let (n, m) = (a.len(), b.len()); + let mut t = vec![vec![0u32; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + t[i][j] = if a[i] == b[j] { + t[i + 1][j + 1] + 1 + } else { + t[i + 1][j].max(t[i][j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let mut out = HashSet::new(); + while i < n && j < m { + if a[i] == b[j] { + out.insert(a[i]); + i += 1; + j += 1; + } else if t[i + 1][j] >= t[i][j + 1] { + i += 1; + } else { + j += 1; + } + } + out +} + +fn facet_impact(kind: &str, a: &[String], b: &[String]) -> Impact { + let num = |v: &[String]| -> Option { + match v { + [x] => x.trim().parse().ok(), + _ => None, + } + }; + let (Some(x), Some(y)) = (num(a), num(b)) else { + return Impact::Breaking; + }; + let tighter = match kind { + "minLength" | "minInclusive" | "minExclusive" => y > x, + "maxLength" | "maxInclusive" | "maxExclusive" | "totalDigits" | "fractionDigits" => y < x, + _ => true, + }; + if tighter { + Impact::Breaking + } else { + Impact::NonBreaking + } +} + +fn wildcard_str(w: &Wildcard) -> String { + let mut s = w.namespace.clone().unwrap_or_else(|| "##any".into()); + if let Some(n) = &w.not_namespace { + s = format!("not {n}"); + } + if let Some(p) = &w.process_contents { + s.push_str(&format!(" ({p})")); + } + s +} + +fn use_name(u: AttrUse) -> &'static str { + match u { + AttrUse::Optional => "optional", + AttrUse::Required => "required", + AttrUse::Prohibited => "prohibited", + } +} + +fn derivation_name(d: &Derivation) -> String { + let m = match d.method { + Method::Extension => "extension", + Method::Restriction => "restriction", + }; + format!( + "{m} ({} content)", + if d.simple { "simple" } else { "complex" } + ) +} + +/// The XSD name of a kind: `complexType`, `element`, ... +pub fn kind_name(k: Kind) -> &'static str { + match k { + Kind::Element => "element", + Kind::ComplexType => "complexType", + Kind::SimpleType => "simpleType", + Kind::Attribute => "attribute", + Kind::AttributeGroup => "attributeGroup", + Kind::Group => "group", + Kind::Key => "key", + Kind::Keyref => "keyref", + Kind::Unique => "unique", + } +} + +/// A readable label of a change kind: "child added", ... +pub fn change_label(k: ChangeKind) -> &'static str { + use ChangeKind::*; + match k { + KindChanged => "kind changed", + BaseChanged => "base type changed", + DerivationChanged => "derivation changed", + AbstractChanged => "abstract changed", + FinalChanged => "final changed", + BlockChanged => "block changed", + MixedChanged => "mixed changed", + NillableChanged => "nillable changed", + TypeChanged => "type changed", + ValueConstraintChanged => "value constraint changed", + SubstitutionGroupChanged => "substitution group changed", + SubstitutionMemberAdded => "substitution member added", + SubstitutionMemberRemoved => "substitution member removed", + ChildAdded => "child added", + ChildRemoved => "child removed", + ChildRetyped => "child retyped", + ChildCardinalityChanged => "cardinality changed", + ChildrenReordered => "children reordered", + ChildMoved => "child moved", + StructureChanged => "structure changed", + AttributeAdded => "attribute added", + AttributeRemoved => "attribute removed", + AttributeRetyped => "attribute retyped", + AttributeUseChanged => "attribute use changed", + AttributeDefaultChanged => "attribute default changed", + AttributeFixedChanged => "attribute fixed value changed", + AnyAttributeChanged => "anyAttribute changed", + EnumerationAdded => "enumeration value added", + EnumerationRemoved => "enumeration value removed", + FacetAdded => "facet added", + FacetRemoved => "facet removed", + FacetChanged => "facet changed", + VarietyChanged => "variety changed", + ItemTypeChanged => "list item type changed", + MemberTypeAdded => "union member added", + MemberTypeRemoved => "union member removed", + AssertAdded => "assertion added", + AssertRemoved => "assertion removed", + DocsChanged => "documentation changed", + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 312bce7..c7544d2 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,11 +9,14 @@ //! [diagnostics](bundle::Diagnostic) in the [`Bundle`]; indexing never fails. pub mod bundle; +pub mod diff; pub mod index; pub mod input; +pub mod report; mod xsd; pub use bundle::{Bundle, FORMAT, Id, Kind}; +pub use diff::{DiffOptions, SchemaDiff, diff}; pub use input::{Resolver, Source}; /// Indexing options. diff --git a/crates/core/src/report.rs b/crates/core/src/report.rs new file mode 100644 index 0000000..08e1259 --- /dev/null +++ b/crates/core/src/report.rs @@ -0,0 +1,483 @@ +//! Readable reports of a [`SchemaDiff`]: Markdown for release notes and a +//! standalone HTML page. + +use std::collections::BTreeMap; +use std::fmt::Write; + +use crate::bundle::Kind; +use crate::diff::{ + ChangeKind, ComponentDiff, Counts, Impact, SchemaDiff, Status, change_label, kind_name, +}; + +/// Inline text: `code` and **bold** spans are rendered by each backend. +enum Block { + H(u8, String), + P(String), + List(Vec), + Table(Vec, Vec>), +} + +/// `+` for additions, `−` for removals, `~` for other changes. +pub fn marker(k: ChangeKind) -> &'static str { + use ChangeKind::*; + match k { + SubstitutionMemberAdded + | ChildAdded + | AttributeAdded + | EnumerationAdded + | FacetAdded + | MemberTypeAdded + | AssertAdded => "+", + SubstitutionMemberRemoved + | ChildRemoved + | AttributeRemoved + | EnumerationRemoved + | FacetRemoved + | MemberTypeRemoved + | AssertRemoved => "−", + _ => "~", + } +} + +fn plural(k: Kind) -> &'static str { + match k { + Kind::ComplexType => "Complex types", + Kind::SimpleType => "Simple types", + Kind::Element => "Elements", + Kind::Attribute => "Attributes", + Kind::Group => "Groups", + Kind::AttributeGroup => "Attribute groups", + _ => "Other", + } +} + +/// Backticks inside code spans are replaced: they cannot be escaped portably. +fn code(s: &str) -> String { + format!("`{}`", s.replace('`', "'")) +} + +fn short(s: &str, max: usize) -> String { + let one = s.split_whitespace().collect::>().join(" "); + if one.chars().count() > max { + format!("{}…", one.chars().take(max).collect::()) + } else { + one + } +} + +fn change_line(c: &crate::diff::Change) -> String { + let mut s = format!("{} {}", marker(c.kind), change_label(c.kind)); + if !c.path.is_empty() { + let _ = write!(s, " in {}", code(&c.path)); + } + if !c.subject.is_empty() { + let _ = write!(s, ": {}", code(&short(&c.subject, 120))); + } + if c.kind != ChangeKind::DocsChanged && (c.old.is_some() || c.new.is_some()) { + let v = |x: &Option| { + x.as_deref() + .map_or("(none)".to_string(), |v| code(&short(v, 120))) + }; + match c.kind { + ChangeKind::ChildAdded | ChangeKind::AttributeAdded | ChangeKind::FacetAdded => { + let _ = write!(s, " ({})", c.new.as_deref().unwrap_or("")); + } + ChangeKind::ChildRemoved | ChangeKind::AttributeRemoved | ChangeKind::FacetRemoved => { + let _ = write!(s, " (was {})", c.old.as_deref().unwrap_or("")); + } + _ => { + let _ = write!(s, " ({} → {})", v(&c.old), v(&c.new)); + } + } + } + if c.impact == Impact::Breaking { + s.push_str(" **breaking**"); + } + s +} + +fn heading(d: &ComponentDiff) -> String { + let mut s = format!("{} {}", kind_name(d.kind), code(d.qname())); + if let Some(o) = &d.old + && d.new.is_some() + && o.qname != d.qname() + { + let _ = write!(s, " (was {})", code(&o.qname)); + } + if let Some(f) = d.file() { + s.push_str(&format!(" · {f}")); + } + s +} + +fn counts_row(label: String, c: &Counts) -> Vec { + vec![ + label, + c.added.to_string(), + c.removed.to_string(), + c.changed.to_string(), + c.breaking.to_string(), + ] +} + +fn set_label(s: &crate::diff::SetInfo) -> String { + if let Some(t) = &s.title { + return t.clone(); + } + if let Some(v) = &s.version { + return format!("version {v}"); + } + format!("{} files", s.files) +} + +fn blocks(d: &SchemaDiff) -> Vec { + let mut out = Vec::new(); + let title = format!( + "Schema changes: {} → {}", + set_label(&d.old), + set_label(&d.new) + ); + out.push(Block::H(1, title)); + let t = &d.summary.total; + out.push(Block::P(format!( + "{} added, {} removed and {} changed global components; {} breaking changes in {} components. {} components changed only in their documentation. Compared {} files with {} files.", + t.added, t.removed, t.changed, d.summary.breaking_changes, t.breaking, t.docs_only, d.old.files, d.new.files + ))); + if !d.namespace_map.is_empty() { + out.push(Block::P( + "The target namespace changed; components were matched by kind and local name:".into(), + )); + out.push(Block::List( + d.namespace_map + .iter() + .map(|(a, b)| format!("{} → {}", code(a), code(b))) + .collect(), + )); + } + let head = || { + vec![ + "".to_string(), + "Added".into(), + "Removed".into(), + "Changed".into(), + "Breaking".into(), + ] + }; + out.push(Block::H(2, "Summary".into())); + let mut rows: Vec> = d + .summary + .by_kind + .iter() + .map(|(k, c)| counts_row(plural(*k).into(), c)) + .collect(); + rows.push(counts_row("**Total**".into(), t)); + out.push(Block::Table(head(), rows)); + if d.summary.by_namespace.len() > 1 { + out.push(Block::H(3, "By namespace".into())); + let rows = d + .summary + .by_namespace + .iter() + .map(|(k, c)| { + counts_row( + if k.is_empty() { + "(none)".into() + } else { + code(k) + }, + c, + ) + }) + .collect(); + out.push(Block::Table(head(), rows)); + } + if d.summary.by_file.len() > 1 { + out.push(Block::H(3, "By file".into())); + let rows = d + .summary + .by_file + .iter() + .map(|(k, c)| counts_row(code(k), c)) + .collect(); + out.push(Block::Table(head(), rows)); + } + + let by_kind = |status: Status| { + let mut m: BTreeMap> = BTreeMap::new(); + for c in d.components.iter().filter(|c| c.status == status) { + let mut s = code(c.qname()); + if let Some(r) = &c.possibly_renamed { + let _ = write!( + s, + " (possibly renamed {} {})", + if status == Status::Added { + "from" + } else { + "to" + }, + code(r) + ); + } + m.entry(c.kind).or_default().push(s); + } + m + }; + let breaking: Vec<&ComponentDiff> = d + .components + .iter() + .filter(|c| c.status == Status::Changed && c.impact == Impact::Breaking) + .collect(); + out.push(Block::H( + 2, + format!("Breaking changes ({})", breaking.len() + t.removed as usize), + )); + let removed = by_kind(Status::Removed); + if !removed.is_empty() { + out.push(Block::H(3, "Removed".into())); + for (k, list) in &removed { + out.push(Block::P(format!( + "**{}** ({}): {}", + plural(*k), + list.len(), + list.join(", ") + ))); + } + } + for c in &breaking { + out.push(Block::H(3, heading(c))); + out.push(Block::List( + c.changes + .iter() + .filter(|x| x.impact == Impact::Breaking) + .map(change_line) + .collect(), + )); + let other: Vec = c + .changes + .iter() + .filter(|x| x.impact == Impact::NonBreaking) + .map(change_line) + .collect(); + if !other.is_empty() { + out.push(Block::P("Also:".into())); + out.push(Block::List(other)); + } + } + let added = by_kind(Status::Added); + out.push(Block::H(2, format!("Added ({})", t.added))); + for (k, list) in &added { + out.push(Block::P(format!( + "**{}** ({}): {}", + plural(*k), + list.len(), + list.join(", ") + ))); + } + if !d.renames.is_empty() { + out.push(Block::H( + 2, + format!("Possibly renamed ({})", d.renames.len()), + )); + out.push(Block::Table( + vec![ + "Kind".into(), + "Old".into(), + "New".into(), + "Similarity".into(), + ], + d.renames + .iter() + .map(|r| { + vec![ + kind_name(r.kind).into(), + code(&r.old.qname), + code(&r.new.qname), + format!("{}%", r.score), + ] + }) + .collect(), + )); + } + let other: Vec<&ComponentDiff> = d + .components + .iter() + .filter(|c| c.status == Status::Changed && c.impact == Impact::NonBreaking) + .collect(); + out.push(Block::H(2, format!("Other changes ({})", other.len()))); + for c in &other { + out.push(Block::H(3, heading(c))); + out.push(Block::List( + c.changes + .iter() + .filter(|x| x.impact != Impact::Info) + .map(change_line) + .collect(), + )); + } + let docs: Vec = d + .components + .iter() + .filter(|c| c.changes.iter().any(|x| x.kind == ChangeKind::DocsChanged)) + .map(|c| code(c.qname())) + .collect(); + if !docs.is_empty() { + out.push(Block::H( + 2, + format!("Documentation changes ({})", docs.len()), + )); + out.push(Block::P(docs.join(", "))); + } + out.push(Block::P(format!("Generated by {}.", d.generator))); + out +} + +/// A Markdown report, for release notes. +pub fn markdown(d: &SchemaDiff) -> String { + let mut s = String::new(); + for b in blocks(d) { + match b { + Block::H(l, t) => { + let _ = writeln!(s, "{} {t}\n", "#".repeat(l as usize)); + } + Block::P(t) => { + let _ = writeln!(s, "{t}\n"); + } + Block::List(items) => { + for i in items { + let _ = writeln!(s, "- {}", i.replace('\n', " ")); + } + s.push('\n'); + } + Block::Table(head, rows) => { + let cell = |c: &String| c.replace('|', "\\|"); + let _ = writeln!( + s, + "| {} |", + head.iter().map(cell).collect::>().join(" | ") + ); + let numeric = |i: usize| { + rows.iter().all(|r| { + r.get(i) + .is_some_and(|c| c.trim_end_matches('%').parse::().is_ok()) + }) + }; + let _ = writeln!( + s, + "|{}", + (0..head.len()) + .map(|i| if numeric(i) { "---:|" } else { "---|" }) + .collect::() + ); + for r in rows { + let _ = writeln!( + s, + "| {} |", + r.iter().map(cell).collect::>().join(" | ") + ); + } + s.push('\n'); + } + } + } + s +} + +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Render the inline `code` and **bold** spans of a text as HTML. +fn inline(s: &str) -> String { + let mut out = String::new(); + let mut code = false; + let mut bold = false; + let mut rest = s; + while !rest.is_empty() { + if let Some(r) = rest.strip_prefix('`') { + out.push_str(if code { "" } else { "" }); + code = !code; + rest = r; + } else if !code && let Some(r) = rest.strip_prefix("**") { + out.push_str(if bold { "" } else { "" }); + bold = !bold; + rest = r; + } else { + let ch = rest.chars().next().unwrap(); + out.push_str(&esc(&ch.to_string())); + rest = &rest[ch.len_utf8()..]; + } + } + if code { + out.push_str(""); + } + if bold { + out.push_str(""); + } + out +} + +const CSS: &str = ":root{color-scheme:light dark;--bg:#fff;--fg:#1d2330;--muted:#5b6577;--line:#d9dee7;--code:#f2f4f8;--add:#1a7f37;--del:#c62828;--chg:#9a6700}@media (prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e4e8ef;--muted:#9aa4b5;--line:#2c323d;--code:#1f242d;--add:#56d364;--del:#ff7b72;--chg:#e3b341}}body{background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif;max-width:960px;margin:0 auto;padding:16px}h1{font-size:1.6em}h2{border-bottom:1px solid var(--line);padding-bottom:4px;margin-top:2em}h3{font-size:1em;margin:1.4em 0 .4em}code{background:var(--code);padding:1px 4px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere}table{border-collapse:collapse;margin:8px 0;max-width:100%;display:block;overflow-x:auto}th,td{border:1px solid var(--line);padding:4px 10px;text-align:right}th:first-child,td:first-child{text-align:left}ul{padding-left:1.2em}li{margin:2px 0}li.mk{list-style:none;margin-left:-1.2em}.mk b{display:inline-block;width:1.2em}.m-add{color:var(--add)}.m-del{color:var(--del)}.m-chg{color:var(--chg)}strong{color:inherit}li strong{color:var(--del)}"; + +/// A standalone HTML report (light and dark theme). +pub fn html(d: &SchemaDiff) -> String { + let bl = blocks(d); + let title = match bl.first() { + Some(Block::H(_, t)) => t.clone(), + _ => "Schema changes".into(), + }; + let mut s = format!( + "\n{}\n", + esc(&title) + ); + for b in bl { + match b { + Block::H(l, t) => { + let _ = writeln!(s, "{}", inline(&t)); + } + Block::P(t) => { + let _ = writeln!(s, "

{}

", inline(&t)); + } + Block::List(items) => { + s.push_str("
    \n"); + for i in items { + let (cls, mark, text) = match i.split_once(' ') { + Some(("+", r)) => ("m-add", "+", r), + Some(("−", r)) => ("m-del", "−", r), + Some(("~", r)) => ("m-chg", "~", r), + _ => ("", "", i.as_str()), + }; + if cls.is_empty() { + let _ = writeln!(s, "
  • {}
  • ", inline(text)); + } else { + let _ = writeln!( + s, + "
  • {mark}{}
  • ", + inline(text) + ); + } + } + s.push_str("
\n"); + } + Block::Table(head, rows) => { + s.push_str(""); + for h in head { + let _ = write!(s, "", inline(&h)); + } + s.push_str("\n"); + for r in rows { + s.push_str(""); + for c in r { + let _ = write!(s, "", inline(&c)); + } + s.push_str("\n"); + } + s.push_str("
{}
{}
\n"); + } + } + } + s.push_str("\n"); + s +} diff --git a/crates/core/tests/data/diff/v1/common.xsd b/crates/core/tests/data/diff/v1/common.xsd new file mode 100644 index 0000000..cfef16e --- /dev/null +++ b/crates/core/tests/data/diff/v1/common.xsd @@ -0,0 +1,19 @@ + + + + A sum of money. + + + + + + + + + + + + + + + diff --git a/crates/core/tests/data/diff/v1/shop.xsd b/crates/core/tests/data/diff/v1/shop.xsd new file mode 100644 index 0000000..7c76166 --- /dev/null +++ b/crates/core/tests/data/diff/v1/shop.xsd @@ -0,0 +1,71 @@ + + + + + + + + + + + + An order. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/core/tests/data/diff/v2/common.xsd b/crates/core/tests/data/diff/v2/common.xsd new file mode 100644 index 0000000..1fe51d5 --- /dev/null +++ b/crates/core/tests/data/diff/v2/common.xsd @@ -0,0 +1,20 @@ + + + + A sum of money. + + + + + + + + + + + + + + + + diff --git a/crates/core/tests/data/diff/v2/shop.xsd b/crates/core/tests/data/diff/v2/shop.xsd new file mode 100644 index 0000000..3df3a14 --- /dev/null +++ b/crates/core/tests/data/diff/v2/shop.xsd @@ -0,0 +1,85 @@ + + + + + + + + + + + + An order placed by a customer. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/core/tests/diff.rs b/crates/core/tests/diff.rs new file mode 100644 index 0000000..ebc0711 --- /dev/null +++ b/crates/core/tests/diff.rs @@ -0,0 +1,225 @@ +//! Tests of the comparison of two versions, on the hand-written pair in +//! tests/data/diff (v1 → v2, whose target namespace changes too). + +use schema_explorer_core::diff::*; +use schema_explorer_core::{Bundle, Kind, Options, Source, index, report}; +use std::path::Path; + +fn load(version: &str) -> Bundle { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/diff"); + let files = ["shop.xsd", "common.xsd"]; + index( + files + .iter() + .map(|f| { + Source::new( + format!("{version}/{f}"), + std::fs::read(dir.join(version).join(f)).unwrap(), + ) + }) + .collect(), + None, + &Options::default(), + ) +} + +fn run(opts: &DiffOptions) -> SchemaDiff { + diff(&load("v1"), &load("v2"), opts) +} + +fn comp<'a>(d: &'a SchemaDiff, kind: Kind, name: &str) -> &'a ComponentDiff { + d.components + .iter() + .find(|c| c.kind == kind && c.name == name) + .unwrap_or_else(|| panic!("{kind:?} {name} not in the diff")) +} + +/// The changes of a component as `kind subject impact` lines. +fn changes(c: &ComponentDiff) -> Vec { + c.changes + .iter() + .map(|x| { + let mut s = format!("{:?}", x.kind); + if !x.path.is_empty() { + s.push_str(&format!(" {}:", x.path)); + } + if !x.subject.is_empty() { + s.push_str(&format!(" {}", x.subject)); + } + s.push_str(&format!(" {:?}", x.impact)); + s + }) + .collect() +} + +fn has(c: &ComponentDiff, line: &str) { + let list = changes(c); + assert!(list.iter().any(|l| l == line), "{line:?} not in {list:#?}"); +} + +#[test] +fn matches_across_namespaces() { + let d = run(&DiffOptions::default()); + assert_eq!(d.format, DIFF_FORMAT); + assert_eq!( + d.namespace_map.get("urn:test:shop:1").map(String::as_str), + Some("urn:test:shop:2") + ); + let order = comp(&d, Kind::ComplexType, "Order"); + assert_eq!(order.status, Status::Changed); + assert!(order.by_local_name); + // Unchanged components are left out, even across the namespace change. + assert!(!d.components.iter().any(|c| c.name == "book")); + assert_eq!(d.old.version.as_deref(), Some("1.0")); + assert_eq!(d.new.version.as_deref(), Some("2.0")); +} + +#[test] +fn strict_namespaces() { + let d = run(&DiffOptions { + match_local_names: false, + ..Default::default() + }); + assert!(d.namespace_map.is_empty()); + // Everything in the target namespace is removed and added again. + let order: Vec = d + .components + .iter() + .filter(|c| c.name == "Order") + .map(|c| c.status) + .collect(); + assert_eq!(order.len(), 2, "{order:?}"); + assert!(order.contains(&Status::Removed) && order.contains(&Status::Added)); +} + +#[test] +fn added_removed_renamed() { + let d = run(&DiffOptions::default()); + assert_eq!(comp(&d, Kind::Element, "legacy").status, Status::Removed); + assert!(comp(&d, Kind::Element, "legacy").breaking()); + let dvd = comp(&d, Kind::Element, "dvd"); + assert_eq!(dvd.status, Status::Added); + assert_eq!(dvd.impact, Impact::NonBreaking); + assert_eq!(comp(&d, Kind::ComplexType, "Base").status, Status::Added); + let addr = comp(&d, Kind::ComplexType, "Address"); + assert_eq!(addr.status, Status::Removed); + assert_eq!(addr.possibly_renamed.as_deref(), Some("s:PostalAddress")); + assert_eq!(d.renames.len(), 1); + assert_eq!(d.renames[0].score, 100); + let t = d.summary.total; + assert_eq!((t.added, t.removed), (3, 2)); + assert_eq!(d.summary.by_kind[&Kind::Element].removed, 1); + assert_eq!(d.summary.by_file["shop.xsd"].added, 3); +} + +#[test] +fn content_model() { + let d = run(&DiffOptions::default()); + let order = comp(&d, Kind::ComplexType, "Order"); + assert!(order.breaking()); + has(order, "ChildCardinalityChanged lines Breaking"); + has(order, "ChildAdded discount NonBreaking"); + has(order, "ChildAdded store NonBreaking"); + has(order, "ChildMoved channel Breaking"); + has(order, "ChildrenReordered note Breaking"); + has(order, "StructureChanged Breaking"); + // In the anonymous type of the local element `lines`. + has(order, "AttributeRetyped lines: @count Breaking"); + let cust = comp(&d, Kind::ComplexType, "Customer"); + has(cust, "ChildCardinalityChanged email NonBreaking"); + has(cust, "ChildCardinalityChanged phone Breaking"); + let group = comp(&d, Kind::Group, "gContact"); + has(group, "ChildRemoved fax Breaking"); + has(group, "ChildAdded phone NonBreaking"); +} + +#[test] +fn attributes() { + let d = run(&DiffOptions::default()); + let order = comp(&d, Kind::ComplexType, "Order"); + has(order, "AttributeRemoved @legacyCode Breaking"); + has(order, "AttributeAdded @currency Breaking"); + has(order, "AttributeDefaultChanged @status NonBreaking"); + has( + comp(&d, Kind::ComplexType, "Customer"), + "AttributeAdded @vip NonBreaking", + ); + has( + comp(&d, Kind::AttributeGroup, "aAudit"), + "AttributeAdded @by Breaking", + ); +} + +#[test] +fn types_and_facets() { + let d = run(&DiffOptions::default()); + let item = comp(&d, Kind::ComplexType, "Item"); + has(item, "BaseChanged Breaking"); + has(item, "AbstractChanged Breaking"); + let st = comp(&d, Kind::SimpleType, "tStatus"); + has(st, "EnumerationAdded new NonBreaking"); + has(st, "EnumerationRemoved cancelled Breaking"); + let code = comp(&d, Kind::SimpleType, "tCode"); + has(code, "FacetChanged maxLength Breaking"); + has(code, "FacetAdded minLength Breaking"); + let amount = comp(&d, Kind::SimpleType, "tAmount"); + has(amount, "FacetChanged fractionDigits NonBreaking"); + assert_eq!(amount.impact, Impact::NonBreaking); +} + +#[test] +fn substitution_groups() { + let d = run(&DiffOptions::default()); + let head = comp(&d, Kind::Element, "item"); + has(head, "SubstitutionMemberRemoved s:cd Breaking"); + has(head, "SubstitutionMemberAdded s:dvd NonBreaking"); + has( + comp(&d, Kind::Element, "cd"), + "SubstitutionGroupChanged Breaking", + ); +} + +#[test] +fn documentation() { + let d = run(&DiffOptions::default()); + let order = comp(&d, Kind::ComplexType, "Order"); + let doc = order + .changes + .iter() + .find(|c| c.kind == ChangeKind::DocsChanged) + .unwrap(); + assert_eq!(doc.impact, Impact::Info); + assert_eq!(doc.old.as_deref(), Some("An order.")); + assert_eq!(doc.new.as_deref(), Some("An order placed by a customer.")); + let d = run(&DiffOptions { + docs: false, + ..Default::default() + }); + assert!( + d.components + .iter() + .all(|c| c.changes.iter().all(|x| x.kind != ChangeKind::DocsChanged)) + ); +} + +#[test] +fn identical_sets() { + let d = diff(&load("v1"), &load("v1"), &DiffOptions::default()); + assert!(d.components.is_empty(), "{:#?}", d.components); + assert_eq!(d.summary.total.total(), 0); +} + +#[test] +fn json_and_reports() { + let d = run(&DiffOptions::default()); + let json = serde_json::to_string(&d).unwrap(); + let back: SchemaDiff = serde_json::from_str(&json).unwrap(); + assert_eq!(back, d); + let md = report::markdown(&d); + assert!(md.starts_with("# Schema changes: version 1.0 → version 2.0")); + assert!(md.contains("- − enumeration value removed: `cancelled` **breaking**")); + assert!(md.contains("| complexType | `s:Address` | `s:PostalAddress` | 100% |")); + let html = report::html(&d); + assert!(html.contains("s:PostalAddress")); + assert!(!html.contains(" Option { + let a = run(&format!("{label} (old)"), old)?; + let b = run(&format!("{label} (new)"), new)?; + let start = Instant::now(); + let d = diff(&a.bundle, &b.bundle, &DiffOptions::default()); + let t = &d.summary.total; + eprintln!( + "{label}: {} added, {} removed, {} changed ({} breaking, {} documentation only), {} possibly renamed, {} breaking changes; compared in {:.2?}", + t.added, + t.removed, + t.changed, + t.breaking, + t.docs_only, + d.summary.possibly_renamed, + d.summary.breaking_changes, + start.elapsed() + ); + Some(d) +} + +fn find<'a>(d: &'a SchemaDiff, kind: Kind, name: &str) -> Option<&'a ComponentDiff> { + d.components + .iter() + .find(|c| c.kind == kind && c.name == name) +} + +fn railml_dirs(v: &str) -> [String; 2] { + [ + format!("railml/{v}/schema"), + format!("railml/{v}/codelists"), + ] +} + +#[test] +fn diff_railml_3_1_to_3_2() { + let [a, _] = railml_dirs("3.1"); + let [b, c] = railml_dirs("3.2"); + let Some(d) = compare("railML 3.1 → 3.2", &[&a], &[&b, &c]) else { + return; + }; + assert_eq!( + d.namespace_map + .get("https://www.railml.org/schemas/3.1") + .map(String::as_str), + Some("https://www.railml.org/schemas/3.2") + ); + // Most components survive the namespace change. + assert!(d.summary.total.changed > 100); + assert!(d.summary.total.removed < d.old.components / 4); +} + +#[test] +fn diff_railml_3_2_to_3_3() { + let [a, b] = railml_dirs("3.2"); + let [c, e] = railml_dirs("3.3"); + let Some(d) = compare("railML 3.2 → 3.3", &[&a, &b], &[&c, &e]) else { + return; + }; + assert_eq!( + d.namespace_map + .get("https://www.railml.org/schemas/3.2") + .map(String::as_str), + Some("https://www.railml.org/schemas/3.3") + ); + // "tons" became "tonnes". + let w = find(&d, Kind::SimpleType, "tWeightTons").expect("tWeightTons"); + assert_eq!(w.status, Status::Removed); + assert_eq!(w.possibly_renamed.as_deref(), Some("rail3:tWeightTonnes")); + // Service section facilities moved out of the attribute group. + let ss = find(&d, Kind::AttributeGroup, "aServiceSection").expect("aServiceSection"); + assert!(ss.changes.iter().any(|c| c.subject == "@allowsCleaning")); + // Container elements must now hold at least one item. + let bal = find(&d, Kind::ComplexType, "Balises").expect("Balises"); + assert!(bal.breaking()); + assert!(d.summary.total.added > 100); +} + +#[test] +fn diff_taf() { + let Some(d) = compare( + "TAF 3.5.1 → current", + &["telematics/taf-v3.5.1/taf_cat_complete_fixed.xsd"], + &["telematics/taf/taf_cat_complete_fixed.xsd"], + ) else { + return; + }; + assert!(d.namespace_map.is_empty()); + let ri = find(&d, Kind::Element, "RouteInformation").expect("RouteInformation"); + assert_eq!(ri.status, Status::Removed); + let hb = find(&d, Kind::Element, "HandBrake").expect("HandBrake"); + assert!(hb.breaking()); +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 8ccf858..4b850f3 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -44,6 +44,38 @@ pub fn expand(json: &str) -> Result { schema_explorer_core::to_json(&bundle).map_err(|e| JsValue::from_str(&e.to_string())) } +fn err(e: impl ToString) -> JsValue { + JsValue::from_str(&e.to_string()) +} + +/// Compare two bundles (JSON, full or compact; `old` is the earlier +/// version) and return the diff as JSON. `options` is a JSON object of +/// `DiffOptions` fields, or empty for the defaults. +#[wasm_bindgen] +pub fn diff(old: &str, new: &str, options: &str) -> Result { + let a: schema_explorer_core::Bundle = serde_json::from_str(old).map_err(err)?; + let b: schema_explorer_core::Bundle = serde_json::from_str(new).map_err(err)?; + let opts: schema_explorer_core::DiffOptions = if options.trim().is_empty() { + Default::default() + } else { + serde_json::from_str(options).map_err(err)? + }; + let mut d = schema_explorer_core::diff(&a, &b, &opts); + d.generator = format!("schema-explorer-wasm {}", env!("CARGO_PKG_VERSION")); + serde_json::to_string(&d).map_err(err) +} + +/// Render a diff (JSON) as a report: `md` or `html`. +#[wasm_bindgen] +pub fn diff_report(diff: &str, format: &str) -> Result { + let d: schema_explorer_core::SchemaDiff = serde_json::from_str(diff).map_err(err)?; + match format { + "md" => Ok(schema_explorer_core::report::markdown(&d)), + "html" => Ok(schema_explorer_core::report::html(&d)), + _ => Err(err(format!("unknown report format {format}"))), + } +} + /// The version of the core library. #[wasm_bindgen] pub fn version() -> String { From e26a55fc3459e7ac29c8042870d1de2d9c521fb1 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 16:57:31 +0000 Subject: [PATCH 2/2] Add compare mode to the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two schema sets can be loaded side by side (two drop zones on the open page, or ?bundle=&compare=). The changes overview has counts per kind, namespace and file, a filterable list kept in the link and Markdown, HTML and JSON downloads; each changed component gets a page with both versions side by side. Diagrams and content trees mark added (+, green), removed (−, red, struck through) and changed (~, amber) boxes, lines and arrows, in both themes. --- README.md | 30 +++ web/scripts/build-wasm.mjs | 2 + web/src/app.ts | 172 +++++++++++++++- web/src/ctx.ts | 5 +- web/src/diagram.ts | 13 +- web/src/diff.ts | 272 ++++++++++++++++++++++++++ web/src/element.ts | 3 +- web/src/graph.ts | 4 + web/src/loader.ts | 12 ++ web/src/styles.css | 264 +++++++++++++++++++++++++ web/src/tree.ts | 70 +++++-- web/src/views/compare.ts | 359 ++++++++++++++++++++++++++++++++++ web/src/views/diagramPanel.ts | 5 +- web/src/wasm.worker.ts | 11 +- 14 files changed, 1194 insertions(+), 28 deletions(-) create mode 100644 web/src/diff.ts create mode 100644 web/src/views/compare.ts diff --git a/README.md b/README.md index b8dd383..5b7f057 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,36 @@ bundle. The bundle format is versioned (`format`, see can recompute. In the browser, the WebAssembly build exposes `index(names, contents, compact)` with the same output. +## Comparing versions + +`diff` compares two versions of a schema set: global complex and simple +types, elements, attributes, groups and attribute groups added, removed or +changed, down to child elements (added, removed, retyped, cardinality, +order, compositor structure), attributes (use, type, default, fixed), +enumeration values, facets, base types, substitution groups and +documentation. Each change is classified as breaking (documents valid +against the old version may not be valid against the new one), +non-breaking or documentation only; removed and added components with +near-identical content are reported as possibly renamed. Components are +matched by qualified name and, when the target namespace changed between +versions (railML 3.2 → 3.3), by kind and local name (`--strict-namespaces` +turns that off). + +```sh +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 +``` + +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 +`?bundle=&compare=` opens the comparison directly. The overview +lists the changes with filters, each changed component gets a page with +both versions side by side, and diagrams and content trees of the later +version mark what was added (+, green), removed (−, red, struck through) +and changed (~, amber). + ## Layout - `crates/core`: Rust library that reads schema sets into an indexed model diff --git a/web/scripts/build-wasm.mjs b/web/scripts/build-wasm.mjs index 8de91a0..87597fd 100644 --- a/web/scripts/build-wasm.mjs +++ b/web/scripts/build-wasm.mjs @@ -49,6 +49,8 @@ writeFileSync( `export default function init(input?: { module_or_path?: string | URL | Request | BufferSource | WebAssembly.Module } | string | URL): Promise; export function index(names: string[], contents: Uint8Array[], compact: boolean): string; export function expand(json: string): string; +export function diff(old: string, cur: string, options: string): string; +export function diff_report(diff: string, format: string): string; export function version(): string; export function format(): number; ` diff --git a/web/src/app.ts b/web/src/app.ts index 63c8b5b..6976d8c 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -1,12 +1,14 @@ // The application: state, routing, the shell around the views. import type { AppCtx, Prefs, Route } from "./ctx"; -import { button, copyText, h, icon, type Child } from "./dom"; -import { fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; +import { button, copyText, h, icon, plural, type Child } from "./dom"; +import { Compare } from "./diff"; +import { diffBundles, fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; import { KIND_SLUG, Model, type Bundle, type Id } from "./model"; import { matdataMark } from "./logo"; import { openAbout, openHelp } from "./views/dialogs"; import { homePage } from "./views/home"; import { overviewPage } from "./views/overview"; +import { diffBanner, diffComponentPage, diffOverviewPage } from "./views/compare"; import { componentPage, filePage } from "./views/pages"; import { sidebar, type Sidebar } from "./views/sidebar"; @@ -24,6 +26,9 @@ export interface AppOptions { minimal?: boolean; } +/** One side of a comparison: an address, or files read from the computer. */ +export type CompareSide = { url: string; sources?: string } | { files: NamedBytes[] }; + const DEFAULT_PREFS: Prefs = { theme: "auto", showInherited: true, @@ -61,6 +66,7 @@ export class App implements AppCtx { private source: { kind: "url"; url: string; sources?: string } | { kind: "files" } | { kind: "server" } | null = null; private sources: SourceProvider | null = null; private sourceCache = new Map>(); + private _cmp: Compare | null = null; private onHash = () => this.readHash(); constructor(root: HTMLElement, opts: AppOptions) { @@ -102,6 +108,9 @@ export class App implements AppCtx { get embed(): boolean { return this.opts.embed; } + get cmp(): Compare | null { + return this._cmp; + } get bundle(): Bundle | null { return this.model?.b ?? null; } @@ -158,7 +167,10 @@ export class App implements AppCtx { u.searchParams.delete("bundle"); u.searchParams.delete("sources"); } - return { url: u.toString(), complete: this.source?.kind !== "files" }; + if (this._cmp?.oldUrl) u.searchParams.set("compare", this._cmp.oldUrl); + else u.searchParams.delete("compare"); + const complete = this.source?.kind !== "files" && (!this._cmp || !!this._cmp.oldUrl); + return { url: u.toString(), complete }; } private async share(): Promise { @@ -205,6 +217,10 @@ export class App implements AppCtx { const c = this.model.comp(r.id); path = c.parent === undefined && c.name ? `#/${KIND_SLUG[c.kind]}/${encodeURIComponent(this.model.qname(r.id))}` : `#/id/${r.id}`; } else if (r.view === "file" && r.id !== undefined) path = `#/file/${r.id}`; + else if (r.view === "diffc" && r.query) { + const i = r.query.indexOf("/"); + path = `#/diff/${r.query.slice(0, i)}/${encodeURIComponent(r.query.slice(i + 1))}`; + } else if (r.view !== "home") path = "#/" + r.view; return path + (qs ? "?" + qs : ""); } @@ -221,6 +237,10 @@ export class App implements AppCtx { const m = this.model; if (!m) return { view: "home", params }; if (parts[0] === "overview") return { view: "overview", params }; + if (parts[0] === "diff") { + if (parts.length >= 3) return { view: "diffc", query: `${parts[1]}/${decodeURIComponent(parts.slice(2).join("/"))}`, params }; + return { view: "diff", params }; + } if (parts[0] === "file" && parts[1] !== undefined) { const n = Number(parts[1]); if (Number.isInteger(n) && m.b.files[n]) return { view: "file", id: n, params }; @@ -264,8 +284,12 @@ export class App implements AppCtx { } // ---- loading ------------------------------------------------------------ - async start(src?: string | null, focus?: string | null, sources?: string | null): Promise { + async start(src?: string | null, focus?: string | null, sources?: string | null, compare?: string | null): Promise { this.server = await serverStatus(); + if (src && compare) { + await this.openCompare({ url: compare }, { url: src, sources: sources ?? undefined }); + return; + } if (src) { await this.openUrl(src, focus ?? undefined, sources ?? undefined); return; @@ -348,9 +372,135 @@ export class App implements AppCtx { } } - setBundle(b: Bundle): void { + /** Read picked or dropped files into a bundle: one bundle file, or .xsd files indexed here. */ + private async bundleFromFiles(named: NamedBytes[]): Promise { + if (named.length === 1 && /\.json$/i.test(named[0].name)) { + const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; + if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); + return b; + } + const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); + if (xsd.length === 0) throw new Error("no .xsd files among the files"); + const b = await indexFiles(xsd, (p) => this.showProgress(p)); + const top = new Set(xsd.map((f) => (f.name.includes("/") ? f.name.split("/")[0] : ""))); + if (top.size === 1 && !top.has("")) b.title = [...top][0]; + return b; + } + + /** Compare two versions: the later one is explored, with what changed since the earlier one highlighted. */ + async openCompare(older: CompareSide, newer: CompareSide): Promise { + try { + const load = async (side: CompareSide, which: string): Promise => { + if ("url" in side) { + this.showProgress({ stage: "download", detail: `${which}: ${side.url}` }); + return loadUrl(side.url, (p) => this.showProgress(p)); + } + this.showProgress({ stage: "index", detail: which }); + return this.bundleFromFiles(side.files); + }; + const a = await load(older, "the earlier version"); + const b = await load(newer, "the later version"); + this.showProgress({ stage: "index", detail: "comparing the versions" }); + const started = performance.now(); + const diff = await diffBundles(a, b); + console.info(`compared in ${Math.round(performance.now() - started)} ms: ${diff.components.length} differences`); + if ("url" in newer) { + this.source = { kind: "url", url: newer.url, sources: newer.sources }; + const abs = new URL(newer.url, document.baseURI).href; + this.sources = urlSources(newer.sources ? new URL(newer.sources, document.baseURI).href : abs); + } else { + this.source = { kind: "files" }; + this.sources = memorySources(newer.files.filter((f) => /\.xsd$/i.test(f.name))); + } + const oldUrl = "url" in older ? older.url : undefined; + if (this.opts.routing) { + const u = new URL(location.href); + u.searchParams.delete("src"); + if ("url" in newer) u.searchParams.set("bundle", newer.url); + else u.searchParams.delete("bundle"); + if (oldUrl && "url" in newer) u.searchParams.set("compare", oldUrl); + else u.searchParams.delete("compare"); + history.replaceState(history.state, "", u.toString()); + } + const home = !location.hash || location.hash === "#/" || !this.opts.routing; + this.setBundle(b, { old: a, diff, oldUrl }); + if (home) this.goRoute({ view: "diff" }); + } catch (e) { + this.showError(e as Error, () => this.showOpen()); + this.opts.onError?.(e as Error); + } + } + + /** The card of the open page that loads two versions to compare. */ + private compareCard(): HTMLElement { + const sides: (CompareSide | null)[] = [null, null]; + const go = h("button", { type: "button", class: "btn primary", disabled: true, onClick: () => { + if (sides[0] && sides[1]) void this.openCompare(sides[0], sides[1]); + } }, "Compare") as HTMLButtonElement; + const zone = (i: number, title: string) => { + const status = h("span", { class: "muted small" }, "nothing yet"); + const set = (side: CompareSide | null, label: string) => { + sides[i] = side; + status.textContent = label; + z.classList.toggle("set", !!side); + go.disabled = !(sides[0] && sides[1]); + }; + const pick = (files: FileList | null) => { + if (files?.length) void readFiles(files).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + }; + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; + const dirInput = h("input", { type: "file", multiple: true, hidden: true, webkitdirectory: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; + const url = h("input", { class: "input", type: "url", placeholder: "or the address of a bundle", "aria-label": `${title}: address of a bundle or an XSD file`, onInput: (ev: Event) => { + const v = (ev.target as HTMLInputElement).value.trim(); + set(v ? { url: v } : null, v ? "from the web" : "nothing yet"); + } }) as HTMLInputElement; + const z = h( + "div", + { class: "dropzone compare-zone" }, + h("p", null, h("strong", null, title)), + h("p", { class: "small" }, "Drop files or a folder, ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "a folder")), + url, + status, + fileInput, + dirInput + ); + z.addEventListener("dragover", (ev) => { + ev.preventDefault(); + z.classList.add("over"); + }); + z.addEventListener("dragleave", () => z.classList.remove("over")); + z.addEventListener("drop", (ev) => { + if (!ev.dataTransfer?.files.length) return; + ev.preventDefault(); + ev.stopPropagation(); + z.classList.remove("over"); + status.textContent = "reading…"; + void readDataTransfer(ev.dataTransfer).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + }); + return z; + }; + return h( + "section", + { class: "card open-card compare-card" }, + h("h2", null, "Compare two versions"), + h("p", { class: "muted" }, "What was added, removed or changed between two versions of a schema set, with breaking changes flagged. Matching follows a namespace that changed between the versions."), + h("div", { class: "compare-zones" }, zone(0, "Earlier version"), zone(1, "Later version")), + h("p", null, go), + h("p", { class: "muted small" }, "Links: ", h("code", null, "?bundle="), "later version", h("code", null, "&compare="), "earlier version.") + ); + } + + setBundle(b: Bundle, cmp: { old: Bundle; diff: import("./diff").SchemaDiff; oldUrl?: string } | null = null): void { const t0 = performance.now(); this.model = new Model(b); + this._cmp = cmp ? new Compare(new Model(cmp.old), this.model, cmp.diff, cmp.oldUrl) : null; + if (this.opts.routing && !cmp) { + const u = new URL(location.href); + if (u.searchParams.has("compare")) { + u.searchParams.delete("compare"); + history.replaceState(history.state, "", u.toString()); + } + } this.sourceCache.clear(); this.rebuildSidebar(); this.renderTopbar(); @@ -389,10 +539,20 @@ export class App implements AppCtx { const title = this.model.title; if (r.view === "comp" && r.id !== undefined) { page = componentPage(this, r.id); + const banner = this._cmp ? diffBanner(this, this._cmp, r.id) : null; + if (banner) page.insertBefore(banner, page.firstChild); if (this.opts.routing) document.title = `${this.model.label(r.id)} · ${title}`; } else if (r.view === "file" && r.id !== undefined) { page = filePage(this, r.id); if (this.opts.routing) document.title = `${this.model.b.files[r.id]?.name} · ${title}`; + } else if (r.view === "diff" && this._cmp) { + page = diffOverviewPage(this, this._cmp); + if (this.opts.routing) document.title = `Changes · ${title}`; + } else if (r.view === "diffc" && this._cmp && r.query) { + const i = r.query.indexOf("/"); + const cd = this._cmp.find(r.query.slice(0, i), r.query.slice(i + 1)); + page = cd ? diffComponentPage(this, this._cmp, cd) : h("div", null, h("h1", null, "Not changed"), h("p", null, h("code", null, r.query), " did not change between the versions."), button("All changes", () => this.goRoute({ view: "diff" }))); + if (this.opts.routing) document.title = `${r.query.slice(i + 1)} (changes) · ${title}`; } else if (r.view === "overview") { page = overviewPage(this); if (this.opts.routing) document.title = `Overview · ${title}`; @@ -423,6 +583,7 @@ export class App implements AppCtx { else this.showOpen(); } }, matdataMark(), h("span", { class: "title-text" }, m?.title ?? "schema-explorer"), m ? h("small", { class: "hide-mobile" }, `${(m.b.stats.global.complexType ?? 0).toLocaleString()} complex types`) : null)); t.appendChild(h("span", { class: "spacer" })); + if (m && this._cmp) t.appendChild(button("Changes", () => this.goRoute({ view: "diff" }), { icon: "table", cls: "small", title: `What changed since ${this._cmp.oldTitle}` })); if (m) t.appendChild(button("Map", () => this.goRoute({ view: "overview" }), { icon: "map", cls: "small hide-mobile", title: "Overview map of the complex types" })); if (m) t.appendChild(button("Share", () => void this.share(), { icon: "share", cls: "small share-btn", title: "Copy a link to exactly this view" })); if (m) t.appendChild(button("Open…", () => this.showOpen(), { icon: "folder", cls: "small hide-mobile", title: "Load other schemas" })); @@ -489,6 +650,7 @@ export class App implements AppCtx { h("section", { class: "card open-card" }, h("h2", null, "From your computer"), drop), h("section", { class: "card open-card" }, h("h2", null, "From the web"), h("p", { class: "muted" }, "The address of a bundle made with ", h("code", null, "schema-explorer index"), ", or of a single .xsd file."), form, h("p", { class: "muted small" }, "Add ", h("code", null, "?bundle="), " and the address to this page's link to open it straight away. The site must allow cross-origin requests.")) ), + this.compareCard(), examples ); this.setMain(page, this.footer()); diff --git a/web/src/ctx.ts b/web/src/ctx.ts index 56c6316..b21c813 100644 --- a/web/src/ctx.ts +++ b/web/src/ctx.ts @@ -1,8 +1,9 @@ // What every view gets: the model, navigation, and small shared UI pieces. +import type { Compare } from "./diff"; import type { Id, Model } from "./model"; export interface Route { - /** "home" | "comp" | "file" | "overview" | "missing" */ + /** "home" | "comp" | "file" | "overview" | "missing" | "diff" | "diffc" (query: `slug/qname`) */ view: string; id?: Id; /** Sub view of a page: "diagram" | "tree" */ @@ -26,6 +27,8 @@ export interface AppCtx { readonly route: Route; readonly prefs: Prefs; readonly embed: boolean; + /** Compare mode: the earlier version and the diff to it (the model is the later version). */ + readonly cmp: Compare | null; go(id: Id, extra?: Partial): void; goRoute(r: Partial): void; href(id: Id, extra?: Partial): string; diff --git a/web/src/diagram.ts b/web/src/diagram.ts index b624b0a..7a1dfe8 100644 --- a/web/src/diagram.ts +++ b/web/src/diagram.ts @@ -255,7 +255,7 @@ export class Diagram { if (!sec) return null; const pts = [sec.startPoint, ...(sec.bendPoints ?? []), sec.endPoint]; const d = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); - const g = s("g", { class: `edge ${e.kind}`, "data-from": e.from, "data-to": e.to }); + const g = s("g", { class: `edge ${e.kind}${e.diff ? ` d-${e.diff}` : ""}`, "data-from": e.from, "data-to": e.to }); const path = s("path", { d }); switch (e.kind) { case "gen": @@ -306,7 +306,7 @@ export class Diagram { } private drawNode(n: NodeSpec, x: number, y: number, sz: Sized): SVGGElement { - const cls = ["node", n.kind, n.focus ? "focus" : "", n.external ? "external" : ""].filter(Boolean).join(" "); + const cls = ["node", n.kind, n.focus ? "focus" : "", n.external ? "external" : "", n.diff ? `d-${n.diff}` : ""].filter(Boolean).join(" "); const g = s("g", { class: cls, transform: `translate(${x} ${y})`, "data-key": n.key }); g.appendChild(s("rect", { class: "box", width: sz.w, height: sz.h, rx: 10 })); const headH = HEAD_H + (n.stereotype ? STEREO_H : 0); @@ -327,7 +327,7 @@ export class Diagram { if ("header" in r) { g.appendChild(s("text", { class: "attr", x: sz.w / 2, y: yy, "text-anchor": "middle", style: { fontStyle: "italic", fill: "var(--se-text-3)" } }, ellipsize(r.header, sz.w - PAD_X * 2))); } else { - const t = s("text", { class: `attr${r.inherited ? " inh" : ""}`, x: PAD_X, y: yy }); + const t = s("text", { class: `attr${r.inherited ? " inh" : ""}${r.diff ? ` d-${r.diff}` : ""}`, x: PAD_X, y: yy }); const full = attrText(r); if (measure(full) > sz.w - PAD_X * 2) { t.textContent = ellipsize(full, sz.w - PAD_X * 2); @@ -654,6 +654,13 @@ line.sep{stroke:${v("--se-border")};stroke-width:1} marker path.tri{fill:${v("--se-panel")};stroke:${v("--se-text")};stroke-width:1.5} marker path.arrow,marker path.arrow-dep{fill:none;stroke:${v("--se-border-strong")};stroke-width:1.5} marker path.diamond{fill:${v("--se-text")};stroke:${v("--se-text")}} +.node.d-added rect.box{stroke:${v("--se-add")};stroke-width:2.5} +.node.d-changed rect.box{stroke:${v("--se-chg")};stroke-width:2.5} +text.attr.d-added{fill:${v("--se-add")}} +text.attr.d-removed{fill:${v("--se-del")};text-decoration:line-through} +text.attr.d-changed{fill:${v("--se-chg")}} +.edge.d-added path{stroke:${v("--se-add")};stroke-width:2} +.edge.d-changed path{stroke:${v("--se-chg")};stroke-width:2} ${this.extraCss()}`; } diff --git a/web/src/diff.ts b/web/src/diff.ts new file mode 100644 index 0000000..422f343 --- /dev/null +++ b/web/src/diff.ts @@ -0,0 +1,272 @@ +// Comparison of two versions of a schema set: the diff made by the core +// (see crates/core/src/diff.rs) and lookups for the views that highlight it. +import type { GraphSpec } from "./graph"; +import { KIND_SLUG, type Id, type Kind, type Model } from "./model"; + +export type Status = "added" | "removed" | "changed"; +export type Impact = "breaking" | "nonBreaking" | "info"; +/** How a box, line or tree node differs from the earlier version. */ +export type Mark = Status; + +export interface Change { + kind: string; + impact: Impact; + path?: string; + subject?: string; + old?: string; + new?: string; +} + +export interface Side { + id: Id; + qname: string; + file?: string; + line?: number; +} + +export interface ComponentDiff { + kind: Kind; + status: Status; + name: string; + namespace?: string; + old?: Side; + new?: Side; + byLocalName?: boolean; + impact: Impact; + changes?: Change[]; + possiblyRenamed?: string; +} + +export interface Counts { + added: number; + removed: number; + changed: number; + breaking: number; + docsOnly: number; +} + +export interface SetInfo { + title?: string; + version?: string; + versions?: string[]; + files: number; + namespaces: string[]; + components: number; +} + +export interface SchemaDiff { + format: number; + generator: string; + options: { matchLocalNames: boolean; docs: boolean; renames: boolean }; + old: SetInfo; + new: SetInfo; + namespaceMap?: Record; + summary: { + total: Counts; + byKind: Partial>; + byNamespace: Record; + byFile: Record; + changes: Record; + breakingChanges: number; + possiblyRenamed: number; + }; + components: ComponentDiff[]; + renames?: { kind: Kind; old: Side; new: Side; score: number }[]; +} + +export const MARKER: Record = { added: "+", removed: "−", changed: "~" }; +export const STATUS_LABEL: Record = { added: "Added", removed: "Removed", changed: "Changed" }; + +const LABELS: Record = { + kindChanged: "kind changed", + baseChanged: "base type changed", + derivationChanged: "derivation changed", + abstractChanged: "abstract changed", + finalChanged: "final changed", + blockChanged: "block changed", + mixedChanged: "mixed changed", + nillableChanged: "nillable changed", + typeChanged: "type changed", + valueConstraintChanged: "value constraint changed", + substitutionGroupChanged: "substitution group changed", + substitutionMemberAdded: "substitution member added", + substitutionMemberRemoved: "substitution member removed", + childAdded: "child added", + childRemoved: "child removed", + childRetyped: "child retyped", + childCardinalityChanged: "cardinality changed", + childrenReordered: "children reordered", + childMoved: "child moved", + structureChanged: "structure changed", + attributeAdded: "attribute added", + attributeRemoved: "attribute removed", + attributeRetyped: "attribute retyped", + attributeUseChanged: "attribute use changed", + attributeDefaultChanged: "attribute default changed", + attributeFixedChanged: "attribute fixed value changed", + anyAttributeChanged: "anyAttribute changed", + enumerationAdded: "enumeration value added", + enumerationRemoved: "enumeration value removed", + facetAdded: "facet added", + facetRemoved: "facet removed", + facetChanged: "facet changed", + varietyChanged: "variety changed", + itemTypeChanged: "list item type changed", + memberTypeAdded: "union member added", + memberTypeRemoved: "union member removed", + assertAdded: "assertion added", + assertRemoved: "assertion removed", + docsChanged: "documentation changed", +}; + +export const changeLabel = (k: string): string => LABELS[k] ?? k; + +/** The marker of a change: + for additions, − for removals, ~ otherwise. */ +export function changeMark(k: string): Status { + if (/Added$/.test(k)) return "added"; + if (/Removed$/.test(k)) return "removed"; + return "changed"; +} + +/** The local part of a subject: `@rail3:foo` → `foo`. */ +const local = (s: string) => { + const t = s.replace(/^@/, ""); + return t.slice(t.lastIndexOf(":") + 1); +}; + +export interface ChildMarks { + children: Map; + removed: { name: string; mult?: string }[]; + attrs: Map; + removedAttrs: { name: string; use?: string }[]; +} + +const NONE: ChildMarks = { children: new Map(), removed: [], attrs: new Map(), removedAttrs: [] }; + +/** Both versions and the diff between them; `cur` is the later version, the one the viewer shows. */ +export class Compare { + readonly byNew = new Map(); + readonly byOld = new Map(); + private marks = new Map(); + + constructor( + readonly old: Model, + readonly cur: Model, + readonly d: SchemaDiff, + /** Where the earlier version came from, for share links. */ + readonly oldUrl?: string + ) { + for (const c of d.components) { + if (c.new) this.byNew.set(c.new.id, c); + if (c.old) this.byOld.set(c.old.id, c); + } + } + + get oldTitle(): string { + return this.d.old.title ?? (this.d.old.version ? `version ${this.d.old.version}` : this.old.title); + } + + get newTitle(): string { + return this.d.new.title ?? (this.d.new.version ? `version ${this.d.new.version}` : this.cur.title); + } + + /** The mark of a global component of the later version. */ + mark(id: Id): Mark | undefined { + return this.byNew.get(id)?.status; + } + + /** The diff entry for a link `#/diff//`. */ + find(slug: string, qname: string): ComponentDiff | undefined { + return this.d.components.find((c) => KIND_SLUG[c.kind] === slug && (c.new?.qname ?? c.old?.qname) === qname); + } + + /** The global component and the path of local names that lead to a (possibly anonymous) type of the later version. */ + private locate(id: Id): { cd: ComponentDiff; path: string } | undefined { + const m = this.cur; + const names: string[] = []; + let cur: Id | undefined = id; + for (let i = 0; cur !== undefined && i < 30; i++) { + const c = m.comp(cur); + if (c.parent === undefined) { + const cd = this.byNew.get(cur); + return cd ? { cd, path: names.join("/") } : undefined; + } + if (c.kind === "element" && c.name) names.unshift(c.name); + cur = c.parent; + } + return undefined; + } + + /** What changed among the children and attributes a type (or group) declares itself. */ + childMarks(owner: Id): ChildMarks { + let r = this.marks.get(owner); + if (r) return r; + const at = this.locate(owner); + if (!at || at.cd.status !== "changed") { + r = NONE; + } else { + r = { children: new Map(), removed: [], attrs: new Map(), removedAttrs: [] }; + const prefix = at.path ? at.path + "/" : ""; + for (const ch of at.cd.changes ?? []) { + const p = ch.path ?? ""; + const subj = ch.subject ?? ""; + if (p !== at.path) { + // A change deeper down marks the child it is under. + if (p.startsWith(prefix) && ch.impact !== "info") { + const first = p.slice(prefix.length).split("/")[0]; + if (first && !r.children.has(first)) r.children.set(first, "changed"); + } + continue; + } + if (ch.impact === "info" || !subj || subj.startsWith("group ") || subj.startsWith("attributeGroup ")) continue; + if (subj.startsWith("@")) { + const n = local(subj); + if (ch.kind === "attributeRemoved") r.removedAttrs.push({ name: n, use: ch.old }); + else if (ch.kind === "attributeAdded") r.attrs.set(n, "added"); + else if (!r.attrs.has(n)) r.attrs.set(n, "changed"); + } else if (ch.kind.startsWith("child") && ch.kind !== "childrenReordered") { + const n = local(subj); + if (ch.kind === "childRemoved") r.removed.push({ name: n, mult: ch.old }); + else if (ch.kind === "childAdded") r.children.set(n, "added"); + else if (!r.children.has(n)) r.children.set(n, "changed"); + } + } + } + this.marks.set(owner, r); + return r; + } +} + +/** Mark what changed in a diagram of the later version: boxes, lines and arrows get a class and a +/−/~ prefix. */ +export function annotateGraph(g: GraphSpec, cmp: Compare): void { + const m = cmp.cur; + const prefix = (d: Mark | undefined, t: string) => (d ? `${MARKER[d]} ${t}` : t); + for (const n of g.nodes) { + if (n.id === undefined) continue; + n.diff = cmp.mark(n.id); + if (n.diff) n.name = prefix(n.diff, n.name); + if (n.kind === "enum") { + const cd = cmp.byNew.get(n.id); + if (cd?.status !== "changed") continue; + const added = new Set((cd.changes ?? []).filter((c) => c.kind === "enumerationAdded" && !c.path).map((c) => c.subject)); + for (const a of n.attrs) if (added.has(a.text)) Object.assign(a, { diff: "added", text: prefix("added", a.text) }); + for (const c of cd.changes ?? []) if (c.kind === "enumerationRemoved" && !c.path && c.subject) n.attrs.push({ text: prefix("removed", c.subject), diff: "removed" }); + continue; + } + if (n.kind !== "class") continue; + const cm = cmp.childMarks(n.id); + for (const a of n.attrs) { + if (a.inherited) continue; + const d = a.text.startsWith("@") ? cm.attrs.get(a.text.slice(1)) : cm.children.get(a.text); + if (d) Object.assign(a, { diff: d, text: prefix(d, a.text) }); + } + for (const r of cm.removedAttrs) n.attrs.push({ text: prefix("removed", "@" + r.name), diff: "removed" }); + for (const r of cm.removed) n.attrs.push({ text: prefix("removed", r.name), mult: r.mult, diff: "removed" }); + } + for (const e of g.edges) { + if (e.kind !== "comp" || e.id === undefined || !e.from.startsWith("c")) continue; + const from = Number(e.from.slice(1)); + const d = cmp.childMarks(from).children.get(m.comp(e.id).name ?? ""); + if (d) Object.assign(e, { diff: d, label: prefix(d, e.label ?? "") }); + } +} diff --git a/web/src/element.ts b/web/src/element.ts index c811c58..0e83858 100644 --- a/web/src/element.ts +++ b/web/src/element.ts @@ -54,7 +54,8 @@ export class SchemaExplorerElement extends HTMLElement { const src = this.getAttribute("src") ?? q?.get("bundle") ?? q?.get("src") ?? null; const focus = this.getAttribute("focus") ?? q?.get("focus") ?? null; const sources = this.getAttribute("sources") ?? q?.get("sources") ?? null; - void this.app.start(src, focus, sources); + const compare = this.getAttribute("compare") ?? q?.get("compare") ?? null; + void this.app.start(src, focus, sources, compare); } disconnectedCallback(): void { diff --git a/web/src/graph.ts b/web/src/graph.ts index 235e2b5..2c9d9d8 100644 --- a/web/src/graph.ts +++ b/web/src/graph.ts @@ -15,6 +15,8 @@ export interface AttrLine { groupKey?: string; /** Component to open on click. */ id?: Id; + /** Compare mode: how the line differs from the earlier version. */ + diff?: "added" | "removed" | "changed"; } export interface NodeSpec { @@ -30,6 +32,7 @@ export interface NodeSpec { /** Lines not shown (collapsed). */ hiddenAttrs: number; moreOf?: { from: string; relation: string; ids: Id[] }; + diff?: "added" | "removed" | "changed"; } export type EdgeKind = "gen" | "restr" | "comp" | "dep" | "incoming" | "typed"; @@ -43,6 +46,7 @@ export interface EdgeSpec { mult?: string; /** Component behind the edge, for navigation. */ id?: Id; + diff?: "added" | "removed" | "changed"; } export interface GraphSpec { diff --git a/web/src/loader.ts b/web/src/loader.ts index da75928..cf77cf7 100644 --- a/web/src/loader.ts +++ b/web/src/loader.ts @@ -1,5 +1,6 @@ // Getting a bundle from somewhere: a bundle URL, dropped .xsd files and // folders (indexed in a WebAssembly worker), or the local `serve` server. +import type { SchemaDiff } from "./diff"; import type { Bundle } from "./model"; export interface LoadProgress { @@ -130,6 +131,17 @@ export async function indexFiles(files: NamedBytes[], progress?: (p: LoadProgres return checkBundle(JSON.parse(json)); } +/** Compare two bundles (WebAssembly, in a worker); `old` is the earlier version. */ +export async function diffBundles(old: Bundle, cur: Bundle, options: Record = {}): Promise { + const strip = (b: Bundle) => JSON.stringify({ ...b, search: {} }); + return JSON.parse(await call({ op: "diff", old: strip(old), new: strip(cur), options: JSON.stringify(options) })) as SchemaDiff; +} + +/** A Markdown or HTML report of a diff. */ +export function diffReport(d: SchemaDiff, format: "md" | "html"): Promise { + return call({ op: "report", json: JSON.stringify(d), format }); +} + /** Read dropped or picked files; directories are walked for .xsd files. */ export async function readDataTransfer(dt: DataTransfer): Promise { const entries = Array.from(dt.items) diff --git a/web/src/styles.css b/web/src/styles.css index 66afa8b..657aee9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -21,6 +21,12 @@ --se-danger-bg: #fbe4e1; --se-warn: #6e4d00; --se-ok: #0563bb; + --se-add: #13773a; + --se-add-bg: #ddf3e4; + --se-del: #b42318; + --se-del-bg: #fbe4e1; + --se-chg: #8a5a00; + --se-chg-bg: #fbefc7; --se-class: #0563bb; --se-class-bg: #e3eefa; --se-prop: #1c2633; @@ -76,6 +82,12 @@ --se-danger-bg: #3d1512; --se-warn: #f5c242; --se-ok: #5aa8f2; + --se-add: #5fd38a; + --se-add-bg: #0f2e1b; + --se-del: #ff8f80; + --se-del-bg: #3d1512; + --se-chg: #f5c242; + --se-chg-bg: #3a2f0c; --se-class: #5aa8f2; --se-class-bg: #0e2a48; --se-prop: #eef1ef; @@ -1990,3 +2002,255 @@ pre.xml .t-str { .cm-tree .tn .expander:hover circle { fill: var(--se-accent-bg); } + +/* ---- compare mode ---------------------------------------------------- */ +.dmark { + display: inline-block; + min-width: 1.2em; + font-family: var(--se-mono); + font-weight: 700; + text-align: center; +} +.dmark.d-added, +.d-added-text { + color: var(--se-add); +} +.dmark.d-removed, +.d-removed-text { + color: var(--se-del); +} +.dmark.d-changed, +.d-changed-text { + color: var(--se-chg); +} +a.d-removed-text, +h1.d-removed-text { + text-decoration: line-through; +} +.badge.d-badge.d-added { + background: var(--se-add-bg); + color: var(--se-add); +} +.badge.d-badge.d-removed { + background: var(--se-del-bg); + color: var(--se-del); +} +.badge.d-badge.d-changed { + background: var(--se-chg-bg); + color: var(--se-chg); +} +.diff-page .stat .n, +.diff-page .stat .l { + display: block; +} +.diff-page .page-head { + display: block; +} +.diff-page .badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.diff-sum > div { + overflow-x: auto; + min-width: 0; +} +.stat-breaking .n { + color: var(--se-del); +} +.note-card { + margin: 12px 0; + padding: 12px 16px; + overflow-wrap: anywhere; +} +.diff-sum { + align-items: start; +} +.ptable.counts td.num, +.ptable.counts th.num { + text-align: right; + white-space: nowrap; +} +.by-file { + margin-top: 12px; + padding: 12px 16px; +} +.by-file summary { + cursor: pointer; + font-weight: 600; +} +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} +.filters .input { + width: auto; + flex: 1 1 160px; + min-width: 0; +} +.diff-list, +.sbs > .side { + overflow-x: auto; +} +.ptable tr.d-row.d-added td { + background: var(--se-add-bg); +} +.ptable tr.d-row.d-removed td { + background: var(--se-del-bg); +} +.ptable tr.d-row.d-changed td { + background: var(--se-chg-bg); +} +.side-table tr.d-removed td.pre, +.diff-table tr.d-removed td a { + text-decoration: line-through; +} +.diff-table td, +.change-table td { + vertical-align: top; +} +.ptable td.mk { + width: 1.6em; + padding-right: 0; +} +.ptable td.pre { + white-space: pre; +} +.sbs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 14px; +} +.side-head { + font-weight: 600; + padding: 4px 0 6px; + color: var(--se-text-2); +} +.sbs > .side.d-added .doc-text { + border-left: 3px solid var(--se-add); +} +.sbs > .side.d-removed .doc-text { + border-left: 3px solid var(--se-del); +} +.doc-text { + margin: 0; + padding: 8px 12px; + background: var(--se-panel); + border-radius: var(--se-radius-sm); + white-space: pre-wrap; +} +.doc-sub { + font-size: 14px; + margin: 12px 0 4px; +} +.diff-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 14px; + margin-bottom: 12px; + border-radius: var(--se-radius); + background: var(--se-panel); + border-left: 4px solid var(--se-border-strong); +} +.diff-banner.d-added { + border-left-color: var(--se-add); +} +.diff-banner.d-changed { + border-left-color: var(--se-chg); +} +.diff-banner .legend-diff { + margin-left: auto; +} +.compare-card { + margin-top: 16px; +} +.compare-zones { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} +.dropzone.compare-zone { + min-height: 120px; + cursor: default; +} +.dropzone.compare-zone.set { + border-style: solid; + border-color: var(--se-add); +} +.compare-zone .input { + width: 100%; +} +.uml .node.d-added rect.box { + stroke: var(--se-add); + stroke-width: 2.5; +} +.uml .node.d-changed rect.box { + stroke: var(--se-chg); + stroke-width: 2.5; +} +.uml text.attr.d-added { + fill: var(--se-add); +} +.uml text.attr.d-changed { + fill: var(--se-chg); +} +.uml text.attr.d-removed { + fill: var(--se-del); + text-decoration: line-through; +} +.uml .edge.d-added path { + stroke: var(--se-add); + stroke-width: 2; +} +.uml .edge.d-changed path { + stroke: var(--se-chg); + stroke-width: 2; +} +.uml .edge.d-added text { + fill: var(--se-add); +} +.uml .edge.d-changed text { + fill: var(--se-chg); +} +.cm-tree .tn.d-added rect.box { + stroke: var(--se-add); + stroke-width: 2.2; + fill: var(--se-add-bg); +} +.cm-tree .tn.d-changed rect.box { + stroke: var(--se-chg); + stroke-width: 2.2; + fill: var(--se-chg-bg); +} +.cm-tree .tn.d-removed rect.box { + stroke: var(--se-del); + stroke-dasharray: 4 3; + fill: var(--se-del-bg); +} +.cm-tree .tn.d-removed text.tname { + fill: var(--se-del); + text-decoration: line-through; +} +.cm-tree .tn text.attr-line.d-added { + fill: var(--se-add); +} +.cm-tree .tn text.attr-line.d-changed { + fill: var(--se-chg); +} +.cm-tree .tn text.attr-line.d-removed { + fill: var(--se-del); + text-decoration: line-through; +} +@media (max-width: 860px) { + .sbs { + grid-template-columns: 1fr; + } + .diff-banner .legend-diff { + margin-left: 0; + } +} diff --git a/web/src/tree.ts b/web/src/tree.ts index acde0ef..10731b8 100644 --- a/web/src/tree.ts +++ b/web/src/tree.ts @@ -4,8 +4,33 @@ // attribute lists. Nodes expand on demand, so deep and recursive models stay cheap. import { Diagram, MONO, SANS, measure, type DiagramCallbacks, type NodeAction, type View } from "./diagram"; import { s } from "./dom"; +import { MARKER, type Compare, type Mark } from "./diff"; import { isId, occursText, type Id, type Model, type Particle, type Ref } from "./model"; +/** Compare mode: the diff whose changes the tree marks. */ +let diffHook: Compare | null = null; +export function setTreeDiff(c: Compare | null): void { + diffHook = c; +} + +const markLine = (d: Mark | undefined, l: string) => (d ? `${MARKER[d]} ${l}` : l); + +/** The child elements removed from what `owner` declares, as nodes to show after its content. */ +function removedNodes(owner: Id | undefined, key: string): TNode[] { + if (!diffHook || owner === undefined) return []; + return diffHook.childMarks(owner).removed.map((r, i) => { + const [a, b] = (r.mult ?? "1..1").split(".."); + return { key: `${key}.x${i}`, kind: "element" as const, label: r.name, min: Number(a) || 0, max: b === "*" ? null : Number(b) || 1, diff: "removed" as const, expandable: false, children: () => [] }; + }); +} + +function withRemoved(n: TNode, owner: Id | undefined): TNode { + const extra = removedNodes(owner, n.key); + if (!extra.length) return n; + const kids = n.children; + return { ...n, expandable: true, children: () => [...kids(), ...extra] }; +} + export interface TNode { key: string; kind: "element" | "type" | "sequence" | "choice" | "all" | "any" | "group" | "attrs"; @@ -21,6 +46,8 @@ export interface TNode { lines?: string[]; expandable: boolean; children: () => TNode[]; + /** Compare mode: how it differs from the earlier version. */ + diff?: Mark; } const NAME_FONT = `600 13px ${SANS}`; @@ -44,14 +71,15 @@ function typeChildren(m: Model, typeId: Ref | undefined, key: string): TNode[] { const lines = attrs.map((a) => { const name = isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr); const t = m.typeName(m.attrType(a.attr)); - return `@${name}${t ? " : " + t : ""}${a.use === "required" ? "" : " ?"}`; + return markLine(diffHook?.childMarks(a.from).attrs.get(name), `@${name}${t ? " : " + t : ""}${a.use === "required" ? "" : " ?"}`); }); + if (diffHook) for (const r of diffHook.childMarks(typeId).removedAttrs) lines.push(markLine("removed", `@${r.name}`)); out.push({ key: `${key}.@`, kind: "attrs", label: `attributes (${attrs.length})`, lines, min: 1, max: 1, expandable: false, children: () => [] }); } const ct = m.comp(typeId).complexType!; const parts = (ct.effective?.content ?? (ct.content ? [typeId] : [])).filter((p) => m.comp(p).complexType?.content); if (parts.length === 1) { - out.push(particleNode(m, m.comp(parts[0]).complexType!.content!, `${key}.0`, parts[0] !== typeId ? `from ${m.label(parts[0])}` : undefined)); + out.push(withRemoved(particleNode(m, m.comp(parts[0]).complexType!.content!, `${key}.0`, parts[0] !== typeId ? `from ${m.label(parts[0])}` : undefined, parts[0]), parts[0])); } else if (parts.length > 1) { // Extension: the base content, then what each derived type adds, in a sequence. out.push({ @@ -61,30 +89,32 @@ function typeChildren(m: Model, typeId: Ref | undefined, key: string): TNode[] { min: 1, max: 1, expandable: true, - children: () => parts.map((p, i) => particleNode(m, m.comp(p).complexType!.content!, `${key}.0.${i}`, `from ${m.label(p)}`)), + children: () => parts.map((p, i) => withRemoved(particleNode(m, m.comp(p).complexType!.content!, `${key}.0.${i}`, `from ${m.label(p)}`, p), p)), }); } return out; } -export function particleNode(m: Model, p: Particle, key: string, note?: string): TNode { +export function particleNode(m: Model, p: Particle, key: string, note?: string, owner?: Id): TNode { const o = occ(p); switch (p.t) { case "element": { const id = isId(p.el) ? p.el : undefined; const t = id !== undefined ? m.elementType(id) : undefined; const complex = m.isComplex(t) && (typeChildren(m, t, key).length > 0); - return { key, kind: "element", label: id !== undefined ? m.comp(id).name ?? "" : m.refName(p.el), id, typeName: t !== undefined ? m.typeName(t) : undefined, typeId: isId(t) ? t : undefined, ...o, note, expandable: complex, children: () => typeChildren(m, t, key) }; + const label = id !== undefined ? m.comp(id).name ?? "" : m.refName(p.el); + const diff = diffHook && owner !== undefined ? diffHook.childMarks(owner).children.get(label) : undefined; + return { key, kind: "element", label, diff, id, typeName: t !== undefined ? m.typeName(t) : undefined, typeId: isId(t) ? t : undefined, ...o, note, expandable: complex, children: () => typeChildren(m, t, key) }; } case "group": { const gid = isId(p.group) ? p.group : undefined; const gp = gid !== undefined ? m.comp(gid).group : undefined; - return { key, kind: "group", label: m.refName(p.group), id: gid, ...o, note, expandable: !!gp, children: () => (gp ? [particleNode(m, gp, `${key}.0`)] : []) }; + return { key, kind: "group", label: m.refName(p.group), id: gid, ...o, note, expandable: !!gp, children: () => (gp ? [withRemoved(particleNode(m, gp, `${key}.0`, undefined, gid), gid)] : []) }; } case "any": return { key, kind: "any", label: "any", typeName: p.namespace ?? "##any", ...o, note, expandable: false, children: () => [] }; default: - return { key, kind: p.t, label: p.t, ...o, note, expandable: p.items.length > 0, children: () => p.items.map((it, i) => particleNode(m, it, `${key}.${i}`)) }; + return { key, kind: p.t, label: p.t, ...o, note, expandable: p.items.length > 0, children: () => p.items.map((it, i) => particleNode(m, it, `${key}.${i}`, undefined, owner)) }; } } @@ -93,12 +123,12 @@ export function rootNode(m: Model, id: Id): TNode { const c = m.comp(id); if (c.kind === "element") { const t = m.elementType(id); - return { key: "r", kind: "element", label: c.name ?? "", id, typeName: t !== undefined ? m.typeName(t) : undefined, typeId: isId(t) ? t : undefined, min: 1, max: 1, expandable: true, children: () => typeChildren(m, t, "r") }; + return { key: "r", kind: "element", label: c.name ?? "", diff: diffHook?.mark(id), id, typeName: t !== undefined ? m.typeName(t) : undefined, typeId: isId(t) ? t : undefined, min: 1, max: 1, expandable: true, children: () => typeChildren(m, t, "r") }; } if (c.kind === "group" && c.group) { - return { key: "r", kind: "group", label: m.qname(id), id, min: 1, max: 1, expandable: true, children: () => [particleNode(m, c.group!, "r.0")] }; + return { key: "r", kind: "group", label: m.qname(id), diff: diffHook?.mark(id), id, min: 1, max: 1, expandable: true, children: () => [withRemoved(particleNode(m, c.group!, "r.0", undefined, id), id)] }; } - return { key: "r", kind: "type", label: m.label(id), id, typeId: id, min: 1, max: 1, expandable: true, children: () => typeChildren(m, id, "r") }; + return { key: "r", kind: "type", label: m.label(id), diff: diffHook?.mark(id), id, typeId: id, min: 1, max: 1, expandable: true, children: () => typeChildren(m, id, "r") }; } interface Placed { @@ -120,11 +150,15 @@ function nodeSize(n: TNode): { w: number; h: number } { const w = Math.max(measure(n.label, SMALL_FONT), ...lines.slice(0, 14).map((l) => measure(l, TYPE_FONT))) + 20; return { w: Math.min(360, Math.ceil(w)), h: 22 + Math.min(lines.length, 14) * 15 + (lines.length > 14 ? 15 : 0) + 6 }; } - const name = n.kind === "group" ? `group ${n.label}` : n.label; + const name = shownName(n); const w = Math.max(measure(name, NAME_FONT), n.typeName ? measure(n.typeName, TYPE_FONT) : 0) + 24 + (n.expandable ? 10 : 0); return { w: Math.min(340, Math.max(70, Math.ceil(w))), h: n.typeName ? 40 : 28 }; } +function shownName(n: TNode): string { + return markLine(n.diff, n.kind === "group" ? `group ${n.label}` : n.label); +} + /** Compositors open with their parent; elements and groups when asked. */ function isOpen(n: TNode, open: Set): boolean { if (!n.expandable) return false; @@ -209,7 +243,7 @@ export class TreeView extends Diagram { const n = p.n; const optional = n.min === 0; const many = n.max === null || n.max > 1; - const g = s("g", { class: `tn ${n.kind}${optional ? " optional" : ""}`, transform: `translate(${p.x} ${p.y})` }); + const g = s("g", { class: `tn ${n.kind}${optional ? " optional" : ""}${n.diff ? ` d-${n.diff}` : ""}`, transform: `translate(${p.x} ${p.y})` }); if (n.note) g.appendChild(s("text", { class: "note", x: 0, y: -7 }, n.note)); if (n.kind === "sequence" || n.kind === "choice" || n.kind === "all") { g.appendChild(s("rect", { class: "comp", width: p.w, height: p.h, rx: 11 })); @@ -228,13 +262,14 @@ export class TreeView extends Diagram { g.appendChild(s("rect", { class: "attrs", width: p.w, height: p.h, rx: 6 })); g.appendChild(s("text", { class: "attrs-head", x: 10, y: 12 }, n.label)); const lines = n.lines ?? []; - lines.slice(0, 14).forEach((l, i) => g.appendChild(s("text", { class: "attr-line", x: 10, y: 30 + i * 15 }, l))); + const lineCls = (l: string) => (l.startsWith("+ ") ? " d-added" : l.startsWith("− ") ? " d-removed" : l.startsWith("~ ") ? " d-changed" : ""); + lines.slice(0, 14).forEach((l, i) => g.appendChild(s("text", { class: `attr-line${lineCls(l)}`, x: 10, y: 30 + i * 15 }, l))); if (lines.length > 14) g.appendChild(s("text", { class: "attr-line more", x: 10, y: 30 + 14 * 15 }, `+${lines.length - 14} more`)); } else { if (many) g.appendChild(s("rect", { class: "box stack", x: 4, y: 4, width: p.w, height: p.h, rx: 4 })); g.appendChild(s("rect", { class: "box", width: p.w, height: p.h, rx: n.kind === "group" ? 10 : 4 })); const nameY = n.typeName ? 14 : p.h / 2; - g.appendChild(s("text", { class: "tname", x: 10, y: nameY }, n.kind === "group" ? `group ${n.label}` : n.label)); + g.appendChild(s("text", { class: "tname", x: 10, y: nameY }, shownName(n))); if (n.typeName) g.appendChild(s("text", { class: "ttype", x: 10, y: 30 }, n.typeName)); } if (n.min !== 1 || n.max !== 1) g.appendChild(s("text", { class: "occ", x: p.w, y: p.h + 10, "text-anchor": "end" }, occursText(n.min, n.max))); @@ -301,6 +336,13 @@ export class TreeView extends Diagram { .tn text.all{text-anchor:middle} .tn .expander circle{fill:${v("--se-panel")};stroke:${v("--se-class")}} .tn .expander path{stroke:${v("--se-class")};stroke-width:1.5} +.tn.d-added rect.box{stroke:${v("--se-add")};stroke-width:2.2;fill:${v("--se-add-bg")}} +.tn.d-changed rect.box{stroke:${v("--se-chg")};stroke-width:2.2;fill:${v("--se-chg-bg")}} +.tn.d-removed rect.box{stroke:${v("--se-del")};stroke-dasharray:4 3;fill:${v("--se-del-bg")}} +.tn.d-removed text.tname{text-decoration:line-through;fill:${v("--se-del")}} +.tn text.attr-line.d-added{fill:${v("--se-add")}} +.tn text.attr-line.d-changed{fill:${v("--se-chg")}} +.tn text.attr-line.d-removed{fill:${v("--se-del")};text-decoration:line-through} `; } } diff --git a/web/src/views/compare.ts b/web/src/views/compare.ts new file mode 100644 index 0000000..3c7a64b --- /dev/null +++ b/web/src/views/compare.ts @@ -0,0 +1,359 @@ +// Compare mode: the overview of what changed between two versions of a +// schema set, and a page per changed component with both versions side by side. +import type { AppCtx } from "../ctx"; +import { button, downloadBlob, h, plural, type Child } from "../dom"; +import { MARKER, STATUS_LABEL, changeLabel, changeMark, type Change, type Compare, type ComponentDiff, type Counts, type Impact, type Mark, type Status } from "../diff"; +import { diffReport } from "../loader"; +import { KIND_LABEL, KIND_SLUG, docsText, isId, occursText, type Id, type Kind, type Model } from "../model"; +import { section } from "./common"; + +const KINDS: Kind[] = ["complexType", "simpleType", "element", "attribute", "group", "attributeGroup"]; + +/** A +/−/~ marker; colour is a second cue, never the only one. */ +export function mark(m: Mark, title?: string): HTMLElement { + return h("span", { class: `dmark d-${m}`, title: title ?? STATUS_LABEL[m], "aria-label": STATUS_LABEL[m] }, MARKER[m]); +} + +function impactBadge(i: Impact): HTMLElement | null { + if (i === "breaking") return h("span", { class: "badge danger" }, "breaking"); + if (i === "info") return h("span", { class: "badge" }, "docs only"); + return null; +} + +const qnameOf = (c: ComponentDiff) => c.new?.qname ?? c.old?.qname ?? c.name; + +export function diffHref(ctx: AppCtx, c: ComponentDiff): string { + return ctx.hrefRoute({ view: "diffc", query: `${KIND_SLUG[c.kind]}/${qnameOf(c)}` }); +} + +function diffLink(ctx: AppCtx, c: ComponentDiff, text?: string): HTMLElement { + return h("a", { class: text ? "" : `mono d-${c.status}-text`, href: diffHref(ctx, c), onClick: (ev: Event) => { + const me = ev as MouseEvent; + if (me.metaKey || me.ctrlKey || me.shiftKey) return; + ev.preventDefault(); + ctx.goRoute({ view: "diffc", query: `${KIND_SLUG[c.kind]}/${qnameOf(c)}` }); + } }, text ?? qnameOf(c)); +} + +function countsTable(rows: [string, Counts][]): HTMLElement { + const n = (v: number, m?: Mark) => h("td", { class: "num" }, v ? [m ? mark(m) : null, " ", v.toLocaleString()] : h("span", { class: "faint" }, "0")); + return h( + "table", + { class: "ptable counts" }, + h("thead", null, h("tr", null, h("th", null, ""), h("th", { class: "num" }, "Added"), h("th", { class: "num" }, "Removed"), h("th", { class: "num" }, "Changed"), h("th", { class: "num" }, "Breaking"))), + h("tbody", null, rows.map(([label, c]) => h("tr", null, h("td", { class: "mono" }, label), n(c.added, "added"), n(c.removed, "removed"), n(c.changed, "changed"), h("td", { class: "num" }, c.breaking ? h("strong", { class: "d-removed-text" }, c.breaking.toLocaleString()) : h("span", { class: "faint" }, "0"))))) + ); +} + +/** The namespace a component counts under: a removed one's old namespace maps to the new one. */ +function nsOf(cmp: Compare, c: ComponentDiff): string { + const u = c.namespace ?? ""; + return c.status === "removed" ? cmp.d.namespaceMap?.[u] ?? u : u; +} + +function changeSummary(c: ComponentDiff): Child { + if (c.status === "added") return h("span", { class: "faint" }, "new"); + if (c.status === "removed") return c.possiblyRenamed ? h("span", null, "possibly renamed to ", h("code", null, c.possiblyRenamed)) : h("span", { class: "faint" }, "removed"); + const ch = c.changes ?? []; + const kinds = new Map(); + for (const x of ch) kinds.set(x.kind, (kinds.get(x.kind) ?? 0) + 1); + const parts = [...kinds].slice(0, 4).map(([k, n]) => `${changeLabel(k)}${n > 1 ? ` ×${n}` : ""}`); + return h("span", { class: "muted small" }, parts.join(" · ") + (kinds.size > 4 ? " …" : "")); +} + +async function download(cmp: Compare, format: "md" | "html" | "json"): Promise { + const name = `schema-diff.${format}`; + const text = format === "json" ? JSON.stringify(cmp.d, null, 2) : await diffReport({ ...cmp.d, old: { ...cmp.d.old, title: cmp.oldTitle }, new: { ...cmp.d.new, title: cmp.newTitle } }, format); + const type = format === "json" ? "application/json" : format === "md" ? "text/markdown" : "text/html"; + downloadBlob(new Blob([text], { type }), name); +} + +/** The overview of all changes, with filters kept in the link. */ +export function diffOverviewPage(ctx: AppCtx, cmp: Compare): HTMLElement { + const d = cmp.d; + const P = ctx.route.params; + const t = d.summary.total; + const page = h("div", { class: "diff-page" }); + page.appendChild(h("header", { class: "page-head" }, h("h1", null, "Changes from ", h("span", { class: "d-removed-text" }, cmp.oldTitle), " to ", h("span", { class: "d-added-text" }, cmp.newTitle)), h("p", { class: "lead" }, `${plural(t.added + t.removed + t.changed, "global component")} differ: ${t.added.toLocaleString()} added, ${t.removed.toLocaleString()} removed, ${t.changed.toLocaleString()} changed. ${plural(d.summary.breakingChanges, "breaking change")} in ${plural(t.breaking, "component")}; ${plural(t.docsOnly, "component")} changed only in their documentation.`))); + const filter = { st: P.st ?? "", k: P.k ?? "", imp: P.imp ?? "", ns: P.ns ?? "", q: P.q ?? "" }; + const stat = (label: string, value: number, m: Mark | null, set: Partial, cls = "") => + h("button", { type: "button", class: `stat ${cls}`, onClick: () => { + Object.assign(filter, { st: "", imp: "" }, set); + apply(); + } }, h("span", { class: "n" }, m ? mark(m) : null, " ", value.toLocaleString()), h("span", { class: "l" }, label)); + page.appendChild( + h( + "div", + { class: "stats" }, + stat("added", t.added, "added", { st: "added" }), + stat("removed", t.removed, "removed", { st: "removed" }), + stat("changed", t.changed, "changed", { st: "changed" }), + stat("breaking", t.breaking, null, { imp: "breaking" }, "stat-breaking"), + stat("docs only", t.docsOnly, null, { imp: "info" }), + stat("possibly renamed", d.summary.possiblyRenamed, null, { st: "renamed" }) + ) + ); + const nsMap = Object.entries(d.namespaceMap ?? {}); + if (nsMap.length) page.appendChild(h("div", { class: "card note-card" }, h("strong", null, "The target namespace changed. "), "Components were matched by kind and local name: ", nsMap.map(([a, b], i) => [i ? "; " : "", h("code", null, a), " → ", h("code", null, b)]))); + const tools = h("div", { class: "btn-group" }, button("Markdown", () => void download(cmp, "md"), { icon: "download", cls: "small", title: "Release notes as Markdown" }), button("HTML", () => void download(cmp, "html"), { cls: "small", title: "A standalone HTML report" }), button("JSON", () => void download(cmp, "json"), { cls: "small", title: "The diff model" })); + page.appendChild(section("Summary", { tools: [tools] })); + const kindRows: [string, Counts][] = KINDS.filter((k) => d.summary.byKind[k]).map((k) => [KIND_LABEL[k], d.summary.byKind[k]!]); + kindRows.push(["Total", t]); + const grid = h("div", { class: "grid-2 diff-sum" }, h("div", null, countsTable(kindRows))); + const nsRows = Object.entries(d.summary.byNamespace); + if (nsRows.length > 1) grid.appendChild(h("div", null, countsTable(nsRows.map(([k, c]) => [k || "(no namespace)", c])))); + page.appendChild(grid); + const files = Object.entries(d.summary.byFile); + if (files.length > 1) page.appendChild(h("details", { class: "card by-file" }, h("summary", null, `By file (${files.length})`), countsTable(files.map(([k, c]) => [k || "(none)", c])))); + + // Filters. + const opt = (v: string, label: string, cur: string) => h("option", { value: v, selected: v === cur }, label); + const q = h("input", { class: "input", type: "search", placeholder: "Filter by name…", value: filter.q, "aria-label": "Filter by name" }) as HTMLInputElement; + const st = h("select", { class: "input", "aria-label": "Status" }, opt("", "Any status", filter.st), (["added", "removed", "changed", "renamed"] as const).map((s) => opt(s, s === "renamed" ? "Possibly renamed" : STATUS_LABEL[s as Status], filter.st))) as HTMLSelectElement; + const k = h("select", { class: "input", "aria-label": "Kind" }, opt("", "Any kind", filter.k), KINDS.filter((x) => d.summary.byKind[x]).map((x) => opt(KIND_SLUG[x], KIND_LABEL[x], filter.k))) as HTMLSelectElement; + const imp = h("select", { class: "input", "aria-label": "Impact" }, opt("", "Any impact", filter.imp), opt("breaking", "Breaking", filter.imp), opt("nonBreaking", "Non-breaking", filter.imp), opt("info", "Documentation only", filter.imp)) as HTMLSelectElement; + const nsSel = h("select", { class: "input", "aria-label": "Namespace" }, opt("", "Any namespace", filter.ns), nsRows.map(([u]) => opt(u, u || "(no namespace)", filter.ns))) as HTMLSelectElement; + const count = h("span", { class: "count" }); + page.appendChild(h("div", { class: "section-title" }, h("span", null, "Changes"), count)); + page.appendChild(h("div", { class: "filters" }, q, st, k, imp, nsRows.length > 1 ? nsSel : null)); + const list = h("div", { class: "diff-list" }); + page.appendChild(list); + let limit = 400; + const render = () => { + const needle = filter.q.trim().toLowerCase(); + const rows = d.components.filter( + (c) => + (!filter.st || (filter.st === "renamed" ? !!c.possiblyRenamed : c.status === filter.st)) && + (!filter.k || KIND_SLUG[c.kind] === filter.k) && + (!filter.imp || c.impact === filter.imp) && + (!filter.ns || nsOf(cmp, c) === filter.ns) && + (!needle || qnameOf(c).toLowerCase().includes(needle) || (c.old?.qname ?? "").toLowerCase().includes(needle)) + ); + count.textContent = rows.length.toLocaleString(); + const table = h("table", { class: "ptable diff-table" }, h("thead", null, h("tr", null, h("th", null, h("span", { class: "sr-only" }, "Status")), h("th", null, "Component"), h("th", null, "Kind"), h("th", null, "What changed"), h("th", null, "Impact")))); + const body = h("tbody"); + for (const c of rows.slice(0, limit)) { + body.appendChild(h("tr", { class: `d-${c.status}` }, h("td", null, mark(c.status)), h("td", null, diffLink(ctx, c), c.byLocalName && c.old && c.old.qname !== c.new?.qname ? h("div", { class: "faint small" }, "was ", c.old.qname) : null), h("td", null, h("span", { class: `badge k-${c.kind}` }, KIND_LABEL[c.kind])), h("td", null, changeSummary(c)), h("td", null, impactBadge(c.impact) ?? h("span", { class: "faint small" }, "non-breaking")))); + } + table.appendChild(body); + list.replaceChildren(rows.length ? table : h("p", { class: "muted" }, "No change matches the filters.")); + if (rows.length > limit) list.appendChild(button(`Show all ${rows.length.toLocaleString()}`, () => { + limit = rows.length; + render(); + }, { cls: "small" })); + }; + const apply = () => { + q.value = filter.q; + st.value = filter.st; + k.value = filter.k; + imp.value = filter.imp; + nsSel.value = filter.ns; + ctx.setParams({ ...filter }); + render(); + }; + q.addEventListener("input", () => { + filter.q = q.value; + apply(); + }); + for (const [el, key] of [[st, "st"], [k, "k"], [imp, "imp"], [nsSel, "ns"]] as const) + el.addEventListener("change", () => { + filter[key] = el.value; + apply(); + }); + render(); + return page; +} + +// ---- one component --------------------------------------------------------- + +interface Line { + key: string; + name: string; + detail: string; + /** Section header (inherited from …). */ + group?: string; +} + +/** The type whose content and attributes describe a component. */ +function typeOf(m: Model, id: Id): Id | undefined { + const c = m.comp(id); + if (c.kind === "complexType" || c.kind === "simpleType") return id; + if (c.kind === "element") { + const t = m.elementType(id); + return isId(t) ? t : undefined; + } + if (c.kind === "attribute") { + const t = c.attribute?.type; + return isId(t) ? t : undefined; + } + return undefined; +} + +function contentLines(m: Model, id: Id): Line[] { + const c = m.comp(id); + const out: Line[] = []; + if (c.kind === "group" && c.group) { + const rows: ReturnType = []; + m.particleRows(c.group, id, 0, false, rows, new Set()); + return rowsToLines(m, rows, id); + } + const t = typeOf(m, id); + if (t === undefined || m.comp(t).kind !== "complexType") return out; + return rowsToLines(m, m.contentRows(t, { inherited: true }), t); +} + +function rowsToLines(m: Model, rows: ReturnType, own: Id): Line[] { + const out: Line[] = []; + const seen = new Map(); + for (const r of rows) { + const indent = " ".repeat(r.depth); + let name: string; + let detail: string; + if (r.kind === "compositor") { + name = `${indent}${r.compositor}`; + detail = occursText(r.min, r.max); + } else if (r.kind === "any") { + name = `${indent}any`; + detail = `${r.wildcard?.namespace ?? "##any"} ${occursText(r.min, r.max)}`; + } else if (r.kind === "group") { + name = `${indent}group ${r.id !== undefined ? m.qname(r.id) : m.refName(r.missing)}`; + detail = occursText(r.min, r.max); + } else { + const local = r.id !== undefined ? m.comp(r.id).name ?? "" : m.refName(r.missing); + name = `${indent}${local}`; + const t = r.id !== undefined ? m.elementType(r.id) : undefined; + detail = `${m.typeName(t) || ""} ${occursText(r.min, r.max)}`.trim(); + } + const base = r.kind === "element" ? name.trim() : name; + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + out.push({ key: n > 1 ? `${base}#${n}` : base, name, detail, group: r.from !== own ? `from ${m.label(r.from)}` : undefined }); + } + return out; +} + +function attrLines(m: Model, id: Id): Line[] { + const c = m.comp(id); + if (c.kind === "attributeGroup") { + return (c.attributeGroup?.attributes ?? []).map((a) => { + if (a.t === "group") return { key: `group ${m.refName(a.group)}`, name: `attributeGroup ${m.refName(a.group)}`, detail: "" }; + const name = isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr); + const t = m.attrType(a.attr); + return { key: name, name: `@${name}`, detail: `${m.typeName(t)} ${a.use ?? "optional"}${a.default !== undefined ? ` = ${a.default}` : ""}${a.fixed !== undefined ? ` fixed ${a.fixed}` : ""}` }; + }); + } + const t = typeOf(m, id); + if (t === undefined || m.comp(t).kind !== "complexType") return []; + return m.attributes(t).map((a) => { + const name = isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr); + return { key: name, name: `@${name}`, detail: `${m.typeName(m.attrType(a.attr))} ${a.use ?? "optional"}${a.default !== undefined ? ` = ${a.default}` : ""}${a.fixed !== undefined ? ` fixed ${a.fixed}` : ""}`, group: a.from !== t ? `from ${m.label(a.from)}` : undefined }; + }); +} + +function enumLines(m: Model, id: Id): Line[] { + const t = typeOf(m, id); + return t === undefined ? [] : m.enumerations(t).map((e) => ({ key: e.value, name: e.value, detail: "" })); +} + +function facetLines(m: Model, id: Id): Line[] { + const t = typeOf(m, id); + if (t === undefined) return []; + const c = m.comp(t); + const out: Line[] = []; + const base = c.simpleType?.base ?? c.complexType?.derivation?.base; + if (base !== undefined) out.push({ key: "base", name: "base", detail: m.refName(base) }); + for (const f of c.simpleType?.facets ?? c.complexType?.facets ?? []) out.push({ key: `${f.kind} ${f.value}`, name: f.kind, detail: f.value }); + return out; +} + +/** Two columns of lines; what differs is marked on both sides. */ +function sideBySide(title: string, a: Line[], b: Line[], labels: [string, string]): HTMLElement | null { + if (!a.length && !b.length) return null; + const ma = new Map(a.map((l) => [l.key, l])); + const mb = new Map(b.map((l) => [l.key, l])); + const col = (lines: Line[], other: Map, gone: Mark, head: string) => { + const body = h("tbody"); + let group: string | undefined; + for (const l of lines) { + if (l.group !== group) { + group = l.group; + if (group) body.appendChild(h("tr", { class: "group-row" }, h("td", { colspan: 3 }, group))); + } + const o = other.get(l.key); + const st: Mark | undefined = !o ? gone : o.detail !== l.detail ? "changed" : undefined; + body.appendChild(h("tr", { class: st ? `d-row d-${st}` : "" }, h("td", { class: "mk" }, st ? mark(st) : null), h("td", { class: "mono pre" }, l.name), h("td", { class: "mono faint" }, l.detail))); + } + if (!lines.length) body.appendChild(h("tr", null, h("td", { colspan: 3, class: "faint" }, "(none)"))); + return h("div", { class: "side" }, h("div", { class: "side-head" }, head), h("table", { class: "ptable side-table" }, body)); + }; + const n = b.filter((l) => !ma.has(l.key)).length + a.filter((l) => !mb.has(l.key)).length + b.filter((l) => ma.has(l.key) && ma.get(l.key)!.detail !== l.detail).length; + return h("div", null, section(title, { count: n || undefined }), h("div", { class: "sbs" }, col(a, mb, "removed", labels[0]), col(b, ma, "added", labels[1]))); +} + +function changeRow(c: Change): HTMLElement { + const m = changeMark(c.kind); + const val = (v: string | undefined) => (v === undefined ? h("span", { class: "faint" }, "—") : h("code", null, v.length > 160 ? v.slice(0, 160) + "…" : v)); + const values = c.kind === "docsChanged" ? h("span", { class: "faint small" }, "see Documentation below") : c.old !== undefined || c.new !== undefined ? h("span", null, val(c.old), " → ", val(c.new)) : null; + return h("tr", { class: `d-row d-${m}` }, h("td", { class: "mk" }, mark(m)), h("td", null, changeLabel(c.kind)), h("td", { class: "mono" }, c.path ? h("span", { class: "faint" }, c.path + " › ") : null, c.subject ?? ""), h("td", null, values), h("td", null, impactBadge(c.impact) ?? h("span", { class: "faint small" }, "non-breaking"))); +} + +/** One changed, added or removed component with both versions side by side. */ +export function diffComponentPage(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTMLElement { + const page = h("div", { class: "diff-page" }); + const back = h("a", { href: ctx.hrefRoute({ view: "diff" }), onClick: (ev: Event) => { + ev.preventDefault(); + ctx.goRoute({ view: "diff" }); + } }, "All changes"); + page.appendChild(h("div", { class: "crumbs" }, back, h("span", { class: "sep" }, "›"), h("span", null, qnameOf(c)))); + const open = c.new ? button(`Open in ${cmp.newTitle}`, () => ctx.go(c.new!.id), { icon: "external", cls: "small" }) : null; + page.appendChild( + h( + "header", + { class: "page-head" }, + h("div", { class: "head-main" }, h("div", { class: "badges" }, h("span", { class: `badge k-${c.kind}` }, KIND_LABEL[c.kind]), h("span", { class: `badge d-badge d-${c.status}` }, MARKER[c.status], " ", STATUS_LABEL[c.status]), impactBadge(c.impact), c.byLocalName ? h("span", { class: "badge", title: "matched by local name: the namespace changed" }, "matched by name") : null), + h("h1", { class: `mono d-${c.status}-text` }, qnameOf(c)), + c.old && c.new && c.old.qname !== c.new.qname ? h("p", { class: "muted" }, "Was ", h("code", null, c.old.qname), ` in ${cmp.oldTitle}.`) : null, + c.possiblyRenamed ? h("p", null, c.status === "removed" ? "Possibly renamed to " : "Possibly renamed from ", diffTarget(ctx, cmp, c), " (near-identical content).") : null, + h("p", { class: "muted small" }, [c.old ? `${cmp.oldTitle}: ${c.old.file ?? ""}${c.old.line ? `:${c.old.line}` : ""}` : null, c.old && c.new ? " · " : null, c.new ? `${cmp.newTitle}: ${c.new.file ?? ""}${c.new.line ? `:${c.new.line}` : ""}` : null]), + open) + ) + ); + const ch = c.changes ?? []; + if (ch.length) { + const sorted = [...ch].sort((a, b) => (a.impact === b.impact ? 0 : a.impact === "breaking" ? -1 : b.impact === "breaking" ? 1 : a.impact === "info" ? 1 : -1)); + page.append(section("Changes", { count: ch.length }), h("table", { class: "ptable change-table" }, h("thead", null, h("tr", null, h("th", null, h("span", { class: "sr-only" }, "Kind")), h("th", null, "Change"), h("th", null, "Where"), h("th", null, "Old → new"), h("th", null, "Impact"))), h("tbody", null, sorted.map(changeRow)))); + } + const labels: [string, string] = [cmp.oldTitle, cmp.newTitle]; + const o = c.old ? c.old.id : undefined; + const n = c.new ? c.new.id : undefined; + const lines = (f: (m: Model, id: Id) => Line[], m: Model, id: Id | undefined) => (id === undefined ? [] : f(m, id)); + for (const [title, f] of [["Content model", contentLines], ["Attributes", attrLines], ["Base and facets", facetLines], ["Enumeration", enumLines]] as const) { + const el = sideBySide(title, lines(f, cmp.old, o), lines(f, cmp.cur, n), labels); + if (el) page.appendChild(el); + } + const da = o !== undefined ? docsText(cmp.old.comp(o).docs) : ""; + const db = n !== undefined ? docsText(cmp.cur.comp(n).docs) : ""; + const docChanges = ch.filter((x) => x.kind === "docsChanged" && (x.path || x.subject)); + if (da || db || docChanges.length) { + const differs = da.replace(/\s+/g, " ").trim() !== db.replace(/\s+/g, " ").trim(); + const doc = (text: string, st: Mark | undefined, head: string) => h("div", { class: `side${st ? ` d-${st}` : ""}` }, h("div", { class: "side-head" }, st ? mark(st) : null, " ", head), text ? h("p", { class: "doc-text" }, text) : h("p", { class: "faint" }, "(none)")); + page.append(section("Documentation"), h("div", { class: "sbs" }, doc(da, differs ? "removed" : undefined, labels[0]), doc(db, differs ? "added" : undefined, labels[1]))); + for (const x of docChanges) { + page.append(h("h3", { class: "doc-sub mono" }, [x.path, x.subject].filter(Boolean).join(" › ")), h("div", { class: "sbs" }, doc(x.old ?? "", "removed", labels[0]), doc(x.new ?? "", "added", labels[1]))); + } + } + return page; +} + +function diffTarget(ctx: AppCtx, cmp: Compare, c: ComponentDiff): Child { + const other = cmp.d.components.find((x) => x.kind === c.kind && x !== c && qnameOf(x) === c.possiblyRenamed); + return other ? diffLink(ctx, other) : h("code", null, c.possiblyRenamed ?? ""); +} + +/** The banner on a normal component page while comparing. */ +export function diffBanner(ctx: AppCtx, cmp: Compare, id: Id): HTMLElement | null { + const c = cmp.byNew.get(id); + if (!c) return cmp.d.components.length && ctx.m.comp(id).parent === undefined && !ctx.m.comp(id).builtin ? h("div", { class: "diff-banner" }, h("span", { class: "faint" }, `Unchanged since ${cmp.oldTitle}.`)) : null; + const n = c.changes?.length ?? 0; + return h("div", { class: `diff-banner d-${c.status}` }, mark(c.status), " ", c.status === "added" ? `New in ${cmp.newTitle}.` : `${plural(n, "change")} since ${cmp.oldTitle}${c.impact === "breaking" ? ", some breaking" : ""}.`, " ", diffLink(ctx, c, "Compare the versions"), h("span", { class: "faint small legend-diff" }, mark("added"), " added ", mark("removed"), " removed ", mark("changed"), " changed")); +} diff --git a/web/src/views/diagramPanel.ts b/web/src/views/diagramPanel.ts index fef5fc5..1bd581e 100644 --- a/web/src/views/diagramPanel.ts +++ b/web/src/views/diagramPanel.ts @@ -5,7 +5,8 @@ import { Diagram, type NodeAction, type View } from "../diagram"; import { button, copyText, h, icon } from "../dom"; import { buildNeighborhood, defaultNeighborhood, toMermaid, toPlantUml, type GraphSpec, type NeighborhoodOptions } from "../graph"; import type { Id } from "../model"; -import { TreeView, rootNode, type TNode } from "../tree"; +import { TreeView, rootNode, setTreeDiff, type TNode } from "../tree"; +import { annotateGraph } from "../diff"; /** What a click on a box in a diagram offers. */ export function boxActions(ctx: AppCtx, id: Id, expand?: { expanded: boolean; toggle: () => void }): NodeAction[] { @@ -128,6 +129,7 @@ export function diagramPanel(ctx: AppCtx, focus: Id, opts: { tall?: boolean } = if (!started) return; wrap.appendChild(overlay); graph = buildNeighborhood(m, o); + if (ctx.cmp) annotateGraph(graph, ctx.cmp); const start = restoreView ? view : undefined; restoreView = false; sync(); @@ -195,6 +197,7 @@ export function treePanel(ctx: AppCtx, focus: Id): HTMLElement { const wrap = h("div", { class: "diagram tall" }); const statusText = h("span"); const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, h("span", { class: "dashed" }, "optional"), h("span", { class: "stacked" }, "repeated"))); + setTreeDiff(ctx.cmp); const root = rootNode(m, focus); let started = false; const toggleNode = (n: TNode, isOpen: boolean) => { diff --git a/web/src/wasm.worker.ts b/web/src/wasm.worker.ts index a8e8233..f5fdb71 100644 --- a/web/src/wasm.worker.ts +++ b/web/src/wasm.worker.ts @@ -1,8 +1,12 @@ // Indexes XSD files with the Rust core compiled to WebAssembly, off the main thread. -import init, { expand, index } from "./wasm/schema_explorer_wasm.js"; +import init, { diff, diff_report, expand, index } from "./wasm/schema_explorer_wasm.js"; import wasmUrl from "./wasm/schema_explorer_wasm_bg.wasm?url"; -type Req = { id: number; op: "index"; names: string[]; contents: Uint8Array[] } | { id: number; op: "expand"; json: string }; +type Req = + | { id: number; op: "index"; names: string[]; contents: Uint8Array[] } + | { id: number; op: "expand"; json: string } + | { id: number; op: "diff"; old: string; new: string; options: string } + | { id: number; op: "report"; json: string; format: string }; let ready: Promise | null = null; @@ -12,7 +16,8 @@ self.onmessage = async (ev: MessageEvent) => { if (!ready) ready = init({ module_or_path: wasmUrl }); await ready; const started = performance.now(); - const json = req.op === "index" ? index(req.names, req.contents, false) : expand(req.json); + const json = + req.op === "index" ? index(req.names, req.contents, false) : req.op === "expand" ? expand(req.json) : req.op === "diff" ? diff(req.old, req.new, req.options) : diff_report(req.json, req.format); (self as unknown as Worker).postMessage({ id: req.id, json, ms: performance.now() - started }); } catch (e) { (self as unknown as Worker).postMessage({ id: req.id, error: String(e) });