1use std::sync::LazyLock;
7
8use regex::Regex;
9use serde_json::Value;
10
11use crate::tree::{MutBlock, MutDoc};
12
13#[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#[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
76fn 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
98fn 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
122fn 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
133fn 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#[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
180fn 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#[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}