use crate::directivepass::DirectiveInstance;
use crate::pipeline::Diagrams;
use crate::util::escape_html;
pub struct PlantumlSupport<'a> {
pub diagrams: &'a Diagrams,
pub renderer: Option<&'a dyn PlantumlRenderer>,
}
pub trait PlantumlRenderer {
fn render(&self, source: &str) -> Result<String, PlantumlError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlantumlError {
Unreachable {
server: String,
detail: String,
},
Server {
status: u16,
message: String,
line: Option<u32>,
},
}
pub fn render_directive(
inst: &DirectiveInstance,
base_dir: &str,
support: Option<&PlantumlSupport>,
id: &str,
) -> String {
let empty = Diagrams::new();
let diagrams = support.map(|s| s.diagrams).unwrap_or(&empty);
let renderer = support.and_then(|s| s.renderer);
let src_attr = inst.attrs.get("src").map(|s| s.trim()).unwrap_or("");
let (identity, source): (String, String) = if !src_attr.is_empty() {
let Some(key) = crate::pipeline::resolve_include_key(base_dir, src_attr) else {
return error_html(
id,
src_attr,
&format!("diagram source path `{src_attr}` escapes the docs root"),
);
};
match diagrams.get(&key) {
Some(s) => (src_attr.to_string(), s.clone()),
None => {
return error_html(
id,
src_attr,
&format!("diagram source file not found: `{key}`"),
)
}
}
} else {
let body = inst.inner_md.trim();
if body.is_empty() {
return error_html(
id,
"plantuml",
"`:::plantuml` needs a `src=\"file.puml\"` attribute or inline diagram source",
);
}
("inline diagram".to_string(), inst.inner_md.clone())
};
let Some(renderer) = renderer else {
return error_html(
id,
&identity,
"PlantUML rendering is disabled (`[features] plantuml = false`, or no server wired)",
);
};
match renderer.render(&source) {
Ok(svg) => container_html(id, &identity, &svg),
Err(e) => error_html(id, &identity, &describe_error(&e)),
}
}
fn describe_error(e: &PlantumlError) -> String {
match e {
PlantumlError::Unreachable { server, detail } => {
format!("could not reach PlantUML server at {server}: {detail}")
}
PlantumlError::Server {
status,
message,
line,
} => match line {
Some(l) => format!("server error (HTTP {status}) at line {l}: {message}"),
None => format!("server error (HTTP {status}): {message}"),
},
}
}
fn container_html(id: &str, identity: &str, svg: &str) -> String {
format!(
"<div class=\"docgen-plantuml\" id=\"{id}\" data-plantuml=\"{}\">{}</div>",
escape_html(identity),
svg_body(svg)
)
}
fn svg_body(svg: &str) -> &str {
match svg.find("<svg") {
Some(i) => svg[i..].trim_end(),
None => svg.trim(),
}
}
fn error_html(id: &str, identity: &str, reason: &str) -> String {
let safe_id = escape_html(identity);
let safe_reason = escape_html(reason);
format!(
"<div class=\"docgen-plantuml-error\" id=\"{id}\" data-plantuml=\"{safe_id}\">\
<strong>PlantUML diagram failed</strong> \
<span class=\"docgen-plantuml-error__id\">({safe_id})</span>\
<span class=\"docgen-plantuml-error__reason\">{safe_reason}</span></div>"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::directivepass::extract;
struct Mock {
result: Result<String, PlantumlError>,
calls: std::cell::Cell<usize>,
}
impl Mock {
fn ok(svg: &str) -> Self {
Self {
result: Ok(svg.to_string()),
calls: std::cell::Cell::new(0),
}
}
fn err(e: PlantumlError) -> Self {
Self {
result: Err(e),
calls: std::cell::Cell::new(0),
}
}
}
impl PlantumlRenderer for Mock {
fn render(&self, _source: &str) -> Result<String, PlantumlError> {
self.calls.set(self.calls.get() + 1);
self.result.clone()
}
}
fn one_directive(md: &str) -> DirectiveInstance {
let (_html, mut inst) = extract(md);
assert_eq!(inst.len(), 1, "expected exactly one directive in {md:?}");
inst.remove(0)
}
fn diagrams(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn run(
inst: &DirectiveInstance,
base: &str,
map: &Diagrams,
renderer: Option<&dyn PlantumlRenderer>,
id: &str,
) -> String {
let support = PlantumlSupport {
diagrams: map,
renderer,
};
render_directive(inst, base, Some(&support), id)
}
#[test]
fn inline_source_renders_svg_container() {
let inst = one_directive(":::plantuml\n@startuml\nA->B\n@enduml\n:::\n");
let mock = Mock::ok("<?xml version=\"1.0\"?>\n<svg>DIAGRAM</svg>\n");
let html = run(&inst, "", &diagrams(&[]), Some(&mock), "d-0");
assert_eq!(mock.calls.get(), 1);
assert!(html.contains("docgen-plantuml\""));
assert!(html.contains("<svg>DIAGRAM</svg>")); assert!(!html.contains("<?xml")); assert!(html.contains("data-plantuml=\"inline diagram\""));
}
#[test]
fn src_file_is_resolved_from_the_diagram_map() {
let inst = one_directive(":::plantuml{src=\"../uml/a.puml\"}\n:::\n");
let mock = Mock::ok("<svg>FROMFILE</svg>");
let map = diagrams(&[("uml/a.puml", "@startuml\nA->B\n@enduml")]);
let html = run(&inst, "guide", &map, Some(&mock), "d-1");
assert_eq!(mock.calls.get(), 1);
assert!(html.contains("<svg>FROMFILE</svg>"));
assert!(html.contains("data-plantuml=\"../uml/a.puml\""));
}
#[test]
fn src_wins_over_inline_body() {
let inst = one_directive(":::plantuml{src=\"a.puml\"}\n@startuml\nIGNORED\n@enduml\n:::\n");
let mock = Mock::ok("<svg>FILE</svg>");
let map = diagrams(&[("a.puml", "@startuml\nREAL\n@enduml")]);
let _html = run(&inst, "", &map, Some(&mock), "d-2");
assert_eq!(mock.calls.get(), 1);
}
#[test]
fn missing_src_file_is_a_detailed_error_not_a_render() {
let inst = one_directive(":::plantuml{src=\"nope.puml\"}\n:::\n");
let mock = Mock::ok("<svg/>");
let html = run(&inst, "", &diagrams(&[]), Some(&mock), "d-3");
assert_eq!(
mock.calls.get(),
0,
"must not call the server for a missing file"
);
assert!(html.contains("docgen-plantuml-error"));
assert!(html.contains("not found"));
assert!(html.contains("nope.puml"));
}
#[test]
fn src_escaping_docs_root_is_rejected() {
let inst = one_directive(":::plantuml{src=\"../../etc/passwd.puml\"}\n:::\n");
let mock = Mock::ok("<svg/>");
let html = run(&inst, "guide", &diagrams(&[]), Some(&mock), "d-4");
assert_eq!(mock.calls.get(), 0);
assert!(html.contains("docgen-plantuml-error"));
assert!(html.contains("escapes the docs root"));
}
#[test]
fn empty_directive_reports_missing_source() {
let inst = one_directive(":::plantuml\n:::\n");
let mock = Mock::ok("<svg/>");
let html = run(&inst, "", &diagrams(&[]), Some(&mock), "d-5");
assert_eq!(mock.calls.get(), 0);
assert!(html.contains("docgen-plantuml-error"));
assert!(html.contains("needs a"));
}
#[test]
fn disabled_when_no_renderer() {
let inst = one_directive(":::plantuml\n@startuml\nA->B\n@enduml\n:::\n");
let html = run(&inst, "", &diagrams(&[]), None, "d-6");
assert!(html.contains("docgen-plantuml-error"));
assert!(html.contains("disabled"));
}
#[test]
fn unreachable_error_shows_server_and_detail() {
let inst = one_directive(":::plantuml\n@startuml\nA->B\n@enduml\n:::\n");
let mock = Mock::err(PlantumlError::Unreachable {
server: "http://localhost:8080".into(),
detail: "connection refused".into(),
});
let html = run(&inst, "", &diagrams(&[]), Some(&mock), "d-7");
assert!(html.contains("docgen-plantuml-error"));
assert!(html.contains("http://localhost:8080"));
assert!(html.contains("connection refused"));
}
#[test]
fn server_syntax_error_shows_message_and_line() {
let inst = one_directive(":::plantuml\n@startuml\nbroken\n@enduml\n:::\n");
let mock = Mock::err(PlantumlError::Server {
status: 400,
message: "Syntax Error?".into(),
line: Some(2),
});
let html = run(&inst, "", &diagrams(&[]), Some(&mock), "d-8");
assert!(html.contains("docgen-plantuml-error"));
assert!(html.contains("HTTP 400"));
assert!(html.contains("line 2"));
assert!(html.contains("Syntax Error?"));
}
#[test]
fn error_reason_is_html_escaped() {
let inst = one_directive(":::plantuml\n@startuml\nx\n@enduml\n:::\n");
let mock = Mock::err(PlantumlError::Server {
status: 400,
message: "<script>alert(1)</script>".into(),
line: None,
});
let html = run(&inst, "", &diagrams(&[]), Some(&mock), "d-9");
assert!(html.contains("<script>"));
assert!(!html.contains("<script>"));
}
}