Skip to main content

opys_engine/mdprism/
render.rs

1//! Rendering: `scaffold` (schema → starter document) and `render` (data → document).
2
3use super::error::RenderError;
4use super::schema::*;
5use serde_json::Value;
6use std::fmt::Write;
7
8impl Schema {
9    /// Emit a conforming starter document: required frontmatter keys with typed
10    /// placeholders, headings, and one placeholder item per required list.
11    /// Optional nodes (`?`/`*`/min-0) are omitted.
12    pub fn scaffold(&self) -> String {
13        let mut out = String::new();
14
15        if !self.frontmatter.is_empty() {
16            out.push_str("---\n");
17            for f in &self.frontmatter {
18                if f.optional {
19                    continue;
20                }
21                let _ = writeln!(out, "{}: {}", f.key, fm_placeholder(&f.ty));
22            }
23            out.push_str("---\n\n");
24        }
25
26        scaffold_nodes(&self.body, &mut out, 0);
27        // Trim trailing blank lines to a single newline.
28        while out.ends_with("\n\n") {
29            out.pop();
30        }
31        out
32    }
33
34    /// Render a conforming markdown document from `data` — the inverse of
35    /// [`Schema::extract`]. `data` is a JSON object keyed by the capture aliases
36    /// declared in the schema.
37    ///
38    /// Returns [`RenderError::MissingField`] if a required field is absent from
39    /// `data`, or [`RenderError::WrongType`] if a value has an incompatible JSON
40    /// type.
41    pub fn render(&self, data: &Value) -> Result<String, RenderError> {
42        let mut out = String::new();
43
44        if !self.frontmatter.is_empty() {
45            out.push_str("---\n");
46            if let Value::Object(obj) = data {
47                for f in &self.frontmatter {
48                    match obj.get(&f.alias) {
49                        Some(v) if !v.is_null() => {
50                            let _ = writeln!(out, "{}: {}", f.key, render_fm_value(v));
51                        }
52                        None if !f.optional => {
53                            return Err(RenderError::MissingField(f.alias.clone()));
54                        }
55                        _ => {} // null or optional-missing → skip
56                    }
57                }
58            } else {
59                // data is not an object — check if any required frontmatter fields exist
60                if let Some(f) = self.frontmatter.iter().find(|f| !f.optional) {
61                    return Err(RenderError::MissingField(f.alias.clone()));
62                }
63            }
64            out.push_str("---\n\n");
65        }
66
67        render_nodes(&self.body, data, &mut out, 0)?;
68
69        // Trim trailing blank lines to a single newline.
70        while out.ends_with("\n\n") {
71            out.pop();
72        }
73
74        Ok(out)
75    }
76}
77
78// ---- render helpers ---------------------------------------------------------
79
80fn render_nodes(
81    nodes: &[Node],
82    scope: &Value,
83    out: &mut String,
84    list_indent: usize,
85) -> Result<(), RenderError> {
86    for node in nodes {
87        let Some(key) = node_alias(node) else {
88            continue;
89        };
90        let value = scope.get(&key).unwrap_or(&Value::Null);
91        render_node(node, &key, value, out, list_indent)?;
92    }
93    Ok(())
94}
95
96fn render_node(
97    node: &Node,
98    key: &str,
99    value: &Value,
100    out: &mut String,
101    list_indent: usize,
102) -> Result<(), RenderError> {
103    match node {
104        Node::Heading {
105            level,
106            title,
107            head,
108            children,
109        } => {
110            if value.is_null() {
111                if !omitted(head.card) {
112                    return Err(RenderError::MissingField(key.to_string()));
113                }
114                return Ok(());
115            }
116            let hashes = "#".repeat(*level as usize);
117            if is_repeated(head.card) {
118                let arr = value.as_array().ok_or_else(|| RenderError::WrongType {
119                    field: key.to_string(),
120                    expected: "array",
121                })?;
122                for item in arr {
123                    let t = heading_title(title, item);
124                    let _ = writeln!(out, "{hashes} {t}");
125                    render_nodes(children, item, out, 0)?;
126                    out.push('\n');
127                }
128            } else {
129                let t = heading_title(title, value);
130                let _ = writeln!(out, "{hashes} {t}");
131                render_nodes(children, value, out, 0)?;
132                out.push('\n');
133            }
134        }
135
136        Node::List {
137            style,
138            item: label,
139            head,
140            children,
141        } => {
142            if value.is_null() {
143                if !omitted(head.card) {
144                    return Err(RenderError::MissingField(key.to_string()));
145                }
146                return Ok(());
147            }
148            let pad = " ".repeat(list_indent);
149            let marker = match style {
150                ListStyle::Bullet => "- ",
151                ListStyle::Ordered => "1. ",
152                ListStyle::Checklist => "- [ ] ",
153            };
154            let label_prefix = match label {
155                Some(Match::Literal(l)) => format!("{l} "),
156                _ => String::new(),
157            };
158
159            let single = matches!(head.card, Card::Required | Card::Optional);
160            if single && children.is_empty() {
161                // single labeled item → value is a String
162                let text = esc_inline(value.as_str().unwrap_or(""));
163                let _ = writeln!(out, "{pad}{marker}{label_prefix}{text}");
164            } else {
165                match value {
166                    Value::Array(arr) => {
167                        for item_val in arr {
168                            if children.is_empty() {
169                                let text = esc_inline(item_val.as_str().unwrap_or(""));
170                                let _ = writeln!(out, "{pad}{marker}{label_prefix}{text}");
171                            } else {
172                                let text = esc_inline(
173                                    item_val
174                                        .get("text")
175                                        .and_then(Value::as_str)
176                                        .or_else(|| item_val.as_str())
177                                        .unwrap_or(""),
178                                );
179                                let _ = writeln!(out, "{pad}{marker}{label_prefix}{text}");
180                                render_nodes(children, item_val, out, list_indent + 2)?;
181                            }
182                        }
183                    }
184                    Value::String(s) => {
185                        // single value where array was expected — render as one item
186                        let text = esc_inline(s);
187                        let _ = writeln!(out, "{pad}{marker}{label_prefix}{text}");
188                    }
189                    _ => {
190                        return Err(RenderError::WrongType {
191                            field: key.to_string(),
192                            expected: "array or string",
193                        });
194                    }
195                }
196            }
197        }
198
199        Node::Prose { head, .. } => {
200            if value.is_null() {
201                if !omitted(head.card) {
202                    return Err(RenderError::MissingField(key.to_string()));
203                }
204                return Ok(());
205            }
206            let text = esc_inline(value.as_str().unwrap_or(""));
207            let _ = writeln!(out, "{text}");
208            out.push('\n');
209        }
210    }
211    Ok(())
212}
213
214/// Escape captured text so that re-parsing it (extract) yields the identical
215/// string — the codec must satisfy `extract(render(x)) == x`. The critical case
216/// is the backslash: comrak treats `\x` as an escape and drops the backslash, so
217/// a literal `\` is emitted as `\\`. (Angle-bracket text like `<uuid>`, which
218/// comrak parses as raw inline HTML, round-trips via `HtmlInline` preservation on
219/// the extract side, so it needs no escaping here.)
220fn esc_inline(s: &str) -> String {
221    s.replace('\\', "\\\\")
222}
223
224/// The literal title to use for a heading: use the schema literal, or fall
225/// back to `value["title"]` for regex headings.
226fn heading_title(title: &Match, value: &Value) -> String {
227    match title {
228        Match::Literal(s) => s.clone(),
229        Match::Regex(_) => value
230            .get("title")
231            .and_then(Value::as_str)
232            .unwrap_or("")
233            .to_string(),
234    }
235}
236
237/// The alias a node captures under for rendering (same logic as extraction).
238fn node_alias(node: &Node) -> Option<String> {
239    match node {
240        Node::Heading { head, title, .. } => head.name.clone().or_else(|| match title {
241            Match::Literal(t) => Some(slug(t)),
242            Match::Regex(_) => None,
243        }),
244        Node::List { head, .. } | Node::Prose { head, .. } => head.name.clone(),
245    }
246}
247
248fn slug(s: &str) -> String {
249    s.chars()
250        .map(|c| {
251            if c.is_ascii_alphanumeric() {
252                c.to_ascii_lowercase()
253            } else {
254                '_'
255            }
256        })
257        .collect::<String>()
258        .trim_matches('_')
259        .to_string()
260}
261
262/// A node is omitted from scaffold/render when it isn't required to be present.
263fn omitted(card: Card) -> bool {
264    matches!(card, Card::Optional | Card::Star | Card::Range(0, _))
265}
266
267/// Cardinality that produces multiple values (array in extracted data).
268fn is_repeated(card: Card) -> bool {
269    matches!(card, Card::Plus | Card::Star | Card::Range(..))
270}
271
272// ---- scaffold helpers -------------------------------------------------------
273
274fn fm_placeholder(ty: &FieldType) -> String {
275    match ty {
276        FieldType::Enum(vals) => vals.first().cloned().unwrap_or_default(),
277        FieldType::List(_) => "[]".to_string(),
278        _ => String::new(),
279    }
280}
281
282fn scaffold_nodes(nodes: &[Node], out: &mut String, indent: usize) {
283    for node in nodes {
284        match node {
285            Node::Heading {
286                level,
287                title,
288                head,
289                children,
290            } => {
291                if omitted(head.card) {
292                    continue;
293                }
294                let hashes = "#".repeat(*level as usize);
295                let _ = writeln!(out, "{hashes} {}", literal_of(title)).map(|_| ());
296                scaffold_nodes(children, out, 0);
297                out.push('\n');
298            }
299            Node::List {
300                style,
301                item,
302                head,
303                children,
304            } => {
305                if omitted(head.card) {
306                    continue;
307                }
308                let pad = " ".repeat(indent);
309                let marker = match style {
310                    ListStyle::Bullet => "- ",
311                    ListStyle::Ordered => "1. ",
312                    ListStyle::Checklist => "- [ ] ",
313                };
314                let lbl = item.as_ref().map(literal_of).unwrap_or_default();
315                let _ = writeln!(out, "{pad}{marker}{lbl}");
316                scaffold_nodes(children, out, indent + 2);
317            }
318            Node::Prose { head, text } => {
319                if omitted(head.card) {
320                    continue;
321                }
322                let lbl = text.as_ref().map(literal_of).unwrap_or_default();
323                let _ = writeln!(out, "{lbl}");
324            }
325        }
326    }
327}
328
329fn render_fm_value(v: &Value) -> String {
330    match v {
331        Value::String(s) => s.clone(),
332        Value::Bool(b) => b.to_string(),
333        Value::Number(n) => n.to_string(),
334        Value::Array(arr) => {
335            let items: Vec<String> = arr.iter().map(render_fm_value).collect();
336            format!("[{}]", items.join(", "))
337        }
338        Value::Null => String::new(),
339        Value::Object(_) => v.to_string(),
340    }
341}
342
343/// The literal text of a match for scaffolding; a regex contributes no text.
344fn literal_of(m: &Match) -> String {
345    match m {
346        Match::Literal(s) => s.clone(),
347        Match::Regex(_) => String::new(),
348    }
349}