1use std::collections::{BTreeMap, BTreeSet};
50
51use headwater_yaml::{Mapping, Spanned, Value};
52
53const PALETTE: [&str; 8] = [
57 "#1f77b4", "#d62728", "#2ca02c", "#9467bd", "#ff7f0e", "#8c564b", "#e377c2", "#17becf",
58];
59
60const IS_A: &str = "#888";
62
63type Pair = (String, String, String, Option<String>);
65
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
69pub enum View {
70 #[default]
72 Concrete,
73 Abstract,
75}
76
77struct Model<'a> {
79 kinds: Option<&'a Mapping>,
80 anchors: BTreeSet<String>,
81 abstracts: BTreeSet<String>,
82 lanes: BTreeMap<Option<String>, BTreeSet<String>>,
84 families: BTreeSet<String>,
85 drawn: Vec<Pair>,
87 captioned: Vec<Pair>,
89}
90
91impl<'a> Model<'a> {
92 fn of(resolved: &'a Mapping) -> Self {
93 let purposes = keys(resolved, "purposes");
94 let anchors = keys(resolved, "anchors");
95 let kinds = map(resolved, "kinds");
96
97 let mut abstracts = BTreeSet::new();
98 let mut lanes: BTreeMap<Option<String>, BTreeSet<String>> = BTreeMap::new();
99 for purpose in &purposes {
100 lanes.entry(Some(purpose.clone())).or_default();
101 }
102 if let Some(kinds) = kinds {
103 for entry in kinds {
104 let name = entry.key.value.clone();
105 let declared = entry.value.value.as_map();
106 if declared.and_then(|k| scalar(k, "abstract")).as_deref() == Some("true") {
107 abstracts.insert(name);
108 continue;
109 }
110 lanes
111 .entry(purpose_of(kinds, &name))
112 .or_default()
113 .insert(name);
114 }
115 }
116
117 let mut families: BTreeSet<String> = BTreeSet::new();
118 let mut drawn: Vec<Pair> = Vec::new();
119 let mut captioned: Vec<Pair> = Vec::new();
120 if let Some(relations) = map(resolved, "relations") {
121 for entry in relations {
122 let Some(relation) = entry.value.value.as_map() else {
123 continue;
124 };
125 let name = entry.key.value.clone();
126 let family = scalar(relation, "family");
127 if let Some(family) = &family {
128 families.insert(family.clone());
129 }
130 for from in names(relation.get("from")) {
131 for to in names(relation.get("to")) {
132 let pair = (name.clone(), from.clone(), to.clone(), family.clone());
133 if abstracts.contains(&from) || abstracts.contains(&to) {
134 captioned.push(pair);
135 } else {
136 drawn.push(pair);
137 }
138 }
139 }
140 }
141 }
142 drawn.sort();
143 drawn.dedup();
144 captioned.sort();
145 captioned.dedup();
146
147 Model {
148 kinds,
149 anchors,
150 abstracts,
151 lanes,
152 families,
153 drawn,
154 captioned,
155 }
156 }
157
158 fn concrete(&self) -> BTreeSet<String> {
159 self.lanes.values().flatten().cloned().collect()
160 }
161
162 fn node(&self, name: &str) -> String {
163 if self.anchors.contains(name) {
164 format!("anchor_{}", ident(name))
165 } else {
166 format!("kind_{}", ident(name))
167 }
168 }
169
170 fn color(&self, family: &str) -> &'static str {
171 let at = self.families.iter().position(|f| f == family).unwrap_or(0);
172 PALETTE[at % PALETTE.len()]
173 }
174}
175
176pub fn render(
178 package: &str,
179 version: &str,
180 resolved: &Mapping,
181 view: View,
182 legend: bool,
183) -> String {
184 let model = Model::of(resolved);
185 match view {
186 View::Concrete => concrete_view(package, version, &model, legend),
187 View::Abstract => abstract_view(package, version, &model, legend),
188 }
189}
190
191type Loops = BTreeMap<String, Vec<(String, Option<String>)>>;
193
194fn split_loops(pairs: &[Pair]) -> (Vec<Pair>, Loops) {
198 let mut edges = Vec::new();
199 let mut loops: Loops = BTreeMap::new();
200 for pair in pairs {
201 let (relation, from, to, family) = pair;
202 if from == to {
203 loops
204 .entry(from.clone())
205 .or_default()
206 .push((relation.clone(), family.clone()));
207 } else {
208 edges.push(pair.clone());
209 }
210 }
211 (edges, loops)
212}
213
214fn with_loops(model: &Model, text: String, name: &str, loops: &Loops) -> String {
218 let mut out = text;
219 for (relation, family) in loops.get(name).into_iter().flatten() {
220 let line = format!("\u{21bb} {}", label(relation));
221 match family {
222 Some(family) => out.push_str(&format!(
223 "<br/><span style='color:{}'>{line}</span>",
224 model.color(family)
225 )),
226 None => out.push_str(&format!("<br/>{line}")),
227 }
228 }
229 out
230}
231
232fn concrete_view(package: &str, version: &str, model: &Model, legend: bool) -> String {
233 let concrete = model.concrete();
234 let (edges, loops) = split_loops(&model.drawn);
235
236 let mut strays: BTreeSet<String> = BTreeSet::new();
240 for (_, from, to, _) in &model.drawn {
241 for end in [from, to] {
242 if !concrete.contains(end) && !model.anchors.contains(end) {
243 strays.insert(end.clone());
244 }
245 }
246 }
247
248 let kind_line = |kind: &String, indent: &str| -> String {
249 format!(
250 "{indent}kind_{}[\"{}\"]\n",
251 ident(kind),
252 with_loops(model, label(kind), kind, &loops)
253 )
254 };
255
256 let mut out = String::new();
257 out.push_str("flowchart LR\n");
258 out.push_str(&format!(
259 "%% The resolved taxonomy of {package} {version}, drawn by `headwater taxonomy graph` from .headwater/taxonomy.lock.\n"
260 ));
261 out.push_str(
262 "%% A lane is a purpose, a rectangle is a concrete kind, a hexagon is an anchor, and an edge is labeled with its relation.\n",
263 );
264 if !loops.is_empty() {
265 out.push_str(
266 "%% A line that starts with \u{21bb} inside a node is a relation from that node to itself, in the color of its family.\n",
267 );
268 }
269 if !model.captioned.is_empty() {
270 out.push_str(&format!(
271 "%% Not drawn here: a relation with an abstract kind ({}) at one end. `headwater taxonomy graph --view abstract` draws each one.\n",
272 join(&model.abstracts)
273 ));
274 }
275
276 for (purpose, members) in &model.lanes {
277 let Some(purpose) = purpose else { continue };
278 out.push_str(&format!(
279 " subgraph purpose_{}[\"{}\"]\n",
280 ident(purpose),
281 label(purpose)
282 ));
283 for kind in members {
284 out.push_str(&kind_line(kind, " "));
285 }
286 out.push_str(" end\n");
287 }
288 let unlaned = model.lanes.get(&None).into_iter().flatten();
289 for kind in unlaned.chain(strays.iter()) {
290 out.push_str(&kind_line(kind, " "));
291 }
292 for anchor in &model.anchors {
293 out.push_str(&format!(
294 " anchor_{}{{{{\"{}\"}}}}\n",
295 ident(anchor),
296 with_loops(model, label(anchor), anchor, &loops)
297 ));
298 }
299 for pair in &edges {
300 out.push_str(&edge(model, pair));
301 }
302
303 let mut styles = Vec::new();
304 let mut anchor_ids: Vec<String> = model.anchors.iter().map(|a| model.node(a)).collect();
305 if legend {
306 let shown = Shown {
307 abstract_kind: false,
308 anchor: !anchor_ids.is_empty(),
309 is_a: false,
310 families: present(&edges, &loops),
311 };
312 styles = key(&mut out, model, &shown, edges.len());
313 if shown.anchor {
314 anchor_ids.push("key_anchor".to_string());
315 }
316 }
317 if !anchor_ids.is_empty() {
318 out.push_str(" classDef anchor fill:#eee,stroke:#555,color:#222\n");
319 out.push_str(&format!(" class {} anchor\n", anchor_ids.join(",")));
320 }
321 family_styles(&mut out, model, &edges, 0);
322 for style in styles {
323 out.push_str(&style);
324 }
325 out
326}
327
328fn abstract_view(package: &str, version: &str, model: &Model, legend: bool) -> String {
329 let mut out = String::new();
330 out.push_str("flowchart LR\n");
331 if model.abstracts.is_empty() {
332 out.push_str(&format!(
333 "%% The resolved taxonomy of {package} {version} declares no abstract kind, so this view has nothing to draw.\n"
334 ));
335 return out;
336 }
337 let (edges, loops) = split_loops(&model.captioned);
338
339 let members: BTreeSet<String> = model
341 .concrete()
342 .into_iter()
343 .filter(|name| under_abstract(model, name))
344 .collect();
345 let mut lanes: BTreeMap<Option<String>, BTreeSet<String>> = BTreeMap::new();
346 for name in &members {
347 let purpose = model.kinds.and_then(|kinds| purpose_of(kinds, name));
348 lanes.entry(purpose).or_default().insert(name.clone());
349 }
350
351 let mut is_a: Vec<(String, String)> = Vec::new();
353 if let Some(kinds) = model.kinds {
354 for name in members.iter().chain(model.abstracts.iter()) {
355 let parent = kinds
356 .get(name)
357 .and_then(|node| node.value.as_map())
358 .and_then(|kind| scalar(kind, "is_a"));
359 if let Some(parent) = parent {
360 is_a.push((name.clone(), parent));
361 }
362 }
363 }
364 is_a.sort();
365
366 let mut strays: BTreeSet<String> = BTreeSet::new();
368 let mut reached: BTreeSet<String> = BTreeSet::new();
369 for (_, from, to, _) in &model.captioned {
370 for end in [from, to] {
371 if model.anchors.contains(end) {
372 reached.insert(end.clone());
373 } else if !members.contains(end) && !model.abstracts.contains(end) {
374 strays.insert(end.clone());
375 }
376 }
377 }
378
379 let kind_line = |kind: &String, indent: &str| -> String {
380 format!(
381 "{indent}kind_{}[\"{}\"]\n",
382 ident(kind),
383 with_loops(model, label(kind), kind, &loops)
384 )
385 };
386
387 out.push_str(&format!(
388 "%% The abstract kinds of {package} {version} and the kinds declared under them, drawn by `headwater taxonomy graph --view abstract` from .headwater/taxonomy.lock.\n"
389 ));
390 out.push_str(
391 "%% A dashed rectangle is an abstract kind, a dotted arrow is is_a, a hexagon is an anchor, and a solid edge is labeled with its relation.\n",
392 );
393 out.push_str(
394 "%% A relation that names an abstract kind is open to every kind declared under it.\n",
395 );
396 if !loops.is_empty() {
397 out.push_str(
398 "%% A line that starts with \u{21bb} inside a node is a relation from that node to itself, in the color of its family.\n",
399 );
400 }
401
402 for (purpose, kinds) in &lanes {
403 match purpose {
404 Some(purpose) => {
405 out.push_str(&format!(
406 " subgraph purpose_{}[\"{}\"]\n",
407 ident(purpose),
408 label(purpose)
409 ));
410 for kind in kinds {
411 out.push_str(&kind_line(kind, " "));
412 }
413 out.push_str(" end\n");
414 }
415 None => {
416 for kind in kinds {
417 out.push_str(&kind_line(kind, " "));
418 }
419 }
420 }
421 }
422 for kind in &strays {
423 out.push_str(&kind_line(kind, " "));
424 }
425 for name in &model.abstracts {
426 let mut text = format!("{}<br/>abstract", label(name));
427 let required = model
428 .kinds
429 .and_then(|kinds| kinds.get(name))
430 .and_then(|node| node.value.as_map())
431 .and_then(|kind| map(kind, "facets"))
432 .map(|facets| names(facets.get("require")))
433 .unwrap_or_default();
434 if !required.is_empty() {
435 text.push_str(&format!("<br/>requires: {}", label(&required.join(", "))));
436 }
437 let text = with_loops(model, text, name, &loops);
438 out.push_str(&format!(" kind_{}[\"{text}\"]\n", ident(name)));
439 }
440 for anchor in &reached {
441 out.push_str(&format!(
442 " anchor_{}{{{{\"{}\"}}}}\n",
443 ident(anchor),
444 with_loops(model, label(anchor), anchor, &loops)
445 ));
446 }
447 for (kind, parent) in &is_a {
448 out.push_str(&format!(
449 " {} -.-> {}\n",
450 model.node(kind),
451 model.node(parent)
452 ));
453 }
454 for pair in &edges {
455 out.push_str(&edge(model, pair));
456 }
457
458 let mut styles = Vec::new();
459 let mut anchor_ids: Vec<String> = reached.iter().map(|a| model.node(a)).collect();
460 let mut abstract_ids: Vec<String> = model.abstracts.iter().map(|a| model.node(a)).collect();
461 let offset = is_a.len() + edges.len();
462 if legend {
463 let shown = Shown {
464 abstract_kind: true,
465 anchor: !anchor_ids.is_empty(),
466 is_a: !is_a.is_empty(),
467 families: present(&edges, &loops),
468 };
469 styles = key(&mut out, model, &shown, offset);
470 abstract_ids.push("key_abstract".to_string());
471 if shown.anchor {
472 anchor_ids.push("key_anchor".to_string());
473 }
474 }
475 out.push_str(" classDef abstract fill:#eee,stroke:#555,color:#222,stroke-dasharray:5 3\n");
476 out.push_str(&format!(" class {} abstract\n", abstract_ids.join(",")));
477 if !anchor_ids.is_empty() {
478 out.push_str(" classDef anchor fill:#eee,stroke:#555,color:#222\n");
479 out.push_str(&format!(" class {} anchor\n", anchor_ids.join(",")));
480 }
481 if !is_a.is_empty() {
482 let at: Vec<String> = (0..is_a.len()).map(|i| i.to_string()).collect();
483 out.push_str(&format!(" linkStyle {} stroke:{IS_A}\n", at.join(",")));
484 }
485 family_styles(&mut out, model, &edges, is_a.len());
486 for style in styles {
487 out.push_str(&style);
488 }
489 out
490}
491
492fn under_abstract(model: &Model, name: &str) -> bool {
494 let Some(kinds) = model.kinds else {
495 return false;
496 };
497 let mut seen = BTreeSet::new();
498 let mut at = name.to_string();
499 while seen.insert(at.clone()) {
500 let parent = kinds
501 .get(&at)
502 .and_then(|node| node.value.as_map())
503 .and_then(|kind| scalar(kind, "is_a"));
504 let Some(parent) = parent else { return false };
505 if model.abstracts.contains(&parent) {
506 return true;
507 }
508 at = parent;
509 }
510 false
511}
512
513fn edge(model: &Model, (relation, from, to, _): &Pair) -> String {
514 format!(
515 " {} -->|{}| {}\n",
516 model.node(from),
517 label(relation),
518 model.node(to)
519 )
520}
521
522fn present(edges: &[Pair], loops: &Loops) -> Vec<String> {
525 let of_edges = edges.iter().filter_map(|(_, _, _, family)| family.clone());
526 let of_loops = loops
527 .values()
528 .flatten()
529 .filter_map(|(_, family)| family.clone());
530 of_edges
531 .chain(of_loops)
532 .collect::<BTreeSet<_>>()
533 .into_iter()
534 .collect()
535}
536
537fn family_styles(out: &mut String, model: &Model, edges: &[Pair], offset: usize) {
540 for family in &model.families {
541 let at: Vec<String> = edges
542 .iter()
543 .enumerate()
544 .filter(|(_, (_, _, _, f))| f.as_deref() == Some(family.as_str()))
545 .map(|(at, _)| (offset + at).to_string())
546 .collect();
547 if at.is_empty() {
548 continue;
549 }
550 out.push_str(&format!(" %% family {family}\n"));
551 out.push_str(&format!(
552 " linkStyle {} stroke:{}\n",
553 at.join(","),
554 model.color(family)
555 ));
556 }
557}
558
559struct Shown {
561 abstract_kind: bool,
562 anchor: bool,
563 is_a: bool,
564 families: Vec<String>,
565}
566
567fn key(out: &mut String, model: &Model, shown: &Shown, start: usize) -> Vec<String> {
571 out.push_str(" subgraph key[\"key\"]\n direction TB\n");
572 out.push_str(" key_kind[\"concrete kind\"]\n");
573 if shown.abstract_kind {
574 out.push_str(" key_abstract[\"abstract kind, stands for every kind under it\"]\n");
575 }
576 if shown.anchor {
577 out.push_str(" key_anchor{{\"anchor, a thing a document points at\"}}\n");
578 }
579 let mut entries: Vec<(String, &str, bool)> = Vec::new();
580 if shown.is_a {
581 entries.push(("is_a".to_string(), IS_A, true));
582 }
583 for family in &shown.families {
584 entries.push((family.clone(), model.color(family), false));
585 }
586 let mut styles = Vec::new();
587 for (index, (name, color, dotted)) in entries.iter().enumerate() {
588 let arrow = if *dotted { "-.->" } else { "-->" };
589 out.push_str(&format!(
590 " key_a{index}[\" \"] {arrow}|{}| key_b{index}[\" \"]\n",
591 label(name)
592 ));
593 styles.push(format!(" linkStyle {} stroke:{color}\n", start + index));
594 }
595 out.push_str(" end\n");
596 styles
597}
598
599fn purpose_of(kinds: &Mapping, name: &str) -> Option<String> {
601 let mut seen = BTreeSet::new();
602 let mut at = name.to_string();
603 while seen.insert(at.clone()) {
604 let kind = kinds.get(&at)?.value.as_map()?;
605 if let Some(purpose) = scalar(kind, "purpose") {
606 return Some(purpose);
607 }
608 at = scalar(kind, "is_a")?;
609 }
610 None
611}
612
613fn map<'a>(of: &'a Mapping, key: &str) -> Option<&'a Mapping> {
614 of.get(key).and_then(|node| node.value.as_map())
615}
616
617fn keys(of: &Mapping, key: &str) -> BTreeSet<String> {
618 map(of, key)
619 .map(|m| m.iter().map(|e| e.key.value.clone()).collect())
620 .unwrap_or_default()
621}
622
623fn scalar(of: &Mapping, key: &str) -> Option<String> {
624 of.get(key)
625 .and_then(|node| node.value.as_scalar())
626 .map(|s| s.text.clone())
627}
628
629fn names(node: Option<&Spanned<Value>>) -> Vec<String> {
630 match node.map(|n| &n.value) {
631 Some(Value::Seq(items)) => items
632 .iter()
633 .filter_map(|item| item.value.as_scalar().map(|s| s.text.clone()))
634 .collect(),
635 Some(Value::Scalar(s)) => vec![s.text.clone()],
636 _ => Vec::new(),
637 }
638}
639
640fn join(names: &BTreeSet<String>) -> String {
641 names.iter().cloned().collect::<Vec<_>>().join(", ")
642}
643
644fn ident(name: &str) -> String {
647 name.chars()
648 .map(|c| match c.is_ascii_alphanumeric() || c == '_' {
649 true => c,
650 false => '_',
651 })
652 .collect()
653}
654
655fn label(name: &str) -> String {
657 name.replace('"', "#quot;")
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 fn resolved(text: &str) -> Mapping {
665 let loaded = headwater_yaml::load(text).expect("the fixture loads");
666 loaded.value.as_map().expect("a mapping").clone()
667 }
668
669 #[test]
670 fn a_kind_with_no_purpose_of_its_own_takes_the_one_above_it() {
671 let taxonomy = resolved(
672 "purposes:\n p: {}\nkinds:\n base:\n purpose: p\n child:\n is_a: base\n",
673 );
674 let text = render("x", "1", &taxonomy, View::Concrete, false);
675 let lane: Vec<&str> = text
676 .lines()
677 .skip_while(|l| l.trim() != "subgraph purpose_p[\"p\"]")
678 .take_while(|l| l.trim() != "end")
679 .collect();
680 assert!(lane.contains(&" kind_child[\"child\"]"), "{text}");
681 }
682
683 #[test]
684 fn a_lock_with_no_abstract_pair_carries_no_caption() {
685 let taxonomy = resolved(
686 "purposes:\n p: {}\nkinds:\n a:\n purpose: p\n b:\n purpose: p\nrelations:\n r:\n family: f\n from: [a]\n to: [b]\n",
687 );
688 let text = render("x", "1", &taxonomy, View::Concrete, false);
689 assert!(!text.contains("note_abstract"), "{text}");
690 assert!(text.contains(" kind_a -->|r| kind_b\n"), "{text}");
691 assert!(text.contains(" linkStyle 0 stroke:#1f77b4\n"), "{text}");
692 }
693 #[test]
694 fn a_lock_with_no_abstract_kind_has_nothing_for_the_abstract_view_to_draw() {
695 let taxonomy = resolved(
696 "purposes:\n p: {}\nkinds:\n a:\n purpose: p\nrelations:\n r:\n family: f\n from: [a]\n to: [a]\n",
697 );
698 let text = render("x", "1", &taxonomy, View::Abstract, true);
699 assert!(text.contains("declares no abstract kind"), "{text}");
700 assert!(
701 !text.contains("subgraph") && !text.contains("-->"),
702 "{text}"
703 );
704 }
705
706 #[test]
707 fn a_family_takes_one_color_in_both_views() {
708 let taxonomy = resolved(
709 "purposes:\n p: {}\nkinds:\n base:\n abstract: true\n a:\n is_a: base\n purpose: p\n b:\n is_a: base\n purpose: p\nrelations:\n s:\n family: f\n from: [a]\n to: [b]\n t:\n family: g\n from: [a]\n to: [base]\n",
710 );
711 let concrete = render("x", "1", &taxonomy, View::Concrete, false);
712 let abstract_view = render("x", "1", &taxonomy, View::Abstract, false);
713 assert!(
714 concrete.contains("linkStyle 0 stroke:#1f77b4"),
715 "{concrete}"
716 );
717 assert!(
718 abstract_view.contains("linkStyle 2 stroke:#d62728"),
719 "family g is second in both views, after two is_a arrows here:\n{abstract_view}"
720 );
721 }
722 #[test]
723 fn a_relation_from_a_kind_to_itself_is_a_colored_line_on_the_kind() {
724 let taxonomy = resolved(
725 "purposes:\n p: {}\nkinds:\n a:\n purpose: p\n b:\n purpose: p\nrelations:\n f_first:\n family: f\n from: [a]\n to: [a]\n g_edge:\n family: g\n from: [a]\n to: [b]\n",
726 );
727 let text = render("x", "1", &taxonomy, View::Concrete, true);
728 assert!(
729 !text.contains("-->|f_first|"),
730 "no edge for the loop:\n{text}"
731 );
732 assert!(
733 text.contains(
734 " kind_a[\"a<br/><span style='color:#1f77b4'>\u{21bb} f_first</span>\"]\n"
735 ),
736 "{text}"
737 );
738 assert!(
739 text.contains(" linkStyle 0 stroke:#d62728\n"),
740 "the one edge takes index 0 and the color of its own family:\n{text}"
741 );
742 assert!(
743 text.contains("-->|f| key_b0") && text.contains("-->|g| key_b1"),
744 "a family carried by a loop line only is keyed:\n{text}"
745 );
746 }
747
748 #[test]
749 fn a_pair_with_no_family_is_a_line_with_no_color() {
750 let taxonomy = resolved(
751 "purposes:\n p: {}\nkinds:\n a:\n purpose: p\nrelations:\n r:\n from: [a]\n to: [a]\n",
752 );
753 let text = render("x", "1", &taxonomy, View::Concrete, false);
754 assert!(
755 text.contains(" kind_a[\"a<br/>\u{21bb} r\"]\n"),
756 "{text}"
757 );
758 }
759}