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        Alert, Block, CodeBlock, CodeBlockKind, CodeInline, ContainerDirective, Definition,
10        DirectiveAttribute, Document, Escape, Frontmatter, Heading, HtmlContainer,
11        HtmlContainerContent, Inline, LeafDirective, LineBreakKind, LinkDestinationKind, List,
12        ListItem, MathInline, MathInlineKind, NodePath, ReferenceKind, Table, TextDirective,
13    },
14    diagnostic::Diagnostic,
15    parse::{
16        alert_title, frontmatter_fence_kind, is_blank, is_footnote_label, is_reference_label,
17        is_written_footnote_label, normalize_label, reads_as_inline_math, MAX_BLOCK_NESTING,
18    },
19    serialize::{write_inline_math, written_after_paragraph_interrupts, written_marker},
20    span::Span,
21};
22
23impl Document {
24    /// Validate this document's AST shape, returning a diagnostic for each
25    /// invalid or unsupported node (empty when the document is well-formed).
26    pub fn validate(&self) -> Vec<Diagnostic> {
27        validate_document(self)
28    }
29}
30
31pub(crate) fn validate_document(document: &Document) -> Vec<Diagnostic> {
32    let mut report = Report::default();
33    validate_blocks(&document.children, 0, &mut report);
34    report.diagnostics
35}
36
37/// The diagnostics found so far, and the path of the node being checked,
38/// which each diagnostic pushed carries.
39#[derive(Default)]
40struct Report {
41    diagnostics: Vec<Diagnostic>,
42    path: Vec<usize>,
43}
44
45impl Report {
46    fn push(&mut self, mut diagnostic: Diagnostic) {
47        diagnostic.path = Some(NodePath(self.path.clone()));
48        self.diagnostics.push(diagnostic);
49    }
50
51    /// Runs `check` with the path at the child `index` of the current node.
52    fn at(&mut self, index: usize, check: impl FnOnce(&mut Self)) {
53        self.path.push(index);
54        check(self);
55        self.path.pop();
56    }
57}
58
59/// A sequence of sibling blocks: each block, and two adjacent lists that one
60/// marker would write as one list. `depth` counts the containers around the
61/// blocks as the parser's nesting limit counts them: block quotes and alerts,
62/// list items, footnote definitions, HTML containers, and container
63/// directives.
64fn validate_blocks(blocks: &[Block], depth: usize, diagnostics: &mut Report) {
65    // Blank lines beyond the separator change how no block reads, so the
66    // block before each one is the last one before them.
67    let mut before: Option<&Block> = None;
68    for (index, block) in blocks.iter().enumerate() {
69        diagnostics.at(index, |diagnostics| {
70            if let (Some(Block::List(before)), Block::List(list)) = (before, block) {
71                if written_marker(before) == written_marker(list) {
72                    diagnostics.push(Diagnostic::invalid(
73                        list.meta.span,
74                        "adjacent lists cannot use the same marker",
75                    ));
76                }
77            }
78            if let Block::Definition(definition) = block {
79                // A line indented four columns or more continues a paragraph,
80                // and starts one on the line after an alert's marker, which may
81                // open containers first; neither starts a footnote definition.
82                let indented = match before {
83                    Some(before) => matches!(before, Block::Definition(_)),
84                    None => depth > 0,
85                };
86                validate_definition(definition, depth, indented, diagnostics);
87            }
88            validate_block(block, depth, diagnostics);
89        });
90        if !matches!(block, Block::BlankLine(_)) {
91            before = Some(block);
92        }
93    }
94}
95
96fn validate_block(block: &Block, depth: usize, diagnostics: &mut Report) {
97    match block {
98        Block::Paragraph(paragraph) => {
99            // An empty paragraph writes nothing and reads back as no block.
100            if paragraph.children.iter().all(is_empty_text) {
101                diagnostics.push(Diagnostic::invalid(
102                    paragraph.meta.span,
103                    "paragraph cannot be empty",
104                ));
105            }
106            validate_inlines(&paragraph.children, diagnostics);
107        }
108        Block::Heading(heading) => validate_heading(heading, diagnostics),
109        Block::BlockQuote(block_quote) => {
110            validate_blocks(&block_quote.children, depth + 1, diagnostics);
111        }
112        Block::Alert(alert) => validate_alert(alert, depth, diagnostics),
113        Block::List(list) => validate_list(list, depth, diagnostics),
114        Block::Table(table) => validate_table(table, diagnostics),
115        Block::FootnoteDefinition(definition) => {
116            if definition.identifier.is_empty() {
117                diagnostics.push(Diagnostic::invalid(
118                    definition.meta.span,
119                    "footnote definition identifier cannot be empty",
120                ));
121            }
122            validate_footnote_label(
123                definition.meta.span,
124                &definition.label,
125                &definition.identifier,
126                diagnostics,
127            );
128            // Its first line holds its label, and a blank line after its last
129            // block belongs to the container around it.
130            let last = definition.children.len().saturating_sub(1);
131            if let Some((at, Block::BlankLine(blank))) = [0, last]
132                .into_iter()
133                .filter_map(|at| definition.children.get(at).map(|block| (at, block)))
134                .find(|(_, block)| matches!(block, Block::BlankLine(_)))
135            {
136                diagnostics.at(at, |diagnostics| {
137                    diagnostics.push(Diagnostic::invalid(
138                        blank.meta.span,
139                        "a footnote definition cannot open or end with a blank line",
140                    ))
141                });
142            }
143            validate_blocks(&definition.children, depth + 1, diagnostics);
144        }
145        // Checked with its siblings in `validate_blocks`.
146        Block::Definition(_) => {}
147        Block::LeafDirective(directive) => validate_leaf_directive(directive, diagnostics),
148        Block::ContainerDirective(directive) => {
149            validate_container_directive(directive, depth, diagnostics)
150        }
151        Block::HtmlContainer(container) => validate_html_container(container, depth, diagnostics),
152        Block::CodeBlock(code) => validate_code_block(code, diagnostics),
153        Block::Frontmatter(frontmatter) => validate_frontmatter(frontmatter, depth, diagnostics),
154        Block::ThematicBreak(_)
155        | Block::HtmlBlock(_)
156        | Block::MathBlock(_)
157        | Block::BlankLine(_) => {}
158    }
159}
160
161fn validate_html_container(container: &HtmlContainer, depth: usize, diagnostics: &mut Report) {
162    if container.opening.name.is_empty() {
163        diagnostics.push(Diagnostic::invalid(
164            container.opening.meta.span,
165            "HTML container opening tag name cannot be empty",
166        ));
167    }
168    if container.closing.name.is_empty() {
169        diagnostics.push(Diagnostic::invalid(
170            container.closing.meta.span,
171            "HTML container closing tag name cannot be empty",
172        ));
173    }
174    if container.opening.name != container.closing.name {
175        diagnostics.push(Diagnostic::invalid(
176            container.meta.span,
177            "HTML container opening and closing tag names must match",
178        ));
179    }
180    if container.opening.raw.is_empty() {
181        diagnostics.push(Diagnostic::invalid(
182            container.opening.meta.span,
183            "HTML container opening tag source cannot be empty",
184        ));
185    }
186    if container.closing.raw.is_empty() {
187        diagnostics.push(Diagnostic::invalid(
188            container.closing.meta.span,
189            "HTML container closing tag source cannot be empty",
190        ));
191    }
192
193    match &container.content {
194        HtmlContainerContent::Blocks(children) => {
195            validate_blocks(children, depth + 1, diagnostics);
196        }
197        HtmlContainerContent::Inlines(children) => validate_inlines(children, diagnostics),
198    }
199}
200
201fn validate_heading(heading: &Heading, diagnostics: &mut Report) {
202    if heading.depth == 0 || heading.depth > 6 {
203        diagnostics.push(Diagnostic::invalid(
204            heading.meta.span,
205            "heading depth must be in the range 1..=6",
206        ));
207    }
208    validate_inlines(&heading.children, diagnostics);
209}
210
211/// An alert's title is what the parser reads from its marker line: never
212/// empty, and without spaces or tabs around it.
213fn validate_alert(alert: &Alert, depth: usize, diagnostics: &mut Report) {
214    if let Some(title) = &alert.title {
215        if alert_title(title).as_deref() != Some(title.as_str()) {
216            diagnostics.push(Diagnostic::invalid(
217                alert.meta.span,
218                "alert title cannot be empty or start or end with a space or a tab",
219            ));
220        }
221    }
222    validate_blocks(&alert.children, depth + 1, diagnostics);
223}
224
225fn validate_list(list: &List, depth: usize, diagnostics: &mut Report) {
226    if list.children.is_empty() {
227        diagnostics.push(Diagnostic::invalid(
228            list.meta.span,
229            "list must hold at least one item",
230        ));
231    }
232    // A list is loose when blank lines part its items or two blocks of one
233    // item, so one item of at most one block is always tight.
234    if !list.tight
235        && matches!(list.children.as_slice(), [item] if item
236            .children
237            .iter()
238            .filter(|block| !matches!(block, Block::BlankLine(_)))
239            .count()
240            <= 1)
241    {
242        diagnostics.push(Diagnostic::invalid(
243            list.meta.span,
244            "a loose list must hold two items or an item of two blocks",
245        ));
246    }
247    for (index, item) in list.children.iter().enumerate() {
248        diagnostics.at(index, |diagnostics| {
249            validate_item_number(list, item, diagnostics);
250            validate_item_blank_lines(list, index, item, diagnostics);
251            validate_task_item(item, diagnostics);
252            if list.tight {
253                validate_tight_item(item, diagnostics);
254            }
255            validate_blocks(&item.children, depth + 1, diagnostics);
256        });
257    }
258}
259
260/// A task item's checkbox opens its first paragraph after the definitions it
261/// starts with; without one, `[ ]` or `[x]` reads as text.
262fn validate_task_item(item: &ListItem, diagnostics: &mut Report) {
263    if item.checked.is_none() {
264        return;
265    }
266    let first = item
267        .children
268        .iter()
269        .find(|block| !matches!(block, Block::Definition(_)));
270    if !matches!(first, Some(Block::Paragraph(paragraph)) if !paragraph.children.is_empty()) {
271        diagnostics.push(Diagnostic::invalid(
272            item.meta.span,
273            "task list item must hold a non-empty paragraph after its leading definitions",
274        ));
275    }
276}
277
278/// A tight item writes each block on the line after the one before; after a
279/// paragraph's line, a block that cannot interrupt the paragraph continues it.
280fn validate_tight_item(item: &ListItem, diagnostics: &mut Report) {
281    for (at, pair) in item.children.windows(2).enumerate() {
282        let [Block::Paragraph(_), block] = pair else {
283            continue;
284        };
285        if !written_after_paragraph_interrupts(block) {
286            diagnostics.at(at + 1, |diagnostics| {
287                diagnostics.push(Diagnostic::invalid(
288                    block.span(),
289                    "in a tight list item, a block after a paragraph must be able to interrupt it",
290                ))
291            });
292        }
293    }
294}
295
296fn validate_table(table: &Table, diagnostics: &mut Report) {
297    if table.rows.is_empty() {
298        diagnostics.push(Diagnostic::invalid(
299            table.meta.span,
300            "table must contain at least a header row",
301        ));
302        return;
303    }
304
305    let width = table.rows[0].cells.len();
306    if width == 0 {
307        diagnostics.push(Diagnostic::invalid(
308            table.meta.span,
309            "table header row must contain at least one cell",
310        ));
311    }
312
313    if table.alignments.len() != width {
314        diagnostics.push(Diagnostic::invalid(
315            table.meta.span,
316            "table alignment count must match header width",
317        ));
318    }
319
320    for (index, row) in table.rows.iter().enumerate() {
321        diagnostics.at(index, |diagnostics| {
322            if row.cells.len() != width {
323                diagnostics.push(Diagnostic::invalid(
324                    row.meta.span,
325                    "table row width must match header width",
326                ));
327            }
328            for (index, cell) in row.cells.iter().enumerate() {
329                diagnostics.at(index, |diagnostics| {
330                    validate_inlines(&cell.children, diagnostics);
331                    validate_cell_values(&cell.children, diagnostics);
332                    validate_one_line(
333                        &cell.children,
334                        "a table cell cannot hold a line break",
335                        diagnostics,
336                    );
337                });
338            }
339        });
340    }
341}
342
343/// The values a table cell writes as they are. The row splits at each `|`
344/// after no backslash or an even run of them, and the cell's inline input
345/// reads an odd run before a `|` with one backslash less, so no cell source
346/// gives a value that holds a `|` after an odd backslash run.
347fn validate_cell_values(inlines: &[Inline], diagnostics: &mut Report) {
348    for inline in inlines {
349        let unwritable = match inline {
350            Inline::Code(node) => has_odd_escaped_pipe(&node.value),
351            Inline::Math(node) => has_odd_escaped_pipe(&node.value),
352            Inline::Html(node) => has_odd_escaped_pipe(&node.value),
353            Inline::Autolink(node) => has_odd_escaped_pipe(&node.text),
354            Inline::LinkReference(node) => has_odd_escaped_pipe(&node.label),
355            Inline::ImageReference(node) => has_odd_escaped_pipe(&node.label),
356            Inline::FootnoteReference(node) => has_odd_escaped_pipe(&node.label),
357            Inline::WikiLink(node) => {
358                has_odd_escaped_pipe(&node.target) || has_odd_escaped_pipe(&node.label)
359            }
360            _ => false,
361        };
362        if unwritable {
363            diagnostics.push(Diagnostic::invalid(
364                inline.span(),
365                "a value in a table cell cannot hold a `|` after an odd run of backslashes",
366            ));
367        }
368        validate_cell_values(inline.children(), diagnostics);
369    }
370}
371
372/// Whether `value` holds a `|` right after an odd run of backslashes.
373fn has_odd_escaped_pipe(value: &str) -> bool {
374    let mut backslashes = 0usize;
375    for byte in value.bytes() {
376        if byte == b'|' && backslashes % 2 == 1 {
377            return true;
378        }
379        backslashes = if byte == b'\\' { backslashes + 1 } else { 0 };
380    }
381    false
382}
383
384/// Inline content written on one line, at any depth: a table cell, or a leaf
385/// or container directive's label on its opening line.
386fn validate_one_line(inlines: &[Inline], message: &'static str, diagnostics: &mut Report) {
387    for inline in inlines {
388        if matches!(inline, Inline::SoftBreak(_) | Inline::LineBreak(_)) {
389            diagnostics.push(Diagnostic::invalid(inline.span(), message));
390        }
391        validate_one_line(inline.children(), message, diagnostics);
392    }
393}
394
395fn validate_leaf_directive(directive: &LeafDirective, diagnostics: &mut Report) {
396    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
397    validate_directive_attributes(&directive.attributes, diagnostics);
398    validate_inlines(&directive.label, diagnostics);
399    validate_one_line(
400        &directive.label,
401        "a leaf directive label cannot hold a line break",
402        diagnostics,
403    );
404}
405
406fn validate_container_directive(
407    directive: &ContainerDirective,
408    depth: usize,
409    diagnostics: &mut Report,
410) {
411    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
412    validate_directive_attributes(&directive.attributes, diagnostics);
413    validate_inlines(&directive.label, diagnostics);
414    validate_one_line(
415        &directive.label,
416        "a container directive label cannot hold a line break",
417        diagnostics,
418    );
419    validate_blocks(&directive.children, depth + 1, diagnostics);
420}
421
422/// A definition's label is written as a reference label, and its identifier
423/// is the parser's normalization of it. A label that is `^` and a footnote
424/// label opens a footnote definition instead, unless the definition is past
425/// the parser's nesting limit or may come from a line `indented` into a
426/// paragraph, which starts no block.
427fn validate_definition(
428    definition: &Definition,
429    depth: usize,
430    indented: bool,
431    diagnostics: &mut Report,
432) {
433    if definition
434        .identifier
435        .trim_matches([' ', '\t', '\n', '\r'])
436        .is_empty()
437    {
438        diagnostics.push(Diagnostic::invalid(
439            definition.meta.span,
440            "definition identifier cannot be empty",
441        ));
442    }
443    validate_reference_label(
444        definition.meta.span,
445        &definition.label,
446        &definition.identifier,
447        true,
448        diagnostics,
449    );
450    let footnote = definition
451        .label
452        .strip_prefix('^')
453        .is_some_and(is_footnote_label);
454    if footnote && depth < MAX_BLOCK_NESTING && !indented {
455        diagnostics.push(Diagnostic::invalid(
456            definition.meta.span,
457            "definition label cannot be `^` followed by a footnote label",
458        ));
459    }
460    validate_destination(
461        definition.meta.span,
462        &definition.destination,
463        definition.destination_kind,
464        diagnostics,
465    );
466}
467
468/// A reference's identifier is the parser's normalization of its label, and
469/// a label that is `written` (a definition's, or a full reference's) reads
470/// back as a reference label.
471fn validate_reference_label(
472    span: Option<Span>,
473    label: &str,
474    identifier: &str,
475    written: bool,
476    diagnostics: &mut Report,
477) {
478    if normalize_label(label) != identifier {
479        diagnostics.push(Diagnostic::invalid(
480            span,
481            "reference identifier must be the normalized label",
482        ));
483    }
484    if written && !is_reference_label(label) {
485        diagnostics.push(Diagnostic::invalid(
486            span,
487            "reference label must be one the parser reads as a label",
488        ));
489    }
490}
491
492/// A footnote's label reads back as a footnote label, and its identifier is
493/// the parser's normalization of it.
494fn validate_footnote_label(
495    span: Option<Span>,
496    label: &str,
497    identifier: &str,
498    diagnostics: &mut Report,
499) {
500    if normalize_label(label) != identifier {
501        diagnostics.push(Diagnostic::invalid(
502            span,
503            "footnote identifier must be the normalized label",
504        ));
505    }
506    if !is_written_footnote_label(label) {
507        diagnostics.push(Diagnostic::invalid(
508            span,
509            "footnote label must be one the parser reads as a footnote label",
510        ));
511    }
512}
513
514/// An empty bare destination is written `<>`, which reads as an
515/// angle-bracket one.
516fn validate_destination(
517    span: Option<Span>,
518    destination: &str,
519    kind: LinkDestinationKind,
520    diagnostics: &mut Report,
521) {
522    if kind == LinkDestinationKind::Bare && destination.is_empty() {
523        diagnostics.push(Diagnostic::invalid(
524            span,
525            "a bare destination cannot be empty",
526        ));
527    }
528}
529
530/// A code block's info string is never empty, and an indented code block's
531/// value, which no fence bounds, starts and ends with a line that is not
532/// blank.
533fn validate_code_block(code: &CodeBlock, diagnostics: &mut Report) {
534    if code.info.as_deref() == Some("") {
535        diagnostics.push(Diagnostic::invalid(
536            code.meta.span,
537            "code block info string cannot be empty",
538        ));
539    }
540    if code.kind == CodeBlockKind::Indented {
541        // Each line of the value ends with a line ending, the last one
542        // optionally.
543        let value = code.value.replace("\r\n", "\n");
544        let body = value.strip_suffix(['\n', '\r']).unwrap_or(&value);
545        let mut lines = body.split(['\n', '\r']);
546        let first = lines.next().unwrap_or_default();
547        let last = lines.next_back().unwrap_or(first);
548        if is_blank(first) || is_blank(last) {
549            diagnostics.push(Diagnostic::invalid(
550                code.meta.span,
551                "indented code cannot be empty or start or end with a blank line",
552            ));
553        }
554    }
555}
556
557/// Frontmatter opens only the document, and ends at the first line that is
558/// its fence.
559fn validate_frontmatter(frontmatter: &Frontmatter, depth: usize, diagnostics: &mut Report) {
560    if depth > 0 {
561        diagnostics.push(Diagnostic::invalid(
562            frontmatter.meta.span,
563            "frontmatter cannot be inside a container",
564        ));
565    }
566    if frontmatter
567        .value
568        .replace("\r\n", "\n")
569        .split(['\n', '\r'])
570        .any(|line| frontmatter_fence_kind(line) == Some(frontmatter.kind))
571    {
572        diagnostics.push(Diagnostic::invalid(
573            frontmatter.meta.span,
574            "frontmatter value cannot hold its own fence line",
575        ));
576    }
577}
578
579/// Inline content whose container ends where its line does, or with a closing
580/// delimiter that a line break before it keeps from closing: a final hard line
581/// break would not survive serialization.
582fn validate_inlines(inlines: &[Inline], diagnostics: &mut Report) {
583    if let Some(Inline::LineBreak(node)) = inlines.last() {
584        diagnostics.push(Diagnostic::invalid(
585            node.meta.span,
586            "hard line break cannot be the final inline of its container",
587        ));
588    }
589    validate_inline_nodes(inlines, diagnostics);
590}
591
592/// Inline content closed by a `]`, which closes after a line break as well, so
593/// it may end with a hard line break.
594fn validate_inline_nodes(inlines: &[Inline], diagnostics: &mut Report) {
595    for (index, inline) in inlines.iter().enumerate() {
596        if let Some(before) = index.checked_sub(1).map(|before| &inlines[before]) {
597            validate_siblings(before, inline, diagnostics);
598        }
599        match inline {
600            Inline::Text(node) => {
601                if node.value.contains(['\n', '\r']) {
602                    diagnostics.push(Diagnostic::invalid(
603                        node.meta.span,
604                        "text cannot hold a line ending; a line ends with a soft or hard break",
605                    ));
606                }
607            }
608            Inline::Emphasis(_) | Inline::Strong(_) | Inline::Delete(_) | Inline::Mark(_) => {
609                validate_emphasis_container(inline, diagnostics)
610            }
611            Inline::Shortcode(node) => {
612                if node.glyph().is_none() {
613                    diagnostics.push(Diagnostic::invalid(
614                        node.meta.span,
615                        "shortcode name must be a gemoji name",
616                    ));
617                }
618            }
619            Inline::Tag(node) => {
620                if !crate::unicode_letters::is_tag_name(&node.name) {
621                    diagnostics.push(Diagnostic::invalid(
622                        node.meta.span,
623                        "tag name must be a Unicode letter followed by letters, numbers, `/`, `_`, or `-`",
624                    ));
625                }
626            }
627            Inline::Link(node) => {
628                validate_destination(
629                    node.meta.span,
630                    &node.destination,
631                    node.destination_kind,
632                    diagnostics,
633                );
634                validate_link_text(&node.children, diagnostics);
635                validate_inline_nodes(&node.children, diagnostics);
636            }
637            Inline::Autolink(node) => {
638                if node.destination().is_none() {
639                    diagnostics.push(Diagnostic::invalid(
640                        node.meta.span,
641                        "autolink text must be exactly one autolink of its form",
642                    ));
643                }
644            }
645            Inline::Image(node) => {
646                validate_destination(
647                    node.meta.span,
648                    &node.destination,
649                    node.destination_kind,
650                    diagnostics,
651                );
652                validate_inline_nodes(&node.alt, diagnostics);
653            }
654            Inline::LinkReference(node) => {
655                if node.identifier.is_empty() {
656                    diagnostics.push(Diagnostic::invalid(
657                        node.meta.span,
658                        "link reference identifier cannot be empty",
659                    ));
660                }
661                validate_reference_label(
662                    node.meta.span,
663                    &node.label,
664                    &node.identifier,
665                    node.kind == ReferenceKind::Full,
666                    diagnostics,
667                );
668                validate_link_text(&node.children, diagnostics);
669                validate_inline_nodes(&node.children, diagnostics);
670            }
671            Inline::ImageReference(node) => {
672                if node.identifier.is_empty() {
673                    diagnostics.push(Diagnostic::invalid(
674                        node.meta.span,
675                        "image reference identifier cannot be empty",
676                    ));
677                }
678                validate_reference_label(
679                    node.meta.span,
680                    &node.label,
681                    &node.identifier,
682                    node.kind == ReferenceKind::Full,
683                    diagnostics,
684                );
685                validate_inline_nodes(&node.alt, diagnostics);
686            }
687            Inline::Escape(node) => validate_escape(node, diagnostics),
688            Inline::CharacterReference(node) => {
689                if node.value().is_none() {
690                    diagnostics.push(Diagnostic::invalid(
691                        node.meta.span,
692                        "character reference must be exactly one character reference",
693                    ));
694                }
695            }
696            Inline::TextDirective(node) => validate_text_directive(node, diagnostics),
697            Inline::FootnoteReference(node) => {
698                if node.identifier.is_empty() {
699                    diagnostics.push(Diagnostic::invalid(
700                        node.meta.span,
701                        "footnote reference identifier cannot be empty",
702                    ));
703                }
704                validate_footnote_label(node.meta.span, &node.label, &node.identifier, diagnostics);
705            }
706            Inline::InlineFootnote(node) => {
707                if node.children.is_empty() {
708                    diagnostics.push(Diagnostic::invalid(
709                        node.meta.span,
710                        "inline footnote cannot be empty",
711                    ));
712                }
713                validate_inline_nodes(&node.children, diagnostics)
714            }
715            Inline::WikiLink(node) => {
716                if node.target.is_empty() {
717                    diagnostics.push(Diagnostic::invalid(
718                        node.meta.span,
719                        "wikilink target cannot be empty",
720                    ));
721                }
722            }
723            Inline::Code(node) => validate_code_inline(node, diagnostics),
724            Inline::Math(node) => validate_math_inline(node, diagnostics),
725            Inline::Html(_) | Inline::SoftBreak(_) | Inline::LineBreak(_) => {}
726        }
727    }
728}
729
730/// Two adjacent inlines whose written forms run together: a break after a
731/// break leaves an empty line, which ends the paragraph, unless it is a
732/// backslash break, which writes its `\` on that line; and two strikethroughs
733/// join their delimiters into `~~~~`, which opens and closes none. Whether
734/// two emphasis or two strong spans with one delimiter char read back depends
735/// on the chars around their joined run (`**:* ~**a**` parses as two
736/// emphasis spans), and two marks join into `====`, which reads as two marks
737/// again; neither is rejected.
738fn validate_siblings(before: &Inline, inline: &Inline, diagnostics: &mut Report) {
739    let after_break = matches!(before, Inline::SoftBreak(_) | Inline::LineBreak(_));
740    let blank_line = match inline {
741        Inline::SoftBreak(_) => true,
742        Inline::LineBreak(node) => node.kind == LineBreakKind::Spaces,
743        _ => false,
744    };
745    if after_break && blank_line {
746        diagnostics.push(Diagnostic::invalid(
747            inline.span(),
748            "a soft break or a spaces hard break cannot follow a line break",
749        ));
750    }
751    if matches!((before, inline), (Inline::Delete(_), Inline::Delete(_))) {
752        diagnostics.push(Diagnostic::invalid(
753            inline.span(),
754            "adjacent strikethroughs cannot be written",
755        ));
756    }
757}
758
759/// An emphasis-like container: it holds content without whitespace at its
760/// edges, and no only child whose delimiter runs join its own into a run
761/// that reads otherwise: `*` around `*a*` writes `**a**`, which is strong;
762/// `**` around `*a*` writes `***a***`, emphasis around strong; `~~` around
763/// `~~a~~` writes `~~~~a~~~~`, which strikes nothing; and `==` around `==a==`
764/// writes `====a====`, one mark between two `==`. `**` around `**a**` reads
765/// back as written.
766fn validate_emphasis_container(node: &Inline, diagnostics: &mut Report) {
767    let children = node.children();
768    let span = node.span();
769    if children.iter().all(is_empty_text) {
770        diagnostics.push(Diagnostic::invalid(
771            span,
772            "emphasis-like inline container cannot have empty children",
773        ));
774    } else if starts_with_whitespace(children) || ends_with_whitespace(children) {
775        diagnostics.push(Diagnostic::invalid(
776            span,
777            "emphasis-like inline content cannot start or end with whitespace",
778        ));
779    }
780    let joined = match (node, children) {
781        (Inline::Emphasis(outer), [Inline::Emphasis(inner)]) => outer.delimiter == inner.delimiter,
782        (Inline::Strong(outer), [Inline::Emphasis(inner)]) => outer.delimiter == inner.delimiter,
783        (Inline::Delete(_), [Inline::Delete(_)]) | (Inline::Mark(_), [Inline::Mark(_)]) => true,
784        _ => false,
785    };
786    if joined {
787        diagnostics.push(Diagnostic::invalid(
788            span,
789            "emphasis-like span cannot hold only a span written with the same delimiter",
790        ));
791    }
792    validate_inlines(children, diagnostics);
793}
794
795/// Whether inline content, past any empty text, starts with whitespace, as
796/// the parser's delimiter flanking reads it, or with a line break other than a
797/// backslash break: a delimiter run before `\` is followed by punctuation,
798/// which may open it as the characters before it allow.
799fn starts_with_whitespace(inlines: &[Inline]) -> bool {
800    match inlines.iter().find(|inline| !is_empty_text(inline)) {
801        Some(Inline::Text(text)) => text.value.starts_with(char::is_whitespace),
802        Some(Inline::SoftBreak(_)) => true,
803        Some(Inline::LineBreak(node)) => node.kind == LineBreakKind::Spaces,
804        _ => false,
805    }
806}
807
808/// Whether inline content, before any empty text, ends with whitespace or a
809/// line break.
810fn ends_with_whitespace(inlines: &[Inline]) -> bool {
811    match inlines.iter().rev().find(|inline| !is_empty_text(inline)) {
812        Some(Inline::Text(text)) => text.value.ends_with(char::is_whitespace),
813        Some(Inline::SoftBreak(_) | Inline::LineBreak(_)) => true,
814        _ => false,
815    }
816}
817
818fn is_empty_text(inline: &Inline) -> bool {
819    matches!(inline, Inline::Text(text) if text.value.is_empty())
820}
821
822/// Link text holds no link, at any depth.
823fn validate_link_text(inlines: &[Inline], diagnostics: &mut Report) {
824    for inline in inlines {
825        match inline {
826            Inline::Link(_)
827            | Inline::Autolink(_)
828            | Inline::LinkReference(_)
829            | Inline::WikiLink(_) => {
830                diagnostics.push(Diagnostic::invalid(
831                    inline.span(),
832                    "link text cannot hold a link",
833                ));
834            }
835            _ => validate_link_text(inline.children(), diagnostics),
836        }
837    }
838}
839
840fn validate_escape(escape: &Escape, diagnostics: &mut Report) {
841    if !escape.value.is_ascii_punctuation() {
842        diagnostics.push(Diagnostic::invalid(
843            escape.meta.span,
844            "escaped value must be an ASCII punctuation character",
845        ));
846    }
847}
848
849/// A code span's value is what the parser reads from one: never empty, and
850/// with its line endings read as spaces.
851fn validate_code_inline(code: &CodeInline, diagnostics: &mut Report) {
852    if code.value.is_empty() {
853        diagnostics.push(Diagnostic::invalid(
854            code.meta.span,
855            "inline code value cannot be empty",
856        ));
857    }
858    if code.value.contains(['\n', '\r']) {
859        diagnostics.push(Diagnostic::invalid(
860            code.meta.span,
861            "inline code value cannot hold a line ending",
862        ));
863    }
864}
865
866/// Inline math that its fence closes around: math needs a value (both `$$`
867/// and `` $`` `$ `` read back as other text), and code-form math ends at the
868/// first `` `$ ``, so its value cannot hold one.
869fn validate_math_inline(math: &MathInline, diagnostics: &mut Report) {
870    if math.value.is_empty() {
871        diagnostics.push(Diagnostic::invalid(
872            math.meta.span,
873            "inline math value cannot be empty",
874        ));
875    }
876    match math.kind {
877        MathInlineKind::Dollar { dollars: 0 } => diagnostics.push(Diagnostic::invalid(
878            math.meta.span,
879            "dollar-fenced inline math must have a fence length of at least 1",
880        )),
881        MathInlineKind::Code if math.value.contains("`$") => diagnostics.push(Diagnostic::invalid(
882            math.meta.span,
883            "code-form inline math value cannot contain a backtick followed by `$`",
884        )),
885        // The parser's own scan tells whether the fence closes where it is
886        // written: not before a `$` inside, nor around edge whitespace or a
887        // line ending the single-`$` form reads as a space.
888        MathInlineKind::Dollar { .. }
889            if !math.value.is_empty()
890                && !reads_as_inline_math(&write_inline_math(math), &math.value, math.kind) =>
891        {
892            diagnostics.push(Diagnostic::invalid(
893                math.meta.span,
894                "dollar-fenced inline math must read back as written",
895            ));
896        }
897        _ => {}
898    }
899}
900
901fn validate_item_number(list: &List, item: &ListItem, diagnostics: &mut Report) {
902    let Some(number) = item.number else {
903        return;
904    };
905    if !list.ordered {
906        diagnostics.push(Diagnostic::invalid(
907            item.meta.span,
908            "a bullet list item cannot hold a number",
909        ));
910    } else if number > crate::parse::MAX_ORDERED_NUMBER {
911        diagnostics.push(Diagnostic::invalid(
912            item.meta.span,
913            "ordered list item number must be representable in at most 9 digits",
914        ));
915    }
916}
917
918/// A blank line in a list item is written below the item's first line and
919/// above a later item, so it cannot open an item, end the last item, or sit
920/// in a tight list.
921fn validate_item_blank_lines(list: &List, index: usize, item: &ListItem, diagnostics: &mut Report) {
922    let last_item = index + 1 == list.children.len();
923    for (at, block) in item.children.iter().enumerate() {
924        let Block::BlankLine(blank) = block else {
925            continue;
926        };
927        let message = if list.tight {
928            "a tight list cannot hold a blank line"
929        } else if at == 0 {
930            "a list item cannot open with a blank line"
931        } else if last_item && at + 1 == item.children.len() {
932            "the last item of a list cannot end with a blank line"
933        } else {
934            continue;
935        };
936        diagnostics.at(at, |diagnostics| {
937            diagnostics.push(Diagnostic::invalid(blank.meta.span, message))
938        });
939    }
940}
941
942fn validate_text_directive(directive: &TextDirective, diagnostics: &mut Report) {
943    validate_directive_name(directive.meta.span, &directive.name, diagnostics);
944    validate_directive_attributes(&directive.attributes, diagnostics);
945    validate_inline_nodes(&directive.label, diagnostics);
946}
947
948fn validate_directive_name(span: Option<Span>, name: &str, diagnostics: &mut Report) {
949    if !is_directive_name(name) {
950        diagnostics.push(Diagnostic::invalid(
951            span,
952            "directive name must be runs of ASCII letters joined by single `-`",
953        ));
954    }
955}
956
957fn validate_directive_attributes(attributes: &[DirectiveAttribute], diagnostics: &mut Report) {
958    for attribute in attributes {
959        if !is_attribute_name(&attribute.name) {
960            diagnostics.push(Diagnostic::invalid(
961                None,
962                "directive attribute name must start with a letter, `_`, or `-`",
963            ));
964        }
965    }
966}
967
968/// Whether `name` is one or more runs of ASCII letters joined by single `-`.
969pub(crate) fn is_directive_name(name: &str) -> bool {
970    name.split('-')
971        .all(|run| !run.is_empty() && run.bytes().all(|byte| byte.is_ascii_alphabetic()))
972}
973
974pub(crate) fn is_attribute_name(name: &str) -> bool {
975    let mut chars = name.chars();
976    let Some(first) = chars.next() else {
977        return false;
978    };
979    if !(first.is_ascii_alphabetic() || first == '_' || first == '-') {
980        return false;
981    }
982    chars.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-' || char == ':')
983}