diff --git a/plantuml/linker/BUILD b/plantuml/linker/BUILD new file mode 100644 index 0000000..5234e28 --- /dev/null +++ b/plantuml/linker/BUILD @@ -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", +) diff --git a/plantuml/linker/README.md b/plantuml/linker/README.md new file mode 100644 index 0000000..1ff65aa --- /dev/null +++ b/plantuml/linker/README.md @@ -0,0 +1,40 @@ + + +# 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 [ ...] --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 +``` diff --git a/plantuml/linker/src/main.rs b/plantuml/linker/src/main.rs new file mode 100644 index 0000000..c6837f6 --- /dev/null +++ b/plantuml/linker/src/main.rs @@ -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 +// +// +// 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, + + /// 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, +} + +/// All components from a single diagram file. +#[derive(Debug)] +struct DiagramInfo { + source_file: String, + components: Vec, +} + +/// 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, +} + +// --------------------------------------------------------------------------- +// FlatBuffers reading +// --------------------------------------------------------------------------- + +fn read_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 graph = flatbuffers::root::(&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 { + // Index: alias → list of diagrams where that alias is a top-level component + let mut top_level_index: HashMap> = 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> { + let args = Args::parse(); + + if args.fbs_files.is_empty() { + return Err("No .fbs.bin files provided. Use --fbs-files ...".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(()) +} diff --git a/plantuml/parser/BUILD b/plantuml/parser/BUILD index 60f334c..9e20338 100644 --- a/plantuml/parser/BUILD +++ b/plantuml/parser/BUILD @@ -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"], +) diff --git a/plantuml/parser/puml_serializer/src/fbs/BUILD b/plantuml/parser/puml_serializer/src/fbs/BUILD index 264785c..189528a 100644 --- a/plantuml/parser/puml_serializer/src/fbs/BUILD +++ b/plantuml/parser/puml_serializer/src/fbs/BUILD @@ -39,8 +39,7 @@ rust_library( "--allow=clippy::needless-lifetimes", ], visibility = [ - "//plantuml/parser:__subpackages__", - "//validation/archver:__pkg__", + "//visibility:public", ], deps = [ "@crates//:flatbuffers", diff --git a/plantuml/sphinx/clickable_plantuml/BUILD b/plantuml/sphinx/clickable_plantuml/BUILD new file mode 100644 index 0000000..33069c8 --- /dev/null +++ b/plantuml/sphinx/clickable_plantuml/BUILD @@ -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"], +) diff --git a/plantuml/sphinx/clickable_plantuml/README.md b/plantuml/sphinx/clickable_plantuml/README.md new file mode 100644 index 0000000..5d02423 --- /dev/null +++ b/plantuml/sphinx/clickable_plantuml/README.md @@ -0,0 +1,172 @@ + +# clickable_plantuml + +Sphinx extension that makes PlantUML diagrams clickable by injecting hyperlinks into rendered SVG/PNG diagrams. + +## Sphinx Integration + +The extension hooks into the native Sphinx build lifecycle. URLs are computed by +`app.builder.get_relative_uri()`, which works for any builder and +output directory layout. + +``` +Sphinx build lifecycle clickable_plantuml hooks +═══════════════════════════════════ ═══════════════════════════════════════ + + builder-inited ───► on_builder_inited() + │ (one-time setup) Load all *plantuml_links.json files + │ from srcdir (recursive). + │ Store {puml_basename → alias_map} + │ in app.env. + │ + ├─ READ PHASE ────────────────────────────────────────────────────────────── + │ for each document: + │ env-purge-doc ───► on_env_purge_doc() + │ │ (incremental rebuild) Remove stale puml→docname entries + │ │ for the document being re-read. + │ │ + │ parse RST → doctree + │ │ + │ doctree-read ───► on_doctree_read() + │ (per document) Traverse the parsed doctree. + │ For every plantuml node that has a + │ filename attribute, record + │ {puml_basename → docname} in app.env. + │ Warn on basename collisions. + │ + │ env-merge-info ───► on_env_merge_info() + │ (parallel builds only) Merge puml→docname maps gathered + │ by worker sub-processes into the + │ main environment. + │ + ├─ WRITE PHASE ───────────────────────────────────────────────────────────── + │ for each document: + │ post-transform / resolve + │ │ + │ doctree-resolved ───► on_doctree_resolved() + │ (per document) For each plantuml node, look up the + │ alias_map from app.env. + │ Resolve target .puml → docname, then + │ call app.builder.get_relative_uri() + │ to get the correct relative URL. + │ Append url of is [[url]] + │ directives to node['uml'] before + │ sphinxcontrib-plantuml renders it. + │ + build-finished +``` + +## How It Works + +1. **Link discovery** (`builder-inited`) – Scans for `*plantuml_links.json` files in the Sphinx source directory. +2. **Diagram location mapping** (`doctree-read`) – As Sphinx reads each document, the extension traverses the parsed doctree to record which `docname` contains which `.puml` diagram (keyed by basename). Basename collisions across documents are reported as warnings. +3. **URL resolution & link injection** (`doctree-resolved`) – For each plantuml node, resolves target `.puml` references to the docname that contains the target diagram, generates a relative URL via `app.builder.get_relative_uri()`, and appends `url of is [[url]]` directives to the PlantUML source before rendering. +4. **Incremental / parallel support** – `env-purge-doc` removes stale entries when a document is re-read; `env-merge-info` merges state from parallel worker processes. + +## Automatic JSON Generation (Bazel) + +`plantuml_links.json` is generated by the `architectural_design()` rule. + +The `architectural_design()` rule invokes `//tools/plantuml/linker:linker` on all +`.fbs.bin` FlatBuffers files produced by the PlantUML parser. See +[Link Generation by the Linker](#link-generation-by-the-linker) for a detailed +description of which links are emitted. + +### Algorithm + +Given the set of `.fbs.bin` files for one `architectural_design()` target: + +1. **Build a top-level index** – For each diagram, collect every component whose + `parent_id` is `None` (i.e. it is not nested inside another component). + The index maps `alias → diagram file`. + +2. **Emit links** – For every component in every diagram, look up its alias in + the top-level index. If a *different* diagram defines that alias as a + top-level component, emit a link entry: + + ``` + source_file = diagram that contains the reference + source_id = alias of the component + target_file = diagram that defines it as a top-level component + ``` + +3. **Deduplicate** – Sort and deduplicate so that each `(source_file, source_id)` + pair has exactly one target (first alphabetically). Duplicate `source_id` + entries within the same source diagram are removed because PlantUML's + `url of X is [[…]]` directive supports only one URL per alias. + +### Concrete Example + +```plantuml +' adas_overview.puml — subsystem context +@startuml +component ADAS +component BrakeController +component LaneKeepAssist +ADAS --> BrakeController +ADAS --> LaneKeepAssist +@enduml +``` + +```plantuml +' brake_controller.puml — component detail +@startuml +component BrakeController +interface BrakeDemandIF +interface WheelSpeedIF +BrakeController --> BrakeDemandIF +BrakeController <-- WheelSpeedIF +@enduml +``` + +Generated links — one in each direction: + +```json +{ + "links": [ + { + "source_file": "adas_overview.puml", + "source_id": "BrakeController", + "target_file": "brake_controller.puml" + }, + { + "source_file": "brake_controller.puml", + "source_id": "BrakeController", + "target_file": "adas_overview.puml" + } + ] +} +``` + +Clicking `BrakeController` in the overview navigates to its detail diagram; +clicking it in the detail diagram navigates back to the overview. + +`ADAS` and `LaneKeepAssist` appear as top-level only in `adas_overview.puml` and +have no dedicated detail diagram, so **no links** are emitted for them. + +## Link Mapping Format + +Place one or more `*plantuml_links.json` filesinside the Sphinx source directory: + +```json +{ + "links": [ + { + "source_file": "my_diagram.puml", + "source_id": "ComponentA", + "target_file": "other_diagram.puml" + } + ] +} +``` diff --git a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py new file mode 100644 index 0000000..7317a05 --- /dev/null +++ b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py @@ -0,0 +1,300 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Sphinx extension to make PlantUML diagrams clickable.""" + +import functools +import json +import re +from pathlib import Path +from typing import Any + +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +# Environment attribute names used by this extension. +_ENV_LINK_DATA = "clickable_plantuml_link_data" +# 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.]+$") + + +def _find_parent_section_id(node: nodes.Node) -> str | None: + """Return the HTML anchor of the closest ancestor section node, if any.""" + parent = node.parent + while parent is not None: + if isinstance(parent, nodes.section): + ids: list[str] = parent.get("ids", []) + if ids: + return ids[0] + parent = getattr(parent, "parent", None) + return None + + +# --------------------------------------------------------------------------- +# JSON loading +# --------------------------------------------------------------------------- + + +def _load_link_mappings( + search_dir: str, + pattern: str = "*plantuml_links.json", +) -> dict[str, dict[str, Any]]: + """Return ``{source_file: {source_id: {target_file, ...}}}``.""" + link_data: dict[str, dict[str, Any]] = {} + for json_file in Path(search_dir).rglob(pattern): + try: + json_data = json.loads(json_file.read_text(encoding="utf-8")) + if "links" not in json_data or not isinstance(json_data["links"], list): + logger.warning( + "Invalid format in %s: missing 'links' array", + json_file.name, + ) + continue + file_link_count = 0 + for link_entry in json_data["links"]: + source_file = link_entry.get("source_file") + source_id = link_entry.get("source_id") + target_file = link_entry.get("target_file") + if not (source_file and source_id and target_file): + continue + link_data.setdefault(source_file, {})[source_id] = { + "target_file": target_file, + "line": link_entry.get("source_line", 0), + "description": link_entry.get("description", ""), + } + file_link_count += 1 + logger.info( + "Loaded %d links from %s", + file_link_count, + json_file.relative_to(search_dir), + ) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Failed to load %s: %s", json_file.name, exc) + return link_data + + +def _collect_link_data(source_dir: Path) -> dict[str, dict[str, Any]]: + """Load all ``*plantuml_links.json`` files from *source_dir*.""" + if source_dir.exists(): + return _load_link_mappings(str(source_dir)) + return {} + + +# --------------------------------------------------------------------------- +# UML injection helper +# --------------------------------------------------------------------------- + + +def _inject_links_into_uml(uml_content: str, links: dict[str, str]) -> str: + """Append ``url of is [[url]]`` directives before ``@enduml``.""" + 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: + return uml_content + url_directives = "\n".join( + f"url of {alias} is [[{url}]]" for alias, url in safe_links.items() + ) + enduml_match = re.search(r"^\s*@enduml\s*$", uml_content, re.MULTILINE) + if enduml_match: + prefix = uml_content[: enduml_match.start()] + if not prefix.endswith("\n"): + prefix += "\n" + return prefix + url_directives + "\n" + uml_content[enduml_match.start() :] + return uml_content + "\n" + url_directives + + +# --------------------------------------------------------------------------- +# Sphinx event handlers +# --------------------------------------------------------------------------- + + +@functools.lru_cache(maxsize=1) +def _get_plantuml_node_class() -> type | None: + """Import the plantuml node class, returning ``None`` if unavailable.""" + try: + from sphinxcontrib.plantuml import plantuml as PlantumlNode # type: ignore[import-not-found] # pylint: disable=import-outside-toplevel + + return PlantumlNode # type: ignore[return-value] + except ImportError: + return None + + +def on_builder_inited(app: Sphinx) -> None: + """Load JSON link data once, before any documents are read.""" + if app.builder.format != "html": + return + + source_dir = Path(app.srcdir) + link_data = _collect_link_data(source_dir) + if not link_data: + logger.info("clickable_plantuml: no link mappings found") + return + + # Normalise keys to basenames for consistent lookup. + normalized = {Path(k).name: v for k, v in link_data.items()} + setattr(app.env, _ENV_LINK_DATA, normalized) + + logger.info( + "clickable_plantuml: loaded links for %d source file(s)", len(normalized) + ) + + +def on_doctree_read(app: Sphinx, doctree: nodes.document) -> None: + """Record which docname (and section anchor) contains which ``.puml`` diagram. + + Traverses the parsed doctree. + The mapping is stored in ``app.env`` and consumed during ``doctree-resolved``. + """ + PlantumlNode = _get_plantuml_node_class() + if PlantumlNode is None: + return + + puml_docnames: dict[str, tuple[str, str | None]] = getattr( + app.env, _ENV_PUML_DOCNAMES, {} + ) + + for node in doctree.traverse(PlantumlNode): + filename = Path(node.get("filename", "")).name + if not filename: + continue + if filename in puml_docnames: + logger.warning( + "clickable_plantuml: diagram '%s' found in both '%s' and '%s' " + "(basename collision — last wins)", + filename, + puml_docnames[filename][0], + app.env.docname, + ) + anchor = _find_parent_section_id(node) + puml_docnames[filename] = (app.env.docname, anchor) + + setattr(app.env, _ENV_PUML_DOCNAMES, puml_docnames) + + +def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> None: + """Inject ``url of is [[url]]`` into plantuml nodes before rendering. + + For each diagram, resolves target ``.puml`` references to the docname that + contains the target diagram and uses ``app.builder.get_relative_uri`` to + produce correct relative URLs. + """ + link_data: dict[str, dict[str, Any]] = getattr(app.env, _ENV_LINK_DATA, {}) + if app.builder.format != "html" or not link_data: + return + + PlantumlNode = _get_plantuml_node_class() + if PlantumlNode is None: + return + + puml_docnames: dict[str, tuple[str, str | None]] = getattr( + app.env, _ENV_PUML_DOCNAMES, {} + ) + absolute_url_prefixes = ("http://", "https://", "/") + + modified_count = 0 + for node in doctree.traverse(PlantumlNode): + diagram_filename = Path(node.get("filename", "")).name + alias_map: dict[str, Any] = link_data.get(diagram_filename, {}) + if not alias_map: + continue + + resolved_links: dict[str, str] = {} + for alias, info in alias_map.items(): + target_file: str = info["target_file"] + + if target_file.endswith(".puml"): + target_basename = Path(target_file).name + target_info = puml_docnames.get(target_basename) + if target_info is not None: + target_docname, target_anchor = target_info + # SVG files are stored in _images/ (one level below the + # HTML output root). Using get_relative_uri() would give a + # page-to-page relative URL, but that path is interpreted + # relative to the SVG file, not the parent HTML page — + # causing the browser to open the raw SVG. Instead, build + # the URL relative to _images/ by prepending "../" to the + # root-relative page URI returned by get_target_uri(). + page_uri = app.builder.get_target_uri(target_docname) + url = f"../{page_uri}" + if target_anchor: + url += f"#{target_anchor}" + resolved_links[alias] = url + else: + logger.debug( + "clickable_plantuml: target diagram '%s' for alias " + "'%s' not found in any document", + target_file, + alias, + ) + elif target_file.startswith(absolute_url_prefixes): + resolved_links[alias] = target_file + else: + resolved_links[alias] = target_file + + if resolved_links: + node["uml"] = _inject_links_into_uml(node.get("uml", ""), resolved_links) + modified_count += 1 + + if modified_count: + logger.debug( + "clickable_plantuml: injected links into %d diagram(s) on '%s'", + modified_count, + docname, + ) + + +def on_env_purge_doc(app: Sphinx, env: Any, docname: str) -> None: + """Remove stale entries when a document is re-read (incremental builds).""" + puml_docnames: dict[str, tuple[str, str | None]] = getattr( + env, _ENV_PUML_DOCNAMES, {} + ) + keys_to_remove = [k for k, (dn, _) in puml_docnames.items() if dn == docname] + for k in keys_to_remove: + del puml_docnames[k] + + +def on_env_merge_info(app: Sphinx, env: Any, docnames: set[str], other: Any) -> None: + """Merge diagram location data from parallel read sub-processes.""" + puml_docnames: dict[str, tuple[str, str | None]] = getattr( + env, _ENV_PUML_DOCNAMES, {} + ) + other_map: dict[str, tuple[str, str | None]] = getattr( + other, _ENV_PUML_DOCNAMES, {} + ) + puml_docnames.update(other_map) + setattr(env, _ENV_PUML_DOCNAMES, puml_docnames) + + +def setup(app: Sphinx) -> dict[str, Any]: + """Register the extension with Sphinx.""" + app.connect("builder-inited", on_builder_inited) + app.connect("doctree-read", on_doctree_read) + app.connect("doctree-resolved", on_doctree_resolved) + app.connect("env-purge-doc", on_env_purge_doc) + app.connect("env-merge-info", on_env_merge_info) + + return { + "version": "4.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + }