From 1ba9e9adf42cfe636b1cbceb74be871dbeb11be3 Mon Sep 17 00:00:00 2001 From: ambujsingh Date: Fri, 22 May 2026 17:14:48 +0530 Subject: [PATCH] fixed cross-diagram linking and extend to class/sequence diagrams - Fix class_serializer to prepend actual source filename to source_files vector, enabling the Sphinx extension to match links by .puml filename - Extend linker to parse class (CLSD) and sequence (SEQD) FlatBuffers, extracting entities and participants for cross-diagram linking - Register diagram names as virtual top-level aliases for title-based linking - Extend clickable_plantuml regex to accept hyphens/dots in element identifiers - Add linker README with architecture docs and usage instructions --- plantuml/linker/BUILD | 2 + plantuml/linker/README.md | 257 +++++++++++++++ plantuml/linker/src/main.rs | 307 +++++++++++++++++- .../puml_parser/src/grammar/component.pest | 2 +- .../component_diagram/src/component_logic.rs | 1 + .../src/component_resolver.rs | 1 + plantuml/parser/puml_serializer/src/fbs/BUILD | 2 + .../puml_serializer/src/fbs/component.fbs | 3 + .../src/serialize/class_serializer.rs | 9 +- .../src/serialize/component_serializer.rs | 3 +- .../clickable_plantuml/clickable_plantuml.py | 53 ++- 11 files changed, 613 insertions(+), 27 deletions(-) diff --git a/plantuml/linker/BUILD b/plantuml/linker/BUILD index bd432299..9c23ed4e 100644 --- a/plantuml/linker/BUILD +++ b/plantuml/linker/BUILD @@ -18,7 +18,9 @@ rust_binary( crate_root = "src/main.rs", visibility = ["//visibility:public"], deps = [ + "//plantuml/parser/puml_serializer/src/fbs:class_fbs", "//plantuml/parser/puml_serializer/src/fbs:component_fbs", + "//plantuml/parser/puml_serializer/src/fbs:sequence_fbs", "@crates//:clap", "@crates//:env_logger", "@crates//:flatbuffers", diff --git a/plantuml/linker/README.md b/plantuml/linker/README.md index 710adbc9..5a106874 100644 --- a/plantuml/linker/README.md +++ b/plantuml/linker/README.md @@ -10,3 +10,260 @@ SPDX-License-Identifier: Apache-2.0 ----------------------------------------------------------------------------- --> + +# PlantUML Linker + +The **linker** reads FlatBuffers `.fbs.bin` files produced by the PlantUML parser and generates a `plantuml_links.json` file consumed by the [`clickable_plantuml`](../sphinx/clickable_plantuml/) Sphinx extension to make diagrams interactive. + +## How It Works + +``` +.puml files + │ + ▼ +┌──────────┐ ┌────────────┐ ┌──────────────────────┐ ┌──────────────────┐ +│ Parser │ ──▶ │ .fbs.bin │ ──▶ │ Linker │ ──▶ │ plantuml_links │ +│ (puml_cli)│ │ (FlatBuf) │ │ (cross-diagram match)│ │ .json │ +└──────────┘ └────────────┘ └──────────────────────┘ └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ clickable_plantuml│ + │ (Sphinx extension)│ + └──────────────────┘ +``` + +### Supported Diagram Types + +| Diagram Type | File ID | What Is Extracted | +|---|---|---| +| **Component** (`COMP`) | Alias, name, FQN (`id`), parent hierarchy, and relation targets | +| **Class** (`CLSD`) | Entity name, FQN (`id`), diagram name, and relationship sources/targets | +| **Sequence** (`SEQD`) | Unique participants (callers and callees from interactions) | + +### Link Generation Algorithm + +1. **Parse** each `.fbs.bin` file, detecting the diagram type by its 4-byte file identifier at bytes 4–7. +2. **Build an index** of top-level aliases — components/entities with no parent — mapped to their source `.puml` file. Both the PlantUML alias and the FQN (`id`) are registered as index keys, along with any distinct `name`. +3. **Register diagram names** (from `@startuml `) as virtual top-level aliases, enabling cross-diagram linking by title. +4. **Extract relationship targets**: relation targets from component diagrams and relationship sources/targets from class diagrams are added as linkable elements. +5. **Match**: for each element alias in diagram A, if a top-level entry with the same name exists in diagram B, emit a link `A → B`. +6. **Deduplicate** and keep one target per alias (PlantUML supports only one `url of` per alias). + +### Output Format + +```json +{ + "links": [ + { + "source_file": "overview.puml", + "source_id": "MyComponent", + "target_file": "my_component_detail.puml" + } + ] +} +``` + +## Usage + +```bash +bazel run //plantuml/linker -- \ + --fbs-files path/to/*.fbs.bin \ + --output plantuml_links.json \ + --log-level info +``` + +### Arguments + +| Argument | Default | Description | +|---|---|---| +| `--fbs-files` | *(required)* | One or more `.fbs.bin` FlatBuffer files to process | +| `--output` | `plantuml_links.json` | Output JSON file path | +| `--log-level` | `warn` | Log verbosity: `error`, `warn`, `info`, `debug`, `trace` | + +## Build & Test + +```bash +# Build +bazel build //plantuml/linker + +# Build with Clippy lint +bazel build //plantuml/linker --config=clippy + +# Run tests +bazel test //plantuml/linker:linker_test +``` + +## Linking Examples + +### Component ↔ Component (alias match) + +A top-level component in one diagram links to its detailed view in another: + +```plantuml +' overview.puml +@startuml +[AuthService] --> [UserDB] +@enduml +``` + +```plantuml +' auth_detail.puml +@startuml +package AuthService { + [TokenManager] + [SessionStore] +} +@enduml +``` + +**Result:** `AuthService` in `overview.puml` becomes clickable → navigates to `auth_detail.puml`. + +### Component ↔ Sequence (participant match) + +Sequence diagram participants link back to component diagrams that define them: + +```plantuml +' login_flow.puml +@startuml +AuthService -> TokenManager : validate() +TokenManager -> SessionStore : getSession() +@enduml +``` + +**Result:** `AuthService`, `TokenManager`, and `SessionStore` in `login_flow.puml` become clickable → navigate to `auth_detail.puml` (where they are top-level components). + +### Class ↔ Class (relationship target) + +Relationship targets in class diagrams link to the diagram where the target class is defined: + +```plantuml +' models.puml +@startuml +class User { + +name: String +} +class Order { + +items: List +} +User --> Order : places +@enduml +``` + +```plantuml +' order_detail.puml +@startuml +class Order { + +items: List + +total: float +} +class Payment { + +amount: float +} +Order --> Payment +@enduml +``` + +**Result:** `Order` in `models.puml` becomes clickable → navigates to `order_detail.puml`. `Payment` extracted from the relationship also becomes linkable. + +### FQN / ID match + +When a component's fully qualified `id` (e.g. `auth.TokenManager`) is indexed, diagrams referencing that FQN can match even when the PlantUML alias differs: + +```plantuml +' system.puml — component with id "auth.TokenManager", alias "TokenManager" +``` + +Other diagrams with a top-level element named `auth.TokenManager` will link to `system.puml`. + +### Diagram name match + +A diagram's `@startuml ` title acts as a virtual alias: + +```plantuml +' auth_detail.puml +@startuml auth_detail +package AuthService { ... } +@enduml +``` + +Any component with alias `auth_detail` in another diagram links to `auth_detail.puml`. + +### Component name + alias (both registered) + +When a component has both an `alias` and a distinct `name`, both are registered as linkable elements: + +```plantuml +' services.puml +@startuml +[Authentication Service] as AuthSvc +@enduml +``` + +**Result:** Both `AuthSvc` (alias) and `Authentication Service` (name) are indexed. A component named `Authentication Service` in another diagram will link here. + +### Component relation targets + +Dependency arrows extract their targets as linkable elements, even when the target isn't explicitly declared as a separate component: + +```plantuml +' gateway.puml +@startuml +[APIGateway] --> [AuthService] : authenticates +[APIGateway] --> [RateLimiter] : throttles +@enduml +``` + +**Result:** `AuthService` and `RateLimiter` are extracted from relation targets. If either is a top-level component in another diagram, the arrow target becomes clickable. + +### Class ↔ Component (cross-type linking) + +Class entity names are matched against component aliases across diagram types: + +```plantuml +' class_model.puml +@startuml +class AuthService { + +login(user: String): Token +} +@enduml +``` + +```plantuml +' system_overview.puml (component diagram) +@startuml +[AuthService] --> [UserDB] +@enduml +``` + +**Result:** `AuthService` in `system_overview.puml` becomes clickable → navigates to `class_model.puml` (where it is defined as a class entity). + +### Sequence ↔ Class (cross-type linking) + +Sequence diagram participants match class entity names from class diagrams: + +```plantuml +' login_flow.puml +@startuml +AuthService -> TokenManager : validate() +@enduml +``` + +```plantuml +' token_classes.puml +@startuml +class TokenManager { + +validate(): bool +} +@enduml +``` + +**Result:** `TokenManager` in `login_flow.puml` becomes clickable → navigates to `token_classes.puml`. + +## Related Changes + +The linker works in concert with two other modified components: + +- **`puml_serializer` ([class_serializer.rs](../parser/puml_serializer/src/serialize/class_serializer.rs))**: Prepends the actual source filename to the serialized `source_files` vector so the linker can correlate class diagrams with their `.puml` file. + +- **`clickable_plantuml` ([clickable_plantuml.py](../sphinx/clickable_plantuml/clickable_plantuml.py))**: Extended alias formatting to accept hyphens and dots in element identifiers (e.g., `my-component`, `pkg.Class`), enabling `url of` directives for FQN-style names. diff --git a/plantuml/linker/src/main.rs b/plantuml/linker/src/main.rs index 87ac1c88..2e8f8331 100644 --- a/plantuml/linker/src/main.rs +++ b/plantuml/linker/src/main.rs @@ -20,13 +20,15 @@ //! alias in diagram A matches a top-level component alias in diagram B, a //! clickable link is created from A → B. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use clap::{Parser, ValueEnum}; use env_logger::Builder; +use class_fbs::class_metamodel as fb_class; use component_fbs::component as fb_component; +use sequence_fbs::sequence_metamodel as fb_sequence; // --------------------------------------------------------------------------- // Log level @@ -89,6 +91,10 @@ struct Args { #[derive(Debug)] struct DiagramComponent { alias: String, + /// Fully qualified identifier (e.g. `package.Component`), registered as + /// an additional key in the top-level index so other diagrams can match + /// by FQN. + id: Option, parent_id: Option, } @@ -96,6 +102,10 @@ struct DiagramComponent { #[derive(Debug)] struct DiagramInfo { source_file: String, + /// Optional diagram name (from `@startuml ` or class diagram name). + /// When present, this name is registered as an additional top-level alias + /// so other diagrams can link to this diagram by its title. + diagram_name: Option, components: Vec, } @@ -117,14 +127,30 @@ struct LinksJson { // FlatBuffers reading // --------------------------------------------------------------------------- -fn read_diagram(path: &str) -> Result { - let data = fs::read(path).map_err(|e| format!("Failed to read {path}: {e}"))?; +/// File identifier bytes written by the component serializer ("COMP" at bytes 4–7). +/// Class ("CLSD") and sequence ("SEQD") diagrams carry a different identifier and +/// are intentionally skipped — they do not participate in component-level linking. +const COMPONENT_FILE_ID: &[u8; 4] = b"COMP"; +fn parse_component_diagram(path: &str, data: &[u8]) -> Result { if data.is_empty() { return Err(format!("Empty file (placeholder): {path}")); } - let graph = flatbuffers::root::(&data) + // Reject non-component FlatBuffers (class / sequence diagrams) early so we + // never mis-parse their binary layout as a ComponentGraph. + if data.len() < 8 || &data[4..8] != COMPONENT_FILE_ID { + let found = if data.len() >= 8 { + String::from_utf8_lossy(&data[4..8]).into_owned() + } else { + "".to_string() + }; + return Err(format!( + "Not a component diagram FlatBuffer (expected id 'COMP', found '{found}'): {path}" + )); + } + + let graph = flatbuffers::root::(data) .map_err(|e| format!("Failed to parse FlatBuffer {path}: {e}"))?; let source_file = graph @@ -143,19 +169,261 @@ fn read_diagram(path: &str) -> Result { if alias.is_empty() { continue; } + let id = comp + .id() + .filter(|s| !s.is_empty() && *s != alias) + .map(|s| s.to_string()); + let parent_id = comp.parent_id().map(|s| s.to_string()); + + // When the component has both an alias and a distinct name, + // register the name as an additional linkable element. + if comp.alias().is_some() { + if let Some(name) = comp.name().filter(|n| !n.is_empty() && *n != alias) { + components.push(DiagramComponent { + alias: name.to_string(), + id: None, + parent_id: parent_id.clone(), + }); + } + } + components.push(DiagramComponent { alias, - parent_id: comp.parent_id().map(|s| s.to_string()), + id, + parent_id, }); + + // Extract relation targets so components referenced via + // dependency arrows (e.g. `[A] --> [B]`) become linkable. + if let Some(relations) = comp.relations() { + for rel in relations.iter() { + if let Some(target) = rel.target() { + if !target.is_empty() { + components.push(DiagramComponent { + alias: target.to_string(), + id: None, + parent_id: None, + }); + } + } + } + } } } Ok(DiagramInfo { source_file, + diagram_name: None, components, }) } +/// File identifier for class diagram FlatBuffers ("CLSD"). +const CLASS_FILE_ID: &[u8; 4] = b"CLSD"; + +fn parse_class_diagram(path: &str, data: &[u8]) -> Result { + if data.is_empty() { + return Err(format!("Empty file (placeholder): {path}")); + } + + if data.len() < 8 || &data[4..8] != CLASS_FILE_ID { + return Err(format!("Not a class diagram FlatBuffer: {path}")); + } + + let diagram = flatbuffers::root::(data) + .map_err(|e| format!("Failed to parse class FlatBuffer {path}: {e}"))?; + + // source_files is a vector; take the first non-empty entry. + let source_file = diagram + .source_files() + .and_then(|v| v.iter().find(|s| !s.is_empty()).map(|s| s.to_string())) + .ok_or_else(|| format!("Missing source_files in class FlatBuffer: {path}"))?; + + // The diagram name (from @startuml ) can serve as a link target. + // ClassDiagram.name is required (non-Option &str). + let raw_name = diagram.name(); + let diagram_name = if !raw_name.is_empty() && raw_name != source_file { + Some(raw_name.to_string()) + } else { + None + }; + + let mut components = Vec::new(); + if let Some(entities) = diagram.entities() { + for entity in entities.iter() { + let name = entity.name().to_string(); + if name.is_empty() { + continue; + } + let id_str = entity.id(); + let id = if !id_str.is_empty() && id_str != name { + Some(id_str.to_string()) + } else { + None + }; + components.push(DiagramComponent { + alias: name, + id, + parent_id: entity.enclosing_namespace_id().map(|s| s.to_string()), + }); + + // Extract relationship targets defined on this entity so that + // referenced classes (inheritance, composition, etc.) become linkable. + if let Some(rels) = entity.relationships() { + for rel in rels.iter() { + let target = rel.target(); + if !target.is_empty() { + components.push(DiagramComponent { + alias: target.to_string(), + id: None, + parent_id: None, + }); + } + } + } + } + } + + // Also extract targets from diagram-level relationships (outside entities). + if let Some(rels) = diagram.relationships() { + for rel in rels.iter() { + let target = rel.target(); + if !target.is_empty() { + components.push(DiagramComponent { + alias: target.to_string(), + id: None, + parent_id: None, + }); + } + let source = rel.source(); + if !source.is_empty() { + components.push(DiagramComponent { + alias: source.to_string(), + id: None, + parent_id: None, + }); + } + } + } + + Ok(DiagramInfo { + source_file, + diagram_name, + components, + }) +} + +/// File identifier for sequence diagram FlatBuffers ("SEQD"). +const SEQUENCE_FILE_ID: &[u8; 4] = b"SEQD"; + +fn parse_sequence_diagram(path: &str, data: &[u8]) -> Result { + if data.is_empty() { + return Err(format!("Empty file (placeholder): {path}")); + } + + if data.len() < 8 || &data[4..8] != SEQUENCE_FILE_ID { + return Err(format!("Not a sequence diagram FlatBuffer: {path}")); + } + + let diagram = flatbuffers::root::(data) + .map_err(|e| format!("Failed to parse sequence FlatBuffer {path}: {e}"))?; + + let source_file = diagram + .source_files() + .and_then(|v| v.iter().find(|s| !s.is_empty()).map(|s| s.to_string())) + .ok_or_else(|| format!("Missing source_files in sequence FlatBuffer: {path}"))?; + + let diagram_name = diagram + .name() + .filter(|n| !n.is_empty() && *n != source_file) + .map(|n| n.to_string()); + + // Extract unique participants from interactions as top-level "components" + // so they can be linked to their defining diagrams (component or class). + let mut participants: HashSet = HashSet::new(); + if let Some(nodes) = diagram.root_interactions() { + collect_sequence_participants(&nodes, &mut participants); + } + + let components = participants + .into_iter() + .map(|name| DiagramComponent { + alias: name, + id: None, + parent_id: None, + }) + .collect(); + + Ok(DiagramInfo { + source_file, + diagram_name, + components, + }) +} + +/// Recursively collect caller/callee names from sequence diagram nodes. +fn collect_sequence_participants( + nodes: &flatbuffers::Vector>, + participants: &mut HashSet, +) { + for node in nodes.iter() { + match node.event_type() { + fb_sequence::Event::Interaction => { + if let Some(interaction) = node.event_as_interaction() { + let caller = interaction.caller(); + if !caller.is_empty() && !participants.contains(caller) { + participants.insert(caller.to_string()); + } + let callee = interaction.callee(); + if !callee.is_empty() && !participants.contains(callee) { + participants.insert(callee.to_string()); + } + } + } + fb_sequence::Event::Return => { + if let Some(ret) = node.event_as_return() { + let caller = ret.caller(); + if !caller.is_empty() && !participants.contains(caller) { + participants.insert(caller.to_string()); + } + let callee = ret.callee(); + if !callee.is_empty() && !participants.contains(callee) { + participants.insert(callee.to_string()); + } + } + } + _ => {} + } + // Recurse into child nodes + if let Some(branches) = node.branches_node() { + collect_sequence_participants(&branches, participants); + } + } +} + +/// Attempt to read a FlatBuffer file as a component, class, or sequence +/// diagram and return the extracted [`DiagramInfo`]. +fn read_any_diagram(path: &str) -> Result { + let data = fs::read(path).map_err(|e| format!("Failed to read {path}: {e}"))?; + if data.is_empty() { + return Err(format!("Empty file (placeholder): {path}")); + } + let id = if data.len() >= 8 { + &data[4..8] + } else { + &[] as &[u8] + }; + match id { + b"COMP" => parse_component_diagram(path, &data), + b"CLSD" => parse_class_diagram(path, &data), + b"SEQD" => parse_sequence_diagram(path, &data), + other => { + let s = String::from_utf8_lossy(other); + Err(format!("Unsupported FlatBuffer type '{s}': {path}")) + } + } +} + // --------------------------------------------------------------------------- // Link generation // --------------------------------------------------------------------------- @@ -167,25 +435,44 @@ fn read_diagram(path: &str) -> Result { /// source_file = A, source_id = alias, target_file = B /// /// A component is considered "top-level" if its `parent_id` is `None`. +/// Additionally, diagrams with a `diagram_name` register that name as a +/// virtual top-level alias, enabling components to link to diagrams by title. fn generate_links(diagrams: &[DiagramInfo]) -> Vec { - // Index: alias → list of diagrams where that alias is a top-level component - let mut top_level_index: HashMap> = HashMap::new(); + // Index: alias → list of diagrams where that alias is a top-level component. + // Uses borrowed &str keys since `diagrams` outlives the index. + let mut top_level_index: HashMap<&str, Vec<&str>> = HashMap::new(); for diagram in diagrams { for comp in &diagram.components { if comp.parent_id.is_none() { top_level_index - .entry(comp.alias.clone()) + .entry(comp.alias.as_str()) .or_default() .push(&diagram.source_file); + // Also register the FQN so other diagrams can match by + // fully qualified identifier. + if let Some(id) = &comp.id { + top_level_index + .entry(id.as_str()) + .or_default() + .push(&diagram.source_file); + } } } + // Register the diagram name as a virtual top-level alias so that + // components in other diagrams can link to this diagram by title. + if let Some(name) = &diagram.diagram_name { + top_level_index + .entry(name.as_str()) + .or_default() + .push(&diagram.source_file); + } } let mut links = Vec::new(); for diagram in diagrams { for comp in &diagram.components { - if let Some(target_diagrams) = top_level_index.get(&comp.alias) { + if let Some(target_diagrams) = top_level_index.get(comp.alias.as_str()) { for &target_file in target_diagrams { // Don't link a component to its own diagram. if target_file == diagram.source_file { @@ -239,7 +526,7 @@ fn main() -> Result<(), Box> { let mut diagrams = Vec::new(); for fbs_path in &args.fbs_files { - match read_diagram(fbs_path) { + match read_any_diagram(fbs_path) { Ok(diagram) => { log::info!( "Read {} components from {}", diff --git a/plantuml/parser/puml_parser/src/grammar/component.pest b/plantuml/parser/puml_parser/src/grammar/component.pest index beb01638..953e4ec6 100644 --- a/plantuml/parser/puml_parser/src/grammar/component.pest +++ b/plantuml/parser/puml_parser/src/grammar/component.pest @@ -60,7 +60,7 @@ BRACKET_NAME = @{ (ASCII_ALPHANUMERIC | "_" | "-" | " " | "\\")+ } element_kind = { component_kind | deployment_kind } component_kind = { - "artifact" | "card" | "cloud" | "component" | "database" | + "artifact" | "card" | "cloud" | "collections" | "component" | "database" | "file" | "folder" | "frame" | "hexagon" | "interface" | "node" | "package" | "queue" | "rectangle" | "stack" | "storage" } diff --git a/plantuml/parser/puml_resolver/src/component_diagram/src/component_logic.rs b/plantuml/parser/puml_resolver/src/component_diagram/src/component_logic.rs index ff0789c2..038525c9 100644 --- a/plantuml/parser/puml_resolver/src/component_diagram/src/component_logic.rs +++ b/plantuml/parser/puml_resolver/src/component_diagram/src/component_logic.rs @@ -38,6 +38,7 @@ pub enum ElementType { Boundary, Card, Cloud, + Collections, Component, Control, Database, diff --git a/plantuml/parser/puml_resolver/src/component_diagram/src/component_resolver.rs b/plantuml/parser/puml_resolver/src/component_diagram/src/component_resolver.rs index 84c55ea4..12472fc7 100644 --- a/plantuml/parser/puml_resolver/src/component_diagram/src/component_resolver.rs +++ b/plantuml/parser/puml_resolver/src/component_diagram/src/component_resolver.rs @@ -289,6 +289,7 @@ const ELEMENT_TYPE_TABLE: &[(&str, ElementType)] = &[ ("boundary", ElementType::Boundary), ("card", ElementType::Card), ("cloud", ElementType::Cloud), + ("collections", ElementType::Collections), ("component", ElementType::Component), ("control", ElementType::Control), ("database", ElementType::Database), diff --git a/plantuml/parser/puml_serializer/src/fbs/BUILD b/plantuml/parser/puml_serializer/src/fbs/BUILD index 6bb8b2bb..7a677764 100644 --- a/plantuml/parser/puml_serializer/src/fbs/BUILD +++ b/plantuml/parser/puml_serializer/src/fbs/BUILD @@ -62,6 +62,7 @@ rust_library( # Generated code from flatc - suppress all lints we can't fix rustc_flags = ["--cap-lints=allow"], visibility = [ + "//plantuml/linker:__pkg__", "//plantuml/parser:__subpackages__", "//validation/core:__subpackages__", ], @@ -91,6 +92,7 @@ rust_library( # Generated code from flatc - suppress all lints we can't fix rustc_flags = ["--cap-lints=allow"], visibility = [ + "//plantuml/linker:__pkg__", "//plantuml/parser:__subpackages__", "//validation/core:__subpackages__", ], diff --git a/plantuml/parser/puml_serializer/src/fbs/component.fbs b/plantuml/parser/puml_serializer/src/fbs/component.fbs index ca9c2660..3b6de3a3 100644 --- a/plantuml/parser/puml_serializer/src/fbs/component.fbs +++ b/plantuml/parser/puml_serializer/src/fbs/component.fbs @@ -26,6 +26,7 @@ enum ComponentType:byte { Boundary, Card, Cloud, + Collections, Component, Control, Database, @@ -65,3 +66,5 @@ table ComponentGraph { } root_type ComponentGraph; + +file_identifier "COMP"; diff --git a/plantuml/parser/puml_serializer/src/serialize/class_serializer.rs b/plantuml/parser/puml_serializer/src/serialize/class_serializer.rs index 079560e1..299d3743 100644 --- a/plantuml/parser/puml_serializer/src/serialize/class_serializer.rs +++ b/plantuml/parser/puml_serializer/src/serialize/class_serializer.rs @@ -23,7 +23,7 @@ const UNKNOWN_SOURCE_LINE: u32 = 0; pub struct ClassSerializer; impl ClassSerializer { - pub fn serialize(diagram: &ClassDiagram, _source_file: &str) -> Vec { + pub fn serialize(diagram: &ClassDiagram, source_file: &str) -> Vec { let mut builder = FlatBufferBuilder::new(); let name_offset = builder.create_string(&diagram.name); @@ -42,8 +42,11 @@ impl ClassSerializer { .collect(); let relationships_offset = builder.create_vector(&relationship_offsets); - let source_offsets: Vec<_> = diagram - .source_files + // Prepend the actual source filename so the linker can correlate + // class diagrams with their .puml file (used by clickable_plantuml). + let mut sources: Vec<&str> = vec![source_file]; + sources.extend(diagram.source_files.iter().map(|s| s.as_str())); + let source_offsets: Vec<_> = sources .iter() .map(|source| builder.create_string(source)) .collect(); diff --git a/plantuml/parser/puml_serializer/src/serialize/component_serializer.rs b/plantuml/parser/puml_serializer/src/serialize/component_serializer.rs index d886ed69..0fc64346 100644 --- a/plantuml/parser/puml_serializer/src/serialize/component_serializer.rs +++ b/plantuml/parser/puml_serializer/src/serialize/component_serializer.rs @@ -105,7 +105,7 @@ impl ComponentSerializer { // -------------------------- // 4) finish // -------------------------- - builder.finish(root, None); + builder.finish(root, Some("COMP")); builder.finished_data().to_vec() } @@ -118,6 +118,7 @@ impl ComponentSerializer { ElementType::Boundary => fb::ComponentType::Boundary, ElementType::Card => fb::ComponentType::Card, ElementType::Cloud => fb::ComponentType::Cloud, + ElementType::Collections => fb::ComponentType::Collections, ElementType::Component => fb::ComponentType::Component, ElementType::Control => fb::ComponentType::Control, ElementType::Database => fb::ComponentType::Database, diff --git a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py index 7317a052..149e572e 100644 --- a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py +++ b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py @@ -29,8 +29,13 @@ # Stores {puml_basename: (docname, anchor_id_or_None)} _ENV_PUML_DOCNAMES = "clickable_plantuml_puml_docnames" -# Characters allowed in PlantUML alias identifiers. -_ALIAS_SAFE_RE = re.compile(r"^[\w.]+$") +# Simple PlantUML alias: alphanumeric + underscore only (matches ALIAS_ID grammar rule). +_ALIAS_SIMPLE_RE = re.compile(r"^[\w]+$") +# Extended simple alias: also allows hyphens and dots (common in FQN-style identifiers). +_ALIAS_EXTENDED_RE = re.compile(r"^[\w][\w.\-]*$") +# Quoted PlantUML alias: word chars, spaces, hyphens, and dots +# (injected as "name" in url-of directives). +_ALIAS_QUOTED_RE = re.compile(r"^[\w\s.\-]+$") def _find_parent_section_id(node: nodes.Node) -> str | None: @@ -100,20 +105,44 @@ def _collect_link_data(source_dir: Path) -> dict[str, dict[str, Any]]: # --------------------------------------------------------------------------- +def _format_alias_part(alias: str) -> str | None: + """Return the PlantUML-safe representation of *alias*, or ``None`` if unsafe. + + Simple identifiers (``[A-Za-z0-9_]+``) are returned as-is. + Extended identifiers (word chars, hyphens, dots) are returned as-is + (PlantUML accepts them in ``url of`` without quoting). + Names that contain spaces are wrapped in double-quotes. + Anything else is rejected to prevent injection into the ``url of`` directive. + """ + if _ALIAS_SIMPLE_RE.match(alias): + return alias + if _ALIAS_EXTENDED_RE.match(alias): + return alias + if _ALIAS_QUOTED_RE.match(alias) and alias.strip(): + return f'"{alias}"' + return None + + def _inject_links_into_uml(uml_content: str, links: dict[str, str]) -> str: - """Append ``url of is [[url]]`` directives before ``@enduml``.""" + """Append ``url of is [[url{}{_top}]]`` directives before ``@enduml``. + + The ``{_top}`` window target ensures that clickable links inside an SVG + embedded via ```` navigate the top-level browser frame rather than + the object's own browsing context. + """ if not links: return uml_content - safe_links = { - alias: url - for alias, url in links.items() - if _ALIAS_SAFE_RE.match(alias) and "]]" not in url - } - if not safe_links: + url_directives_list = [] + for alias, url in links.items(): + if "]]" in url: + continue + alias_part = _format_alias_part(alias) + if alias_part is None: + continue + url_directives_list.append(f"url of {alias_part} is [[{url}{{}}{{_top}}]]") + if not url_directives_list: return uml_content - url_directives = "\n".join( - f"url of {alias} is [[{url}]]" for alias, url in safe_links.items() - ) + url_directives = "\n".join(url_directives_list) enduml_match = re.search(r"^\s*@enduml\s*$", uml_content, re.MULTILINE) if enduml_match: prefix = uml_content[: enduml_match.start()]