Skip to main content

typub_passes/
validate_document.rs

1//! Validation pass for v2 semantic document IR.
2
3use anyhow::{Result, bail};
4use std::collections::BTreeSet;
5use typub_ir::{
6    AssetId, Block, Document, FootnoteId, Inline, MathPayload, MathSource, RenderedArtifact,
7    SvgPayload,
8};
9
10use super::walk::{NodePath, VisitorMut, walk_document_mut};
11use super::{Diagnostic, Pass, PassCtx};
12
13#[derive(Debug, Default)]
14pub struct ValidateDocumentPass;
15
16impl Pass for ValidateDocumentPass {
17    fn name(&self) -> &'static str {
18        "validate_document"
19    }
20
21    fn run(&mut self, doc: &mut Document, ctx: &mut PassCtx) -> Result<()> {
22        validate_document_with_ctx(doc, ctx)
23    }
24}
25
26/// Validate a document and return an error if any issue is found.
27pub fn validate_document(doc: &Document) -> Result<()> {
28    let mut cloned = doc.clone();
29    let mut ctx = PassCtx::default();
30    validate_document_with_ctx(&mut cloned, &mut ctx)
31}
32
33fn validate_document_with_ctx(doc: &mut Document, ctx: &mut PassCtx) -> Result<()> {
34    let asset_ids: BTreeSet<AssetId> = doc.assets.keys().cloned().collect();
35    let footnote_ids: BTreeSet<FootnoteId> = doc.footnotes.keys().cloned().collect();
36
37    let mut validator = Validator {
38        asset_ids,
39        footnote_ids,
40        issues: Vec::new(),
41    };
42    walk_document_mut(doc, &mut validator)?;
43
44    if validator.issues.is_empty() {
45        return Ok(());
46    }
47
48    for issue in &validator.issues {
49        ctx.push_diagnostic(Diagnostic::error(
50            "validate_document",
51            issue.message.clone(),
52            Some(issue.location.clone()),
53        ));
54    }
55
56    let details = validator
57        .issues
58        .iter()
59        .map(|issue| format!("{}: {}", issue.location, issue.message))
60        .collect::<Vec<_>>()
61        .join("; ");
62    bail!(
63        "validation failed with {} issue(s): {}",
64        validator.issues.len(),
65        details
66    );
67}
68
69#[derive(Debug)]
70struct ValidationIssue {
71    location: String,
72    message: String,
73}
74
75struct Validator {
76    asset_ids: BTreeSet<AssetId>,
77    footnote_ids: BTreeSet<FootnoteId>,
78    issues: Vec<ValidationIssue>,
79}
80
81impl Validator {
82    fn issue(&mut self, path: &NodePath, message: impl Into<String>) {
83        self.issues.push(ValidationIssue {
84            location: path.render(),
85            message: message.into(),
86        });
87    }
88}
89
90impl VisitorMut for Validator {
91    fn visit_block(&mut self, block: &mut Block, path: &NodePath) -> Result<()> {
92        match block {
93            Block::Heading { level, .. } => {
94                if !(1..=6).contains(&level.get()) {
95                    self.issue(
96                        path,
97                        format!("heading level {} out of range 1..=6", level.get()),
98                    );
99                }
100            }
101            Block::MathBlock { math, .. } => validate_math_payload(math, path, self),
102            Block::SvgBlock { svg, .. } => validate_svg_payload(svg, path, self),
103            _ => {}
104        }
105        Ok(())
106    }
107
108    fn visit_inline(&mut self, inline: &mut Inline, path: &NodePath) -> Result<()> {
109        match inline {
110            Inline::Image { asset, .. } => {
111                if !self.asset_ids.contains(&asset.0) {
112                    self.issue(
113                        path,
114                        format!("unresolvable asset reference '{}'", asset.0.0),
115                    );
116                }
117            }
118            Inline::FootnoteRef(id) => {
119                if !self.footnote_ids.contains(id) {
120                    self.issue(path, format!("unresolvable footnote reference '{}'", id.0));
121                }
122            }
123            Inline::MathInline { math, .. } => validate_math_payload(math, path, self),
124            Inline::SvgInline { svg, .. } => validate_svg_payload(svg, path, self),
125            _ => {}
126        }
127        Ok(())
128    }
129}
130
131fn validate_math_payload(math: &MathPayload, path: &NodePath, validator: &mut Validator) {
132    if let Some(src) = &math.src {
133        match src {
134            MathSource::Typst(src) => {
135                if src.trim().is_empty() {
136                    validator.issue(path, "empty typst math source");
137                }
138            }
139            MathSource::Latex(src) => {
140                if src.trim().is_empty() {
141                    validator.issue(path, "empty latex math source");
142                }
143            }
144            MathSource::Custom { src, .. } => {
145                if src.trim().is_empty() {
146                    validator.issue(path, "empty custom math source");
147                }
148            }
149        }
150    }
151
152    if !math.has_source_or_rendered() {
153        validator.issue(path, "math payload must contain source or rendered payload");
154    }
155
156    validate_rendered_artifact(math.rendered.as_ref(), path, validator);
157}
158
159fn validate_svg_payload(svg: &SvgPayload, path: &NodePath, validator: &mut Validator) {
160    if !svg.has_source_or_rendered() {
161        validator.issue(path, "svg payload must contain source or rendered payload");
162    }
163
164    validate_rendered_artifact(svg.rendered.as_ref(), path, validator);
165}
166
167fn validate_rendered_artifact(
168    rendered: Option<&RenderedArtifact>,
169    path: &NodePath,
170    validator: &mut Validator,
171) {
172    if let Some(rendered) = rendered {
173        match rendered {
174            RenderedArtifact::Svg(svg) => {
175                if svg.trim().is_empty() {
176                    validator.issue(path, "empty rendered svg payload");
177                }
178            }
179            RenderedArtifact::MathMl(mathml) => {
180                if mathml.trim().is_empty() {
181                    validator.issue(path, "empty rendered mathml payload");
182                }
183            }
184            RenderedArtifact::Asset {
185                asset,
186                width,
187                height,
188                ..
189            } => {
190                if !validator.asset_ids.contains(&asset.0) {
191                    validator.issue(
192                        path,
193                        format!("unresolvable rendered-asset reference '{}'", asset.0.0),
194                    );
195                }
196                if width.is_some_and(|w| w == 0) {
197                    validator.issue(path, "rendered asset width must be > 0 when present");
198                }
199                if height.is_some_and(|h| h == 0) {
200                    validator.issue(path, "rendered asset height must be > 0 when present");
201                }
202            }
203            RenderedArtifact::Custom { data, .. } => {
204                if data.is_empty() {
205                    validator.issue(path, "empty rendered custom payload");
206                }
207            }
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    #![allow(clippy::expect_used)]
215
216    use super::*;
217    use typub_ir::{
218        Asset, AssetRef, AssetSource, BlockAttrs, DocMeta, Document, FootnoteId, ImageAsset,
219        ImageAttrs, InlineAttrs, MathPayload, RelativePath,
220    };
221
222    fn empty_doc(blocks: Vec<Block>) -> Document {
223        Document {
224            blocks,
225            footnotes: Default::default(),
226            assets: Default::default(),
227            meta: DocMeta::default(),
228        }
229    }
230
231    #[test]
232    fn validate_document_ok_for_simple_paragraph() {
233        let doc = empty_doc(vec![Block::Paragraph {
234            content: vec![Inline::Text("ok".to_string())],
235            attrs: BlockAttrs::default(),
236        }]);
237        assert!(validate_document(&doc).is_ok());
238    }
239
240    #[test]
241    fn validate_document_rejects_missing_asset_ref() {
242        let doc = empty_doc(vec![Block::Paragraph {
243            content: vec![Inline::Image {
244                asset: AssetRef(AssetId("missing".to_string())),
245                alt: String::new(),
246                title: None,
247                attrs: ImageAttrs::default(),
248            }],
249            attrs: BlockAttrs::default(),
250        }]);
251        assert!(validate_document(&doc).is_err());
252    }
253
254    #[test]
255    fn validate_document_accepts_resolved_asset_ref() {
256        let mut doc = empty_doc(vec![Block::Paragraph {
257            content: vec![Inline::Image {
258                asset: AssetRef(AssetId("asset-1".to_string())),
259                alt: String::new(),
260                title: None,
261                attrs: ImageAttrs::default(),
262            }],
263            attrs: BlockAttrs::default(),
264        }]);
265        doc.assets.insert(
266            AssetId("asset-1".to_string()),
267            Asset::Image(ImageAsset {
268                source: AssetSource::LocalPath {
269                    path: RelativePath::new("img/a.png".to_string()).expect("relative path"),
270                },
271                meta: None,
272                variants: Vec::new(),
273            }),
274        );
275        assert!(validate_document(&doc).is_ok());
276    }
277
278    #[test]
279    fn validate_document_rejects_empty_math_source() {
280        let doc = empty_doc(vec![Block::Paragraph {
281            content: vec![Inline::MathInline {
282                math: MathPayload {
283                    src: Some(MathSource::Latex(" ".to_string())),
284                    rendered: None,
285                    id: None,
286                },
287                attrs: InlineAttrs::default(),
288            }],
289            attrs: BlockAttrs::default(),
290        }]);
291        assert!(validate_document(&doc).is_err());
292    }
293
294    #[test]
295    fn validate_document_rejects_missing_footnote_ref() {
296        let doc = empty_doc(vec![Block::Paragraph {
297            content: vec![Inline::FootnoteRef(FootnoteId(999))],
298            attrs: BlockAttrs::default(),
299        }]);
300        assert!(validate_document(&doc).is_err());
301    }
302}