Skip to main content

asbuilt_core/
emit.rs

1//! The LikeC4 emitter: a [`Model`] to the text of one `.c4` file.
2//!
3//! This is the only place LikeC4 syntax is produced. The facts it relies
4//! on were verified against the LikeC4 release the CLI pins, and the CLI's `likec4_` tests
5//! run every emitted shape through the real parser. Output is a pure
6//! function of the normalized model: same model, same bytes, on every
7//! platform.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Write;
11
12use crate::model::{Element, Id, Model, RelationKind, sanitize_id, sanitize_segment};
13
14/// The first line of every generated file. No version in it: a version
15/// would turn every asbuilt release into drift in every consumer.
16pub const HEADER: &str = "// GENERATED by asbuilt. Do not edit; run asbuilt survey.";
17
18/// How the file relates to the repo it describes.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct EmitOptions {
21    /// The prefix that takes a `link` from the output file's directory
22    /// back to the surveyed root: `../../` for `docs/architecture/x.c4`,
23    /// `./` for a file at the root.
24    pub link_prefix: String,
25    /// The color per element kind (`container`, `component`, an external
26    /// kind), declared in the specification and applied as that kind's
27    /// style. Kinds absent here get LikeC4's default.
28    pub theme: BTreeMap<String, String>,
29}
30
31impl EmitOptions {
32    /// Options for a file written at `output_path`, relative to the root
33    /// and `/`-separated.
34    pub fn for_output_path(output_path: &str) -> Self {
35        let depth = output_path
36            .split('/')
37            .filter(|s| !s.is_empty() && *s != ".")
38            .count()
39            .saturating_sub(1);
40        let link_prefix = if depth == 0 {
41            "./".to_string()
42        } else {
43            "../".repeat(depth)
44        };
45        Self {
46            link_prefix,
47            theme: BTreeMap::new(),
48        }
49    }
50
51    /// The same options with a theme.
52    pub fn with_theme(mut self, theme: BTreeMap<String, String>) -> Self {
53        self.theme = theme;
54        self
55    }
56}
57
58/// A LikeC4 single-quoted string. `\` and `'` are escaped; newlines
59/// pass through, since a single-quoted string may span lines.
60fn quote(text: &str) -> String {
61    let mut out = String::with_capacity(text.len() + 2);
62    out.push('\'');
63    for c in text.chars() {
64        match c {
65            '\\' => out.push_str("\\\\"),
66            '\'' => out.push_str("\\'"),
67            '\r' => {}
68            c => out.push(c),
69        }
70    }
71    out.push('\'');
72    out
73}
74
75/// A path as a LikeC4 link target, which is an unquoted URI: every byte
76/// outside the unreserved set and `/` is percent-encoded, so a space
77/// or a quote in a directory name cannot break the parse.
78pub fn link_encode(path: &str) -> String {
79    let mut out = String::with_capacity(path.len());
80    for byte in path.bytes() {
81        let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/');
82        if keep {
83            out.push(byte as char);
84        } else {
85            out.push_str(&format!("%{byte:02X}"));
86        }
87    }
88    out
89}
90
91/// The `.c4` text for `model`. The model is normalized on a copy first,
92/// so the caller's element order does not matter.
93pub fn emit(model: &Model, options: &EmitOptions) -> String {
94    let mut model = model.clone();
95    model.normalize();
96
97    let mut out = String::new();
98    out.push_str(HEADER);
99    out.push('\n');
100    out.push('\n');
101    emit_specification(&mut out, &model, &options.theme);
102    out.push('\n');
103    emit_model(&mut out, &model, options);
104    out.push('\n');
105    emit_views(&mut out, &model);
106    out
107}
108
109/// The name of a themed kind's color in the specification.
110pub fn theme_color_name(kind: &str) -> String {
111    format!("theme_{kind}")
112}
113
114fn emit_specification(out: &mut String, model: &Model, theme: &BTreeMap<String, String>) {
115    let mut kinds: BTreeSet<&str> = BTreeSet::new();
116    let mut tags: BTreeSet<&str> = BTreeSet::new();
117    for element in &model.elements {
118        kinds.insert(element.kind.keyword());
119        tags.extend(element.tags.iter().map(String::as_str));
120    }
121    out.push_str("specification {\n");
122    for kind in &kinds {
123        if let Some(color) = theme.get(*kind) {
124            writeln!(out, "  color {} {color}", theme_color_name(kind)).unwrap();
125        }
126    }
127    for kind in &kinds {
128        if theme.contains_key(*kind) {
129            writeln!(
130                out,
131                "  element {kind} {{\n    style {{\n      color {}\n    }}\n  }}",
132                theme_color_name(kind)
133            )
134            .unwrap();
135        } else {
136            writeln!(out, "  element {kind}").unwrap();
137        }
138    }
139    for tag in tags {
140        writeln!(out, "  tag {tag}").unwrap();
141    }
142    for kind in RelationKind::ALL {
143        writeln!(
144            out,
145            "  relationship {} {{\n    line {}\n    head {}\n  }}",
146            kind.keyword(),
147            kind.line(),
148            kind.head()
149        )
150        .unwrap();
151    }
152    out.push_str("}\n");
153}
154
155/// Elements whose parent id is `parent` (or every top-level element when
156/// `parent` is empty). "Top-level" means no element in the model has the
157/// id one segment shorter, so an orphan component still gets emitted.
158pub(crate) fn children_of<'a>(model: &'a Model, parent: &[String]) -> Vec<&'a Element> {
159    model
160        .elements
161        .iter()
162        .filter(|e| {
163            if parent.is_empty() {
164                e.id.len() == 1 || model.element(&e.id[..e.id.len() - 1]).is_none()
165            } else {
166                e.id.len() == parent.len() + 1 && e.id.starts_with(parent)
167            }
168        })
169        .collect()
170}
171
172fn emit_model(out: &mut String, model: &Model, options: &EmitOptions) {
173    out.push_str("model {\n");
174    for element in children_of(model, &[]) {
175        emit_element(out, model, element, options, 1);
176    }
177    if !model.relations.is_empty() {
178        out.push('\n');
179    }
180    for rel in &model.relations {
181        let indent = "  ";
182        write!(
183            out,
184            "{indent}{} -[{}]-> {}",
185            sanitize_id(&rel.source),
186            rel.kind.keyword(),
187            sanitize_id(&rel.target)
188        )
189        .unwrap();
190        if !rel.items.is_empty() {
191            write!(out, " {}", quote(&rel.items.join(", "))).unwrap();
192        }
193        match &rel.technology {
194            Some(technology) => {
195                writeln!(
196                    out,
197                    " {{\n{indent}  technology {}\n{indent}}}",
198                    quote(technology)
199                )
200                .unwrap();
201            }
202            None => out.push('\n'),
203        }
204    }
205    out.push_str("}\n");
206}
207
208fn emit_element(
209    out: &mut String,
210    model: &Model,
211    element: &Element,
212    options: &EmitOptions,
213    depth: usize,
214) {
215    let indent = "  ".repeat(depth);
216    let inner = "  ".repeat(depth + 1);
217    // Only the last segment is written; nesting supplies the rest of the
218    // fully qualified id. An element whose parent is not in the model
219    // lands at the top level under its last segment, and any reference
220    // to its full id then fails `likec4 validate`, which is the right
221    // signal for a front-end that forgot the parent.
222    let name = sanitize_segment(element.id.last().map(String::as_str).unwrap_or_default());
223    let children = children_of(model, &element.id);
224    let has_body = !element.tags.is_empty()
225        || element.description.is_some()
226        || element.technology.is_some()
227        || element.path.is_some()
228        || !children.is_empty();
229
230    write!(
231        out,
232        "{indent}{name} = {} {}",
233        element.kind.keyword(),
234        quote(&element.title)
235    )
236    .unwrap();
237    if !has_body {
238        out.push('\n');
239        return;
240    }
241    out.push_str(" {\n");
242    // Tags must come first in the body.
243    if !element.tags.is_empty() {
244        let tags: Vec<String> = element.tags.iter().map(|t| format!("#{t}")).collect();
245        writeln!(out, "{inner}{}", tags.join(" ")).unwrap();
246    }
247    if let Some(description) = &element.description {
248        writeln!(out, "{inner}description {}", quote(description)).unwrap();
249    }
250    if let Some(technology) = &element.technology {
251        writeln!(out, "{inner}technology {}", quote(technology)).unwrap();
252    }
253    if let Some(path) = &element.path {
254        writeln!(
255            out,
256            "{inner}metadata {{\n{inner}  path {}\n{inner}}}",
257            quote(path)
258        )
259        .unwrap();
260        // The surveyed root itself has no link: likec4 rejects a link
261        // that is only a prefix (`./`), and the file's own directory
262        // needs no link to be found.
263        if path != "." {
264            writeln!(
265                out,
266                "{inner}link {}{}",
267                options.link_prefix,
268                link_encode(path)
269            )
270            .unwrap();
271        }
272    }
273    for child in children {
274        emit_element(out, model, child, options, depth + 1);
275    }
276    writeln!(out, "{indent}}}").unwrap();
277}
278
279/// The base view id for an element: its sanitized id with `_` for `.`.
280/// Two elements can share one (`a.b_c` and `a_b.c`); [`view_ids`] is
281/// where the later one gets its suffix.
282pub fn view_id(id: &Id) -> String {
283    format!("view_{}", sanitize_id(id).replace('.', "_"))
284}
285
286/// The generated view of every element with children, keyed by element
287/// id: the view name as the emitter declares it and as `asbuilt render`
288/// names the SVG. Where two ids collapse to one base name, the second
289/// and later take a numeric suffix in element order, so the names are
290/// stable for a normalized model (`emit` normalizes first; call this on
291/// the same model to get the same names).
292pub fn view_ids(model: &Model) -> BTreeMap<Id, String> {
293    let mut used: BTreeSet<String> = BTreeSet::from(["index".to_string()]);
294    let mut names = BTreeMap::new();
295    for element in &model.elements {
296        if children_of(model, &element.id).is_empty() {
297            continue;
298        }
299        let base = view_id(&element.id);
300        let id = std::iter::once(base.clone())
301            .chain((2..).map(|n| format!("{base}_{n}")))
302            .find(|candidate| used.insert(candidate.clone()))
303            .expect("an unbounded sequence of candidates always has a free one");
304        names.insert(element.id.clone(), id);
305    }
306    names
307}
308
309fn emit_views(out: &mut String, model: &Model) {
310    out.push_str("views {\n");
311    let top: Vec<String> = children_of(model, &[])
312        .iter()
313        .map(|e| sanitize_id(&e.id))
314        .collect();
315    out.push_str("  view index {\n    title 'Overview'\n");
316    if !top.is_empty() {
317        writeln!(out, "    include {}", top.join(", ")).unwrap();
318    }
319    out.push_str("    autoLayout LeftRight\n  }\n");
320    let names = view_ids(model);
321    for element in &model.elements {
322        let Some(id) = names.get(&element.id) else {
323            continue;
324        };
325        let fqn = sanitize_id(&element.id);
326        writeln!(
327            out,
328            "\n  view {id} of {fqn} {{\n    title {}\n    include *\n    autoLayout TopBottom\n  }}",
329            quote(&element.id.join("."))
330        )
331        .unwrap();
332    }
333    out.push_str("}\n");
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::model::{ElementKind, Relation};
340
341    fn id(s: &str) -> Id {
342        s.split('.').map(str::to_string).collect()
343    }
344
345    fn element(s: &str, kind: ElementKind) -> Element {
346        Element {
347            id: id(s),
348            kind,
349            title: s.rsplit('.').next().unwrap().to_string(),
350            description: None,
351            technology: None,
352            path: None,
353            tags: vec![],
354        }
355    }
356
357    fn sample() -> Model {
358        let mut app = element("app", ElementKind::Container);
359        app.technology = Some("library crate".into());
360        app.path = Some("crates/app".into());
361        let mut server = element("app.server", ElementKind::Component);
362        server.description = Some("Where it's sent.\n\n- one\n- two".into());
363        server.path = Some("crates/app/src/server.rs".into());
364        let mut tests = element("app.tests", ElementKind::Tests);
365        tests.tags = vec!["tests".into()];
366        let mut driver = element("node_driver", ElementKind::External("process".into()));
367        driver.tags = vec!["external".into()];
368        driver.technology = Some("Node.js process".into());
369        Model {
370            elements: vec![tests, driver, server, app],
371            relations: vec![
372                Relation {
373                    source: id("app.tests"),
374                    target: id("app.server"),
375                    kind: RelationKind::Calls,
376                    items: vec!["Server".into(), "connect".into()],
377                    technology: None,
378                },
379                Relation {
380                    source: id("app.server"),
381                    target: id("node_driver"),
382                    kind: RelationKind::Uses,
383                    items: vec!["spawns".into()],
384                    technology: Some("stdio".into()),
385                },
386            ],
387            ..Default::default()
388        }
389    }
390
391    fn options() -> EmitOptions {
392        EmitOptions::for_output_path("docs/architecture/model.c4")
393    }
394
395    #[test]
396    fn the_header_is_the_first_line() {
397        let text = emit(&sample(), &options());
398        assert_eq!(text.lines().next(), Some(HEADER));
399    }
400
401    #[test]
402    fn the_specification_declares_every_element_kind_tag_and_relationship_kind() {
403        let text = emit(&sample(), &options());
404        let spec = text.split("model {").next().unwrap();
405        for line in [
406            "  element component",
407            "  element container",
408            "  element process",
409            "  element tests",
410            "  tag external",
411            "  tag tests",
412            "  relationship implements {",
413            "  relationship constructs {",
414            "  relationship calls {",
415            "  relationship names {",
416            "  relationship uses {",
417        ] {
418            assert!(
419                spec.contains(&format!("{line}\n")),
420                "missing {line:?} in:\n{spec}"
421            );
422        }
423    }
424
425    #[test]
426    fn a_themed_kind_declares_its_color_and_styles_the_element_with_it() {
427        let opts = options().with_theme(BTreeMap::from([
428            ("container".to_string(), "#f0a884".to_string()),
429            ("process".to_string(), "#c9c9c9".to_string()),
430        ]));
431        let text = emit(&sample(), &opts);
432        let spec = text.split("model {").next().unwrap();
433        assert!(
434            spec.starts_with(&format!(
435                "{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"
436            )),
437            "{spec}"
438        );
439    }
440
441    #[test]
442    fn a_theme_for_a_kind_the_model_lacks_emits_nothing_for_it() {
443        let opts = options().with_theme(BTreeMap::from([(
444            "browser".to_string(),
445            "#000000".to_string(),
446        )]));
447        let text = emit(&sample(), &opts);
448        assert!(!text.contains("browser"), "{text}");
449        assert_eq!(text, emit(&sample(), &options()));
450    }
451
452    #[test]
453    fn a_child_is_nested_inside_its_parent_under_its_last_segment() {
454        let text = emit(&sample(), &options());
455        let app_block = text.split("  app = container 'app' {").nth(1).unwrap();
456        assert!(
457            app_block.contains("\n    server = component 'server' {"),
458            "{text}"
459        );
460        assert!(!text.contains("app.server = component"), "{text}");
461    }
462
463    #[test]
464    fn tags_come_first_in_a_body() {
465        let text = emit(&sample(), &options());
466        assert!(
467            text.contains("tests = tests 'tests' {\n      #tests\n    }"),
468            "{text}"
469        );
470    }
471
472    #[test]
473    fn a_quote_in_a_description_is_escaped_and_markdown_kept() {
474        let text = emit(&sample(), &options());
475        assert!(
476            text.contains("description 'Where it\\'s sent.\n\n- one\n- two'"),
477            "{text}"
478        );
479    }
480
481    #[test]
482    fn a_path_becomes_metadata_and_a_link_relative_to_the_output_directory() {
483        let text = emit(&sample(), &options());
484        assert!(text.contains("metadata {\n        path 'crates/app/src/server.rs'\n      }\n      link ../../crates/app/src/server.rs\n"), "{text}");
485    }
486
487    #[test]
488    fn an_output_file_at_the_root_links_with_a_dot_prefix() {
489        let text = emit(&sample(), &EmitOptions::for_output_path("model.c4"));
490        assert!(text.contains("link ./crates/app\n"), "{text}");
491        assert_eq!(EmitOptions::for_output_path("./model.c4").link_prefix, "./");
492    }
493
494    #[test]
495    fn a_relation_line_carries_kind_label_and_technology() {
496        let text = emit(&sample(), &options());
497        assert!(
498            text.contains("  app.tests -[calls]-> app.server 'Server, connect'\n"),
499            "{text}"
500        );
501        assert!(
502            text.contains(
503                "  app.server -[uses]-> node_driver 'spawns' {\n    technology 'stdio'\n  }\n"
504            ),
505            "{text}"
506        );
507    }
508
509    #[test]
510    fn a_relation_without_items_has_no_label() {
511        let mut model = sample();
512        model.relations[0].items.clear();
513        let text = emit(&model, &options());
514        assert!(
515            text.contains("  app.tests -[calls]-> app.server\n"),
516            "{text}"
517        );
518    }
519
520    #[test]
521    fn the_index_view_includes_every_top_level_element() {
522        let text = emit(&sample(), &options());
523        assert!(text.contains("  view index {\n    title 'Overview'\n    include app, node_driver\n    autoLayout LeftRight\n  }"), "{text}");
524    }
525
526    #[test]
527    fn an_element_with_children_gets_a_scoped_view_and_a_leaf_does_not() {
528        let text = emit(&sample(), &options());
529        assert!(text.contains("  view view_app of app {\n    title 'app'\n    include *\n    autoLayout TopBottom\n  }"), "{text}");
530        assert!(!text.contains("of app.server {"), "{text}");
531        assert!(!text.contains("of node_driver {"), "{text}");
532    }
533
534    #[test]
535    fn a_hyphen_in_an_id_is_an_underscore_everywhere_it_appears() {
536        let mut model = sample();
537        for e in &mut model.elements {
538            if e.id[0] == "app" {
539                e.id[0] = "my-app".into();
540            }
541        }
542        for r in &mut model.relations {
543            if r.source[0] == "app" {
544                r.source[0] = "my-app".into();
545            }
546            if r.target[0] == "app" {
547                r.target[0] = "my-app".into();
548            }
549        }
550        let text = emit(&model, &options());
551        assert!(text.contains("  my_app = container"), "{text}");
552        assert!(
553            text.contains("my_app.tests -[calls]-> my_app.server"),
554            "{text}"
555        );
556        assert!(text.contains("view view_my_app of my_app"), "{text}");
557        // The raw name survives only inside a quoted title.
558        for line in text.lines().filter(|l| l.contains("my-app")) {
559            assert!(line.trim_start().starts_with("title "), "{line}");
560        }
561    }
562
563    #[test]
564    fn an_element_whose_parent_is_absent_is_emitted_at_the_top_level() {
565        let model = Model {
566            elements: vec![element("gone.child", ElementKind::Component)],
567            ..Default::default()
568        };
569        let text = emit(&model, &options());
570        assert!(
571            text.contains("model {\n  child = component 'child'\n"),
572            "{text}"
573        );
574    }
575
576    #[test]
577    fn each_body_field_alone_earns_a_block() {
578        fn only(f: impl FnOnce(&mut Element)) -> Model {
579            let mut e = element("x", ElementKind::Container);
580            f(&mut e);
581            Model {
582                elements: vec![e],
583                ..Default::default()
584            }
585        }
586        let mut with_child = only(|_| {});
587        with_child
588            .elements
589            .push(element("x.y", ElementKind::Component));
590        let cases = [
591            ("tags", only(|e| e.tags = vec!["tests".into()])),
592            ("description", only(|e| e.description = Some("d".into()))),
593            ("technology", only(|e| e.technology = Some("t".into()))),
594            ("path", only(|e| e.path = Some("p".into()))),
595            ("child", with_child),
596        ];
597        for (field, model) in cases {
598            let text = emit(&model, &options());
599            assert!(
600                text.contains("  x = container 'x' {\n"),
601                "{field} alone should open a block:\n{text}"
602            );
603        }
604    }
605
606    #[test]
607    fn an_element_with_nothing_to_say_has_no_block() {
608        let model = Model {
609            elements: vec![element("leaf", ElementKind::Container)],
610            ..Default::default()
611        };
612        let text = emit(&model, &options());
613        assert!(text.contains("  leaf = container 'leaf'\n}"), "{text}");
614    }
615
616    #[test]
617    fn output_does_not_depend_on_input_order() {
618        let a = emit(&sample(), &options());
619        let mut shuffled = sample();
620        shuffled.elements.reverse();
621        shuffled.relations.reverse();
622        let b = emit(&shuffled, &options());
623        assert_eq!(a, b);
624    }
625
626    #[test]
627    fn output_has_no_carriage_returns_or_backslashes_in_paths() {
628        let mut model = sample();
629        model.elements[2].description = Some("line\r\nnext".into());
630        let text = emit(&model, &options());
631        assert!(!text.contains('\r'), "{text:?}");
632        assert!(text.contains("description 'line\nnext'"), "{text}");
633    }
634
635    #[test]
636    fn the_root_path_keeps_its_metadata_but_has_no_link() {
637        let mut model = sample();
638        model
639            .elements
640            .iter_mut()
641            .find(|e| e.id == ["app"])
642            .unwrap()
643            .path = Some(".".into());
644        let text = emit(&model, &options());
645        let app_block = text.split("  app = container 'app' {").nth(1).unwrap();
646        let head: String = app_block.lines().take(4).collect::<Vec<_>>().join("\n");
647        assert!(head.contains("path '.'"), "{head}");
648        assert!(!head.contains("link"), "{head}");
649    }
650
651    #[test]
652    fn a_space_or_quote_in_a_path_is_percent_encoded_in_the_link_only() {
653        let mut model = sample();
654        model
655            .elements
656            .iter_mut()
657            .find(|e| e.id == ["app", "server"])
658            .unwrap()
659            .path = Some("crates/my tool/it's.rs".into());
660        let text = emit(&model, &options());
661        assert!(text.contains("path 'crates/my tool/it\\'s.rs'"), "{text}");
662        assert!(
663            text.contains("link ../../crates/my%20tool/it%27s.rs\n"),
664            "{text}"
665        );
666    }
667
668    #[test]
669    fn link_encoding_keeps_unreserved_characters_and_slashes() {
670        assert_eq!(link_encode("a-b_c.d~/e"), "a-b_c.d~/e");
671        assert_eq!(link_encode("é"), "%C3%A9");
672    }
673
674    #[test]
675    fn two_ids_that_collapse_to_one_view_id_get_distinct_views() {
676        let model = Model {
677            elements: vec![
678                element("a.b_c", ElementKind::Container),
679                element("a.b_c.x", ElementKind::Component),
680                element("a_b.c", ElementKind::Container),
681                element("a_b.c.y", ElementKind::Component),
682                element("a_b_c", ElementKind::Container),
683                element("a_b_c.z", ElementKind::Component),
684            ],
685            ..Default::default()
686        };
687        let text = emit(&model, &options());
688        assert!(text.contains("view view_a_b_c of a.b_c {"), "{text}");
689        assert!(text.contains("view view_a_b_c_2 of a_b.c {"), "{text}");
690        assert!(text.contains("view view_a_b_c_3 of a_b_c {"), "{text}");
691        let mut normalized = model.clone();
692        normalized.normalize();
693        let names: Vec<String> = view_ids(&normalized).into_values().collect();
694        assert_eq!(names, ["view_a_b_c", "view_a_b_c_2", "view_a_b_c_3"]);
695    }
696
697    #[test]
698    fn view_ids_names_every_element_with_children_and_only_those() {
699        let mut model = sample();
700        model.normalize();
701        let names = view_ids(&model);
702        let keyed: Vec<(String, String)> = names
703            .iter()
704            .map(|(id, name)| (id.join("."), name.clone()))
705            .collect();
706        assert_eq!(keyed, [("app".to_string(), "view_app".to_string())]);
707        assert!(!names.contains_key(&id("app.server")));
708        assert!(!names.contains_key(&id("node_driver")));
709    }
710
711    #[test]
712    fn a_backslash_in_a_string_is_escaped() {
713        assert_eq!(quote("a\\b"), "'a\\\\b'");
714    }
715
716    #[test]
717    fn link_prefix_counts_directories_not_the_file() {
718        assert_eq!(
719            EmitOptions::for_output_path("a/b/c/m.c4").link_prefix,
720            "../../../"
721        );
722        assert_eq!(EmitOptions::for_output_path("a/m.c4").link_prefix, "../");
723    }
724
725    #[test]
726    fn a_relationship_kind_is_declared_with_its_line_and_head() {
727        let text = emit(&sample(), &options());
728        assert!(
729            text.contains("  relationship implements {\n    line solid\n    head onormal\n  }\n"),
730            "{text}"
731        );
732    }
733}