Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions plantuml/linker/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# *******************************************************************************
# 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_binary", "rust_test")

rust_binary(
name = "linker",
srcs = ["src/main.rs"],
crate_root = "src/main.rs",
visibility = ["//visibility:public"],
deps = [
"//plantuml/parser/puml_serializer/src/fbs:component_fbs",
"@crates//:clap",
"@crates//:flatbuffers",
"@crates//:serde",
"@crates//:serde_json",
],
)

rust_test(
name = "linker_test",
crate = ":linker",
)
40 changes: 40 additions & 0 deletions plantuml/linker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!--
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
-->

# PlantUML Linker

Reads `.fbs.bin` files produced by the [PlantUML parser](../parser/README.md) and generates a
`plantuml_links.json` file consumed by the `clickable_plantuml` Sphinx extension.

## What it does

When an architecture is described across multiple PlantUML component diagrams, the linker
correlates components between them: if a component alias in diagram **A** matches a top-level
component alias in diagram **B**, the linker emits a link from A → B. This lets the Sphinx
documentation render clickable diagrams where high-level overview components link through to
their detailed sub-diagrams.

## Usage

```
linker --fbs-files <file1.fbs.bin> [<file2.fbs.bin> ...] --output plantuml_links.json
```

The tool is invoked automatically by the `architectural_design()` Bazel rule — there is
normally no need to call it manually.

## Build

```bash
bazel build //plantuml/linker:linker
```
232 changes: 232 additions & 0 deletions plantuml/linker/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// *******************************************************************************
// 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
// *******************************************************************************

//! PlantUML Linker
//!
//! Reads FlatBuffers `.fbs.bin` files produced by the PlantUML parser and
//! generates `plantuml_links.json` for the `clickable_plantuml` Sphinx extension.
//!
//! The tool correlates components across multiple diagrams: when a component
//! 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::fs;

use clap::Parser;

use component_fbs::component as fb_component;

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

#[derive(Parser, Debug)]
#[command(name = "linker")]
#[command(version = "1.0")]
#[command(
about = "Generate plantuml_links.json from FlatBuffers diagram outputs",
long_about = "Reads .fbs.bin files from the PlantUML parser and produces a \
plantuml_links.json file mapping component aliases to their \
detailed diagrams for the clickable_plantuml Sphinx extension."
)]
struct Args {
/// FlatBuffers binary files to process (.fbs.bin)
#[arg(long, num_args = 1..)]
fbs_files: Vec<String>,

/// Output JSON file path
#[arg(long, default_value = "plantuml_links.json")]
output: String,
}

// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------

/// A component extracted from a FlatBuffers diagram.
#[derive(Debug)]
struct DiagramComponent {
alias: String,
parent_id: Option<String>,
}

/// All components from a single diagram file.
#[derive(Debug)]
struct DiagramInfo {
source_file: String,
components: Vec<DiagramComponent>,
}

/// One entry in the output JSON `links` array.
#[derive(Debug, serde::Serialize)]
struct LinkEntry {
source_file: String,
source_id: String,
target_file: String,
}

/// Root structure of the output JSON.
#[derive(Debug, serde::Serialize)]
struct LinksJson {
links: Vec<LinkEntry>,
}

// ---------------------------------------------------------------------------
// FlatBuffers reading
// ---------------------------------------------------------------------------

fn read_diagram(path: &str) -> Result<DiagramInfo, String> {
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 graph = flatbuffers::root::<fb_component::ComponentGraph>(&data)
.map_err(|e| format!("Failed to parse FlatBuffer {path}: {e}"))?;

let source_file = graph
.source_file()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| format!("Missing source_file in FlatBuffer: {path}"))?;

let mut components = Vec::new();
if let Some(entries) = graph.components() {
for entry in entries.iter() {
let Some(comp) = entry.value() else {
continue;
};
let alias = comp.alias().or(comp.name()).unwrap_or_default().to_string();
if alias.is_empty() {
continue;
}
components.push(DiagramComponent {
alias,
parent_id: comp.parent_id().map(|s| s.to_string()),
});
}
}

Ok(DiagramInfo {
source_file,
components,
})
}

// ---------------------------------------------------------------------------
// Link generation
// ---------------------------------------------------------------------------

/// Build links by matching component aliases across diagrams.
///
/// For each component alias in diagram A, if a top-level component (no parent)
/// with the same alias exists in diagram B, we create a link:
/// source_file = A, source_id = alias, target_file = B
///
/// A component is considered "top-level" if its `parent_id` is `None`.
fn generate_links(diagrams: &[DiagramInfo]) -> Vec<LinkEntry> {
// Index: alias → list of diagrams where that alias is a top-level component
let mut top_level_index: HashMap<String, 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())
.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) {
for &target_file in target_diagrams {
// Don't link a component to its own diagram.
if target_file == diagram.source_file {
continue;
}
links.push(LinkEntry {
source_file: diagram.source_file.clone(),
source_id: comp.alias.clone(),
target_file: target_file.to_string(),
});
}
}
}
}

// Deduplicate: same (source_file, source_id, target_file) may appear
// when a component is nested inside multiple parent scopes.
links.sort_by(|a, b| {
(&a.source_file, &a.source_id, &a.target_file).cmp(&(
&b.source_file,
&b.source_id,
&b.target_file,
))
});
links.dedup_by(|a, b| {
a.source_file == b.source_file
&& a.source_id == b.source_id
&& a.target_file == b.target_file
});

// PlantUML supports only one URL per alias — keep the first target
// (alphabetically) for each (source_file, source_id) pair.
links.dedup_by(|a, b| a.source_file == b.source_file && a.source_id == b.source_id);

links
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();

if args.fbs_files.is_empty() {
return Err("No .fbs.bin files provided. Use --fbs-files <file> ...".into());
}

let mut diagrams = Vec::new();
for fbs_path in &args.fbs_files {
match read_diagram(fbs_path) {
Ok(diagram) => {
eprintln!(
"Read {} components from {}",
diagram.components.len(),
diagram.source_file
);
diagrams.push(diagram);
}
Err(e) => {
eprintln!("Warning: skipping {fbs_path}: {e}");
}
}
}

let links = generate_links(&diagrams);
eprintln!("Generated {} link(s)", links.len());

let output = LinksJson { links };
let json = serde_json::to_string_pretty(&output)?;
fs::write(&args.output, &json)?;
eprintln!("Written to {}", args.output);

Ok(())
}
6 changes: 6 additions & 0 deletions plantuml/parser/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ alias(
actual = "//plantuml/parser/puml_cli:puml_cli",
visibility = ["//visibility:public"],
)

alias(
name = "linker",
actual = "//plantuml/linker:linker",
visibility = ["//visibility:public"],
)
3 changes: 1 addition & 2 deletions plantuml/parser/puml_serializer/src/fbs/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ rust_library(
"--allow=clippy::needless-lifetimes",
],
visibility = [
"//plantuml/parser:__subpackages__",
"//validation/archver:__pkg__",
"//visibility:public",
],
deps = [
"@crates//:flatbuffers",
Expand Down
18 changes: 18 additions & 0 deletions plantuml/sphinx/clickable_plantuml/BUILD
Original file line number Diff line number Diff line change
@@ -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
# *******************************************************************************
py_library(
name = "clickable_plantuml",
srcs = ["clickable_plantuml.py"],
imports = ["."],
visibility = ["//visibility:public"],
)
Loading
Loading