use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use crate::model::{Element, Id, Model, RelationKind, sanitize_id, sanitize_segment};
pub const HEADER: &str = "// GENERATED by asbuilt. Do not edit; run asbuilt survey.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmitOptions {
pub link_prefix: String,
pub theme: BTreeMap<String, String>,
}
impl EmitOptions {
pub fn for_output_path(output_path: &str) -> Self {
let depth = output_path
.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.count()
.saturating_sub(1);
let link_prefix = if depth == 0 {
"./".to_string()
} else {
"../".repeat(depth)
};
Self {
link_prefix,
theme: BTreeMap::new(),
}
}
pub fn with_theme(mut self, theme: BTreeMap<String, String>) -> Self {
self.theme = theme;
self
}
}
fn quote(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('\'');
for c in text.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\r' => {}
c => out.push(c),
}
}
out.push('\'');
out
}
pub fn link_encode(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for byte in path.bytes() {
let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/');
if keep {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
pub fn emit(model: &Model, options: &EmitOptions) -> String {
let mut model = model.clone();
model.normalize();
let mut out = String::new();
out.push_str(HEADER);
out.push('\n');
out.push('\n');
emit_specification(&mut out, &model, &options.theme);
out.push('\n');
emit_model(&mut out, &model, options);
out.push('\n');
emit_views(&mut out, &model);
out
}
pub fn theme_color_name(kind: &str) -> String {
format!("theme_{kind}")
}
fn emit_specification(out: &mut String, model: &Model, theme: &BTreeMap<String, String>) {
let mut kinds: BTreeSet<&str> = BTreeSet::new();
let mut tags: BTreeSet<&str> = BTreeSet::new();
for element in &model.elements {
kinds.insert(element.kind.keyword());
tags.extend(element.tags.iter().map(String::as_str));
}
out.push_str("specification {\n");
for kind in &kinds {
if let Some(color) = theme.get(*kind) {
writeln!(out, " color {} {color}", theme_color_name(kind)).unwrap();
}
}
for kind in &kinds {
if theme.contains_key(*kind) {
writeln!(
out,
" element {kind} {{\n style {{\n color {}\n }}\n }}",
theme_color_name(kind)
)
.unwrap();
} else {
writeln!(out, " element {kind}").unwrap();
}
}
for tag in tags {
writeln!(out, " tag {tag}").unwrap();
}
for kind in RelationKind::ALL {
writeln!(
out,
" relationship {} {{\n line {}\n head {}\n }}",
kind.keyword(),
kind.line(),
kind.head()
)
.unwrap();
}
out.push_str("}\n");
}
pub(crate) fn children_of<'a>(model: &'a Model, parent: &[String]) -> Vec<&'a Element> {
model
.elements
.iter()
.filter(|e| {
if parent.is_empty() {
e.id.len() == 1 || model.element(&e.id[..e.id.len() - 1]).is_none()
} else {
e.id.len() == parent.len() + 1 && e.id.starts_with(parent)
}
})
.collect()
}
fn emit_model(out: &mut String, model: &Model, options: &EmitOptions) {
out.push_str("model {\n");
for element in children_of(model, &[]) {
emit_element(out, model, element, options, 1);
}
if !model.relations.is_empty() {
out.push('\n');
}
for rel in &model.relations {
let indent = " ";
write!(
out,
"{indent}{} -[{}]-> {}",
sanitize_id(&rel.source),
rel.kind.keyword(),
sanitize_id(&rel.target)
)
.unwrap();
if !rel.items.is_empty() {
write!(out, " {}", quote(&rel.items.join(", "))).unwrap();
}
match &rel.technology {
Some(technology) => {
writeln!(
out,
" {{\n{indent} technology {}\n{indent}}}",
quote(technology)
)
.unwrap();
}
None => out.push('\n'),
}
}
out.push_str("}\n");
}
fn emit_element(
out: &mut String,
model: &Model,
element: &Element,
options: &EmitOptions,
depth: usize,
) {
let indent = " ".repeat(depth);
let inner = " ".repeat(depth + 1);
let name = sanitize_segment(element.id.last().map(String::as_str).unwrap_or_default());
let children = children_of(model, &element.id);
let has_body = !element.tags.is_empty()
|| element.description.is_some()
|| element.technology.is_some()
|| element.path.is_some()
|| !children.is_empty();
write!(
out,
"{indent}{name} = {} {}",
element.kind.keyword(),
quote(&element.title)
)
.unwrap();
if !has_body {
out.push('\n');
return;
}
out.push_str(" {\n");
if !element.tags.is_empty() {
let tags: Vec<String> = element.tags.iter().map(|t| format!("#{t}")).collect();
writeln!(out, "{inner}{}", tags.join(" ")).unwrap();
}
if let Some(description) = &element.description {
writeln!(out, "{inner}description {}", quote(description)).unwrap();
}
if let Some(technology) = &element.technology {
writeln!(out, "{inner}technology {}", quote(technology)).unwrap();
}
if let Some(path) = &element.path {
writeln!(
out,
"{inner}metadata {{\n{inner} path {}\n{inner}}}",
quote(path)
)
.unwrap();
if path != "." {
writeln!(
out,
"{inner}link {}{}",
options.link_prefix,
link_encode(path)
)
.unwrap();
}
}
for child in children {
emit_element(out, model, child, options, depth + 1);
}
writeln!(out, "{indent}}}").unwrap();
}
pub fn view_id(id: &Id) -> String {
format!("view_{}", sanitize_id(id).replace('.', "_"))
}
pub fn view_ids(model: &Model) -> BTreeMap<Id, String> {
let mut used: BTreeSet<String> = BTreeSet::from(["index".to_string()]);
let mut names = BTreeMap::new();
for element in &model.elements {
if children_of(model, &element.id).is_empty() {
continue;
}
let base = view_id(&element.id);
let id = std::iter::once(base.clone())
.chain((2..).map(|n| format!("{base}_{n}")))
.find(|candidate| used.insert(candidate.clone()))
.expect("an unbounded sequence of candidates always has a free one");
names.insert(element.id.clone(), id);
}
names
}
fn emit_views(out: &mut String, model: &Model) {
out.push_str("views {\n");
let top: Vec<String> = children_of(model, &[])
.iter()
.map(|e| sanitize_id(&e.id))
.collect();
out.push_str(" view index {\n title 'Overview'\n");
if !top.is_empty() {
writeln!(out, " include {}", top.join(", ")).unwrap();
}
out.push_str(" autoLayout LeftRight\n }\n");
let names = view_ids(model);
for element in &model.elements {
let Some(id) = names.get(&element.id) else {
continue;
};
let fqn = sanitize_id(&element.id);
writeln!(
out,
"\n view {id} of {fqn} {{\n title {}\n include *\n autoLayout TopBottom\n }}",
quote(&element.id.join("."))
)
.unwrap();
}
out.push_str("}\n");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ElementKind, Relation};
fn id(s: &str) -> Id {
s.split('.').map(str::to_string).collect()
}
fn element(s: &str, kind: ElementKind) -> Element {
Element {
id: id(s),
kind,
title: s.rsplit('.').next().unwrap().to_string(),
description: None,
technology: None,
path: None,
tags: vec![],
}
}
fn sample() -> Model {
let mut app = element("app", ElementKind::Container);
app.technology = Some("library crate".into());
app.path = Some("crates/app".into());
let mut server = element("app.server", ElementKind::Component);
server.description = Some("Where it's sent.\n\n- one\n- two".into());
server.path = Some("crates/app/src/server.rs".into());
let mut tests = element("app.tests", ElementKind::Tests);
tests.tags = vec!["tests".into()];
let mut driver = element("node_driver", ElementKind::External("process".into()));
driver.tags = vec!["external".into()];
driver.technology = Some("Node.js process".into());
Model {
elements: vec![tests, driver, server, app],
relations: vec![
Relation {
source: id("app.tests"),
target: id("app.server"),
kind: RelationKind::Calls,
items: vec!["Server".into(), "connect".into()],
technology: None,
},
Relation {
source: id("app.server"),
target: id("node_driver"),
kind: RelationKind::Uses,
items: vec!["spawns".into()],
technology: Some("stdio".into()),
},
],
..Default::default()
}
}
fn options() -> EmitOptions {
EmitOptions::for_output_path("docs/architecture/model.c4")
}
#[test]
fn the_header_is_the_first_line() {
let text = emit(&sample(), &options());
assert_eq!(text.lines().next(), Some(HEADER));
}
#[test]
fn the_specification_declares_every_element_kind_tag_and_relationship_kind() {
let text = emit(&sample(), &options());
let spec = text.split("model {").next().unwrap();
for line in [
" element component",
" element container",
" element process",
" element tests",
" tag external",
" tag tests",
" relationship implements {",
" relationship constructs {",
" relationship calls {",
" relationship names {",
" relationship uses {",
] {
assert!(
spec.contains(&format!("{line}\n")),
"missing {line:?} in:\n{spec}"
);
}
}
#[test]
fn a_themed_kind_declares_its_color_and_styles_the_element_with_it() {
let opts = options().with_theme(BTreeMap::from([
("container".to_string(), "#f0a884".to_string()),
("process".to_string(), "#c9c9c9".to_string()),
]));
let text = emit(&sample(), &opts);
let spec = text.split("model {").next().unwrap();
assert!(
spec.starts_with(&format!(
"{HEADER}\n\nspecification {{\n color theme_container #f0a884\n color theme_process #c9c9c9\n element component\n element container {{\n style {{\n color theme_container\n }}\n }}\n element process {{\n style {{\n color theme_process\n }}\n }}\n element tests\n tag external\n"
)),
"{spec}"
);
}
#[test]
fn a_theme_for_a_kind_the_model_lacks_emits_nothing_for_it() {
let opts = options().with_theme(BTreeMap::from([(
"browser".to_string(),
"#000000".to_string(),
)]));
let text = emit(&sample(), &opts);
assert!(!text.contains("browser"), "{text}");
assert_eq!(text, emit(&sample(), &options()));
}
#[test]
fn a_child_is_nested_inside_its_parent_under_its_last_segment() {
let text = emit(&sample(), &options());
let app_block = text.split(" app = container 'app' {").nth(1).unwrap();
assert!(
app_block.contains("\n server = component 'server' {"),
"{text}"
);
assert!(!text.contains("app.server = component"), "{text}");
}
#[test]
fn tags_come_first_in_a_body() {
let text = emit(&sample(), &options());
assert!(
text.contains("tests = tests 'tests' {\n #tests\n }"),
"{text}"
);
}
#[test]
fn a_quote_in_a_description_is_escaped_and_markdown_kept() {
let text = emit(&sample(), &options());
assert!(
text.contains("description 'Where it\\'s sent.\n\n- one\n- two'"),
"{text}"
);
}
#[test]
fn a_path_becomes_metadata_and_a_link_relative_to_the_output_directory() {
let text = emit(&sample(), &options());
assert!(text.contains("metadata {\n path 'crates/app/src/server.rs'\n }\n link ../../crates/app/src/server.rs\n"), "{text}");
}
#[test]
fn an_output_file_at_the_root_links_with_a_dot_prefix() {
let text = emit(&sample(), &EmitOptions::for_output_path("model.c4"));
assert!(text.contains("link ./crates/app\n"), "{text}");
assert_eq!(EmitOptions::for_output_path("./model.c4").link_prefix, "./");
}
#[test]
fn a_relation_line_carries_kind_label_and_technology() {
let text = emit(&sample(), &options());
assert!(
text.contains(" app.tests -[calls]-> app.server 'Server, connect'\n"),
"{text}"
);
assert!(
text.contains(
" app.server -[uses]-> node_driver 'spawns' {\n technology 'stdio'\n }\n"
),
"{text}"
);
}
#[test]
fn a_relation_without_items_has_no_label() {
let mut model = sample();
model.relations[0].items.clear();
let text = emit(&model, &options());
assert!(
text.contains(" app.tests -[calls]-> app.server\n"),
"{text}"
);
}
#[test]
fn the_index_view_includes_every_top_level_element() {
let text = emit(&sample(), &options());
assert!(text.contains(" view index {\n title 'Overview'\n include app, node_driver\n autoLayout LeftRight\n }"), "{text}");
}
#[test]
fn an_element_with_children_gets_a_scoped_view_and_a_leaf_does_not() {
let text = emit(&sample(), &options());
assert!(text.contains(" view view_app of app {\n title 'app'\n include *\n autoLayout TopBottom\n }"), "{text}");
assert!(!text.contains("of app.server {"), "{text}");
assert!(!text.contains("of node_driver {"), "{text}");
}
#[test]
fn a_hyphen_in_an_id_is_an_underscore_everywhere_it_appears() {
let mut model = sample();
for e in &mut model.elements {
if e.id[0] == "app" {
e.id[0] = "my-app".into();
}
}
for r in &mut model.relations {
if r.source[0] == "app" {
r.source[0] = "my-app".into();
}
if r.target[0] == "app" {
r.target[0] = "my-app".into();
}
}
let text = emit(&model, &options());
assert!(text.contains(" my_app = container"), "{text}");
assert!(
text.contains("my_app.tests -[calls]-> my_app.server"),
"{text}"
);
assert!(text.contains("view view_my_app of my_app"), "{text}");
for line in text.lines().filter(|l| l.contains("my-app")) {
assert!(line.trim_start().starts_with("title "), "{line}");
}
}
#[test]
fn an_element_whose_parent_is_absent_is_emitted_at_the_top_level() {
let model = Model {
elements: vec![element("gone.child", ElementKind::Component)],
..Default::default()
};
let text = emit(&model, &options());
assert!(
text.contains("model {\n child = component 'child'\n"),
"{text}"
);
}
#[test]
fn each_body_field_alone_earns_a_block() {
fn only(f: impl FnOnce(&mut Element)) -> Model {
let mut e = element("x", ElementKind::Container);
f(&mut e);
Model {
elements: vec![e],
..Default::default()
}
}
let mut with_child = only(|_| {});
with_child
.elements
.push(element("x.y", ElementKind::Component));
let cases = [
("tags", only(|e| e.tags = vec!["tests".into()])),
("description", only(|e| e.description = Some("d".into()))),
("technology", only(|e| e.technology = Some("t".into()))),
("path", only(|e| e.path = Some("p".into()))),
("child", with_child),
];
for (field, model) in cases {
let text = emit(&model, &options());
assert!(
text.contains(" x = container 'x' {\n"),
"{field} alone should open a block:\n{text}"
);
}
}
#[test]
fn an_element_with_nothing_to_say_has_no_block() {
let model = Model {
elements: vec![element("leaf", ElementKind::Container)],
..Default::default()
};
let text = emit(&model, &options());
assert!(text.contains(" leaf = container 'leaf'\n}"), "{text}");
}
#[test]
fn output_does_not_depend_on_input_order() {
let a = emit(&sample(), &options());
let mut shuffled = sample();
shuffled.elements.reverse();
shuffled.relations.reverse();
let b = emit(&shuffled, &options());
assert_eq!(a, b);
}
#[test]
fn output_has_no_carriage_returns_or_backslashes_in_paths() {
let mut model = sample();
model.elements[2].description = Some("line\r\nnext".into());
let text = emit(&model, &options());
assert!(!text.contains('\r'), "{text:?}");
assert!(text.contains("description 'line\nnext'"), "{text}");
}
#[test]
fn the_root_path_keeps_its_metadata_but_has_no_link() {
let mut model = sample();
model
.elements
.iter_mut()
.find(|e| e.id == ["app"])
.unwrap()
.path = Some(".".into());
let text = emit(&model, &options());
let app_block = text.split(" app = container 'app' {").nth(1).unwrap();
let head: String = app_block.lines().take(4).collect::<Vec<_>>().join("\n");
assert!(head.contains("path '.'"), "{head}");
assert!(!head.contains("link"), "{head}");
}
#[test]
fn a_space_or_quote_in_a_path_is_percent_encoded_in_the_link_only() {
let mut model = sample();
model
.elements
.iter_mut()
.find(|e| e.id == ["app", "server"])
.unwrap()
.path = Some("crates/my tool/it's.rs".into());
let text = emit(&model, &options());
assert!(text.contains("path 'crates/my tool/it\\'s.rs'"), "{text}");
assert!(
text.contains("link ../../crates/my%20tool/it%27s.rs\n"),
"{text}"
);
}
#[test]
fn link_encoding_keeps_unreserved_characters_and_slashes() {
assert_eq!(link_encode("a-b_c.d~/e"), "a-b_c.d~/e");
assert_eq!(link_encode("é"), "%C3%A9");
}
#[test]
fn two_ids_that_collapse_to_one_view_id_get_distinct_views() {
let model = Model {
elements: vec![
element("a.b_c", ElementKind::Container),
element("a.b_c.x", ElementKind::Component),
element("a_b.c", ElementKind::Container),
element("a_b.c.y", ElementKind::Component),
element("a_b_c", ElementKind::Container),
element("a_b_c.z", ElementKind::Component),
],
..Default::default()
};
let text = emit(&model, &options());
assert!(text.contains("view view_a_b_c of a.b_c {"), "{text}");
assert!(text.contains("view view_a_b_c_2 of a_b.c {"), "{text}");
assert!(text.contains("view view_a_b_c_3 of a_b_c {"), "{text}");
let mut normalized = model.clone();
normalized.normalize();
let names: Vec<String> = view_ids(&normalized).into_values().collect();
assert_eq!(names, ["view_a_b_c", "view_a_b_c_2", "view_a_b_c_3"]);
}
#[test]
fn view_ids_names_every_element_with_children_and_only_those() {
let mut model = sample();
model.normalize();
let names = view_ids(&model);
let keyed: Vec<(String, String)> = names
.iter()
.map(|(id, name)| (id.join("."), name.clone()))
.collect();
assert_eq!(keyed, [("app".to_string(), "view_app".to_string())]);
assert!(!names.contains_key(&id("app.server")));
assert!(!names.contains_key(&id("node_driver")));
}
#[test]
fn a_backslash_in_a_string_is_escaped() {
assert_eq!(quote("a\\b"), "'a\\\\b'");
}
#[test]
fn link_prefix_counts_directories_not_the_file() {
assert_eq!(
EmitOptions::for_output_path("a/b/c/m.c4").link_prefix,
"../../../"
);
assert_eq!(EmitOptions::for_output_path("a/m.c4").link_prefix, "../");
}
#[test]
fn a_relationship_kind_is_declared_with_its_line_and_head() {
let text = emit(&sample(), &options());
assert!(
text.contains(" relationship implements {\n line solid\n head onormal\n }\n"),
"{text}"
);
}
}