Skip to content

Commit b539154

Browse files
authored
Joho migrate plantuml linker (#141)
* add plantuml linker * add sphinx extension for clickable plantuml figures
1 parent ccca9e4 commit b539154

8 files changed

Lines changed: 801 additions & 2 deletions

File tree

plantuml/linker/BUILD

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# *******************************************************************************
2+
# Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
#
4+
# See the NOTICE file(s) distributed with this work for additional
5+
# information regarding copyright ownership.
6+
#
7+
# This program and the accompanying materials are made available under the
8+
# terms of the Apache License Version 2.0 which is available at
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# SPDX-License-Identifier: Apache-2.0
12+
# *******************************************************************************
13+
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test")
14+
15+
rust_binary(
16+
name = "linker",
17+
srcs = ["src/main.rs"],
18+
crate_root = "src/main.rs",
19+
visibility = ["//visibility:public"],
20+
deps = [
21+
"//plantuml/parser/puml_serializer/src/fbs:component_fbs",
22+
"@crates//:clap",
23+
"@crates//:flatbuffers",
24+
"@crates//:serde",
25+
"@crates//:serde_json",
26+
],
27+
)
28+
29+
rust_test(
30+
name = "linker_test",
31+
crate = ":linker",
32+
)

plantuml/linker/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!--
2+
Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
4+
See the NOTICE file(s) distributed with this work for additional
5+
information regarding copyright ownership.
6+
7+
This program and the accompanying materials are made available under the
8+
terms of the Apache License Version 2.0 which is available at
9+
https://www.apache.org/licenses/LICENSE-2.0
10+
11+
SPDX-License-Identifier: Apache-2.0
12+
-->
13+
14+
# PlantUML Linker
15+
16+
Reads `.fbs.bin` files produced by the [PlantUML parser](../parser/README.md) and generates a
17+
`plantuml_links.json` file consumed by the `clickable_plantuml` Sphinx extension.
18+
19+
## What it does
20+
21+
When an architecture is described across multiple PlantUML component diagrams, the linker
22+
correlates components between them: if a component alias in diagram **A** matches a top-level
23+
component alias in diagram **B**, the linker emits a link from A → B. This lets the Sphinx
24+
documentation render clickable diagrams where high-level overview components link through to
25+
their detailed sub-diagrams.
26+
27+
## Usage
28+
29+
```
30+
linker --fbs-files <file1.fbs.bin> [<file2.fbs.bin> ...] --output plantuml_links.json
31+
```
32+
33+
The tool is invoked automatically by the `architectural_design()` Bazel rule — there is
34+
normally no need to call it manually.
35+
36+
## Build
37+
38+
```bash
39+
bazel build //plantuml/linker:linker
40+
```

plantuml/linker/src/main.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// *******************************************************************************
2+
// Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
//
4+
// See the NOTICE file(s) distributed with this work for additional
5+
// information regarding copyright ownership.
6+
//
7+
// This program and the accompanying materials are made available under the
8+
// terms of the Apache License Version 2.0 which is available at
9+
// <https://www.apache.org/licenses/LICENSE-2.0>
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
// *******************************************************************************
13+
14+
//! PlantUML Linker
15+
//!
16+
//! Reads FlatBuffers `.fbs.bin` files produced by the PlantUML parser and
17+
//! generates `plantuml_links.json` for the `clickable_plantuml` Sphinx extension.
18+
//!
19+
//! The tool correlates components across multiple diagrams: when a component
20+
//! alias in diagram A matches a top-level component alias in diagram B, a
21+
//! clickable link is created from A → B.
22+
23+
use std::collections::HashMap;
24+
use std::fs;
25+
26+
use clap::Parser;
27+
28+
use component_fbs::component as fb_component;
29+
30+
// ---------------------------------------------------------------------------
31+
// CLI
32+
// ---------------------------------------------------------------------------
33+
34+
#[derive(Parser, Debug)]
35+
#[command(name = "linker")]
36+
#[command(version = "1.0")]
37+
#[command(
38+
about = "Generate plantuml_links.json from FlatBuffers diagram outputs",
39+
long_about = "Reads .fbs.bin files from the PlantUML parser and produces a \
40+
plantuml_links.json file mapping component aliases to their \
41+
detailed diagrams for the clickable_plantuml Sphinx extension."
42+
)]
43+
struct Args {
44+
/// FlatBuffers binary files to process (.fbs.bin)
45+
#[arg(long, num_args = 1..)]
46+
fbs_files: Vec<String>,
47+
48+
/// Output JSON file path
49+
#[arg(long, default_value = "plantuml_links.json")]
50+
output: String,
51+
}
52+
53+
// ---------------------------------------------------------------------------
54+
// Data model
55+
// ---------------------------------------------------------------------------
56+
57+
/// A component extracted from a FlatBuffers diagram.
58+
#[derive(Debug)]
59+
struct DiagramComponent {
60+
alias: String,
61+
parent_id: Option<String>,
62+
}
63+
64+
/// All components from a single diagram file.
65+
#[derive(Debug)]
66+
struct DiagramInfo {
67+
source_file: String,
68+
components: Vec<DiagramComponent>,
69+
}
70+
71+
/// One entry in the output JSON `links` array.
72+
#[derive(Debug, serde::Serialize)]
73+
struct LinkEntry {
74+
source_file: String,
75+
source_id: String,
76+
target_file: String,
77+
}
78+
79+
/// Root structure of the output JSON.
80+
#[derive(Debug, serde::Serialize)]
81+
struct LinksJson {
82+
links: Vec<LinkEntry>,
83+
}
84+
85+
// ---------------------------------------------------------------------------
86+
// FlatBuffers reading
87+
// ---------------------------------------------------------------------------
88+
89+
fn read_diagram(path: &str) -> Result<DiagramInfo, String> {
90+
let data = fs::read(path).map_err(|e| format!("Failed to read {path}: {e}"))?;
91+
92+
if data.is_empty() {
93+
return Err(format!("Empty file (placeholder): {path}"));
94+
}
95+
96+
let graph = flatbuffers::root::<fb_component::ComponentGraph>(&data)
97+
.map_err(|e| format!("Failed to parse FlatBuffer {path}: {e}"))?;
98+
99+
let source_file = graph
100+
.source_file()
101+
.filter(|s| !s.is_empty())
102+
.map(|s| s.to_string())
103+
.ok_or_else(|| format!("Missing source_file in FlatBuffer: {path}"))?;
104+
105+
let mut components = Vec::new();
106+
if let Some(entries) = graph.components() {
107+
for entry in entries.iter() {
108+
let Some(comp) = entry.value() else {
109+
continue;
110+
};
111+
let alias = comp.alias().or(comp.name()).unwrap_or_default().to_string();
112+
if alias.is_empty() {
113+
continue;
114+
}
115+
components.push(DiagramComponent {
116+
alias,
117+
parent_id: comp.parent_id().map(|s| s.to_string()),
118+
});
119+
}
120+
}
121+
122+
Ok(DiagramInfo {
123+
source_file,
124+
components,
125+
})
126+
}
127+
128+
// ---------------------------------------------------------------------------
129+
// Link generation
130+
// ---------------------------------------------------------------------------
131+
132+
/// Build links by matching component aliases across diagrams.
133+
///
134+
/// For each component alias in diagram A, if a top-level component (no parent)
135+
/// with the same alias exists in diagram B, we create a link:
136+
/// source_file = A, source_id = alias, target_file = B
137+
///
138+
/// A component is considered "top-level" if its `parent_id` is `None`.
139+
fn generate_links(diagrams: &[DiagramInfo]) -> Vec<LinkEntry> {
140+
// Index: alias → list of diagrams where that alias is a top-level component
141+
let mut top_level_index: HashMap<String, Vec<&str>> = HashMap::new();
142+
for diagram in diagrams {
143+
for comp in &diagram.components {
144+
if comp.parent_id.is_none() {
145+
top_level_index
146+
.entry(comp.alias.clone())
147+
.or_default()
148+
.push(&diagram.source_file);
149+
}
150+
}
151+
}
152+
153+
let mut links = Vec::new();
154+
155+
for diagram in diagrams {
156+
for comp in &diagram.components {
157+
if let Some(target_diagrams) = top_level_index.get(&comp.alias) {
158+
for &target_file in target_diagrams {
159+
// Don't link a component to its own diagram.
160+
if target_file == diagram.source_file {
161+
continue;
162+
}
163+
links.push(LinkEntry {
164+
source_file: diagram.source_file.clone(),
165+
source_id: comp.alias.clone(),
166+
target_file: target_file.to_string(),
167+
});
168+
}
169+
}
170+
}
171+
}
172+
173+
// Deduplicate: same (source_file, source_id, target_file) may appear
174+
// when a component is nested inside multiple parent scopes.
175+
links.sort_by(|a, b| {
176+
(&a.source_file, &a.source_id, &a.target_file).cmp(&(
177+
&b.source_file,
178+
&b.source_id,
179+
&b.target_file,
180+
))
181+
});
182+
links.dedup_by(|a, b| {
183+
a.source_file == b.source_file
184+
&& a.source_id == b.source_id
185+
&& a.target_file == b.target_file
186+
});
187+
188+
// PlantUML supports only one URL per alias — keep the first target
189+
// (alphabetically) for each (source_file, source_id) pair.
190+
links.dedup_by(|a, b| a.source_file == b.source_file && a.source_id == b.source_id);
191+
192+
links
193+
}
194+
195+
// ---------------------------------------------------------------------------
196+
// Main
197+
// ---------------------------------------------------------------------------
198+
199+
fn main() -> Result<(), Box<dyn std::error::Error>> {
200+
let args = Args::parse();
201+
202+
if args.fbs_files.is_empty() {
203+
return Err("No .fbs.bin files provided. Use --fbs-files <file> ...".into());
204+
}
205+
206+
let mut diagrams = Vec::new();
207+
for fbs_path in &args.fbs_files {
208+
match read_diagram(fbs_path) {
209+
Ok(diagram) => {
210+
eprintln!(
211+
"Read {} components from {}",
212+
diagram.components.len(),
213+
diagram.source_file
214+
);
215+
diagrams.push(diagram);
216+
}
217+
Err(e) => {
218+
eprintln!("Warning: skipping {fbs_path}: {e}");
219+
}
220+
}
221+
}
222+
223+
let links = generate_links(&diagrams);
224+
eprintln!("Generated {} link(s)", links.len());
225+
226+
let output = LinksJson { links };
227+
let json = serde_json::to_string_pretty(&output)?;
228+
fs::write(&args.output, &json)?;
229+
eprintln!("Written to {}", args.output);
230+
231+
Ok(())
232+
}

plantuml/parser/BUILD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ alias(
1515
actual = "//plantuml/parser/puml_cli:puml_cli",
1616
visibility = ["//visibility:public"],
1717
)
18+
19+
alias(
20+
name = "linker",
21+
actual = "//plantuml/linker:linker",
22+
visibility = ["//visibility:public"],
23+
)

plantuml/parser/puml_serializer/src/fbs/BUILD

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ rust_library(
3939
"--allow=clippy::needless-lifetimes",
4040
],
4141
visibility = [
42-
"//plantuml/parser:__subpackages__",
43-
"//validation/archver:__pkg__",
42+
"//visibility:public",
4443
],
4544
deps = [
4645
"@crates//:flatbuffers",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# *******************************************************************************
2+
# Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
#
4+
# See the NOTICE file(s) distributed with this work for additional
5+
# information regarding copyright ownership.
6+
#
7+
# This program and the accompanying materials are made available under the
8+
# terms of the Apache License Version 2.0 which is available at
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# SPDX-License-Identifier: Apache-2.0
12+
# *******************************************************************************
13+
py_library(
14+
name = "clickable_plantuml",
15+
srcs = ["clickable_plantuml.py"],
16+
imports = ["."],
17+
visibility = ["//visibility:public"],
18+
)

0 commit comments

Comments
 (0)