Skip to main content

asbuilt_core/
read.rs

1//! A model asbuilt wrote, read back: the elements and relations in the
2//! `model` block of a `.c4` file, as the file spells them. The inverse of
3//! [`emit`](crate::emit) for the model block it writes, so the drift
4//! check can say what changed rather than only which lines did. The
5//! specification and views are derived from the model block and are not
6//! read.
7//!
8//! The model block must have emit's shape: its statements, each element's
9//! fields in emit's order and at most once, every element before the first
10//! relation, and every relation joining two elements the block declares.
11//! Anything else (a hand edit, another tool's model, a release that wrote
12//! a different shape) reads as `None`, never as a partial model: a summary
13//! that silently missed part of a file would be worse than none.
14
15use std::collections::BTreeMap;
16use std::iter::Peekable;
17use std::vec;
18
19use crate::check::normalize;
20use crate::emit::HEADER;
21
22/// The elements and relations of a written model. Ids are dotted and as
23/// the file writes them (LikeC4 identifiers); kinds are the keywords the
24/// specification declares.
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
26pub struct Written {
27    pub elements: BTreeMap<String, WrittenElement>,
28    /// Keyed by source and target id: the survey records one relation per
29    /// pair.
30    pub relations: BTreeMap<(String, String), WrittenRelation>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub struct WrittenElement {
35    pub kind: String,
36    pub title: String,
37    pub description: Option<String>,
38    pub technology: Option<String>,
39    pub path: Option<String>,
40    pub tags: Vec<String>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
44pub struct WrittenRelation {
45    pub kind: String,
46    /// The label as written: a surveyed relation's item names joined with
47    /// `, `, or an external relation's title, which is free text and may
48    /// hold `, ` itself, so it is not split back.
49    pub label: Option<String>,
50    pub technology: Option<String>,
51}
52
53/// The model block of `text`, or `None` when `text` is not a model
54/// asbuilt wrote. Line endings may be `\r\n`.
55pub fn read(text: &str) -> Option<Written> {
56    let text = normalize(text);
57    let rest = text.strip_prefix(HEADER)?.strip_prefix('\n')?;
58    let start = rest.find("\nmodel {\n")? + "\nmodel {\n".len();
59    let mut parser = Parser {
60        tokens: tokenize(&rest[start..])?.into_iter().peekable(),
61        written: Written::default(),
62    };
63    parser.model_body()?;
64    let written = parser.written;
65    written
66        .relations
67        .keys()
68        .all(|(from, to)| written.elements.contains_key(from) && written.elements.contains_key(to))
69        .then_some(written)
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73enum Token {
74    Word(String),
75    Quoted(String),
76    Open,
77    Close,
78}
79
80/// Words, single-quoted strings (unescaped) and braces, up to the brace
81/// that closes the model block; the views after it are not read. Every
82/// pass takes a character, so malformed text ends the loop.
83fn tokenize(text: &str) -> Option<Vec<Token>> {
84    let mut tokens = Vec::new();
85    let mut depth = 1usize;
86    let mut chars = text.chars().peekable();
87    while let Some(c) = chars.next() {
88        match c {
89            '{' => {
90                depth += 1;
91                tokens.push(Token::Open);
92            }
93            '}' => {
94                tokens.push(Token::Close);
95                depth -= 1;
96                if depth == 0 {
97                    return Some(tokens);
98                }
99            }
100            '\'' => {
101                let mut value = String::new();
102                loop {
103                    match chars.next()? {
104                        '\'' => break,
105                        '\\' => value.push(chars.next()?),
106                        c => value.push(c),
107                    }
108                }
109                tokens.push(Token::Quoted(value));
110            }
111            c if c.is_whitespace() => {}
112            c => {
113                let mut word = String::from(c);
114                while let Some(&c) = chars.peek() {
115                    if c.is_whitespace() || matches!(c, '{' | '}' | '\'') {
116                        break;
117                    }
118                    word.push(c);
119                    chars.next();
120                }
121                tokens.push(Token::Word(word));
122            }
123        }
124    }
125    None
126}
127
128/// Takes each token as it reads it, so every step of every loop consumes.
129struct Parser {
130    tokens: Peekable<vec::IntoIter<Token>>,
131    written: Written,
132}
133
134/// Where an element body is in emit's order: tags, description,
135/// technology, metadata, link, children.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
137enum Field {
138    Tags,
139    Description,
140    Technology,
141    Metadata,
142    Link,
143    Children,
144}
145
146impl Field {
147    /// Tags share a line and children follow one another; every other
148    /// field comes once.
149    fn repeats(self) -> bool {
150        matches!(self, Field::Tags | Field::Children)
151    }
152}
153
154impl Parser {
155    fn next(&mut self) -> Option<Token> {
156        self.tokens.next()
157    }
158
159    fn word(&mut self) -> Option<String> {
160        match self.next()? {
161            Token::Word(word) => Some(word),
162            _ => None,
163        }
164    }
165
166    fn quoted(&mut self) -> Option<String> {
167        match self.next()? {
168            Token::Quoted(value) => Some(value),
169            _ => None,
170        }
171    }
172
173    fn expect(&mut self, token: Token) -> Option<()> {
174        (self.next()? == token).then_some(())
175    }
176
177    fn expect_word(&mut self, word: &str) -> Option<()> {
178        (self.word()? == word).then_some(())
179    }
180
181    /// Top-level elements, then relations, then the closing brace.
182    fn model_body(&mut self) -> Option<()> {
183        let mut in_relations = false;
184        loop {
185            let first = match self.next()? {
186                Token::Close => return Some(()),
187                Token::Word(word) => word,
188                _ => return None,
189            };
190            let second = self.word()?;
191            if second == "=" {
192                if in_relations {
193                    return None;
194                }
195                self.element("", first)?;
196            } else {
197                in_relations = true;
198                self.relation(first, &second)?;
199            }
200        }
201    }
202
203    /// After `name =`: `kind 'title'`, then the body when one follows.
204    fn element(&mut self, parent: &str, name: String) -> Option<()> {
205        let kind = self.word()?;
206        let title = self.quoted()?;
207        let id = if parent.is_empty() {
208            name
209        } else {
210            format!("{parent}.{name}")
211        };
212        let mut element = WrittenElement {
213            kind,
214            title,
215            ..Default::default()
216        };
217        if self.tokens.peek() == Some(&Token::Open) {
218            self.next();
219            self.element_body(&id, &mut element)?;
220        }
221        self.written
222            .elements
223            .insert(id, element)
224            .is_none()
225            .then_some(())
226    }
227
228    fn element_body(&mut self, id: &str, element: &mut WrittenElement) -> Option<()> {
229        let mut at: Option<Field> = None;
230        loop {
231            let word = match self.next()? {
232                Token::Close => return Some(()),
233                Token::Word(word) => word,
234                _ => return None,
235            };
236            let field = match word.as_str() {
237                w if w.starts_with('#') => Field::Tags,
238                "description" => Field::Description,
239                "technology" => Field::Technology,
240                "metadata" => Field::Metadata,
241                // emit writes a link only right after the metadata; one
242                // anywhere else falls to a child, which wants `=`.
243                "link" if at == Some(Field::Metadata) => Field::Link,
244                _ => Field::Children,
245            };
246            if at.is_some_and(|at| field < at || (field == at && !field.repeats())) {
247                return None;
248            }
249            at = Some(field);
250            match field {
251                Field::Tags => element.tags.push(word[1..].to_string()),
252                Field::Description => element.description = Some(self.quoted()?),
253                Field::Technology => element.technology = Some(self.quoted()?),
254                Field::Metadata => {
255                    self.expect(Token::Open)?;
256                    self.expect_word("path")?;
257                    element.path = Some(self.quoted()?);
258                    self.expect(Token::Close)?;
259                }
260                // Derived from the path and the file's place; not compared.
261                Field::Link => {
262                    self.word()?;
263                }
264                Field::Children => {
265                    self.expect_word("=")?;
266                    self.element(id, word)?;
267                }
268            }
269        }
270    }
271
272    /// After `from`: `-[kind]-> to`, the label when there is one, and a
273    /// technology block when there is one.
274    fn relation(&mut self, from: String, arrow: &str) -> Option<()> {
275        let kind = arrow.strip_prefix("-[")?.strip_suffix("]->")?.to_string();
276        let to = self.word()?;
277        let mut relation = WrittenRelation {
278            kind,
279            ..Default::default()
280        };
281        if matches!(self.tokens.peek(), Some(Token::Quoted(_))) {
282            relation.label = Some(self.quoted()?);
283        }
284        if self.tokens.peek() == Some(&Token::Open) {
285            self.next();
286            self.expect_word("technology")?;
287            relation.technology = Some(self.quoted()?);
288            self.expect(Token::Close)?;
289        }
290        self.written
291            .relations
292            .insert((from, to), relation)
293            .is_none()
294            .then_some(())
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::emit::{EmitOptions, emit};
302    use crate::model::{Element, ElementKind, Model, Relation, RelationKind};
303
304    fn id(dotted: &str) -> Vec<String> {
305        dotted.split('.').map(str::to_string).collect()
306    }
307
308    fn element(dotted: &str, kind: ElementKind) -> Element {
309        Element {
310            id: id(dotted),
311            kind,
312            title: dotted.rsplit('.').next().unwrap().to_string(),
313            description: None,
314            technology: None,
315            path: None,
316            tags: vec![],
317        }
318    }
319
320    fn relation(source: &str, target: &str, kind: RelationKind, items: &[&str]) -> Relation {
321        Relation {
322            source: id(source),
323            target: id(target),
324            kind,
325            items: items.iter().map(|s| s.to_string()).collect(),
326            technology: None,
327        }
328    }
329
330    /// A crate with a described module, a bin, a leaf module with no body,
331    /// an external with a technology, and relations with and without items.
332    fn sample() -> Model {
333        let mut app = element("app", ElementKind::Container);
334        app.technology = Some("library crate".into());
335        app.path = Some("app".into());
336        let mut server = element("app.server", ElementKind::Component);
337        server.description =
338            Some("Serves 'quoted' text,\nover two lines, with \\ and {braces}.".into());
339        server.path = Some("app/src/server.rs".into());
340        let mut bin = element("app.main", ElementKind::Bin);
341        bin.tags = vec!["bin".into()];
342        bin.path = Some("app/src/main.rs".into());
343        let leaf = element("app.leaf", ElementKind::Component);
344        let mut driver = element("node_driver", ElementKind::External("process".into()));
345        driver.title = "Driver process".into();
346        driver.tags = vec!["external".into()];
347        driver.technology = Some("Node.js".into());
348        let mut spawns = relation("app.server", "node_driver", RelationKind::Uses, &["spawns"]);
349        spawns.technology = Some("stdio".into());
350        Model {
351            elements: vec![app, server, bin, leaf, driver],
352            relations: vec![
353                relation(
354                    "app.main",
355                    "app.server",
356                    RelationKind::Calls,
357                    &["Server", "serve"],
358                ),
359                relation("app.leaf", "app.server", RelationKind::NamesType, &[]),
360                spawns,
361            ],
362            ..Default::default()
363        }
364    }
365
366    fn sample_written() -> Written {
367        read(&emit(
368            &sample(),
369            &EmitOptions::for_output_path("docs/architecture/model.c4"),
370        ))
371        .unwrap()
372    }
373
374    #[test]
375    fn every_element_is_read_back_under_its_full_id() {
376        assert_eq!(
377            sample_written().elements.keys().collect::<Vec<_>>(),
378            ["app", "app.leaf", "app.main", "app.server", "node_driver"]
379        );
380    }
381
382    #[test]
383    fn an_element_s_fields_are_read_back_as_written() {
384        assert_eq!(
385            sample_written().elements["app.server"],
386            WrittenElement {
387                kind: "component".into(),
388                title: "server".into(),
389                description: Some(
390                    "Serves 'quoted' text,\nover two lines, with \\ and {braces}.".into()
391                ),
392                technology: None,
393                path: Some("app/src/server.rs".into()),
394                tags: vec![],
395            }
396        );
397    }
398
399    #[test]
400    fn a_crate_s_technology_is_read_back() {
401        assert_eq!(
402            sample_written().elements["app"].technology.as_deref(),
403            Some("library crate")
404        );
405    }
406
407    #[test]
408    fn a_bin_s_tag_is_read_back() {
409        assert_eq!(sample_written().elements["app.main"].tags, ["bin"]);
410    }
411
412    #[test]
413    fn an_external_is_read_back_with_its_own_kind() {
414        assert_eq!(
415            sample_written().elements["node_driver"],
416            WrittenElement {
417                kind: "process".into(),
418                title: "Driver process".into(),
419                description: None,
420                technology: Some("Node.js".into()),
421                path: None,
422                tags: vec!["external".into()],
423            }
424        );
425    }
426
427    #[test]
428    fn a_leaf_with_no_body_is_read_back_with_only_its_kind_and_title() {
429        assert_eq!(
430            sample_written().elements["app.leaf"],
431            WrittenElement {
432                kind: "component".into(),
433                title: "leaf".into(),
434                ..Default::default()
435            }
436        );
437    }
438
439    fn pair(from: &str, to: &str) -> (String, String) {
440        (from.to_string(), to.to_string())
441    }
442
443    #[test]
444    fn a_relation_is_read_back_with_its_kind_and_label() {
445        assert_eq!(
446            sample_written().relations[&pair("app.main", "app.server")],
447            WrittenRelation {
448                kind: "calls".into(),
449                label: Some("Server, serve".into()),
450                technology: None,
451            }
452        );
453    }
454
455    #[test]
456    fn a_relation_without_items_has_no_label() {
457        assert_eq!(
458            sample_written().relations[&pair("app.leaf", "app.server")].label,
459            None
460        );
461    }
462
463    #[test]
464    fn a_relation_s_technology_is_read_back() {
465        assert_eq!(
466            sample_written().relations[&pair("app.server", "node_driver")],
467            WrittenRelation {
468                kind: "uses".into(),
469                label: Some("spawns".into()),
470                technology: Some("stdio".into()),
471            }
472        );
473    }
474
475    #[test]
476    fn an_external_title_holding_a_comma_reads_back_whole() {
477        let mut model = sample();
478        model.relations[2].items = vec!["reads, writes".into()];
479        let written = read(&emit(&model, &EmitOptions::for_output_path("m.c4"))).unwrap();
480        assert_eq!(
481            written.relations[&pair("app.server", "node_driver")]
482                .label
483                .as_deref(),
484            Some("reads, writes")
485        );
486    }
487
488    #[test]
489    fn crlf_line_endings_read_the_same() {
490        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
491        assert_eq!(read(&text.replace('\n', "\r\n")), read(&text));
492    }
493
494    #[test]
495    fn a_file_without_the_header_is_not_read() {
496        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
497        let edited = text.replacen("// GENERATED by asbuilt.", "// mine", 1);
498        assert_eq!(read(&edited), None);
499    }
500
501    #[test]
502    fn a_statement_emit_would_not_write_is_not_read() {
503        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
504        let edited = text.replacen(
505            "    technology 'library crate'",
506            "    style { color red }",
507            1,
508        );
509        assert_eq!(read(&edited), None);
510    }
511
512    #[test]
513    fn an_unterminated_string_is_not_read() {
514        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
515        let edited = text.replacen("'Driver process'", "'Driver process", 1);
516        assert_eq!(read(&edited), None);
517    }
518
519    #[test]
520    fn a_relation_written_twice_is_not_read() {
521        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
522        let line = "  app.main -[calls]-> app.server 'Server, serve'\n";
523        assert_eq!(
524            read(&text.replacen(line, &format!("{line}{line}"), 1)),
525            None
526        );
527    }
528
529    #[test]
530    fn an_element_written_twice_is_not_read() {
531        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
532        let line = "    leaf = component 'leaf'\n";
533        assert_eq!(
534            read(&text.replacen(line, &format!("{line}{line}"), 1)),
535            None
536        );
537    }
538
539    fn edited(from: &str, to: &str) -> Option<Written> {
540        let text = emit(&sample(), &EmitOptions::for_output_path("m.c4"));
541        assert!(text.contains(from), "{from:?} not in\n{text}");
542        read(&text.replacen(from, to, 1))
543    }
544
545    #[test]
546    fn a_header_line_with_more_on_it_is_not_read() {
547        assert_eq!(edited("survey.\n", "survey. And mine.\n"), None);
548    }
549
550    #[test]
551    fn fields_out_of_emit_s_order_are_not_read() {
552        assert_eq!(
553            edited(
554                "    technology 'library crate'\n    metadata {\n      path 'app'\n    }\n",
555                "    metadata {\n      path 'app'\n    }\n    technology 'library crate'\n"
556            ),
557            None
558        );
559    }
560
561    #[test]
562    fn a_field_written_twice_is_not_read() {
563        assert_eq!(
564            edited(
565                "    technology 'library crate'\n",
566                "    technology 'library crate'\n    technology 'binary crate'\n"
567            ),
568            None
569        );
570    }
571
572    #[test]
573    fn a_link_without_the_metadata_before_it_is_not_read() {
574        assert_eq!(
575            edited("    metadata {\n      path 'app'\n    }\n", ""),
576            None
577        );
578    }
579
580    #[test]
581    fn an_element_after_a_relation_is_not_read() {
582        assert_eq!(
583            edited(
584                "  app.main -[calls]-> ",
585                "  extra = component 'extra'\n  app.main -[calls]-> "
586            ),
587            None
588        );
589    }
590
591    #[test]
592    fn a_relation_to_an_element_the_file_does_not_declare_is_not_read() {
593        // An orphan: emit writes `a.b` at the top level as `b`, while the
594        // relation names `a.b`.
595        let mut model = sample();
596        model.elements.push(element("a.b", ElementKind::Component));
597        model
598            .relations
599            .push(relation("app.leaf", "a.b", RelationKind::Uses, &[]));
600        assert_eq!(
601            read(&emit(&model, &EmitOptions::for_output_path("m.c4"))),
602            None
603        );
604    }
605}