From 3e38d071dcb773195aa563d752eccc9fafc354c9 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Sun, 20 Sep 2026 15:29:04 -0700 Subject: [PATCH] chore(release): v0.9.1 --- .gitignore | 1 - CHANGELOG.md | 98 ++++ Cargo.lock | 26 +- Cargo.toml | 16 +- crates/sysmlv2-cbor/Cargo.toml | 2 +- crates/sysmlv2-cli/Cargo.toml | 2 +- crates/sysmlv2-lint/Cargo.toml | 4 +- crates/sysmlv2-model/Cargo.toml | 2 +- crates/sysmlv2-parser/Cargo.toml | 4 +- crates/sysmlv2-py/Cargo.toml | 10 +- crates/sysmlv2-solve/Cargo.toml | 4 +- crates/sysmlv2-transform/Cargo.toml | 6 +- crates/sysmlv2-viz/Cargo.toml | 4 +- crates/sysmlv2-viz/src/graph.rs | 482 ++++++++++++++-- crates/sysmlv2-viz/src/lib.rs | 30 + .../tests/golden/graph-full-options.json | 1 + .../tests/golden/graph-overlapping-roots.json | 1 + crates/sysmlv2-viz/tests/viz.rs | 521 +++++++++++++++++- crates/sysmlv2-wasm/src/lib.rs | 147 ++++- crates/sysmlv2-wasm/tests/api.rs | 94 ++++ rust-toolchain.toml | 6 + 21 files changed, 1363 insertions(+), 98 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 crates/sysmlv2-viz/tests/golden/graph-full-options.json create mode 100644 crates/sysmlv2-viz/tests/golden/graph-overlapping-roots.json create mode 100644 rust-toolchain.toml diff --git a/.gitignore b/.gitignore index fada066..dc22136 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ target/ target-wasm/ target-msrv/ -/CLAUDE.local.md node_modules/ .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c628350 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,98 @@ +# Changelog + +## v0.9.1 + +- Added summary mode for large tree graphs: collapsed containers report hidden element counts, references to hidden elements are grouped with counts, and excess notes are counted on their targets. +- Added configurable member and note limits, with per-container overrides to show every direct member. +- Added WebAssembly controls to expand containers by qualified name or element ID, report unresolved selections, and reveal an element's containing path with `revealPath`. +- Included the pinned Rust toolchain configuration in the public source release. + +## v0.9.0 + +### Added + +- Derived-property access in Rust, Python, and WebAssembly for names, ownership, annotations, types, connector ends, behaviors, expressions, requirements, and views. Property catalogs report which values are exact, approximate, or not computed. +- Optional inheritance and import closures in derived properties and full-form export, including implied library inheritance. Closure expansion is opt-in. +- JSON reports for `check` and `lint`, with consistent positions, stages, severities, rule identifiers, fixes, and summary counts. +- Checks for conflicting inherited member names, an `inherited-name-shadow` lint with a redefinition fix, and editor actions to fix all findings of one rule. +- Editor requests to preview and apply package splitting: `sysmlv2/splitPlan` and `sysmlv2/split`. +- Reusable prepared libraries across in-memory sessions, including a WebAssembly `PreparedLibrary` handle, and reusable parsed-source inputs for validation. +- Session checks reuse the session's resolved model without changing lint or unused-import findings; syntax-only checks are available separately. +- Python `Error` and `RefusedError` exceptions; Python checks release the interpreter lock while running. + +### Fixed + +- Interchange sessions preserve supplied element IDs across loading, edits, and export. Compact and full forms retain the same identities, including references to external elements. +- JSON and CBOR round trips preserve long expressions and owned element graphs. Cycles, excessive depth, and incomplete reconstruction report errors instead of silently dropping content. +- Numeric literals that exceed JSON numeric precision retain their exact text. Full-form output preserves external references and derives names, implied relationships, and connector structure consistently. +- Evaluation keeps type defaults unknown through unbound subjects, references, and inputs, including aliases, nested members, and calculation calls. Fixed formulas use the receiver's redefinitions; unsupported nested solver paths remain undecided. +- Parser handling of accept actions, conditional triggers, `else` branches, word-operator operands, and escaped names and strings. Binding and connector printing preserves ends in both dialects, including one-ended and multi-ended bindings. +- Deep nesting and long operator chains produce bounded diagnostics; parsing entry points reserve sufficient stack space and report reservation failures. Evaluation budgets cover all materialized values. +- Refactorings reject edits to library declarations and blank source names, avoid conflicting renames and overlapping qualification edits, and remove whole member lines correctly with either line ending. Inferred typing fixes account for values and redefinitions. +- Language-server sessions survive malformed requests and recoverable panics, report model and library failures, handle encoded file paths and large offsets correctly, and process documents in a stable order. +- Model-file extensions are recognized regardless of case. CLI output handles closed pipes quietly, and project archives validate offsets and total expanded size. +- Library caches handle concurrent writes and invalid data safely, with clearer failures and fewer repeated warnings. +- CBOR readers reject malformed references, invalid map lengths, and out-of-range integers and type codes without panicking or truncating values. +- Solver propagation continues tightening one-sided ranges, supports enumerations beyond 128 literals, and avoids repeated operand evaluation. Solver failures preserve their causes and clean up child processes. +- Diagram labels, notes, and links escape line breaks, backslashes, and delimiters correctly. +- Generated web libraries pass inherited-name checks; displayed element names follow the specification's naming rules. + +### Performance + +- Reduced repeated parsing, graph construction, metaclass lookup, import resolution, and full-form export work. Linting, editor diagnostics, binary conversion, and diagram generation reuse indexes and shared data. + +### Upgrade notes + +- Rust 1.97 or newer is required. Development and WebAssembly package builds use the pinned toolchain. +- Rust diagram options use default construction and setters. Several public error and result types are now typed or non-exhaustive; callers may need updated matches and error handling. +- Compact CBOR marks explicit IDs with a new flag. Sessions with explicit IDs reject ID-elided snapshots and delta export. +- JSON consumers must accept numeric literal values as either numbers or exact-text strings; the string form is a documented departure from the interchange schema. + +## v0.8.0 + +### Added + +- Guided lint repairs for incompatible usage kinds, composite port members, unqualified enumeration literals, inaccessible private members, and import visibility, with editor quick fixes and a fix-all action. +- Package splitting into per-child files through the transformation API, `refactor split`, and editor move actions. +- Opt-in repair of parse-broken sources for session construction, with records of removed or appended text and any unrepaired units. +- Diagnostic stages distinguish parse, context, reference-resolution, and semantic findings. +- Inherited-member enumeration and dialect-aware reference spelling in the model API and bindings. +- WebAssembly view information and view-directed diagrams based on exposed elements. + +### Fixed + +- Generated references and edits quote reserved words for the source dialect. +- Named dependencies, documentation, comments, and textual representations resolve in their namespaces. Inheritance and recursive imports respect lookup precedence and import order. +- Full-form interchange derives ownership, annotation endpoints, and type multiplicity correctly. +- Diagrams resolve library renderings, retain explicitly inherited library ports, omit generic implied ports, and draw nothing for views with no exposed elements. +- Anonymous comments keep their indentation; editor symbols always have nonempty labels; CLI diagnostics handle multibyte text safely. +- Diagnostic rendering and full-form relationship export reuse indexes to reduce repeated work. + +## v0.7.2 + +- Constraint propagation uses exact rational interval endpoints, preserving decimal bounds without rounding drift. Solver witnesses also retain exact rational values. +- Editor and WebAssembly range reports provide marked approximate decimals alongside exact ranges. +- **Rust API change:** `WitnessValue::Real` now carries a rational value. + +## v0.7.1 + +- Fixed diagram occurrence endpoints and implicit action succession, preserving the identity of connected elements. + +## v0.7.0 + +### Added + +- Exact rational arithmetic for numeric evaluation, comparisons, unit scales, and solver literals, exposed through Python and WebAssembly. Editor hints mark approximate displays of non-terminating decimals. +- Broader static checks for typing, dimensions, structure, relationships, expressions, connector accessibility, and action/state contracts, plus warnings for declarations that shadow standard-library roots. +- Evaluation limits for steps, ranges, strings, cumulative allocation, and exact-number size. +- WebAssembly lookup of anonymous elements by interchange ID. + +### Fixed + +- Anonymous redefinitions no longer target sibling declarations. KerML feature chains resolve in the preceding feature's context, and indexing is distinguished from quantity diagnostics. +- Full-form export includes effective qualified names for redefined elements and correct library-element flags. Calculation results receive typing and dimension checks. +- WebAssembly packaging selects the current package when older archives are present; generated web attributes with unrestricted types use `Base::DataValue`. + +### Performance + +- Prepared library graphs, shared lookup tables, deferred syntax loading, and compact semantic storage reduce library startup, memory use, and repeated validation work. Connector and unused-import checks reuse indexes; failed cache saves warn once per process. diff --git a/Cargo.lock b/Cargo.lock index a2a1834..a611e06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1711,7 +1711,7 @@ dependencies = [ [[package]] name = "sysmlv2-cbor" -version = "0.9.0" +version = "0.9.1" dependencies = [ "miniz_oxide", "serde_json", @@ -1723,7 +1723,7 @@ dependencies = [ [[package]] name = "sysmlv2-cli" -version = "0.9.0" +version = "0.9.1" dependencies = [ "clap", "miniz_oxide", @@ -1743,7 +1743,7 @@ dependencies = [ [[package]] name = "sysmlv2-lint" -version = "0.9.0" +version = "0.9.1" dependencies = [ "regex-lite", "serde_json", @@ -1755,7 +1755,7 @@ dependencies = [ [[package]] name = "sysmlv2-lsp" -version = "0.9.0" +version = "0.9.1" dependencies = [ "crossbeam-channel", "lsp-server", @@ -1771,7 +1771,7 @@ dependencies = [ [[package]] name = "sysmlv2-model" -version = "0.9.0" +version = "0.9.1" dependencies = [ "num-bigint", "num-integer", @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "sysmlv2-parser" -version = "0.9.0" +version = "0.9.1" dependencies = [ "jsonschema", "serde_json", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "sysmlv2-py" -version = "0.9.0" +version = "0.9.1" dependencies = [ "pyo3", "serde_json", @@ -1810,7 +1810,7 @@ dependencies = [ [[package]] name = "sysmlv2-solve" -version = "0.9.0" +version = "0.9.1" dependencies = [ "sysmlv2-model", "sysmlv2-syntax", @@ -1819,18 +1819,18 @@ dependencies = [ [[package]] name = "sysmlv2-syntax" -version = "0.9.0" +version = "0.9.1" dependencies = [ "serde", ] [[package]] name = "sysmlv2-testkit" -version = "0.9.0" +version = "0.9.1" [[package]] name = "sysmlv2-transform" -version = "0.9.0" +version = "0.9.1" dependencies = [ "serde_json", "sysmlv2-cbor", @@ -1843,7 +1843,7 @@ dependencies = [ [[package]] name = "sysmlv2-viz" -version = "0.9.0" +version = "0.9.1" dependencies = [ "serde", "serde_json", @@ -1854,7 +1854,7 @@ dependencies = [ [[package]] name = "sysmlv2-wasm" -version = "0.9.0" +version = "0.9.1" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7d467e3..f657545 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,21 +44,21 @@ default-members = [ ] [workspace.package] -version = "0.9.0" +version = "0.9.1" edition = "2021" rust-version = "1.97" license = "Apache-2.0" repository = "https://github.com/Open-MBEE/sysml-toolkit" [workspace.dependencies] -sysmlv2-parser = { path = "crates/sysmlv2-parser", version = "0.9.0" } -sysmlv2-cbor = { path = "crates/sysmlv2-cbor", version = "0.9.0" } -sysmlv2-solve = { path = "crates/sysmlv2-solve", version = "0.9.0" } -sysmlv2-lint = { path = "crates/sysmlv2-lint", version = "0.9.0" } +sysmlv2-parser = { path = "crates/sysmlv2-parser", version = "0.9.1" } +sysmlv2-cbor = { path = "crates/sysmlv2-cbor", version = "0.9.1" } +sysmlv2-solve = { path = "crates/sysmlv2-solve", version = "0.9.1" } +sysmlv2-lint = { path = "crates/sysmlv2-lint", version = "0.9.1" } sysmlv2-testkit = { path = "crates/sysmlv2-testkit" } -sysmlv2-transform = { path = "crates/sysmlv2-transform", version = "0.9.0" } -sysmlv2-viz = { path = "crates/sysmlv2-viz", version = "0.9.0" } -sysmlv2-lsp = { path = "crates/sysmlv2-lsp", version = "0.9.0" } +sysmlv2-transform = { path = "crates/sysmlv2-transform", version = "0.9.1" } +sysmlv2-viz = { path = "crates/sysmlv2-viz", version = "0.9.1" } +sysmlv2-lsp = { path = "crates/sysmlv2-lsp", version = "0.9.1" } lsp-server = "0.10" lsp-types = "0.97" crossbeam-channel = "0.5" diff --git a/crates/sysmlv2-cbor/Cargo.toml b/crates/sysmlv2-cbor/Cargo.toml index 934c97c..cf12fa8 100644 --- a/crates/sysmlv2-cbor/Cargo.toml +++ b/crates/sysmlv2-cbor/Cargo.toml @@ -12,7 +12,7 @@ repository.workspace = true include = ["src/**", "Cargo.toml", "README.md"] [dependencies] -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/sysmlv2-cli/Cargo.toml b/crates/sysmlv2-cli/Cargo.toml index 08fc42e..b956ff4 100644 --- a/crates/sysmlv2-cli/Cargo.toml +++ b/crates/sysmlv2-cli/Cargo.toml @@ -44,4 +44,4 @@ xattr = "1" [dev-dependencies] sysmlv2-testkit.workspace = true -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } diff --git a/crates/sysmlv2-lint/Cargo.toml b/crates/sysmlv2-lint/Cargo.toml index d8b5f1d..60dee5c 100644 --- a/crates/sysmlv2-lint/Cargo.toml +++ b/crates/sysmlv2-lint/Cargo.toml @@ -12,8 +12,8 @@ repository.workspace = true include = ["src/**", "Cargo.toml"] [dependencies] -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0" } -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } serde_json.workspace = true regex-lite.workspace = true diff --git a/crates/sysmlv2-model/Cargo.toml b/crates/sysmlv2-model/Cargo.toml index 39de2ee..f774125 100644 --- a/crates/sysmlv2-model/Cargo.toml +++ b/crates/sysmlv2-model/Cargo.toml @@ -12,7 +12,7 @@ repository.workspace = true include = ["src/**", "build.rs", "Cargo.toml"] [dependencies] -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0", features = ["serde"] } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1", features = ["serde"] } serde = { workspace = true, features = ["rc"] } serde_json.workspace = true uuid = { workspace = true, features = ["serde"] } diff --git a/crates/sysmlv2-parser/Cargo.toml b/crates/sysmlv2-parser/Cargo.toml index 75f5e00..8130f5b 100644 --- a/crates/sysmlv2-parser/Cargo.toml +++ b/crates/sysmlv2-parser/Cargo.toml @@ -22,8 +22,8 @@ default = ["json"] json = ["dep:sysmlv2-model", "dep:serde_json"] [dependencies] -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0" } -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0", optional = true } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1", optional = true } # for tests/examples that build serde_json values directly serde_json = { workspace = true, optional = true } diff --git a/crates/sysmlv2-py/Cargo.toml b/crates/sysmlv2-py/Cargo.toml index 67f13b2..30863bc 100644 --- a/crates/sysmlv2-py/Cargo.toml +++ b/crates/sysmlv2-py/Cargo.toml @@ -17,10 +17,10 @@ name = "sysmlv2" crate-type = ["cdylib", "rlib"] [dependencies] -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0" } -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } -sysmlv2-transform = { path = "../sysmlv2-transform", version = "0.9.0" } -sysmlv2-viz = { path = "../sysmlv2-viz", version = "0.9.0" } -sysmlv2-lsp = { path = "../sysmlv2-lsp", version = "0.9.0" } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } +sysmlv2-transform = { path = "../sysmlv2-transform", version = "0.9.1" } +sysmlv2-viz = { path = "../sysmlv2-viz", version = "0.9.1" } +sysmlv2-lsp = { path = "../sysmlv2-lsp", version = "0.9.1" } serde_json.workspace = true pyo3 = { version = "0.29", features = ["abi3-py39"] } diff --git a/crates/sysmlv2-solve/Cargo.toml b/crates/sysmlv2-solve/Cargo.toml index dac16ab..9248191 100644 --- a/crates/sysmlv2-solve/Cargo.toml +++ b/crates/sysmlv2-solve/Cargo.toml @@ -12,8 +12,8 @@ repository.workspace = true include = ["src/**", "Cargo.toml"] [dependencies] -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0" } -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } [dev-dependencies] sysmlv2-testkit.workspace = true diff --git a/crates/sysmlv2-transform/Cargo.toml b/crates/sysmlv2-transform/Cargo.toml index 1d47d40..b4649a5 100644 --- a/crates/sysmlv2-transform/Cargo.toml +++ b/crates/sysmlv2-transform/Cargo.toml @@ -12,9 +12,9 @@ repository.workspace = true include = ["src/**", "Cargo.toml"] [dependencies] -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0" } -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } -sysmlv2-cbor = { path = "../sysmlv2-cbor", version = "0.9.0" } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } +sysmlv2-cbor = { path = "../sysmlv2-cbor", version = "0.9.1" } uuid.workspace = true serde_json.workspace = true diff --git a/crates/sysmlv2-viz/Cargo.toml b/crates/sysmlv2-viz/Cargo.toml index 0aff27c..f432ce9 100644 --- a/crates/sysmlv2-viz/Cargo.toml +++ b/crates/sysmlv2-viz/Cargo.toml @@ -19,8 +19,8 @@ license.workspace = true repository.workspace = true [dependencies] -sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.0" } -sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.0" } +sysmlv2-model = { path = "../sysmlv2-model", version = "0.9.1" } +sysmlv2-syntax = { path = "../sysmlv2-syntax", version = "0.9.1" } serde.workspace = true serde_json.workspace = true diff --git a/crates/sysmlv2-viz/src/graph.rs b/crates/sysmlv2-viz/src/graph.rs index 3e19179..1a1ab87 100644 --- a/crates/sysmlv2-viz/src/graph.rs +++ b/crates/sysmlv2-viz/src/graph.rs @@ -40,15 +40,30 @@ //! "source", "target" }] //! } //! ``` +//! +//! Summary emission ([`VizOptions::summary`], tree view): a container +//! (a package, or an owner card with drawn members) outside the open +//! set is one node whose subtree is hidden, carrying +//! `"summary": { "open": false, "members", "containers", "leaves", +//! "notes", "hidden", "truncated", "edgesIn", "edgesOut" }` — `notes` +//! counts the notes on hidden elements below it and `hidden` the hidden +//! drawable elements below it (the cards the full picture would add); an open container carries the +//! same object with `"open": true` and `truncated` = direct members +//! beyond the leaf budget (hidden and counted like a closed one's). +//! Every reference edge with an endpoint that stands in for a hidden +//! element is a bundle `{ "kind", "source", "target", "count" }` with no +//! identity; edges between drawn nodes keep their shape. Notes beyond +//! the per-cluster budget fold into `"noteCount"` on their target (a +//! container's own overflow, distinct from `summary.notes` below it). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde_json::{Value as Json, json}; use sysmlv2_model::json::{ElementRef, ResolvedModel}; use crate::behavior::{ACTIONS, CONTROL_NODES, FLOWS, STATES, node_label, transition_edge_label}; use crate::interconnect::{BLOCK_USAGES, CONNECTORS, KERML_BLOCKS}; -use crate::{Kind, View, VizOptions, classify, roots_of, stereotype, usage_label}; +use crate::{Kind, SummaryOptions, View, VizOptions, classify, roots_of, stereotype, usage_label}; /// Why [`graph`] drew nothing. #[derive(Clone, PartialEq, Eq, Debug)] @@ -86,6 +101,12 @@ pub fn graph( } match opts.view { View::Tree => { + let notes = if opts.show_notes || opts.summary.is_some() { + r.annotation_notes() + } else { + Vec::new() + }; + let summary = opts.summary.as_ref().map(|s| SummaryState::new(¬es, s)); let mut g = GraphEmitter { r, opts, @@ -94,12 +115,23 @@ pub fn graph( edges: Vec::new(), drawn: HashMap::new(), rendered: Vec::new(), + summary, }; for e in tops { g.render(e, None); } g.reference_edges(); - emit_note_nodes(g.r, opts, &g.drawn, &mut g.nodes, &mut g.edges); + let note_budget = g.summary.as_ref().map(|s| s.note_budget); + emit_note_nodes( + g.r, + opts, + notes, + &g.drawn, + &mut g.nodes, + &mut g.edges, + note_budget, + ); + g.finish_summary(); Ok(json!({ "view": opts.view.as_str(), "nodes": g.nodes, "edges": g.edges })) } View::Interconnection => { @@ -118,7 +150,12 @@ pub fn graph( g.render(e, None); } g.connector_edges(); - emit_note_nodes(g.r, opts, &g.drawn, &mut g.nodes, &mut g.edges); + let notes = if opts.show_notes { + g.r.annotation_notes() + } else { + Vec::new() + }; + emit_note_nodes(g.r, opts, notes, &g.drawn, &mut g.nodes, &mut g.edges, None); Ok(json!({ "view": opts.view.as_str(), "nodes": g.nodes, "edges": g.edges })) } View::State | View::Action => { @@ -149,24 +186,51 @@ pub fn graph( fn emit_note_nodes( r: &mut ResolvedModel, opts: &VizOptions, + notes: Vec<(ElementRef, ElementRef, Option, String)>, drawn: &HashMap, nodes: &mut Vec, edges: &mut Vec, + note_budget: Option, ) { if !opts.show_notes { return; } - for (note, target, name, body) in r.annotation_notes() { + // Node id → FIRST position with that id (overlapping roots emit an + // element twice; the note keeps following the first, as the linear + // search did), for the parent lookup and the folded-count patch + // under a budget. + let mut index: HashMap = HashMap::new(); + for (i, n) in nodes.iter().enumerate() { + if let Some(id) = n["id"].as_str() { + index.entry(id.to_string()).or_insert(i); + } + } + let mut per_cluster: HashMap = HashMap::new(); + for (note, target, name, body) in notes { let Some(target_id) = drawn.get(&target) else { continue; }; // The note joins its target's cluster (package box) so layout // keeps them together; the target itself may be a cluster. - let parent = nodes - .iter() - .find(|n| n["id"].as_str() == Some(target_id)) - .and_then(|n| n["parent"].as_str()) + let parent = index + .get(target_id) + .and_then(|&i| nodes[i]["parent"].as_str()) .map(str::to_string); + if let Some(budget) = note_budget { + // Notes beyond the per-cluster budget fold into a count on + // their target instead of a card each. + let slot = per_cluster + .entry(parent.clone().unwrap_or_default()) + .or_insert(0); + if *slot >= budget { + if let Some(&i) = index.get(target_id) { + let count = nodes[i]["noteCount"].as_u64().unwrap_or(0) + 1; + nodes[i]["noteCount"] = json!(count); + } + continue; + } + *slot += 1; + } let id = r.element_id(note).to_string(); let mut obj = serde_json::Map::new(); obj.insert("id".into(), json!(id)); @@ -213,9 +277,285 @@ struct GraphEmitter<'a> { drawn: HashMap, /// Node-rendered elements (usage-likeness), for the second pass. rendered: Vec<(ElementRef, bool)>, + /// Summary-mode bookkeeping (large scopes); `None` is the full emission. + summary: Option, +} + +/// Counts a container node carries in summary mode. +#[derive(Clone, Debug, Default)] +struct SummaryCounts { + open: bool, + members: usize, + containers: usize, + leaves: usize, + notes: usize, + /// Drawable elements hidden below the container, at every depth. + hidden: usize, + truncated: usize, + edges_in: usize, + edges_out: usize, +} + +/// Summary-mode state of the tree emitter (see [`SummaryOptions`]). +struct SummaryState { + open: HashSet, + /// Open containers whose members all draw, whatever the leaf budget. + unbounded: HashSet, + note_budget: usize, + leaf_budget: usize, + /// Notes per annotated element, for the counts under closed containers. + notes_by_target: HashMap, + /// Hidden element → id of the emitted container standing in for it. + hidden: HashMap, + /// Hidden elements in walk order with their usage-likeness, for the + /// reference pass that aggregates their edges onto the container. + hidden_order: Vec<(ElementRef, bool)>, + /// Container node id → its counts, patched onto the node at the end. + counts: HashMap, + /// Reference edges with a hidden end: (source node, target node, + /// kind), bundled with a count by [`GraphEmitter::finish_summary`]. + bundle: Vec<(String, String, String)>, +} + +impl SummaryState { + fn new( + notes: &[(ElementRef, ElementRef, Option, String)], + opts: &SummaryOptions, + ) -> SummaryState { + let mut notes_by_target: HashMap = HashMap::new(); + for (_, target, _, _) in notes { + *notes_by_target.entry(*target).or_insert(0) += 1; + } + SummaryState { + open: opts.open.iter().copied().collect(), + unbounded: opts.unbounded.iter().copied().collect(), + note_budget: opts.note_budget, + leaf_budget: opts.leaf_budget, + notes_by_target, + hidden: HashMap::new(), + hidden_order: Vec::new(), + counts: HashMap::new(), + bundle: Vec::new(), + } + } } impl GraphEmitter<'_> { + /// Members of `e` that would draw as nodes: everything but metadata + /// and skipped kinds; on a non-package, compartment lines are rows, + /// not members. + fn drawable_children(&mut self, e: ElementRef, is_package: bool) -> Vec { + // Enumerations draw their literals as rows and nothing else. + if matches!( + self.r.element_type(e), + "EnumerationDefinition" | "EnumerationUsage" + ) { + return Vec::new(); + } + let metas = self.r.metadata_of(e); + let mut out = Vec::new(); + for m in self.r.owned_members(e) { + if metas.contains(&m) { + continue; + } + match classify(self.r, self.opts.show_metadata, m) { + Kind::Skip => {} + Kind::Line if !is_package => {} + _ => out.push(m), + } + } + out + } + + /// Hide `e` and its drawable subtree under the container `cid`: + /// every hidden element maps to the container for the reference + /// pass, and the notes below it join the container's count. Returns + /// how many drawable members `e` has (nonzero: a container). + fn hide_subtree(&mut self, e: ElementRef, cid: &str, counts: &mut SummaryCounts) -> usize { + let kind = classify(self.r, self.opts.show_metadata, e); + let is_usage = matches!(kind, Kind::UsageNode | Kind::Line); + let is_package = matches!(kind, Kind::Package); + let notes = self + .summary + .as_ref() + .and_then(|s| s.notes_by_target.get(&e).copied()) + .unwrap_or(0); + counts.notes += notes; + counts.hidden += 1; + if let Some(s) = self.summary.as_mut() { + // Overlapping roots reach an element twice; its references + // bundle once. + if s.hidden.insert(e, cid.to_string()).is_none() { + s.hidden_order.push((e, is_usage)); + } + } + let children = self.drawable_children(e, is_package); + let n = children.len(); + for c in children { + self.hide_subtree(c, cid, counts); + } + n + } + + /// Summary mode: emit the members of container `id` (element `e`) + /// — closed: every member hidden and counted; open: members drawn + /// up to the leaf budget, the rest hidden and counted as truncated. + /// Returns false when not in summary mode (the caller draws as + /// usual). + fn summary_members(&mut self, e: ElementRef, id: &str, is_package: bool) -> bool { + let Some(open) = self.summary.as_ref().map(|s| s.open.contains(&e)) else { + return false; + }; + let children = self.drawable_children(e, is_package); + // An empty package is a leaf, not an openable box. + if !children.is_empty() { + self.summary_children(e, id, open, children, Some(id)); + } + true + } + + /// Summary mode: draw or hide the drawable `children` of the + /// container `id` and record its counts. Open: members draw (as + /// `parent`'s children) up to the leaf budget, the rest hide as + /// `truncated`; closed: everything hides. A drawn member is a + /// container when it recorded counts of its own; a hidden one when + /// it had drawable members. Returns the members that drew. + fn summary_children( + &mut self, + e: ElementRef, + id: &str, + open: bool, + children: Vec, + parent: Option<&str>, + ) -> Vec { + // An unbounded container (the per-container override) draws + // every member; the rest share the leaf budget. + let leaf_budget = self.summary.as_ref().map_or(usize::MAX, |s| { + if s.unbounded.contains(&e) { + usize::MAX + } else { + s.leaf_budget + } + }); + let mut counts = SummaryCounts { + open, + ..SummaryCounts::default() + }; + let mut emitted = 0usize; + let mut drawn = Vec::new(); + for c in children { + counts.members += 1; + if open && emitted < leaf_budget { + self.render(c, parent); + emitted += 1; + drawn.push(c); + let container = self.drawn.get(&c).is_some_and(|cid| { + self.summary + .as_ref() + .is_some_and(|s| s.counts.contains_key(cid)) + }); + if container { + counts.containers += 1; + } else { + counts.leaves += 1; + } + } else { + if open { + counts.truncated += 1; + } + if self.hide_subtree(c, id, &mut counts) > 0 { + counts.containers += 1; + } else { + counts.leaves += 1; + } + } + } + if let Some(s) = self.summary.as_mut() { + s.counts.insert(id.to_string(), counts); + } + drawn + } + + /// The emitted node an element resolves to: its own node when drawn + /// (`false`), else (summary mode) the container standing in for it + /// (`true` — an edge to it is a bundle, not a relationship). + fn visible_id(&self, e: ElementRef) -> Option<(String, bool)> { + if let Some(id) = self.drawn.get(&e) { + return Some((id.clone(), false)); + } + self.summary + .as_ref() + .and_then(|s| s.hidden.get(&e).map(|id| (id.clone(), true))) + } + + /// A reference edge: as itself between drawn nodes, or — when either + /// end stands in for hidden elements — counted into the bundle + /// between the two container nodes (summary mode only). + fn emit_ref(&mut self, remapped: bool, src: &str, tgt: &str, kind: &str, edge: Json) { + if remapped { + if let Some(s) = self.summary.as_mut() { + s.bundle + .push((src.to_string(), tgt.to_string(), kind.to_string())); + } + } else { + self.edges.push(edge); + } + } + + /// Summary mode, after every edge is in: edges touching a container + /// node collapse to one per (source, target, kind) with a `count` + /// (identity dropped — a bundle is not one relationship), self-loops + /// on a container vanish, the counts land on the container nodes. + fn finish_summary(&mut self) { + let Some(s) = self.summary.as_mut() else { + return; + }; + let mut aggregated: Vec<((String, String, String), usize)> = Vec::new(); + let mut slot: HashMap<(String, String, String), usize> = HashMap::new(); + for key in s.bundle.drain(..) { + // Both ends inside one container: interior, not a bundle. + if key.0 == key.1 { + continue; + } + match slot.get(&key) { + Some(&i) => aggregated[i].1 += 1, + None => { + slot.insert(key.clone(), aggregated.len()); + aggregated.push((key, 1)); + } + } + } + for ((src, tgt, kind), count) in aggregated { + if let Some(c) = s.counts.get_mut(&src) { + c.edges_out += count; + } + if let Some(c) = s.counts.get_mut(&tgt) { + c.edges_in += count; + } + self.edges + .push(json!({ "kind": kind, "source": src, "target": tgt, "count": count })); + } + for n in self.nodes.iter_mut() { + let Some(id) = n["id"].as_str() else { + continue; + }; + let Some(c) = s.counts.get(id) else { + continue; + }; + n["summary"] = json!({ + "open": c.open, + "members": c.members, + "containers": c.containers, + "leaves": c.leaves, + "notes": c.notes, + "hidden": c.hidden, + "truncated": c.truncated, + "edgesIn": c.edges_in, + "edgesOut": c.edges_out, + }); + } + } + fn id_of(&self, e: ElementRef) -> String { self.r.element_id(e).to_string() } @@ -378,6 +718,9 @@ impl GraphEmitter<'_> { self.nodes.push(Json::Object(obj)); self.drawn.insert(e, id.clone()); self.rendered.push((e, false)); + if self.summary_members(e, &id, true) { + return; + } let metas = self.r.metadata_of(e); for m in self.r.owned_members(e) { if !metas.contains(&m) { @@ -455,46 +798,89 @@ impl GraphEmitter<'_> { self.drawn.insert(e, id.clone()); self.rendered.push((e, is_usage)); + // Summary mode: an owner card is a container — closed, its + // members hide under it with counts; open, they draw as sibling + // cards up to the budget. Ownership edges to drawn members are + // emitted below for the open case. + if !children.is_empty() && self.summary.is_some() { + let open = self.summary.as_ref().is_some_and(|s| s.open.contains(&e)); + let drawn_children = self.summary_children(e, &id, open, children, parent); + for child in drawn_children { + self.ownership_edge(&id, child); + } + return; + } + for child in children { self.render(child, parent); - if let Some(child_id) = self.drawn.get(&child).cloned() { - let kind = match classify(self.r, self.opts.show_metadata, child) { - Kind::UsageNode | Kind::Line => "composition", - _ => "membership", - }; - let mut obj = serde_json::Map::new(); - obj.insert("kind".into(), json!(kind)); - obj.insert("source".into(), json!(id)); - obj.insert("target".into(), json!(child_id)); - // The notation splits composite (filled diamond) from - // non-composite (hollow diamond) feature membership — - // the graph twin of the PlantUML `*--` vs `o--` pick. - if kind == "composition" && self.r.is_composite(child) == Some(false) { - obj.insert("composite".into(), json!(false)); - } - self.edges.push(Json::Object(obj)); + self.ownership_edge(&id, child); + } + } + + /// The composition / membership edge from an owner card to a drawn + /// member (nothing when the member did not draw). + fn ownership_edge(&mut self, id: &str, child: ElementRef) { + if let Some(child_id) = self.drawn.get(&child).cloned() { + let kind = match classify(self.r, self.opts.show_metadata, child) { + Kind::UsageNode | Kind::Line => "composition", + _ => "membership", + }; + let mut obj = serde_json::Map::new(); + obj.insert("kind".into(), json!(kind)); + obj.insert("source".into(), json!(id)); + obj.insert("target".into(), json!(child_id)); + // The notation splits composite (filled diamond) from + // non-composite (hollow diamond) feature membership — + // the graph twin of the PlantUML `*--` vs `o--` pick. + if kind == "composition" && self.r.is_composite(child) == Some(false) { + obj.insert("composite".into(), json!(false)); } + self.edges.push(Json::Object(obj)); } } /// Second pass: typing / specialization / import edges between /// drawn nodes (plus on-demand library nodes under `show_lib`), /// «keyword» reference edges for the shorthand usages, and - /// dependency edges. + /// dependency edges. In summary mode the hidden elements take the + /// same pass with their container as the source, and + /// [`Self::finish_summary`] bundles what touches a container. fn reference_edges(&mut self) { // Collection is over, so the pass takes the list rather than // copying it to keep the emitter free to mutate. for (e, is_usage) in std::mem::take(&mut self.rendered) { let id = self.drawn[&e].clone(); + self.reference_edges_of(e, is_usage, &id, false); + } + let hidden: Vec<(ElementRef, bool, String)> = self + .summary + .as_ref() + .map(|s| { + s.hidden_order + .iter() + .map(|&(e, u)| (e, u, s.hidden[&e].clone())) + .collect() + }) + .unwrap_or_default(); + for (e, is_usage, cid) in hidden { + self.reference_edges_of(e, is_usage, &cid, true); + } + self.dependency_edges(); + } + + /// One element's reference edges from node `id` (`hidden`: the + /// element stands behind a container, so every edge is a bundle). + fn reference_edges_of(&mut self, e: ElementRef, is_usage: bool, id: &str, hidden: bool) { + { let typings = if is_usage { self.r.typings(e) } else { Vec::new() }; for target in &typings { - if let Some(tid) = self.target_id(*target) { - self.edges - .push(json!({ "kind": "typing", "source": id, "target": tid })); + if let Some((tid, remapped)) = self.target_id(*target) { + let edge = json!({ "kind": "typing", "source": id, "target": tid }); + self.emit_ref(hidden || remapped, id, &tid, "typing", edge); } } // Kept under the umbrella kind "specialization" (consumer @@ -507,12 +893,13 @@ impl GraphEmitter<'_> { // portion-relationship, not a plain subsetting arrow. if rel == "Subsetting" { if let Some(p) = self.r.portion_kind(e).map(str::to_string) { - if let Some(tid) = self.target_id(target) { - self.edges.push(json!({ + if let Some((tid, remapped)) = self.target_id(target) { + let edge = json!({ "kind": "portion", "portionKind": p, "directed": true, "source": id, "target": tid, - })); + }); + self.emit_ref(hidden || remapped, id, &tid, "portion", edge); } continue; } @@ -524,16 +911,17 @@ impl GraphEmitter<'_> { "FeatureTyping" => "typing", _ => continue, }; - if let Some(tid) = self.target_id(target) { - self.edges.push(json!({ + if let Some((tid, remapped)) = self.target_id(target) { + let edge = json!({ "kind": "specialization", "rel": rel, "source": id, "target": tid, - })); + }); + self.emit_ref(hidden || remapped, id, &tid, "specialization", edge); } } if self.opts.show_imported { for (target, is_ns, recursive, visibility) in self.r.import_details(e) { - if let Some(tid) = self.drawn.get(&target).cloned() { + if let Some((tid, remapped)) = self.visible_id(target) { let import_kind = if recursive { "recursive" } else if is_ns { @@ -551,7 +939,7 @@ impl GraphEmitter<'_> { } obj.insert("source".into(), json!(id)); obj.insert("target".into(), json!(tid)); - self.edges.push(Json::Object(obj)); + self.emit_ref(hidden || remapped, id, &tid, "import", Json::Object(obj)); } } } @@ -565,7 +953,8 @@ impl GraphEmitter<'_> { .map(|(_, k)| *k) { for chain in self.r.referenced_features(e) { - let Some(tid) = chain.iter().rev().find_map(|l| self.drawn.get(l).cloned()) + let Some((tid, remapped)) = + chain.iter().rev().find_map(|l| self.visible_id(*l)) else { continue; }; @@ -575,11 +964,10 @@ impl GraphEmitter<'_> { obj.insert("directed".into(), json!(true)); obj.insert("source".into(), json!(id)); obj.insert("target".into(), json!(tid)); - self.edges.push(Json::Object(obj)); + self.emit_ref(hidden || remapped, id, &tid, kind, Json::Object(obj)); } } } - self.dependency_edges(); } /// Dependency elements between drawn nodes: one directed edge per @@ -596,17 +984,17 @@ impl GraphEmitter<'_> { base.insert("label".into(), json!(n)); } for c in &clients { - let Some(cid) = self.drawn.get(c).cloned() else { + let Some((cid, rc)) = self.visible_id(*c) else { continue; }; for s in &suppliers { - let Some(sid) = self.drawn.get(s).cloned() else { + let Some((sid, rs)) = self.visible_id(*s) else { continue; }; let mut obj = base.clone(); obj.insert("source".into(), json!(cid)); obj.insert("target".into(), json!(sid)); - self.edges.push(Json::Object(obj)); + self.emit_ref(rc || rs, &cid, &sid, "dependency", Json::Object(obj)); } } } @@ -614,9 +1002,9 @@ impl GraphEmitter<'_> { /// The node an edge target draws to: its node when rendered; under /// `show_lib`, an on-demand marked node for a library element. - fn target_id(&mut self, target: ElementRef) -> Option { - if let Some(id) = self.drawn.get(&target) { - return Some(id.clone()); + fn target_id(&mut self, target: ElementRef) -> Option<(String, bool)> { + if let Some(hit) = self.visible_id(target) { + return Some(hit); } if !self.opts.show_lib || !self.r.is_library_element(target) { return None; @@ -638,7 +1026,7 @@ impl GraphEmitter<'_> { obj.insert("rows".into(), json!([])); self.nodes.push(Json::Object(obj)); self.drawn.insert(target, id.clone()); - Some(id) + Some((id, false)) } } diff --git a/crates/sysmlv2-viz/src/lib.rs b/crates/sysmlv2-viz/src/lib.rs index a1d0e61..5ae921c 100644 --- a/crates/sysmlv2-viz/src/lib.rs +++ b/crates/sysmlv2-viz/src/lib.rs @@ -300,6 +300,29 @@ pub struct VizOptions { /// package/root filter behind the diagram panel's package selector. /// Ignored when a single `root` is passed (that scoping wins). pub roots: Option>, + /// Summary emission for large scopes (graph emitter, tree view): + /// containers outside `open` emit as one node with counts, cross- + /// container references aggregate, notes beyond a budget fold into + /// counts. `None` is the full emission. + pub summary: Option, +} + +/// Summary emission controls (see [`VizOptions::summary`]). +#[derive(Clone, Debug, Default)] +pub struct SummaryOptions { + /// Containers whose direct members are emitted; every other container + /// is one summary node. An entry under a closed ancestor is inert + /// (the ancestor hides it); the scope roots must be listed to open. + pub open: Vec, + /// Notes drawn as nodes per cluster; the rest become `noteCount` on + /// their target. + pub note_budget: usize, + /// Direct members emitted per open container; the rest are counted as + /// `truncated` and hidden under it. + pub leaf_budget: usize, + /// Open containers drawn whole: every direct member emits regardless + /// of `leaf_budget` (a per-container override). + pub unbounded: Vec, } impl Default for VizOptions { @@ -317,6 +340,7 @@ impl Default for VizOptions { std_color: false, link_template: None, roots: None, + summary: None, } } } @@ -324,6 +348,12 @@ impl Default for VizOptions { /// One setter per option, each consuming and returning the options so /// they chain from [`VizOptions::default`]. impl VizOptions { + /// Configure summary emission for the structured tree graph. + pub fn with_summary(mut self, summary: Option) -> VizOptions { + self.summary = summary; + self + } + /// Layout direction. pub fn with_direction(mut self, direction: Direction) -> VizOptions { self.direction = direction; diff --git a/crates/sysmlv2-viz/tests/golden/graph-full-options.json b/crates/sysmlv2-viz/tests/golden/graph-full-options.json new file mode 100644 index 0000000..dc319e6 --- /dev/null +++ b/crates/sysmlv2-viz/tests/golden/graph-full-options.json @@ -0,0 +1 @@ +{"edges":[{"kind":"composition","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"91478322-de33-5dab-afb2-fc541959fdc4"},{"kind":"composition","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"ec92a384-10e7-5abf-b5df-025e09e4d710"},{"kind":"specialization","rel":"subclassification","source":"4e34d33e-9010-526c-9fae-3c55aca8a34e","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"9b531262-9cb9-59d1-88cb-901103d4b17b","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"specialization","rel":"subclassification","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"91478322-de33-5dab-afb2-fc541959fdc4","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"ec92a384-10e7-5abf-b5df-025e09e4d710","target":"4e34d33e-9010-526c-9fae-3c55aca8a34e"},{"kind":"typing","source":"46de4342-195f-59cb-b396-d672814e9caf","target":"83a7c1f3-11ef-50f8-a70d-354f44a55dea"},{"col":257,"directed":true,"file":"a.sysml","id":"756cc85c-66e3-5223-a67b-f31a4fdaeb5c","kind":"dependency","label":"Dep","line":1,"metaclass":"Dependency","qname":"P::Dep","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"b06cd112-a63e-5c37-aa05-9f3fde0c9f49"},{"kind":"note","source":"175b0adb-8693-5514-a828-e819bba60238","target":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f"},{"kind":"note","source":"7b231e5f-b65b-5b16-97e7-ad2f35e09a40","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"note","source":"14df10aa-c873-5b64-9988-924eac087f6d","target":"b06cd112-a63e-5c37-aa05-9f3fde0c9f49"}],"nodes":[{"col":9,"file":"a.sysml","id":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","kind":"package","label":"P","line":1,"metaclass":"Package","qname":"P","rows":[],"stereo":"package"},{"col":68,"file":"a.sysml","id":"af121763-8273-574e-8d50-19190f7cfec8","kind":"package","label":"Q","line":1,"metaclass":"Package","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::Q","rows":[],"stereo":"package"},{"col":81,"file":"a.sysml","id":"a66c5cfa-ba92-5654-9708-033c3808af8d","kind":"node","label":"A","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::A","rows":[],"stereo":"part def"},{"col":93,"file":"a.sysml","id":"4e34d33e-9010-526c-9fae-3c55aca8a34e","kind":"node","label":"B","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::B","rows":[],"stereo":"part def"},{"col":106,"file":"a.sysml","id":"9b531262-9cb9-59d1-88cb-901103d4b17b","kind":"node","label":"q : A","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::q","rows":[],"stereo":"part"},{"col":149,"file":"a.sysml","id":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","kind":"node","label":"D","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::D","rows":[{"col":203,"file":"a.sysml","id":"aa737334-a9ed-59eb-abc9-11b574d2c4e5","label":"mass = 3","line":1,"qname":"P::D::mass","section":"attributes"}],"stereo":"part def"},{"col":166,"file":"a.sysml","id":"91478322-de33-5dab-afb2-fc541959fdc4","kind":"node","label":"d1 : A","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::D::d1","rows":[],"stereo":"part"},{"col":182,"file":"a.sysml","id":"ec92a384-10e7-5abf-b5df-025e09e4d710","kind":"node","label":"d2 : B","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::D::d2","rows":[],"stereo":"part"},{"col":231,"file":"a.sysml","id":"b06cd112-a63e-5c37-aa05-9f3fde0c9f49","kind":"node","label":"L","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::L","rows":[],"stereo":"part def"},{"col":239,"file":"a.sysml","id":"46de4342-195f-59cb-b396-d672814e9caf","kind":"node","label":"x : D","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::x","rows":[{"col":203,"file":"a.sysml","id":"aa737334-a9ed-59eb-abc9-11b574d2c4e5","inherited":true,"label":"mass = 3","line":1,"qname":"P::D::mass","section":"attributes"}],"stereo":"part"},{"col":283,"file":"a.sysml","id":"47ab3842-1834-5dc5-ad27-923b494ded6c","kind":"enum","label":"E","line":1,"metaclass":"EnumerationDefinition","nodeKind":"def","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::E","rows":[{"col":287,"file":"a.sysml","id":"5b27b3dc-ba61-59f4-846f-f42075111a77","label":"a","line":1,"qname":"P::E::a","section":"literals"},{"col":290,"file":"a.sysml","id":"018c9555-d455-5ac9-9b74-dcc80207cfe0","label":"b","line":1,"qname":"P::E::b","section":"literals"}],"stereo":"enum def"},{"col":303,"file":"a.sysml","id":"ad9479ec-2a23-529e-a6a7-d364f6c97dc7","kind":"package","label":"Empty","line":1,"metaclass":"Package","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::Empty","rows":[],"stereo":"package"},{"col":45,"file":"a.sysml","id":"175b0adb-8693-5514-a828-e819bba60238","kind":"note","label":"on P","line":1,"metaclass":"Documentation","rows":[],"stereo":"doc"},{"col":113,"file":"a.sysml","id":"7b231e5f-b65b-5b16-97e7-ad2f35e09a40","kind":"note","label":"c2","line":1,"metaclass":"Comment","parent":"af121763-8273-574e-8d50-19190f7cfec8","rows":[],"stereo":"comment"},{"col":310,"file":"a.sysml","id":"14df10aa-c873-5b64-9988-924eac087f6d","kind":"note","label":"c1","line":1,"metaclass":"Comment","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","rows":[],"stereo":"comment"}],"view":"tree"} diff --git a/crates/sysmlv2-viz/tests/golden/graph-overlapping-roots.json b/crates/sysmlv2-viz/tests/golden/graph-overlapping-roots.json new file mode 100644 index 0000000..4406592 --- /dev/null +++ b/crates/sysmlv2-viz/tests/golden/graph-overlapping-roots.json @@ -0,0 +1 @@ +{"edges":[{"kind":"composition","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"91478322-de33-5dab-afb2-fc541959fdc4"},{"kind":"composition","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"ec92a384-10e7-5abf-b5df-025e09e4d710"},{"kind":"specialization","rel":"subclassification","source":"4e34d33e-9010-526c-9fae-3c55aca8a34e","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"9b531262-9cb9-59d1-88cb-901103d4b17b","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"specialization","rel":"subclassification","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"91478322-de33-5dab-afb2-fc541959fdc4","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"ec92a384-10e7-5abf-b5df-025e09e4d710","target":"4e34d33e-9010-526c-9fae-3c55aca8a34e"},{"kind":"typing","source":"46de4342-195f-59cb-b396-d672814e9caf","target":"83a7c1f3-11ef-50f8-a70d-354f44a55dea"},{"kind":"specialization","rel":"subclassification","source":"4e34d33e-9010-526c-9fae-3c55aca8a34e","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"typing","source":"9b531262-9cb9-59d1-88cb-901103d4b17b","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"col":257,"directed":true,"file":"a.sysml","id":"756cc85c-66e3-5223-a67b-f31a4fdaeb5c","kind":"dependency","label":"Dep","line":1,"metaclass":"Dependency","qname":"P::Dep","source":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","target":"b06cd112-a63e-5c37-aa05-9f3fde0c9f49"},{"kind":"note","source":"175b0adb-8693-5514-a828-e819bba60238","target":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f"},{"kind":"note","source":"7b231e5f-b65b-5b16-97e7-ad2f35e09a40","target":"a66c5cfa-ba92-5654-9708-033c3808af8d"},{"kind":"note","source":"14df10aa-c873-5b64-9988-924eac087f6d","target":"b06cd112-a63e-5c37-aa05-9f3fde0c9f49"}],"nodes":[{"col":9,"file":"a.sysml","id":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","kind":"package","label":"P","line":1,"metaclass":"Package","qname":"P","rows":[],"stereo":"package"},{"col":68,"file":"a.sysml","id":"af121763-8273-574e-8d50-19190f7cfec8","kind":"package","label":"Q","line":1,"metaclass":"Package","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::Q","rows":[],"stereo":"package"},{"col":81,"file":"a.sysml","id":"a66c5cfa-ba92-5654-9708-033c3808af8d","kind":"node","label":"A","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::A","rows":[],"stereo":"part def"},{"col":93,"file":"a.sysml","id":"4e34d33e-9010-526c-9fae-3c55aca8a34e","kind":"node","label":"B","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::B","rows":[],"stereo":"part def"},{"col":106,"file":"a.sysml","id":"9b531262-9cb9-59d1-88cb-901103d4b17b","kind":"node","label":"q : A","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::q","rows":[],"stereo":"part"},{"col":149,"file":"a.sysml","id":"83a7c1f3-11ef-50f8-a70d-354f44a55dea","kind":"node","label":"D","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::D","rows":[{"col":203,"file":"a.sysml","id":"aa737334-a9ed-59eb-abc9-11b574d2c4e5","label":"mass = 3","line":1,"qname":"P::D::mass","section":"attributes"}],"stereo":"part def"},{"col":166,"file":"a.sysml","id":"91478322-de33-5dab-afb2-fc541959fdc4","kind":"node","label":"d1 : A","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::D::d1","rows":[],"stereo":"part"},{"col":182,"file":"a.sysml","id":"ec92a384-10e7-5abf-b5df-025e09e4d710","kind":"node","label":"d2 : B","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::D::d2","rows":[],"stereo":"part"},{"col":231,"file":"a.sysml","id":"b06cd112-a63e-5c37-aa05-9f3fde0c9f49","kind":"node","label":"L","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::L","rows":[],"stereo":"part def"},{"col":239,"file":"a.sysml","id":"46de4342-195f-59cb-b396-d672814e9caf","kind":"node","label":"x : D","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::x","rows":[],"stereo":"part"},{"col":283,"file":"a.sysml","id":"47ab3842-1834-5dc5-ad27-923b494ded6c","kind":"enum","label":"E","line":1,"metaclass":"EnumerationDefinition","nodeKind":"def","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::E","rows":[{"col":287,"file":"a.sysml","id":"5b27b3dc-ba61-59f4-846f-f42075111a77","label":"a","line":1,"qname":"P::E::a","section":"literals"},{"col":290,"file":"a.sysml","id":"018c9555-d455-5ac9-9b74-dcc80207cfe0","label":"b","line":1,"qname":"P::E::b","section":"literals"}],"stereo":"enum def"},{"col":303,"file":"a.sysml","id":"ad9479ec-2a23-529e-a6a7-d364f6c97dc7","kind":"package","label":"Empty","line":1,"metaclass":"Package","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","qname":"P::Empty","rows":[],"stereo":"package"},{"col":68,"file":"a.sysml","id":"af121763-8273-574e-8d50-19190f7cfec8","kind":"package","label":"Q","line":1,"metaclass":"Package","qname":"P::Q","rows":[],"stereo":"package"},{"col":81,"file":"a.sysml","id":"a66c5cfa-ba92-5654-9708-033c3808af8d","kind":"node","label":"A","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::A","rows":[],"stereo":"part def"},{"col":93,"file":"a.sysml","id":"4e34d33e-9010-526c-9fae-3c55aca8a34e","kind":"node","label":"B","line":1,"metaclass":"PartDefinition","nodeKind":"def","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::B","rows":[],"stereo":"part def"},{"col":106,"file":"a.sysml","id":"9b531262-9cb9-59d1-88cb-901103d4b17b","kind":"node","label":"q : A","line":1,"metaclass":"PartUsage","nodeKind":"usage","parent":"af121763-8273-574e-8d50-19190f7cfec8","qname":"P::Q::q","rows":[],"stereo":"part"},{"col":45,"file":"a.sysml","id":"175b0adb-8693-5514-a828-e819bba60238","kind":"note","label":"on P","line":1,"metaclass":"Documentation","rows":[],"stereo":"doc"},{"col":113,"file":"a.sysml","id":"7b231e5f-b65b-5b16-97e7-ad2f35e09a40","kind":"note","label":"c2","line":1,"metaclass":"Comment","parent":"af121763-8273-574e-8d50-19190f7cfec8","rows":[],"stereo":"comment"},{"col":310,"file":"a.sysml","id":"14df10aa-c873-5b64-9988-924eac087f6d","kind":"note","label":"c1","line":1,"metaclass":"Comment","parent":"59b00bd6-e519-5ea4-8d4a-2b07557bbc6f","rows":[],"stereo":"comment"}],"view":"tree"} diff --git a/crates/sysmlv2-viz/tests/viz.rs b/crates/sysmlv2-viz/tests/viz.rs index b3293f7..a500182 100644 --- a/crates/sysmlv2-viz/tests/viz.rs +++ b/crates/sysmlv2-viz/tests/viz.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; -use sysmlv2_model::json::ResolvedModel; +use sysmlv2_model::json::{ElementRef, ResolvedModel}; use sysmlv2_model::model::Model; use sysmlv2_viz::{Direction, LineStyle, View, VizOptions, plantuml}; @@ -1626,3 +1626,522 @@ fn graph_action_view_params_are_border_chips() { assert_eq!(param("frames")["direction"], "out"); assert_eq!(param("shots")["parent"], capture["id"]); } + +// --------------------------------------------------------------------------- +// Summary emission (large scopes) +// --------------------------------------------------------------------------- + +const SUMMARY_MODEL: &str = "package P { + doc /* on P */ + package Q { + part def A; + part def B :> A; + part q : A; + comment about A /* c2 */ + } + part def D { part d1 : Q::A; part d2 : Q::B; } + part def L; + comment about L /* c1 */ +} +"; + +fn summary_graph( + r: &mut ResolvedModel, + open: &[&str], + note_budget: usize, + leaf_budget: usize, +) -> serde_json::Value { + let open = open + .iter() + .map(|q| r.resolve_qualified(q).expect(q)) + .collect(); + let opts = VizOptions::default().with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget, + leaf_budget, + unbounded: Vec::new(), + })); + sysmlv2_viz::graph(r, None, &opts).unwrap() +} + +fn node<'a>(g: &'a serde_json::Value, qname: &str) -> &'a serde_json::Value { + g["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["qname"] == qname) + .unwrap_or_else(|| panic!("node {qname}")) +} + +#[test] +fn summary_closed_root_is_one_node_with_counts() { + let mut r = resolved(SUMMARY_MODEL); + let g = summary_graph(&mut r, &[], 200, 500); + let ids: Vec<&str> = g["nodes"] + .as_array() + .unwrap() + .iter() + .filter_map(|n| n["qname"].as_str()) + .collect(); + // P plus its own doc note (P is drawn, so its note draws too); nothing below. + assert_eq!( + ids.iter() + .filter(|q| !q.contains("doc") && !q.contains("comment")) + .collect::>(), + vec![&"P"] + ); + let s = &node(&g, "P")["summary"]; + assert_eq!(s["open"], false); + assert_eq!(s["members"], 3, "Q, D, L"); + assert_eq!(s["containers"], 2, "Q and the owner card D"); + assert_eq!(s["leaves"], 1); + assert_eq!(s["notes"], 2, "c1 on L and c2 on A hide under P"); + assert_eq!(s["hidden"], 8, "Q, A, B, q, D, d1, d2, L"); + assert_eq!(s["truncated"], 0); + // Every reference stays inside P: no aggregated edge. + assert!( + g["edges"] + .as_array() + .unwrap() + .iter() + .all(|e| e["kind"] == "note") + ); +} + +#[test] +fn summary_open_root_draws_members_and_aggregates_cross_container_edges() { + let mut r = resolved(SUMMARY_MODEL); + let g = summary_graph(&mut r, &["P"], 200, 500); + assert_eq!(node(&g, "P")["summary"]["open"], true); + let q = &node(&g, "P::Q")["summary"]; + assert_eq!(q["open"], false); + assert_eq!(q["members"], 3, "A, B, q"); + assert_eq!(q["containers"], 0); + assert_eq!(q["notes"], 1); + let d = &node(&g, "P::D")["summary"]; + assert_eq!(d["members"], 2, "d1, d2 hide under the closed owner card"); + assert_eq!(d["hidden"], 2); + assert_eq!(q["hidden"], 3); + assert!( + node(&g, "P::L").get("summary").is_none(), + "a leaf carries no summary" + ); + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .all(|n| n["qname"] != "P::D::d1") + ); + // d1 : A and d2 : B become one typing bundle D → Q with a count and no identity. + let bundle: Vec<&serde_json::Value> = g["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["kind"] == "typing") + .collect(); + assert_eq!(bundle.len(), 1); + assert_eq!(bundle[0]["source"], node(&g, "P::D")["id"]); + assert_eq!(bundle[0]["target"], node(&g, "P::Q")["id"]); + assert_eq!(bundle[0]["count"], 2); + assert!(bundle[0].get("id").is_none()); + assert_eq!(d["edgesOut"], 2); + assert_eq!(q["edgesIn"], 2); +} + +#[test] +fn summary_leaf_budget_truncates_and_counts() { + let mut r = resolved(SUMMARY_MODEL); + let g = summary_graph(&mut r, &["P"], 200, 1); + let p = &node(&g, "P")["summary"]; + assert_eq!(p["members"], 3); + assert_eq!(p["truncated"], 2, "D and L beyond the budget of one"); + assert_eq!(p["notes"], 1, "c1 on the hidden L"); + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .all(|n| n["qname"] != "P::D" && n["qname"] != "P::L") + ); + // The hidden D's typings now leave P for Q. + let bundle: Vec<&serde_json::Value> = g["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["kind"] == "typing") + .collect(); + assert_eq!(bundle.len(), 1); + assert_eq!(bundle[0]["source"], node(&g, "P")["id"]); + assert_eq!(bundle[0]["count"], 2); +} + +#[test] +fn summary_unbounded_container_draws_every_member() { + let mut r = resolved(SUMMARY_MODEL); + let q = r.resolve_qualified("P::Q").expect("P::Q"); + let open = ["P", "P::Q"] + .iter() + .map(|name| r.resolve_qualified(name).expect(name)) + .collect(); + let opts = VizOptions::default().with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: 200, + leaf_budget: 1, + unbounded: vec![q], + })); + let g = sysmlv2_viz::graph(&mut r, None, &opts).unwrap(); + // Q is unbounded: all three members draw, nothing truncated. + let qs = &node(&g, "P::Q")["summary"]; + assert_eq!(qs["members"], 3); + assert_eq!( + qs["truncated"], 0, + "an unbounded container draws every member" + ); + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .any(|n| n["qname"] == "P::Q::B") + ); + // P keeps the shared budget: Q drew, D and L are truncated. + let ps = &node(&g, "P")["summary"]; + assert_eq!(ps["truncated"], 2, "the override is per container"); +} + +#[test] +fn summary_note_budget_folds_notes_into_counts() { + let mut r = resolved(SUMMARY_MODEL); + let g = summary_graph(&mut r, &["P"], 0, 500); + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .all(|n| n["kind"] != "note") + ); + assert_eq!( + node(&g, "P")["noteCount"], + 1, + "the doc on P folds into a count" + ); + assert!( + g["edges"] + .as_array() + .unwrap() + .iter() + .all(|e| e["kind"] != "note") + ); +} + +#[test] +fn full_emission_carries_no_summary_fields() { + let mut r = resolved(SUMMARY_MODEL); + let g = sysmlv2_viz::graph(&mut r, None, &VizOptions::default()).unwrap(); + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .all(|n| n.get("summary").is_none() && n.get("noteCount").is_none()) + ); + assert!( + g["edges"] + .as_array() + .unwrap() + .iter() + .all(|e| e.get("count").is_none()) + ); + assert_eq!( + g["nodes"].as_array().unwrap().len(), + 12, + "P, Q, A, B, q, D, d1, d2, L + 3 notes" + ); +} + +/// The full emission with the summary-only keys removed, for the +/// invariant that opening every container with unbounded budgets is +/// the full picture. +fn strip_summary(mut g: serde_json::Value) -> serde_json::Value { + for n in g["nodes"].as_array_mut().unwrap() { + n.as_object_mut().unwrap().remove("summary"); + } + g +} + +#[test] +fn summary_with_everything_open_is_the_full_emission() { + let src = "package P { + private import ScalarValues::*; + doc /* on P */ + package Q { part def A; part def B :> A; part q : A; comment about A /* c2 */ } + part def D :> Q::A { part d1 : Q::A; part d2 : Q::B; attribute mass : Real; } + part def L; + part x : D; + dependency Dep from D to L; + enum def E { a; b; } + package Empty; + comment about L /* c1 */ + }"; + let mut r = resolved(src); + let full = sysmlv2_viz::graph( + &mut r, + None, + &VizOptions::default().with_show_imported(true), + ) + .unwrap(); + let open: Vec = ["P", "P::Q", "P::D", "P::Empty", "P::E"] + .iter() + .map(|q| r.resolve_qualified(q).unwrap()) + .collect(); + let opts = VizOptions::default() + .with_show_imported(true) + .with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: usize::MAX, + leaf_budget: usize::MAX, + unbounded: Vec::new(), + })); + let all_open = strip_summary(sysmlv2_viz::graph(&mut r, None, &opts).unwrap()); + assert_eq!(all_open.to_string(), full.to_string()); +} + +#[test] +fn summary_open_owner_card_keeps_member_identity_and_bundles_only_hidden_ends() { + let src = "package P { + package Q { part def A; part def B :> A; } + part def Base; + part def D :> Base { part d1 : Q::A; part d2 : Q::B; } + part x : D; + dependency Dep from D to Base; + }"; + let mut r = resolved(src); + let open: Vec = ["P", "P::D"] + .iter() + .map(|q| r.resolve_qualified(q).unwrap()) + .collect(); + let opts = VizOptions::default().with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: 200, + leaf_budget: 500, + unbounded: Vec::new(), + })); + let g = sysmlv2_viz::graph(&mut r, None, &opts).unwrap(); + let edges = g["edges"].as_array().unwrap(); + // The open owner card's members draw beside it, wired by composition edges without counts. + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .any(|n| n["qname"] == "P::D::d1") + ); + assert_eq!( + edges + .iter() + .filter(|e| e["kind"] == "composition" && e.get("count").is_none()) + .count(), + 2 + ); + // Edges between drawn nodes keep their shape: the specialization's `rel`, the dependency's identity, x's typing. + let spec = edges + .iter() + .find(|e| e["kind"] == "specialization") + .unwrap(); + assert_eq!(spec["rel"], "subclassification"); + assert!(spec.get("count").is_none()); + let dep = edges.iter().find(|e| e["kind"] == "dependency").unwrap(); + assert!(dep.get("id").is_some() && dep.get("count").is_none()); + let x_typing = edges + .iter() + .filter(|e| e["kind"] == "typing" && e.get("count").is_none()) + .count(); + assert_eq!(x_typing, 1, "x : D between two drawn nodes"); + // d1 : Q::A and d2 : Q::B each reach the closed Q: two separate bundles (one per drawn source). + let bundles: Vec<&serde_json::Value> = edges + .iter() + .filter(|e| e["kind"] == "typing" && e.get("count").is_some()) + .collect(); + assert_eq!(bundles.len(), 2); + assert!( + bundles + .iter() + .all(|b| b["count"] == 1 && b["target"] == node(&g, "P::Q")["id"]) + ); + assert_eq!(node(&g, "P::Q")["summary"]["edgesIn"], 2); + assert_eq!(node(&g, "P::D")["summary"]["open"], true); +} + +#[test] +fn summary_bundles_imports_dependencies_and_library_typings_from_hidden_elements() { + let src = "package P { + package Lib { part def T; } + package C { + private import Lib::*; + part def U { part u : T; } + part def V; + dependency Need from V to Lib::T; + } + part def W; + dependency Uses from W to Lib::T; + }"; + let mut r = resolved(src); + let open: Vec = ["P"] + .iter() + .map(|q| r.resolve_qualified(q).unwrap()) + .collect(); + let opts = VizOptions::default() + .with_show_imported(true) + .with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: 200, + leaf_budget: 500, + unbounded: Vec::new(), + })); + let g = sysmlv2_viz::graph(&mut r, None, &opts).unwrap(); + let edges = g["edges"].as_array().unwrap(); + let c = node(&g, "P::C")["id"].clone(); + let lib = node(&g, "P::Lib")["id"].clone(); + let bundle = |kind: &str| { + edges + .iter() + .find(|e| e["kind"] == kind && e["source"] == c && e["target"] == lib) + .cloned() + }; + // C itself is drawn and so is Lib: its import is an edge between drawn nodes and keeps its shape. + let imp = edges + .iter() + .find(|e| e["kind"] == "import" && e["source"] == c && e["target"] == lib) + .unwrap(); + assert!(imp.get("count").is_none() && imp["importKind"] == "namespace"); + assert_eq!( + bundle("typing").unwrap()["count"], + 1, + "u : T inside the hidden U" + ); + let dep_bundle = bundle("dependency").unwrap(); + assert_eq!(dep_bundle["count"], 1); + assert!( + dep_bundle.get("id").is_none(), + "a hidden dependency loses its identity" + ); + // The drawn W's dependency to the closed Lib is a bundle too (its target stands in for T). + let w = node(&g, "P::W")["id"].clone(); + let w_dep = edges + .iter() + .find(|e| e["kind"] == "dependency" && e["source"] == w) + .unwrap(); + assert_eq!(w_dep["target"], lib); + assert_eq!(w_dep["count"], 1); + assert_eq!( + node(&g, "P::C")["summary"]["edgesOut"], + 2, + "the typing and the dependency from hidden elements" + ); +} + +#[test] +fn summary_truncated_container_counts_and_interior_edges_vanish() { + let src = "package P { + part def Z; + package Q { part def A; part q : A; comment about A /* c2 */ } + }"; + let mut r = resolved(src); + let open: Vec = ["P"] + .iter() + .map(|q| r.resolve_qualified(q).unwrap()) + .collect(); + let opts = VizOptions::default().with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: 200, + leaf_budget: 1, + unbounded: Vec::new(), + })); + let g = sysmlv2_viz::graph(&mut r, None, &opts).unwrap(); + let p = &node(&g, "P")["summary"]; + assert_eq!(p["truncated"], 1); + assert_eq!( + p["containers"], 1, + "the truncated Q is still counted as a container" + ); + assert_eq!(p["leaves"], 1); + assert_eq!(p["notes"], 1, "Q's note rolls into P"); + assert!( + g["nodes"] + .as_array() + .unwrap() + .iter() + .all(|n| n["qname"] != "P::Q") + ); + assert!( + g["edges"] + .as_array() + .unwrap() + .iter() + .all(|e| e["kind"] == "note" || e.get("count").is_none()), + "q : A is interior to P: no bundle" + ); +} + +#[test] +fn summary_enumerations_and_empty_packages_are_leaves() { + let src = "package P { enum def E { a; b; } enum e : E { part p : P::T; } part def T; package Empty; }"; + let mut r = resolved(src); + let open: Vec = ["P"] + .iter() + .map(|q| r.resolve_qualified(q).unwrap()) + .collect(); + let opts = VizOptions::default().with_summary(Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: 200, + leaf_budget: 500, + unbounded: Vec::new(), + })); + let g = sysmlv2_viz::graph(&mut r, None, &opts).unwrap(); + let p = &node(&g, "P")["summary"]; + assert_eq!(p["containers"], 0); + assert_eq!(p["leaves"], p["members"]); + assert!( + node(&g, "P::Empty").get("summary").is_none(), + "an empty package is not an openable box" + ); + assert!(node(&g, "P::E").get("summary").is_none()); + assert!( + g["edges"] + .as_array() + .unwrap() + .iter() + .all(|e| e.get("count").is_none()) + ); +} + +/// The probe fixture behind the byte-identity goldens: every node kind +/// the tree emitter draws, values, notes, imports, inherited rows, a +/// library type, a dependency, an enumeration and an empty package. +const IDENTITY_FIXTURE: &str = "package P { private import ScalarValues::*; doc /* on P */ package Q { part def A; part def B :> A; part q : A; comment about A /* c2 */ } part def D :> Q::A { part d1 : Q::A; part d2 : Q::B; attribute mass : Real = 3; } part def L; part x : D; dependency Dep from D to L; enum def E { a; b; } package Empty; comment about L /* c1 */ }"; + +/// The full emission is byte-identical to the emitter before summary +/// mode existed (goldens captured from it), with every option that +/// touches the emission on, and with overlapping roots. +#[test] +fn full_emission_matches_pre_summary_goldens() { + let mut r = resolved_named("a.sysml", IDENTITY_FIXTURE); + let opts = VizOptions::default() + .with_show_imported(true) + .with_show_inherited(true) + .with_show_lib(true); + let g = sysmlv2_viz::graph(&mut r, None, &opts).unwrap().to_string(); + assert_eq!(g, include_str!("golden/graph-full-options.json").trim_end()); + let roots: Vec = ["P", "P::Q"] + .iter() + .map(|q| r.resolve_qualified(q).unwrap()) + .collect(); + let g = sysmlv2_viz::graph(&mut r, None, &VizOptions::default().with_roots(Some(roots))) + .unwrap() + .to_string(); + assert_eq!( + g, + include_str!("golden/graph-overlapping-roots.json").trim_end() + ); +} diff --git a/crates/sysmlv2-wasm/src/lib.rs b/crates/sysmlv2-wasm/src/lib.rs index 9d31c94..73781d4 100644 --- a/crates/sysmlv2-wasm/src/lib.rs +++ b/crates/sysmlv2-wasm/src/lib.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use wasm_bindgen::prelude::*; @@ -197,6 +197,11 @@ struct PlantumlOpts { element: Option, #[serde(default)] roots: Option>, + /// Summary emission for large scopes (`toGraph`, tree view; ignored + /// by `toPlantuml`): containers outside `open` emit as one node + /// with counts. See `SummaryIn`. + #[serde(default)] + summary: Option, #[serde(default = "default_view")] view: String, #[serde(default)] @@ -219,6 +224,54 @@ struct PlantumlOpts { link_template: Option, } +/// `{open: [selector…], noteBudget, leafBudget, unbounded: [selector…]}`: +/// which containers emit their members, how many notes draw as cards +/// per cluster, how many members draw per open container, and which +/// open containers draw every member regardless (a selector that does +/// not resolve is ignored). +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SummaryIn { + #[serde(default)] + open: Vec, + #[serde(default)] + unbounded: Vec, + #[serde(default = "default_note_budget")] + note_budget: usize, + #[serde(default = "default_leaf_budget")] + leaf_budget: usize, +} + +fn default_note_budget() -> usize { + 200 +} + +fn default_leaf_budget() -> usize { + 500 +} + +/// An element selector: a `::`-qualified name, or `{elementId}` for +/// elements without a resolvable name. +#[derive(Clone, Deserialize, Serialize)] +#[serde(untagged)] +enum Selector { + Name(String), + Id { + #[serde(rename = "elementId")] + element_id: String, + }, +} + +fn resolve_selector( + r: &mut sysmlv2_model::json::ResolvedModel, + sel: &Selector, +) -> Option { + match sel { + Selector::Name(name) => r.resolve_qualified(name), + Selector::Id { element_id } => r.element_by_id(element_id), + } +} + fn default_view() -> String { "tree".to_string() } @@ -385,7 +438,14 @@ impl Session { fn diagram_request( &mut self, opts_json: Option<&str>, - ) -> Result<(sysmlv2_viz::VizOptions, Option), String> { + ) -> Result< + ( + sysmlv2_viz::VizOptions, + Option, + Option, + ), + String, + > { let opts: PlantumlOpts = serde_json::from_str(opts_json.filter(|s| !s.is_empty()).unwrap_or("{}")) .map_err(|e| format!("bad options: {e}"))?; @@ -425,7 +485,7 @@ impl Session { .with_std_color(opts.std_color) .with_link_template(opts.link_template) .with_roots(roots); - Ok((viz, root)) + Ok((viz, root, opts.summary)) } fn value_to_json(&mut self, v: &EvalValue) -> serde_json::Value { @@ -1411,7 +1471,7 @@ impl Session { #[allow(clippy::needless_pass_by_value)] #[wasm_bindgen(js_name = toPlantuml)] pub fn to_plantuml(&mut self, opts_json: Option) -> Result { - let (viz, root) = self.diagram_request(opts_json.as_deref())?; + let (viz, root, _) = self.diagram_request(opts_json.as_deref())?; if viz.roots.as_ref().is_some_and(|r| r.is_empty()) { return Ok("@startuml\n' the view exposes nothing\n@enduml\n".to_string()); } @@ -1426,7 +1486,7 @@ impl Session { #[allow(clippy::needless_pass_by_value)] #[wasm_bindgen(js_name = toGraph)] pub fn to_graph(&mut self, opts_json: Option) -> Result { - let (viz, root) = self.diagram_request(opts_json.as_deref())?; + let (mut viz, root, summary_opts) = self.diagram_request(opts_json.as_deref())?; let view = view_name(viz.view); if !matches!( viz.view, @@ -1437,12 +1497,81 @@ impl Session { ) { return Err(format!("no structured-graph emitter for view: {view}")); } + if summary_opts.is_some() && viz.view != sysmlv2_viz::View::Tree { + return Err("summary emission is tree-only".to_string()); + } if viz.roots.as_ref().is_some_and(|r| r.is_empty()) { - return Ok(json!({ "view": view, "nodes": [], "edges": [] }).to_string()); + let mut g = json!({ "view": view, "nodes": [], "edges": [] }); + if summary_opts.is_some() { + g["summary"] = json!({ "resolved": [], "unresolved": [] }); + } + return Ok(g.to_string()); } - sysmlv2_viz::graph(self.inner.resolved(), root, &viz) - .map(|v| v.to_string()) - .map_err(|e| e.to_string()) + // Summary mode: resolve the open set, remembering what did not + // resolve so the client can prune a persisted set. + let mut summary = None; + let mut resolved_open = Vec::new(); + let mut unresolved_open = Vec::new(); + if let Some(sm) = &summary_opts { + let mut open = Vec::new(); + for sel in &sm.open { + match resolve_selector(self.inner.resolved(), sel) { + Some(e) => { + let r = self.inner.resolved(); + resolved_open.push(json!({ + "id": r.element_id(e).to_string(), + "qualifiedName": r.element_qualified_name(e), + })); + open.push(e); + } + None => unresolved_open + .push(serde_json::to_value(sel).unwrap_or(serde_json::Value::Null)), + } + } + let unbounded = sm + .unbounded + .iter() + .filter_map(|sel| resolve_selector(self.inner.resolved(), sel)) + .collect(); + summary = Some(sysmlv2_viz::SummaryOptions { + open, + note_budget: sm.note_budget, + leaf_budget: sm.leaf_budget, + unbounded, + }); + } + viz.summary = summary; + let mut g = + sysmlv2_viz::graph(self.inner.resolved(), root, &viz).map_err(|e| e.to_string())?; + if summary_opts.is_some() { + g["summary"] = json!({ "resolved": resolved_open, "unresolved": unresolved_open }); + } + Ok(g.to_string()) + } + + /// The containers from the scope root down to an element (root + /// first, the element's owner last), for opening a summary picture + /// onto it: JSON `[{id, qualifiedName}]`. The selector is a + /// qualified name or `{elementId}`; errors when it does not resolve. + #[wasm_bindgen(js_name = revealPath)] + pub fn reveal_path(&mut self, selector_json: &str) -> Result { + // A bare name is as good as its JSON string form. + let sel: Selector = serde_json::from_str(selector_json) + .unwrap_or_else(|_| Selector::Name(selector_json.to_string())); + let r = self.inner.resolved(); + let e = resolve_selector(r, &sel) + .ok_or_else(|| format!("element not found: {selector_json}"))?; + let mut path = Vec::new(); + let mut cur = r.owner(e); + while let Some(o) = cur { + if r.element_type(o) == "Namespace" { + break; + } + path.push(json!({ "id": r.element_id(o).to_string(), "qualifiedName": r.element_qualified_name(o) })); + cur = r.owner(o); + } + path.reverse(); + Ok(serde_json::Value::Array(path).to_string()) } /// Apply an edit batch (JSON array of `EditOpIn` operations) via diff --git a/crates/sysmlv2-wasm/tests/api.rs b/crates/sysmlv2-wasm/tests/api.rs index 89dff95..4d9ce63 100644 --- a/crates/sysmlv2-wasm/tests/api.rs +++ b/crates/sysmlv2-wasm/tests/api.rs @@ -2312,3 +2312,97 @@ fn verify_positions_bindings_in_their_own_unit() { assert_eq!(report["summary"]["satisfied"], 1); assert_eq!(report["summary"]["violated"], 1); } + +const SUMMARY_MODEL: &str = "package P { package Q { part def A; part def B :> A; part q : A; } part def D { part d1 : Q::A; } part def L; }"; + +#[test] +fn to_graph_summary_resolves_selectors_and_reports_the_rest() { + let mut s = Session::from_sources(&sources(&[("p.sysml", SUMMARY_MODEL)])).unwrap(); + let full: serde_json::Value = serde_json::from_str(&s.to_graph(None).unwrap()).unwrap(); + assert!( + full.get("summary").is_none(), + "no reply field without a request" + ); + let d_id = full["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["qname"] == "P::D") + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let req = format!( + r#"{{"summary":{{"open":["P",{{"elementId":"{d_id}"}},"Nope::Missing",{{"elementId":"00000000-0000-0000-0000-000000000000"}}],"noteBudget":10,"leafBudget":50}}}}"# + ); + let g: serde_json::Value = serde_json::from_str(&s.to_graph(Some(req)).unwrap()).unwrap(); + let resolved = g["summary"]["resolved"].as_array().unwrap(); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0]["qualifiedName"], "P"); + assert_eq!(resolved[1]["id"], d_id); + assert_eq!(g["summary"]["unresolved"].as_array().unwrap().len(), 2); + assert_eq!(g["summary"]["unresolved"][0], "Nope::Missing"); + let nodes = g["nodes"].as_array().unwrap(); + assert!( + nodes.iter().any(|n| n["qname"] == "P::D::d1"), + "the opened owner card draws its member" + ); + let q = nodes.iter().find(|n| n["qname"] == "P::Q").unwrap(); + assert_eq!(q["summary"]["open"], false); + assert!(nodes.iter().all(|n| n["qname"] != "P::Q::A")); + // Unknown fields inside the summary object are rejected like any option. + assert!( + s.to_graph(Some(r#"{"summary":{"open":[],"bogus":1}}"#.into())) + .unwrap_err() + .contains("bad options") + ); + assert!( + s.to_graph(Some( + r#"{"view":"interconnection","summary":{"open":[]}}"#.into() + )) + .unwrap_err() + .contains("tree-only") + ); + // An empty roots list means the whole model, as without a summary: + // the root package draws closed, and the reply field rides along. + let whole: serde_json::Value = serde_json::from_str( + &s.to_graph(Some(r#"{"roots":[],"summary":{"open":[]}}"#.into())) + .unwrap(), + ) + .unwrap(); + assert_eq!(whole["nodes"].as_array().unwrap().len(), 1); + assert!(whole.get("summary").is_some()); + // The PlantUML path ignores the field. + assert!( + s.to_plantuml(Some(r#"{"summary":{"open":["P"]}}"#.into())) + .unwrap() + .starts_with("@startuml") + ); +} + +#[test] +fn reveal_path_walks_owners_root_first() { + let mut s = Session::from_sources(&sources(&[("p.sysml", SUMMARY_MODEL)])).unwrap(); + let by_name: serde_json::Value = + serde_json::from_str(&s.reveal_path("P::Q::A").unwrap()).unwrap(); + let names: Vec<&str> = by_name + .as_array() + .unwrap() + .iter() + .map(|e| e["qualifiedName"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["P", "P::Q"]); + let as_json: serde_json::Value = + serde_json::from_str(&s.reveal_path(r#""P::Q::A""#).unwrap()).unwrap(); + assert_eq!(as_json, by_name); + let q_id = by_name[1]["id"].as_str().unwrap(); + let by_id: serde_json::Value = serde_json::from_str( + &s.reveal_path(&format!(r#"{{"elementId":"{q_id}"}}"#)) + .unwrap(), + ) + .unwrap(); + assert_eq!(by_id.as_array().unwrap().len(), 1, "Q's only owner is P"); + let root: serde_json::Value = serde_json::from_str(&s.reveal_path("P").unwrap()).unwrap(); + assert_eq!(root.as_array().unwrap().len(), 0); + assert!(s.reveal_path("Nope").is_err()); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..fb3120f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +# The toolchain every gate runs under. CI reads the channel from this +# file, so a formatting or lint change in a newer release cannot flip a +# gate without a deliberate bump here. +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"]