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
211fn validate_inlines(inlines: &[Inline], diagnostics: &mut Vec<Diagnostic>) {
212    if let Some(Inline::LineBreak(node)) = inlines.last() {
213        diagnostics.push(Diagnostic::invalid(
214            node.meta.span,
215            "hard line break cannot be the final inline of its container",
216        ));
217    }
218    for inline in inlines {
219        match inline {
220            Inline::Emphasis(node) => {
221                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
222            }
223            Inline::Strong(node) => {
224                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
225            }
226            Inline::Underline(node) => {
227                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
228            }
229            Inline::Delete(node) => {
230                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
231            }
232            Inline::Insert(node) => {
233                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
234            }
235            Inline::Mark(node) => {
236                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
237            }
238            Inline::Subscript(node) => {
239                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
240            }
241            Inline::Superscript(node) => {
242                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
243            }
244            Inline::Spoiler(node) => {
245                validate_emphasis_container(&node.children, node.meta.span, diagnostics)
246            }
247            Inline::Shortcode(node) => {
248                if node.name.is_empty() {
249                    diagnostics.push(Diagnostic::invalid(
250                        node.meta.span,
251                        "shortcode name cannot be empty",
252                    ));
253                }
254            }
255            Inline::Link(node) => validate_inlines(&node.children, diagnostics),
256            Inline::Image(node) => validate_inlines(&node.alt, diagnostics),
257            Inline::LinkReference(node) => {
258                if node.identifier.is_empty() {
259                    diagnostics.push(Diagnostic::invalid(
260                        node.meta.span,
261                        "link reference identifier cannot be empty",
262                    ));
263                }
264                validate_inlines(&node.children, diagnostics);
265            }
266            Inline::ImageReference(node) => {
267                if node.identifier.is_empty() {
268                    diagnostics.push(Diagnostic::invalid(
269                        node.meta.span,
270                        "image reference identifier cannot be empty",
271                    ));
272                }
273                validate_inlines(&node.alt, diagnostics);
274            }
275            Inline::Escape(node) => validate_escape(node, diagnostics),
276            Inline::CharacterReference(node) => {
277                if node.reference.is_empty() {
278                    diagnostics.push(Diagnostic::invalid(
279                        node.meta.span,
280                        "character reference source cannot be empty",
281                    ));
282                }
283                if node.value.is_empty() {
284                    diagnostics.push(Diagnostic::invalid(
285                        node.meta.span,
286                        "character reference value cannot be empty",
287                    ));
288                }
289            }
290            Inline::TextDirective(node) => validate_text_directive(node, diagnostics),
291            Inline::FootnoteReference(node) => {
292                if node.identifier.is_empty() {
293                    diagnostics.push(Diagnostic::invalid(
294                        node.meta.span,
295                        "footnote reference identifier cannot be empty",
296                    ));
297                }
298            }
299            Inline::InlineFootnote(node) => validate_inlines(&node.children, diagnostics),
300            Inline::WikiLink(node) => {
301                if node.target.is_empty() {
302                    diagnostics.push(Diagnostic::invalid(
303                        node.meta.span,
304                        "wikilink target cannot be empty",
305                    ));
306                }
307            }
308            Inline::Code(node) => validate_code_inline(node, diagnostics),
309            Inline::Autolink(node) => validate_autolink(node, diagnostics),
310            Inline::Math(node) => {
311                if let MathInlineKind::Dollar { dollars: 0 } = node.kind {
312                    diagnostics.push(Diagnostic::invalid(
313                        node.meta.span,
314                        "dollar-fenced inline math must have a fence length of at least 1",
315                    ));
316                }
317            }
318            Inline::Text(_)
319            | Inline::Html(_)
320            | Inline::SoftBreak(_)
321            | Inline::LineBreak(_)
322            | Inline::MdxExpression(_)
323            | Inline::MdxJsx(_) => {}
324        }
325    }
326}
327
328fn validate_emphasis_container(
329    children: &[Inline],
330    span: Option<Span>,
331    diagnostics: &mut Vec<Diagnostic>,
332) {
333    if children.is_empty() {
334        diagnostics.push(Diagnostic::invalid(
335            span,
336            "emphasis-like inline container cannot have empty children",
337        ));
338    }
339    validate_inlines(children, diagnostics);
340}
341
342fn validate_escape(escape: &Escape, diagnostics: &mut Vec<Diagnostic>) {
343    if !escape.value.is_ascii_punctuation() {
344        diagnostics.push(Diagnostic::invalid(
345            escape.meta.span,
346            "escaped value must be an ASCII punctuation character",
347        ));
348    }
349}
350
351fn validate_autolink(autolink: &Autolink, diagnostics: &mut Vec<Diagnostic>) {
352    // GFM literal autolinks carry a synthesized destination that MAY contain
353    // `>` (the renderer percent-encodes it). Only angle-bracket autolinks
354    // forbid whitespace, `<`, and `>` in the destination.
355    if matches!(autolink.kind, AutolinkKind::GfmLiteral { .. }) {
356        return;
357    }
358    if autolink
359        .destination
360        .chars()
361        .any(|char| char.is_whitespace() || char == '<' || char == '>')
362    {
363        diagnostics.push(Diagnostic::invalid(
364            autolink.meta.span,
365            "autolink destination cannot contain whitespace, `<`, or `>`",
366        ));
367    }
368}
369
370fn validate_code_inline(code: &CodeInline, diagnostics: &mut Vec<Diagnostic>) {
371    if code.fence_length == 0 {
372        return;
373    }
374    // A code span fence of length N is closed only by a backtick run of exactly
375    // length N. A run shorter or longer than the fence is inert, so only an
376    // exactly-matching interior run would close the raw passthrough early.
377    if raw_has_backtick_run(&code.raw, code.fence_length) {
378        diagnostics.push(Diagnostic::invalid(
379            code.meta.span,
380            "inline code raw passthrough contains a backtick run equal to its fence length",
381        ));
382    }
383}
384
385fn raw_has_backtick_run(input: &str, length: usize) -> bool {
386    let mut current = 0;
387    for byte in input.bytes() {
388        if byte == b'`' {
389            current += 1;
390        } else {
391            if current == length {
392                return true;
393            }
394            current = 0;
395        }
396    }
397    current == length
398}
399
400fn validate_list_start(list: &List, diagnostics: &mut Vec<Diagnostic>) {
401    if !list.ordered {
402        return;
403    }
404    let Some(start) = list.start else {
405        return;
406    };
407    if start > 999_999_999 {
408        diagnostics.push(Diagnostic::invalid(
409            list.meta.span,
410            "ordered list start must be representable in at most 9 digits",
411        ));
412    }
413}
414
415fn validate_text_directive(directive: &TextDirective, diagnostics: &mut Vec<Diagnostic>) {
416    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
417    validate_directive_attributes(&directive.attributes, diagnostics);
418    validate_inlines(&directive.label, diagnostics);
419}
420
421fn validate_directive_name(span: Option<Span>, name: &str, diagnostics: &mut Vec<Diagnostic>) {
422    if !is_directive_name(name) {
423        diagnostics.push(Diagnostic::invalid(
424            span,
425            "directive name must start with a letter and contain letters, digits, `_`, or `-`",
426        ));
427    }
428}
429
430fn validate_directive_attributes(
431    attributes: &[DirectiveAttribute],
432    diagnostics: &mut Vec<Diagnostic>,
433) {
434    for attribute in attributes {
435        if !is_attribute_name(&attribute.name) {
436            diagnostics.push(Diagnostic::invalid(
437                None,
438                "directive attribute name must start with a letter, `_`, or `-`",
439            ));
440        }
441    }
442}
443
444pub(crate) fn is_directive_name(name: &str) -> bool {
445    let mut chars = name.chars();
446    let Some(first) = chars.next() else {
447        return false;
448    };
449    if !first.is_ascii_alphabetic() {
450        return false;
451    }
452    chars.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-')
453}
454
455pub(crate) fn is_attribute_name(name: &str) -> bool {
456    let mut chars = name.chars();
457    let Some(first) = chars.next() else {
458        return false;
459    };
460    if !(first.is_ascii_alphabetic() || first == '_' || first == '-') {
461        return false;
462    }
463    chars.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-' || char == ':')
464}