diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b41be9b..d5455d18 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,12 @@ jobs: - name: Run Validation Tooling tests run: | bazel test //validation/... + - name: Run Tools clippy + run: | + bazel build //tools/... --config=clippy + - name: Run Tools tests + run: | + bazel test //tools/... - name: Ensure correct dependency resolution run: | bazel mod deps --lockfile_mode=update diff --git a/plantuml/parser/docs/element-identifiers.md b/plantuml/parser/docs/element-identifiers.md index e7db67ee..a4e6f6fe 100644 --- a/plantuml/parser/docs/element-identifiers.md +++ b/plantuml/parser/docs/element-identifiers.md @@ -28,6 +28,31 @@ to author diagrams so that the links actually resolve. --- +## 0. Implementation status + +This guide describes the target design. As of this writing, only part of it is +implemented on `main`; the rest lands incrementally. The +[cross-diagram test suite](../integration_test/cross_diagram/) pins down +exactly what is true today with executable goldens — when a row below changes, +that suite's goldens change with it. + +| Topic | Today (`main`) | Target (this guide) | Test case | +|-------|----------------|----------------------|-----------| +| Root anchor ([§1](#1-the-three-inputs), [Rule D](#rule-d)) | **not implemented** — identifiers have no Bazel-package prefix | `ctx.label.package` prepended to every identifier | — | +| Component id leaf ([§2](#2-component-diagrams), [Rule A](#rule-a)) | implemented — alias when present, else name | (same) | `component_nesting` | +| Class id leaf ([§3](#3-class-diagrams), [Rule A](#rule-a)) | **not implemented** — the label/internal name is used, not the alias | alias wins over the label | `class_alias_wins` | +| Class/component reference resolution ([§5](#5-linking-the-three-diagrams), [Rule C](#rule-c)) | first match, qualified names accepted unchecked | nearest enclosing scope, existence and ambiguity checked | `qualified_reference` | +| Sequence participant identity ([§4](#4-sequence-diagrams), [Rule B](#rule-b)) | **not implemented** — identity is the alias, else the display name, verbatim (none of the label forms are parsed) | `uid` derived from the label per the label forms | `sequence_forms`, `prose_without_alias` | +| Sequence ↔ component/class linking ([§5](#5-linking-the-three-diagrams)) | works only by coincidence when both sides use a plain, un-nested alias | component/class id == participant uid | `linking_three_diagrams`, `component_nesting` | +| `ExternalEndpoint` marker ([§6](#6-special-cases), [Rule E](#rule-e)) | **not implemented** — no such reserved participant exists | emitted verbatim, never anchored | — | +| Errors in [§7](#7-errors-you-may-hit) (`free-text participant display names require an alias…`, `multiple standalone ':' separators…`, `Duplicate entity id`, `duplicate sequence participant id`) | **not implemented** — none of these diagnostics exist yet; the rejected forms currently parse without error | as described | `prose_without_alias` | +| Id normalization (`::` / `.` equivalence, [Definitions](#definitions)) | implemented for scope paths inside one class diagram; `::` in class and component relationship endpoints (`A --> ns::B`) is rejected, not normalized | works everywhere an identifier is read or written | `namespace_and_package`, `qualified_reference` | +| Label markup stripping (creole tags in labels) | implemented for activity diagram labels only | also strips markup from sequence participant labels before Rule B derivation | — | +| Qualified name inside a nested declaration ([§9](#9-current-limitations)) | **bug** — appended to the enclosing scope instead of replacing it | replaces the enclosing scope | — | +| Cross-diagram hyperlinks (`idmap`) for sequence participants ([§9](#9-current-limitations)) | not identifier-based; links from a sequence participant may not resolve | identifier-based, same as component/class | — | + +--- + ## 1. The three inputs An identifier is assembled from exactly three things: @@ -136,7 +161,7 @@ up. Sequence diagrams have no nesting, so the whole scope has to be written into the label. -### The four forms +### The label forms | What you write | Identity is taken from | Resulting identifier | |----------------|------------------------|----------------------| @@ -331,7 +356,9 @@ participant "Display Service" as DisplayService ## 9. Current limitations -Known gaps between this guide and the present implementation: +See the [implementation status table](#0-implementation-status) for what is and +isn't implemented yet. The two rows below are bugs rather than pending work — +they have no planned test case and no target-design row of their own: - **A qualified name used in a *declaration* inside a block is appended to the enclosing scope instead of replacing it.** For example @@ -341,7 +368,6 @@ Known gaps between this guide and the present implementation: - **Cross-diagram hyperlinks (`idmap`) are not yet identifier-based for sequence diagrams**, so clickable links from a sequence participant to its component may not resolve. -- **Cross-diagrams in Class Diagrams** also have a bug currently --- diff --git a/plantuml/parser/docs/sequence-diagram.md b/plantuml/parser/docs/sequence-diagram.md index f387726e..fd8b1ae2 100644 --- a/plantuml/parser/docs/sequence-diagram.md +++ b/plantuml/parser/docs/sequence-diagram.md @@ -67,6 +67,8 @@ The alias form is recommended. Although `participant OrderService as "Order Serv A quoted free-text display name **requires** an alias — `participant "Order Service"` on its own is rejected, because the resolver cannot derive an identifier from it. See [Element Identifiers](element-identifiers.md) for how the identifier is built from the display name and why the qualified form `"instance : package::Component::Unit"` is preferred for diagrams that must link to a component or class diagram. +> **Not yet implemented.** Today `participant "Order Service"` without an alias is accepted; the display name itself becomes the participant's identity. The rejection described above, and the label-derived `uid` it depends on, land with Rule B — see [Element Identifiers §0](element-identifiers.md#0-implementation-status). + The display name, alias, participant type, and stereotype are written to the logical model. ```text @@ -76,6 +78,8 @@ Client -> OrderService : correct() After declaring an alias, subsequent messages, lifecycle commands, and `ref` blocks must use that alias consistently. Referring to the quoted display name instead is rejected when that display name is free text. +> **Not yet implemented.** Today, referring to the quoted display name instead of the declared alias does not error — it silently creates a second, separate implicit participant keyed by that display name. See [Element Identifiers §0](element-identifiers.md#0-implementation-status). + Undeclared message endpoints are automatically created as regular `participant` instances. This is convenient for short diagrams, but explicit declarations are recommended for production diagrams to preserve participant type, stereotype, and stable source locations. ### Messages and arrow direction diff --git a/plantuml/parser/integration_test/cross_diagram/BUILD b/plantuml/parser/integration_test/cross_diagram/BUILD new file mode 100644 index 00000000..7b1d9ee5 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/BUILD @@ -0,0 +1,39 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@rules_rust//rust:defs.bzl", "rust_test") + +filegroup( + name = "cross_diagram_files", + srcs = glob([ + "**/*.json", + "**/*.puml", + "**/*.yaml", + ]), +) + +rust_test( + name = "cross_diagram_test", + srcs = ["cross_diagram_test.rs"], + crate_root = "cross_diagram_test.rs", + data = [ + ":cross_diagram_files", + "//plantuml/parser/puml_cli", + ], + deps = [ + "//plantuml/parser/integration_test:test_framework", + "@crates//:serde", + "@crates//:serde_json", + "@crates//:serde_yaml", + ], +) diff --git a/plantuml/parser/integration_test/cross_diagram/class_alias_wins/case.yaml b/plantuml/parser/integration_test/cross_diagram/class_alias_wins/case.yaml new file mode 100644 index 00000000..1c3c2c0c --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/class_alias_wins/case.yaml @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# Regression baseline: today a class's idmap id is derived from its label +# text, not its alias, so an aliased class with a display label still +# surfaces the label as the id. This is expected to change once Rule A +# (alias wins) is implemented; the golden then becomes +# `SampleLibraryAPI: "SampleLibraryAPI"`. +diagram_types: + class_diagram.puml: class diff --git a/plantuml/parser/integration_test/cross_diagram/class_alias_wins/class_diagram.puml b/plantuml/parser/integration_test/cross_diagram/class_alias_wins/class_diagram.puml new file mode 100644 index 00000000..dc221352 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/class_alias_wins/class_diagram.puml @@ -0,0 +1,17 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +class "Sample Library API" as SampleLibraryAPI { + +GetNumber() : int +} +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/class_alias_wins/output.json b/plantuml/parser/integration_test/cross_diagram/class_alias_wins/output.json new file mode 100644 index 00000000..cca4ca2f --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/class_alias_wins/output.json @@ -0,0 +1,8 @@ +{ + "class_diagram.puml": { + "defines": { + "SampleLibraryAPI": "Sample Library API" + }, + "references": {} + } +} diff --git a/plantuml/parser/integration_test/cross_diagram/component_nesting/case.yaml b/plantuml/parser/integration_test/cross_diagram/component_nesting/case.yaml new file mode 100644 index 00000000..465eef3c --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/component_nesting/case.yaml @@ -0,0 +1,25 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# Nested package -> component -> component. Each enclosing scope (package, +# outer component) is itself a "define" so it can be referenced/linked to, +# and the leaf components (aliased and bare) are "reference"s. The label +# ("Log Recorder") differs from the alias (Recorder) to prove the id is +# built from the alias, not the label. `distinct` records that a nested +# component does not accidentally link to a same-named sequence participant +# today, since the component's id carries the full nesting chain and the +# participant's does not (no root anchor yet). +diagram_types: + component_diagram.puml: component + sequence_diagram.puml: sequence +distinct: + - ["component_diagram.puml#Backend", "sequence_diagram.puml#Backend"] diff --git a/plantuml/parser/integration_test/cross_diagram/component_nesting/component_diagram.puml b/plantuml/parser/integration_test/cross_diagram/component_nesting/component_diagram.puml new file mode 100644 index 00000000..4ec53984 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/component_nesting/component_diagram.puml @@ -0,0 +1,20 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +package "score.mw.log" as logging { + component "Log Recorder" as Recorder { + component "Backend" as Backend + component Frontend + } +} +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/component_nesting/output.json b/plantuml/parser/integration_test/cross_diagram/component_nesting/output.json new file mode 100644 index 00000000..32c8c880 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/component_nesting/output.json @@ -0,0 +1,18 @@ +{ + "component_diagram.puml": { + "defines": { + "logging": "logging", + "Recorder": "logging.Recorder" + }, + "references": { + "Backend": "logging.Recorder.Backend", + "Frontend": "logging.Recorder.Frontend" + } + }, + "sequence_diagram.puml": { + "defines": {}, + "references": { + "Backend": "Backend" + } + } +} diff --git a/plantuml/parser/integration_test/cross_diagram/component_nesting/sequence_diagram.puml b/plantuml/parser/integration_test/cross_diagram/component_nesting/sequence_diagram.puml new file mode 100644 index 00000000..b0557a10 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/component_nesting/sequence_diagram.puml @@ -0,0 +1,15 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +participant Backend +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/cross_diagram_test.rs b/plantuml/parser/integration_test/cross_diagram/cross_diagram_test.rs new file mode 100644 index 00000000..1adb33f3 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/cross_diagram_test.rs @@ -0,0 +1,494 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Cross-diagram integration suite for `puml_cli`'s `--idmap-output-dir` output. +//! +//! Each seed case directory under this crate contains one or more `.puml` +//! files, an optional `case.yaml` describing how to invoke `puml_cli` and +//! which cross-file identifier assertions must hold, and an `output.json` +//! (or, for cases where every file is expected to fail, `output.yaml`) +//! golden capturing the idmap `defines`/`references` produced for every +//! file. This suite drives `test_framework::run_case` like the other +//! diagram-parser/resolver suites: [`PumlCliIdmapRunner`] shells out to +//! `puml_cli` per file (`DiagramProcessor`), and [`CrossDiagramChecker`] +//! adds the `links`/`distinct` cross-file id assertions on top of the +//! framework's default per-file checks (`ExpectationChecker::check_case`). +//! +//! Goldens document *current* behavior; they are updated by the changes that +//! implement the target design in `plantuml/parser/docs/element-identifiers.md`. + +use serde::{Deserialize, Serialize}; +use serde_yaml::Value as YamlValue; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::rc::Rc; +use test_framework::{ + run_case, DefaultExpectationChecker, DiagramProcessor, ErrorView, ExpectationChecker, Expected, + ProjectedError, +}; + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields, default)] +struct CaseConfig { + /// Maps a `.puml` file name to the `--diagram-type` value to pass to + /// `puml_cli`. Files not listed here are run without `--diagram-type` + /// (letting `puml_cli` auto-detect the diagram type). + diagram_types: HashMap, + /// Groups of `file.puml#Alias` references that must resolve to the same + /// idmap id. + links: Vec>, + /// Groups of `file.puml#Alias` references that must resolve to pairwise + /// distinct idmap ids. + distinct: Vec>, +} + +/// One or more idmap ids sharing a single alias, e.g. two same-named classes +/// nested in different scopes. A golden may write either a single scalar +/// (`Recorder: "logging.Recorder"`) or a list (`Recorder: ["a.R", "b.R"]`). +#[derive(Debug, PartialEq, Eq)] +struct IdSet(BTreeSet); + +impl<'de> Deserialize<'de> for IdSet { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + One(String), + Many(Vec), + } + + Ok(match Repr::deserialize(deserializer)? { + Repr::One(id) => IdSet(BTreeSet::from([id])), + Repr::Many(ids) => IdSet(ids.into_iter().collect()), + }) + } +} + +impl Serialize for IdSet { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Mirrors the flexible Deserialize impl above: a single id is + // written as a bare scalar (matching how goldens are hand-written), + // multiple ids as a list. + match self.0.len() { + 1 => self.0.iter().next().unwrap().serialize(serializer), + _ => self.0.iter().collect::>().serialize(serializer), + } + } +} + +#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +struct IdMapSections { + defines: BTreeMap, + references: BTreeMap, +} + +/// Resolves a path under `TEST_SRCDIR`/`TEST_WORKSPACE`, mirroring +/// `validation/core/integration_test`'s `case_file_path` helper. +fn case_file_path(relative_path: &str) -> PathBuf { + let test_srcdir = std::env::var("TEST_SRCDIR").expect("TEST_SRCDIR is not set"); + let workspace = std::env::var("TEST_WORKSPACE").expect("TEST_WORKSPACE is not set"); + + PathBuf::from(test_srcdir) + .join(workspace) + .join(relative_path) +} + +fn puml_cli_path() -> PathBuf { + case_file_path("plantuml/parser/puml_cli/puml_cli") +} + +fn load_case_config(dir: &Path) -> CaseConfig { + let path = dir.join("case.yaml"); + if !path.exists() { + return CaseConfig::default(); + } + + let content = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + serde_yaml::from_str(&content) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", path.display())) +} + +fn puml_files(dir: &Path) -> Vec { + let mut files: Vec = fs::read_dir(dir) + .unwrap_or_else(|error| panic!("failed to list {}: {error}", dir.display())) + .filter_map(|entry| { + let path = entry + .unwrap_or_else(|error| { + panic!("failed to read entry in {}: {error}", dir.display()) + }) + .path(); + (path.extension().is_some_and(|ext| ext == "puml")).then_some(path) + }) + .collect(); + files.sort(); + files +} + +fn test_tmp_dir(case_name: &str, file_stem: &str) -> PathBuf { + let test_tmpdir = std::env::var("TEST_TMPDIR").expect("TEST_TMPDIR is not set"); + let dir = PathBuf::from(test_tmpdir) + .join("cross_diagram") + .join(case_name) + .join(file_stem); + fs::create_dir_all(&dir) + .unwrap_or_else(|error| panic!("failed to create {}: {error}", dir.display())); + dir +} + +/// The error `puml_cli` reports on stderr for a single file, with the +/// `"Resolve error in {file}: "` (or equivalent) prefix stripped, since the +/// input file is already known to the caller. +#[derive(Debug)] +struct PumlCliError { + file: PathBuf, + message: String, +} + +impl ErrorView for PumlCliError { + fn project(&self, base_dir: &Path) -> ProjectedError { + let file = self.file.strip_prefix(base_dir).unwrap_or(&self.file); + ProjectedError::new("ResolveError") + .with_field("file", file.to_string_lossy().into_owned()) + .with_field("message", self.message.clone()) + } +} + +/// Runs `puml_cli --idmap-output-dir` for a single file and parses its +/// output (or failure) into this suite's `DiagramProcessor` types. +fn run_file( + case_name: &str, + path: &Path, + diagram_type: Option<&str>, +) -> Result { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_else(|| panic!("{case_name}: non-utf8 file name {}", path.display())); + let out_dir = test_tmp_dir(case_name, stem); + + let mut cmd = Command::new(puml_cli_path()); + cmd.arg("--file").arg(path); + if let Some(diagram_type) = diagram_type { + cmd.arg("--diagram-type").arg(diagram_type); + } + cmd.arg("--idmap-output-dir").arg(&out_dir); + + let output = cmd + .output() + .unwrap_or_else(|error| panic!("failed to execute puml_cli for {case_name}: {error}")); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let prefix = format!("Resolve error in {}: ", path.display()); + let message = stderr + .strip_prefix(prefix.as_str()) + .unwrap_or(&stderr) + .to_string(); + return Err(PumlCliError { + file: path.to_path_buf(), + message, + }); + } + + let idmap_path = out_dir.join(format!("{stem}.idmap.json")); + let idmap_content = fs::read_to_string(&idmap_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", idmap_path.display())); + + #[derive(Deserialize)] + struct RawIdMapEntry { + alias: String, + id: String, + } + #[derive(Deserialize)] + struct RawIdMapFile { + defines: Vec, + references: Vec, + } + + let raw: RawIdMapFile = serde_json::from_str(&idmap_content) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", idmap_path.display())); + let to_map = |entries: Vec| { + let mut grouped: BTreeMap> = BTreeMap::new(); + for entry in entries { + grouped.entry(entry.alias).or_default().insert(entry.id); + } + grouped + .into_iter() + .map(|(alias, ids)| (alias, IdSet(ids))) + .collect::>() + }; + + Ok(IdMapSections { + defines: to_map(raw.defines), + references: to_map(raw.references), + }) +} + +/// Runs `puml_cli` for every file in a case, driven by `test_framework`. +struct PumlCliIdmapRunner; + +impl DiagramProcessor for PumlCliIdmapRunner { + type Output = IdMapSections; + type Error = PumlCliError; + + fn run( + &self, + files: &HashSet>, + ) -> Result, IdMapSections>, PumlCliError> { + let dir = files + .iter() + .next() + .and_then(|file| file.parent()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let case_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("cross_diagram_case") + .to_string(); + let config = load_case_config(&dir); + + let mut results = HashMap::new(); + for path in files { + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let diagram_type = config.diagram_types.get(&file_name).map(String::as_str); + let idmap = run_file(&case_name, path, diagram_type)?; + results.insert(Rc::clone(path), idmap); + } + Ok(results) + } +} + +/// Resolves a `"file.puml#Alias"` reference to its idmap id. `alias` must +/// name exactly one define-or-reference entry, and that entry must resolve +/// to exactly one id, or this panics. +fn resolve_ref( + resolved: &HashMap, + reference: &str, + case_name: &str, +) -> String { + let (file_name, alias) = reference.split_once('#').unwrap_or_else(|| { + panic!("{case_name}: malformed reference {reference:?}, expected file.puml#Alias") + }); + + let sections = resolved.get(file_name).unwrap_or_else(|| { + panic!("{case_name}: reference {reference:?} names a file with no successful idmap output") + }); + + let ids = match (sections.defines.get(alias), sections.references.get(alias)) { + (Some(_), Some(_)) => { + panic!("{case_name}: alias {alias:?} in {file_name} is both a define and a reference") + } + (Some(ids), None) | (None, Some(ids)) => ids, + (None, None) => { + panic!("{case_name}: alias {alias:?} not found in {file_name}'s idmap") + } + }; + + assert_eq!( + ids.0.len(), + 1, + "{case_name}: alias {alias:?} in {file_name} resolves to more than one id: {:?}", + ids.0 + ); + ids.0.iter().next().unwrap().clone() +} + +/// Catches typos in `case.yaml`: file names that don't exist in this case, +/// and `links`/`distinct` groups too small to assert anything. +fn validate_case_config(case_name: &str, file_names: &BTreeSet, config: &CaseConfig) { + for file_name in config.diagram_types.keys() { + assert!( + file_names.contains(file_name), + "{case_name}: case.yaml names {file_name:?}, which is not a .puml file in this case" + ); + } + + for group in config.links.iter().chain(config.distinct.iter()) { + assert!( + group.len() >= 2, + "{case_name}: a links/distinct group needs at least 2 entries, got {group:?}" + ); + for reference in group { + let (file_name, _alias) = reference.split_once('#').unwrap_or_else(|| { + panic!("{case_name}: malformed reference {reference:?}, expected file.puml#Alias") + }); + assert!( + file_names.contains(file_name), + "{case_name}: reference {reference:?} names a file that is not in this case" + ); + } + } +} + +/// Adds the `links`/`distinct` cross-file id assertions on top of the +/// framework's default per-file `check_ok`/`check_err`. +struct CrossDiagramChecker; + +impl ExpectationChecker for CrossDiagramChecker { + fn check_ok(&self, actual: &IdMapSections, expected: &Expected) { + ExpectationChecker::::check_ok( + &DefaultExpectationChecker, + actual, + expected, + ); + } + + fn check_err(&self, err: &PumlCliError, expected: &YamlValue, base_dir: &Path) { + ExpectationChecker::::check_err( + &DefaultExpectationChecker, + err, + expected, + base_dir, + ); + } + + fn check_case(&self, outputs: &HashMap, IdMapSections>, dir: &Path) { + let case_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("cross_diagram_case"); + let config = load_case_config(dir); + + let file_names: BTreeSet = outputs + .keys() + .map(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or_else(|| panic!("{case_name}: non-utf8 file name {}", path.display())) + .to_string() + }) + .collect(); + validate_case_config(case_name, &file_names, &config); + + let by_name: HashMap = outputs + .iter() + .map(|(path, sections)| { + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_else(|| panic!("{case_name}: non-utf8 file name {}", path.display())) + .to_string(); + (file_name, sections) + }) + .collect(); + + for group in &config.links { + let ids: Vec = group + .iter() + .map(|reference| resolve_ref(&by_name, reference, case_name)) + .collect(); + for pair in ids.windows(2) { + assert_eq!( + pair[0], pair[1], + "{case_name}: expected {group:?} to all resolve to the same id, got {ids:?}" + ); + } + } + + for group in &config.distinct { + let ids: Vec = group + .iter() + .map(|reference| resolve_ref(&by_name, reference, case_name)) + .collect(); + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!( + ids[i], ids[j], + "{case_name}: expected {:?} and {:?} to resolve to distinct ids, both got {:?}", + group[i], group[j], ids[i] + ); + } + } + } + } +} + +/// Lists the case directories that actually exist on disk, so +/// [`cross_diagram_cases!`] can be checked against reality instead of silently +/// never running a case whose directory has no matching `#[test]` fn. A +/// directory counts as a case only if it directly contains a `.puml` file, +/// which excludes unrelated sibling directories the test runner may place +/// under this crate at runtime (e.g. its own working directory). +fn discover_case_names() -> BTreeSet { + let base = case_file_path("plantuml/parser/integration_test/cross_diagram"); + fs::read_dir(&base) + .unwrap_or_else(|error| panic!("failed to list {}: {error}", base.display())) + .filter_map(|entry| { + let entry = entry.unwrap_or_else(|error| { + panic!("failed to read entry in {}: {error}", base.display()) + }); + let path = entry.path(); + (path.is_dir() && !puml_files(&path).is_empty()) + .then(|| entry.file_name().to_string_lossy().into_owned()) + }) + .collect() +} + +fn run_cross_diagram_case(case_name: &str) { + run_case( + "integration_test/cross_diagram", + case_name, + PumlCliIdmapRunner, + CrossDiagramChecker, + ); +} + +/// Declares one `#[test]` per case name, plus a guard test asserting that the +/// list matches the case directories on disk in both directions. +macro_rules! cross_diagram_cases { + ($($name:ident),+ $(,)?) => { + $( + #[test] + fn $name() { + run_cross_diagram_case(stringify!($name)); + } + )+ + + #[test] + fn all_case_directories_are_registered() { + let registered: BTreeSet = + [$(stringify!($name)),+].into_iter().map(str::to_string).collect(); + let on_disk = discover_case_names(); + assert_eq!( + registered, on_disk, + "case directories under cross_diagram/ must exactly match the #[test] fns \ + declared via cross_diagram_cases!()" + ); + } + }; +} + +cross_diagram_cases!( + component_nesting, + class_alias_wins, + namespace_and_package, + sequence_forms, + prose_without_alias, + linking_three_diagrams, + qualified_reference, +); diff --git a/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/case.yaml b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/case.yaml new file mode 100644 index 00000000..cf4a23d9 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/case.yaml @@ -0,0 +1,24 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# Today, a root-level (non-nested) component and a class with no alias both +# get an idmap id equal to their plain name, which is also how a sequence +# participant's id is derived. This lets a sequence diagram link to both a +# component and a class purely by matching alias text, without any explicit +# root-anchor/qualification mechanism (that lands in a later PR). +diagram_types: + component_diagram.puml: component + class_diagram.puml: class + sequence_diagram.puml: sequence +links: + - ["component_diagram.puml#Backend", "sequence_diagram.puml#Backend"] + - ["class_diagram.puml#IBackend", "sequence_diagram.puml#IBackend"] diff --git a/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/class_diagram.puml b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/class_diagram.puml new file mode 100644 index 00000000..bf0e74dc --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/class_diagram.puml @@ -0,0 +1,17 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +interface IBackend { + +StartListening() : void +} +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/component_diagram.puml b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/component_diagram.puml new file mode 100644 index 00000000..062da73b --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/component_diagram.puml @@ -0,0 +1,15 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +component "Backend" as Backend +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/output.json b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/output.json new file mode 100644 index 00000000..86ba0f3b --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/output.json @@ -0,0 +1,21 @@ +{ + "component_diagram.puml": { + "defines": {}, + "references": { + "Backend": "Backend" + } + }, + "class_diagram.puml": { + "defines": { + "IBackend": "IBackend" + }, + "references": {} + }, + "sequence_diagram.puml": { + "defines": {}, + "references": { + "Backend": "Backend", + "IBackend": "IBackend" + } + } +} diff --git a/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/sequence_diagram.puml b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/sequence_diagram.puml new file mode 100644 index 00000000..5d86689a --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/linking_three_diagrams/sequence_diagram.puml @@ -0,0 +1,17 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +participant "Backend" as Backend +participant IBackend +Backend -> IBackend : StartListening() +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/namespace_and_package/case.yaml b/plantuml/parser/integration_test/cross_diagram/namespace_and_package/case.yaml new file mode 100644 index 00000000..e43e37c6 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/namespace_and_package/case.yaml @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# `namespace` and `package` blocks both contribute a dotted scope to the +# nested class's id, and each scope is itself synthesized as a "define" so +# it can be referenced on its own. +diagram_types: + class_diagram.puml: class diff --git a/plantuml/parser/integration_test/cross_diagram/namespace_and_package/class_diagram.puml b/plantuml/parser/integration_test/cross_diagram/namespace_and_package/class_diagram.puml new file mode 100644 index 00000000..4b06926b --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/namespace_and_package/class_diagram.puml @@ -0,0 +1,24 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +namespace score { + class Recorder { + +Record() : void + } +} +package logging { + class Formatter { + +Format() : void + } +} +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/namespace_and_package/output.json b/plantuml/parser/integration_test/cross_diagram/namespace_and_package/output.json new file mode 100644 index 00000000..e9d48bef --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/namespace_and_package/output.json @@ -0,0 +1,11 @@ +{ + "class_diagram.puml": { + "defines": { + "score": "score", + "Recorder": "score.Recorder", + "logging": "logging", + "Formatter": "logging.Formatter" + }, + "references": {} + } +} diff --git a/plantuml/parser/integration_test/cross_diagram/prose_without_alias/case.yaml b/plantuml/parser/integration_test/cross_diagram/prose_without_alias/case.yaml new file mode 100644 index 00000000..40894b12 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/prose_without_alias/case.yaml @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# Regression baseline for the two "Not yet implemented" notes in +# sequence-diagram.md: a prose participant label with no alias is accepted +# (id == the label text), and referring to a declared participant by its +# display name instead of its alias silently creates a second, separate +# implicit participant. Once identity is derived per the label forms, this +# becomes an `errors` case for both. +diagram_types: + sequence_diagram.puml: sequence diff --git a/plantuml/parser/integration_test/cross_diagram/prose_without_alias/output.json b/plantuml/parser/integration_test/cross_diagram/prose_without_alias/output.json new file mode 100644 index 00000000..6754a3ea --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/prose_without_alias/output.json @@ -0,0 +1,11 @@ +{ + "sequence_diagram.puml": { + "defines": {}, + "references": { + "Caller": "Caller", + "Order Service": "Order Service", + "Display Service": "Display Service", + "DisplayService": "DisplayService" + } + } +} diff --git a/plantuml/parser/integration_test/cross_diagram/prose_without_alias/sequence_diagram.puml b/plantuml/parser/integration_test/cross_diagram/prose_without_alias/sequence_diagram.puml new file mode 100644 index 00000000..54478490 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/prose_without_alias/sequence_diagram.puml @@ -0,0 +1,18 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +participant "Order Service" +participant "Display Service" as DisplayService +Caller -> "Order Service" : call() +Caller -> "Display Service" : notify() +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/qualified_reference/case.yaml b/plantuml/parser/integration_test/cross_diagram/qualified_reference/case.yaml new file mode 100644 index 00000000..d16a3504 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/qualified_reference/case.yaml @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# Regression baseline for F3: the grammar truncates a `::`-qualified +# relationship endpoint to its first segment, so `A --> a::B` looks up a bare +# name `a` instead of resolving `a::B` as a path. This is expected to become a +# passing case once the qualified_identifier grammar accepts `::` in +# relationship endpoints. +diagram_types: + class_diagram.puml: class diff --git a/plantuml/parser/integration_test/cross_diagram/qualified_reference/class_diagram.puml b/plantuml/parser/integration_test/cross_diagram/qualified_reference/class_diagram.puml new file mode 100644 index 00000000..69890d6b --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/qualified_reference/class_diagram.puml @@ -0,0 +1,16 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +class A +A --> a::B +@enduml diff --git a/plantuml/parser/integration_test/cross_diagram/qualified_reference/output.yaml b/plantuml/parser/integration_test/cross_diagram/qualified_reference/output.yaml new file mode 100644 index 00000000..2fa167fc --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/qualified_reference/output.yaml @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +class_diagram.puml: + error: + type: "ResolveError" + fields: + message: "Class Resolver: Unresolved reference: a" diff --git a/plantuml/parser/integration_test/cross_diagram/sequence_forms/case.yaml b/plantuml/parser/integration_test/cross_diagram/sequence_forms/case.yaml new file mode 100644 index 00000000..1097be1c --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/sequence_forms/case.yaml @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# Baseline for today's sequence participant identity: every participant is a +# "reference" (never a "define"), and its id is always exactly its alias, +# whether declared with an explicit `as` clause or bare. This is expected to +# change once Rule B (participant uid derivation) lands in a later PR. +diagram_types: + sequence_diagram.puml: sequence diff --git a/plantuml/parser/integration_test/cross_diagram/sequence_forms/output.json b/plantuml/parser/integration_test/cross_diagram/sequence_forms/output.json new file mode 100644 index 00000000..2769abf8 --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/sequence_forms/output.json @@ -0,0 +1,9 @@ +{ + "sequence_diagram.puml": { + "defines": {}, + "references": { + "Frontend": "Frontend", + "Backend": "Backend" + } + } +} diff --git a/plantuml/parser/integration_test/cross_diagram/sequence_forms/sequence_diagram.puml b/plantuml/parser/integration_test/cross_diagram/sequence_forms/sequence_diagram.puml new file mode 100644 index 00000000..4831ceef --- /dev/null +++ b/plantuml/parser/integration_test/cross_diagram/sequence_forms/sequence_diagram.puml @@ -0,0 +1,18 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +@startuml +participant "Front End" as Frontend +participant Backend +Frontend -> Backend : Request() +Backend --> Frontend : Response() +@enduml diff --git a/plantuml/parser/integration_test/src/lib.rs b/plantuml/parser/integration_test/src/lib.rs index 45fac08a..f0da7a01 100644 --- a/plantuml/parser/integration_test/src/lib.rs +++ b/plantuml/parser/integration_test/src/lib.rs @@ -15,5 +15,5 @@ mod test_framework; pub use test_error_view::{ErrorView, ProjectedError}; pub use test_framework::{ - run_case, DefaultExpectationChecker, DiagramProcessor, ExpectationChecker, + run_case, DefaultExpectationChecker, DiagramProcessor, ExpectationChecker, Expected, }; diff --git a/plantuml/parser/integration_test/src/test_framework.rs b/plantuml/parser/integration_test/src/test_framework.rs index de5459cf..a0ec2c97 100644 --- a/plantuml/parser/integration_test/src/test_framework.rs +++ b/plantuml/parser/integration_test/src/test_framework.rs @@ -135,6 +135,11 @@ pub trait DiagramProcessor { pub trait ExpectationChecker { fn check_ok(&self, actual: &Output, expected: &Expected); fn check_err(&self, err: &Error, expected: &YamlValue, base_dir: &Path); + + /// Called once per case, after every per-file check has passed, with + /// every output the processor produced. No-op by default; override it + /// for assertions that span multiple files (e.g. cross-file id links). + fn check_case(&self, _outputs: &HashMap, Output>, _dir: &Path) {} } // =================== Default Checker =================== @@ -286,4 +291,8 @@ where } } } + + if let Ok(outputs) = &result { + checker.check_case(outputs, &dir); + } } diff --git a/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs b/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs index 1363725c..2bb710a5 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs @@ -67,7 +67,7 @@ fn normalize_creole_inline(text: &str) -> String { continue; } - if let Some(tag_len) = creole_tag_length(remaining) { + if let Some(tag_len) = puml_utils::style_markup_tag_length(remaining) { index += tag_len; continue; } @@ -193,29 +193,6 @@ fn strip_tooltip(text: &str) -> &str { } } -fn creole_tag_length(text: &str) -> Option { - if !text.starts_with('<') { - return None; - } - - let end = text.find('>')?; - let tag = &text[1..end].trim().to_ascii_lowercase(); - - let known_tags = [ - "b", "/b", "i", "/i", "u", "/u", "s", "/s", "w", "/w", "img", "/img", "font", "/font", - ]; - - if known_tags.contains(&tag.as_str()) - || tag.starts_with("color:") - || tag.starts_with("back:") - || tag.starts_with("size:") - { - Some(end + 1) - } else { - None - } -} - fn looks_like_table_row(text: &str) -> bool { text.starts_with('|') && text.ends_with('|') && text.len() >= 2 } @@ -301,4 +278,14 @@ mod tests { fn normalize_full_line_bold_text_without_list_stripping() { assert_eq!(normalize_creole_text("**action green**"), "action green"); } + + #[test] + fn normalize_creole_strips_closing_color_back_size_tags() { + assert_eq!( + normalize_creole_text( + "red hl big" + ), + "red hl big" + ); + } } diff --git a/plantuml/parser/puml_parser/src/class_diagram/BUILD b/plantuml/parser/puml_parser/src/class_diagram/BUILD index 5c689fb7..ffb56397 100644 --- a/plantuml/parser/puml_parser/src/class_diagram/BUILD +++ b/plantuml/parser/puml_parser/src/class_diagram/BUILD @@ -40,6 +40,7 @@ rust_library( "//plantuml/parser/puml_parser:parser_core", "//plantuml/parser/puml_utils", "//tools/metamodel/common:source_location", + "//tools/metamodel/common:uid_utils", "@crates//:log", "@crates//:pest", "@crates//:serde", diff --git a/plantuml/parser/puml_parser/src/class_diagram/src/class_parser.rs b/plantuml/parser/puml_parser/src/class_diagram/src/class_parser.rs index 785e7524..69253d6c 100644 --- a/plantuml/parser/puml_parser/src/class_diagram/src/class_parser.rs +++ b/plantuml/parser/puml_parser/src/class_diagram/src/class_parser.rs @@ -61,16 +61,12 @@ struct IgnoredObjectRegistry { } impl IgnoredObjectRegistry { - fn normalize_fqn(raw: &str) -> String { - raw.replace("::", ".").trim_matches('.').to_string() - } - fn build_fqn(name: &str, parent: &Option) -> String { - let normalized_name = Self::normalize_fqn(name); + let normalized_name = uid_utils::normalize(name); match parent { Some(p) => { - let normalized_parent = Self::normalize_fqn(p); + let normalized_parent = uid_utils::normalize(p); if normalized_parent.is_empty() { normalized_name @@ -99,7 +95,7 @@ impl IgnoredObjectRegistry { } fn contains_reference(&self, name: &str, parent: &Option) -> bool { - let normalized = Self::normalize_fqn(name); + let normalized = uid_utils::normalize(name); self.ids.contains(&normalized) || self.ids.contains(&Self::build_fqn(name, parent)) diff --git a/plantuml/parser/puml_resolver/src/class_diagram/BUILD b/plantuml/parser/puml_resolver/src/class_diagram/BUILD index 6c446a0a..d12e2a3a 100644 --- a/plantuml/parser/puml_resolver/src/class_diagram/BUILD +++ b/plantuml/parser/puml_resolver/src/class_diagram/BUILD @@ -26,6 +26,7 @@ rust_library( "//plantuml/parser/puml_parser:parser_core", "//plantuml/parser/puml_resolver:resolver_traits", "//tools/metamodel/class:class_diagram", + "//tools/metamodel/common:uid_utils", "@crates//:log", "@crates//:serde", "@crates//:thiserror", diff --git a/plantuml/parser/puml_resolver/src/class_diagram/src/class_resolver.rs b/plantuml/parser/puml_resolver/src/class_diagram/src/class_resolver.rs index ddc8bff8..1835c531 100644 --- a/plantuml/parser/puml_resolver/src/class_diagram/src/class_resolver.rs +++ b/plantuml/parser/puml_resolver/src/class_diagram/src/class_resolver.rs @@ -105,16 +105,12 @@ impl ClassResolver { } } - fn normalize_fqn(raw: &str) -> String { - raw.replace("::", ".").trim_matches('.').to_string() - } - fn build_fqn(&self, name: &str, parent: &Option) -> String { - let normalized_name = Self::normalize_fqn(name); + let normalized_name = uid_utils::normalize(name); match parent { Some(p) => { - let normalized_parent = Self::normalize_fqn(p); + let normalized_parent = uid_utils::normalize(p); if normalized_parent.is_empty() { normalized_name @@ -144,8 +140,8 @@ impl ClassResolver { fn resolve_name(&self, name: &str, parent: &Option) -> Option { // 1. FQN - if name.contains('.') || name.contains("::") { - return Some(Self::normalize_fqn(name)); + if uid_utils::is_identifier_path(name) { + return Some(uid_utils::normalize(name)); } // 2. Current Namespace diff --git a/plantuml/parser/puml_utils/BUILD b/plantuml/parser/puml_utils/BUILD index 6ee17950..14f8b2da 100644 --- a/plantuml/parser/puml_utils/BUILD +++ b/plantuml/parser/puml_utils/BUILD @@ -10,11 +10,12 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("@rules_rust//rust:defs.bzl", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( name = "puml_utils", srcs = [ + "src/label_markup.rs", "src/lib.rs", "src/log.rs", "src/write_files.rs", @@ -26,3 +27,8 @@ rust_library( "@crates//:serde_json", ], ) + +rust_test( + name = "puml_utils_unit_test", + crate = ":puml_utils", +) diff --git a/plantuml/parser/puml_utils/src/label_markup.rs b/plantuml/parser/puml_utils/src/label_markup.rs new file mode 100644 index 00000000..a3f43bf1 --- /dev/null +++ b/plantuml/parser/puml_utils/src/label_markup.rs @@ -0,0 +1,138 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Shared cleanup for PlantUML display-name labels. +//! +//! Both the activity diagram's creole normalization and the sequence +//! resolver's participant identity derivation need to recognize the same set +//! of inline style markup tags (``, ``, ``, ...) and, for +//! sequence participants, decode literal `\n` escapes into real line breaks +//! before the first line is inspected. This module is the single source of +//! truth for both. + +const KNOWN_TAGS: &[&str] = &[ + "b", "/b", "i", "/i", "u", "/u", "s", "/s", "w", "/w", "img", "/img", "font", "/font", +]; +const STYLED_TAGS: &[&str] = &["color", "back", "size"]; + +/// Whether `tag` (the text between `<` and `>`, without the brackets) is a +/// recognized PlantUML inline style markup tag, e.g. `b`, `/b`, `color:red` +/// or `/color`. +fn is_style_markup_tag(tag: &str) -> bool { + let tag = tag.trim().to_ascii_lowercase(); + + KNOWN_TAGS.contains(&tag.as_str()) + || STYLED_TAGS.iter().any(|styled_tag| { + tag.strip_prefix('/') + .is_some_and(|rest| rest == *styled_tag) + || tag + .split_once(':') + .is_some_and(|(name, _)| name == *styled_tag) + }) +} + +/// Length in bytes (including both `<` and `>`) of a recognized style markup +/// tag at the start of `text`, or `None` if `text` doesn't start with one. +pub fn style_markup_tag_length(text: &str) -> Option { + if !text.starts_with('<') { + return None; + } + + let end = text.find('>')?; + + is_style_markup_tag(&text[1..end]).then_some(end + 1) +} + +/// Strips all recognized inline style markup tags from `text`, leaving +/// everything else (including unrecognized `<...>` sequences) untouched. +pub fn strip_style_markup(text: &str) -> String { + let mut normalized = String::new(); + let mut index = 0; + + while index < text.len() { + let remaining = &text[index..]; + + if let Some(tag_len) = style_markup_tag_length(remaining) { + index += tag_len; + continue; + } + + let ch = remaining.chars().next().expect("remaining is non-empty"); + normalized.push(ch); + index += ch.len_utf8(); + } + + normalized +} + +/// Decodes literal `\n` escape sequences, as PlantUML authors write them in +/// quoted labels, into real newline characters. +pub fn decode_newline_escapes(text: &str) -> String { + text.replace("\\n", "\n") +} + +/// Identity-label cleanup: decode `\n` escapes into line breaks and strip +/// inline style markup, so the result is ready for first-line/`:`-based +/// inspection. +pub fn normalize_identity_label(text: &str) -> String { + strip_style_markup(&decode_newline_escapes(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_style_markup_removes_known_tags() { + assert_eq!(strip_style_markup("bold plain"), "bold plain"); + assert_eq!( + strip_style_markup("red text"), + "red text" + ); + assert_eq!(strip_style_markup("hl"), "hl"); + assert_eq!(strip_style_markup("big"), "big"); + } + + #[test] + fn strip_style_markup_keeps_unknown_tags() { + assert_eq!( + strip_style_markup("text"), + "text" + ); + } + + #[test] + fn strip_style_markup_is_case_insensitive_and_trims_whitespace() { + assert_eq!(strip_style_markup("bold"), "bold"); + assert_eq!(strip_style_markup("< b >bold< /b >"), "bold"); + assert_eq!(strip_style_markup("text"), "text"); + } + + #[test] + fn strip_style_markup_keeps_non_ascii_text() { + assert_eq!(strip_style_markup("\u{fc}ber"), "\u{fc}ber"); + } + + #[test] + fn decode_newline_escapes_turns_backslash_n_into_newline() { + assert_eq!(decode_newline_escapes("a\\nb"), "a\nb"); + } + + #[test] + fn normalize_identity_label_decodes_then_strips() { + assert_eq!( + normalize_identity_label("Service\\nignored"), + "Service\nignored" + ); + } +} diff --git a/plantuml/parser/puml_utils/src/lib.rs b/plantuml/parser/puml_utils/src/lib.rs index cd0f806b..7db0306f 100644 --- a/plantuml/parser/puml_utils/src/lib.rs +++ b/plantuml/parser/puml_utils/src/lib.rs @@ -10,8 +10,12 @@ // // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* +mod label_markup; mod log; mod write_files; +pub use label_markup::{ + decode_newline_escapes, normalize_identity_label, strip_style_markup, style_markup_tag_length, +}; pub use log::LogLevel; pub use write_files::{write_fbs_to_file, write_json_to_file, write_placeholder_file}; diff --git a/tools/metamodel/common/BUILD b/tools/metamodel/common/BUILD index ac233eb1..34bc447a 100644 --- a/tools/metamodel/common/BUILD +++ b/tools/metamodel/common/BUILD @@ -10,7 +10,7 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("@rules_rust//rust:defs.bzl", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") package(default_visibility = ["//visibility:public"]) @@ -24,3 +24,16 @@ rust_library( "@crates//:serde", ], ) + +rust_library( + name = "uid_utils", + srcs = ["uid_utils.rs"], + crate_name = "uid_utils", + crate_root = "uid_utils.rs", + edition = "2021", +) + +rust_test( + name = "uid_utils_unit_test", + crate = ":uid_utils", +) diff --git a/tools/metamodel/common/uid_utils.rs b/tools/metamodel/common/uid_utils.rs new file mode 100644 index 00000000..65f01db6 --- /dev/null +++ b/tools/metamodel/common/uid_utils.rs @@ -0,0 +1,151 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +/// Normalizes `::` and `.` separators to `.` and trims leading/trailing `.`. +/// Interior empty segments (e.g. `"a..b"`) are kept, and whitespace is not +/// trimmed; callers that need either normalize the input themselves. +pub fn normalize(value: &str) -> String { + value.replace("::", ".").trim_matches('.').to_string() +} + +pub fn normalized_segments(value: &str) -> Vec { + normalize(value) + .split('.') + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect() +} + +pub fn join(parts: I) -> String +where + I: IntoIterator, + S: AsRef, +{ + parts + .into_iter() + .filter(|part| !part.as_ref().is_empty()) + .map(|part| part.as_ref().to_string()) + .collect::>() + .join(".") +} + +/// Returns `true` if `value` looks like a qualified identifier path (contains +/// a `.` or `::` separator) rather than a single unqualified name. +pub fn is_identifier_path(value: &str) -> bool { + value.contains('.') || value.contains("::") +} + +/// Strips a leading `anchor` prefix from `value`, returning the remainder +/// (with any leading separator removed). If `anchor` is `None`, empty, or is +/// not a prefix of `value`, the normalized `value` is returned unchanged. +pub fn strip_anchor(value: &str, anchor: Option<&str>) -> String { + let normalized_value = normalize(value); + let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) else { + return normalized_value; + }; + let value_segments = normalized_segments(value); + let anchor_segments = normalized_segments(anchor); + + if !value_segments.starts_with(&anchor_segments) { + return normalized_value; + } + + join(&value_segments[anchor_segments.len()..]) +} + +#[cfg(test)] +mod tests { + use super::{is_identifier_path, join, normalize, normalized_segments, strip_anchor}; + + #[test] + fn normalize_treats_cpp_and_dot_separators_equally() { + assert_eq!( + normalize("score::mw.log::Recorder"), + "score.mw.log.Recorder" + ); + } + + #[test] + fn normalize_drops_leading_and_trailing_dots() { + assert_eq!(normalize("..score::mw::log.."), "score.mw.log"); + } + + #[test] + fn normalized_segments_drops_empty_segments_after_normalization() { + assert_eq!( + normalized_segments("..score::mw.log::Recorder.."), + vec!["score", "mw", "log", "Recorder"] + ); + } + + #[test] + fn join_skips_empty_segments() { + assert_eq!( + join(["root", "", "domain", "Controller"]), + "root.domain.Controller" + ); + } + + #[test] + fn join_returns_empty_string_when_all_segments_are_empty() { + assert!(join(["", "", ""]).is_empty()); + } + + #[test] + fn is_identifier_path_accepts_cpp_and_dot_qualified_names() { + assert!(is_identifier_path("score::mw::log::Recorder")); + assert!(is_identifier_path("score.mw.log.Recorder")); + assert!(!is_identifier_path("Recorder")); + } + + #[test] + fn strip_anchor_strips_matching_prefix() { + assert_eq!( + strip_anchor( + "score.logging.package_a.InternalInterface", + Some("score::logging") + ), + "package_a.InternalInterface" + ); + } + + #[test] + fn strip_anchor_returns_empty_string_when_value_equals_anchor() { + assert_eq!(strip_anchor("score::logging", Some("score.logging")), ""); + } + + #[test] + fn strip_anchor_keeps_unrooted_values_unchanged() { + assert_eq!( + strip_anchor("package_a.InternalInterface", Some("score::logging")), + "package_a.InternalInterface" + ); + assert_eq!( + strip_anchor("score.logginging.Component", Some("score::logging")), + "score.logginging.Component" + ); + } + + #[test] + fn strip_anchor_skips_interior_empty_segments() { + assert_eq!(strip_anchor("score.logging..x", Some("score.logging")), "x"); + } + + #[test] + fn strip_anchor_returns_normalized_value_when_anchor_is_absent() { + assert_eq!( + strip_anchor("score::logging::Recorder", None), + "score.logging.Recorder" + ); + } +} diff --git a/tools/serialization/flatbuffers/sequence/sequence_serializer.rs b/tools/serialization/flatbuffers/sequence/sequence_serializer.rs index ea6e5b42..fb0aecd8 100644 --- a/tools/serialization/flatbuffers/sequence/sequence_serializer.rs +++ b/tools/serialization/flatbuffers/sequence/sequence_serializer.rs @@ -178,7 +178,7 @@ impl SequenceSerializer { builder, &fb::InteractionArgs { sender, - receiver: receiver, + receiver, message, source_location: Some(source_location), },