1use crate::{
2 diagnostic::{Diagnostic, Diagnostics},
3 model::*,
4};
5
6pub 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(¶graph.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}