use alloc::vec::Vec;
use crate::{
ast::{
Alert, Block, CodeBlock, CodeBlockKind, CodeInline, ContainerDirective, Definition,
DirectiveAttribute, Document, Escape, Frontmatter, Heading, HtmlContainer,
HtmlContainerContent, Inline, LeafDirective, LineBreakKind, LinkDestinationKind, List,
ListItem, MathInline, MathInlineKind, NodePath, ReferenceKind, Table, TextDirective,
},
diagnostic::Diagnostic,
parse::{
alert_title, frontmatter_fence_kind, html_block_ends_at_blank_line, html_block_left_open,
html_block_opens, interrupts_paragraph, is_blank, is_footnote_label, is_reference_label,
is_written_footnote_label, normalize_label, reads_as_inline_math, MAX_BLOCK_NESTING,
},
serialize::{
item_misreads_after_marker, write_inline_math, written_marker, written_opens_fence_like,
},
span::Span,
};
impl Document {
pub fn validate(&self) -> Vec<Diagnostic> {
validate_document(self)
}
}
pub(crate) fn validate_document(document: &Document) -> Vec<Diagnostic> {
let mut report = Report::default();
validate_blocks(&document.children, 0, false, &mut report);
report.diagnostics
}
#[derive(Default)]
struct Report {
diagnostics: Vec<Diagnostic>,
path: Vec<usize>,
}
impl Report {
fn push(&mut self, mut diagnostic: Diagnostic) {
diagnostic.path = Some(NodePath(self.path.clone()));
self.diagnostics.push(diagnostic);
}
fn at(&mut self, index: usize, check: impl FnOnce(&mut Self)) {
self.path.push(index);
check(self);
self.path.pop();
}
}
fn validate_blocks(blocks: &[Block], depth: usize, tight: bool, diagnostics: &mut Report) {
let mut before: Option<&Block> = None;
for (index, block) in blocks.iter().enumerate() {
diagnostics.at(index, |diagnostics| {
if let (Some(Block::List(before)), Block::List(list)) = (before, block) {
if written_marker(before) == written_marker(list) {
diagnostics.push(Diagnostic::invalid(
list.meta.span,
"adjacent lists cannot use the same marker",
));
}
}
if let Some(before) = before {
validate_taken_block(before, block, tight, diagnostics);
}
if let Block::Definition(definition) = block {
let indented = match before {
Some(before) => matches!(before, Block::Definition(_)),
None => depth > 0,
};
validate_definition(definition, depth, indented, diagnostics);
}
validate_block(block, depth, diagnostics);
});
if !matches!(block, Block::BlankLine(_)) {
before = Some(block);
}
}
}
fn validate_block(block: &Block, depth: usize, diagnostics: &mut Report) {
match block {
Block::Paragraph(paragraph) => {
if paragraph.children.iter().all(is_empty_text) {
diagnostics.push(Diagnostic::invalid(
paragraph.meta.span,
"paragraph cannot be empty",
));
}
validate_inlines(¶graph.children, diagnostics);
}
Block::Heading(heading) => validate_heading(heading, diagnostics),
Block::BlockQuote(block_quote) => {
validate_blocks(&block_quote.children, depth + 1, false, diagnostics);
}
Block::Alert(alert) => validate_alert(alert, depth, diagnostics),
Block::List(list) => validate_list(list, depth, diagnostics),
Block::Table(table) => validate_table(table, diagnostics),
Block::FootnoteDefinition(definition) => {
if definition.identifier.is_empty() {
diagnostics.push(Diagnostic::invalid(
definition.meta.span,
"footnote definition identifier cannot be empty",
));
}
validate_footnote_label(
definition.meta.span,
&definition.label,
&definition.identifier,
diagnostics,
);
let last = definition.children.len().saturating_sub(1);
if let Some((at, Block::BlankLine(blank))) = [0, last]
.into_iter()
.filter_map(|at| definition.children.get(at).map(|block| (at, block)))
.find(|(_, block)| matches!(block, Block::BlankLine(_)))
{
diagnostics.at(at, |diagnostics| {
diagnostics.push(Diagnostic::invalid(
blank.meta.span,
"a footnote definition cannot open or end with a blank line",
))
});
}
validate_blocks(&definition.children, depth + 1, false, diagnostics);
}
Block::Definition(_) => {}
Block::LeafDirective(directive) => validate_leaf_directive(directive, diagnostics),
Block::ContainerDirective(directive) => {
validate_container_directive(directive, depth, diagnostics)
}
Block::HtmlContainer(container) => validate_html_container(container, depth, diagnostics),
Block::CodeBlock(code) => validate_code_block(code, diagnostics),
Block::Frontmatter(frontmatter) => validate_frontmatter(frontmatter, depth, diagnostics),
Block::ThematicBreak(_)
| Block::HtmlBlock(_)
| Block::MathBlock(_)
| Block::BlankLine(_) => {}
}
}
fn validate_html_container(container: &HtmlContainer, depth: usize, diagnostics: &mut Report) {
if container.opening.name.is_empty() {
diagnostics.push(Diagnostic::invalid(
container.opening.meta.span,
"HTML container opening tag name cannot be empty",
));
}
if container.closing.name.is_empty() {
diagnostics.push(Diagnostic::invalid(
container.closing.meta.span,
"HTML container closing tag name cannot be empty",
));
}
if container.opening.name != container.closing.name {
diagnostics.push(Diagnostic::invalid(
container.meta.span,
"HTML container opening and closing tag names must match",
));
}
if container.opening.raw.is_empty() {
diagnostics.push(Diagnostic::invalid(
container.opening.meta.span,
"HTML container opening tag source cannot be empty",
));
}
if container.closing.raw.is_empty() {
diagnostics.push(Diagnostic::invalid(
container.closing.meta.span,
"HTML container closing tag source cannot be empty",
));
}
match &container.content {
HtmlContainerContent::Blocks(children) => {
validate_blocks(children, depth + 1, false, diagnostics);
}
HtmlContainerContent::Inlines(children) => validate_inlines(children, diagnostics),
}
}
fn validate_heading(heading: &Heading, diagnostics: &mut Report) {
if heading.depth == 0 || heading.depth > 6 {
diagnostics.push(Diagnostic::invalid(
heading.meta.span,
"heading depth must be in the range 1..=6",
));
}
validate_inlines(&heading.children, diagnostics);
}
fn validate_alert(alert: &Alert, depth: usize, diagnostics: &mut Report) {
if let Some(title) = &alert.title {
if alert_title(title).as_deref() != Some(title.as_str()) {
diagnostics.push(Diagnostic::invalid(
alert.meta.span,
"alert title cannot be empty or start or end with a space or a tab",
));
}
}
validate_blocks(&alert.children, depth + 1, false, diagnostics);
}
fn validate_list(list: &List, depth: usize, diagnostics: &mut Report) {
if list.children.is_empty() {
diagnostics.push(Diagnostic::invalid(
list.meta.span,
"list must hold at least one item",
));
}
if !list.tight
&& matches!(list.children.as_slice(), [item] if item
.children
.iter()
.filter(|block| !matches!(block, Block::BlankLine(_)))
.count()
<= 1)
{
diagnostics.push(Diagnostic::invalid(
list.meta.span,
"a loose list must hold two items or an item of two blocks",
));
}
for (index, item) in list.children.iter().enumerate() {
diagnostics.at(index, |diagnostics| {
validate_item_number(list, item, diagnostics);
validate_item_first_line(list, item, diagnostics);
validate_item_blank_lines(list, index, item, diagnostics);
validate_task_item(item, diagnostics);
validate_blocks(&item.children, depth + 1, list.tight, diagnostics);
});
}
}
fn validate_task_item(item: &ListItem, diagnostics: &mut Report) {
if item.checked.is_none() {
return;
}
let first = item
.children
.iter()
.find(|block| !matches!(block, Block::Definition(_)));
if !matches!(first, Some(Block::Paragraph(paragraph)) if !paragraph.children.is_empty()) {
diagnostics.push(Diagnostic::invalid(
item.meta.span,
"task list item must hold a non-empty paragraph after its leading definitions",
));
}
}
fn validate_taken_block(left: &Block, right: &Block, tight: bool, diagnostics: &mut Report) {
let message = if matches!(left, Block::HtmlBlock(node) if html_block_left_open(node)) {
"a block must not follow an HTML block that its value leaves open"
} else if matches!(right, Block::CodeBlock(code) if code.kind == CodeBlockKind::Indented)
&& takes_indented_code(left)
{
"an indented code block must not be read as part of the block before it"
} else if tight && matches!(left, Block::Paragraph(_)) && takes_next_line(left, right) {
"in a tight list item, a block after a paragraph must be able to interrupt it"
} else if tight && takes_next_line(left, right) {
"in a tight list item, a block must not be read as part of the block before it"
} else {
return;
};
diagnostics.push(Diagnostic::invalid(right.span(), message));
}
fn takes_indented_code(left: &Block) -> bool {
match left {
Block::CodeBlock(code) => code.kind == CodeBlockKind::Indented,
Block::FootnoteDefinition(_) => true,
_ => false,
}
}
fn takes_next_line(left: &Block, right: &Block) -> bool {
match left {
Block::Paragraph(_) => !interrupts_paragraph(right),
Block::Definition(_) => {
!interrupts_paragraph(right)
&& !matches!(right, Block::Paragraph(_) | Block::Definition(_))
}
Block::Table(_) => !starts_outside_paragraph(right, false),
Block::HtmlBlock(node) => html_block_ends_at_blank_line(node),
Block::BlockQuote(_) | Block::Alert(_)
if matches!(right, Block::BlockQuote(_) | Block::Alert(_)) =>
{
true
}
_ => match ends_in_open_paragraph(left) {
Some(through_quote) => {
!starts_outside_paragraph(right, true)
&& !(through_quote && written_opens_fence_like(right))
}
None => false,
},
}
}
fn starts_outside_paragraph(block: &Block, lazy: bool) -> bool {
match block {
Block::Paragraph(_) | Block::Definition(_) | Block::Table(_) => false,
Block::HtmlBlock(node) => html_block_opens(node),
Block::CodeBlock(code) => !(lazy && code.kind == CodeBlockKind::Indented),
_ => true,
}
}
fn ends_in_open_paragraph(block: &Block) -> Option<bool> {
let mut block = block;
let mut nested = false;
let mut through_quote = false;
loop {
let children = match block {
Block::Paragraph(_) | Block::Definition(_) => return nested.then_some(through_quote),
Block::Alert(alert) if alert.children.is_empty() => return Some(true),
Block::BlockQuote(quote) => "e.children,
Block::Alert(alert) => &alert.children,
Block::FootnoteDefinition(footnote) => &footnote.children,
Block::List(list) => &list.children.last()?.children,
_ => return None,
};
through_quote |= matches!(block, Block::BlockQuote(_) | Block::Alert(_));
block = children.last()?;
nested = true;
}
}
fn validate_table(table: &Table, diagnostics: &mut Report) {
if table.rows.is_empty() {
diagnostics.push(Diagnostic::invalid(
table.meta.span,
"table must contain at least a header row",
));
return;
}
let width = table.rows[0].cells.len();
if width == 0 {
diagnostics.push(Diagnostic::invalid(
table.meta.span,
"table header row must contain at least one cell",
));
}
if table.alignments.len() != width {
diagnostics.push(Diagnostic::invalid(
table.meta.span,
"table alignment count must match header width",
));
}
for (index, row) in table.rows.iter().enumerate() {
diagnostics.at(index, |diagnostics| {
if row.cells.len() != width {
diagnostics.push(Diagnostic::invalid(
row.meta.span,
"table row width must match header width",
));
}
for (index, cell) in row.cells.iter().enumerate() {
diagnostics.at(index, |diagnostics| {
validate_inlines(&cell.children, diagnostics);
validate_cell_values(&cell.children, diagnostics);
validate_one_line(
&cell.children,
"a table cell cannot hold a line break",
diagnostics,
);
});
}
});
}
}
fn validate_cell_values(inlines: &[Inline], diagnostics: &mut Report) {
for inline in inlines {
let unwritable = match inline {
Inline::Code(node) => has_odd_escaped_pipe(&node.value),
Inline::Math(node) => has_odd_escaped_pipe(&node.value),
Inline::Html(node) => has_odd_escaped_pipe(&node.value),
Inline::Autolink(node) => has_odd_escaped_pipe(&node.text),
Inline::LinkReference(node) => has_odd_escaped_pipe(&node.label),
Inline::ImageReference(node) => has_odd_escaped_pipe(&node.label),
Inline::FootnoteReference(node) => has_odd_escaped_pipe(&node.label),
Inline::WikiLink(node) => {
has_odd_escaped_pipe(&node.target) || has_odd_escaped_pipe(&node.label)
}
_ => false,
};
if unwritable {
diagnostics.push(Diagnostic::invalid(
inline.span(),
"a value in a table cell cannot hold a `|` after an odd run of backslashes",
));
}
validate_cell_values(inline.children(), diagnostics);
}
}
fn has_odd_escaped_pipe(value: &str) -> bool {
let mut backslashes = 0usize;
for byte in value.bytes() {
if byte == b'|' && backslashes % 2 == 1 {
return true;
}
backslashes = if byte == b'\\' { backslashes + 1 } else { 0 };
}
false
}
fn validate_one_line(inlines: &[Inline], message: &'static str, diagnostics: &mut Report) {
for inline in inlines {
if matches!(inline, Inline::SoftBreak(_) | Inline::LineBreak(_)) {
diagnostics.push(Diagnostic::invalid(inline.span(), message));
}
validate_one_line(inline.children(), message, diagnostics);
}
}
fn validate_leaf_directive(directive: &LeafDirective, diagnostics: &mut Report) {
validate_directive_name(directive.meta.span, &directive.name, diagnostics);
validate_directive_attributes(&directive.attributes, diagnostics);
validate_inlines(&directive.label, diagnostics);
validate_one_line(
&directive.label,
"a leaf directive label cannot hold a line break",
diagnostics,
);
}
fn validate_container_directive(
directive: &ContainerDirective,
depth: usize,
diagnostics: &mut Report,
) {
validate_directive_name(directive.meta.span, &directive.name, diagnostics);
validate_directive_attributes(&directive.attributes, diagnostics);
validate_inlines(&directive.label, diagnostics);
validate_one_line(
&directive.label,
"a container directive label cannot hold a line break",
diagnostics,
);
validate_blocks(&directive.children, depth + 1, false, diagnostics);
}
fn validate_definition(
definition: &Definition,
depth: usize,
indented: bool,
diagnostics: &mut Report,
) {
if definition
.identifier
.trim_matches([' ', '\t', '\n', '\r'])
.is_empty()
{
diagnostics.push(Diagnostic::invalid(
definition.meta.span,
"definition identifier cannot be empty",
));
}
validate_reference_label(
definition.meta.span,
&definition.label,
&definition.identifier,
true,
diagnostics,
);
let footnote = definition
.label
.strip_prefix('^')
.is_some_and(is_footnote_label);
if footnote && depth < MAX_BLOCK_NESTING && !indented {
diagnostics.push(Diagnostic::invalid(
definition.meta.span,
"definition label cannot be `^` followed by a footnote label",
));
}
validate_destination(
definition.meta.span,
&definition.destination,
definition.destination_kind,
diagnostics,
);
}
fn validate_reference_label(
span: Option<Span>,
label: &str,
identifier: &str,
written: bool,
diagnostics: &mut Report,
) {
if normalize_label(label) != identifier {
diagnostics.push(Diagnostic::invalid(
span,
"reference identifier must be the normalized label",
));
}
if written && !is_reference_label(label) {
diagnostics.push(Diagnostic::invalid(
span,
"reference label must be one the parser reads as a label",
));
}
}
fn validate_footnote_label(
span: Option<Span>,
label: &str,
identifier: &str,
diagnostics: &mut Report,
) {
if normalize_label(label) != identifier {
diagnostics.push(Diagnostic::invalid(
span,
"footnote identifier must be the normalized label",
));
}
if !is_written_footnote_label(label) {
diagnostics.push(Diagnostic::invalid(
span,
"footnote label must be one the parser reads as a footnote label",
));
}
}
fn validate_destination(
span: Option<Span>,
destination: &str,
kind: LinkDestinationKind,
diagnostics: &mut Report,
) {
if kind == LinkDestinationKind::Bare && destination.is_empty() {
diagnostics.push(Diagnostic::invalid(
span,
"a bare destination cannot be empty",
));
}
}
fn validate_code_block(code: &CodeBlock, diagnostics: &mut Report) {
if code.info.as_deref() == Some("") {
diagnostics.push(Diagnostic::invalid(
code.meta.span,
"code block info string cannot be empty",
));
}
if code.kind == CodeBlockKind::Indented {
let value = code.value.replace("\r\n", "\n");
let body = value.strip_suffix(['\n', '\r']).unwrap_or(&value);
let mut lines = body.split(['\n', '\r']);
let first = lines.next().unwrap_or_default();
let last = lines.next_back().unwrap_or(first);
if is_blank(first) || is_blank(last) {
diagnostics.push(Diagnostic::invalid(
code.meta.span,
"indented code cannot be empty or start or end with a blank line",
));
}
}
}
fn validate_frontmatter(frontmatter: &Frontmatter, depth: usize, diagnostics: &mut Report) {
if depth > 0 {
diagnostics.push(Diagnostic::invalid(
frontmatter.meta.span,
"frontmatter cannot be inside a container",
));
}
if frontmatter
.value
.replace("\r\n", "\n")
.split(['\n', '\r'])
.any(|line| frontmatter_fence_kind(line) == Some(frontmatter.kind))
{
diagnostics.push(Diagnostic::invalid(
frontmatter.meta.span,
"frontmatter value cannot hold its own fence line",
));
}
}
fn validate_inlines(inlines: &[Inline], diagnostics: &mut Report) {
if let Some(Inline::LineBreak(node)) = inlines.last() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"hard line break cannot be the final inline of its container",
));
}
validate_inline_nodes(inlines, diagnostics);
}
fn validate_inline_nodes(inlines: &[Inline], diagnostics: &mut Report) {
for (index, inline) in inlines.iter().enumerate() {
if let Some(before) = index.checked_sub(1).map(|before| &inlines[before]) {
validate_siblings(before, inline, diagnostics);
}
match inline {
Inline::Text(node) => {
if node.value.contains(['\n', '\r']) {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"text cannot hold a line ending; a line ends with a soft or hard break",
));
}
}
Inline::Emphasis(_) | Inline::Strong(_) | Inline::Delete(_) | Inline::Mark(_) => {
validate_emphasis_container(inline, diagnostics)
}
Inline::Shortcode(node) => {
if node.glyph().is_none() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"shortcode name must be a gemoji name",
));
}
}
Inline::Tag(node) => {
if !crate::unicode_letters::is_tag_name(&node.name) {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"tag name must be a Unicode letter followed by letters, numbers, `/`, `_`, or `-`",
));
}
}
Inline::Link(node) => {
validate_destination(
node.meta.span,
&node.destination,
node.destination_kind,
diagnostics,
);
validate_link_text(&node.children, diagnostics);
validate_inline_nodes(&node.children, diagnostics);
}
Inline::Autolink(node) => {
if node.destination().is_none() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"autolink text must be exactly one autolink of its form",
));
}
}
Inline::Image(node) => {
validate_destination(
node.meta.span,
&node.destination,
node.destination_kind,
diagnostics,
);
validate_inline_nodes(&node.alt, diagnostics);
}
Inline::LinkReference(node) => {
if node.identifier.is_empty() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"link reference identifier cannot be empty",
));
}
validate_reference_label(
node.meta.span,
&node.label,
&node.identifier,
node.kind == ReferenceKind::Full,
diagnostics,
);
validate_link_text(&node.children, diagnostics);
validate_inline_nodes(&node.children, diagnostics);
}
Inline::ImageReference(node) => {
if node.identifier.is_empty() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"image reference identifier cannot be empty",
));
}
validate_reference_label(
node.meta.span,
&node.label,
&node.identifier,
node.kind == ReferenceKind::Full,
diagnostics,
);
validate_inline_nodes(&node.alt, diagnostics);
}
Inline::Escape(node) => validate_escape(node, diagnostics),
Inline::CharacterReference(node) => {
if node.value().is_none() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"character reference must be exactly one character reference",
));
}
}
Inline::TextDirective(node) => validate_text_directive(node, diagnostics),
Inline::FootnoteReference(node) => {
if node.identifier.is_empty() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"footnote reference identifier cannot be empty",
));
}
validate_footnote_label(node.meta.span, &node.label, &node.identifier, diagnostics);
}
Inline::InlineFootnote(node) => {
if node.children.is_empty() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"inline footnote cannot be empty",
));
}
validate_inline_nodes(&node.children, diagnostics)
}
Inline::WikiLink(node) => {
if node.target.is_empty() {
diagnostics.push(Diagnostic::invalid(
node.meta.span,
"wikilink target cannot be empty",
));
}
}
Inline::Code(node) => validate_code_inline(node, diagnostics),
Inline::Math(node) => validate_math_inline(node, diagnostics),
Inline::Html(_) | Inline::SoftBreak(_) | Inline::LineBreak(_) => {}
}
}
}
fn validate_siblings(before: &Inline, inline: &Inline, diagnostics: &mut Report) {
let after_break = matches!(before, Inline::SoftBreak(_) | Inline::LineBreak(_));
let blank_line = match inline {
Inline::SoftBreak(_) => true,
Inline::LineBreak(node) => node.kind == LineBreakKind::Spaces,
_ => false,
};
if after_break && blank_line {
diagnostics.push(Diagnostic::invalid(
inline.span(),
"a soft break or a spaces hard break cannot follow a line break",
));
}
if matches!((before, inline), (Inline::Delete(_), Inline::Delete(_))) {
diagnostics.push(Diagnostic::invalid(
inline.span(),
"adjacent strikethroughs cannot be written",
));
}
}
fn validate_emphasis_container(node: &Inline, diagnostics: &mut Report) {
let children = node.children();
let span = node.span();
if children.iter().all(is_empty_text) {
diagnostics.push(Diagnostic::invalid(
span,
"emphasis-like inline container cannot have empty children",
));
} else if starts_with_whitespace(children) || ends_with_whitespace(children) {
diagnostics.push(Diagnostic::invalid(
span,
"emphasis-like inline content cannot start or end with whitespace",
));
}
let joined = match (node, children) {
(Inline::Emphasis(outer), [Inline::Emphasis(inner)]) => outer.delimiter == inner.delimiter,
(Inline::Strong(outer), [Inline::Emphasis(inner)]) => outer.delimiter == inner.delimiter,
(Inline::Delete(_), [Inline::Delete(_)]) | (Inline::Mark(_), [Inline::Mark(_)]) => true,
_ => false,
};
if joined {
diagnostics.push(Diagnostic::invalid(
span,
"emphasis-like span cannot hold only a span written with the same delimiter",
));
}
validate_inlines(children, diagnostics);
}
fn starts_with_whitespace(inlines: &[Inline]) -> bool {
match inlines.iter().find(|inline| !is_empty_text(inline)) {
Some(Inline::Text(text)) => text.value.starts_with(char::is_whitespace),
Some(Inline::SoftBreak(_)) => true,
Some(Inline::LineBreak(node)) => node.kind == LineBreakKind::Spaces,
_ => false,
}
}
fn ends_with_whitespace(inlines: &[Inline]) -> bool {
match inlines.iter().rev().find(|inline| !is_empty_text(inline)) {
Some(Inline::Text(text)) => text.value.ends_with(char::is_whitespace),
Some(Inline::SoftBreak(_) | Inline::LineBreak(_)) => true,
_ => false,
}
}
fn is_empty_text(inline: &Inline) -> bool {
matches!(inline, Inline::Text(text) if text.value.is_empty())
}
fn validate_link_text(inlines: &[Inline], diagnostics: &mut Report) {
for inline in inlines {
match inline {
Inline::Link(_)
| Inline::Autolink(_)
| Inline::LinkReference(_)
| Inline::WikiLink(_) => {
diagnostics.push(Diagnostic::invalid(
inline.span(),
"link text cannot hold a link",
));
}
_ => validate_link_text(inline.children(), diagnostics),
}
}
}
fn validate_escape(escape: &Escape, diagnostics: &mut Report) {
if !escape.value.is_ascii_punctuation() {
diagnostics.push(Diagnostic::invalid(
escape.meta.span,
"escaped value must be an ASCII punctuation character",
));
}
}
fn validate_code_inline(code: &CodeInline, diagnostics: &mut Report) {
if code.value.is_empty() {
diagnostics.push(Diagnostic::invalid(
code.meta.span,
"inline code value cannot be empty",
));
}
if code.value.contains(['\n', '\r']) {
diagnostics.push(Diagnostic::invalid(
code.meta.span,
"inline code value cannot hold a line ending",
));
}
}
fn validate_math_inline(math: &MathInline, diagnostics: &mut Report) {
if math.value.is_empty() {
diagnostics.push(Diagnostic::invalid(
math.meta.span,
"inline math value cannot be empty",
));
}
match math.kind {
MathInlineKind::Dollar { dollars: 0 } => diagnostics.push(Diagnostic::invalid(
math.meta.span,
"dollar-fenced inline math must have a fence length of at least 1",
)),
MathInlineKind::Code if math.value.contains("`$") => diagnostics.push(Diagnostic::invalid(
math.meta.span,
"code-form inline math value cannot contain a backtick followed by `$`",
)),
MathInlineKind::Dollar { .. }
if !math.value.is_empty()
&& !reads_as_inline_math(&write_inline_math(math), &math.value, math.kind) =>
{
diagnostics.push(Diagnostic::invalid(
math.meta.span,
"dollar-fenced inline math must read back as written",
));
}
_ => {}
}
}
fn validate_item_number(list: &List, item: &ListItem, diagnostics: &mut Report) {
let Some(number) = item.number else {
return;
};
if !list.ordered {
diagnostics.push(Diagnostic::invalid(
item.meta.span,
"a bullet list item cannot hold a number",
));
} else if number > crate::parse::MAX_ORDERED_NUMBER {
diagnostics.push(Diagnostic::invalid(
item.meta.span,
"ordered list item number must be representable in at most 9 digits",
));
}
}
fn validate_item_first_line(list: &List, item: &ListItem, diagnostics: &mut Report) {
if item_misreads_after_marker(list, item) {
diagnostics.push(Diagnostic::invalid(
item.meta.span,
"a list item's first line must not read as padding after its marker or as a thematic break with it",
));
}
}
fn validate_item_blank_lines(list: &List, index: usize, item: &ListItem, diagnostics: &mut Report) {
let last_item = index + 1 == list.children.len();
for (at, block) in item.children.iter().enumerate() {
let Block::BlankLine(blank) = block else {
continue;
};
let message = if list.tight {
"a tight list cannot hold a blank line"
} else if at == 0 {
"a list item cannot open with a blank line"
} else if last_item && at + 1 == item.children.len() {
"the last item of a list cannot end with a blank line"
} else {
continue;
};
diagnostics.at(at, |diagnostics| {
diagnostics.push(Diagnostic::invalid(blank.meta.span, message))
});
}
}
fn validate_text_directive(directive: &TextDirective, diagnostics: &mut Report) {
validate_directive_name(directive.meta.span, &directive.name, diagnostics);
validate_directive_attributes(&directive.attributes, diagnostics);
validate_inline_nodes(&directive.label, diagnostics);
}
fn validate_directive_name(span: Option<Span>, name: &str, diagnostics: &mut Report) {
if !is_directive_name(name) {
diagnostics.push(Diagnostic::invalid(
span,
"directive name must be runs of ASCII letters joined by single `-`",
));
}
}
fn validate_directive_attributes(attributes: &[DirectiveAttribute], diagnostics: &mut Report) {
for attribute in attributes {
if !is_attribute_name(&attribute.name) {
diagnostics.push(Diagnostic::invalid(
None,
"directive attribute name must start with a letter, `_`, or `-`",
));
}
}
}
pub(crate) fn is_directive_name(name: &str) -> bool {
name.split('-')
.all(|run| !run.is_empty() && run.bytes().all(|byte| byte.is_ascii_alphabetic()))
}
pub(crate) fn is_attribute_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_' || first == '-') {
return false;
}
chars.all(|char| char.is_ascii_alphanumeric() || char == '_' || char == '-' || char == ':')
}