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