Skip to main content

weimo_wdf/
validate.rs

1use crate::{
2    diagnostic::{Diagnostic, Diagnostics},
3    model::*,
4};
5
6/// Validates WDF JSON and returns the canonical [`WdfDocument`].
7///
8/// This is the only supported entry point for untrusted WDF input. It checks
9/// the `schema`/`version` envelope, rejects unknown properties, nodes, marks
10/// and structurally invalid content (empty documents/lists, heading levels
11/// outside 1–6, list items that are not exactly one paragraph), and then
12/// canonicalizes:
13///
14/// - `\r\n` and `\r` newlines become `\n`;
15/// - empty text runs are dropped;
16/// - adjacent runs with equal marks are merged;
17/// - ordered lists get an explicit `attrs: {"start": 1}` when omitted.
18///
19/// Every violation is returned as a [`Diagnostic`] in the [`Diagnostics`]
20/// list — never as a panic.
21///
22/// ```
23/// use serde_json::json;
24/// use weimo_wdf::validate_and_canonicalize;
25///
26/// let input = json!({
27///     "schema": "weimo.document",
28///     "version": 1,
29///     "root": { "type": "doc", "content": [
30///         { "type": "paragraph", "content": [
31///             { "type": "text", "text": "hello" }
32///         ]}
33///     ]}
34/// });
35/// let doc = validate_and_canonicalize(&input).expect("valid WDF");
36/// assert_eq!(doc.root.content.len(), 1);
37/// ```
38pub fn validate_and_canonicalize(value: &serde_json::Value) -> Result<WdfDocument, Diagnostics> {
39    let mut diagnostics = Vec::new();
40    reject_unknown_properties(value, &mut diagnostics, &[]);
41    let document = match serde_json::from_value::<WdfDocument>(value.clone()) {
42        Ok(document) => document,
43        Err(error) => {
44            diagnostics.push(Diagnostic::error(
45                "wdf",
46                "wdf.invalid_document",
47                error.to_string(),
48            ));
49            return Err(diagnostics);
50        }
51    };
52    if document.schema != WDF_SCHEMA {
53        diagnostics.push(Diagnostic::error(
54            "wdf",
55            "wdf.invalid_schema",
56            "schema must be weimo.document",
57        ));
58    }
59    if document.version != WDF_VERSION {
60        diagnostics.push(Diagnostic::error(
61            "wdf",
62            "wdf.invalid_version",
63            "version must be 1",
64        ));
65    }
66    if document.root.node_type != "doc" {
67        diagnostics.push(Diagnostic::error(
68            "wdf",
69            "wdf.invalid_root",
70            "root type must be doc",
71        ));
72    }
73    if document.root.content.is_empty() {
74        diagnostics.push(Diagnostic::error(
75            "wdf",
76            "wdf.empty_document",
77            "document must contain at least one block",
78        ));
79    }
80    validate_blocks(&document.root.content, &mut diagnostics);
81    if diagnostics.is_empty() {
82        Ok(canonicalize(document))
83    } else {
84        Err(diagnostics)
85    }
86}
87
88fn reject_unknown_properties(
89    value: &serde_json::Value,
90    diagnostics: &mut Diagnostics,
91    path: &[serde_json::Value],
92) {
93    let Some(object) = value.as_object() else {
94        return;
95    };
96    let is_mark_object = path.len() >= 2 && path[path.len() - 2].as_str() == Some("marks");
97    let allowed: &[&str] = if is_mark_object {
98        &["type"]
99    } else {
100        match path.last().and_then(serde_json::Value::as_str) {
101            Some("root") => &["type", "content"],
102            Some("attrs") => &["level", "start"],
103            Some("marks") => &["type"],
104            _ => match object.get("type").and_then(serde_json::Value::as_str) {
105                Some("paragraph") | Some("list_item") => &["type", "content"],
106                Some("heading") => &["type", "attrs", "content"],
107                Some("blockquote") | Some("bullet_list") => &["type", "content"],
108                Some("ordered_list") => &["type", "attrs", "content"],
109                Some("text") => &["type", "text", "marks"],
110                Some("doc") => &["type", "content"],
111                Some("bold") => &["type"],
112                _ => &["schema", "version", "root"],
113            },
114        }
115    };
116    for key in object.keys() {
117        if !allowed.contains(&key.as_str()) {
118            diagnostics.push(
119                Diagnostic::error(
120                    "wdf",
121                    "wdf.unknown_property",
122                    format!("unknown property {key}"),
123                )
124                .at(path
125                    .iter()
126                    .cloned()
127                    .chain(std::iter::once(key.clone().into()))
128                    .collect()),
129            );
130        }
131    }
132    for (key, child) in object {
133        let mut child_path = path.to_vec();
134        child_path.push(key.clone().into());
135        match key.as_str() {
136            "root" | "attrs" | "marks" => {
137                reject_unknown_properties(child, diagnostics, &child_path)
138            }
139            "content" => {
140                if let Some(array) = child.as_array() {
141                    for (index, item) in array.iter().enumerate() {
142                        let mut item_path = child_path.clone();
143                        item_path.push(index.into());
144                        reject_unknown_properties(item, diagnostics, &item_path);
145                    }
146                }
147            }
148            _ => {}
149        }
150    }
151}
152
153fn validate_blocks(blocks: &[WdfBlock], diagnostics: &mut Diagnostics) {
154    for (index, block) in blocks.iter().enumerate() {
155        match block {
156            WdfBlock::Paragraph { content } => validate_inline(
157                content,
158                diagnostics,
159                vec!["root".into(), "content".into(), index.into()],
160            ),
161            WdfBlock::Heading { attrs, content } => {
162                if !(1..=6).contains(&attrs.level) {
163                    diagnostics.push(
164                        Diagnostic::error(
165                            "wdf",
166                            "wdf.invalid_heading_level",
167                            "heading level must be between 1 and 6",
168                        )
169                        .at(vec![
170                            "root".into(),
171                            "content".into(),
172                            index.into(),
173                            "attrs".into(),
174                            "level".into(),
175                        ]),
176                    );
177                }
178                validate_inline(
179                    content,
180                    diagnostics,
181                    vec!["root".into(), "content".into(), index.into()],
182                );
183            }
184            WdfBlock::Blockquote { content } => {
185                if content.is_empty() {
186                    diagnostics.push(Diagnostic::error(
187                        "wdf",
188                        "wdf.invalid_blockquote",
189                        "blockquote must contain a paragraph",
190                    ));
191                }
192                for paragraph in content {
193                    validate_paragraph(paragraph, diagnostics);
194                }
195            }
196            WdfBlock::BulletList { content } => validate_list(content, None, diagnostics),
197            WdfBlock::OrderedList { attrs, content } => {
198                validate_list(content, attrs.as_ref().map(|a| a.start), diagnostics)
199            }
200        }
201    }
202}
203
204fn validate_paragraph(paragraph: &WdfParagraph, diagnostics: &mut Diagnostics) {
205    if paragraph.node_type != "paragraph" {
206        diagnostics.push(Diagnostic::error(
207            "wdf",
208            "wdf.invalid_paragraph",
209            "paragraph type must be paragraph",
210        ));
211    }
212    validate_inline(&paragraph.content, diagnostics, vec![]);
213}
214
215fn validate_list(items: &[WdfListItem], start: Option<u32>, diagnostics: &mut Diagnostics) {
216    if items.is_empty() {
217        diagnostics.push(Diagnostic::error(
218            "wdf",
219            "wdf.empty_list",
220            "list must contain at least one list item",
221        ));
222    }
223    if let Some(start) = start.filter(|value| *value == 0) {
224        let _ = start;
225        diagnostics.push(Diagnostic::error(
226            "wdf",
227            "wdf.invalid_list_start",
228            "ordered list start must be at least 1",
229        ));
230    }
231    for item in items {
232        if item.node_type != "list_item" || item.content.len() != 1 {
233            diagnostics.push(Diagnostic::error(
234                "wdf",
235                "wdf.invalid_list_item",
236                "list item must contain exactly one paragraph",
237            ));
238            continue;
239        }
240        validate_paragraph(&item.content[0], diagnostics);
241    }
242}
243
244fn validate_inline(
245    content: &[WdfText],
246    diagnostics: &mut Diagnostics,
247    _path: Vec<serde_json::Value>,
248) {
249    for text in content {
250        if text.node_type != "text" {
251            diagnostics.push(Diagnostic::error(
252                "wdf",
253                "wdf.invalid_text_node",
254                "inline node type must be text",
255            ));
256        }
257        if let Some(marks) = &text.marks
258            && (marks.len() > 1 || marks.iter().any(|mark| mark.mark_type != "bold"))
259        {
260            diagnostics.push(Diagnostic::error(
261                "wdf",
262                "wdf.unsupported_mark",
263                "only one bold mark is supported",
264            ));
265        }
266    }
267}
268
269fn canonicalize(mut document: WdfDocument) -> WdfDocument {
270    document.schema = WDF_SCHEMA.to_string();
271    document.version = WDF_VERSION;
272    document.root.node_type = "doc".to_string();
273    document.root.content = document
274        .root
275        .content
276        .into_iter()
277        .map(canonical_block)
278        .collect();
279    document
280}
281
282fn canonical_block(block: WdfBlock) -> WdfBlock {
283    match block {
284        WdfBlock::Paragraph { content } => WdfBlock::Paragraph {
285            content: canonical_inline(content),
286        },
287        WdfBlock::Heading { attrs, content } => WdfBlock::Heading {
288            attrs,
289            content: canonical_inline(content),
290        },
291        WdfBlock::Blockquote { content } => WdfBlock::Blockquote {
292            content: content.into_iter().map(canonical_paragraph).collect(),
293        },
294        WdfBlock::BulletList { content } => WdfBlock::BulletList {
295            content: content.into_iter().map(canonical_item).collect(),
296        },
297        WdfBlock::OrderedList { attrs, content } => WdfBlock::OrderedList {
298            attrs: Some(OrderedListAttrs {
299                start: attrs.map_or(1, |a| a.start),
300            }),
301            content: content.into_iter().map(canonical_item).collect(),
302        },
303    }
304}
305
306fn canonical_paragraph(paragraph: WdfParagraph) -> WdfParagraph {
307    WdfParagraph {
308        node_type: "paragraph".to_string(),
309        content: canonical_inline(paragraph.content),
310    }
311}
312fn canonical_item(item: WdfListItem) -> WdfListItem {
313    WdfListItem {
314        node_type: "list_item".to_string(),
315        content: vec![canonical_paragraph(
316            item.content
317                .into_iter()
318                .next()
319                .unwrap_or_else(|| paragraph(vec![])),
320        )],
321    }
322}
323
324fn canonical_inline(content: Vec<WdfText>) -> Vec<WdfText> {
325    let mut result: Vec<WdfText> = Vec::new();
326    for mut text in content {
327        text.text = text.text.replace("\r\n", "\n").replace('\r', "\n");
328        if text.text.is_empty() {
329            continue;
330        }
331        text.node_type = "text".to_string();
332        if text.is_bold() {
333            text.marks = Some(vec![WdfMark {
334                mark_type: "bold".to_string(),
335            }]);
336        } else {
337            text.marks = None;
338        }
339        if let Some(previous) = result.last_mut()
340            && previous.is_bold() == text.is_bold()
341        {
342            previous.text.push_str(&text.text);
343            continue;
344        }
345        result.push(text);
346    }
347    result
348}