diff --git a/README.md b/README.md index b9b94a0..2c40a2f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,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/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 { 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 c5302b0..a9f4201 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, @@ -65,6 +70,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) { @@ -106,6 +112,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; } @@ -162,7 +171,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 { @@ -209,6 +221,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 : ""); } @@ -225,6 +241,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 }; @@ -268,8 +288,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; @@ -352,9 +376,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(); @@ -393,10 +543,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}`; @@ -427,6 +587,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" })); @@ -493,6 +654,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 6e2fdf1..06c8b33 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; @@ -2009,3 +2021,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) });