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