Skip to main content

markdown_syntax/
validate.rs

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