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