Skip to main content

opys_engine/mdprism/
mod.rs

1//! # mdprism — markdown ⇄ data, via a template
2//!
3//! One compact schema (a textual DSL) defines a bidirectional mapping between a
4//! markdown document and a typed data object. From it you can **validate**,
5//! **extract** (parse → data), **render** (data → markdown), **scaffold**, and
6//! **edit in-place**.
7//!
8//! See `docs/structure-dsl-spec.md` for the language and `docs/mdprism-reference.md`
9//! for a worked example.
10//!
11//! ## Status
12//!
13//! Implemented: schema parser ([`parse_schema`]), [`Schema`] data model,
14//! **`scaffold`** (starter document), **`validate`** / **`extract`** (body
15//! conformance), **`render`** (data → markdown), and in-place **`edit`**. The
16//! jq-based `query` selector was dropped once corpus stats moved to SQL.
17
18mod edit;
19mod error;
20mod parse;
21mod render;
22mod schema;
23mod validate;
24
25pub use error::{EditError, Problem, RenderError, SchemaError};
26pub use parse::parse_schema;
27pub use schema::{Card, FieldSchema, FieldType, Head, ListStyle, Match, Node, Schema, SchemaOpts};
28
29#[cfg(test)]
30mod tests {
31    use super::*;
32
33    fn parse(src: &str) -> Schema {
34        parse_schema(src).expect("schema parses")
35    }
36
37    #[test]
38    fn defaults_are_strict() {
39        let s = parse("> @x");
40        assert!(s.opts.ordered && s.opts.strict && !s.opts.frontmatter_open);
41    }
42
43    #[test]
44    fn directives_override_defaults() {
45        let s = parse("%ordered = false\n%strict = false\n%frontmatter = open\n> @x");
46        assert!(!s.opts.ordered && !s.opts.strict && s.opts.frontmatter_open);
47    }
48
49    #[test]
50    fn frontmatter_types_and_alias() {
51        let s = parse(
52            "---\n\
53             title: string\n\
54             status: enum(planned, done)\n\
55             tags: [string]+\n\
56             owner?: string\n\
57             spec_url? @spec: /^https/\n\
58             ---\n",
59        );
60        let f = &s.frontmatter;
61        assert_eq!(f.len(), 5);
62        assert_eq!(f[0].key, "title");
63        assert_eq!(f[0].ty, FieldType::Str);
64        assert_eq!(
65            f[1].ty,
66            FieldType::Enum(vec!["planned".into(), "done".into()])
67        );
68        assert_eq!(f[2].ty, FieldType::List(Box::new(FieldType::Str)));
69        assert!(f[3].optional && f[3].alias == "owner");
70        // alias override + regex type
71        assert_eq!(f[4].key, "spec_url");
72        assert_eq!(f[4].alias, "spec");
73        assert!(f[4].optional);
74        assert!(matches!(f[4].ty, FieldType::Regex(_)));
75    }
76
77    #[test]
78    fn body_head_card_name_label() {
79        let s = parse(
80            "## @manual Manual verification\n\
81            \x20 ### @setup Setup\n\
82            \x20   - +@items\n\
83            \x20 ### @procedure Procedure\n\
84            \x20   1. +@steps\n\
85            \x20     - ?@note\n",
86        );
87        // top level: one heading "manual" with two child headings
88        assert_eq!(s.body.len(), 1);
89        let Node::Heading {
90            level,
91            title,
92            head,
93            children,
94        } = &s.body[0]
95        else {
96            panic!("expected heading");
97        };
98        assert_eq!(*level, 2);
99        assert_eq!(head.name.as_deref(), Some("manual"));
100        assert_eq!(*title, Match::Literal("Manual verification".into()));
101        assert_eq!(children.len(), 2);
102
103        // setup -> a "+@items" bullet list
104        let Node::Heading {
105            children: setup, ..
106        } = &children[0]
107        else {
108            panic!();
109        };
110        assert_eq!(setup.len(), 1);
111        let Node::List { style, head, .. } = &setup[0] else {
112            panic!("expected list");
113        };
114        assert_eq!(*style, ListStyle::Bullet);
115        assert_eq!(head.name.as_deref(), Some("items"));
116        assert_eq!(head.card, Card::Plus);
117
118        // procedure -> ordered list -> optional nested bullet
119        let Node::Heading { children: proc, .. } = &children[1] else {
120            panic!();
121        };
122        let Node::List {
123            style,
124            children: steps_kids,
125            ..
126        } = &proc[0]
127        else {
128            panic!();
129        };
130        assert_eq!(*style, ListStyle::Ordered);
131        assert_eq!(steps_kids.len(), 1);
132        let Node::List { head, .. } = &steps_kids[0] else {
133            panic!();
134        };
135        assert_eq!(head.card, Card::Optional);
136        assert_eq!(head.name.as_deref(), Some("note"));
137    }
138
139    #[test]
140    fn labels_regex_literal_and_bare_with_card() {
141        // regex label
142        let s = parse("# @title /.+/");
143        let Node::Heading { title, .. } = &s.body[0] else {
144            panic!();
145        };
146        assert_eq!(*title, Match::Regex(".+".into()));
147
148        // bare literal label with a leading card, no name
149        let s = parse("## + Docs:");
150        let Node::Heading { title, head, .. } = &s.body[0] else {
151            panic!();
152        };
153        assert_eq!(head.card, Card::Plus);
154        assert_eq!(head.name, None);
155        assert_eq!(*title, Match::Literal("Docs:".into()));
156
157        // range card
158        let s = parse("- {1,5}@points");
159        let Node::List { head, .. } = &s.body[0] else {
160            panic!();
161        };
162        assert_eq!(head.card, Card::Range(1, Some(5)));
163        assert_eq!(head.name.as_deref(), Some("points"));
164    }
165
166    #[test]
167    fn description_is_fenced_and_label_may_contain_dashes() {
168        let s = parse("## @t Trade-offs: speed -- vs -- safety   <? the desc ?>");
169        let Node::Heading { title, head, .. } = &s.body[0] else {
170            panic!();
171        };
172        assert_eq!(
173            *title,
174            Match::Literal("Trade-offs: speed -- vs -- safety".into())
175        );
176        assert_eq!(head.desc.as_deref(), Some("the desc"));
177    }
178
179    #[test]
180    fn escaping_leading_special() {
181        let s = parse("- \\+literal-plus");
182        let Node::List { item, head, .. } = &s.body[0] else {
183            panic!();
184        };
185        assert_eq!(head.card, Card::Required); // the '+' was escaped, not a card
186        assert_eq!(*item, Some(Match::Literal("+literal-plus".into())));
187    }
188
189    #[test]
190    fn unterminated_frontmatter_errors() {
191        let err = parse_schema("---\ntitle: string\n").unwrap_err();
192        assert!(err.message.contains("unterminated frontmatter"));
193    }
194
195    const SCHEMA: &str = "## @manual Manual verification\n\
196        \x20 ### @setup Setup\n\
197        \x20   - +@items\n\
198        \x20 ### @procedure Procedure\n\
199        \x20   1. +@steps\n\
200         ## @plan Test plan\n\
201        \x20 - [ ] +@cases\n";
202
203    #[test]
204    fn validate_accepts_a_conforming_document() {
205        let s = parse(SCHEMA);
206        let doc = "## Manual verification\n\
207            ### Setup\n\
208            - external monitor\n\n\
209            ### Procedure\n\
210            1. open a tab\n\
211            2. run it\n\n\
212            ## Test plan\n\
213            - [x] one\n\
214            - [ ] two\n";
215        let problems = s.validate(doc);
216        assert!(problems.is_empty(), "{problems:?}");
217    }
218
219    #[test]
220    fn validate_flags_missing_section_and_empty_list() {
221        let s = parse(SCHEMA);
222        // No "Test plan" section; Setup present but with no items.
223        let doc = "## Manual verification\n\
224            ### Setup\n\n\
225            ### Procedure\n\
226            1. open\n";
227        let problems = s.validate(doc);
228        let joined: Vec<String> = problems.iter().map(|p| p.to_string()).collect();
229        let all = joined.join("\n");
230        assert!(all.contains("plan"), "missing Test plan: {all}");
231        assert!(
232            all.contains("setup") && all.contains("at least one item"),
233            "empty Setup list: {all}"
234        );
235    }
236
237    #[test]
238    fn validate_respects_strict_ordering() {
239        // Schema wants Setup then Procedure; doc has them reversed.
240        let s = parse("## @m M\n  ### @setup Setup\n    > @x\n  ### @proc Procedure\n    > @y\n");
241        let reversed = "## M\n### Procedure\nbody\n\n### Setup\nbody\n";
242        let problems = s.validate(reversed);
243        assert!(!problems.is_empty(), "strict order should flag reversal");
244    }
245
246    #[test]
247    fn extract_builds_the_capture_object() {
248        let s = parse(SCHEMA);
249        let doc = "## Manual verification\n\
250            ### Setup\n\
251            - external monitor\n\n\
252            ### Procedure\n\
253            1. open a tab\n\
254            2. run it\n\n\
255            ## Test plan\n\
256            - [x] one\n\
257            - [ ] two\n";
258        let v = s.extract(doc).expect("conforms");
259        assert_eq!(v["manual"]["setup"]["items"][0], "external monitor");
260        assert_eq!(v["manual"]["procedure"]["steps"][1], "run it");
261        assert_eq!(v["plan"]["cases"], serde_json::json!(["one", "two"]));
262    }
263
264    #[test]
265    fn extract_errors_on_nonconforming() {
266        let s = parse(SCHEMA);
267        let bad = "## Manual verification\n### Setup\n\n### Procedure\n1. x\n";
268        assert!(s.extract(bad).is_err());
269    }
270
271    #[test]
272    fn scaffold_emits_required_only() {
273        let s = parse(
274            "---\n\
275             title: string\n\
276             status: enum(planned, done)\n\
277             tags: [string]+\n\
278             owner?: string\n\
279             ---\n\
280             ## @manual Manual verification\n\
281            \x20 ### @setup Setup\n\
282            \x20   - +@items\n\
283             ## ?@refs References\n\
284            \x20 - *@links\n",
285        );
286        let out = s.scaffold();
287        assert!(out.contains("title: \n"), "{out}");
288        assert!(out.contains("status: planned\n"), "{out}"); // first enum value
289        assert!(out.contains("tags: []\n"), "{out}");
290        assert!(!out.contains("owner"), "optional key omitted: {out}");
291        assert!(out.contains("## Manual verification\n"), "{out}");
292        assert!(out.contains("### Setup\n"), "{out}");
293        assert!(out.contains("- \n") || out.contains("- "), "{out}");
294        assert!(
295            !out.contains("References"),
296            "optional heading omitted: {out}"
297        );
298    }
299
300    #[test]
301    fn render_produces_conforming_document() {
302        let s = parse(SCHEMA);
303        let data = serde_json::json!({
304            "manual": {
305                "setup": { "items": ["external monitor", "test device"] },
306                "procedure": { "steps": ["open a tab", "run it"] }
307            },
308            "plan": { "cases": ["smoke", "edge case"] }
309        });
310        let md = s.render(&data).expect("renders");
311        // Re-validate the rendered output
312        let problems = s.validate(&md);
313        assert!(
314            problems.is_empty(),
315            "rendered doc fails validate: {problems:?}\n---\n{md}"
316        );
317    }
318
319    #[test]
320    fn render_errors_on_missing_required_field() {
321        let s = parse(SCHEMA);
322        // Missing "plan" (required section)
323        let data = serde_json::json!({
324            "manual": {
325                "setup": { "items": ["a"] },
326                "procedure": { "steps": ["b"] }
327            }
328        });
329        let err = s.render(&data).unwrap_err();
330        assert!(matches!(err, RenderError::MissingField(_)));
331    }
332
333    #[test]
334    fn round_trip_preserves_markdown_significant_text() {
335        // The codec must be a lossless fixpoint for text comrak would otherwise
336        // mangle. Two classes bit vikno's real docs (regression: 55/1214
337        // structured sections lost data):
338        //   - angle brackets: comrak parses `<uuid>` as raw inline HTML (dropped
339        //     unless extract keeps the HtmlInline literal);
340        //   - backslashes: comrak treats `\x` as an escape (dropped unless render
341        //     re-emits a literal `\` as `\\`).
342        let schema = parse("- +@items\n");
343        // Backslashes arrive verbatim via a code span (`printf '\033\\x'`);
344        // `<ctrl>` is bare, which comrak parses as raw inline HTML.
345        let md = "- run `printf '\\033\\\\x'` then press <ctrl>c\n";
346        let data1 = schema.extract(md).expect("extract");
347        let text = data1["items"][0].as_str().unwrap();
348        assert!(text.contains("<ctrl>c"), "angle-bracket text lost: {text}");
349        assert!(text.contains("\\033\\\\x"), "backslashes lost: {text}");
350        // The codec is a lossless fixpoint: render then re-extract is identical.
351        let rendered = schema.render(&data1).expect("render");
352        let data2 = schema.extract(&rendered).expect("re-extract");
353        assert_eq!(data1, data2);
354    }
355
356    #[test]
357    fn edit_replaces_list_item_text() {
358        let s = parse(SCHEMA);
359        let doc = "## Manual verification\n\
360            ### Setup\n\
361            - external monitor\n\n\
362            ### Procedure\n\
363            1. open a tab\n\
364            2. run it\n\n\
365            ## Test plan\n\
366            - [x] one\n\
367            - [ ] two\n";
368        // Replace the first setup item.
369        let edited = s
370            .edit(doc, "manual.setup.items.0", "projector")
371            .expect("edit succeeds");
372        assert!(edited.contains("- projector\n"), "item replaced: {edited}");
373        assert!(
374            !edited.contains("external monitor"),
375            "old text gone: {edited}"
376        );
377        // Rest of the document is untouched.
378        assert!(edited.contains("open a tab"), "rest preserved: {edited}");
379        // Re-validates.
380        let problems = s.validate(&edited);
381        assert!(problems.is_empty(), "edited doc conforms: {problems:?}");
382    }
383
384    #[test]
385    fn edit_replaces_checklist_item_preserving_checkbox() {
386        let s = parse(SCHEMA);
387        let doc = "## Manual verification\n\
388            ### Setup\n\
389            - a\n\n\
390            ### Procedure\n\
391            1. step\n\n\
392            ## Test plan\n\
393            - [x] old case\n\
394            - [ ] second\n";
395        let edited = s
396            .edit(doc, "plan.cases.0", "new case")
397            .expect("edit succeeds");
398        // The `[x]` prefix must be preserved.
399        assert!(
400            edited.contains("- [x] new case\n"),
401            "checkbox preserved: {edited}"
402        );
403        assert!(!edited.contains("old case"), "old text gone: {edited}");
404    }
405
406    #[test]
407    fn edit_returns_target_not_found_for_bad_path() {
408        let s = parse(SCHEMA);
409        let doc = "## Manual verification\n\
410            ### Setup\n\
411            - a\n\n\
412            ### Procedure\n\
413            1. b\n\n\
414            ## Test plan\n\
415            - [ ] c\n";
416        let err = s.edit(doc, "nonexistent.path", "x").unwrap_err();
417        assert!(matches!(err, EditError::TargetNotFound));
418    }
419}