Skip to main content

markdown_syntax/
validate.rs

1//! AST validation: [`Document::validate`] walks the tree and reports each
2//! invalid or unsupported node shape as a [`Diagnostic`]. Serialization and HTML
3//! rendering run this first and refuse an invalid document.
4
5use alloc::vec::Vec;
6
7use crate::{
8    ast::{
9        Autolink, AutolinkKind, Block, CodeInline, ContainerDirective, DirectiveAttribute,
10        Document, Escape, Heading, HtmlContainer, HtmlContainerContent, Inline, LeafDirective,
11        List, MathInlineKind, Table, TextDirective,
12    },
13    diagnostic::Diagnostic,
14    span::Span,
15};
16
17impl Document {
18    /// Validate this document's AST shape, returning a diagnostic for each
19    /// invalid or unsupported node (empty when the document is well-formed).
20    pub fn validate(&self) -> Vec<Diagnostic> {
21        validate_document(self)
22    }
23}
24
25pub(crate) fn validate_document(document: &Document) -> Vec<Diagnostic> {
26    let mut diagnostics = Vec::new();
27    for block in &document.children {
28        validate_block(block, &mut diagnostics);
29    }
30    diagnostics
31}
32
33fn validate_block(block: &Block, diagnostics: &mut Vec<Diagnostic>) {
34    match block {
35        Block::Paragraph(paragraph) => validate_inlines(&paragraph.children, diagnostics),
36        Block::Heading(heading) => validate_heading(heading, diagnostics),
37        Block::BlockQuote(block_quote) => {
38            for child in &block_quote.children {
39                validate_block(child, diagnostics);
40            }
41        }
42        Block::Alert(alert) => {
43            for child in &alert.children {
44                validate_block(child, diagnostics);
45            }
46        }
47        Block::List(list) => {
48            validate_list_start(list, diagnostics);
49            for item in &list.children {
50                for child in &item.children {
51                    validate_block(child, diagnostics);
52                }
53            }
54        }
55        Block::DescriptionList(list) => {
56            for item in &list.children {
57                validate_inlines(&item.term, diagnostics);
58                if item.details.is_empty() {
59                    diagnostics.push(Diagnostic::invalid(
60                        item.meta.span,
61                        "description item must contain at least one details block",
62                    ));
63                }
64                for details in &item.details {
65                    for child in &details.children {
66                        validate_block(child, diagnostics);
67                    }
68                }
69            }
70        }
71        Block::Table(table) => validate_table(table, diagnostics),
72        Block::FootnoteDefinition(definition) => {
73            if definition.identifier.is_empty() {
74                diagnostics.push(Diagnostic::invalid(
75                    definition.meta.span,
76                    "footnote definition identifier cannot be empty",
77                ));
78            }
79            for child in &definition.children {
80                validate_block(child, diagnostics);
81            }
82        }
83        Block::Definition(definition) => {
84            if definition.identifier.trim().is_empty() {
85                diagnostics.push(Diagnostic::invalid(
86                    definition.meta.span,
87                    "definition identifier cannot be empty",
88                ));
89            }
90        }
91        Block::LeafDirective(directive) => validate_leaf_directive(directive, diagnostics),
92        Block::ContainerDirective(directive) => {
93            validate_container_directive(directive, diagnostics)
94        }
95        Block::HtmlContainer(container) => validate_html_container(container, diagnostics),
96        Block::ThematicBreak(_)
97        | Block::CodeBlock(_)
98        | Block::HtmlBlock(_)
99        | Block::MathBlock(_)
100        | Block::Frontmatter(_)
101        | Block::MdxEsm(_)
102        | Block::MdxExpression(_)
103        | Block::MdxJsx(_) => {}
104    }
105}
106
107fn validate_html_container(container: &HtmlContainer, diagnostics: &mut Vec<Diagnostic>) {
108    if container.opening.name.is_empty() {
109        diagnostics.push(Diagnostic::invalid(
110            container.opening.meta.span,
111            "HTML container opening tag name cannot be empty",
112        ));
113    }
114    if container.closing.name.is_empty() {
115        diagnostics.push(Diagnostic::invalid(
116            container.closing.meta.span,
117            "HTML container closing tag name cannot be empty",
118        ));
119    }
120    if container.opening.name != container.closing.name {
121        diagnostics.push(Diagnostic::invalid(
122            container.meta.span,
123            "HTML container opening and closing tag names must match",
124        ));
125    }
126    if container.opening.raw.is_empty() {
127        diagnostics.push(Diagnostic::invalid(
128            container.opening.meta.span,
129            "HTML container opening tag source cannot be empty",
130        ));
131    }
132    if container.closing.raw.is_empty() {
133        diagnostics.push(Diagnostic::invalid(
134            container.closing.meta.span,
135            "HTML container closing tag source cannot be empty",
136        ));
137    }
138
139    match &container.content {
140        HtmlContainerContent::Blocks(children) => {
141            for child in children {
142                validate_block(child, diagnostics);
143            }
144        }
145        HtmlContainerContent::Inlines(children) => validate_inlines(children, diagnostics),
146    }
147}
148
149fn validate_heading(heading: &Heading, diagnostics: &mut Vec<Diagnostic>) {
150    if heading.depth == 0 || heading.depth > 6 {
151        diagnostics.push(Diagnostic::invalid(
152            heading.meta.span,
153            "heading depth must be in the range 1..=6",
154        ));
155    }
156    validate_inlines(&heading.children, diagnostics);
157}
158
159fn validate_table(table: &Table, diagnostics: &mut Vec<Diagnostic>) {
160    if table.rows.is_empty() {
161        diagnostics.push(Diagnostic::invalid(
162            table.meta.span,
163            "table must contain at least a header row",
164        ));
165        return;
166    }
167
168    let width = table.rows[0].cells.len();
169    if width == 0 {
170        diagnostics.push(Diagnostic::invalid(
171            table.meta.span,
172            "table header row must contain at least one cell",
173        ));
174    }
175
176    if table.alignments.len() != width {
177        diagnostics.push(Diagnostic::invalid(
178            table.meta.span,
179            "table alignment count must match header width",
180        ));
181    }
182
183    for row in &table.rows {
184        if row.cells.len() != width {
185            diagnostics.push(Diagnostic::invalid(
186                row.meta.span,
187                "table row width must match header width",
188            ));
189        }
190        for cell in &row.cells {
191            validate_inlines(&cell.children, diagnostics);
192        }
193    }
194}
195
196fn validate_leaf_directive(directive: &LeafDirective, diagnostics: &mut Vec<Diagnostic>) {
197    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
198    validate_directive_attributes(&directive.attributes, diagnostics);
199    validate_inlines(&directive.label, diagnostics);
200}
201
202fn validate_container_directive(directive: &ContainerDirective, diagnostics: &mut Vec<Diagnostic>) {
203    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
204    validate_directive_attributes(&directive.attributes, diagnostics);
205    validate_inlines(&directive.label, diagnostics);
206    for child in &directive.children {
207        validate_block(child, diagnostics);
208    }
209}
210
211/// Inline content whose container ends where its line does, or with a closing
212/// delimiter that a line break before it keeps from closing: a final hard line
213/// break would not survive serialization.
214fn validate_inlines(inlines: &[Inline], diagnostics: &mut Vec<Diagnostic>) {
215    if let Some(Inline::LineBreak(node)) = inlines.last() {
216        diagnostics.push(Diagnostic::invalid(
217            node.meta.span,
218            "hard line break cannot be the final inline of its container",
219        ));
220    }
221    validate_inline_nodes(inlines, diagnostics);
222}
223
224/// Inline content closed by a `]`, which closes after a line break as well, so
225/// it may end with a hard line break.
226fn validate_inline_nodes(inlines: &[Inline], diagnostics: &mut Vec<Diagnostic>) {
227    for inline in inlines {
228        match inline {
229            Inline::Emphasis(node) => {
230                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
231            }
232            Inline::Strong(node) => {
233                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
234            }
235            Inline::Underline(node) => {
236                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
237            }
238            Inline::Delete(node) => {
239                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
240            }
241            Inline::Insert(node) => {
242                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
243            }
244            Inline::Mark(node) => {
245                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
246            }
247            Inline::Subscript(node) => {
248                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
249            }
250            Inline::Superscript(node) => {
251                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
252            }
253            Inline::Spoiler(node) => {
254                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
255            }
256            Inline::Shortcode(node) => {
257                if node.name.is_empty() {
258                    diagnostics.push(Diagnostic::invalid(
259                        node.meta.span,
260                        "shortcode name cannot be empty",
261                    ));
262                }
263            }
264            Inline::Link(node) => validate_inline_nodes(&node.children, diagnostics),
265            Inline::Image(node) => validate_inline_nodes(&node.alt, diagnostics),
266            Inline::LinkReference(node) => {
267                if node.identifier.is_empty() {
268                    diagnostics.push(Diagnostic::invalid(
269                        node.meta.span,
270                        "link reference identifier cannot be empty",
271                    ));
272                }
273                validate_inline_nodes(&node.children, diagnostics);
274            }
275            Inline::ImageReference(node) => {
276                if node.identifier.is_empty() {
277                    diagnostics.push(Diagnostic::invalid(
278                        node.meta.span,
279                        "image reference identifier cannot be empty",
280                    ));
281                }
282                validate_inline_nodes(&node.alt, diagnostics);
283            }
284            Inline::Escape(node) => validate_escape(node, diagnostics),
285            Inline::CharacterReference(node) => {
286                if node.reference.is_empty() {
287                    diagnostics.push(Diagnostic::invalid(
288                        node.meta.span,
289                        "character reference source cannot be empty",
290                    ));
291                }
292                if node.value.is_empty() {
293                    diagnostics.push(Diagnostic::invalid(
294                        node.meta.span,
295                        "character reference value cannot be empty",
296                    ));
297                }
298            }
299            Inline::TextDirective(node) => validate_text_directive(node, diagnostics),
300            Inline::FootnoteReference(node) => {
301                if node.identifier.is_empty() {
302                    diagnostics.push(Diagnostic::invalid(
303                        node.meta.span,
304                        "footnote reference identifier cannot be empty",
305                    ));
306                }
307            }
308            Inline::InlineFootnote(node) => validate_inline_nodes(&node.children, diagnostics),
309            Inline::WikiLink(node) => {
310                if node.target.is_empty() {
311                    diagnostics.push(Diagnostic::invalid(
312                        node.meta.span,
313                        "wikilink target cannot be empty",
314                    ));
315                }
316            }
317            Inline::Code(node) => validate_code_inline(node, diagnostics),
318            Inline::Autolink(node) => validate_autolink(node, diagnostics),
319            Inline::Math(node) => {
320                if let MathInlineKind::Dollar { dollars: 0 } = node.kind {
321                    diagnostics.push(Diagnostic::invalid(
322                        node.meta.span,
323                        "dollar-fenced inline math must have a fence length of at least 1",
324                    ));
325                }
326            }
327            Inline::Text(_)
328            | Inline::Html(_)
329            | Inline::SoftBreak(_)
330            | Inline::LineBreak(_)
331            | Inline::MdxExpression(_)
332            | Inline::MdxJsx(_) => {}
333        }
334    }
335}
336
337fn validate_emphasis_container(
338    children: &[Inline],
339    span: Option<Span>,
340    diagnostics: &mut Vec<Diagnostic>,
341) {
342    if children.is_empty() {
343        diagnostics.push(Diagnostic::invalid(
344            span,
345            "emphasis-like inline container cannot have empty children",
346        ));
347    }
348    validate_inlines(children, diagnostics);
349}
350
351fn validate_escape(escape: &Escape, diagnostics: &mut Vec<Diagnostic>) {
352    if !escape.value.is_ascii_punctuation() {
353        diagnostics.push(Diagnostic::invalid(
354            escape.meta.span,
355            "escaped value must be an ASCII punctuation character",
356        ));
357    }
358}
359
360fn validate_autolink(autolink: &Autolink, diagnostics: &mut Vec<Diagnostic>) {
361    // GFM literal autolinks carry a synthesized destination that MAY contain
362    // `>` (the renderer percent-encodes it). Only angle-bracket autolinks
363    // forbid whitespace, `<`, and `>` in the destination.
364    if matches!(autolink.kind, AutolinkKind::GfmLiteral { .. }) {
365        return;
366    }
367    if autolink
368        .destination
369        .chars()
370        .any(|char| char.is_whitespace() || char == '<' || char == '>')
371    {
372        diagnostics.push(Diagnostic::invalid(
373            autolink.meta.span,
374            "autolink destination cannot contain whitespace, `<`, or `>`",
375        ));
376    }
377}
378
379fn validate_code_inline(code: &CodeInline, diagnostics: &mut Vec<Diagnostic>) {
380    if code.fence_length == 0 {
381        return;
382    }
383    // A code span fence of length N is closed only by a backtick run of exactly
384    // length N. A run shorter or longer than the fence is inert, so only an
385    // exactly-matching interior run would close the raw passthrough early.
386    if raw_has_backtick_run(&code.raw, code.fence_length) {
387        diagnostics.push(Diagnostic::invalid(
388            code.meta.span,
389            "inline code raw passthrough contains a backtick run equal to its fence length",
390        ));
391    }
392}
393
394fn raw_has_backtick_run(input: &str, length: usize) -> bool {
395    let mut current = 0;
396    for byte in input.bytes() {
397        if byte == b'`' {
398            current += 1;
399        } else {
400            if current == length {
401                return true;
402            }
403            current = 0;
404        }
405    }
406    current == length
407}
408
409fn validate_list_start(list: &List, diagnostics: &mut Vec<Diagnostic>) {
410    if !list.ordered {
411        return;
412    }
413    let Some(start) = list.start else {
414        return;
415    };
416    if start > 999_999_999 {
417        diagnostics.push(Diagnostic::invalid(
418            list.meta.span,
419            "ordered list start must be representable in at most 9 digits",
420        ));
421    }
422}
423
424fn validate_text_directive(directive: &TextDirective, diagnostics: &mut Vec<Diagnostic>) {
425    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
426    validate_directive_attributes(&directive.attributes, diagnostics);
427    validate_inline_nodes(&directive.label, diagnostics);
428}
429
430fn validate_directive_name(span: Option<Span>, name: &str, diagnostics: &mut Vec<Diagnostic>) {
431    if !is_directive_name(name) {
432        diagnostics.push(Diagnostic::invalid(
433            span,
434            "directive name must start with a letter and contain letters, digits, `_`, or `-`",
435        ));
436    }
437}
438
439fn validate_directive_attributes(
440    attributes: &[DirectiveAttribute],
441    diagnostics: &mut Vec<Diagnostic>,
442) {
443    for attribute in attributes {
444        if !is_attribute_name(&attribute.name) {
445            diagnostics.push(Diagnostic::invalid(
446                None,
447                "directive attribute name must start with a letter, `_`, or `-`",
448            ));
449        }
450    }
451}
452
453pub(crate) fn is_directive_name(name: &str) -> bool {
454    let mut chars = name.chars();
455    let Some(first) = chars.next() else {
456        return false;
457    };
458    if !first.is_ascii_alphabetic() {
459        return false;
460    }
461    chars.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-')
462}
463
464pub(crate) fn is_attribute_name(name: &str) -> bool {
465    let mut chars = name.chars();
466    let Some(first) = chars.next() else {
467        return false;
468    };
469    if !(first.is_ascii_alphabetic() || first == '_' || first == '-') {
470        return false;
471    }
472    chars.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-' || char == ':')
473}