1use 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
26pub 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}