1use 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 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#[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 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
63fn validate_blocks(blocks: &[Block], depth: usize, tight: bool, diagnostics: &mut Report) {
69 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 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 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(¶graph.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 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 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
218fn 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 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
264fn 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
282fn 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
304fn 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
318fn takes_next_line(left: &Block, right: &Block) -> bool {
327 match left {
328 Block::Paragraph(_) => !written_after_paragraph_interrupts(right),
329 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 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
356fn 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
372fn 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) => "e.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
446fn 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
475fn 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
487fn 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
525fn 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
571fn 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
595fn 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
617fn 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
633fn 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 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
660fn 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
682fn 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
695fn 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
833fn 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
862fn 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
898fn 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
911fn 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
925fn 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
952fn 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
969fn 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 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
1021fn 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
1071pub(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}