Skip to main content

omgbase_mutate/
render.rs

1//! Rendering (`spec/mutate/README.md` §3): the document by splice — leading
2//! trivia, the frontmatter, then every top-level block and its trivia — where
3//! a block with no dirty descendant emits its raw verbatim and a container
4//! with one is rebuilt from its children by type.
5
6use std::sync::LazyLock;
7
8use regex::Regex;
9use serde_json::Value;
10
11use crate::tree::{MutBlock, MutDoc};
12
13/// §3 `render(doc)`.
14#[must_use]
15pub fn render(doc: &MutDoc) -> String {
16    let mut out = String::new();
17    out.push_str(&doc.leading_trivia);
18    if let Some(fm) = &doc.frontmatter_raw {
19        out.push_str(fm);
20    }
21    for b in &doc.children {
22        out.push_str(&render_block(b, 0));
23        out.push_str(&b.trivia);
24    }
25    out
26}
27
28/// §3 `render_block(b, depth)`: a leaf or a clean subtree emits its raw;
29/// otherwise the container is rebuilt by type.
30#[must_use]
31pub fn render_block(b: &MutBlock, depth: usize) -> String {
32    if b.children.is_empty() || !b.has_dirty_descendant() {
33        return b.raw.clone();
34    }
35    match b.kind.as_str() {
36        "list" => render_list(b),
37        "blockquote" => render_blockquote(b, depth),
38        "table" => render_table(b, depth),
39        _ => {
40            let indent = "  ".repeat(depth);
41            b.children
42                .iter()
43                .map(|c| {
44                    let inner = render_block(c, depth + 1).replace('\n', &format!("\n{indent}"));
45                    format!("{indent}{inner}")
46                })
47                .collect::<Vec<_>>()
48                .join("\n")
49        }
50    }
51}
52
53fn render_blockquote(bq: &MutBlock, depth: usize) -> String {
54    let body = bq
55        .children
56        .iter()
57        .map(|c| render_block(c, depth + 1))
58        .collect::<Vec<_>>()
59        .join("\n\n");
60    body.split('\n')
61        .map(|ln| {
62            if ln.is_empty() {
63                ">".to_owned()
64            } else {
65                format!("> {ln}")
66            }
67        })
68        .collect::<Vec<_>>()
69        .join("\n")
70}
71
72static DELIMITER_ROW: LazyLock<Regex> = LazyLock::new(|| {
73    Regex::new(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$").expect("valid regex")
74});
75
76/// §3: the raw's second line when it is a table delimiter row.
77fn table_delimiter(raw: &str) -> Option<&str> {
78    let mut lines = raw.split('\n');
79    lines.next()?;
80    let second = lines.next()?;
81    DELIMITER_ROW.is_match(second).then_some(second)
82}
83
84fn render_table(t: &MutBlock, depth: usize) -> String {
85    let mut rows: Vec<String> = t
86        .children
87        .iter()
88        .map(|c| render_block(c, depth + 1))
89        .collect();
90    if let Some(delim) = table_delimiter(&t.raw) {
91        if !rows.is_empty() {
92            rows.insert(1, delim.to_owned());
93        }
94    }
95    rows.join("\n")
96}
97
98/// §3 list rule: items re-marked and joined by the list's own separator.
99fn render_list(list: &MutBlock) -> String {
100    let ordered = list.attrs.get("ordered").is_some_and(truthy);
101    let start = list.attrs.get("start").and_then(Value::as_i64).unwrap_or(1);
102    let sep = if list.raw.contains("\n\n") {
103        "\n\n"
104    } else {
105        "\n"
106    };
107    list.children
108        .iter()
109        .enumerate()
110        .map(|(i, item)| {
111            let marker = if ordered {
112                format!("{}. ", start + i as i64)
113            } else {
114                "- ".to_owned()
115            };
116            render_item(item, &marker)
117        })
118        .collect::<Vec<_>>()
119        .join(sep)
120}
121
122/// JavaScript `Boolean(v)` for a JSON value.
123fn truthy(v: &Value) -> bool {
124    match v {
125        Value::Null => false,
126        Value::Bool(b) => *b,
127        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
128        Value::String(s) => !s.is_empty(),
129        Value::Array(_) | Value::Object(_) => true,
130    }
131}
132
133/// §3 `render_item`: a rebuilt body when a child changed, else the raw with
134/// its marker stripped; then the marker applied.
135fn render_item(item: &MutBlock, marker: &str) -> String {
136    if item.children.iter().any(MutBlock::has_dirty_descendant) {
137        let body = item
138            .children
139            .iter()
140            .map(|c| {
141                if c.kind == "list" {
142                    render_list(c)
143                } else {
144                    render_block(c, 0)
145                }
146            })
147            .collect::<Vec<_>>()
148            .join("\n");
149        return apply_marker(&body, marker);
150    }
151    apply_marker(&strip_marker(&item.raw), marker)
152}
153
154static MARKER_RE: LazyLock<Regex> =
155    LazyLock::new(|| Regex::new(r"^(\s*)([-*+]|[0-9]+[.)])(\s+)").expect("valid regex"));
156
157/// Remove the leading marker (and its indentation) from the first line, and
158/// the same width from continuation lines whose prefix of that width is blank.
159#[must_use]
160pub fn strip_marker(raw: &str) -> String {
161    let lines: Vec<&str> = raw.split('\n').collect();
162    let Some(m) = MARKER_RE.find(lines.first().copied().unwrap_or("")) else {
163        return raw.to_owned();
164    };
165    let width = m.end();
166    lines
167        .iter()
168        .enumerate()
169        .map(|(i, ln)| {
170            if i == 0 || slice_to(ln, width).trim().is_empty() {
171                slice_from(ln, width)
172            } else {
173                (*ln).to_owned()
174            }
175        })
176        .collect::<Vec<_>>()
177        .join("\n")
178}
179
180/// `s[width..]` in characters, clamped (JavaScript `slice(width)` on a
181/// string whose marker match is ASCII, so bytes and chars agree there).
182fn slice_from(s: &str, width: usize) -> String {
183    s.chars().skip(width).collect()
184}
185
186fn slice_to(s: &str, width: usize) -> String {
187    s.chars().take(width).collect()
188}
189
190/// Prefix the first line with `marker`; indent every non-empty later line by
191/// the marker's width.
192#[must_use]
193pub fn apply_marker(body: &str, marker: &str) -> String {
194    let pad = " ".repeat(marker.chars().count());
195    body.split('\n')
196        .enumerate()
197        .map(|(i, ln)| {
198            if i == 0 {
199                format!("{marker}{ln}")
200            } else if ln.is_empty() {
201                String::new()
202            } else {
203                format!("{pad}{ln}")
204            }
205        })
206        .collect::<Vec<_>>()
207        .join("\n")
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214
215    fn item(id: &str, raw: &str) -> MutBlock {
216        MutBlock::new(id, "list_item", raw, "")
217    }
218
219    fn list(raw: &str, items: Vec<MutBlock>, ordered: bool, start: Option<i64>) -> MutBlock {
220        let mut l = MutBlock::new("l", "list", raw, "\n");
221        if ordered {
222            l.attrs.insert("ordered".into(), json!(true));
223        }
224        if let Some(s) = start {
225            l.attrs.insert("start".into(), json!(s));
226        }
227        l.children = items;
228        l
229    }
230
231    #[test]
232    fn clean_blocks_render_their_raw_verbatim() {
233        let l = list(
234            "- a\n- b",
235            vec![item("a", "- a"), item("b", "- b")],
236            false,
237            None,
238        );
239        let doc = MutDoc::new("d", "a.md", vec![l]);
240        assert_eq!(render(&doc), "- a\n- b\n");
241    }
242
243    #[test]
244    fn dirty_list_renumbers_with_tight_or_loose_separator() {
245        let mut l = list(
246            "1. a\n2. b",
247            vec![item("b", "2. b"), item("a", "1. a")],
248            true,
249            None,
250        );
251        l.dirty = true;
252        assert_eq!(render_block(&l, 0), "1. b\n2. a");
253        let mut loose = list(
254            "3) a\n\n4) b",
255            vec![item("a", "3) a"), item("b", "4) b"), item("c", "c")],
256            true,
257            Some(3),
258        );
259        loose.dirty = true;
260        assert_eq!(render_block(&loose, 0), "3. a\n\n4. b\n\n5. c");
261        let mut bullets = list("* a", vec![item("a", "* a\n  cont")], false, None);
262        bullets.dirty = true;
263        assert_eq!(render_block(&bullets, 0), "- a\n  cont");
264    }
265
266    #[test]
267    fn item_with_dirty_child_is_rebuilt_from_children() {
268        let mut inner = MutBlock::new("p", "paragraph", "edited", "");
269        inner.dirty = true;
270        let mut sub = list("- x", vec![item("x", "- x")], false, None);
271        sub.dirty = false;
272        let mut it = item("a", "- old\n  - x");
273        it.children = vec![inner, sub];
274        let mut l = list("- old\n  - x", vec![it], false, None);
275        l.dirty = false;
276        assert_eq!(render_block(&l, 0), "- edited\n  - x");
277    }
278
279    #[test]
280    fn blockquote_rebuild_prefixes_every_line() {
281        let mut bq = MutBlock::new("q", "blockquote", "> a\n>\n> b", "\n");
282        let mut b = MutBlock::new("b", "paragraph", "b edited\nmore", "");
283        b.dirty = true;
284        bq.children = vec![MutBlock::new("a", "paragraph", "a", ""), b];
285        assert_eq!(render_block(&bq, 0), "> a\n>\n> b edited\n> more");
286    }
287
288    #[test]
289    fn table_rebuild_reinserts_the_delimiter_row() {
290        let mut t = MutBlock::new("t", "table", "| a | b |\n|:--|--:|\n| 1 | 2 |", "\n");
291        let mut row = MutBlock::new("r2", "table_row", "| 1 | 20 |", "");
292        row.dirty = true;
293        t.children = vec![MutBlock::new("r1", "table_row", "| a | b |", ""), row];
294        assert_eq!(render_block(&t, 0), "| a | b |\n|:--|--:|\n| 1 | 20 |");
295        assert!(table_delimiter("| a |\n| b |").is_none());
296        assert!(table_delimiter("| a |\n| --- | :---: |").is_some());
297        assert!(table_delimiter("| a |\n---").is_some());
298    }
299
300    #[test]
301    fn other_containers_indent_children() {
302        let mut it = item("a", "- a");
303        let mut p = MutBlock::new("p", "paragraph", "x\ny", "");
304        p.dirty = true;
305        it.children = vec![p];
306        assert_eq!(render_block(&it, 1), "  x\n  y");
307    }
308
309    #[test]
310    fn marker_helpers() {
311        assert_eq!(strip_marker("- a\n  b\n c"), "a\nb\n c");
312        assert_eq!(strip_marker("12. a\n    b"), "a\nb");
313        assert_eq!(strip_marker("plain"), "plain");
314        assert_eq!(apply_marker("a\n\nb", "10. "), "10. a\n\n    b");
315    }
316
317    #[test]
318    fn frontmatter_and_leading_trivia_are_emitted_first() {
319        let mut doc = MutDoc::new(
320            "d",
321            "a.md",
322            vec![MutBlock::new("h", "heading", "# H", "\n")],
323        );
324        doc.leading_trivia = "\n".into();
325        doc.frontmatter_raw = Some("---\na: 1\n---\n\n".into());
326        assert_eq!(render(&doc), "\n---\na: 1\n---\n\n# H\n");
327    }
328}