#![allow(clippy::too_many_arguments)]
use bumpalo::collections::String as BumpString;
use std::{
borrow::Cow,
collections::{HashMap, HashSet, VecDeque},
ops::Range,
rc::Rc,
};
use crate::{
Admonition, AdmonitionVariant, Anchor, AttributeValue, Attribution, Audio, Author, Block,
BlockMetadata, CalloutList, CalloutListItem, CalloutRef, CiteTitle, ColumnStyle, ColumnWidth,
Comment, CommentKind, DelimitedBlock, DelimitedBlockType, DescriptionList, DescriptionListItem,
DiscreteHeader, Document, DocumentAttribute, DocumentAttributes, ElementAttributes, Error,
Header, HorizontalAlignment, Image, InlineMacro, InlineNode, ListItem, ListItemCheckedStatus,
Location, OrderedList, PageBreak, Paragraph, Plain, Position, Raw, Reference, Section, Source,
SourceLocation, StemContent, StemNotation, Subtitle, Table, TableOfContents, TablePresentation,
TableRow, ThematicBreak, Title, TocEntry, UnorderedList, Verbatim, VerticalAlignment, Video,
Warning, WarningKind,
blocks::table::MAX_TABLE_COLUMNS,
document_attribute::{AttributeDeclaration, RawAttributeValue},
grammar::{
ParserState,
author::{derive_author_attrs, register_author_attrs},
doctype::{is_book_doctype, is_manpage_doctype},
inline_preprocessing,
inline_preprocessor::InlinePreprocessorParserState,
inline_processing::{adjust_and_log_parse_error, process_inlines},
location_walk::{walk_document_inline_nodes_mut, walk_inline_nodes_mut},
manpage::{NameSectionAttributes, derive_manpage_header_attrs, derive_name_section_attrs},
marked_text::MarkedText,
revision::{IgnoredRevisionFields, RevisionInfo, process_revision_info},
state::BlockContext,
table::parse_table_cell,
},
model::{
Caption, CaptionKind, LeveloffsetRange, ListLevel, PositionalAttribute, SectionKind,
SectionLevel, Substitution, caption, section, strip_quotes, substitute,
substitution::{HEADER, SubstitutionPlan, VERBATIM},
},
};
#[cfg(feature = "pre-spec-subs")]
use crate::{
grammar::inline_processing::process_verbatim_inlines, model::substitution::parse_subs_attribute,
};
use super::{
helpers::{
AttributeOrAnchorLine, BlockMetadataLine, BlockParsingMetadata, MacroAttributeContext,
PositionWithOffset, RESERVED_NAMED_ATTRIBUTE_ID, RESERVED_NAMED_ATTRIBUTE_OPTIONS,
RESERVED_NAMED_ATTRIBUTE_ROLE, RESERVED_NAMED_ATTRIBUTE_SUBS, is_valid_bibliography_id,
parse_comma_separated_values, restore_url_path, title_looks_like_description_list,
},
setext,
};
struct ManpageNameSection<'input> {
title: &'input str,
attributes: NameSectionAttributes,
metadata_attributes: Vec<AttributeDeclaration<'input>>,
}
fn prepare_manpage_name_attributes<'input>(
state: &mut ParserState<'input>,
section: Option<ManpageNameSection<'input>>,
) {
if !is_manpage_doctype(&state.document_attributes) {
return;
}
if let (Some(name), Some(purpose)) = (
state.document_attributes.text("manname").map(strip_quotes),
state
.document_attributes
.text("manpurpose")
.map(strip_quotes),
) {
let name = state.intern_str(name);
let purpose = state.intern_str(purpose);
set_manpage_name_attributes(state, name, Some(purpose), Some("Name"));
return;
}
if let Some(section) = section {
let mut attributes = DocumentAttributes::clone(&state.document_attributes);
for AttributeDeclaration { name, value } in section.metadata_attributes {
let value = value.resolve(&attributes);
let _ = attributes.assign_document_value(name.into(), value, false, false, None);
}
let name = substitute(§ion.attributes.name, HEADER, &attributes);
let name = name.split(',').next().unwrap_or_default().trim();
let name = state.intern_str(name);
let purpose = substitute(§ion.attributes.purpose, HEADER, &attributes);
let purpose = state.intern_str(&purpose);
let title = substitute(section.title, HEADER, &attributes);
let title = state.intern_str(&title);
set_manpage_name_attributes(state, name, Some(purpose), Some(title));
return;
}
let fallback = state
.document_attributes
.text("docname")
.map_or("command", strip_quotes);
let fallback = state.intern_str(fallback);
set_manpage_name_attributes(state, fallback, None, None);
}
fn set_manpage_name_attributes<'input>(
state: &mut ParserState<'input>,
name: &'input str,
purpose: Option<&'input str>,
title: Option<&'input str>,
) {
let attributes = Rc::make_mut(&mut state.document_attributes);
attributes.set_text("manname".into(), name.into());
if let Some(purpose) = purpose {
attributes.set_text("manpurpose".into(), purpose.into());
}
if let Some(title) = title {
attributes.insert_text("manname-title".into(), title.into());
}
if attributes.text("backend").map(strip_quotes) == Some("manpage") {
attributes.set_text("docname".into(), name.into());
}
}
fn assign_block_caption<'input>(state: &ParserState<'input>, block: &mut Block<'input>) {
let Some(kind) = CaptionKind::for_block(block) else {
return;
};
if let Some(metadata) = block.metadata_mut() {
let caption = Caption::resolve(metadata, &state.document_attributes, kind);
metadata.caption = Some(caption);
}
}
fn order_document_attribute_events(blocks: Vec<Block<'_>>) -> Vec<Block<'_>> {
let needs_ordering = blocks.iter().any(|block| {
if let Block::DocumentAttribute(attribute) = block {
return !attribute.is_accepted();
}
block
.metadata()
.is_some_and(BlockMetadata::has_document_attributes)
});
if !needs_ordering {
return blocks;
}
let mut ordered = Vec::with_capacity(blocks.len());
for mut block in blocks {
if matches!(&block, Block::DocumentAttribute(attribute) if !attribute.is_accepted()) {
continue;
}
if let Some(metadata) = block.metadata_mut() {
ordered.extend(
metadata
.take_document_attributes()
.into_iter()
.map(Block::DocumentAttribute),
);
}
ordered.push(block);
}
ordered
}
fn push_metadata_anchor<'a>(metadata: &mut BlockMetadata<'a>, anchor: Anchor<'a>) {
if let Some(label) = anchor.xreflabel
&& metadata.attributes.contains_key("reftext")
{
metadata.attributes.set(
"reftext".into(),
AttributeValue::String(Cow::Borrowed(label)),
);
}
metadata.anchors.push(anchor);
}
fn merge_attribute_metadata<'input>(
metadata: &mut BlockMetadata<'input>,
mut attribute_metadata: BlockMetadata<'input>,
) {
metadata.append_document_attributes(attribute_metadata.take_document_attributes());
if attribute_metadata.id.is_some() {
metadata.id = attribute_metadata.id;
}
if attribute_metadata.style.is_some() {
metadata.style = attribute_metadata.style;
}
metadata.roles.extend(attribute_metadata.roles);
metadata.options.extend(attribute_metadata.options);
for (name, value) in attribute_metadata.attributes.iter() {
metadata.attributes.set(name.clone(), value.clone());
}
metadata.overlay_positional_attributes(&attribute_metadata.positional_attributes);
#[cfg(feature = "pre-spec-subs")]
if attribute_metadata.substitutions.is_some() {
metadata.substitutions = attribute_metadata.substitutions;
}
if attribute_metadata.attribution.is_some() {
metadata.attribution = attribute_metadata.attribution;
metadata.attribution_substitutions = attribute_metadata.attribution_substitutions;
} else if attribute_metadata.attribution_substitutions {
metadata.attribution_substitutions = true;
}
if attribute_metadata.citetitle.is_some() {
metadata.citetitle = attribute_metadata.citetitle;
metadata.citetitle_substitutions = attribute_metadata.citetitle_substitutions;
} else if attribute_metadata.citetitle_substitutions {
metadata.citetitle_substitutions = true;
}
}
fn finish_block_parsing_metadata<'input>(
state: &mut ParserState<'input>,
mut metadata: BlockMetadata<'input>,
title: Title<'input>,
parent_section_level: Option<SectionLevel>,
discrete: bool,
offset: usize,
) -> Result<BlockParsingMetadata<'input>, Error> {
let discrete = discrete || matches!(metadata.style, Some("discrete" | "float"));
#[cfg(feature = "pre-spec-subs")]
let substitutions = metadata
.substitutions
.as_ref()
.map_or_else(SubstitutionPlan::default, SubstitutionPlan::for_block_spec);
#[cfg(not(feature = "pre-spec-subs"))]
let substitutions = SubstitutionPlan::default();
let hardbreaks = substitutions.enabled(&Substitution::PostReplacements)
&& (state.hardbreaks || metadata.options.contains(&"hardbreaks"));
extract_source_attributes(state, &mut metadata);
extract_quote_attributes(&mut metadata);
apply_quote_attribute_substitutions(state, &mut metadata, offset, substitutions)?;
Ok(BlockParsingMetadata {
metadata,
title,
parent_section_level,
substitutions,
hardbreaks,
discrete,
})
}
fn metadata_marks_discrete_heading(metadata: &str, state: &ParserState<'_>) -> bool {
let mut discrete = false;
for line in metadata.lines() {
let Some(attributes) = line
.strip_prefix('[')
.and_then(|line| line.strip_suffix(']'))
else {
continue;
};
if attributes.starts_with('[') {
continue;
}
let attributes = substitute(
attributes,
&[Substitution::Attributes],
&state.document_attributes,
);
let attributes = scan_attribute_list(&attributes, &[]);
if let Some(first) = attributes
.first()
.filter(|attribute| attribute.name.is_none())
{
let style = first
.value
.split(['#', '.', '%'])
.next()
.unwrap_or_default();
if !style.is_empty() {
discrete = matches!(style, "discrete" | "float");
}
}
}
discrete
}
fn check_delimiters(
open: &str,
close: &str,
block_type: &str,
detail: SourceLocation,
) -> Result<(), Error> {
if open == close {
Ok(())
} else {
Err(Error::mismatched_delimiters(detail, block_type))
}
}
fn resolve_delimited_close<'input>(
state: &mut ParserState<'input>,
p: &DelimitedParams<'input>,
) -> Result<Option<Location>, Error> {
if let Some((close_start, close_delim)) = p.close {
check_delimiters(
p.open_delim,
close_delim,
p.kind.name(),
state.create_error_source_location(
state.create_block_location(p.start, p.end, p.offset),
),
)?;
Ok(Some(state.create_block_location(
close_start,
p.end,
p.offset,
)))
} else {
let open_delimiter_location = state.create_location(
p.open_start + p.offset,
p.open_start + p.offset + p.open_delim.len().saturating_sub(1),
);
state.add_warning(Warning::new(
WarningKind::UnterminatedDelimitedBlock {
kind: p.kind.name(),
delimiter: p.open_delim.to_string(),
},
Some(state.create_error_source_location(open_delimiter_location)),
));
Ok(None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DelimitedKind {
Example,
Comment,
Listing,
Literal,
Open,
Sidebar,
Pass,
Quote,
}
impl DelimitedKind {
fn name(self) -> &'static str {
match self {
DelimitedKind::Example => "example",
DelimitedKind::Comment => "comment",
DelimitedKind::Listing => "listing",
DelimitedKind::Literal => "literal",
DelimitedKind::Open => "open",
DelimitedKind::Sidebar => "sidebar",
DelimitedKind::Pass => "pass",
DelimitedKind::Quote => "quote",
}
}
}
fn parse_block_content<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
content: &'input str,
content_start: usize,
offset: usize,
error_context: &str,
) -> Result<Vec<Block<'input>>, Error> {
if content.trim().is_empty() {
Ok(Vec::new())
} else {
let outer_context = std::mem::replace(&mut state.block_context, BlockContext::Compound);
let result = document_parser::compound_blocks(
content,
state,
content_start + offset,
block_metadata.parent_section_level,
);
state.block_context = outer_context;
result.unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start + offset, state, error_context);
Ok(Vec::new())
})
}
}
struct DelimitedParams<'input> {
kind: DelimitedKind,
open_delim: &'input str,
lang: Option<&'input str>,
content: &'input str,
source_text: &'input str,
open_start: usize,
start: usize,
content_start: usize,
content_end: usize,
end: usize,
offset: usize,
close: Option<(usize, &'input str)>,
}
fn build_delimited_block<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
p: &DelimitedParams<'input>,
) -> Result<Block<'input>, Error> {
let close_delimiter_location = resolve_delimited_close(state, p)?;
let location = state.create_block_location(p.start, p.end, p.offset);
let open_delimiter_location = state.create_location(
p.open_start + p.offset,
p.open_start + p.offset + p.open_delim.len().saturating_sub(1),
);
let mut metadata = block_metadata.metadata.clone();
let inner = match p.kind {
DelimitedKind::Example => {
return build_example_block(
state,
block_metadata,
metadata,
p,
location,
open_delimiter_location,
close_delimiter_location,
);
}
DelimitedKind::Comment => comment_inner(state, &mut metadata, p),
DelimitedKind::Listing | DelimitedKind::Literal => {
verbatim_inner(state, block_metadata, &mut metadata, p)?
}
DelimitedKind::Open => open_inner(state, block_metadata, &mut metadata, p)?,
DelimitedKind::Sidebar => sidebar_inner(state, block_metadata, &mut metadata, p)?,
DelimitedKind::Pass => pass_inner(state, &mut metadata, p),
DelimitedKind::Quote => quote_inner(state, block_metadata, &mut metadata, p)?,
};
Ok(assemble_delimited(
metadata,
p,
inner,
block_metadata.title.clone(),
location,
open_delimiter_location,
close_delimiter_location,
))
}
fn assemble_delimited<'input>(
metadata: BlockMetadata<'input>,
p: &DelimitedParams<'input>,
inner: DelimitedBlockType<'input>,
title: Title<'input>,
location: Location,
open_delimiter_location: Location,
close_delimiter_location: Option<Location>,
) -> Block<'input> {
Block::DelimitedBlock(DelimitedBlock {
metadata,
delimiter: p.open_delim,
inner,
title,
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location,
source_text: Some(p.source_text),
})
}
fn build_example_block<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
mut metadata: BlockMetadata<'input>,
p: &DelimitedParams<'input>,
location: Location,
open_delimiter_location: Location,
close_delimiter_location: Option<Location>,
) -> Result<Block<'input>, Error> {
metadata.move_positional_attributes_to_attributes();
let blocks = parse_block_content(
state,
block_metadata,
p.content,
p.content_start,
p.offset,
"Error parsing example content as blocks in example block",
)?;
if let Some(style) = block_metadata.metadata.style
&& let Ok(variant) = style.parse::<AdmonitionVariant>()
{
metadata.style = None;
return Ok(Block::Admonition(
Admonition::new(variant, blocks, location)
.with_metadata(metadata)
.with_title(block_metadata.title.clone()),
));
}
Ok(assemble_delimited(
metadata,
p,
DelimitedBlockType::DelimitedExample(blocks),
block_metadata.title.clone(),
location,
open_delimiter_location,
close_delimiter_location,
))
}
fn comment_inner<'input>(
state: &ParserState<'input>,
metadata: &mut BlockMetadata<'input>,
p: &DelimitedParams<'input>,
) -> DelimitedBlockType<'input> {
metadata.move_positional_attributes_to_attributes();
let content_location = state.create_block_location(p.content_start, p.content_end, p.offset);
DelimitedBlockType::DelimitedComment(vec![InlineNode::PlainText(Plain {
content: p.content,
location: content_location,
escaped: false,
})])
}
fn verbatim_inner<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
metadata: &mut BlockMetadata<'input>,
p: &DelimitedParams<'input>,
) -> Result<DelimitedBlockType<'input>, Error> {
if let Some(language) = p.lang {
metadata.prepend_synthetic_positional_attribute(PositionalAttribute {
value: language,
substitutions: false,
location: None,
});
metadata.style = Some("source");
}
extract_source_attributes(state, metadata);
metadata.move_positional_attributes_to_attributes();
let content_location = state.create_block_location(p.content_start, p.content_end, p.offset);
let (inlines, callouts) = resolve_verbatim_callouts(
state,
p.content,
content_location,
block_metadata
.substitutions
.enabled(&Substitution::Callouts),
!metadata.attributes.contains_key("line-comment"),
);
state.pending_callouts.extend(callouts);
let inlines = resolve_verbatim_inlines(state, block_metadata, inlines)?;
Ok(if p.kind == DelimitedKind::Literal {
DelimitedBlockType::DelimitedLiteral(inlines)
} else {
DelimitedBlockType::DelimitedListing(inlines)
})
}
fn verbatim_substitutions(metadata: &BlockParsingMetadata<'_>) -> SubstitutionPlan {
#[cfg(feature = "pre-spec-subs")]
if let Some(spec) = &metadata.metadata.substitutions {
return SubstitutionPlan::from_substitutions(&spec.resolve(VERBATIM));
}
let _ = metadata;
SubstitutionPlan::from_substitutions(VERBATIM)
}
fn needs_verbatim_inlines(substitutions: SubstitutionPlan) -> bool {
[
Substitution::Quotes,
Substitution::Attributes,
Substitution::Macros,
]
.iter()
.any(|substitution| substitutions.enabled(substitution))
}
#[cfg(feature = "pre-spec-subs")]
fn resolve_verbatim_inlines<'a>(
state: &mut ParserState<'a>,
metadata: &BlockParsingMetadata<'_>,
inlines: Vec<InlineNode<'a>>,
) -> Result<Vec<InlineNode<'a>>, Error> {
let substitutions = verbatim_substitutions(metadata);
if !needs_verbatim_inlines(substitutions) {
return Ok(inlines);
}
let metadata = BlockParsingMetadata {
substitutions,
..BlockParsingMetadata::default()
};
let mut resolved = Vec::new();
for node in inlines {
if let InlineNode::VerbatimText(text) = node {
resolved.extend(process_verbatim_inlines(
state,
&metadata,
text.location.absolute_start,
text.content,
)?);
} else {
resolved.push(node);
}
}
Ok(resolved)
}
#[cfg(not(feature = "pre-spec-subs"))]
fn resolve_verbatim_inlines<'a>(
_state: &mut ParserState<'a>,
_metadata: &BlockParsingMetadata<'_>,
inlines: Vec<InlineNode<'a>>,
) -> Result<Vec<InlineNode<'a>>, Error> {
Ok(inlines)
}
fn open_inner<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
metadata: &mut BlockMetadata<'input>,
p: &DelimitedParams<'input>,
) -> Result<DelimitedBlockType<'input>, Error> {
metadata.move_positional_attributes_to_attributes();
if block_metadata.metadata.style == Some("comment") {
metadata.style = None;
let content_location =
state.create_block_location(p.content_start, p.content_end, p.offset);
return Ok(DelimitedBlockType::DelimitedComment(vec![
InlineNode::PlainText(Plain {
content: p.content,
location: content_location,
escaped: false,
}),
]));
}
let blocks = parse_block_content(
state,
block_metadata,
p.content,
p.content_start,
p.offset,
"Error parsing content as blocks in open block",
)?;
Ok(DelimitedBlockType::DelimitedOpen(blocks))
}
fn sidebar_inner<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
metadata: &mut BlockMetadata<'input>,
p: &DelimitedParams<'input>,
) -> Result<DelimitedBlockType<'input>, Error> {
metadata.move_positional_attributes_to_attributes();
let blocks = parse_block_content(
state,
block_metadata,
p.content,
p.content_start,
p.offset,
"Error parsing sidebar content as blocks",
)?;
Ok(DelimitedBlockType::DelimitedSidebar(blocks))
}
fn pass_inner<'input>(
state: &ParserState<'input>,
metadata: &mut BlockMetadata<'input>,
p: &DelimitedParams<'input>,
) -> DelimitedBlockType<'input> {
if metadata.style == Some("stem") {
let [notation] = metadata.take_positional_attributes::<1>();
let notation = notation
.filter(|attribute| !attribute.value.is_empty())
.and_then(|attribute| attribute.value.parse::<StemNotation>().ok())
.or_else(|| {
state
.document_attributes
.text("stem")
.and_then(|value| value.parse::<StemNotation>().ok())
})
.unwrap_or(StemNotation::Latexmath);
metadata.move_positional_attributes_to_attributes();
metadata.style = None;
DelimitedBlockType::DelimitedStem(StemContent {
content: p.content,
notation,
})
} else {
metadata.move_positional_attributes_to_attributes();
let content_location =
state.create_block_location(p.content_start, p.content_end, p.offset);
DelimitedBlockType::DelimitedPass(vec![InlineNode::RawText(Raw {
content: p.content,
location: content_location,
subs: vec![],
})])
}
}
fn quote_inner<'input>(
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
metadata: &mut BlockMetadata<'input>,
p: &DelimitedParams<'input>,
) -> Result<DelimitedBlockType<'input>, Error> {
metadata.move_positional_attributes_to_attributes();
if metadata.style == Some("verse") {
let (inlines, _) = process_inlines(
state,
block_metadata,
p.content_start,
p.content_end,
p.offset,
p.content,
)?;
Ok(DelimitedBlockType::DelimitedVerse(inlines))
} else if metadata.style.is_some() {
let blocks = document_parser::blocks(
p.content,
state,
p.content_start + p.offset,
block_metadata.parent_section_level,
None,
)
.unwrap_or_else(|e| {
adjust_and_log_parse_error(
&e,
p.content,
p.content_start + p.offset,
state,
"Error parsing example content as blocks in quote block",
);
Ok(Vec::new())
})?;
Ok(DelimitedBlockType::DelimitedQuote(blocks))
} else {
let blocks = parse_block_content(
state,
block_metadata,
p.content,
p.content_start,
p.offset,
"Error parsing content as blocks in quote block",
)?;
Ok(DelimitedBlockType::DelimitedQuote(blocks))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AttributeQuote {
Unquoted,
Single,
Double,
}
#[derive(Debug)]
pub(super) struct ScannedAttribute {
pub(super) name: Option<String>,
pub(super) value: String,
quote: AttributeQuote,
pub(super) value_start: usize,
pub(super) value_end: usize,
}
#[derive(Clone, Copy)]
enum BlockAttributeMode {
Block,
Macro(MacroAttributeContext),
}
fn is_attribute_name_start(character: char) -> bool {
character == '_' || character.is_alphanumeric()
}
fn is_attribute_name_continue(character: char) -> bool {
is_attribute_name_start(character) || matches!(character, '-' | '.')
}
fn named_attribute_parts(value: &str) -> Option<(&str, usize)> {
let mut characters = value.char_indices();
let (_, first) = characters.next()?;
if !is_attribute_name_start(first) {
return None;
}
let mut name_end = first.len_utf8();
for (index, character) in characters {
if !is_attribute_name_continue(character) {
break;
}
name_end = index + character.len_utf8();
}
let remainder = &value[name_end..];
let equals_offset = remainder.len() - remainder.trim_start_matches([' ', '\t']).len();
remainder
.get(equals_offset..)
.is_some_and(|remainder| remainder.starts_with('='))
.then_some((&value[..name_end], name_end + equals_offset + 1))
}
fn closing_quote(
value: &str,
quote: char,
start: usize,
protected: &[Range<usize>],
) -> Option<usize> {
let mut escaped = false;
for (index, character) in value.char_indices().skip(1) {
if character == quote && !escaped && !inside_attribute_content(start + index, protected) {
return Some(index);
}
escaped = character == '\\';
}
None
}
pub(super) fn unescape_attribute_quote(value: &str, quote: char) -> String {
let escaped_quote = format!("\\{quote}");
value.replace(&escaped_quote, "e.to_string())
}
fn inside_attribute_content(position: usize, protected: &[Range<usize>]) -> bool {
protected
.get(protected.partition_point(|range| range.end <= position))
.is_some_and(|range| range.contains(&position))
}
fn attribute_list_comma(source: &str, start: usize, protected: &[Range<usize>]) -> Option<usize> {
if protected.is_empty() {
return source.find(',');
}
source.char_indices().find_map(|(index, character)| {
(character == ',' && !inside_attribute_content(start + index, protected)).then_some(index)
})
}
pub(super) fn scan_attribute_list(
source: &str,
protected: &[Range<usize>],
) -> Vec<ScannedAttribute> {
let mut attributes = Vec::new();
let mut cursor = 0;
loop {
let slot_start = cursor;
let remaining = &source[slot_start..];
let leading = remaining.len() - remaining.trim_start_matches([' ', '\t']).len();
let trimmed_start = slot_start + leading;
let candidate = &source[trimmed_start..];
let named = named_attribute_parts(candidate);
let raw_value_start = named.map_or(trimmed_start, |(_, start)| trimmed_start + start);
let value_leading = source[raw_value_start..].len()
- source[raw_value_start..]
.trim_start_matches([' ', '\t'])
.len();
let value_start = raw_value_start + value_leading;
let first = source[value_start..].chars().next();
if let Some(quote @ ('\'' | '"')) = first {
let quoted = &source[value_start..];
if let Some(close) = closing_quote(quoted, quote, value_start, protected) {
let after_close = value_start + close + quote.len_utf8();
attributes.push(ScannedAttribute {
name: named.map(|(name, _)| name.to_string()),
value: unescape_attribute_quote("ed[quote.len_utf8()..close], quote),
quote: if quote == '\'' {
AttributeQuote::Single
} else {
AttributeQuote::Double
},
value_start: value_start + quote.len_utf8(),
value_end: value_start + close,
});
let trailing = &source[after_close..];
let whitespace = trailing.len() - trailing.trim_start_matches([' ', '\t']).len();
cursor = after_close + whitespace;
if cursor == source.len() {
break;
}
if source[cursor..].starts_with(',') {
cursor += 1;
if cursor == source.len() {
attributes.push(ScannedAttribute {
name: None,
value: String::new(),
quote: AttributeQuote::Unquoted,
value_start: cursor,
value_end: cursor,
});
break;
}
}
continue;
}
}
let comma = attribute_list_comma(&source[value_start..], value_start, protected)
.map(|index| value_start + index);
let end = comma.unwrap_or(source.len());
let trimmed = source[value_start..end].trim_end();
attributes.push(ScannedAttribute {
name: named.map(|(name, _)| name.to_string()),
value: trimmed.to_string(),
quote: AttributeQuote::Unquoted,
value_start,
value_end: value_start + trimmed.len(),
});
let Some(comma) = comma else {
break;
};
cursor = comma + 1;
if cursor == source.len() {
attributes.push(ScannedAttribute {
name: None,
value: String::new(),
quote: AttributeQuote::Unquoted,
value_start: cursor,
value_end: cursor,
});
break;
}
}
attributes
}
fn apply_block_style<'input>(
state: &ParserState<'input>,
metadata: &mut BlockMetadata<'input>,
value: &'input str,
location: Option<&Location>,
) -> bool {
if value.is_empty() {
return false;
}
if value.chars().any(char::is_whitespace)
|| !value
.chars()
.any(|character| matches!(character, '#' | '.' | '%'))
{
metadata.style = Some(value);
return matches!(value, "discrete" | "float");
}
let mut kind = None;
let mut start = 0;
for (index, character) in value.char_indices() {
let next_kind = match character {
'#' => Some(StylePartKind::Id),
'.' => Some(StylePartKind::Role),
'%' => Some(StylePartKind::Option),
_ => continue,
};
apply_style_part(
state,
metadata,
kind,
&value[start..index],
start,
index,
location,
);
kind = next_kind;
start = index + character.len_utf8();
}
apply_style_part(
state,
metadata,
kind,
&value[start..],
start,
value.len(),
location,
);
matches!(metadata.style, Some("discrete" | "float"))
}
pub(crate) fn verbatim_paragraph_style(
source: &str,
attributes: &DocumentAttributes<'_>,
) -> Option<bool> {
let source = source.strip_prefix('[')?.strip_suffix(']')?;
if source.starts_with('[') {
return None;
}
let substituted = substitute(source, &[Substitution::Attributes], attributes);
let mut style = None;
for (slot, attribute) in scan_attribute_list(&substituted, &[])
.into_iter()
.enumerate()
{
let value = attribute.value.as_str();
let name = match attribute.name.as_deref() {
Some("style") if attribute.quote != AttributeQuote::Unquoted || value != "None" => {
value
}
None if slot == 0 && !value.is_empty() => {
if value.chars().any(char::is_whitespace) {
value
} else {
value.split(['#', '.', '%']).next().unwrap_or_default()
}
}
_ => continue,
};
if !name.is_empty() || attribute.name.is_some() {
style = Some(matches!(name, "source" | "listing" | "literal" | "verse"));
}
}
style
}
#[derive(Clone, Copy)]
enum StylePartKind {
Id,
Role,
Option,
}
fn apply_style_part<'input>(
state: &ParserState<'input>,
metadata: &mut BlockMetadata<'input>,
kind: Option<StylePartKind>,
value: &'input str,
start: usize,
end: usize,
location: Option<&Location>,
) {
if value.is_empty() {
return;
}
match kind {
None => metadata.style = Some(value),
Some(StylePartKind::Id) => {
let location = location.map_or_else(Location::default, |location| {
state.create_location(
location.absolute_start + start,
location.absolute_start + end,
)
});
metadata.id = Some(Anchor {
id: value,
xreflabel: None,
location,
bibliography_label: None,
bibliography: false,
});
}
Some(StylePartKind::Role) => metadata.roles.push(value),
Some(StylePartKind::Option) => metadata.options.push(value),
}
}
fn extract_media_dimensions(metadata: &mut BlockMetadata<'_>) {
for (slot, name) in [(0, "width"), (1, "height")] {
if let Some(value) = metadata
.positional_attributes
.get(slot)
.map(|attribute| attribute.value)
.filter(|value| !value.is_empty())
{
metadata
.attributes
.insert(name.into(), AttributeValue::String(Cow::Borrowed(value)));
}
}
}
fn ensure_positional_slot(metadata: &mut BlockMetadata<'_>, slot: usize) {
metadata
.positional_attributes
.resize(slot, PositionalAttribute::default());
}
fn extract_source_attributes(state: &ParserState<'_>, metadata: &mut BlockMetadata<'_>) {
if metadata.style != Some("source") {
return;
}
if let Some(language) = metadata
.positional_attributes
.first()
.map(|attribute| attribute.value)
.filter(|value| !value.is_empty())
{
metadata.attributes.set(
"language".into(),
AttributeValue::String(Cow::Borrowed(language)),
);
}
if let Some(linenums) = metadata
.positional_attributes
.get(1)
.map(|attribute| attribute.value)
.filter(|value| !value.is_empty())
{
metadata.attributes.set(
"linenums".into(),
AttributeValue::String(Cow::Borrowed(linenums)),
);
}
if !metadata.attributes.contains_key("linenums")
&& (metadata.options.contains(&"linenums")
|| state
.document_attributes
.contains_key("source-linenums-option"))
{
metadata
.attributes
.set("linenums".into(), AttributeValue::String(Cow::Borrowed("")));
}
if !metadata.options.contains(&"nowrap")
&& state.document_attributes.is_explicit("prewrap")
&& state.document_attributes.get("prewrap").is_none()
{
metadata.options.push("nowrap");
}
}
fn store_named_block_attribute<'input>(
state: &mut ParserState<'input>,
metadata: &mut BlockMetadata<'input>,
name: &'input str,
value: &'input str,
quote: AttributeQuote,
location: Option<Location>,
context: Option<MacroAttributeContext>,
) -> Option<(usize, usize)> {
if quote == AttributeQuote::Unquoted && value == "None" {
return None;
}
let title_position = if name == "title" {
location
.as_ref()
.map(|location| (location.absolute_start, location.absolute_end))
} else {
None
};
match name {
RESERVED_NAMED_ATTRIBUTE_ID if metadata.id.is_none() => {
metadata.id = Some(Anchor {
id: value,
xreflabel: None,
location: location.clone().unwrap_or_default(),
bibliography_label: None,
bibliography: false,
});
}
RESERVED_NAMED_ATTRIBUTE_ROLE | "roles" => {
metadata
.roles
.extend(value.split_whitespace().filter(|role| !role.is_empty()));
}
RESERVED_NAMED_ATTRIBUTE_OPTIONS | "options" => {
metadata
.options
.extend(parse_comma_separated_values(state, value));
}
"style" => metadata.style = Some(value),
RESERVED_NAMED_ATTRIBUTE_SUBS => {
#[cfg(feature = "pre-spec-subs")]
{
state.add_generic_warning_at(
"The subs= attribute may change when the AsciiDoc specification is finalized. See: https://gitlab.eclipse.org/eclipse/asciidoc-lang/asciidoc-lang/-/issues/16".to_string(),
location.clone().unwrap_or_default(),
);
metadata.substitutions = Some(parse_subs_attribute(value));
}
#[cfg(not(feature = "pre-spec-subs"))]
state.add_warning(Warning::new(
WarningKind::ContentRecovery {
message: "The subs= attribute is not honoured in this build (the `pre-spec-subs` feature is disabled). Requested content substitutions were ignored.".into(),
},
Some(state.create_error_source_location(location.clone().unwrap_or_default())),
));
}
"attribution" => {
metadata.attribution = Some(Attribution::new(plain_attribute_value(value, location)));
metadata.attribution_substitutions = quote == AttributeQuote::Single;
}
"citetitle" => {
metadata.citetitle = Some(CiteTitle::new(plain_attribute_value(value, location)));
metadata.citetitle_substitutions = quote == AttributeQuote::Single;
}
_ => {
if context == Some(MacroAttributeContext::Image) && name == "link" {
metadata.attributes.set(
Cow::Borrowed(name),
AttributeValue::String(Cow::Borrowed(value)),
);
} else {
metadata.attributes.insert(
Cow::Borrowed(name),
AttributeValue::String(Cow::Borrowed(value)),
);
}
}
}
title_position
}
fn parse_block_attribute_list<'input>(
state: &mut ParserState<'input>,
source: &'input str,
content_start: usize,
fallback_end: usize,
mode: BlockAttributeMode,
) -> (bool, BlockMetadata<'input>, Option<(usize, usize)>) {
let substituted = substitute(
source,
&[Substitution::Attributes],
&state.document_attributes,
);
let positions_exact = matches!(substituted, Cow::Borrowed(_));
let attributes = scan_attribute_list(&substituted, &[]);
let mut metadata = BlockMetadata::default();
let mut discrete = false;
let mut title_position = None;
for (slot, attribute) in attributes.into_iter().enumerate() {
let value = state.intern_str(&attribute.value);
let name = attribute.name.as_deref().map(|name| state.intern_str(name));
let location = positions_exact.then(|| {
state.create_location(
content_start + attribute.value_start,
content_start + attribute.value_end,
)
});
if let Some(name) = name {
title_position = store_named_block_attribute(
state,
&mut metadata,
name,
value,
attribute.quote,
location,
match mode {
BlockAttributeMode::Block => None,
BlockAttributeMode::Macro(context) => Some(context),
},
)
.or(title_position);
} else if slot == 0 {
match mode {
BlockAttributeMode::Block => {
discrete = apply_block_style(state, &mut metadata, value, location.as_ref());
}
BlockAttributeMode::Macro(_) if !value.is_empty() => metadata.style = Some(value),
BlockAttributeMode::Macro(_) => {}
}
} else {
ensure_positional_slot(&mut metadata, slot);
if let Some(positional) = metadata.positional_attributes.get_mut(slot - 1) {
*positional = PositionalAttribute {
value,
substitutions: attribute.quote == AttributeQuote::Single,
location,
};
}
}
}
if title_position.is_none() && metadata.attributes.get("title").is_some() {
title_position = Some((content_start, fallback_end));
}
(discrete, metadata, title_position)
}
fn plain_attribute_value(value: &str, location: Option<Location>) -> Vec<InlineNode<'_>> {
vec![InlineNode::PlainText(Plain {
content: value,
location: location.unwrap_or_default(),
escaped: false,
})]
}
fn extract_quote_attributes(metadata: &mut BlockMetadata<'_>) {
if !matches!(metadata.style, Some("quote" | "verse")) {
return;
}
let named_attribution = metadata.attribution.take();
let named_citetitle = metadata.citetitle.take();
let positional_attribution = metadata.positional_attributes.first().cloned();
let positional_citetitle = metadata.positional_attributes.get(1).cloned();
if let Some(attribute) = positional_attribution.filter(|value| !value.value.is_empty()) {
metadata.attribution = Some(Attribution::new(plain_attribute_value(
attribute.value,
attribute.location,
)));
metadata.attribution_substitutions = attribute.substitutions;
} else {
metadata.attribution = named_attribution;
}
if let Some(attribute) = positional_citetitle.filter(|value| !value.value.is_empty()) {
metadata.citetitle = Some(CiteTitle::new(plain_attribute_value(
attribute.value,
attribute.location,
)));
metadata.citetitle_substitutions = attribute.substitutions;
} else {
metadata.citetitle = named_citetitle;
}
let _ = metadata.take_positional_attributes::<2>();
}
fn apply_quote_attribute_substitutions<'input>(
state: &mut ParserState<'input>,
metadata: &mut BlockMetadata<'input>,
offset: usize,
substitutions: SubstitutionPlan,
) -> Result<(), Error> {
let block_metadata = BlockParsingMetadata {
substitutions,
..BlockParsingMetadata::default()
};
if metadata.attribution_substitutions
&& let Some(InlineNode::PlainText(plain)) = metadata
.attribution
.as_deref()
.and_then(|value| value.first())
{
let start = plain.location.absolute_start.saturating_sub(offset);
let end = plain.location.absolute_end.saturating_sub(offset);
let (inlines, _) =
process_inlines(state, &block_metadata, start, end, offset, plain.content)?;
metadata.attribution = Some(Attribution::new(inlines));
}
if metadata.citetitle_substitutions
&& let Some(InlineNode::PlainText(plain)) = metadata
.citetitle
.as_deref()
.and_then(|value| value.first())
{
let start = plain.location.absolute_start.saturating_sub(offset);
let end = plain.location.absolute_end.saturating_sub(offset);
let (inlines, _) =
process_inlines(state, &block_metadata, start, end, offset, plain.content)?;
metadata.citetitle = Some(CiteTitle::new(inlines));
}
Ok(())
}
fn parse_reference_label<'a>(
state: &mut ParserState<'a>,
label: Option<&'a str>,
location: &Location,
) -> Option<Vec<InlineNode<'a>>> {
let label = label?;
if label.trim().is_empty() {
return None;
}
let block_metadata = BlockParsingMetadata {
substitutions: SubstitutionPlan::only(&Substitution::Quotes),
..BlockParsingMetadata::default()
};
match process_inlines(
state,
&block_metadata,
location.absolute_start,
location.absolute_end,
0,
label,
) {
Ok((inlines, _)) if !inlines.is_empty() => Some(inlines),
_ => Some(vec![InlineNode::PlainText(Plain {
content: label,
location: location.clone(),
escaped: false,
})]),
}
}
fn insert_reference<'a>(
state: &mut ParserState<'a>,
refs: &mut ReferenceCatalog<'a>,
anchor: &Anchor<'a>,
label: Option<&'a str>,
title: Option<Title<'a>>,
caption: Option<Caption<'a>>,
) {
if refs.duplicate(state, anchor.id, &anchor.location) {
return;
}
let mut xreflabel = parse_reference_label(state, label, &anchor.location);
if !anchor.is_bibliography()
&& let Some(label) = label.filter(|label| !label.trim().is_empty())
{
refs.natural_targets.entry(label).or_insert(anchor.id);
}
if anchor.is_bibliography()
&& let Some(label) = xreflabel.as_mut()
{
label.insert(
0,
InlineNode::PlainText(Plain {
content: "[",
location: anchor.location.clone(),
escaped: false,
}),
);
label.push(InlineNode::PlainText(Plain {
content: "]",
location: anchor.location.clone(),
escaped: false,
}));
}
refs.entries.insert(
anchor.id,
Reference {
xreflabel,
title,
location: anchor.location.clone(),
caption,
section: None,
bibliography: anchor.is_bibliography(),
automatic_citation: false,
},
);
}
struct ReferenceCatalog<'a> {
entries: HashMap<&'a str, Reference<'a>>,
next_suffix: HashMap<&'a str, usize>,
natural_targets: HashMap<&'a str, &'a str>,
}
impl<'a> ReferenceCatalog<'a> {
fn duplicate(&self, state: &mut ParserState<'a>, id: &str, location: &Location) -> bool {
let Some(first) = self.entries.get(id) else {
return false;
};
if first.location != *location {
state.add_warning(Warning::new(
WarningKind::DuplicateId {
id: id.to_owned(),
first: Box::new(state.create_error_source_location(first.location.clone())),
},
Some(state.create_error_source_location(location.clone())),
));
}
true
}
fn section_id(&mut self, state: &ParserState<'a>, section: &Section<'a>) -> &'a str {
if let Some(id) = Section::explicit_id(§ion.metadata) {
return id;
}
let base = Section::generate_id(state.arena, §ion.metadata, §ion.title)
.as_arena_str(state.arena);
if base.is_empty() || !self.entries.contains_key(base) {
return base;
}
let next = self.next_suffix.entry(base).or_insert(2);
loop {
let candidate = format!("{base}_{next}");
*next += 1;
if !self.entries.contains_key(candidate.as_str()) {
return state.intern_str(&candidate);
}
}
}
}
struct CrossReferenceUse<'a> {
target: &'a str,
location: Location,
automatic: bool,
resolve_natural_target: bool,
source_syntax: crate::model::XrefSourceSyntax,
}
fn insert_untitled_reference<'a>(
state: &mut ParserState<'a>,
refs: &mut ReferenceCatalog<'a>,
id: &'a str,
location: &Location,
) {
if refs.duplicate(state, id, location) {
return;
}
refs.entries.insert(
id,
Reference {
xreflabel: None,
title: None,
location: location.clone(),
caption: None,
section: None,
bibliography: false,
automatic_citation: false,
},
);
}
fn finalize_inline_semantics<'a>(
state: &ParserState<'a>,
document: &mut Document<'a>,
reference_ids: &HashSet<&'a str>,
natural_targets: &HashMap<&'a str, &'a str>,
) {
let caption_kinds = document
.references
.iter()
.filter_map(|(target, reference)| {
let Caption::Numbered { kind, .. } = reference.caption.as_ref()? else {
return None;
};
Some((*target, *kind))
})
.collect::<HashMap<_, _>>();
let section_names = document
.references
.iter()
.filter_map(|(target, reference)| Some((*target, reference.section.as_ref()?.name)))
.collect::<HashMap<_, _>>();
for footnote in &mut document.footnotes {
super::inlines::finalize_footnote_text(footnote);
}
walk_document_inline_nodes_mut(document, &mut |inline| {
super::inlines::finalize_registered_inline(inline);
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return;
};
if let Some(target) = resolve_source_target(state, xref.target, xref.source_syntax) {
xref.target = target;
xref.target_is_local = true;
} else {
let target = resolve_xref_target(
xref.target,
xref.resolve_natural_target,
reference_ids,
natural_targets,
);
if target != xref.target {
xref.target_is_local = true;
}
xref.target = target;
}
let Some(snapshot) = xref.caption_label_snapshot_id.take() else {
return;
};
if let Some(kind) = caption_kinds.get(xref.target) {
xref.caption_label = state.xref_caption_label(snapshot, *kind);
}
xref.section_signifiers = state.xref_signifiers(snapshot);
xref.refresh_signifier(section_names.get(xref.target).copied());
});
}
fn metadata_xreflabel<'a>(
state: &ParserState<'a>,
metadata: &BlockMetadata<'a>,
) -> Option<&'a str> {
if let Some(AttributeValue::String(reftext)) = metadata.attributes.get("reftext") {
return Some(state.intern_cow(reftext.clone()));
}
metadata.anchors.last().and_then(|anchor| anchor.xreflabel)
}
fn register_section_header<'a>(
state: &mut ParserState<'a>,
block_metadata: &BlockParsingMetadata<'a>,
title: Title<'a>,
natural_title: &'a str,
level: SectionLevel,
location: Location,
direct_parent_section_kind: Option<SectionKind>,
) -> (Title<'a>, section::SectionNumbering, &'a str) {
let xreflabel = metadata_xreflabel(state, &block_metadata.metadata);
let reference_text = xreflabel
.filter(|label| !label.trim().is_empty())
.unwrap_or(natural_title);
let kind = SectionKind::from_style(block_metadata.metadata.style);
state.toc_entries.push(TocEntry::for_section(
"",
title.clone(),
level,
xreflabel,
kind,
location.clone(),
));
warn_for_nested_special_section(state, direct_parent_section_kind, location);
let numbering = section::SectionNumbering::from_attributes(&state.document_attributes);
(title, numbering, reference_text)
}
#[allow(
clippy::case_sensitive_file_extension_comparisons,
reason = "Asciidoctor recognizes only the lowercase .adoc suffix in xref macros"
)]
fn resolve_source_target<'a>(
state: &ParserState<'_>,
target: &'a str,
syntax: crate::model::XrefSourceSyntax,
) -> Option<&'a str> {
use crate::model::XrefSourceSyntax;
if matches!(syntax, XrefSourceSyntax::Literal) {
return None;
}
let (path, fragment) = match target.split_once('#') {
Some(parts) => parts,
None if matches!(syntax, XrefSourceSyntax::Macro) && target.ends_with(".adoc") => {
(target, "")
}
None => return None,
};
if path.is_empty() {
return Some(fragment);
}
let stem = match syntax {
XrefSourceSyntax::Shorthand => [".adoc", ".asciidoc", ".asc", ".ad", ".txt"]
.iter()
.find_map(|extension| path.strip_suffix(extension))
.unwrap_or(path),
XrefSourceSyntax::Macro => path
.strip_suffix(".adoc")
.or_else(|| (!path.rsplit('/').next().unwrap_or(path).contains('.')).then_some(path))?,
XrefSourceSyntax::Literal => return None,
};
(state.document_attributes.text("docname") == Some(stem) || state.included_files.contains(stem))
.then_some(fragment)
}
fn register_document_top<'a>(
state: &mut ParserState<'a>,
document: &mut Document<'a>,
xrefs: &[CrossReferenceUse<'a>],
) {
if !xrefs
.iter()
.any(|xref| resolve_source_target(state, xref.target, xref.source_syntax) == Some(""))
{
return;
}
let title = document
.header
.as_ref()
.map(header_reference_title)
.or_else(|| {
document.blocks.iter().find_map(|block| {
if let Block::Section(section) = block {
Some(section.title.clone())
} else {
None
}
})
});
let label = document
.attributes
.text("reftext")
.map(|label| state.intern_str(label))
.or_else(|| {
document
.header
.as_ref()
.and_then(|header| metadata_xreflabel(state, &header.metadata))
});
let location = document.header.as_ref().map_or_else(
|| document.location.clone(),
|header| header.location.clone(),
);
document.references.insert(
"",
Reference {
xreflabel: parse_reference_label(state, label, &location),
title,
location,
caption: None,
section: None,
bibliography: false,
automatic_citation: false,
},
);
}
fn resolve_xref_target<'a>(
target: &'a str,
resolve_natural_target: bool,
reference_ids: &HashSet<&'a str>,
natural_targets: &HashMap<&'a str, &'a str>,
) -> &'a str {
if !resolve_natural_target || target.contains('#') || reference_ids.contains(target) {
return target;
}
let resembles_reference_text = target.contains(' ') || target.chars().any(char::is_uppercase);
if resembles_reference_text {
natural_targets.get(target).copied().unwrap_or(target)
} else {
target
}
}
fn collect_formatted_references<'a, T>(
state: &mut ParserState<'a>,
text: &T,
refs: &mut ReferenceCatalog<'a>,
xrefs: &mut Vec<CrossReferenceUse<'a>>,
) where
T: MarkedText<'a, Content = Vec<InlineNode<'a>>>,
{
if let Some(id) = text.id() {
insert_untitled_reference(state, refs, id, text.location());
}
collect_inline_references(state, text.content(), refs, xrefs);
}
fn collect_link_references<'a>(
state: &mut ParserState<'a>,
attributes: &ElementAttributes<'a>,
location: &Location,
text: &[InlineNode<'a>],
refs: &mut ReferenceCatalog<'a>,
xrefs: &mut Vec<CrossReferenceUse<'a>>,
) {
if let Some(id) = attributes.get_string("id") {
insert_untitled_reference(state, refs, state.intern_cow(id), location);
}
collect_inline_references(state, text, refs, xrefs);
}
fn header_reference_title<'a>(header: &Header<'a>) -> Title<'a> {
let mut inlines = header.title.clone().into_inlines();
if let Some(subtitle) = &header.subtitle {
inlines.push(InlineNode::PlainText(Plain {
content: ": ",
location: header.location.clone(),
escaped: false,
}));
inlines.extend(subtitle.clone().into_inlines());
}
Title::new(inlines)
}
fn collect_metadata_references<'a>(
state: &mut ParserState<'a>,
metadata: &BlockMetadata<'a>,
refs: &mut ReferenceCatalog<'a>,
xrefs: &mut Vec<CrossReferenceUse<'a>>,
) {
if let Some(attribution) = &metadata.attribution {
collect_inline_references(state, attribution, refs, xrefs);
}
if let Some(citetitle) = &metadata.citetitle {
collect_inline_references(state, citetitle, refs, xrefs);
}
}
fn collect_references<'a>(
state: &mut ParserState<'a>,
blocks: &mut [Block<'a>],
refs: &mut ReferenceCatalog<'a>,
xrefs: &mut Vec<CrossReferenceUse<'a>>,
) {
for block in blocks {
if let Block::Section(section) = block {
let id = refs.section_id(state, section);
section.id = Some(id);
let reference_text = section.reference_text.take();
let xreflabel = metadata_xreflabel(state, §ion.metadata);
let mut location = section.location.clone();
if let Some(last) = section.title.last() {
location.absolute_end = last.location().absolute_end;
location.end = last.location().end.clone();
}
if !refs.duplicate(state, id, &location) {
if let Some(text) = reference_text.filter(|text| !text.is_empty()) {
refs.natural_targets.entry(text).or_insert(id);
}
refs.entries.insert(
id,
Reference {
xreflabel: parse_reference_label(state, xreflabel, &location),
title: Some(section.title.clone()),
location,
caption: None,
section: None,
bibliography: false,
automatic_citation: false,
},
);
}
}
if !matches!(block, Block::Section(_))
&& let Some(anchor) = block.anchor()
{
let caption = block
.metadata()
.and_then(|metadata| metadata.caption.clone());
let label = block
.metadata()
.and_then(|metadata| metadata_xreflabel(state, metadata))
.or(anchor.xreflabel);
insert_reference(state, refs, anchor, label, block.title().cloned(), caption);
}
if let Some(title) = block.title() {
collect_inline_references(state, title, refs, xrefs);
}
if let Some(metadata) = block.metadata() {
collect_metadata_references(state, metadata, refs, xrefs);
}
match block {
Block::Section(s) => {
collect_references(state, &mut s.content, refs, xrefs);
}
Block::Paragraph(p) => collect_inline_references(state, &p.content, refs, xrefs),
Block::Admonition(a) => collect_references(state, &mut a.blocks, refs, xrefs),
Block::UnorderedList(l) => {
for item in &mut l.items {
collect_inline_references(state, &item.principal, refs, xrefs);
collect_references(state, &mut item.blocks, refs, xrefs);
}
}
Block::OrderedList(l) => {
for item in &mut l.items {
collect_inline_references(state, &item.principal, refs, xrefs);
collect_references(state, &mut item.blocks, refs, xrefs);
}
}
Block::CalloutList(l) => {
for item in &mut l.items {
collect_inline_references(state, &item.principal, refs, xrefs);
collect_references(state, &mut item.blocks, refs, xrefs);
}
}
Block::DescriptionList(l) => {
for item in &mut l.items {
for anchor in &item.anchors {
insert_reference(state, refs, anchor, anchor.xreflabel, None, None);
}
collect_inline_references(state, &item.term, refs, xrefs);
collect_inline_references(state, &item.principal_text, refs, xrefs);
collect_references(state, &mut item.description, refs, xrefs);
}
}
Block::DelimitedBlock(d) => {
collect_delimited_references(state, &mut d.inner, refs, xrefs);
}
Block::DiscreteHeader(_)
| Block::ThematicBreak(_)
| Block::PageBreak(_)
| Block::Image(_)
| Block::Audio(_)
| Block::Video(_)
| Block::TableOfContents(_)
| Block::DocumentAttribute(_)
| Block::Comment(_) => {}
}
}
}
fn normalize_bibliography_lists<'a>(state: &ParserState<'a>, blocks: &mut [Block<'a>]) {
for block in blocks {
match block {
Block::Section(section) => {
if section.kind == SectionKind::Bibliography {
for child in &mut section.content {
if let Block::UnorderedList(list) = child
&& list.metadata.style.is_none()
{
list.metadata.style = Some("bibliography");
}
}
}
normalize_bibliography_lists(state, &mut section.content);
}
Block::UnorderedList(list) => {
if list.metadata.style == Some("bibliography") {
for item in &mut list.items {
promote_bibliography_anchor(state, &mut item.principal);
}
}
for item in &mut list.items {
normalize_bibliography_lists(state, &mut item.blocks);
}
}
Block::OrderedList(list) => {
for item in &mut list.items {
normalize_bibliography_lists(state, &mut item.blocks);
}
}
Block::CalloutList(list) => {
for item in &mut list.items {
normalize_bibliography_lists(state, &mut item.blocks);
}
}
Block::DescriptionList(list) => {
for item in &mut list.items {
normalize_bibliography_lists(state, &mut item.description);
}
}
Block::Admonition(admonition) => {
normalize_bibliography_lists(state, &mut admonition.blocks);
}
Block::DelimitedBlock(block) => match &mut block.inner {
DelimitedBlockType::DelimitedExample(blocks)
| DelimitedBlockType::DelimitedOpen(blocks)
| DelimitedBlockType::DelimitedSidebar(blocks)
| DelimitedBlockType::DelimitedQuote(blocks) => {
normalize_bibliography_lists(state, blocks);
}
DelimitedBlockType::DelimitedTable(table) => {
for row in table
.header
.iter_mut()
.chain(table.rows.iter_mut())
.chain(table.footer.iter_mut())
{
for column in &mut row.columns {
normalize_bibliography_lists(state, &mut column.content);
}
}
}
DelimitedBlockType::DelimitedListing(_)
| DelimitedBlockType::DelimitedLiteral(_)
| DelimitedBlockType::DelimitedPass(_)
| DelimitedBlockType::DelimitedVerse(_)
| DelimitedBlockType::DelimitedComment(_)
| DelimitedBlockType::DelimitedStem(_) => {}
},
Block::Paragraph(_)
| Block::DiscreteHeader(_)
| Block::ThematicBreak(_)
| Block::PageBreak(_)
| Block::Image(_)
| Block::Audio(_)
| Block::Video(_)
| Block::TableOfContents(_)
| Block::DocumentAttribute(_)
| Block::Comment(_) => {}
}
}
}
fn promote_bibliography_anchor<'a>(state: &ParserState<'a>, principal: &mut Vec<InlineNode<'a>>) {
let [
InlineNode::PlainText(open),
InlineNode::InlineAnchor(anchor),
InlineNode::PlainText(close),
..,
] = principal.as_mut_slice()
else {
return;
};
if open.content != "["
|| !close.content.starts_with(']')
|| !is_valid_bibliography_id(anchor.id)
|| open.location.absolute_start + 1 != anchor.location.absolute_start
|| anchor.location.absolute_end + 1 != close.location.absolute_start
{
return;
}
anchor.bibliography = true;
if let Some(label) = &anchor.bibliography_label {
anchor.xreflabel = label.source;
}
anchor.location =
state.create_location(open.location.absolute_start, close.location.absolute_start);
let remove_close = if close.content == "]" {
true
} else {
close.content = &close.content[1..];
close.location = state.create_location(
close.location.absolute_start + 1,
close.location.absolute_end,
);
false
};
principal.remove(0);
if remove_close {
principal.remove(1);
}
}
fn collect_delimited_references<'a>(
state: &mut ParserState<'a>,
inner: &mut DelimitedBlockType<'a>,
refs: &mut ReferenceCatalog<'a>,
xrefs: &mut Vec<CrossReferenceUse<'a>>,
) {
match inner {
DelimitedBlockType::DelimitedExample(blocks)
| DelimitedBlockType::DelimitedOpen(blocks)
| DelimitedBlockType::DelimitedSidebar(blocks)
| DelimitedBlockType::DelimitedQuote(blocks) => {
collect_references(state, blocks, refs, xrefs);
}
DelimitedBlockType::DelimitedListing(inlines)
| DelimitedBlockType::DelimitedLiteral(inlines)
| DelimitedBlockType::DelimitedPass(inlines)
| DelimitedBlockType::DelimitedVerse(inlines)
| DelimitedBlockType::DelimitedComment(inlines) => {
collect_inline_references(state, inlines, refs, xrefs);
}
DelimitedBlockType::DelimitedTable(table) => {
for row in table
.header
.iter_mut()
.chain(table.rows.iter_mut())
.chain(table.footer.iter_mut())
{
for column in &mut row.columns {
collect_references(state, &mut column.content, refs, xrefs);
}
}
}
DelimitedBlockType::DelimitedStem(_) => {}
}
}
fn collect_inline_references<'a>(
state: &mut ParserState<'a>,
inlines: &[InlineNode<'a>],
refs: &mut ReferenceCatalog<'a>,
xrefs: &mut Vec<CrossReferenceUse<'a>>,
) {
for inline in inlines {
match inline {
InlineNode::InlineAnchor(anchor) => {
insert_reference(state, refs, anchor, anchor.xreflabel, None, None);
if let Some(label) = anchor.bibliography_label() {
collect_inline_references(state, label, refs, xrefs);
}
}
InlineNode::Macro(InlineMacro::CrossReference(xref)) => {
xrefs.push(CrossReferenceUse {
target: xref.target,
location: xref.location.clone(),
automatic: xref.text.is_empty(),
resolve_natural_target: xref.resolve_natural_target,
source_syntax: xref.source_syntax,
});
collect_inline_references(state, &xref.text, refs, xrefs);
}
InlineNode::Macro(InlineMacro::Footnote(footnote)) => {
collect_inline_references(state, &footnote.content, refs, xrefs);
}
InlineNode::Macro(InlineMacro::IndexTerm(term)) if term.is_visible() => {
collect_inline_references(state, term.term(), refs, xrefs);
}
InlineNode::Macro(InlineMacro::Link(link)) => collect_link_references(
state,
&link.attributes,
&link.location,
&link.text,
refs,
xrefs,
),
InlineNode::Macro(InlineMacro::Url(url)) => collect_link_references(
state,
&url.attributes,
&url.location,
&url.text,
refs,
xrefs,
),
InlineNode::Macro(InlineMacro::Mailto(mailto)) => collect_link_references(
state,
&mailto.attributes,
&mailto.location,
&mailto.text,
refs,
xrefs,
),
InlineNode::BoldText(t) => collect_formatted_references(state, t, refs, xrefs),
InlineNode::ItalicText(t) => collect_formatted_references(state, t, refs, xrefs),
InlineNode::MonospaceText(t) => collect_formatted_references(state, t, refs, xrefs),
InlineNode::HighlightText(t) => collect_formatted_references(state, t, refs, xrefs),
InlineNode::SubscriptText(t) => collect_formatted_references(state, t, refs, xrefs),
InlineNode::SuperscriptText(t) => collect_formatted_references(state, t, refs, xrefs),
InlineNode::CurvedQuotationText(t) => {
collect_formatted_references(state, t, refs, xrefs);
}
InlineNode::CurvedApostropheText(t) => {
collect_formatted_references(state, t, refs, xrefs);
}
InlineNode::PlainText(_)
| InlineNode::RawText(_)
| InlineNode::VerbatimText(_)
| InlineNode::StandaloneCurvedApostrophe(_)
| InlineNode::LineBreak(_)
| InlineNode::Macro(_)
| InlineNode::CalloutRef(_) => {}
}
}
}
fn get_literal_paragraph<'input>(
state: &mut ParserState<'input>,
content: &'input str,
start: usize,
content_start: usize,
end: usize,
offset: usize,
block_metadata: &BlockParsingMetadata<'input>,
) -> Result<Block<'input>, Error> {
tracing::debug!(
content,
"paragraph starts with a space - switching to literal block"
);
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
metadata.style = Some("literal");
let location = state.create_block_location(start, end, offset);
let substitutions = verbatim_substitutions(block_metadata);
let (mut inlines, callouts) = resolve_verbatim_callouts(
state,
content,
state.create_block_location(content_start, end, offset),
substitutions.enabled(&Substitution::Callouts),
!metadata.attributes.contains_key("line-comment"),
);
if callouts.is_empty() && !needs_verbatim_inlines(substitutions) {
for node in &mut inlines {
if let InlineNode::VerbatimText(text) = node {
*node = InlineNode::PlainText(Plain {
content: text.content,
location: location.clone(),
escaped: false,
});
}
}
} else {
inlines = resolve_verbatim_inlines(state, block_metadata, inlines)?;
}
state.pending_callouts.extend(callouts);
let all_lines_have_leading_space = content
.lines()
.all(|line| line.is_empty() || line.starts_with(' '));
if all_lines_have_leading_space {
for node in &mut inlines {
walk_inline_nodes_mut(node, &mut |node| {
let starts_line = node.location().start.column == 1;
let content = if let InlineNode::VerbatimText(text) = node {
&mut text.content
} else if let InlineNode::PlainText(text) = node {
&mut text.content
} else {
return;
};
let mut first = true;
*content = state.intern_join(
content.split_inclusive('\n').map(|line| {
let strip = !first || starts_line;
first = false;
if strip {
line.strip_prefix(' ').unwrap_or(line)
} else {
line
}
}),
"",
);
});
}
}
Ok(Block::Paragraph(Paragraph {
source_text: Some(content),
content: inlines,
metadata,
title: block_metadata.title.clone(),
location,
}))
}
fn assemble_principal_text<'a>(
state: &ParserState<'a>,
first_line: &'a str,
continuation_lines: &[&str],
) -> &'a str {
if continuation_lines.is_empty() {
first_line
} else {
let mut s = BumpString::new_in(state.arena);
s.push_str(first_line);
for line in continuation_lines {
s.push('\n');
s.push_str(line);
}
s.into_bump_str()
}
}
const fn calculate_item_end(
principal_text_is_empty: bool,
start: usize,
first_line_end: usize,
) -> usize {
if principal_text_is_empty {
start
} else {
first_line_end.saturating_sub(1)
}
}
fn apply_leveloffset(
base_level: SectionLevel,
byte_offset: usize,
leveloffset_ranges: &[LeveloffsetRange],
document_attributes: &DocumentAttributes,
) -> SectionLevel {
let range_offset = crate::model::calculate_leveloffset_at(leveloffset_ranges, byte_offset);
let attr_offset = document_attributes
.text("leveloffset")
.map(strip_quotes)
.and_then(|s| s.parse::<isize>().ok())
.unwrap_or(0);
let total_offset = range_offset + attr_offset;
if total_offset != 0 {
let adjusted = isize::from(base_level) + total_offset;
let clamped = adjusted.clamp(0, 5);
SectionLevel::try_from(clamped)
.inspect_err(|error| {
tracing::error!(
clamped,
?error,
"not a valid section after applying leveloffset"
);
})
.unwrap_or(0)
} else {
base_level
}
}
fn expected_child_level(level: SectionLevel, kind: SectionKind, is_book: bool) -> SectionLevel {
if level == 0
&& is_book
&& matches!(
kind,
SectionKind::Preface | SectionKind::Abstract | SectionKind::Appendix
)
{
2
} else {
level + 1
}
}
fn warn_for_nested_special_section(
state: &ParserState<'_>,
direct_parent_section_kind: Option<SectionKind>,
heading_location: Location,
) {
let kind = match direct_parent_section_kind {
Some(SectionKind::Bibliography) => WarningKind::NestedSectionInBibliography,
Some(SectionKind::Index) => WarningKind::NestedSectionInIndex,
_ => return,
};
let location = state.create_error_source_location(heading_location);
state.add_warning(Warning::new(kind, Some(location)));
}
#[derive(Clone, Copy)]
enum TableClosing<'a> {
Terminated {
close_delim: &'a str,
close_start: usize,
},
Unterminated,
}
struct TableParseParams<'a> {
start: usize,
offset: usize,
table_start: usize,
content_start: usize,
content_end: usize,
end: usize,
open_delim: &'a str,
content: &'a str,
default_separator: &'a str,
closing: TableClosing<'a>,
}
fn table_limit_error(
state: &ParserState<'_>,
location: Location,
resource: &str,
requested: impl std::fmt::Display,
limit: usize,
) -> Error {
Error::Parse(
Box::new(state.create_error_source_location(location)),
format!("table {resource} request of {requested} exceeds the maximum of {limit}"),
)
}
#[allow(clippy::too_many_lines)]
fn parse_table_block_impl<'input>(
params: &TableParseParams<'_>,
state: &mut ParserState<'input>,
block_metadata: &BlockParsingMetadata<'input>,
) -> Result<Block<'input>, Error> {
let &TableParseParams {
start,
offset,
table_start,
content_start,
content_end: _content_end,
end,
open_delim,
content,
default_separator,
closing,
} = params;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let presentation =
TablePresentation::from_attributes(&metadata, |name| (state.document_attributes).get(name));
let location = state.create_block_location(start, end, offset);
let table_location = state.create_block_location(table_start, end, offset);
let open_delimiter_location = state.create_location(
table_start + offset,
table_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = match closing {
TableClosing::Terminated {
close_delim,
close_start,
} => {
check_delimiters(
open_delim,
close_delim,
"table",
state.create_error_source_location(state.create_block_location(start, end, offset)),
)?;
Some(state.create_block_location(close_start, end, offset))
}
TableClosing::Unterminated => {
state.add_warning(Warning::new(
WarningKind::UnterminatedTable {
delimiter: open_delim.to_string(),
},
Some(state.create_error_source_location(open_delimiter_location.clone())),
));
None
}
};
let separator = if let Some(AttributeValue::String(sep)) =
block_metadata.metadata.attributes.get("separator")
{
sep.to_string()
} else if let Some(AttributeValue::String(format)) =
block_metadata.metadata.attributes.get("format")
{
match &**format {
"csv" => ",",
"dsv" => ":",
"tsv" => "\t",
unknown_format => {
state.add_warning(Warning::new(
WarningKind::TableUnknownFormat {
format: unknown_format.to_string(),
},
Some(state.create_error_source_location(table_location.clone())),
));
default_separator
}
}
.to_string()
} else {
default_separator.to_string()
};
let (ncols, column_formats) = if let Some(AttributeValue::String(cols)) =
block_metadata.metadata.attributes.get("cols")
{
let mut specs = Vec::new();
for part in cols.split(',') {
let s = strip_quotes(part.trim());
let (multiplier, spec_str) = if let Some(pos) = s.find('*') {
let mult_str = &s[..pos];
let mult = mult_str.parse::<usize>().unwrap_or_else(|_| {
if !mult_str.is_empty() && mult_str.bytes().all(|b| b.is_ascii_digit()) {
MAX_TABLE_COLUMNS + 1
} else {
1
}
});
(mult, &s[pos + 1..])
} else {
(1, s)
};
let mut halign = HorizontalAlignment::default();
let mut valign = VerticalAlignment::default();
let mut width = ColumnWidth::default();
let mut style = ColumnStyle::default();
let spec_str = if let Some(last_char) = spec_str.chars().last() {
match last_char {
'a' => {
style = ColumnStyle::AsciiDoc;
&spec_str[..spec_str.len() - 1]
}
'd' => {
style = ColumnStyle::Default;
&spec_str[..spec_str.len() - 1]
}
'e' => {
style = ColumnStyle::Emphasis;
&spec_str[..spec_str.len() - 1]
}
'h' => {
style = ColumnStyle::Header;
&spec_str[..spec_str.len() - 1]
}
'l' => {
style = ColumnStyle::Literal;
&spec_str[..spec_str.len() - 1]
}
'm' => {
style = ColumnStyle::Monospace;
&spec_str[..spec_str.len() - 1]
}
's' => {
style = ColumnStyle::Strong;
&spec_str[..spec_str.len() - 1]
}
_ => spec_str,
}
} else {
spec_str
};
if spec_str.contains(".<") {
valign = VerticalAlignment::Top;
} else if spec_str.contains(".^") {
valign = VerticalAlignment::Middle;
} else if spec_str.contains(".>") {
valign = VerticalAlignment::Bottom;
}
for (i, c) in spec_str.char_indices() {
let prev_char = if i > 0 {
spec_str.chars().nth(i - 1)
} else {
None
};
if prev_char == Some('.') {
continue; }
match c {
'<' => halign = HorizontalAlignment::Left,
'^' => halign = HorizontalAlignment::Center,
'>' => halign = HorizontalAlignment::Right,
_ => {}
}
}
let width_str: String = spec_str
.chars()
.filter(|c| !matches!(c, '<' | '^' | '>' | '.'))
.collect();
if !width_str.is_empty() {
if width_str == "~" {
width = ColumnWidth::Auto;
} else if width_str.ends_with('%') {
if let Ok(pct) = width_str.trim_end_matches('%').parse::<u32>() {
width = ColumnWidth::Percentage(pct);
}
} else if let Ok(prop) = width_str.parse::<u32>() {
width = ColumnWidth::Proportional(prop);
}
}
let spec = crate::ColumnFormat {
halign,
valign,
width,
style,
};
let column_count = specs.len().saturating_add(multiplier);
if column_count > MAX_TABLE_COLUMNS {
return Err(table_limit_error(
state,
table_location.clone(),
"column count",
column_count.to_string(),
MAX_TABLE_COLUMNS,
));
}
specs.extend(std::iter::repeat_n(spec, multiplier));
}
(Some(specs.len()), specs)
} else {
(None, Vec::new())
};
let mut has_header = block_metadata.metadata.options.contains(&"header");
let mut dropped_span = None;
let raw_rows = Table::parse_rows_with_positions(
content,
&separator,
&mut has_header,
content_start + offset,
ncols,
&mut dropped_span,
)
.map_err(|violation| {
table_limit_error(
state,
state.create_location(violation.start, violation.end),
violation.resource,
violation.requested,
violation.limit,
)
})?;
if let Some((start, end)) = dropped_span {
state.add_warning(Warning::new(
WarningKind::TableIncompleteRow,
Some(state.create_error_source_location(state.create_location(start, end))),
));
}
if block_metadata.metadata.options.contains(&"noheader") {
has_header = false;
}
let has_footer = block_metadata.metadata.options.contains(&"footer");
let mut header = None;
let mut footer = None;
let mut rows = Vec::with_capacity(raw_rows.len());
let mut active_rowspans: Vec<(usize, usize, usize)> = Vec::new();
for (i, row) in raw_rows.iter().enumerate() {
let is_header_row = has_header;
let mut columns = Vec::with_capacity(row.len());
for cell in row {
let cell_count = if cell.spec.is_duplication {
cell.spec.duplication_count
} else {
1
};
if cell_count == 0 {
continue;
}
let cell_content = state.intern_str(&cell.content);
for _ in 0..cell_count {
let column_index = columns.len();
let mut spec = cell.spec;
if is_header_row {
spec.style = None;
} else if spec.style.is_none()
&& let Some(col_format) = column_formats.get(column_index)
&& col_format.style != ColumnStyle::Default
{
spec.style = Some(col_format.style);
}
let parsed = parse_table_cell(cell_content, state, cell.content_start, &spec)?;
columns.push(parsed);
}
}
let row_location = if let Some(first) = row.first() {
state.create_location(first.start, first.end)
} else {
table_location.clone()
};
let occupied_from_rowspans: usize = active_rowspans
.iter()
.map(|(_pos, _remaining, width)| *width)
.sum();
let logical_col_count: usize =
occupied_from_rowspans + columns.iter().map(|c| c.colspan).sum::<usize>();
if logical_col_count > MAX_TABLE_COLUMNS {
return Err(table_limit_error(
state,
row_location.clone(),
"column count",
logical_col_count.to_string(),
MAX_TABLE_COLUMNS,
));
}
if let Some(ncols) = ncols
&& logical_col_count != ncols
{
let has_overflow = columns.iter().any(|c| c.colspan > ncols);
if has_overflow {
state.add_warning(Warning::new(
WarningKind::TableCellOverflow {
actual: logical_col_count,
expected: ncols,
},
Some(state.create_error_source_location(row_location)),
));
} else {
state.add_warning(Warning::new(
WarningKind::TableColumnCount {
actual: logical_col_count,
expected: ncols,
occupied_from_rowspans,
},
Some(state.create_error_source_location(row_location)),
));
}
continue;
}
active_rowspans.retain_mut(|(_pos, remaining, _width)| {
*remaining -= 1;
*remaining > 0
});
let mut col_position = 0;
for (_, active_pos, _, colspan) in active_rowspans.iter().map(|(p, r, c)| (*p, *p, *r, *c))
{
if col_position == active_pos {
col_position += colspan;
}
}
for cell in &columns {
while active_rowspans
.iter()
.any(|(pos, _, width)| col_position >= *pos && col_position < pos + width)
{
if let Some((_, _, width)) = active_rowspans
.iter()
.find(|(pos, _, w)| col_position >= *pos && col_position < pos + w)
{
col_position += width;
}
}
if cell.rowspan > 1 {
active_rowspans.push((col_position, cell.rowspan - 1, cell.colspan));
}
col_position += cell.colspan;
}
if is_header_row {
header = Some(TableRow { columns });
has_header = false;
continue;
}
if has_footer && i == raw_rows.len() - 1 {
footer = Some(TableRow { columns });
continue;
}
rows.push(TableRow { columns });
}
let table = Table::new(rows, table_location.clone())
.with_header(header)
.with_footer(footer)
.with_columns(column_formats)
.with_presentation(presentation);
Ok(Block::DelimitedBlock(DelimitedBlock {
source_text: None,
metadata: metadata.clone(),
delimiter: state.intern_str(open_delim),
inner: DelimitedBlockType::DelimitedTable(table),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location,
}))
}
#[inline]
fn find_dlist_marker(bytes: &[u8], pos: usize, scan_across_eol: bool, allow_eoi: bool) -> bool {
let mut i = pos;
while let Some(&b) = bytes.get(i) {
if b == b'\n' && (!scan_across_eol || bytes.get(i + 1) == Some(&b'\n')) {
return false;
}
if i > pos && (b == b':' || b == b';') {
let marker_len = if b == b':' {
let mut k = 1;
while k < 4 && bytes.get(i + k) == Some(&b':') {
k += 1;
}
if k >= 2 { k } else { 0 }
} else if bytes.get(i + 1) == Some(&b';') {
2
} else {
0
};
if marker_len > 0 {
let after = bytes.get(i + marker_len).copied();
if matches!(after, Some(b'\n' | b' ')) || (allow_eoi && after.is_none()) {
return true;
}
}
}
i += 1;
}
false
}
fn description_list_location(items: &[DescriptionListItem<'_>]) -> Location {
let Some((first, rest)) = items.split_first() else {
return Location::default();
};
let last = rest.last().unwrap_or(first);
Location {
absolute_start: first.location.absolute_start,
absolute_end: last.location.absolute_end,
start: first.location.start.clone(),
end: last.location.end.clone(),
}
}
fn nest_description_list_items<'input>(
items: &mut VecDeque<DescriptionListItem<'input>>,
delimiter: &'input str,
ancestors: &mut Vec<&'input str>,
) -> Vec<DescriptionListItem<'input>> {
let mut nested = Vec::new();
while items
.front()
.is_some_and(|item| item.delimiter == delimiter)
{
let Some(mut item) = items.pop_front() else {
break;
};
if let Some(child_delimiter) = items.front().map(|child| child.delimiter)
&& child_delimiter != delimiter
&& !ancestors.contains(&child_delimiter)
{
ancestors.push(delimiter);
let children = nest_description_list_items(items, child_delimiter, ancestors);
ancestors.pop();
let location = description_list_location(&children);
item.location.absolute_end = location.absolute_end;
item.location.end = location.end.clone();
item.description
.push(Block::DescriptionList(DescriptionList {
title: Title::default(),
metadata: BlockMetadata::default(),
items: children,
location,
}));
}
nested.push(item);
}
nested
}
fn build_description_list_topology(
items: Vec<DescriptionListItem<'_>>,
) -> Vec<DescriptionListItem<'_>> {
if items
.first()
.is_none_or(|first| items.iter().all(|item| item.delimiter == first.delimiter))
{
return items;
}
let mut items = VecDeque::from(items);
let delimiter = items.front().map_or("", |item| item.delimiter);
nest_description_list_items(&mut items, delimiter, &mut Vec::new())
}
peg::parser! {
pub(crate) grammar document_parser(state: &mut ParserState<'input>) for str {
use std::str::FromStr;
use crate::model::substitute;
inject span_start(_input, l, _r) -> usize { l }
inject span_end(_input, _l, r) -> usize { r }
pub(crate) rule document() -> Result<Document<'input>, Error>
= eol()* start:position() comments_before_header:(comment:leading_comment() eol()* { comment })* header_result:header() prepare_manpage_front_matter() header_attributes:header_attribute_snapshot() blocks:blocks(0, None, None) end:position!() (eol()* / ![_]) {
let header = header_result?;
let mut blocks: Vec<Block<'_>> = comments_before_header.into_iter().collect::<Result<Vec<_>, Error>>()?.into_iter().chain(blocks?).collect();
let mut document_end_offset = end;
if document_end_offset > state.input.len() {
document_end_offset = state.input.len();
}
while document_end_offset < state.input.len() && !state.input.is_char_boundary(document_end_offset) {
document_end_offset += 1;
}
let document_end_offset = if document_end_offset == 0 {
0
} else {
crate::grammar::utf8_utils::safe_decrement_offset(state.input, document_end_offset)
};
let (absolute_start, absolute_end) = if start.offset > document_end_offset {
(start.offset, start.offset)
} else {
(start.offset, document_end_offset)
};
let (start_position, end_position) = if state.input.is_empty() || (absolute_start == 0 && absolute_end == 0) {
(Position::new(1, 0), Position::new(1, 0))
} else {
(
start.position,
state.line_map.offset_to_position(absolute_end, state.input)
)
};
if !state.toc_entries.is_empty() {
let mut anchored = header.as_ref().is_some_and(|h| !h.title.is_empty());
let mut seen_section = false;
for block in &blocks {
if let Block::Section(section) = block {
if !anchored {
break;
}
if section.level > 1 {
let location = state
.create_error_source_location(section.location.clone());
state.add_warning(Warning::new(
WarningKind::SectionLevelOutOfSequence {
expected: 1,
got: section.level,
},
Some(location),
));
}
seen_section = true;
} else if !seen_section && !matches!(block, Block::Comment(_)) {
anchored = true;
}
}
}
normalize_bibliography_lists(state, &mut blocks);
caption::renumber_captions(&mut blocks);
let header_has_anchor = header.as_ref().is_some_and(|header| {
header.metadata.id.is_some() || !header.metadata.anchors.is_empty()
});
let mut references = ReferenceCatalog {
entries: HashMap::with_capacity(state.toc_entries.len() + usize::from(header_has_anchor)),
next_suffix: HashMap::new(),
natural_targets: HashMap::new(),
};
let mut xrefs = Vec::new();
if let Some(header) = &header {
if let Some(anchor) = header
.metadata
.id
.as_ref()
.or_else(|| header.metadata.anchors.last())
{
let label = metadata_xreflabel(state, &header.metadata);
insert_reference(
state,
&mut references,
anchor,
label,
Some(header_reference_title(header)),
None,
);
}
collect_inline_references(
state,
header.title.as_ref(),
&mut references,
&mut xrefs,
);
if let Some(subtitle) = &header.subtitle {
collect_inline_references(
state,
subtitle.as_ref(),
&mut references,
&mut xrefs,
);
}
collect_metadata_references(
state,
&header.metadata,
&mut references,
&mut xrefs,
);
}
collect_references(state, &mut blocks, &mut references, &mut xrefs);
let is_book = is_book_doctype(&header_attributes);
section::number_parsed_sections(
&mut blocks,
&mut state.toc_entries,
&mut references.entries,
is_book,
);
let toc_entries = state.toc_entries.clone();
let mut document = Document {
header,
location: Location {
absolute_start,
absolute_end,
start: start_position,
end: end_position,
},
attributes: header_attributes,
blocks,
footnotes: state.footnote_tracker.borrow().footnotes.clone(),
toc_entries,
references: references.entries,
};
register_document_top(state, &mut document, &xrefs);
let reference_ids = document.references.keys().copied().collect::<HashSet<_>>();
for xref in xrefs {
let source_target = resolve_source_target(state, xref.target, xref.source_syntax);
let target = source_target.unwrap_or_else(|| resolve_xref_target(
xref.target,
xref.resolve_natural_target,
&reference_ids,
&references.natural_targets,
));
let target_is_local = source_target.is_some()
|| xref.source_syntax.target_is_local(target);
if target_is_local && xref.automatic
&& let Some(reference) = document.references.get_mut(target)
&& reference.is_bibliography()
{
reference.automatic_citation = true;
}
if target_is_local && !reference_ids.contains(target)
{
let source_location = state.create_error_source_location(xref.location);
state.add_warning(Warning::new(
WarningKind::UnresolvedReference {
target: target.to_string(),
},
Some(source_location),
));
}
}
finalize_inline_semantics(
state,
&mut document,
&reference_ids,
&references.natural_targets,
);
Ok(document)
}
rule header_attribute_snapshot() -> DocumentAttributes<'input>
= {
crate::document_attribute::normalize_toc_attributes(Rc::make_mut(&mut state.document_attributes));
DocumentAttributes::clone(&state.document_attributes)
}
rule prepare_manpage_front_matter()
= manpage_name_section_required() section:&manpage_name_section() {
prepare_manpage_name_attributes(state, Some(section));
}
/ {
prepare_manpage_name_attributes(state, None);
}
rule manpage_name_section_required()
= {?
if is_manpage_doctype(&state.document_attributes)
&& !(state.document_attributes.text("manname").is_some()
&& state.document_attributes.text("manpurpose").is_some())
{
Ok(())
} else {
Err("manpage name section is not required")
}
}
rule manpage_name_section() -> ManpageNameSection<'input>
= eol()*
metadata_attributes:(attribute:manpage_name_metadata() eol()* { attribute })*
title:manpage_level_one_title()
eol()*
lines:manpage_name_body_line()+
{?
derive_name_section_attrs(lines)
.map(|attributes| ManpageNameSection {
title,
attributes,
metadata_attributes: metadata_attributes.into_iter().flatten().collect(),
})
.ok_or("non-conforming manpage name section body")
}
rule manpage_level_one_title() -> &'input str
= level:section_level(0, None) whitespace()+ title:$([^'\n']+) (eol() / ![_]) {?
(level.1 == 1)
.then_some(title.trim())
.ok_or("not a level-one manpage name section")
}
/ title:$([^'\n']+) eol()
level:setext_section_level(title.trim().chars().count(), None) {?
(level == 1 && !title_looks_like_description_list(title))
.then_some(title.trim())
.ok_or("not a level-one Setext manpage name section")
}
rule manpage_name_metadata() -> Option<AttributeDeclaration<'input>>
= manpage_comment_block() { None }
/ "//" !"/" [^'\n']* (eol() / ![_]) { None }
/ "[[" (!"]]" [^'\n'])+ "]]" (eol() / ![_]) { None }
/ !empty_list_separator() !double_open_square_bracket()
open_square_bracket() attribute_list_content() (eol() / ![_]) { None }
/ "." ![' ' | '\t' | '\n' | '\r' | '.'] [^'\n']* (eol() / ![_]) { None }
/ attribute:document_attribute_match() (eol() / ![_]) { Some(attribute) }
rule manpage_comment_block()
= delimiter:$(['/']*<4,>) eol()
(!manpage_comment_delimiter(delimiter) [^'\n']* eol())*
manpage_comment_delimiter(delimiter)
rule manpage_comment_delimiter(delimiter: &str)
= candidate:$(['/']*<4,>) (eol() / ![_]) {?
(candidate == delimiter)
.then_some(())
.ok_or("not the matching comment delimiter")
}
rule manpage_name_body_line() -> Option<&'input str>
= "//" !"/" [^'\n']* (eol() / ![_]) { None }
/ line:$([^'\n']+) (eol() / ![_]) { Some(line) }
pub(crate) rule header() -> Result<Option<Header<'input>>, Error>
= start:position!()
((document_attribute() / header_comment()) (eol() / ![_]))*
metadata:header_metadata()
title_authors:(title_authors:title_authors() { title_authors })?
(eol() (document_attribute() / header_comment()))*
end:position!()
(eol()*<,2> / ![_])
{
if let Some((title, subtitle, authors)) = title_authors {
let mut location = state.create_location(start, end);
location.absolute_end = crate::grammar::utf8_utils::safe_decrement_offset(state.input, location.absolute_end);
location.end.column = location.end.column.saturating_sub(1);
let mut header = Header {
metadata,
title,
subtitle,
authors,
location,
};
derive_author_attrs(
state.arena,
&mut header,
Rc::make_mut(&mut state.document_attributes),
);
if is_manpage_doctype(&state.document_attributes) {
derive_manpage_header_attrs(
Some(&header),
Rc::make_mut(&mut state.document_attributes),
state.options.strict,
state.current_file.as_deref().map(std::path::PathBuf::as_path),
)?;
}
Ok(Some(header))
} else {
tracing::debug!("No title or authors found in the document header.");
Ok(None)
}
}
rule header_metadata() -> BlockMetadata<'input>
= lines:(
anchor:anchor() { AttributeOrAnchorLine::Anchor(anchor) }
/ attr:attributes_line() { AttributeOrAnchorLine::Attributes((attr.0, Box::new(attr.1))) }
)+ &document_title()
{
let mut metadata = BlockMetadata::default();
for line in lines {
match line {
AttributeOrAnchorLine::Anchor(anchor) => push_metadata_anchor(&mut metadata, anchor),
AttributeOrAnchorLine::Attributes((_, attr_metadata)) => {
let attr_metadata = *attr_metadata;
if attr_metadata.id.is_some() {
metadata.id = attr_metadata.id;
}
if attr_metadata.style.is_some() {
metadata.style = attr_metadata.style;
}
metadata.roles.extend(attr_metadata.roles);
metadata.options.extend(attr_metadata.options);
for (name, value) in attr_metadata.attributes.iter() {
metadata.attributes.set(name.clone(), value.clone());
}
metadata.positional_attributes = attr_metadata.positional_attributes;
}
}
}
metadata
}
/ { BlockMetadata::default() }
pub(crate) rule title_authors() -> (Title<'input>, Option<Subtitle<'input>>, Vec<Author<'input>>)
= title_and_subtitle:document_title()
(eol() (document_attribute() / header_comment()))*
authors:(eol() authors:authors_and_revision() { authors })?
&(eol() / ![_])
{
let (title, subtitle) = title_and_subtitle;
tracing::debug!(?title, ?subtitle, ?authors, "Found title and authors in the document header.");
(title, subtitle, authors.unwrap_or_default())
}
pub(crate) rule document_title() -> (Title<'input>, Option<Subtitle<'input>>)
= document_title_atx()
/ document_title_setext()
rule document_title_atx() -> (Title<'input>, Option<Subtitle<'input>>)
= &atx_heading_prefix() document_title_token() whitespace() start:position!() title:$([^'\n']*) end:position!()
{?
tracing::debug!(?title, "Processing ATX document title");
let block_metadata = BlockParsingMetadata::default();
let (title_inlines, subtitle) = if let Some(colon_pos) = title.rfind(": ") {
let subtitle_raw = &title[colon_pos + 1..];
let subtitle_text = subtitle_raw.trim();
if subtitle_text.is_empty() {
let (inlines, _) = process_inlines(state, &block_metadata, start, end, 0, title)
.map_err(|_| "could not process document title")?;
(inlines, None)
} else {
let title_raw = &title[..colon_pos];
let title_text = title_raw.trim_end();
let title_end = start + title_text.len();
let (inlines, _) = process_inlines(state, &block_metadata, start, title_end, 0, title_text)
.map_err(|_| "could not process document title")?;
let sub_leading = subtitle_raw.len() - subtitle_raw.trim_start().len();
let sub_start_offset = start + colon_pos + 1 + sub_leading;
let subtitle_start = PositionWithOffset {
offset: sub_start_offset,
position: state.line_map.offset_to_position(sub_start_offset, state.input),
};
let sub_end = sub_start_offset + subtitle_text.len();
let (subtitle_inlines, _) = process_inlines(state, &block_metadata, subtitle_start.offset, sub_end, 0, subtitle_text)
.map_err(|_| "could not process document subtitle")?;
(inlines, Some(Subtitle::new(subtitle_inlines)))
}
} else {
let (inlines, _) = process_inlines(state, &block_metadata, start, end, 0, title)
.map_err(|_| "could not process document title")?;
(inlines, None)
};
Ok((Title::new(title_inlines), subtitle))
}
rule document_title_setext() -> (Title<'input>, Option<Subtitle<'input>>)
= title:$([^'\n']+) end:position!() eol()
underline:$("="+) &(eol() / ![_])
{?
if !setext::is_enabled(state) {
return Err("setext mode not enabled");
}
let title_text = title.trim();
let title_width = title_text.chars().count();
let underline_width = underline.chars().count();
if !setext::width_ok(title_width, underline_width) {
return Err("underline width out of tolerance");
}
if !underline.starts_with('=') {
return Err("document title must use = underline");
}
tracing::debug!(?title_text, "Processing setext document title");
let block_metadata = BlockParsingMetadata::default();
let (title_inlines, subtitle) = if let Some(colon_pos) = title.rfind(": ") {
let subtitle_raw = &title[colon_pos + 1..];
let subtitle_text = subtitle_raw.trim();
if subtitle_text.is_empty() {
let (inlines, _) = process_inlines(state, &block_metadata, span_start, end, 0, title)
.map_err(|_| "could not process setext document title")?;
(inlines, None)
} else {
let title_raw = &title[..colon_pos];
let title_text = title_raw.trim_end();
let title_end = span_start + title_text.len();
let (inlines, _) = process_inlines(state, &block_metadata, span_start, title_end, 0, title_text)
.map_err(|_| "could not process setext document title")?;
let sub_leading = subtitle_raw.len() - subtitle_raw.trim_start().len();
let sub_start_offset = span_start + colon_pos + 1 + sub_leading;
let subtitle_start = PositionWithOffset {
offset: sub_start_offset,
position: state.line_map.offset_to_position(sub_start_offset, state.input),
};
let sub_end = sub_start_offset + subtitle_text.len();
let (subtitle_inlines, _) = process_inlines(state, &block_metadata, subtitle_start.offset, sub_end, 0, subtitle_text)
.map_err(|_| "could not process setext document subtitle")?;
(inlines, Some(Subtitle::new(subtitle_inlines)))
}
} else {
let (inlines, _) = process_inlines(state, &block_metadata, span_start, end, 0, title)
.map_err(|_| "could not process setext document title")?;
(inlines, None)
};
Ok((Title::new(title_inlines), subtitle))
}
rule document_title_token() = "=" / "#"
rule authors_and_revision() -> Vec<Author<'input>>
= authors:author_line()
(eol() (document_attribute() / header_comment()))*
(eol() revision_pre_substitution())?
{ authors }
rule author_line() -> Vec<Author<'input>>
= !document_attribute_match() !comment()
start:position!() author_line:$([^'\n']+) end:position!() {?
let substituted_cow = substitute(author_line.trim(), HEADER, &state.document_attributes);
let substituted: &'input str = match substituted_cow {
Cow::Borrowed(s) => s,
Cow::Owned(s) => state.intern_str(&s),
};
tracing::debug!(?author_line, ?substituted, "Processing author line with substitution");
let mut temp_state =
ParserState::for_inline_parsing(substituted, state, state.inline_ctx);
let authors = if let Ok(authors) = document_parser::authors(substituted, &mut temp_state) {
tracing::debug!(?authors, "Parsed authors from line");
authors
} else {
tracing::debug!(?substituted, "Author line did not parse structurally; using whole line as a single author");
let location = state.create_error_source_location(state.create_location(start, end));
state.add_warning(Warning::new(
WarningKind::NonStandardAuthorLine { line: substituted.to_string() },
Some(location),
));
vec![Author::new(state.arena, substituted, None, None)]
};
register_author_attrs(&authors, Rc::make_mut(&mut state.document_attributes));
Ok(authors)
}
pub(crate) rule authors() -> Vec<Author<'input>>
= authors:(author() ++ (";" whitespace()*)) {
authors
}
pub(crate) rule author() -> Author<'input>
= name:author_name() email:author_email()? {
let mut author = name;
if let Some(email_addr) = email {
author.email = Some(email_addr);
}
author
}
rule author_name() -> Author<'input>
= first:name_part() whitespace()+ middle:name_part() whitespace()+ last:$(name_part() ++ whitespace()) {
Author::new(state.arena, first, Some(middle), Some(last))
}
/ first:name_part() whitespace()+ last:name_part() {
Author::new(state.arena, first, None, Some(last))
}
/ first:name_part() {
Author::new(state.arena, first, None, None)
}
rule author_email() -> &'input str
= whitespace()* "<" email:$([^'>']*) ">" { email }
rule name_part() -> &'input str
= name:$([c if c.is_alphanumeric() || c == '.' || c == '-' || c == '\'']+ ("_" [c if c.is_alphanumeric() || c == '.' || c == '-' || c == '\'']+)*) {
name
}
pub(crate) rule revision() -> ()
= "v"? number:$(digits() ++ ".") date:revision_date()? remark:revision_remark()? {
let revision_info = RevisionInfo {
number: Cow::Owned(number.to_string()),
date: date.map(|d| Cow::Owned(d.to_string())),
remark: remark.map(|r| Cow::Owned(r.to_string())),
};
if revision_info.number.is_empty() {
return;
}
let revision_location = state.create_location(span_start, span_end);
let ignored: IgnoredRevisionFields = {
let document_attributes = Rc::make_mut(&mut state.document_attributes);
process_revision_info(revision_info, document_attributes)
};
if ignored.number {
state.add_generic_warning_at(
"Revision number found in revision line but ignoring due to being set through attribute entries.".to_string(),
revision_location.clone(),
);
}
if ignored.date {
state.add_generic_warning_at(
"Revision date found in revision line but ignoring due to being set through attribute entries.".to_string(),
revision_location.clone(),
);
}
if ignored.remark {
state.add_generic_warning_at(
"Revision remark found in revision line but ignoring due to being set through attribute entries.".to_string(),
revision_location,
);
}
}
rule revision_pre_substitution() -> ()
= rev_line:$([^'\n']+) {?
let substituted_cow = substitute(rev_line.trim(), HEADER, &state.document_attributes);
let substituted: &'input str = match substituted_cow {
Cow::Borrowed(s) => s,
Cow::Owned(s) => state.intern_str(&s),
};
tracing::debug!(?rev_line, ?substituted, "Processing revision line with substitution");
let mut temp_state =
ParserState::for_inline_parsing(substituted, state, state.inline_ctx);
match document_parser::revision(substituted, &mut temp_state) {
Ok(()) => {
for key in ["revnumber", "revdate", "revremark"] {
if let Some(value) = temp_state.document_attributes.text(key) {
let value = state.intern_str(value);
Rc::make_mut(&mut state.document_attributes)
.insert_text(key.into(), value.into());
}
}
tracing::debug!("Parsed revision from line");
Ok(())
}
Err(_) => Err("line did not parse as revision")
}
}
rule revision_date() -> &'input str
= ", " date:$([^ (':'|'\n')]+) {
date
}
rule revision_remark() -> &'input str
= ": " remark:$([^'\n']+) {
remark
}
rule document_attribute() -> ()
= start:position!() att:document_attribute_match() end:position!() (&eol() / ![_])
{
tracing::debug!(?att, "Found document attribute in the document header");
let location = state.create_block_location(start, end, 0);
state.apply_document_attribute(&att, true, location);
}
pub(crate) rule blocks(offset: usize, parent_section_level: Option<SectionLevel>, direct_parent_section_kind: Option<SectionKind>) -> Result<Vec<Block<'input>>, Error>
= blocks:(!trailing_block_metadata_match() block:block(offset, parent_section_level, direct_parent_section_kind) { block })*
trailing:trailing_block_metadata(offset)?
{
let mut blocks = blocks.into_iter().collect::<Result<Vec<_>, Error>>()?;
if let Some(trailing) = trailing {
blocks.extend(trailing?);
}
Ok(order_document_attribute_events(blocks))
}
pub(crate) rule compound_blocks(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Vec<Block<'input>>, Error>
= content:blocks(offset, parent_section_level, None) eol()* { content }
rule trailing_block_metadata_match()
= eol()* &("[" / ".") (block_metadata_line_match() eol()*)+ ![_]
rule trailing_block_metadata(offset: usize) -> Result<Vec<Block<'input>>, Error>
= &trailing_block_metadata_match() eol()* events:(
event:document_attribute_block(offset) (eol() / ![_]) eol()* { Some(event) }
/ (attribute_or_anchor_line_match() / title_line_match()) eol()* { None }
)+
{
events.into_iter().flatten().collect()
}
pub(crate) rule nested_document_blocks(offset: usize, initial_attributes: &mut Vec<(crate::AttributeName<'input>, crate::DocumentAttributeAssignment<'input>)>) -> Result<Vec<Block<'input>>, Error>
= eol()*
header:(
comment:comment_line_block(offset) eol()* { comment }
/ attribute:document_attribute_block(offset) (eol() / ![_]) { attribute }
)*
header:normalize_nested_header(header, initial_attributes)
body:blocks(offset, None, None)
{
let mut blocks = header?;
blocks.extend(body?);
Ok(order_document_attribute_events(blocks))
}
rule normalize_nested_header(header: Vec<Result<Block<'input>, Error>>, initial_attributes: &mut Vec<(crate::AttributeName<'input>, crate::DocumentAttributeAssignment<'input>)>) -> Result<Vec<Block<'input>>, Error>
= { crate::grammar::table::normalize_nested_header(state, header, initial_attributes) }
pub(crate) rule blocks_for_table_cell(offset: usize) -> Result<Vec<Block<'input>>, Error>
= eol()*
blocks:(
start:position!()
content:$((!(eol()*<2,> / eol()* ![_]) [_])+)
end:position!()
eol()*
{
let (content, _) = process_inlines(
state,
&BlockParsingMetadata::default(),
start,
end,
offset,
content,
)?;
Ok(Block::Paragraph(Paragraph::new(
content,
state.create_block_location(start, end, offset),
)))
}
)*
{
blocks.into_iter().collect()
}
pub(crate) rule block(offset: usize, parent_section_level: Option<SectionLevel>, direct_parent_section_kind: Option<SectionKind>) -> Result<Block<'input>, Error>
= eol()*
!same_or_higher_level_section(offset, parent_section_level)
block:(
comment_line_block(offset) /
document_attribute_block(offset) /
&"[" dh:discrete_header(offset) { dh } /
section:section(offset, parent_section_level, direct_parent_section_kind) { section } /
section_setext:section_setext(offset, parent_section_level, direct_parent_section_kind) { section_setext } /
block_generic(offset, parent_section_level)
)
{ block }
rule comment_line_block(offset: usize) -> Result<Block<'input>, Error>
= "//" !("/") content:$([^'\n']*) end:position!() (eol() / ![_])
{
Ok(Block::Comment(Comment {
kind: CommentKind::Line,
content,
location: state.create_location(span_start + offset, end + offset),
}))
}
rule leading_comment() -> Result<Block<'input>, Error>
= comment_line_block(0)
/ start:position!() block:comment_block(start, 0, &BlockParsingMetadata::default()) { block }
rule header_comment()
= comment()
/ start:position!() result:comment_block(start, 0, &BlockParsingMetadata::default()) {?
result.map(|_| ()).map_err(|_| "invalid header comment")
}
rule comment_line_block_keep_eol(offset: usize) -> Result<Block<'input>, Error>
= "//" !("/") content:$([^'\n']*) end:position!() &(eol() / ![_])
{
Ok(Block::Comment(Comment {
kind: CommentKind::Line,
content,
location: state.create_location(span_start + offset, end + offset),
}))
}
rule same_or_higher_level_section(offset: usize, parent_section_level: Option<SectionLevel>) -> ()
= check_section_blocks() !document_attribute_match()
(metadata:$((block_metadata_line_match() eol()*)*)
{? if metadata_marks_discrete_heading(metadata, state) { Err("discrete heading belongs to its parent") } else { Ok(()) } })
(
level:section_level(offset, parent_section_level) &" "
{?
if let Some(parent_level) = parent_section_level {
let upcoming_level = level.1 + 1; if upcoming_level <= parent_level {
Ok(()) } else {
Err("not a same or higher level section")
}
} else {
Err("no parent section level to compare")
}
}
/
&setext_section_lookahead(parent_section_level)
)
rule setext_section_lookahead(parent_section_level: Option<SectionLevel>) -> ()
= title:$([^'\n']+) eol() underline:$(['-' | '~' | '^' | '+']+) &(eol() / ![_])
{?
if title_looks_like_description_list(title) {
return Err("title looks like a description list item");
}
if !setext::is_enabled(state) {
return Err("setext mode not enabled");
}
let title_width = title.trim().chars().count();
let underline_width = underline.chars().count();
if !setext::width_ok(title_width, underline_width) {
return Err("underline width out of tolerance");
}
let underline_char = underline.chars().next().ok_or("empty underline")?;
let level = setext::char_to_level(underline_char).ok_or("invalid setext char")?;
if level == 0 && !is_book_doctype(&state.document_attributes) {
return Err("not a section, seems like you're trying to define a document title");
}
if let Some(parent_level) = parent_section_level {
if level < parent_level {
Ok(()) } else {
Err("not a same or higher level section")
}
} else {
Err("no parent section level to compare")
}
}
rule atx_heading_prefix()
= ("=" / "#")*<1,6> whitespace()+ !eol() &[_]
rule atx_heading_match()
= (block_metadata_line_match() eol()*)* atx_heading_prefix()
rule discrete_header(offset: usize) -> Result<Block<'input>, Error>
= &atx_heading_match() block_metadata:(bm:heading_metadata(offset, None) {?
let bm = bm.map_err(|e| {
tracing::error!(?e, "error parsing block metadata in discrete_header");
"block metadata parse error"
})?;
if !bm.discrete {
return Err("not a discrete heading");
}
Ok(bm)
})
section_level:section_level(offset, None) whitespace()
title_start:position!() title:section_title(offset, &block_metadata) title_end:position!() &(eol()*<1,2> / ![_])
{
let (title, _) = title?;
tracing::debug!(?block_metadata, ?title, ?title_start, ?title_end, "parsing discrete header block");
let level = section_level.1;
let location = state.create_block_location(span_start, span_end, offset);
if block_metadata.metadata.style == Some("float") {
let warning_location = state.create_error_source_location(
state.create_block_location(span_start, span_end, offset),
);
state.add_warning(Warning::new(
WarningKind::LegacyFloatDiscreteHeading,
Some(warning_location),
));
}
Ok(Block::DiscreteHeader(DiscreteHeader {
metadata: block_metadata.metadata,
title,
level,
location,
}))
}
pub(crate) rule document_attribute_block(offset: usize) -> Result<Block<'input>, Error>
= att:document_attribute_match()
{
let name = Cow::Borrowed(att.name);
let location = state.create_block_location(span_start, span_end, offset);
let event = state
.apply_document_attribute(&att, false, location.clone())
.unwrap_or_else(|| DocumentAttribute::rejected(name, location));
Ok(Block::DocumentAttribute(event))
}
pub(crate) rule section(offset: usize, parent_section_level: Option<SectionLevel>, direct_parent_section_kind: Option<SectionKind>) -> Result<Block<'input>, Error>
= check_section_blocks() &atx_heading_match()
block_metadata:(bm:heading_metadata(offset, parent_section_level) {?
bm.map_err(|e| {
tracing::error!(?e, "error parsing block metadata in section");
"block metadata parse error"
})
})
section_level_start:position!()
section_level:section_level(offset, parent_section_level)
section_level_end:position!()
whitespace()
title_start:position!()
section_header:(title:section_title(offset, &block_metadata) title_end:position!() &(eol()*<1,2> / ![_]) {
let (title, natural_title) = title?;
let location = state.create_block_location(section_level_start, title_end, offset);
Ok::<(Title<'input>, section::SectionNumbering, &'input str), Error>(register_section_header(
state,
&block_metadata,
title,
natural_title,
section_level.1,
location,
direct_parent_section_kind,
))
})
content:section_content(offset, Some(expected_child_level(
section_level.1,
SectionKind::from_style(block_metadata.metadata.style),
is_book_doctype(&state.document_attributes),
)), Some(SectionKind::from_style(block_metadata.metadata.style)))?
{
let (title, numbering, reference_text) = section_header?;
tracing::debug!(?offset, ?block_metadata, ?title, "parsing section block");
if let Some(parent_level) = parent_section_level {
if section_level.1 < parent_level || section_level.1 > 5 {
return Err(Error::NestedSectionLevelMismatch(
Box::new(state.create_error_source_location(state.create_block_location(section_level_start, section_level_end, offset))),
section_level.1+1,
parent_level + 1,
));
}
if section_level.1 > parent_level {
let location = state.create_error_source_location(
state.create_block_location(section_level_start, section_level_end, offset),
);
state.add_warning(Warning::new(
WarningKind::SectionLevelOutOfSequence {
expected: parent_level,
got: section_level.1,
},
Some(location),
));
}
}
let level = section_level.1;
let location = state.create_block_location(span_start, span_end, offset);
let kind = SectionKind::from_style(block_metadata.metadata.style);
let mut section = Section::parsed(
block_metadata.metadata,
title,
level,
content.unwrap_or(Ok(Vec::new()))?,
kind,
numbering,
location,
);
section.reference_text = Some(reference_text);
Ok(Block::Section(section))
}
rule setext_section_level(title_width: usize, parent_section_level: Option<SectionLevel>) -> u8
= underline:$(['-' | '~' | '^' | '+']+) &(eol() / ![_])
{?
if !setext::is_enabled(state) {
return Err("setext mode not enabled");
}
let underline_width = underline.chars().count();
if !setext::width_ok(title_width, underline_width) {
return Err("underline width out of tolerance");
}
let underline_char = underline.chars().next().ok_or("empty underline")?;
let level = setext::char_to_level(underline_char).ok_or("invalid setext underline character")?;
if level == 0 && !is_book_doctype(&state.document_attributes) {
return Err("use = underline for document title, not section");
}
if let Some(parent_level) = parent_section_level
&& (level < parent_level || level > parent_level + 1 || level > 5)
{
return Err("section level mismatch with parent");
}
Ok(level)
}
rule setext_section_match(parent_section_level: Option<SectionLevel>)
= (block_metadata_line_match() eol()*)*
title:$([^'\n']+) eol()
setext_section_level(title.trim().chars().count(), parent_section_level)
pub(crate) rule section_setext(offset: usize, parent_section_level: Option<SectionLevel>, direct_parent_section_kind: Option<SectionKind>) -> Result<Block<'input>, Error>
= check_section_blocks() !check_line_is_description_list(offset)
&setext_section_match(parent_section_level)
block_metadata:(bm:heading_metadata(offset, parent_section_level) {?
bm.map_err(|e| {
tracing::error!(?e, "error parsing block metadata in section_setext");
"block metadata parse error"
})
})
title_start:position!() title:$([^'\n']+) title_end:position!() eol()
setext_level:setext_section_level(title.trim().chars().count(), parent_section_level)
section_header:({
let (processed_title, natural_title) =
process_inlines(state, &block_metadata, title_start, title_end, offset, title)?;
let title = Title::new(processed_title);
let location = state.create_block_location(title_start, title_end, offset);
Ok::<(Title<'input>, section::SectionNumbering, &'input str), Error>(register_section_header(
state,
&block_metadata,
title,
natural_title,
setext_level,
location,
direct_parent_section_kind,
))
})
content:section_content(offset, Some(expected_child_level(
setext_level,
SectionKind::from_style(block_metadata.metadata.style),
is_book_doctype(&state.document_attributes),
)), Some(SectionKind::from_style(block_metadata.metadata.style)))?
{
let (title, numbering, reference_text) = section_header?;
let location = state.create_block_location(span_start, span_end, offset);
let kind = SectionKind::from_style(block_metadata.metadata.style);
let mut section = Section::parsed(
block_metadata.metadata,
title,
setext_level,
content.unwrap_or(Ok(Vec::new()))?,
kind,
numbering,
location,
);
section.reference_text = Some(reference_text);
Ok(Block::Section(section))
}
rule block_metadata(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<BlockParsingMetadata<'input>, Error>
= metadata:block_metadata_with_title(offset, parent_section_level, true) { metadata }
rule heading_metadata(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<BlockParsingMetadata<'input>, Error>
= metadata:block_metadata_with_title(offset, parent_section_level, false) { metadata }
rule block_metadata_with_title(offset: usize, parent_section_level: Option<SectionLevel>, parse_title: bool) -> Result<BlockParsingMetadata<'input>, Error>
= meta_start:position!() lines:(line:(
anchor:anchor() { BlockMetadataLine::Anchor(anchor) }
/ attr:attributes_line() { BlockMetadataLine::Attributes((attr.0, Box::new(attr.1))) }
/ doc_attr:document_attribute_line(offset) { BlockMetadataLine::DocumentAttribute(doc_attr.0, doc_attr.1) }
/ title_line()
) end:position!() eol()* { (line, end) })*
{
let mut metadata = BlockMetadata::default();
let mut discrete = false;
let mut title_source = None;
let meta_end = lines.last().map_or(meta_start, |(_, end)| *end);
for (line, _) in lines {
match line {
BlockMetadataLine::Anchor(value) => push_metadata_anchor(&mut metadata, value),
BlockMetadataLine::Attributes((attr_discrete, attr_metadata)) => {
discrete = attr_discrete;
merge_attribute_metadata(&mut metadata, *attr_metadata);
},
BlockMetadataLine::DocumentAttribute(declaration, location) => {
if let Some(event) = state.apply_document_attribute(
&declaration,
false,
location,
) {
metadata.push_document_attribute(event);
}
},
BlockMetadataLine::Title { source, start, end } => {
title_source = Some((source, start, end));
}
}
}
let title = if let Some((source, start, end)) = title_source.filter(|_| parse_title) {
match process_inlines(
state, &BlockParsingMetadata::default(), start, end, offset, source,
) {
Ok((title, _)) => title.into(),
Err(error) => {
state.add_generic_warning(format!("failed to parse block title, skipping: {error:?}"));
Title::default()
}
}
} else {
Title::default()
};
if meta_start != meta_end {
metadata.location = Some(state.create_block_location(meta_start, meta_end, offset));
}
finish_block_parsing_metadata(
state,
metadata,
title,
parent_section_level,
discrete,
offset,
)
}
rule block_metadata_line_match()
= attribute_or_anchor_line_match()
/ document_attribute_match() (eol() / ![_])
/ title_line_match()
rule title_line_match()
= period() ![' ' | '\t' | '\n' | '\r' | '.'] [^'\n']+ (eol() / ![_])
rule title_line() -> BlockMetadataLine<'input>
= period() start:position!() title:$(![' ' | '\t' | '\n' | '\r' | '.'] [^'\n']*) end:position!() eol()
{
tracing::debug!(?title, ?start, ?end, "Found title line in block metadata");
BlockMetadataLine::Title { source: title, start, end }
}
rule document_attribute_line(offset: usize) -> (AttributeDeclaration<'input>, Location)
= start:position!() attr:document_attribute_match() end:position!() eol()
{
tracing::debug!(?attr, "Found document attribute in block metadata");
(attr, state.create_block_location(start, end, offset))
}
rule check_section_blocks()
= {? (state.block_context == BlockContext::Document).then_some(()).ok_or("section blocks are not allowed in compound content") }
rule section_level(offset: usize, parent_section_level: Option<SectionLevel>) -> (&'input str, SectionLevel)
= level:$(("=" / "#")*<1,6>)
{
let base_level: SectionLevel = level.len().try_into().unwrap_or(1) - 1;
let byte_offset = span_start + offset;
(level, apply_leveloffset(base_level, byte_offset, &state.leveloffset_ranges, &state.document_attributes))
}
rule at_line_start(offset: usize)
= pos:position!()
{?
let absolute_pos = pos + offset;
let at_line_start = absolute_pos == 0 || {
let prev_byte_pos = absolute_pos.saturating_sub(1);
state.input.as_bytes().get(prev_byte_pos).is_some_and(|&b| b == b'\n')
};
if !at_line_start {
return Err("expected line start");
}
Ok(())
}
rule heading_boundary(offset: usize)
= at_line_start(offset)
(check_section_blocks() (attribute_or_anchor_line_match() eol()*)*
/ (attribute_or_anchor_line_match() eol()*)+)
at_line_start(offset) atx_heading_prefix()
rule section_title(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<(Title<'input>, &'input str), Error>
= title:$([^'\n']*)
{
tracing::debug!(?title, title_start = span_start, title_end = span_end, offset, "Found section title");
let (content, natural_title) = process_inlines(
state,
block_metadata,
span_start,
span_end,
offset,
title,
)?;
Ok((Title::new(content), natural_title))
}
rule section_content(offset: usize, parent_section_level: Option<SectionLevel>, direct_parent_section_kind: Option<SectionKind>) -> Result<Vec<Block<'input>>, Error>
= blocks(offset, parent_section_level, direct_parent_section_kind) / { Ok(vec![]) }
pub(crate) rule block_generic(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block<'input>, Error>
= start:position!()
block_metadata:(bm:block_metadata(offset, parent_section_level) {?
bm.map_err(|e| {
tracing::error!(?e, "error parsing block metadata in block_generic");
"block metadata parse error"
})
})
block:(
delimited_block:delimited_block(start, offset, &block_metadata) { delimited_block }
/ verbatim:styled_verbatim_paragraph(start, offset, &block_metadata) { verbatim }
/ image:image(start, offset, &block_metadata) { image }
/ audio:audio(start, offset, &block_metadata) { audio }
/ video:video(start, offset, &block_metadata) { video }
/ toc:toc(start, offset, &block_metadata) { toc }
/ thematic_break:thematic_break(start, offset, &block_metadata) { thematic_break }
/ page_break:page_break(start, offset, &block_metadata) { page_break }
/ list:list(start, offset, &block_metadata) { list }
/ quoted_paragraph:quoted_paragraph(start, offset, &block_metadata) { quoted_paragraph }
/ markdown_blockquote:markdown_blockquote(start, offset, &block_metadata) { markdown_blockquote }
/ paragraph:paragraph(start, offset, &block_metadata) { paragraph }
) {
let mut block = block?;
assign_block_caption(state, &mut block);
Ok(block)
}
rule block_in_continuation(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block<'input>, Error>
= !trailing_block_metadata_match() start:position!()
block_metadata:(bm:block_metadata(offset, parent_section_level) {?
bm.map_err(|e| {
tracing::error!(?e, "error parsing block metadata in block_in_continuation");
"block metadata parse error"
})
})
block:(
comment:(eol()* comment_start:position!() c:(
comment_line_block_keep_eol(offset)
/ comment_block(comment_start, offset, &block_metadata)
) { c }) { comment }
/ delimited_block:delimited_block(start, offset, &block_metadata) { delimited_block }
/ verbatim:styled_verbatim_paragraph(start, offset, &block_metadata) { verbatim }
/ image:image(start, offset, &block_metadata) { image }
/ audio:audio(start, offset, &block_metadata) { audio }
/ video:video(start, offset, &block_metadata) { video }
/ toc:toc(start, offset, &block_metadata) { toc }
/ thematic_break:thematic_break(start, offset, &block_metadata) { thematic_break }
/ page_break:page_break(start, offset, &block_metadata) { page_break }
/ list:list_with_continuation(start, offset, &block_metadata, false) { list }
/ quoted_paragraph:quoted_paragraph(start, offset, &block_metadata) { quoted_paragraph }
/ markdown_blockquote:markdown_blockquote(start, offset, &block_metadata) { markdown_blockquote }
/ paragraph:paragraph(start, offset, &block_metadata) { paragraph }
) {
let mut block = block?;
assign_block_caption(state, &mut block);
Ok(block)
}
rule delimited_block(
start: usize,
offset: usize,
block_metadata: &BlockParsingMetadata<'input>,
) -> Result<Block<'input>, Error>
= generic_delimited_block(start, offset, block_metadata)
/ table_block(start, offset, block_metadata)
rule generic_delimited_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= open_start:position!() open:block_open() (eol() / ![_])
content_start:position!()
source_text:$(until_block_close(open.1) (eol() &block_close_delim(open.1))?) body_end:position!()
close:(close_start:position!() close_delim:block_close_delim(open.1) { (close_start, close_delim) })?
{
let content = if close.is_some() {
source_text.strip_suffix('\n').unwrap_or(source_text)
} else {
source_text
};
let content_end = body_end - (source_text.len() - content.len());
let kind = match (open.0, block_metadata.metadata.style) {
(DelimitedKind::Open, Some("source" | "listing")) => DelimitedKind::Listing,
(DelimitedKind::Open, Some("literal")) => DelimitedKind::Literal,
(kind, _) => kind,
};
build_delimited_block(state, block_metadata, &DelimitedParams {
kind, open_delim: open.1, lang: open.2, content, source_text,
open_start, start, content_start, content_end, end: span_end, offset, close,
})
}
rule block_open() -> (DelimitedKind, &'input str, Option<&'input str>)
= d:comment_delimiter() { (DelimitedKind::Comment, d, None) }
/ d:example_delimiter() { (DelimitedKind::Example, d, None) }
/ d:listing_delimiter() { (DelimitedKind::Listing, d, None) }
/ d:literal_delimiter() { (DelimitedKind::Literal, d, None) }
/ d:open_delimiter() { (DelimitedKind::Open, d, None) }
/ d:sidebar_delimiter() { (DelimitedKind::Sidebar, d, None) }
/ d:pass_delimiter() { (DelimitedKind::Pass, d, None) }
/ d:quote_delimiter() { (DelimitedKind::Quote, d, None) }
/ d:markdown_code_delimiter() lang:markdown_language()? { (DelimitedKind::Listing, d, lang) }
rule until_block_close(expected: &str) -> &'input str
= &block_close_delim(expected) { "" }
/ content:$((!(eol() block_close_delim(expected)) (eol() / [^'\n']+))*) { content }
rule block_close_delim(expected: &str) -> &'input str
= delim:$("="+ / "/"+ / "-"+ / "."+ / "*"+ / "_"+ / "+"+ / "~"+ / "`"+)
&(eol() / ![_])
{? if delim == expected { Ok(delim) } else { Err("delimiter mismatch") } }
rule comment_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= open_start:position!() open_delim:comment_delimiter() (eol() / ![_])
content_start:position!()
source_text:$(until_block_close(open_delim) (eol() &block_close_delim(open_delim))?) body_end:position!()
close:(close_start:position!() close_delim:block_close_delim(open_delim) { (close_start, close_delim) })?
{
let content = if close.is_some() {
source_text.strip_suffix('\n').unwrap_or(source_text)
} else {
source_text
};
let content_end = body_end - (source_text.len() - content.len());
build_delimited_block(state, block_metadata, &DelimitedParams {
kind: DelimitedKind::Comment, open_delim, lang: None, content, source_text,
open_start, start, content_start, content_end, end: span_end, offset, close,
})
}
rule comment_delimiter() -> &'input str = delim:$("/"*<4,>) { delim }
rule example_delimiter() -> &'input str = delim:$("="*<4,>) { delim }
rule listing_delimiter() -> &'input str = delim:$("-"*<4,>) { delim }
rule literal_delimiter() -> &'input str = delim:$("."*<4,>) { delim }
rule open_delimiter() -> &'input str = delim:$("-"*<2,2> / "~"*<4,>) { delim }
rule sidebar_delimiter() -> &'input str = delim:$("*"*<4,>) { delim }
rule table_delimiter() -> &'input str = delim:$((['|' | ',' | ':' | '!'] "="*<3,>)) { delim }
rule pipe_table_delimiter() -> &'input str = delim:$("|" "="*<3,>) { delim }
rule excl_table_delimiter() -> &'input str = delim:$("!" "="*<3,>) { delim }
rule comma_table_delimiter() -> &'input str = delim:$("," "="*<3,>) { delim }
rule colon_table_delimiter() -> &'input str = delim:$(":" "="*<3,>) { delim }
rule pass_delimiter() -> &'input str = delim:$("+"*<4,>) { delim }
rule markdown_code_delimiter() -> &'input str = delim:$("`"*<3,>) { delim }
rule quote_delimiter() -> &'input str = delim:$("_"*<4,>) { delim }
rule until_table_delimiter() -> &'input str
= content:$((!(eol() table_delimiter()) [_])*) { content }
rule until_pipe_table_delimiter() -> &'input str
= content:$((!(eol() pipe_table_delimiter()) [_])*) { content }
rule until_excl_table_delimiter() -> &'input str
= content:$((!(eol() excl_table_delimiter()) [_])*) { content }
rule until_comma_table_delimiter() -> &'input str
= content:$((!(eol() comma_table_delimiter()) [_])*) { content }
rule until_colon_table_delimiter() -> &'input str
= content:$((!(eol() colon_table_delimiter()) [_])*) { content }
rule markdown_language() -> &'input str
= lang:$((['a'..='z'] / ['A'..='Z'] / ['0'..='9'] / "_" / "+" / "-")+) { lang }
rule table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= pipe_table_block(start, offset, block_metadata)
/ excl_table_block(start, offset, block_metadata)
/ comma_table_block(start, offset, block_metadata)
/ colon_table_block(start, offset, block_metadata)
/ unterminated_pipe_table_block(start, offset, block_metadata)
/ unterminated_excl_table_block(start, offset, block_metadata)
/ unterminated_comma_table_block(start, offset, block_metadata)
/ unterminated_colon_table_block(start, offset, block_metadata)
rule pipe_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:pipe_table_delimiter() eol()
content_start:position!() content:until_pipe_table_delimiter() content_end:position!()
eol() close_start:position!() close_delim:pipe_table_delimiter()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: "|",
closing: TableClosing::Terminated { close_delim, close_start },
},
state,
block_metadata,
)
}
rule excl_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:excl_table_delimiter() eol()
content_start:position!() content:until_excl_table_delimiter() content_end:position!()
eol() close_start:position!() close_delim:excl_table_delimiter()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: "!",
closing: TableClosing::Terminated { close_delim, close_start },
},
state,
block_metadata,
)
}
rule comma_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:comma_table_delimiter() eol()
content_start:position!() content:until_comma_table_delimiter() content_end:position!()
eol() close_start:position!() close_delim:comma_table_delimiter()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: ",",
closing: TableClosing::Terminated { close_delim, close_start },
},
state,
block_metadata,
)
}
rule colon_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:colon_table_delimiter() eol()
content_start:position!() content:until_colon_table_delimiter() content_end:position!()
eol() close_start:position!() close_delim:colon_table_delimiter()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: ":",
closing: TableClosing::Terminated { close_delim, close_start },
},
state,
block_metadata,
)
}
rule unterminated_pipe_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:pipe_table_delimiter() (eol() / ![_])
content_start:position!() content:until_pipe_table_delimiter() content_end:position!()
![_]
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: "|",
closing: TableClosing::Unterminated,
},
state,
block_metadata,
)
}
rule unterminated_excl_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:excl_table_delimiter() (eol() / ![_])
content_start:position!() content:until_excl_table_delimiter() content_end:position!()
![_]
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: "!",
closing: TableClosing::Unterminated,
},
state,
block_metadata,
)
}
rule unterminated_comma_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:comma_table_delimiter() (eol() / ![_])
content_start:position!() content:until_comma_table_delimiter() content_end:position!()
![_]
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: ",",
closing: TableClosing::Unterminated,
},
state,
block_metadata,
)
}
rule unterminated_colon_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= table_start:position!() open_delim:colon_table_delimiter() (eol() / ![_])
content_start:position!() content:until_colon_table_delimiter() content_end:position!()
![_]
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end: span_end,
open_delim, content, default_separator: ":",
closing: TableClosing::Unterminated,
},
state,
block_metadata,
)
}
rule toc(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= "toc::" attributes:attributes() end:position!()
trailing:$([^'\n']*)
{
let (_discrete, metadata_from_attributes, _title_position) = attributes;
let mut metadata = block_metadata.metadata.clone();
metadata.merge(&metadata_from_attributes);
metadata.move_positional_attributes_to_attributes();
state.warn_trailing_macro_content("toc", trailing, end, offset);
tracing::debug!("Found Table of Contents block");
Ok(Block::TableOfContents(TableOfContents {
metadata,
location: state.create_location(start+offset, end+offset),
}))
}
rule normal_paragraph_style(block_metadata: &BlockParsingMetadata<'input>)
= {?
if matches!(
block_metadata.metadata.style,
Some("listing" | "source" | "literal" | "verse")
) {
Err("verbatim paragraph style")
} else {
Ok(())
}
}
rule image(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= normal_paragraph_style(block_metadata)
"image::" source:source() attributes:image_macro_attributes() end:position!()
trailing:$([^'\n']*)
{
state.warn_trailing_macro_content("image", trailing, end, offset);
let (_discrete, mut metadata_from_attributes, _title_position) = attributes;
extract_media_dimensions(&mut metadata_from_attributes);
let title = block_metadata.title.clone();
let mut metadata = block_metadata.metadata.clone();
metadata.merge(&metadata_from_attributes);
if let Some(style) = metadata.style {
metadata.style = None; metadata
.attributes
.set("alt".into(), AttributeValue::String(Cow::Borrowed(style)));
}
let _ = metadata.take_positional_attributes::<2>();
metadata.move_positional_attributes_to_attributes();
Ok(Block::Image(Image {
title,
source,
metadata,
location: state.create_block_location(start, end, offset),
}))
}
rule audio(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= "audio::" source:source() attributes:macro_attributes() end:position!()
trailing:$([^'\n']*)
{
state.warn_trailing_macro_content("audio", trailing, end, offset);
let (_discrete, metadata_from_attributes, _title_position) = attributes;
let title = block_metadata.title.clone();
let mut metadata = block_metadata.metadata.clone();
metadata.merge(&metadata_from_attributes);
metadata.move_positional_attributes_to_attributes();
Ok(Block::Audio(Audio {
title,
source,
metadata,
location: state.create_block_location(start, end, offset),
}))
}
rule video(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= "video::" sources:(source() ** comma()) attributes:macro_attributes() end:position!()
trailing:$([^'\n']*)
{
state.warn_trailing_macro_content("video", trailing, end, offset);
let (_discrete, mut metadata_from_attributes, _title_position) = attributes;
extract_media_dimensions(&mut metadata_from_attributes);
let title = block_metadata.title.clone();
let mut metadata = block_metadata.metadata.clone();
metadata.merge(&metadata_from_attributes);
if let Some(style) = metadata.style {
metadata.style = None;
if style == "youtube" || style == "vimeo" {
tracing::debug!(?metadata, "transforming video metadata style into attribute");
metadata
.attributes
.set(Cow::Borrowed(style), AttributeValue::Bool(true));
} else {
tracing::debug!(?metadata, "transforming video metadata style into attribute, assuming poster");
metadata.attributes.set(
"poster".into(),
AttributeValue::String(Cow::Borrowed(style)),
);
}
}
let _ = metadata.take_positional_attributes::<2>();
metadata.move_positional_attributes_to_attributes();
Ok(Block::Video(Video {
title,
sources,
metadata,
location: state.create_block_location(start, end, offset),
}))
}
rule thematic_break(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= ("'''"
/ "---"
/ "- - -"
/ "***"
/ "* * *"
)
{
tracing::debug!("Found thematic break block");
Ok(Block::ThematicBreak(ThematicBreak {
anchors: block_metadata.metadata.anchors.clone(), title: block_metadata.title.clone(),
location: state.create_block_location(start, span_end, offset),
}))
}
rule page_break(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= "<<<" &eol()*<2,2>
{
tracing::debug!("Found page break block");
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
Ok(Block::PageBreak(PageBreak {
title: block_metadata.title.clone(),
metadata,
location: state.create_location(start+offset, span_end+offset),
}))
}
rule list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= list_with_continuation(start, offset, block_metadata, true)
rule list_continuation_allowed(allow: bool)
= {? allow.then_some(()).ok_or("continuation belongs to parent") }
rule list_with_continuation(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>, allow_continuation: bool) -> Result<Block<'input>, Error>
= callout_list(start, offset, block_metadata)
/ unordered_list(start, offset, block_metadata, None, allow_continuation, false)
/ ordered_list(start, offset, block_metadata, None, allow_continuation, false)
/ description_list(start, offset, block_metadata, allow_continuation)
rule unordered_list_marker() -> &'input str = $("*"+ / "-")
rule ordered_list_marker() -> &'input str = $(digits()? "."+)
rule description_list_marker() -> &'input str = $("::::" / ":::" / "::" / ";;")
rule callout_list_marker() -> &'input str = $("<" (digits() / ".") ">")
rule section_level_marker() -> &'input str = $(("=" / "#")+)
rule nested_list_metadata(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<BlockParsingMetadata<'input>, Error>
= meta_start:position!() lines:(line:(
anchor:anchor() { AttributeOrAnchorLine::Anchor(anchor) }
/ attr:attributes_line() { AttributeOrAnchorLine::Attributes((attr.0, Box::new(attr.1))) }
) end:position!() eol()* { (line, end) })+
{
let mut metadata = BlockMetadata::default();
let mut discrete = false;
let meta_end = lines.last().map_or(meta_start, |(_, end)| *end);
for (line, _) in lines {
match line {
AttributeOrAnchorLine::Anchor(anchor) => push_metadata_anchor(&mut metadata, anchor),
AttributeOrAnchorLine::Attributes((attr_discrete, attr_metadata)) => {
discrete = attr_discrete;
merge_attribute_metadata(&mut metadata, *attr_metadata);
}
}
}
metadata.location = Some(state.create_block_location(meta_start, meta_end, offset));
finish_block_parsing_metadata(
state,
metadata,
Title::default(),
parent_section_level,
discrete,
offset,
)
}
rule parsed_nested_list_metadata(offset: usize, parent_section_level: Option<SectionLevel>) -> BlockParsingMetadata<'input>
= metadata:nested_list_metadata(offset, parent_section_level) {?
metadata.map_err(|error| {
tracing::error!(?error, "error parsing nested list metadata");
"nested list metadata parse error"
})
}
rule attributes_line_match()
= !empty_list_separator() !double_open_square_bracket()
open_square_bracket() attribute_list_content() (eol() / ![_])
rule anchor_line_match()
= double_open_square_bracket() [^'\'' | ',' | ']' | ' ' | '\t' | '\n' | '\r']+
(comma() [^']']+)? double_close_square_bracket() (eol() / ![_])
rule attribute_or_anchor_line_match()
= anchor_line_match() / attributes_line_match()
rule nested_list_metadata_gap()
= eol() / comment_line()
rule nested_unordered_child_after_metadata(offset: usize, current_marker: &str, parent_ordered_marker: Option<&'input str>)
= (attribute_or_anchor_line_match() eol()*)+ nested_list_metadata_gap()* (
!at_ancestor_ordered_marker(parent_ordered_marker)
&(whitespace()* ordered_list_marker() whitespace())
/ &at_deeper_unordered_marker(current_marker)
/ !at_callout_parent_item(offset) at_callout_list_item()
)
rule nested_ordered_child_after_metadata(offset: usize, current_marker: &str, parent_unordered_marker: Option<&'input str>)
= (attribute_or_anchor_line_match() eol()*)+ nested_list_metadata_gap()* (
!at_ancestor_unordered_marker(parent_unordered_marker)
&(whitespace()* unordered_list_marker() whitespace())
/ &at_deeper_ordered_marker(current_marker)
/ !at_callout_parent_item(offset) at_callout_list_item()
)
rule at_list_item_start() = whitespace()* (unordered_list_marker() / ordered_list_marker()) whitespace()
rule at_section_start() = (attribute_or_anchor_line_match() eol()*)* atx_heading_prefix()
rule at_ordered_marker_ahead() = eol()+ whitespace()* ordered_list_marker()
rule at_unordered_marker_ahead() = eol()+ whitespace()* unordered_list_marker()
rule at_ancestor_ordered_marker(ancestor: Option<&'input str>)
= whitespace()* marker:ordered_list_marker() whitespace() {?
match ancestor {
Some(m) if marker.len() <= m.len() => Ok(()),
_ => Err("not ancestor")
}
}
rule at_ancestor_unordered_marker(ancestor: Option<&'input str>)
= whitespace()* marker:unordered_list_marker() whitespace() {?
match ancestor {
Some(m) if marker.len() <= m.len() => Ok(()),
_ => Err("not ancestor")
}
}
rule at_shallower_unordered_marker(base_marker: &str)
= whitespace()* marker:unordered_list_marker() whitespace() {?
if marker.len() < base_marker.len() { Ok(()) } else { Err("same-or-deeper") }
}
rule at_shallower_ordered_marker(base_marker: &str)
= whitespace()* marker:ordered_list_marker() whitespace() {?
if marker.len() < base_marker.len() { Ok(()) } else { Err("same-or-deeper") }
}
rule at_deeper_unordered_marker(base_marker: &str)
= whitespace()* marker:unordered_list_marker() whitespace() {?
if marker.len() > base_marker.len() { Ok(()) } else { Err("same-or-shallower") }
}
rule at_deeper_ordered_marker(base_marker: &str)
= whitespace()* marker:ordered_list_marker() whitespace() {?
if marker.len() > base_marker.len() { Ok(()) } else { Err("same-or-shallower") }
}
rule at_list_separator()
= eol()*<2,> at_list_separator_content()
rule at_list_separator_content()
= "//" [^'\n']* (&eol() / ![_]) / whitespace()* "[" whitespace()* "]" whitespace()* (&eol() / ![_])
rule unordered_list_principal_continuation(offset: usize, current_marker: &str, parent_ordered_marker: Option<&'input str>) -> &'input str
= eol()
!(
&eol()
/ &at_list_item_start()
/ &"+"
/ &at_section_start()
/ &at_list_separator_content()
/ &nested_unordered_child_after_metadata(offset, current_marker, parent_ordered_marker)
/ at_callout_list_item()
)
line:$((!eol() [_])*) { line }
rule ordered_list_principal_continuation(offset: usize, current_marker: &str, parent_unordered_marker: Option<&'input str>) -> &'input str
= eol()
!(
&eol()
/ &at_list_item_start()
/ &"+"
/ &at_section_start()
/ &at_list_separator_content()
/ &nested_ordered_child_after_metadata(offset, current_marker, parent_unordered_marker)
/ at_callout_list_item()
)
line:$((!eol() [_])*) { line }
rule at_dlist_block_boundary()
= eol()*<2,> &(
("[" ![']' | '['] [^']' | '\n']+ "]" whitespace()* eol())
/ ("[[" [^']']+ "]]" whitespace()* eol())
/ ("." ![' ' | '\t' | '\n' | '\r' | '.'] [^'\n']* eol())
)
rule unordered_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_ordered_marker: Option<&'input str>, allow_continuation: bool, is_nested: bool) -> Result<Block<'input>, Error>
= whitespace()* marker_start:position!() base_marker:$(unordered_list_marker()) &whitespace()
first:unordered_list_item_after_marker(offset, block_metadata, allow_continuation, base_marker, marker_start, parent_ordered_marker)
rest:(unordered_list_rest_item(offset, block_metadata, parent_ordered_marker, allow_continuation, base_marker))*
{
tracing::debug!("Found unordered list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(span_end, |(_, item_end)| *item_end);
let items: Vec<ListItem<'input>> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or("", |item| item.marker);
Ok(Block::UnorderedList(UnorderedList {
title: if is_nested { Title::default() } else { block_metadata.title.clone() },
metadata: if is_nested { BlockMetadata::default() } else { block_metadata.metadata.clone() },
items,
marker,
location: state.create_location(start+offset, end+offset),
}))
}
rule unordered_list_item_after_marker(offset: usize, block_metadata: &BlockParsingMetadata<'input>, allow_continuation: bool, marker: &'input str, marker_start: usize, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= list_continuation_allowed(allow_continuation)
item:unordered_list_item_with_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_ordered_marker) { item }
/ item:unordered_list_item_no_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_ordered_marker) { item }
rule parent_is_some(parent: Option<&'input str>) -> ()
= {? if parent.is_some() { Ok(()) } else { Err("parent_is_none") } }
rule parent_is_none(parent: Option<&'input str>) -> ()
= {? if parent.is_none() { Ok(()) } else { Err("parent_is_some") } }
rule unordered_list_rest_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_ordered_marker: Option<&'input str>, allow_continuation: bool, base_marker: &str) -> Result<(ListItem<'input>, usize), Error>
= parent_is_some(parent_ordered_marker) !at_list_separator() !eol() comment_line()* !at_ordered_marker_ahead() item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{ item }
/ parent_is_some(parent_ordered_marker) !at_list_separator() eol()+ comment_line()* !at_shallower_unordered_marker(base_marker) !at_ordered_marker_ahead() item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{ item }
/ parent_is_none(parent_ordered_marker) !at_list_separator() !eol() comment_line()* item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{ item }
/ parent_is_none(parent_ordered_marker) !at_list_separator() eol()+ comment_line()* !at_shallower_unordered_marker(base_marker) item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{ item }
rule ordered_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_unordered_marker: Option<&'input str>, allow_continuation: bool, is_nested: bool) -> Result<Block<'input>, Error>
= whitespace()* marker_start:position!() base_marker:$(ordered_list_marker()) &whitespace()
first:ordered_list_item_after_marker(offset, block_metadata, allow_continuation, base_marker, marker_start, parent_unordered_marker)
rest:(ordered_list_rest_item(offset, block_metadata, parent_unordered_marker, allow_continuation, base_marker))*
{
tracing::debug!("Found ordered list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(span_end, |(_, item_end)| *item_end);
let items: Vec<ListItem<'input>> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or("", |item| item.marker);
Ok(Block::OrderedList(OrderedList {
title: if is_nested { Title::default() } else { block_metadata.title.clone() },
metadata: if is_nested { BlockMetadata::default() } else { block_metadata.metadata.clone() },
items,
marker,
location: state.create_location(start+offset, end+offset),
}))
}
rule ordered_list_item_after_marker(offset: usize, block_metadata: &BlockParsingMetadata<'input>, allow_continuation: bool, marker: &'input str, marker_start: usize, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= list_continuation_allowed(allow_continuation)
item:ordered_list_item_with_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_unordered_marker) { item }
/ item:ordered_list_item_no_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_unordered_marker) { item }
rule ordered_list_rest_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_unordered_marker: Option<&'input str>, allow_continuation: bool, base_marker: &str) -> Result<(ListItem<'input>, usize), Error>
= parent_is_some(parent_unordered_marker) !at_list_separator() !eol() comment_line()* !at_unordered_marker_ahead() item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{ item }
/ parent_is_some(parent_unordered_marker) !at_list_separator() eol()+ comment_line()* !at_shallower_ordered_marker(base_marker) !at_unordered_marker_ahead() item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{ item }
/ parent_is_none(parent_unordered_marker) !at_list_separator() !eol() comment_line()* item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{ item }
/ parent_is_none(parent_unordered_marker) !at_list_separator() eol()+ comment_line()* !at_shallower_ordered_marker(base_marker) item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{ item }
rule unordered_list_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>, allow_continuation: bool, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= list_continuation_allowed(allow_continuation)
item:unordered_list_item_with_continuation(offset, block_metadata, parent_ordered_marker) { item }
/ item:unordered_list_item_no_continuation(offset, block_metadata, parent_ordered_marker) { item }
rule unordered_list_item_with_continuation(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()*
marker:unordered_list_marker()
whitespace()
checked:checklist_item()?
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:unordered_list_principal_continuation(offset, marker, parent_ordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:unordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_ordered_marker) { nested_content })?
explicit_continuations:(!at_list_separator() cont:(
list_explicit_continuation_immediate(offset, block_metadata)
/ list_explicit_continuation_ancestor(offset, block_metadata)
) { cont })*
list_dangling_continuation()?
{
tracing::debug!(%first_line, ?continuation_lines, %marker, ?checked, "found unordered list item");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(explicit_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked,
location: state.create_location(span_start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_no_continuation(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()*
marker:unordered_list_marker()
whitespace()
checked:checklist_item()?
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:unordered_list_principal_continuation(offset, marker, parent_ordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:unordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_ordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
{
tracing::debug!(%first_line, ?continuation_lines, %marker, ?checked, "found unordered list item (immediate continuation only)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(immediate_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked,
location: state.create_location(span_start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_with_continuation_after_marker(offset: usize, block_metadata: &BlockParsingMetadata<'input>, marker: &'input str, marker_start: usize, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()
checked:checklist_item()?
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:unordered_list_principal_continuation(offset, marker, parent_ordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:unordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_ordered_marker) { nested_content })?
explicit_continuations:(!at_list_separator() cont:(
list_explicit_continuation_immediate(offset, block_metadata)
/ list_explicit_continuation_ancestor(offset, block_metadata)
) { cont })*
list_dangling_continuation()?
{
tracing::debug!(%first_line, ?continuation_lines, %marker, ?checked, "found unordered list item (after marker)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(explicit_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked,
location: state.create_location(marker_start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_no_continuation_after_marker(offset: usize, block_metadata: &BlockParsingMetadata<'input>, marker: &'input str, marker_start: usize, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()
checked:checklist_item()?
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:unordered_list_principal_continuation(offset, marker, parent_ordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:unordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_ordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
{
tracing::debug!(%first_line, ?continuation_lines, %marker, ?checked, "found unordered list item (after marker, immediate only)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(immediate_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked,
location: state.create_location(marker_start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_nested_after_principal(offset: usize, block_metadata: &BlockParsingMetadata<'input>, current_marker: &'input str, parent_ordered_marker: Option<&'input str>) -> Option<Result<Block<'input>, Error>>
= eol() nested:(
unordered_list_item_nested_content_with_metadata(offset, block_metadata, current_marker, parent_ordered_marker)
/ unordered_list_item_nested_content(offset, block_metadata, current_marker, parent_ordered_marker)
) { nested }
/ eol()+ !at_callout_list_item() nested:unordered_list_item_nested_content(offset, block_metadata, current_marker, parent_ordered_marker) { nested }
rule unordered_list_item_nested_content_with_metadata(offset: usize, block_metadata: &BlockParsingMetadata<'input>, current_marker: &'input str, parent_ordered_marker: Option<&'input str>) -> Option<Result<Block<'input>, Error>>
= nested_start:position!()
metadata:parsed_nested_list_metadata(offset, block_metadata.parent_section_level)
nested_list_metadata_gap()*
list:(
!at_ancestor_ordered_marker(parent_ordered_marker)
list:ordered_list(nested_start, offset, &metadata, Some(current_marker), false, false) { list }
/ &at_deeper_unordered_marker(current_marker)
list:unordered_list_nested(nested_start, offset, &metadata, current_marker, parent_ordered_marker, true) { list }
/ !at_callout_parent_item(offset)
list:callout_list(nested_start, offset, &metadata) { list }
)
{
Some(list)
}
rule unordered_list_item_nested_content(offset: usize, block_metadata: &BlockParsingMetadata<'input>, current_marker: &'input str, parent_ordered_marker: Option<&'input str>) -> Option<Result<Block<'input>, Error>>
= !at_ancestor_ordered_marker(parent_ordered_marker) nested_start:position!() list:ordered_list(nested_start, offset, block_metadata, Some(current_marker), false, true) {
Some(list)
}
/ &at_deeper_unordered_marker(current_marker)
nested_start:position!()
list:unordered_list_nested(nested_start, offset, block_metadata, current_marker, parent_ordered_marker, false)
{
Some(list)
}
/ list:nested_callout_list(offset, block_metadata) { Some(list) }
rule unordered_list_nested(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_marker: &str, parent_ordered_marker: Option<&'input str>, has_own_metadata: bool) -> Result<Block<'input>, Error>
= &at_deeper_unordered_marker(parent_marker)
whitespace()* marker_start:position!() base_marker:$(unordered_list_marker()) &whitespace()
first:unordered_list_item_after_marker(offset, block_metadata, false, base_marker, marker_start, parent_ordered_marker)
rest:(unordered_list_nested_rest_item(offset, block_metadata, parent_marker, base_marker, parent_ordered_marker))*
{
tracing::debug!(?parent_marker, ?base_marker, "Found nested unordered list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(span_end, |(_, item_end)| *item_end);
let items: Vec<ListItem<'input>> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or("", |item| item.marker);
Ok(Block::UnorderedList(UnorderedList {
title: if has_own_metadata { block_metadata.title.clone() } else { Title::default() },
metadata: if has_own_metadata { block_metadata.metadata.clone() } else { BlockMetadata::default() },
items,
marker,
location: state.create_location(start+offset, end+offset),
}))
}
rule unordered_list_nested_rest_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_marker: &str, base_marker: &str, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= !at_list_separator() !eol() comment_line()*
!at_shallower_or_equal_unordered_marker(parent_marker)
item:unordered_list_item(offset, block_metadata, false, parent_ordered_marker)
{ item }
/ !at_list_separator() eol()+ comment_line()*
!at_shallower_or_equal_unordered_marker(parent_marker)
!at_deeper_unordered_marker(base_marker)
item:unordered_list_item(offset, block_metadata, false, parent_ordered_marker)
{ item }
rule at_shallower_or_equal_unordered_marker(parent_marker: &str)
= whitespace()* marker:unordered_list_marker() whitespace() {?
if marker.len() <= parent_marker.len() { Ok(()) } else { Err("deeper") }
}
rule ordered_list_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>, allow_continuation: bool, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= list_continuation_allowed(allow_continuation)
item:ordered_list_item_with_continuation(offset, block_metadata, parent_unordered_marker) { item }
/ item:ordered_list_item_no_continuation(offset, block_metadata, parent_unordered_marker) { item }
rule ordered_list_item_with_continuation(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()*
marker:ordered_list_marker()
whitespace()
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:ordered_list_principal_continuation(offset, marker, parent_unordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:ordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_unordered_marker) { nested_content })?
explicit_continuations:(!at_list_separator() cont:(
list_explicit_continuation_immediate(offset, block_metadata)
/ list_explicit_continuation_ancestor(offset, block_metadata)
) { cont })*
list_dangling_continuation()?
{
tracing::debug!(%first_line, ?continuation_lines, %marker, "found ordered list item");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(explicit_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked: None,
location: state.create_location(span_start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_no_continuation(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()*
marker:ordered_list_marker()
whitespace()
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:ordered_list_principal_continuation(offset, marker, parent_unordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:ordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_unordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
{
tracing::debug!(%first_line, ?continuation_lines, %marker, "found ordered list item (immediate continuation only)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(immediate_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked: None,
location: state.create_location(span_start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_with_continuation_after_marker(offset: usize, block_metadata: &BlockParsingMetadata<'input>, marker: &'input str, marker_start: usize, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:ordered_list_principal_continuation(offset, marker, parent_unordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:ordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_unordered_marker) { nested_content })?
explicit_continuations:(!at_list_separator() cont:(
list_explicit_continuation_immediate(offset, block_metadata)
/ list_explicit_continuation_ancestor(offset, block_metadata)
) { cont })*
list_dangling_continuation()?
{
tracing::debug!(%first_line, ?continuation_lines, %marker, "found ordered list item (after marker)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(explicit_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked: None,
location: state.create_location(marker_start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_no_continuation_after_marker(offset: usize, block_metadata: &BlockParsingMetadata<'input>, marker: &'input str, marker_start: usize, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= whitespace()
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:ordered_list_principal_continuation(offset, marker, parent_unordered_marker)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() nested_content:ordered_list_item_nested_after_principal(offset, block_metadata, marker, parent_unordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
{
tracing::debug!(%first_line, ?continuation_lines, %marker, "found ordered list item (after marker, immediate only)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let principal = principal?;
let mut blocks = Vec::new();
if let Some(Some(Ok(nested_list))) = nested {
blocks.push(nested_list);
}
blocks.extend(immediate_continuations.into_iter().flatten());
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker,
checked: None,
location: state.create_location(marker_start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_nested_after_principal(offset: usize, block_metadata: &BlockParsingMetadata<'input>, current_marker: &'input str, parent_unordered_marker: Option<&'input str>) -> Option<Result<Block<'input>, Error>>
= eol() nested:(
ordered_list_item_nested_content_with_metadata(offset, block_metadata, current_marker, parent_unordered_marker)
/ ordered_list_item_nested_content(offset, block_metadata, current_marker, parent_unordered_marker)
) { nested }
/ eol()+ !at_callout_list_item() nested:ordered_list_item_nested_content(offset, block_metadata, current_marker, parent_unordered_marker) { nested }
rule ordered_list_item_nested_content_with_metadata(offset: usize, block_metadata: &BlockParsingMetadata<'input>, current_marker: &'input str, parent_unordered_marker: Option<&'input str>) -> Option<Result<Block<'input>, Error>>
= nested_start:position!()
metadata:parsed_nested_list_metadata(offset, block_metadata.parent_section_level)
nested_list_metadata_gap()*
list:(
!at_ancestor_unordered_marker(parent_unordered_marker)
list:unordered_list(nested_start, offset, &metadata, Some(current_marker), false, false) { list }
/ &at_deeper_ordered_marker(current_marker)
list:ordered_list_nested(nested_start, offset, &metadata, current_marker, parent_unordered_marker, true) { list }
/ !at_callout_parent_item(offset)
list:callout_list(nested_start, offset, &metadata) { list }
)
{
Some(list)
}
rule ordered_list_item_nested_content(offset: usize, block_metadata: &BlockParsingMetadata<'input>, current_marker: &'input str, parent_unordered_marker: Option<&'input str>) -> Option<Result<Block<'input>, Error>>
= !at_ancestor_unordered_marker(parent_unordered_marker) nested_start:position!() list:unordered_list(nested_start, offset, block_metadata, Some(current_marker), false, true) {
Some(list)
}
/ &at_deeper_ordered_marker(current_marker)
nested_start:position!()
list:ordered_list_nested(nested_start, offset, block_metadata, current_marker, parent_unordered_marker, false)
{
Some(list)
}
/ list:nested_callout_list(offset, block_metadata) { Some(list) }
rule ordered_list_nested(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_marker: &str, parent_unordered_marker: Option<&'input str>, has_own_metadata: bool) -> Result<Block<'input>, Error>
= &at_deeper_ordered_marker(parent_marker)
whitespace()* marker_start:position!() base_marker:$(ordered_list_marker()) &whitespace()
first:ordered_list_item_after_marker(offset, block_metadata, false, base_marker, marker_start, parent_unordered_marker)
rest:(ordered_list_nested_rest_item(offset, block_metadata, parent_marker, base_marker, parent_unordered_marker))*
{
tracing::debug!(?parent_marker, ?base_marker, "Found nested ordered list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(span_end, |(_, item_end)| *item_end);
let items: Vec<ListItem<'input>> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or("", |item| item.marker);
Ok(Block::OrderedList(OrderedList {
title: if has_own_metadata { block_metadata.title.clone() } else { Title::default() },
metadata: if has_own_metadata { block_metadata.metadata.clone() } else { BlockMetadata::default() },
items,
marker,
location: state.create_location(start+offset, end+offset),
}))
}
rule ordered_list_nested_rest_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>, parent_marker: &str, base_marker: &str, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem<'input>, usize), Error>
= !at_list_separator() !eol() comment_line()*
!at_shallower_or_equal_ordered_marker(parent_marker)
item:ordered_list_item(offset, block_metadata, false, parent_unordered_marker)
{ item }
/ !at_list_separator() eol()+ comment_line()*
!at_shallower_or_equal_ordered_marker(parent_marker)
!at_deeper_ordered_marker(base_marker)
item:ordered_list_item(offset, block_metadata, false, parent_unordered_marker)
{ item }
rule at_shallower_or_equal_ordered_marker(parent_marker: &str)
= whitespace()* marker:ordered_list_marker() whitespace() {?
if marker.len() <= parent_marker.len() { Ok(()) } else { Err("deeper") }
}
rule at_callout_list_item()
= &(callout_list_marker() whitespace()+ [^' ' | '\t' | '\r' | '\n'])
rule at_callout_parent_item(offset: usize)
= at_callout_list_item() {?
(state.callout_list_offset == Some(offset)).then_some(()).ok_or("not a callout parent")
}
rule enter_callout_list(offset: usize) -> (Option<usize>, Vec<CalloutRef>)
= {
let parent = state.callout_list_offset.replace(offset);
(parent, std::mem::take(&mut state.pending_callouts))
}
rule callout_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= at_callout_list_item() normal_paragraph_style(block_metadata)
context:enter_callout_list(offset)
first:callout_list_item(offset, block_metadata)
rest:(callout_list_rest_item(offset, block_metadata))*
{
let (parent, callouts) = context;
state.callout_list_offset = parent;
state.pending_callouts.clear();
tracing::debug!("Found callout list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(span_end, |(_, _, item_end)| *item_end);
let mut auto_number = 0;
let mut items: Vec<CalloutListItem> = Vec::with_capacity(content.len());
for (expected_number, (mut item, marker, _end)) in (1..).zip(content) {
let actual_number = if marker == "<.>" {
auto_number += 1;
auto_number.to_string()
} else {
marker.trim_start_matches('<').trim_end_matches('>').to_string()
};
if actual_number != expected_number.to_string() {
state.add_generic_warning_at(
format!(
"callout list item index: expected {expected_number}, got {actual_number}"
),
item.location.clone(),
);
}
item.callout.number = expected_number;
let callout_exists = callouts.iter()
.any(|c| c.number == expected_number);
if !callout_exists {
state.add_generic_warning_at(
format!("no callout found for <{expected_number}>"),
item.location.clone(),
);
}
items.push(item);
}
Ok(Block::CalloutList(CalloutList {
title: block_metadata.title.clone(),
metadata: block_metadata.metadata.clone(),
items,
location: state.create_location(start+offset, end+offset),
}))
}
rule callout_list_rest_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<(CalloutListItem<'input>, String, usize), Error>
= eol()+ item:callout_list_item(offset, block_metadata)
{?
Ok(item)
}
rule nested_callout_list(offset: usize, metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= !at_callout_parent_item(offset) start:position!()
child_metadata:callout_child_metadata(metadata)
list:callout_list(start, offset, &child_metadata) { list }
rule callout_child_marker(offset: usize)
= !at_callout_parent_item(offset) (
&(whitespace()* (unordered_list_marker() / ordered_list_marker()) whitespace())
/ check_line_is_description_list(offset)
)
rule callout_list_nested(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= eol() child:(
&((attribute_or_anchor_line_match() eol()*)+ callout_child_marker(offset))
start:position!()
metadata:parsed_nested_list_metadata(offset, block_metadata.parent_section_level)
list:callout_child_list(start, offset, &metadata) { list }
/ eol()* callout_child_marker(offset) start:position!()
metadata:callout_child_metadata(block_metadata)
list:callout_child_list(start, offset, &metadata) { list }
) { child }
rule callout_child_metadata(parent: &BlockParsingMetadata<'input>) -> BlockParsingMetadata<'input>
= {
BlockParsingMetadata {
parent_section_level: parent.parent_section_level,
substitutions: parent.substitutions,
hardbreaks: parent.hardbreaks,
..BlockParsingMetadata::default()
}
}
rule callout_child_list(start: usize, offset: usize, metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= unordered_list(start, offset, metadata, None, false, false)
/ ordered_list(start, offset, metadata, None, false, false)
/ description_list(start, offset, metadata, false)
rule callout_list_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<(CalloutListItem<'input>, String, usize), Error>
= at_callout_list_item()
marker:callout_list_marker()
whitespace()
first_line_start:position!()
first_line:$((!(eol()) [_])*)
continuation_lines:(
eol()
!at_callout_list_item()
!check_line_is_description_list(offset)
!(whitespace()* (unordered_list_marker() / ordered_list_marker() / section_level_marker() whitespace() / "[" / "+" whitespace()* eol() / eol()))
line:$((!(eol()) [_])*)
{ line }
)*
first_line_end:position!()
principal:list_item_principal(offset, block_metadata, first_line_start, first_line_end, first_line, &continuation_lines)
nested:(!at_list_separator() child:callout_list_nested(offset, block_metadata) { child })?
explicit_continuations:(!at_list_separator() cont:(
list_explicit_continuation_immediate(offset, block_metadata)
/ list_explicit_continuation_ancestor(offset, block_metadata)
) { cont })*
list_dangling_continuation()?
{
let principal = principal?;
let item_end = calculate_item_end(first_line.is_empty() && continuation_lines.is_empty(), span_start, first_line_end);
let blocks = nested
.into_iter()
.chain(explicit_continuations)
.collect::<Result<Vec<_>, _>>()?;
let location = state.create_location(span_start+offset, item_end+offset);
let callout = if marker == "<.>" {
CalloutRef::auto(0, location.clone()) } else {
CalloutRef::explicit(0, location.clone())
};
let actual_end = if blocks.is_empty() { item_end } else { span_end.saturating_sub(1) };
Ok((CalloutListItem {
callout,
principal,
blocks,
location: state.create_location(span_start+offset, actual_end+offset),
}, marker.to_string(), actual_end))
}
rule list_item_principal(offset: usize, metadata: &BlockParsingMetadata<'input>, start: usize, end: usize, first: &'input str, rest: &[&'input str]) -> Result<Vec<InlineNode<'input>>, Error>
= {
let text = assemble_principal_text(state, first, rest);
if text.trim().is_empty() {
Ok(Vec::new())
} else {
process_inlines(state, metadata, start, end, offset, text).map(|(nodes, _)| nodes)
}
}
rule checklist_item() -> ListItemCheckedStatus
= checked:(("[x]" / "[X]" / "[*]") { ListItemCheckedStatus::Checked } / "[ ]" { ListItemCheckedStatus::Unchecked }) whitespace()
{
checked
}
rule check_start_of_description_list(offset: usize)
= pos:position!() {?
if find_dlist_marker(state.input.as_bytes(), pos + offset, true, true) {
Ok(())
} else {
Err("no dlist marker before next blank line")
}
}
rule check_line_is_description_list(offset: usize)
= pos:position!() {?
if find_dlist_marker(state.input.as_bytes(), pos + offset, false, true) {
Ok(())
} else {
Err("no dlist marker on current line")
}
}
rule check_start_of_description_list_in_context(offset: usize, scan_across_eol: bool)
= pos:position!() {?
if find_dlist_marker(state.input.as_bytes(), pos + offset, scan_across_eol, true) {
Ok(())
} else {
Err("no dlist marker in this block context")
}
}
rule description_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>, scan_across_eol: bool) -> Result<Block<'input>, Error>
= check_start_of_description_list_in_context(offset, scan_across_eol)
first_item:description_list_item(offset, block_metadata)
additional_items:description_list_additional_items(offset, block_metadata)*
{
tracing::debug!("Found description list block with auto-attachment support");
let mut items = vec![first_item?];
for additional in additional_items {
items.push(additional?);
}
let actual_end = items.last().map_or(span_end, |item| {
let loc_end = item.location.absolute_end;
loc_end - offset
});
Ok(Block::DescriptionList(DescriptionList {
title: block_metadata.title.clone(),
metadata: block_metadata.metadata.clone(),
items: build_description_list_topology(items),
location: state.create_location(start+offset, actual_end+offset),
}))
}
rule description_list_additional_items(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<DescriptionListItem<'input>, Error>
= !at_dlist_block_boundary()
eol()*
!attribute_or_anchor_line_match()
!at_callout_list_item()
check_start_of_description_list(offset)
item:description_list_item(offset, block_metadata)
{
tracing::debug!("Found additional description list item");
item
}
rule description_list_item(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<DescriptionListItem<'input>, Error>
= term_start:position!()
term:$((!(description_list_marker() (eol() / " " / ![_]) / eol()*<2,2>) [_])+)
term_end:position!()
delim_start:position!() delimiter:description_list_marker() delim_end:position!()
whitespace()?
principal_start:position!()
principal_content:$(
(!eol() [_])*
(eol()
!eol() !at_callout_list_item()
!check_line_is_description_list(offset)
!(whitespace()* (unordered_list_marker() / ordered_list_marker()) whitespace()) !("+" (whitespace() / eol() / ![_])) !example_delimiter() !listing_delimiter()
!literal_delimiter()
!sidebar_delimiter()
!quote_delimiter()
!pass_delimiter()
!comment_delimiter()
!table_delimiter()
!(open_delimiter() (whitespace()* eol()))
!markdown_code_delimiter()
!attribute_or_anchor_line_match() !heading_boundary(offset) (!eol() [_])+ )*
)
principal:description_list_principal(offset, block_metadata, term, term_start, term_end, principal_content, principal_start)
attached_content:description_list_attached_content(offset, block_metadata)*
{
tracing::debug!(%term, %delimiter, "parsing description list item with auto-attachment");
let (term, principal_text) = principal?;
let mut description = Vec::with_capacity(attached_content.len());
for content in attached_content {
match content {
Ok(blocks) => description.extend(blocks),
Err(e) => {
state.add_warning(Warning::new(
WarningKind::ContentRecovery {
message: format!("discarded attached content: {e}").into(),
},
e.source_location().cloned(),
));
tracing::error!(?e, "Error processing attached content");
}
}
}
let actual_end = description.last().map_or_else(
|| {
if principal_content.is_empty() {
principal_start
} else {
principal_start + principal_content.len()
}
},
|b| {
let loc = b.location();
loc.absolute_end - offset
},
);
let delimiter_location = state.create_block_location(delim_start, delim_end, offset);
Ok(DescriptionListItem {
anchors: vec![],
term,
delimiter,
delimiter_location: Some(delimiter_location),
principal_text,
description,
location: state.create_location(span_start+offset, actual_end+offset),
})
}
rule description_list_principal(offset: usize, block_metadata: &BlockParsingMetadata<'input>, term: &'input str, term_start: usize, term_end: usize, principal_content: &'input str, principal_start: usize) -> Result<(Vec<InlineNode<'input>>, Vec<InlineNode<'input>>), Error>
= {
let trimmed_term = term.trim();
let leading_whitespace = term.len() - term.trim_start().len();
let term_start = term_start + leading_whitespace;
let term_end = term_end - (term.len() - term.trim_end().len());
let (term, _) = process_inlines(
state,
block_metadata,
term_start,
term_end,
offset,
trimmed_term,
)?;
let trimmed_principal = principal_content.trim();
let principal_text = if trimmed_principal.is_empty() {
Vec::new()
} else {
let content_start = principal_start + principal_content.len()
- principal_content.trim_start().len();
let (principal, _) = process_inlines(
state,
block_metadata,
content_start,
content_start + trimmed_principal.len(),
offset,
trimmed_principal,
)?;
principal
};
Ok((term, principal_text))
}
rule description_list_attached_content(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Vec<Block<'input>>, Error>
= eol() content:(
description_list_explicit_continuation(offset, block_metadata)
/ description_list_auto_attached_list(offset, block_metadata)
)
{
content
}
rule description_list_auto_attached_list(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Vec<Block<'input>>, Error>
= eol()* list:description_list_auto_attached_list_with_metadata(offset, block_metadata) { list }
/ eol()* &(whitespace()* (unordered_list_marker() / ordered_list_marker()) whitespace())
list_start:position!()
list:(unordered_list(list_start, offset, block_metadata, None, true, true) / ordered_list(list_start, offset, block_metadata, None, true, true))
{
tracing::debug!("Auto-attaching list to description list item");
Ok(vec![list?])
}
/ list:nested_callout_list(offset, block_metadata) { Ok(vec![list?]) }
rule description_list_auto_attached_list_with_metadata(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Vec<Block<'input>>, Error>
= &((attribute_or_anchor_line_match() eol()*)+ (whitespace()* (unordered_list_marker() / ordered_list_marker()) whitespace() / !at_callout_parent_item(offset) at_callout_list_item()))
list_start:position!()
metadata:parsed_nested_list_metadata(offset, block_metadata.parent_section_level)
list:(callout_list(list_start, offset, &metadata) / unordered_list(list_start, offset, &metadata, None, true, false) / ordered_list(list_start, offset, &metadata, None, true, false))
{
Ok(vec![list?])
}
rule description_list_explicit_continuation(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Vec<Block<'input>>, Error>
= continuations:(
eol()* "+" eol()
block:block_in_continuation(offset, block_metadata.parent_section_level)
{ block }
)+
{
tracing::debug!(count = continuations.len(), "Description list explicit continuation blocks");
Ok(continuations.into_iter().filter_map(Result::ok).collect())
}
rule list_explicit_continuation_immediate(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= eol() !eol() "+" eol()
block:block_in_continuation(offset, block_metadata.parent_section_level)
{
tracing::debug!("List immediate continuation block (0 empty lines)");
block
}
rule list_explicit_continuation_ancestor(offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= eol() eol()+ "+" eol()
block:block_in_continuation(offset, block_metadata.parent_section_level)
{
tracing::debug!("List ancestor continuation block (1+ empty lines)");
block
}
rule list_dangling_continuation()
= eol()+ "+" whitespace()* eol()? &(eol() / ![_] / trailing_block_metadata_match())
{
tracing::debug!("Dropped dangling list continuation marker");
}
rule quoted_paragraph(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= content_start:position!()
"\"" quoted_content:$((!"\"" [_])+) "\""
eol()
"-- " attr_start:position!() attribution_line:$([^'\n']+)
{
tracing::debug!(?quoted_content, ?attribution_line, "found quoted paragraph");
let (attr_str, cite_str): (&'input str, Option<&'input str>) = match attribution_line.split_once(',') {
Some((attr, cite)) => (state.intern_str(attr.trim()), Some(state.intern_str(cite.trim()))),
None => (state.intern_str(attribution_line.trim()), None),
};
let attr_end_offset = attr_start + attr_str.len();
let (attr_inlines, _) = process_inlines(
state,
block_metadata,
attr_start,
attr_end_offset,
offset,
attr_str,
)?;
let cite_inlines = if let Some(cite) = cite_str {
let cite_offset_in_line = attribution_line.find(',').unwrap_or(0) + 1;
let cite_raw_start = attr_start + cite_offset_in_line + (attribution_line[cite_offset_in_line..].len() - attribution_line[cite_offset_in_line..].trim_start().len());
let cite_pos = PositionWithOffset {
offset: cite_raw_start,
position: state.line_map.offset_to_position(cite_raw_start, state.input),
};
let (cite_inlines, _) = process_inlines(
state,
block_metadata,
cite_pos.offset,
cite_raw_start + cite.len(),
offset,
cite,
)?;
Some(cite_inlines)
} else {
None
};
let blocks = document_parser::blocks(quoted_content, state, content_start + offset, block_metadata.parent_section_level, None).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, quoted_content, content_start + offset, state, "Error parsing content as blocks in quoted paragraph");
Ok(Vec::new())
})?;
let mut metadata = block_metadata.metadata.clone();
metadata.style = Some("quote");
metadata.attribution = Some(Attribution::new(attr_inlines));
if let Some(inlines) = cite_inlines {
metadata.citetitle = Some(CiteTitle::new(inlines));
}
Ok(Block::DelimitedBlock(DelimitedBlock {
source_text: None,
metadata,
delimiter: "\"",
inner: DelimitedBlockType::DelimitedQuote(blocks),
title: block_metadata.title.clone(),
location: state.create_block_location(start, span_end, offset),
open_delimiter_location: None,
close_delimiter_location: None,
}))
}
rule markdown_blockquote(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= lines:markdown_blockquote_content_line()+ attribution:markdown_blockquote_attribution()?
{
tracing::debug!(?lines, ?attribution, "found markdown blockquote");
let content: &'input str = state.intern_join(lines.iter(), "\n");
let content_start = start;
let mut metadata = block_metadata.metadata.clone();
metadata.style = Some("quote");
if let Some((author, author_start, citation)) = attribution {
let author: &'input str = state.intern_str(&author);
let author_pos = PositionWithOffset {
offset: author_start,
position: state.line_map.offset_to_position(author_start, state.input),
};
let attr_end_offset = author_start + author.len();
let (attr_inlines, _) = process_inlines(
state,
block_metadata,
author_pos.offset,
attr_end_offset,
offset,
author,
)?;
metadata.attribution = Some(Attribution::new(attr_inlines));
if let Some((cite, cite_start)) = citation {
let cite: &'input str = state.intern_str(&cite);
let cite_pos = PositionWithOffset {
offset: cite_start,
position: state.line_map.offset_to_position(cite_start, state.input),
};
let (cite_inlines, _) = process_inlines(
state,
block_metadata,
cite_pos.offset,
cite_start + cite.len(),
offset,
cite,
)?;
metadata.citetitle = Some(CiteTitle::new(cite_inlines));
}
}
let location = state.create_block_location(start, span_end, offset);
let blocks = if content.trim().is_empty() {
Vec::new()
} else {
document_parser::blocks(content, state, content_start + offset, block_metadata.parent_section_level, None).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start + offset, state, "Error parsing content as blocks in markdown blockquote");
Ok(Vec::new())
})?
};
Ok(Block::DelimitedBlock(DelimitedBlock {
source_text: None,
metadata,
delimiter: ">",
inner: DelimitedBlockType::DelimitedQuote(blocks),
title: block_metadata.title.clone(),
location,
open_delimiter_location: None,
close_delimiter_location: None,
}))
}
rule markdown_blockquote_content_line() -> &'input str
= "> " content:$([^'\n']*) eol() &">" { content }
/ "> " !("-- ") content:$([^'\n']*) (eol() / ![_]) { content }
/ ">" eol() &">" { "" }
/ ">" eol() { "" }
/ ">" ![_] { "" }
rule markdown_blockquote_attribution() -> (String, usize, Option<(String, usize)>)
= "> -- " author_start:position!() author:$([^(',' | '\n')]+) ", " cite_start:position!() citation:$([^'\n']+) ((eol() !">") / ![_]) {
(author.trim().to_string(), author_start, Some((citation.trim().to_string(), cite_start)))
}
/ "> -- " author_start:position!() author:$([^'\n']+) ((eol() !">") / ![_]) {
(author.trim().to_string(), author_start, None)
}
rule styled_verbatim_paragraph(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= !normal_paragraph_style(block_metadata) block:paragraph(start, offset, block_metadata) { block }
rule verbatim_paragraph_content() -> &'input str
= content:$((!(
eol()*<2,>
/ eol()* ![_]
/ eol() "+" whitespace()* (eol() / ![_])
) [_])+) { content }
rule paragraph(start: usize, offset: usize, block_metadata: &BlockParsingMetadata<'input>) -> Result<Block<'input>, Error>
= admonition:(normal_paragraph_style(block_metadata) value:admonition() { value })?
content_start:position!()
content:(
!normal_paragraph_style(block_metadata) text:verbatim_paragraph_content() { text }
/ $((
"[[" (!eol() [_])*
/ !(
eol()*<2,>
/ eol()* ![_]
/ eol() &attributes_line()
/ eol() &anchor_line_match()
/ eol() example_delimiter()
/ eol() listing_delimiter()
/ eol() literal_delimiter()
/ eol() sidebar_delimiter()
/ eol() quote_delimiter()
/ eol() pass_delimiter()
/ eol() table_delimiter()
/ eol() markdown_code_delimiter()
/ eol() comment_delimiter()
/ eol() open_delimiter() &(whitespace()* eol())
/ eol() at_callout_parent_item(offset)
/ eol() !at_callout_list_item() list(start, offset, block_metadata)
/ eol() &("+" whitespace()* (eol() / ![_])) / eol()* &heading_boundary(offset)
) [_]
)+))
{
let is_styled_verbatim = matches!(
block_metadata.metadata.style,
Some("source" | "listing" | "literal")
);
if block_metadata.metadata.style == Some("comment") {
return Ok(Block::Comment(Comment {
kind: CommentKind::Paragraph,
content,
location: state.create_block_location(start, span_end, offset),
}));
}
if content.starts_with(' ')
&& !is_styled_verbatim
&& block_metadata.metadata.style != Some("verse")
{
return get_literal_paragraph(state, content, start, content_start, span_end, offset, block_metadata);
}
let source_text = content;
let content = if is_styled_verbatim {
let content_location =
state.create_block_location(content_start, span_end, offset);
let (verbatim_content, callouts) = resolve_verbatim_callouts(
state,
content,
content_location,
block_metadata
.substitutions
.enabled(&Substitution::Callouts),
!block_metadata.metadata.attributes.contains_key("line-comment"),
);
let content = if callouts.is_empty() {
let verbatim_metadata = BlockParsingMetadata {
substitutions: verbatim_substitutions(block_metadata),
..BlockParsingMetadata::default()
};
process_inlines(
state,
&verbatim_metadata,
content_start,
span_end,
offset,
content,
)?
.0
} else {
resolve_verbatim_inlines(state, block_metadata, verbatim_content)?
};
state.pending_callouts.extend(callouts);
content
} else {
process_inlines(
state,
block_metadata,
content_start,
span_end,
offset,
content,
)?
.0
};
let title: Title = if let Some(AttributeValue::String(title)) = block_metadata.metadata.attributes.get("title") {
vec![InlineNode::PlainText(Plain {
content: state.intern_cow(title.clone()),
location: state.create_location(start+offset, (start+offset).saturating_add(title.len()).saturating_sub(1)),
escaped: false,
})].into()
} else {
block_metadata.title.clone()
};
if let Some((variant, admonition_start, admonition_end)) = admonition {
let Ok(parsed_variant) = AdmonitionVariant::from_str(&variant) else {
tracing::error!(%variant, "invalid admonition variant");
return Err(Error::InvalidAdmonitionVariant(
Box::new(state.create_error_source_location(state.create_location(admonition_start + offset, admonition_end + offset - 1))),
variant
));
};
tracing::debug!(%variant, "found admonition block with variant");
Ok(Block::Admonition(Admonition{
metadata: block_metadata.metadata.clone(),
title,
blocks: vec![Block::Paragraph(Paragraph {
source_text: Some(source_text),
content,
metadata: block_metadata.metadata.clone(),
title: Title::default(),
location: state.create_block_location(content_start, span_end, offset),
})],
location: state.create_block_location(start, span_end, offset),
variant: parsed_variant,
}))
} else {
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
tracing::debug!(?content, "found paragraph block");
Ok(Block::Paragraph(Paragraph {
source_text: Some(source_text),
content,
metadata,
title,
location: state.create_block_location(start, span_end, offset),
}))
}
}
rule admonition() -> (String, usize, usize)
= variant:$("NOTE" / "WARNING" / "TIP" / "IMPORTANT" / "CAUTION") ": "
{
(variant.to_string(), span_start, span_end)
}
rule warn_anchor_id_with_whitespace() -> ()
= &(
id:$([^'\'' | ',' | ']' | '.' | '#']+)
{?
if id.chars().any(char::is_whitespace) {
let location = state.create_location(span_start, span_end);
state.add_generic_warning_at(
format!("anchor id '{id}' contains whitespace which is not allowed, treating as literal text"),
location,
);
}
Err::<(), &'static str>("")
}
)
rule anchor() -> Anchor<'input>
= result:(
double_open_square_bracket() warn_anchor_id_with_whitespace()? id:$([^'\'' | ',' | ']' | ' ' | '\t' | '\n' | '\r']+) comma() reftext:$([^']']+) double_close_square_bracket() {
(id, Some(reftext))
} /
double_open_square_bracket() warn_anchor_id_with_whitespace()? id:$([^'\'' | ',' | ']' | ' ' | '\t' | '\n' | '\r']+) double_close_square_bracket() {
(id, None)
} /
open_square_bracket() "#" warn_anchor_id_with_whitespace()? id:$([^'\'' | ',' | ']' | '.' | '%' | ' ' | '\t' | '\n' | '\r']+) close_square_bracket() {
(id, None)
}
)
end:position!()
eol()
{
let (id, reftext) = result;
let substituted_id = state.intern_cow(substitute(id, HEADER, &state.document_attributes));
let substituted_reftext = reftext.map(|rt| state.intern_cow(substitute(rt, HEADER, &state.document_attributes)));
Anchor {
id: substituted_id,
xreflabel: substituted_reftext,
location: state.create_location(span_start, end),
bibliography_label: None,
bibliography: false,
}
}
rule inline_anchor(offset: usize) -> InlineNode<'input>
= double_open_square_bracket()
warn_anchor_id_with_whitespace()?
id:$([^'\'' | ',' | ']' | '[' | ' ' | '\t' | '\n' | '\r']+)
reftext:(
comma() reftext:$([^']']+) {
Some(reftext)
} /
{
None
}
)
double_close_square_bracket()
{
let substituted_id = state.intern_cow(substitute(id, HEADER, &state.document_attributes));
let substituted_reftext = reftext.map(|rt| state.intern_cow(substitute(rt, HEADER, &state.document_attributes)));
InlineNode::InlineAnchor(Anchor {
id: substituted_id,
xreflabel: substituted_reftext,
location: state.create_block_location(span_start, span_end, offset),
bibliography_label: None,
bibliography: false,
})
}
rule inline_anchor_match() -> ()
= double_open_square_bracket() [^'\'' | ',' | ']' | '[' | ' ' | '\t' | '\n' | '\r']+ (comma() [^']']+)? double_close_square_bracket()
rule invalid_bibliography_anchor(offset: usize) -> InlineNode<'input>
= syntax:$("[[[" [^']' | '\n']* "]]]") {?
let body = &syntax[3..syntax.len() - 3];
let id = body.split_once(',').map_or(body, |(id, _)| id);
if is_valid_bibliography_id(id) {
Err("valid bibliography anchor")
} else {
Ok(InlineNode::PlainText(Plain {
content: syntax,
location: state.create_block_location(span_start, span_end, offset),
escaped: false,
}))
}
}
rule attributes_line() -> (bool, BlockMetadata<'input>)
= !empty_list_separator() attributes:attributes() eol() {
let (discrete, metadata, _title_position) = attributes;
(discrete, metadata)
}
rule empty_list_separator()
= whitespace()* "[" whitespace()* "]" whitespace()* eol() eol()
pub(crate) rule attributes() -> (bool, BlockMetadata<'input>, Option<(usize, usize)>)
= !double_open_square_bracket()
open_square_bracket()
content_start:position!()
content:attribute_list_content()
{
parse_block_attribute_list(
state,
content,
content_start,
span_end,
BlockAttributeMode::Block,
)
}
pub(crate) rule macro_attributes() -> (bool, BlockMetadata<'input>, Option<(usize, usize)>)
= macro_attributes_for(MacroAttributeContext::General)
rule image_macro_attributes() -> (bool, BlockMetadata<'input>, Option<(usize, usize)>)
= macro_attributes_for(MacroAttributeContext::Image)
rule macro_attributes_for(context: MacroAttributeContext) -> (bool, BlockMetadata<'input>, Option<(usize, usize)>)
= open_square_bracket()
content_start:position!()
content:attribute_list_content()
{
parse_block_attribute_list(
state,
content,
content_start,
span_end,
BlockAttributeMode::Macro(context),
)
}
rule open_square_bracket() = "["
rule close_square_bracket() = "]"
rule attribute_list_content() -> &'input str
= content:$((!last_close_square_bracket() [^'\n' | '\r'])*) close_square_bracket() { content }
rule last_close_square_bracket()
= &("]" [^']' | '\n' | '\r']* (eol() / ![_]))
rule double_open_square_bracket() = "[["
rule double_close_square_bracket() = "]]"
rule comma() = ","
rule period() = "."
pub rule url() -> String =
proto:$("https" / "http" / "ftp" / "irc") "://" path:url_path() { format!("{proto}://{path}") }
/ "mailto:" email:email_address() { format!("mailto:{email}") }
rule email_address() -> String
= local:$(
"\"" [^'"']+ "\""
/ ['a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '%' | '+' | '-']+
)
"@"
domain:$(
['a'..='z' | 'A'..='Z' | '0'..='9']+
(['.' | '-'] ['a'..='z' | 'A'..='Z' | '0'..='9']+)*
)
{?
if !domain.contains('.') {
return Err("email domain must have TLD (contain a dot)");
}
Ok(format!("{local}@{domain}"))
}
rule url_path() -> String = path:$(url_path_char() (url_path_char() / internal_url_path_spaces())*)
{?
let inline_state = InlinePreprocessorParserState::new_all_enabled(
path,
state.line_map.clone(),
state.input,
state.arena,
);
let processed = inline_preprocessing::run(path, &state.document_attributes, &inline_state)
.map_err(|e| {
tracing::error!(?e, "could not preprocess url path");
"could not preprocess url path"
})?;
let result = restore_url_path(processed);
let warnings = inline_state.drain_warnings();
drop(inline_state);
for warning in warnings {
state.add_inline_preprocessor_warning(warning);
}
Ok(result)
}
rule url_path_char() = ['A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '.' | '_' | '~' | ':' | '/' | '?' | '#' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | '%' | '\\' ]
rule internal_url_path_spaces() = [' ']+ &url_path_char()
rule bare_url() -> String =
proto:$("https" / "http" / "ftp" / "irc") "://" path:bare_url_path()
{ format!("{proto}://{path}") }
rule bare_url_path() -> String = path:$(
bare_url_safe_char()
( bare_url_safe_char()
/ bare_url_paren_group()
/ "("
/ bare_url_trailing_char() &bare_url_char()
)*
)
{?
let inline_state = InlinePreprocessorParserState::new_all_enabled(
path,
state.line_map.clone(),
state.input,
state.arena,
);
let processed = inline_preprocessing::run(path, &state.document_attributes, &inline_state)
.map_err(|e| {
tracing::error!(?e, "could not preprocess bare url path");
"could not preprocess bare url path"
})?;
let result = restore_url_path(processed);
let warnings = inline_state.drain_warnings();
drop(inline_state);
for warning in warnings {
state.add_inline_preprocessor_warning(warning);
}
Ok(result)
}
rule bare_url_paren_group()
= "(" (bare_url_safe_char() / bare_url_trailing_char() / bare_url_paren_group() / "(")* ")"
rule bare_url_safe_char() = ['A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '~'
| '/' | '#' | '@' | '$' | '&'
| '+' | '=' | '%' | '\\']
rule bare_url_trailing_char() = ['.' | ',' | ';' | '!' | '?' | ':' | '\'' | '*']
rule bare_url_char() = bare_url_safe_char() / bare_url_trailing_char() / "("
rule path_fragment() -> String
= "#" fragment:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-']+)
{
format!("#{fragment}")
}
pub rule path() -> String = path:$(path_char() (path_char() / internal_path_spaces())*)
{?
let inline_state = InlinePreprocessorParserState::new_all_enabled(
path,
state.line_map.clone(),
state.input,
state.arena,
);
let processed = inline_preprocessing::run(path, &state.document_attributes, &inline_state)
.map_err(|e| {
tracing::error!(?e, "could not preprocess path");
"could not preprocess path"
})?;
let result = processed.text.into_owned();
let warnings = inline_state.drain_warnings();
drop(inline_state);
for warning in warnings {
state.add_inline_preprocessor_warning(warning);
}
Ok(result)
}
rule path_char() = ['A'..='Z' | 'a'..='z' | '0'..='9' | '{' | '}' | '_' | '-' | '.' | '/' | '\\' | '%' | '\u{80}'..='\u{10FFFF}' ]
rule internal_path_spaces() = [' ']+ &path_char()
pub rule source() -> Source<'input>
= source:
(
u:url() {?
let interned = state.intern_str(&u);
Source::from_str_borrowed(interned).map_err(|_| "failed to parse URL")
}
/ p:path() {?
let interned = state.intern_str(&p);
Source::from_str_borrowed(interned).map_err(|_| "failed to parse path")
}
)
{ source }
rule digits() = ['0'..='9']+
rule whitespace() = quiet!{ " " / "\t" }
rule eol() = quiet!{ "\n" }
rule comment_line() = quiet!{ comment() (eol() / ![_]) }
rule comment() = quiet!{ "//" !"/" [^'\n']* (&eol() / ![_]) }
rule document_attribute_value() -> Cow<'input, str>
= whitespace()+ value:(
lines:backslash_continuation_lines() { Cow::Owned(lines.join("\n")) }
/ single_line:$([^'\n']+) { Cow::Borrowed(single_line) }
) { value }
rule backslash_continuation_lines() -> Vec<&'input str>
= lines:(line:$((!(" \\" eol()) [^'\n'])+ " \\") eol() { line })+
last:$([^'\n']+)?
{
let mut result = lines;
if let Some(l) = last {
result.push(l);
}
result
}
rule document_attribute_match() -> AttributeDeclaration<'input>
= ":"
key_entry:(
"!" key:$([^':']+) { (false, key) }
/ key:$([^('!' | ':')]+) "!" { (false, key) }
/ key:$([^':']+) { (true, key) }
)
":" &" "?
value:document_attribute_value()?
{
let (set, key) = key_entry;
let attr_value = if !set {
RawAttributeValue::Unset
} else if let Some(v) = value {
RawAttributeValue::Text(v)
} else {
RawAttributeValue::Set
};
AttributeDeclaration { name: key, value: attr_value }
}
/ expected!("document attribute key starting with ':'")
rule position() -> PositionWithOffset = offset:position!() {
PositionWithOffset {
offset,
position: state.line_map.offset_to_position(offset, state.input)
}
}
}
}
fn resolve_verbatim_callouts<'a>(
state: &ParserState<'a>,
text: &str,
base_location: Location,
callouts_enabled: bool,
xml_callouts_enabled: bool,
) -> (Vec<InlineNode<'a>>, Vec<CalloutRef>) {
let arena = state.arena;
if !callouts_enabled {
return verbatim_without_callouts(state, text, base_location);
}
let mut inlines = Vec::new();
let mut callouts = Vec::new();
let mut auto_number = 1usize;
let mut segment = VerbatimSegment::new(arena);
let mut line_start = 0;
let lines = text.split_inclusive('\n');
for raw_line in lines {
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let line = line.strip_suffix('\r').unwrap_or(line);
if let Some(first) = first_trailing_callout_marker(line, xml_callouts_enabled) {
segment.push(
&line[..first.source_start],
line_start,
line_start + first.source_start,
);
let mut previous_end = first.source_start;
let mut marker = first;
loop {
segment.push(
&line[previous_end..marker.source_start],
line_start + previous_end,
line_start + marker.source_start,
);
if marker.escaped {
segment.push(
&line[marker.marker_start..marker.end],
line_start + marker.source_start,
line_start + marker.end,
);
} else {
if marker.xml {
segment.push(
"<!--",
line_start + marker.marker_start,
line_start + marker.marker_start + 4,
);
}
if let Some(text) = segment.flush(state, base_location.absolute_start) {
inlines.push(text);
}
let callout_start = if marker.xml {
marker.marker_start + 4
} else {
marker.marker_start
};
let callout_end = if marker.xml {
marker.end - 3
} else {
marker.end
};
let location = state.create_block_location(
line_start + callout_start,
line_start + callout_end,
base_location.absolute_start,
);
let callout_ref = match marker.number {
ParsedCalloutNumber::Auto => {
let callout = CalloutRef::auto(auto_number, location);
auto_number += 1;
callout
}
ParsedCalloutNumber::Explicit(number) => {
CalloutRef::explicit(number, location)
}
};
inlines.push(InlineNode::CalloutRef(callout_ref.clone()));
callouts.push(callout_ref);
if marker.xml {
segment.push("-->", line_start + marker.end - 3, line_start + marker.end);
}
}
previous_end = marker.end;
let Some(next) = next_callout_marker(line, previous_end) else {
break;
};
marker = next;
}
segment.push(
&line[previous_end..],
line_start + previous_end,
line_start + line.len(),
);
} else {
segment.push(line, line_start, line_start + line.len());
}
if raw_line.ends_with('\n') {
segment.push("\n", line_start + line.len(), line_start + raw_line.len());
}
line_start += raw_line.len();
}
if let Some(text) = segment.flush(state, base_location.absolute_start) {
inlines.push(text);
}
(inlines, callouts)
}
fn verbatim_without_callouts<'a>(
state: &ParserState<'a>,
text: &str,
base_location: Location,
) -> (Vec<InlineNode<'a>>, Vec<CalloutRef>) {
let location = if text.is_empty() {
base_location
} else {
state.create_block_location(0, text.len(), base_location.absolute_start)
};
let mut content = BumpString::new_in(state.arena);
content.push_str(text);
(
vec![InlineNode::VerbatimText(Verbatim {
content: content.into_bump_str(),
location,
})],
Vec::new(),
)
}
struct VerbatimSegment<'a> {
content: BumpString<'a>,
source_start: Option<usize>,
source_end: usize,
}
impl<'a> VerbatimSegment<'a> {
fn new(arena: &'a bumpalo::Bump) -> Self {
Self {
content: BumpString::new_in(arena),
source_start: None,
source_end: 0,
}
}
fn push(&mut self, content: &str, source_start: usize, source_end: usize) {
if content.is_empty() {
return;
}
debug_assert!(source_start < source_end);
debug_assert!(self.source_start.is_none() || self.source_end == source_start);
self.source_start.get_or_insert(source_start);
self.source_end = source_end;
self.content.push_str(content);
}
fn flush(&mut self, state: &ParserState<'a>, base_offset: usize) -> Option<InlineNode<'a>> {
let source_start = self.source_start.take()?;
let source_end = std::mem::take(&mut self.source_end);
let content = std::mem::replace(&mut self.content, BumpString::new_in(state.arena));
Some(InlineNode::VerbatimText(Verbatim {
content: content.into_bump_str(),
location: state.create_block_location(source_start, source_end, base_offset),
}))
}
}
#[derive(Clone, Copy)]
enum ParsedCalloutNumber {
Auto,
Explicit(usize),
}
#[derive(Clone, Copy)]
struct ParsedCalloutMarker {
source_start: usize,
marker_start: usize,
end: usize,
number: ParsedCalloutNumber,
escaped: bool,
xml: bool,
}
fn first_trailing_callout_marker(
line: &str,
xml_callouts_enabled: bool,
) -> Option<ParsedCalloutMarker> {
let parse = |end| {
parse_callout_marker_ending_at(line, end)
.filter(|marker| xml_callouts_enabled || !marker.xml)
};
let mut marker = parse(line.trim_end().len())?;
loop {
let adjacent = parse(marker.source_start);
let spaced = marker
.source_start
.checked_sub(1)
.filter(|index| line.as_bytes().get(*index) == Some(&b' '))
.and_then(parse);
let Some(previous) = adjacent.or(spaced) else {
break;
};
marker = previous;
}
Some(marker)
}
fn next_callout_marker(line: &str, previous_end: usize) -> Option<ParsedCalloutMarker> {
parse_callout_marker_starting_at(line, previous_end).or_else(|| {
previous_end
.checked_add(1)
.filter(|_| line.as_bytes().get(previous_end) == Some(&b' '))
.and_then(|start| parse_callout_marker_starting_at(line, start))
})
}
fn parse_callout_marker_starting_at(
line: &str,
source_start: usize,
) -> Option<ParsedCalloutMarker> {
let escaped = line.as_bytes().get(source_start) == Some(&b'\\');
let marker_start = source_start + usize::from(escaped);
let marker = line.get(marker_start..)?;
let (number, end, xml) = if let Some(value) = marker.strip_prefix("<!--") {
let close = value.find("-->")?;
(
parse_callout_number(value.get(..close)?)?,
marker_start + 4 + close + 3,
true,
)
} else {
let value = marker.strip_prefix('<')?;
let close = value.find('>')?;
(
parse_callout_number(value.get(..close)?)?,
marker_start + 1 + close + 1,
false,
)
};
Some(ParsedCalloutMarker {
source_start,
marker_start,
end,
number,
escaped,
xml,
})
}
fn parse_callout_marker_ending_at(line: &str, end: usize) -> Option<ParsedCalloutMarker> {
let prefix = line.get(..end)?;
let marker_start = prefix.rfind('<')?;
let marker = prefix.get(marker_start..)?;
let (number, xml) = if marker.starts_with("<!--") && marker.ends_with("-->") {
(
parse_callout_number(marker.get(4..marker.len().checked_sub(3)?)?)?,
true,
)
} else if marker.starts_with('<') && marker.ends_with('>') {
(
parse_callout_number(marker.get(1..marker.len().checked_sub(1)?)?)?,
false,
)
} else {
return None;
};
let source_start = marker_start
.checked_sub(1)
.filter(|index| line.as_bytes().get(*index) == Some(&b'\\'))
.unwrap_or(marker_start);
Some(ParsedCalloutMarker {
source_start,
marker_start,
end,
number,
escaped: source_start != marker_start,
xml,
})
}
fn parse_callout_number(value: &str) -> Option<ParsedCalloutNumber> {
if value == "." {
Some(ParsedCalloutNumber::Auto)
} else {
value.parse().ok().map(ParsedCalloutNumber::Explicit)
}
}
#[cfg(test)]
#[allow(
clippy::indexing_slicing,
clippy::unwrap_used,
clippy::expect_used,
clippy::unreachable
)]
mod tests {
use super::*;
use crate::{Options, XrefCaptionLabel, XrefStyle, parse};
#[test]
#[tracing_test::traced_test]
fn test_document() -> Result<(), Error> {
let input = "// this comment line is ignored
= Document Title
Lorn_Kismet R. Lee <kismet@asciidoctor.org>; Norberto M. Lopes <nlopesml@gmail.com>
v2.9, 01-09-2024: Fall incarnation
:description: The document's description.
:sectanchors:
:url-repo: https://my-git-repo.com";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let header = result.header.expect("document has a header");
assert_eq!(header.title.len(), 1);
assert_eq!(
header.title[0],
InlineNode::PlainText(Plain {
content: "Document Title",
location: Location {
absolute_start: 34,
absolute_end: 47,
start: Position::new(2, 3),
end: Position::new(2, 16),
},
escaped: false,
})
);
assert_eq!(header.authors.len(), 2);
assert_eq!(header.authors[0].first_name, "Lorn Kismet");
assert_eq!(header.authors[0].middle_name, Some("R."));
assert_eq!(header.authors[0].last_name, "Lee");
assert_eq!(header.authors[0].initials, "LRL");
assert_eq!(header.authors[0].email, Some("kismet@asciidoctor.org"));
assert_eq!(header.authors[1].first_name, "Norberto");
assert_eq!(header.authors[1].middle_name, Some("M."));
assert_eq!(header.authors[1].last_name, "Lopes");
assert_eq!(header.authors[1].initials, "NML");
assert_eq!(header.authors[1].email, Some("nlopesml@gmail.com"));
assert_eq!(state.document_attributes.text("revnumber"), Some("2.9"));
assert_eq!(
state.document_attributes.text("revdate"),
Some("01-09-2024")
);
assert_eq!(
state.document_attributes.text("revremark"),
Some("Fall incarnation")
);
assert_eq!(
state.document_attributes.text("description"),
Some("The document's description.")
);
assert!(
state
.document_attributes
.get("sectanchors")
.is_some_and(crate::DocumentAttributeValue::is_presence)
);
assert_eq!(
state.document_attributes.text("url-repo"),
Some("https://my-git-repo.com")
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_authors() -> Result<(), Error> {
let input =
"Lorn_Kismet R. Lee <kismet@asciidoctor.org>; Norberto M. Lopes <nlopesml@gmail.com>";
let mut state = ParserState::new_for_test(input);
let result = document_parser::authors(input, &mut state)?;
assert_eq!(result.len(), 2);
assert_eq!(result[0].first_name, "Lorn Kismet");
assert_eq!(result[0].middle_name, Some("R."));
assert_eq!(result[0].last_name, "Lee");
assert_eq!(result[0].initials, "LRL");
assert_eq!(result[0].email, Some("kismet@asciidoctor.org"));
assert_eq!(result[1].first_name, "Norberto");
assert_eq!(result[1].middle_name, Some("M."));
assert_eq!(result[1].last_name, "Lopes");
assert_eq!(result[1].initials, "NML");
assert_eq!(result[1].email, Some("nlopesml@gmail.com"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_author() -> Result<(), Error> {
let input = "Norberto M. Lopes supa dough <nlopesml@gmail.com>";
let mut state = ParserState::new_for_test(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "Norberto");
assert_eq!(result.middle_name, Some("M."));
assert_eq!(result.last_name, "Lopes supa dough");
assert_eq!(result.initials, "NML");
assert_eq!(result.email, Some("nlopesml@gmail.com"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_compound_first_name() -> Result<(), Error> {
let input = "Ann_Marie Jenson";
let mut state = ParserState::new_for_test(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "Ann Marie");
assert_eq!(result.middle_name, None);
assert_eq!(result.last_name, "Jenson");
assert_eq!(result.initials, "AJ");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_compound_last_name() -> Result<(), Error> {
let input = "Tomás López_del_Toro";
let mut state = ParserState::new_for_test(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "Tomás");
assert_eq!(result.middle_name, None);
assert_eq!(result.last_name, "López del Toro");
assert_eq!(result.initials, "TL");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_compound_middle_name() -> Result<(), Error> {
let input = "First Middle_Name Last";
let mut state = ParserState::new_for_test(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "First");
assert_eq!(result.middle_name, Some("Middle Name"));
assert_eq!(result.last_name, "Last");
assert_eq!(result.initials, "FML");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_multiple_compound_authors() -> Result<(), Error> {
let input = "Ann_Marie Jenson; Tomás López_del_Toro";
let mut state = ParserState::new_for_test(input);
let result = document_parser::authors(input, &mut state)?;
assert_eq!(result.len(), 2);
assert_eq!(result[0].first_name, "Ann Marie");
assert_eq!(result[0].last_name, "Jenson");
assert_eq!(result[0].initials, "AJ");
assert_eq!(result[1].first_name, "Tomás");
assert_eq!(result[1].last_name, "López del Toro");
assert_eq!(result[1].initials, "TL");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_unicode_author_name() -> Result<(), Error> {
let input = "Tomás Müller";
let mut state = ParserState::new_for_test(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "Tomás");
assert_eq!(result.last_name, "Müller");
assert_eq!(result.initials, "TM");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_revision_full() -> Result<(), Error> {
let input = "v2.9, 01-09-2024: Fall incarnation";
let mut state = ParserState::new_for_test(input);
document_parser::revision(input, &mut state)?;
assert_eq!(state.document_attributes.text("revnumber"), Some("2.9"));
assert_eq!(
state.document_attributes.text("revdate"),
Some("01-09-2024")
);
assert_eq!(
state.document_attributes.text("revremark"),
Some("Fall incarnation")
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_revision_with_date_no_remark() -> Result<(), Error> {
let input = "v2.9, 01-09-2024";
let mut state = ParserState::new_for_test(input);
document_parser::revision(input, &mut state)?;
assert_eq!(state.document_attributes.text("revnumber"), Some("2.9"));
assert_eq!(
state.document_attributes.text("revdate"),
Some("01-09-2024")
);
assert_eq!(state.document_attributes.get("revremark"), None);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_revision_no_date_with_remark() -> Result<(), Error> {
let input = "v2.9: Fall incarnation";
let mut state = ParserState::new_for_test(input);
document_parser::revision(input, &mut state)?;
assert_eq!(state.document_attributes.text("revnumber"), Some("2.9"));
assert_eq!(state.document_attributes.get("revdate"), None);
assert_eq!(
state.document_attributes.text("revremark"),
Some("Fall incarnation")
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_revision_no_date_no_remark() -> Result<(), Error> {
let input = "v2.9";
let mut state = ParserState::new_for_test(input);
document_parser::revision(input, &mut state)?;
assert_eq!(state.document_attributes.text("revnumber"), Some("2.9"));
assert_eq!(state.document_attributes.get("revdate"), None);
assert_eq!(state.document_attributes.get("revremark"), None);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_comment_between_author_and_revision() -> Result<(), Error> {
let input = "= T
Roberto Avanzi
// a comment
v2.0, 2026-01-15: rel
:foo: bar";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let header = result.header.expect("document has a header");
assert_eq!(header.authors.len(), 1);
assert_eq!(header.authors[0].first_name, "Roberto");
assert_eq!(state.document_attributes.text("revnumber"), Some("2.0"));
assert_eq!(
state.document_attributes.text("revdate"),
Some("2026-01-15")
);
assert_eq!(state.document_attributes.text("foo"), Some("bar"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_authorcount_defaults_to_zero_without_author() -> Result<(), Error> {
let input = "= T\n\nbody";
let mut state = ParserState::new_for_test(input);
document_parser::document(input, &mut state)??;
assert_eq!(state.document_attributes.text("authorcount"), Some("0"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_title() -> Result<(), Error> {
let input = "= Document Title";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document_title(input, &mut state)?;
assert_eq!(result.0.len(), 1);
assert_eq!(
result.0[0],
InlineNode::PlainText(Plain {
content: "Document Title",
location: Location {
absolute_start: 2,
absolute_end: 15,
start: Position::new(1, 3),
end: Position::new(1, 16),
},
escaped: false,
})
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_title_and_subtitle() -> Result<(), Error> {
let input = "= Document Title: And a subtitle";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document_title(input, &mut state)?;
assert_eq!(
result,
(
Title::new(vec![InlineNode::PlainText(Plain {
content: "Document Title",
location: Location {
absolute_start: 2,
absolute_end: 15,
start: Position::new(1, 3),
end: Position::new(1, 16),
},
escaped: false,
})]),
Some(Subtitle::new(vec![InlineNode::PlainText(Plain {
content: "And a subtitle",
location: Location {
absolute_start: 18,
absolute_end: 31,
start: Position::new(1, 19),
end: Position::new(1, 32),
},
escaped: false,
})]))
)
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_header_with_title_and_authors() -> Result<(), Error> {
let input = "= Document Title
Lorn_Kismet R. Lee <kismet@asciidoctor.org>; Norberto M. Lopes <nlopesml@gmail.com>";
let mut state = ParserState::new_for_test(input);
let result =
document_parser::header(input, &mut state)??.expect("header should be present");
assert_eq!(result.title.len(), 1);
assert_eq!(
result.title[0],
InlineNode::PlainText(Plain {
content: "Document Title",
location: Location {
absolute_start: 2,
absolute_end: 15,
start: Position::new(1, 3),
end: Position::new(1, 16),
},
escaped: false,
})
);
assert_eq!(result.authors.len(), 2);
assert_eq!(result.authors[0].first_name, "Lorn Kismet");
assert_eq!(result.authors[0].middle_name, Some("R."));
assert_eq!(result.authors[0].last_name, "Lee");
assert_eq!(result.authors[0].initials, "LRL");
assert_eq!(result.authors[0].email, Some("kismet@asciidoctor.org"));
assert_eq!(result.authors[1].first_name, "Norberto");
assert_eq!(result.authors[1].middle_name, Some("M."));
assert_eq!(result.authors[1].last_name, "Lopes");
assert_eq!(result.authors[1].initials, "NML");
assert_eq!(result.authors[1].email, Some("nlopesml@gmail.com"));
Ok(())
}
#[test]
fn test_title_only_document_is_doctitle() -> Result<(), Error> {
let input = "= Doc Title";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let header = doc.header.expect("title-only doc should have a header");
assert_eq!(header.title.len(), 1);
assert_eq!(
header.title[0],
InlineNode::PlainText(Plain {
content: "Doc Title",
location: Location {
absolute_start: 2,
absolute_end: 10,
start: Position::new(1, 3),
end: Position::new(1, 11),
},
escaped: false,
})
);
assert!(
doc.blocks.is_empty(),
"title-only doc should have no body blocks, got: {:?}",
doc.blocks
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_empty_attribute_list() -> Result<(), Error> {
let input = "[]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(metadata.id, None);
assert_eq!(metadata.style, None);
assert_eq!(metadata.roles, [] as [&str; 0]);
assert_eq!(metadata.options, [] as [&str; 0]);
assert!(metadata.attributes.is_empty());
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_empty_attribute_list_with_discrete() -> Result<(), Error> {
let input = "[discrete]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(discrete); assert_eq!(metadata.id, None);
assert_eq!(metadata.style, Some("discrete"));
assert_eq!(metadata.roles, [] as [&str; 0]);
assert_eq!(metadata.options, [] as [&str; 0]);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_attribute_with_id() -> Result<(), Error> {
let input = "[id=my-id,role=admin,options=read,options=write]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(
metadata.id,
Some(Anchor {
id: "my-id",
xreflabel: None,
location: Location {
absolute_start: 4,
absolute_end: 9,
start: Position::new(1, 5),
end: Position::new(1, 10),
},
bibliography_label: None,
bibliography: false,
})
);
assert_eq!(metadata.style, None);
assert!(metadata.roles.contains(&"admin"));
assert!(metadata.options.contains(&"read"));
assert!(metadata.options.contains(&"write"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_attribute_with_id_mixed() -> Result<(), Error> {
let input = "[astyle#myid.admin,options=read,options=write]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(
metadata.id,
Some(Anchor {
id: "myid",
xreflabel: None,
location: Location {
absolute_start: 8,
absolute_end: 12,
start: Position::new(1, 9),
end: Position::new(1, 13),
},
bibliography_label: None,
bibliography: false,
})
);
assert_eq!(metadata.style, Some("astyle"));
assert!(metadata.roles.contains(&"admin"));
assert!(metadata.options.contains(&"read"));
assert!(metadata.options.contains(&"write"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_attribute_with_id_mixed_with_quotes() -> Result<(), Error> {
let input = "[astyle#myid.admin,options=\"read,write\"]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(
metadata.id,
Some(Anchor {
id: "myid",
xreflabel: None,
location: Location {
absolute_start: 8,
absolute_end: 12,
start: Position::new(1, 9),
end: Position::new(1, 13),
},
bibliography_label: None,
bibliography: false,
})
);
assert_eq!(metadata.style, Some("astyle"));
assert!(metadata.roles.contains(&"admin"));
assert!(metadata.options.contains(&"read"));
assert!(metadata.options.contains(&"write"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_shorthand_id_role_combined() -> Result<(), Error> {
let input = "[#bracket-id.some-role]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(
metadata.id,
Some(Anchor {
id: "bracket-id",
xreflabel: None,
location: Location {
absolute_start: 2,
absolute_end: 12,
start: Position::new(1, 3),
end: Position::new(1, 13),
},
bibliography_label: None,
bibliography: false,
})
);
assert_eq!(metadata.style, None);
assert!(metadata.roles.contains(&"some-role"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_shorthand_id_role_option_combined() -> Result<(), Error> {
let input = "[#my-id.my-role%my-option]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(
metadata.id,
Some(Anchor {
id: "my-id",
xreflabel: None,
location: Location {
absolute_start: 2,
absolute_end: 7,
start: Position::new(1, 3),
end: Position::new(1, 8),
},
bibliography_label: None,
bibliography: false,
})
);
assert_eq!(metadata.style, None);
assert!(metadata.roles.contains(&"my-role"));
assert!(metadata.options.contains(&"my-option"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_shorthand_multiple_roles() -> Result<(), Error> {
let input = "[#my-id.role-one.role-two]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(metadata.id.as_ref().map(|a| a.id), Some("my-id"));
assert!(metadata.roles.contains(&"role-one"));
assert!(metadata.roles.contains(&"role-two"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_shorthand_style_id_role() -> Result<(), Error> {
let input = "[quote#my-id.my-role]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(metadata.id.as_ref().map(|a| a.id), Some("my-id"));
assert_eq!(metadata.style, Some("quote"));
assert!(metadata.roles.contains(&"my-role"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_shorthand_just_roles() -> Result<(), Error> {
let input = "[.role-one.role-two]";
let mut state = ParserState::new_for_test(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(metadata.id, None);
assert!(metadata.roles.contains(&"role-one"));
assert!(metadata.roles.contains(&"role-two"));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_toc_simple() -> Result<(), Error> {
let input =
"= Document Title\n\n== Section 1\n\nSome content.\n\n== Section 2\n\nMore content.";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
assert_eq!(result.toc_entries.len(), 2);
assert_eq!(result.toc_entries[0].level, 1);
assert_eq!(result.toc_entries[0].id, "_section_1");
assert_eq!(result.toc_entries[1].level, 1);
assert_eq!(result.toc_entries[1].id, "_section_2");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_toc_tree() -> Result<(), Error> {
let input = "= Document Title\n\n== Section A\n\nContent A.\n\n=== Section A.1\n\nContent A.1\n\n== Section B\n\nContent B.";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
assert_eq!(result.toc_entries.len(), 3);
assert_eq!(result.toc_entries[0].id, "_section_a");
assert_eq!(result.toc_entries[1].id, "_section_a_1");
assert_eq!(result.toc_entries[2].id, "_section_b");
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_section_kind_classifies_special_sections() -> Result<(), Error> {
let input = "= Title\n\n[preface]\n== Introduction\n\nintro\n\n=== Features\n\nfeatures\n\n== Real Chapter\n\ntext";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let mut sections = Vec::new();
fn collect<'a, 'b>(blocks: &'b [Block<'a>], out: &mut Vec<&'b Section<'a>>) {
for block in blocks {
if let Block::Section(s) = block {
out.push(s);
collect(&s.content, out);
}
}
}
collect(&result.blocks, &mut sections);
assert_eq!(sections.len(), 3);
assert_eq!(sections[0].kind, SectionKind::Preface); assert_eq!(sections[1].kind, SectionKind::Normal); assert_eq!(sections[2].kind, SectionKind::Normal);
assert_eq!(result.toc_entries.len(), 3);
assert_eq!(result.toc_entries[0].kind, SectionKind::Preface);
assert_eq!(result.toc_entries[1].kind, SectionKind::Normal);
assert_eq!(result.toc_entries[2].kind, SectionKind::Normal);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_section_kind_appendix() -> Result<(), Error> {
let input = "= Title\n:doctype: book\n\n[appendix]\n== App\n\napp\n\n=== App Sub\n\nsub";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
assert_eq!(result.toc_entries.len(), 2);
assert_eq!(result.toc_entries[0].kind, SectionKind::Appendix);
assert_eq!(result.toc_entries[1].kind, SectionKind::Normal);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_toc_empty_document() -> Result<(), Error> {
let input = "= Document Title\n\nJust some content without sections.";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
assert_eq!(result.toc_entries.len(), 0);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_document_title() -> Result<(), Error> {
let input = "Document Title
==============
Some content.
";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let header = result.header.expect("document has a header");
assert_eq!(header.title.len(), 1);
assert!(
matches!(&header.title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Document Title")
);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_section() -> Result<(), Error> {
let input = "= Document Title
Section One
-----------
Content.
";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let section = result.blocks.iter().find_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
});
let section = section.expect("should have a section");
assert_eq!(section.level, 1);
assert!(
matches!(§ion.title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Section One")
);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_disabled_by_default() {
let input = "Document Title
==============
Some content.
";
let mut state = ParserState::new_for_test(input);
assert!(!state.options.setext);
let result = document_parser::document(input, &mut state);
if let Ok(Ok(doc)) = result {
assert!(doc.header.is_none());
}
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_single_section_per_level() -> Result<(), Error> {
let input = "Document Title
==============
Section One
-----------
Content here.
";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let header = result.header.expect("document has a header");
assert!(
matches!(&header.title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Document Title")
);
let section = result
.blocks
.iter()
.find_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.expect("should have a section");
assert_eq!(section.level, 1);
assert!(
matches!(§ion.title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Section One")
);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_sibling_sections() -> Result<(), Error> {
let input = "Document Title
==============
Section A
---------
Content A.
Section B
---------
Content B.
Section C
---------
Content C.
";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let header = result.header.expect("document has a header");
assert!(
matches!(&header.title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Document Title")
);
let sections: Vec<&Section> = result
.blocks
.iter()
.filter_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.collect();
assert_eq!(
sections.len(),
3,
"should have 3 top-level sibling sections"
);
for (i, section) in sections.iter().enumerate() {
assert_eq!(section.level, 1, "section {i} should be level 1");
}
assert!(
matches!(§ions[0].title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Section A")
);
assert!(
matches!(§ions[1].title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Section B")
);
assert!(
matches!(§ions[2].title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "Section C")
);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_all_underline_characters() -> Result<(), Error> {
let input = "= Doc\n\nLevel One\n---------\n\nContent.\n";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let section = result
.blocks
.iter()
.find_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.expect("level 1 section");
assert_eq!(section.level, 1);
let input = "= Doc\n\nLevel Two\n~~~~~~~~~\n\nContent.\n";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let section = result
.blocks
.iter()
.find_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.expect("level 2 section");
assert_eq!(section.level, 2);
let input = "= Doc\n\nLevel Three\n^^^^^^^^^^^\n\nContent.\n";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let section = result
.blocks
.iter()
.find_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.expect("level 3 section");
assert_eq!(section.level, 3);
let input = "= Doc\n\nLevel Four\n++++++++++\n\nContent.\n";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let section = result
.blocks
.iter()
.find_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.expect("level 4 section");
assert_eq!(section.level, 4);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_manpage_style_document() -> Result<(), Error> {
let input = "gitdatamodel(7)\n===============\n\nNAME\n----\ngitdatamodel - Git's core data model\n\nSYNOPSIS\n--------\ngitdatamodel\n";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let result = document_parser::document(input, &mut state)??;
let header = result.header.expect("document has a header");
assert!(
matches!(&header.title[0], InlineNode::PlainText(Plain { content, .. }) if content.contains("gitdatamodel"))
);
let sections: Vec<&Section> = result
.blocks
.iter()
.filter_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.collect();
assert_eq!(
sections.len(),
2,
"should have 2 top-level sections (NAME and SYNOPSIS)"
);
assert_eq!(sections[0].level, 1);
assert_eq!(sections[1].level, 1);
assert!(
matches!(§ions[0].title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "NAME")
);
assert!(
matches!(§ions[1].title[0], InlineNode::PlainText(Plain { content, .. }) if *content == "SYNOPSIS")
);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
#[tracing_test::traced_test]
fn test_setext_with_description_lists() -> Result<(), Error> {
let input = "\
gitdatamodel(7)
===============
NAME
----
gitdatamodel - description
SYNOPSIS
--------
gitdatamodel
OBJECTS
-------
commit::
A commit.
REFERENCES
----------
References.
";
let options = Options::builder().with_setext().build()?;
let parsed = parse(input, &options)?;
let result = parsed.document();
let header = result.header.as_ref().expect("document has a header");
assert!(
matches!(&header.title[0], InlineNode::PlainText(Plain { content, .. }) if content.contains("gitdatamodel"))
);
let sections: Vec<&Section> = result
.blocks
.iter()
.filter_map(|b| {
if let Block::Section(s) = b {
Some(s)
} else {
None
}
})
.collect();
assert_eq!(
sections.len(),
4,
"should have 4 sections (NAME, SYNOPSIS, OBJECTS, REFERENCES)"
);
for section in §ions {
assert_eq!(section.level, 1);
}
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_index_term_flow() -> Result<(), Error> {
use crate::InlineMacro;
let input = "= Test\n\nThis is about ((Arthur)) the king.\n";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let paragraph = result
.blocks
.iter()
.find_map(|b| {
if let Block::Paragraph(p) = b {
Some(p)
} else {
None
}
})
.expect("paragraph exists");
let has_index_term = paragraph.content.iter().any(|inline| {
matches!(
inline,
InlineNode::Macro(InlineMacro::IndexTerm(it))
if it.is_visible()
&& matches!(it.term(), [InlineNode::PlainText(text)] if text.content == "Arthur")
)
});
assert!(
has_index_term,
"Expected to find visible index term 'Arthur', but found: {:?}",
paragraph.content
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_index_term_concealed() -> Result<(), Error> {
use crate::InlineMacro;
let input = "= Test\n\n(((Sword, Broadsword)))This is a concealed index term.\n";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let paragraph = result
.blocks
.iter()
.find_map(|b| {
if let Block::Paragraph(p) = b {
Some(p)
} else {
None
}
})
.expect("paragraph exists");
let has_concealed_term = paragraph.content.iter().any(|inline| {
matches!(
inline,
InlineNode::Macro(InlineMacro::IndexTerm(it))
if !it.is_visible()
&& matches!(it.term(), [InlineNode::PlainText(text)] if text.content == "Sword")
)
});
assert!(
has_concealed_term,
"Expected to find concealed index term 'Sword', but found: {:?}",
paragraph.content
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_macro_attributes_allow_literal_special_chars() -> Result<(), Error> {
fn get_image<'a>(doc: &'a Document<'a>) -> &'a Image<'a> {
doc.blocks
.iter()
.find_map(|b| {
if let Block::Image(img) = b {
Some(img)
} else {
None
}
})
.expect("document should have an image block")
}
let input = "image::photo.jpg[Diablo 4 picture of Lilith.]";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let img = get_image(&result);
assert_eq!(
img.metadata.attributes.get("alt"),
Some(&AttributeValue::String(
"Diablo 4 picture of Lilith.".into()
)),
"Trailing period should be preserved in alt text"
);
let input = "image::photo.jpg[.role]";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let img = get_image(&result);
assert_eq!(
img.metadata.attributes.get("alt"),
Some(&AttributeValue::String(".role".into())),
".role should be literal alt text, not a CSS class"
);
assert!(
img.metadata.roles.is_empty(),
"roles should be empty - .role is literal text"
);
let input = "image::photo.jpg[Issue #42]";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let img = get_image(&result);
assert_eq!(
img.metadata.attributes.get("alt"),
Some(&AttributeValue::String("Issue #42".into())),
"#42 should be preserved as literal text"
);
assert!(
img.metadata.id.is_none(),
"id should be empty - #42 is literal text"
);
let input = "image::photo.jpg[role=thumbnail]";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let img = get_image(&result);
assert_eq!(
img.metadata.roles,
vec![std::borrow::Cow::Borrowed("thumbnail")],
"Named role= attribute should work"
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_block_macro_uses_last_closing_bracket() -> Result<(), Error> {
let input = "image::foo.svg[role=inline][100,100]\n\n[.lead]\nHello\n";
let mut state = ParserState::new_for_test(input);
let result = document_parser::document(input, &mut state)??;
let image = result.blocks.iter().find_map(|block| {
if let Block::Image(image) = block {
Some(image)
} else {
None
}
});
assert!(image.is_some(), "document should contain an image block");
if let Some(image) = image {
assert_eq!(image.metadata.roles, ["inline][100"]);
assert_eq!(
image.metadata.attributes.get("width"),
Some(&AttributeValue::String("100".into()))
);
}
assert!(
result
.blocks
.iter()
.any(|b| matches!(b, Block::Paragraph(_))),
"document should contain a paragraph block"
);
assert!(state.warnings.borrow().is_empty());
Ok(())
}
#[test]
fn test_trailing_content_warning_resolves_source_range() {
use crate::model::SourceRange;
use std::path::PathBuf;
let input = "a]b\n".repeat(20); let mut state = ParserState::new_for_test(&input);
state.current_file = Some(PathBuf::from("/docs/main.adoc").into());
state.source_ranges = vec![SourceRange {
start_offset: 28, end_offset: 60,
file: Some(PathBuf::from("/docs/sponsor.adoc")),
file_chain: vec!["sponsor.adoc".to_string()],
start_line: 1,
source_start_offset: 0,
column_shift: 0,
}];
state.warn_trailing_macro_content("image", "[100,100]", 40, 0);
let warnings = state.warnings.borrow();
assert_eq!(warnings.len(), 1);
let loc = warnings[0]
.source_location()
.expect("warning should have a location");
assert_eq!(
loc.file.as_deref(),
Some(std::path::Path::new("/docs/sponsor.adoc")),
"should reference the included file, got: {:?}",
loc.file,
);
let position_line = loc.location.start.line;
assert_eq!(
position_line, 4,
"should reference line 4 in included file, got line {position_line}",
);
}
#[test]
fn test_trailing_content_warning_falls_back_to_entry_file() {
use crate::model::SourceRange;
use std::path::PathBuf;
let input = "image::x.png[alt]extra\nsecond line\n";
let mut state = ParserState::new_for_test(input);
state.current_file = Some(PathBuf::from("/docs/main.adoc").into());
state.source_ranges = vec![SourceRange {
start_offset: 100, end_offset: 200,
file: Some(PathBuf::from("/docs/other.adoc")),
file_chain: vec!["other.adoc".to_string()],
start_line: 1,
source_start_offset: 0,
column_shift: 0,
}];
state.warn_trailing_macro_content("image", "extra", 17, 0);
let warnings = state.warnings.borrow();
assert_eq!(warnings.len(), 1);
let loc = warnings[0]
.source_location()
.expect("warning should have a location");
assert_eq!(
loc.file.as_deref(),
Some(std::path::Path::new("/docs/main.adoc")),
"should reference the entry-point file, got: {:?}",
loc.file,
);
}
#[test]
fn test_first_section_not_level_1_emits_warning() -> Result<(), Error> {
let input = "= Doc Title\n\n=== Starts at level 2\n\nContent\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
let warning = warnings
.iter()
.find(|w| {
matches!(
&w.kind,
WarningKind::SectionLevelOutOfSequence { got: 2, .. },
)
})
.expect("expected out-of-sequence warning");
let loc = warning
.source_location()
.expect("warning should carry a location");
assert_eq!(loc.location.start.line, 3);
Ok(())
}
#[test]
fn test_level0_appendix_level2_subsection_no_warning() -> Result<(), Error> {
let input = "= Book\n:doctype: book\n\n= Part One\n\n== Chapter\n\nbody\n\n[appendix]\n= App Part\n\nintro\n\n=== First Subsection\n\nbody\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::SectionLevelOutOfSequence { .. },)),
"level-2 subsection of a level-0 appendix is in sequence, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_level0_preface_level2_subsection_no_warning() -> Result<(), Error> {
let input =
"= Book\n:doctype: book\n\n[preface]\n= Preface\n\nintro\n\n=== Background\n\nbody\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings.iter().any(|warning| matches!(
&warning.kind,
WarningKind::SectionLevelOutOfSequence { .. },
)),
"level-2 subsection of a level-0 preface is in sequence, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_level0_appendix_level3_child_still_warns() -> Result<(), Error> {
let input = "= Book\n:doctype: book\n\n= Part One\n\n== Chapter\n\nbody\n\n[appendix]\n= App Part\n\nintro\n\n==== Too Deep\n\nbody\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings.iter().any(|w| matches!(
&w.kind,
WarningKind::SectionLevelOutOfSequence {
expected: 2,
got: 3
},
)),
"level-3 child skipping level 2 should warn, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_titleless_preamble_then_deep_section_emits_warning() -> Result<(), Error> {
let input = "term:: desc\n\n===== Deep\n\ntext\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings.iter().any(|w| matches!(
&w.kind,
WarningKind::SectionLevelOutOfSequence {
expected: 1,
got: 4
},
)),
"expected out-of-sequence warning, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_titleless_bare_deep_section_no_warning() -> Result<(), Error> {
let input = "===== Deep\n\ntext\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::SectionLevelOutOfSequence { .. },)),
"expected no out-of-sequence warning, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_multiple_top_level_sections_each_warn() -> Result<(), Error> {
let input = "= Doc Title\n\n===== One\n\ntext\n\n===== Two\n\ntext\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
let count = warnings
.iter()
.filter(|w| {
matches!(
&w.kind,
WarningKind::SectionLevelOutOfSequence {
expected: 1,
got: 4
},
)
})
.count();
assert_eq!(
count, 2,
"expected one warning per sibling, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_bare_deep_section_siblings_no_warning() -> Result<(), Error> {
let input = "===== One\n\ntext\n\n===== Two\n\ntext\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::SectionLevelOutOfSequence { .. },)),
"expected no out-of-sequence warning, got: {warnings:?}"
);
Ok(())
}
#[test]
fn test_comment_style_block_dropped() -> Result<(), Error> {
let input = "[comment]\n--\nhidden\n\n== Hidden heading\n--\n\n[comment]\nhidden para.\n\nVisible.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
assert_eq!(doc.blocks.len(), 3);
assert!(
matches!(
&doc.blocks[0],
Block::DelimitedBlock(delimited)
if matches!(&delimited.inner, DelimitedBlockType::DelimitedComment(nodes)
if matches!(&nodes[0], InlineNode::PlainText(text)
if text.content.contains("Hidden heading")))
&& delimited.delimiter == "--"
&& delimited.metadata.style.is_none()
),
"the [comment] open block should be a `--` DelimitedComment"
);
assert!(
matches!(&doc.blocks[1], Block::Comment(comment) if comment.kind == CommentKind::Paragraph),
"the [comment] paragraph should be a Comment of kind Paragraph"
);
assert!(
matches!(&doc.blocks[2], Block::Paragraph(para)
if matches!(¶.content[..], [InlineNode::PlainText(text)]
if text.content == "Visible.")),
"the trailing paragraph should survive"
);
Ok(())
}
#[test]
fn test_comment_style_on_listing_renders() -> Result<(), Error> {
let input = "[comment]\n----\nvisible\n----\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
assert_eq!(doc.blocks.len(), 1);
assert!(
matches!(
&doc.blocks[0],
Block::DelimitedBlock(delimited)
if matches!(delimited.inner, DelimitedBlockType::DelimitedListing(_))
),
"a [comment]-styled listing must still render as a listing"
);
Ok(())
}
#[test]
fn test_unresolved_reference_warns() -> Result<(), Error> {
let input = "A paragraph.\n\nSee <<missing>>.\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings.iter().any(|w| matches!(
&w.kind,
WarningKind::UnresolvedReference { target } if target == "missing"
)),
"expected an unresolved-reference warning for `missing`"
);
Ok(())
}
#[test]
fn test_inline_anchor_reference_resolves() -> Result<(), Error> {
let input = "Some text [[here]] in a paragraph.\n\nSee <<here>>.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
assert!(doc.references.contains_key("here"));
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::UnresolvedReference { .. })),
"a reference to an existing inline anchor must not warn"
);
Ok(())
}
#[test]
fn test_formatted_inline_ids_resolve_cross_references() -> Result<(), Error> {
let input = r#"A [#bold-id]*bold*, [#italic-id]_italic_, [#mono-id]`mono`, [#mark-id]#mark#, [#sub-id]~sub~, [#super-id]^super^, [#double-id]"`double`", and [#single-id]'`single`'.
See <<bold-id>>, <<italic-id>>, <<mono-id>>, <<mark-id>>, <<sub-id>>, <<super-id>>, <<double-id>>, and <<single-id>>.
"#;
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
for id in [
"bold-id",
"italic-id",
"mono-id",
"mark-id",
"sub-id",
"super-id",
"double-id",
"single-id",
] {
assert!(
doc.references.contains_key(id),
"formatted inline ID `{id}` must be a reference target"
);
}
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|warning| matches!(warning.kind, WarningKind::UnresolvedReference { .. })),
"formatted inline references should resolve: {warnings:?}"
);
Ok(())
}
#[test]
fn test_link_ids_resolve_cross_references_without_using_link_text() -> Result<(), Error> {
let input = r"Before: <<link-id>>, <<url-id>>, <<mailto-id>>, and <<bare-id>>.
link:https://example.com[Link text,id=link-id,role=hot]
https://example.org[URL text,id=url-id]
mailto:person@example.com[Mail text,id=mailto-id]
link:https://example.net[,id=bare-id]
After: <<link-id>>, <<url-id>>, <<mailto-id>>, and <<bare-id>>.
";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
for id in ["link-id", "url-id", "mailto-id", "bare-id"] {
let Some(reference) = doc.references.get(id) else {
unreachable!("link ID `{id}` must be a reference target");
};
assert!(reference.xreflabel.is_none());
assert!(reference.title.is_none());
}
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|warning| matches!(warning.kind, WarningKind::UnresolvedReference { .. })),
"link references should resolve: {warnings:?}"
);
Ok(())
}
#[test]
fn test_link_id_catalog_keeps_first_definition_and_ignores_positional_text() -> Result<(), Error>
{
let input = r"link:https://example.com[First,id=duplicate]
link:https://example.org[Second,id=duplicate]
link:https://example.net[Text,positional-id]
";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
assert_eq!(
doc.references
.get("duplicate")
.expect("the first link ID must be catalogued")
.location
.start
.line,
1
);
assert!(!doc.references.contains_key("positional-id"));
Ok(())
}
#[test]
fn test_callout_item_inline_anchor_resolves() -> Result<(), Error> {
let input = "----\ncode <1>\n----\n<1> Note with an [[cnote]] anchor.\n\nSee <<cnote>>.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
assert!(doc.references.contains_key("cnote"));
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::UnresolvedReference { .. })),
"a reference to an anchor inside a callout item must not warn"
);
Ok(())
}
#[test]
fn test_callout_item_explicit_continuation_attaches_block() -> Result<(), Error> {
let input = "----\nfirst <1>\nsecond <2>\n----\n<1> First explanation.\n+\nAttached paragraph.\n<2> Second explanation.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let list = doc
.blocks
.iter()
.find_map(|block| {
if let Block::CalloutList(list) = block {
Some(list)
} else {
None
}
})
.expect("callout list must be parsed");
assert_eq!(list.items.len(), 2);
assert_eq!(list.items[0].blocks.len(), 1);
assert!(matches!(list.items[0].blocks[0], Block::Paragraph(_)));
assert_eq!(list.items[1].blocks, []);
assert!(state.warnings.borrow().is_empty());
Ok(())
}
#[test]
fn test_titled_block_collected_in_references() -> Result<(), Error> {
let input = "[[data-table]]\n.Important Data\n[cols=\"1,1\"]\n|===\n| a | b\n|===\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let entry = doc
.references
.get("data-table")
.expect("titled block should be a reference target");
let title = entry
.title
.as_ref()
.expect("titled block has reference text");
assert!(
matches!(&title[..], [InlineNode::PlainText(text)] if text.content == "Important Data")
);
assert_eq!(entry.location.start.line, 1);
Ok(())
}
#[test]
fn natural_title_cross_references_resolve_to_section_ids() -> Result<(), Error> {
let input = "Generated: <<Syntax Highlighting>>.\n\nCustom: <<Syntax Highlighting,section>>.\n\nExplicit: <<explicit-id>>.\n\nExplicit title: <<Explicit Title>>.\n\nMissing: <<Missing Title>>.\n\n== Syntax Highlighting\n\n[#explicit-id]\n== Explicit Title\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let xrefs = doc
.blocks
.iter()
.filter_map(|block| {
let Block::Paragraph(paragraph) = block else {
return None;
};
paragraph.content.iter().find_map(|inline| {
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return None;
};
Some(xref.target)
})
})
.collect::<Vec<_>>();
assert_eq!(
xrefs,
[
"_syntax_highlighting",
"_syntax_highlighting",
"explicit-id",
"explicit-id",
"Missing Title",
]
);
let warnings = state.warnings.borrow();
assert_eq!(
warnings
.iter()
.filter_map(|warning| {
let WarningKind::UnresolvedReference { target } = &warning.kind else {
return None;
};
Some(target.as_str())
})
.collect::<Vec<_>>(),
["Missing Title"]
);
Ok(())
}
#[test]
fn named_section_reftext_populates_catalog_toc_and_warnings()
-> Result<(), Box<dyn std::error::Error>> {
let input =
include_str!("../../fixtures/tests/named_section_reftext_cross_references.adoc");
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let reference = doc.references.get("id").ok_or("missing id reference")?;
assert!(
matches!(
reference.xreflabel.as_deref(),
Some([InlineNode::PlainText(text)]) if text.content == "Custom Label"
),
"{:?}",
reference.xreflabel
);
assert_eq!(
doc.toc_entries
.iter()
.find(|entry| entry.id == "id")
.and_then(|entry| entry.xreflabel),
Some("Custom Label")
);
let formatted = doc
.references
.get("formatted")
.ok_or("missing formatted reference")?;
assert!(
matches!(
formatted.xreflabel.as_deref(),
Some([
InlineNode::PlainText(prefix),
InlineNode::BoldText(bold),
InlineNode::PlainText(suffix),
]) if prefix.content == "Custom "
&& suffix.content == " Label"
&& matches!(
&bold.content[..],
[InlineNode::PlainText(text)] if text.content == "Formatted"
)
),
"{:?}",
formatted.xreflabel
);
assert_eq!(
state
.warnings
.borrow()
.iter()
.filter_map(|warning| {
let WarningKind::UnresolvedReference { target } = &warning.kind else {
return None;
};
Some(target.as_str())
})
.collect::<Vec<_>>(),
["Actual Title", "Generated Title", "Custom Formatted Label"]
);
Ok(())
}
#[test]
fn passthrough_xref_warnings_use_restored_targets_and_source_locations() -> Result<(), Error> {
let input = include_str!(
"../../fixtures/tests/natural_title_cross_references_with_passthrough.adoc"
);
let result = parse(input, &Options::default())?;
let warnings = result
.warnings()
.iter()
.filter_map(|warning| {
let WarningKind::UnresolvedReference { target } = &warning.kind else {
return None;
};
let location = warning.source_location()?;
Some((
target.as_str(),
location.location.start.line,
location.location.start.column,
))
})
.collect::<Vec<_>>();
assert_eq!(
warnings,
[
("Target raw Title", 3, 15),
("Target raw Title", 4, 14),
("Missing raw Title", 5, 16),
("Missing raw Title", 6, 15),
]
);
Ok(())
}
#[test]
fn compat_mode_skips_natural_title_cross_reference_resolution() -> Result<(), Error> {
let input = "= Document\n:compat-mode:\n\nNatural: <<Syntax Highlighting>>.\n\nExplicit: <<_syntax_highlighting>>.\n\n== Syntax Highlighting\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let targets = doc
.blocks
.iter()
.filter_map(|block| {
let Block::Paragraph(paragraph) = block else {
return None;
};
paragraph.content.iter().find_map(|inline| {
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return None;
};
Some(xref.target)
})
})
.collect::<Vec<_>>();
assert_eq!(targets, ["Syntax Highlighting", "_syntax_highlighting"]);
assert!(doc.references.contains_key("_syntax_highlighting"));
assert_eq!(
state
.warnings
.borrow()
.iter()
.filter_map(|warning| {
let WarningKind::UnresolvedReference { target } = &warning.kind else {
return None;
};
Some(target.as_str())
})
.collect::<Vec<_>>(),
["Syntax Highlighting"]
);
Ok(())
}
#[test]
fn compat_mode_natural_reference_resolution_follows_source_position() -> Result<(), Error> {
let input = "Before set: <<First Natural>>.\n\n:compat-mode:\n\nAfter set: <<Second Natural>>.\n\n:compat-mode!:\n\nAfter unset: <<Third Natural>>.\n\n== First Natural\n\n== Second Natural\n\n== Third Natural\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let targets = doc
.blocks
.iter()
.filter_map(|block| {
let Block::Paragraph(paragraph) = block else {
return None;
};
paragraph.content.iter().find_map(|inline| {
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return None;
};
Some(xref.target)
})
})
.collect::<Vec<_>>();
assert_eq!(
targets,
["_first_natural", "Second Natural", "_third_natural"]
);
assert_eq!(
state
.warnings
.borrow()
.iter()
.filter_map(|warning| {
let WarningKind::UnresolvedReference { target } = &warning.kind else {
return None;
};
Some(target.as_str())
})
.collect::<Vec<_>>(),
["Second Natural"]
);
Ok(())
}
#[test]
fn interdocument_xref_macro_targets_are_not_naturally_resolved() -> Result<(), Error> {
let input = "Empty: xref:Other.adoc[].\n\nExplicit: xref:Other.adoc[Other].\n\nShorthand: <<Other.adoc>>.\n\nFragment: xref:Foo#Bar[].\n\n== Other.adoc\n\n== Foo#Bar\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let targets = doc
.blocks
.iter()
.filter_map(|block| {
let Block::Paragraph(paragraph) = block else {
return None;
};
paragraph.content.iter().find_map(|inline| {
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return None;
};
Some(xref.target)
})
})
.collect::<Vec<_>>();
assert_eq!(
targets,
["Other.adoc", "Other.adoc", "_other_adoc", "Foo#Bar"]
);
assert!(
state.warnings.borrow().is_empty(),
"interdocument targets and the resolved shorthand must not warn"
);
Ok(())
}
#[cfg(feature = "setext")]
#[test]
fn natural_title_cross_references_resolve_setext_section_ids() -> Result<(), Error> {
let input = "See <<Setext Title>>.\n\nSetext Title\n------------\n";
let mut state = ParserState::new_for_test(input);
Rc::make_mut(&mut state.options).setext = true;
let doc = document_parser::document(input, &mut state)??;
let xrefs = doc
.blocks
.iter()
.filter_map(|block| {
let Block::Paragraph(paragraph) = block else {
return None;
};
paragraph.content.iter().find_map(|inline| {
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return None;
};
Some(xref.target)
})
})
.collect::<Vec<_>>();
assert_eq!(xrefs, ["_setext_title"]);
Ok(())
}
#[test]
fn captioned_references_keep_source_order_xrefstyle_and_target_caption() -> Result<(), Error> {
let input = "= Caption references\n:xrefstyle: short\n\nShort: <<figure-target>>.\n\n:table-caption: ReferenceTable\n:xrefstyle: full\n\nFull: <<table-target>>.\n\n:xrefstyle: basic\n\nBasic: <<figure-target>>.\n\n:table-caption:\n:xrefstyle: short\n\nNumber only: <<table-target>>.\n\n:table-caption!:\n\nTarget label: <<table-target>>.\n\n:table-caption: TargetTable\n\n[[figure-target]]\n.A figure\nimage::figure.svg[]\n\n[[table-target]]\n.A table\n|===\n|Cell\n|===\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let xrefs = doc
.blocks
.iter()
.filter_map(|block| {
let Block::Paragraph(paragraph) = block else {
return None;
};
paragraph.content.iter().find_map(|inline| {
let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline else {
return None;
};
Some((xref.target, xref.xrefstyle, xref.caption_label))
})
})
.collect::<Vec<_>>();
assert_eq!(
xrefs,
[
(
"figure-target",
XrefStyle::Short,
XrefCaptionLabel::AtTarget,
),
(
"table-target",
XrefStyle::Full,
XrefCaptionLabel::AtReference("ReferenceTable"),
),
(
"figure-target",
XrefStyle::Basic,
XrefCaptionLabel::AtTarget,
),
(
"table-target",
XrefStyle::Short,
XrefCaptionLabel::NumberOnly,
),
("table-target", XrefStyle::Short, XrefCaptionLabel::AtTarget,),
]
);
assert!(matches!(
doc.references
.get("figure-target")
.and_then(|reference| reference.caption.as_ref()),
Some(Caption::Numbered {
kind: CaptionKind::Figure,
label,
number: Some(number),
}) if label == "Figure" && number.get() == 1
));
assert!(matches!(
doc.references
.get("table-target")
.and_then(|reference| reference.caption.as_ref()),
Some(Caption::Numbered {
kind: CaptionKind::Table,
label,
number: Some(number),
}) if label == "TargetTable" && number.get() == 1
));
Ok(())
}
#[test]
fn test_titled_single_line_admonition_keeps_reference_title() -> Result<(), Error> {
let input = "[[notice]]\n.Admonition *Title*\nNOTE: note\n\nSee <<notice>>.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let entry = doc
.references
.get("notice")
.expect("titled admonition should be a reference target");
let title = entry
.title
.as_ref()
.expect("titled admonition should keep its reference text");
assert!(
matches!(
&title[..],
[InlineNode::PlainText(prefix), InlineNode::BoldText(bold)]
if prefix.content == "Admonition "
&& matches!(
&bold.content[..],
[InlineNode::PlainText(text)] if text.content == "Title"
)
),
"{title:?}"
);
assert!(
!state
.warnings
.borrow()
.iter()
.any(|warning| matches!(warning.kind, WarningKind::UnresolvedReference { .. }))
);
Ok(())
}
#[test]
fn test_untitled_block_in_references_without_reftext() -> Result<(), Error> {
let input = "[[untitled]]\n[cols=\"1,1\"]\n|===\n| a | b\n|===\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let entry = doc
.references
.get("untitled")
.expect("untitled block with an id is still a reference target");
assert!(
entry.title.is_none(),
"untitled block has no reference text"
);
Ok(())
}
#[test]
fn test_duplicate_id_keeps_first_reference_text() -> Result<(), Error> {
let input = "[[dup]]\n.Titled Block\n====\nbody\n====\n\nA [#dup]*bold* span.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let entry = doc
.references
.get("dup")
.expect("the id is a reference target");
let title = entry
.title
.as_ref()
.expect("the first registration keeps its reference text");
assert!(
matches!(
&title[..],
[InlineNode::PlainText(text)] if text.content == "Titled Block"
),
"{title:?}"
);
Ok(())
}
#[test]
fn test_reference_label_is_parsed_as_inlines() -> Result<(), Error> {
let input = "Some [[labelled,*Bold* label]]text.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let label = doc
.references
.get("labelled")
.expect("the id is a reference target")
.xreflabel
.as_ref()
.expect("the anchor has a label");
assert!(
matches!(
&label[..],
[InlineNode::BoldText(bold), InlineNode::PlainText(rest)]
if rest.content == " label"
&& matches!(
&bold.content[..],
[InlineNode::PlainText(text)] if text.content == "Bold"
)
),
"{label:?}"
);
Ok(())
}
#[test]
fn test_reference_label_restores_passthrough_syntax() -> Result<(), Error> {
let input = "Some [[labelled,+++<mark>Label</mark>+++]]text.\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let label = doc
.references
.get("labelled")
.expect("the id is a reference target")
.xreflabel
.as_ref()
.expect("the anchor has a label");
assert!(
matches!(
&label[..],
[InlineNode::PlainText(text)]
if text.content == "+++<mark>Label</mark>+++"
),
"{label:?}"
);
Ok(())
}
#[test]
fn test_nested_passthrough_retains_substitution_policy() -> Result<(), Error> {
let input = "*before +++<mark>nested</mark>+++ after*\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
assert!(
matches!(
&doc.blocks[..],
[Block::Paragraph(paragraph)]
if matches!(
¶graph.content[..],
[InlineNode::BoldText(bold)]
if matches!(
&bold.content[..],
[InlineNode::PlainText(before), InlineNode::RawText(raw), InlineNode::PlainText(after)]
if before.content == "before "
&& raw.content == "<mark>nested</mark>"
&& raw.subs.is_empty()
&& after.content == " after"
)
)
),
"{:?}",
doc.blocks
);
Ok(())
}
#[test]
fn test_non_standard_author_line_emits_warning() -> Result<(), Error> {
let input = "= Doc Title\nAuthor: Roberto Avanzi (Lead), Ruud Derwig\n:foo: bar\n\nBody.\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
let warning = warnings
.iter()
.find(|w| {
matches!(
&w.kind,
WarningKind::NonStandardAuthorLine { line }
if line == "Author: Roberto Avanzi (Lead), Ruud Derwig"
)
})
.expect("expected non-standard author line warning");
let loc = warning
.source_location()
.expect("warning should carry a location");
assert_eq!(loc.location.start.line, 2);
Ok(())
}
#[test]
fn test_legacy_float_discrete_heading_warns() -> Result<(), Error> {
let input = "== Parent\n\n[float]\n==== Floating\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::LegacyFloatDiscreteHeading)),
"`[float]` discrete heading should warn"
);
Ok(())
}
#[test]
fn test_no_legacy_float_warning() -> Result<(), Error> {
for input in [
"== Parent\n\n[discrete]\n==== Disc\n",
"[float=\"center\",cols=\"1,1\"]\n|===\n| a | b\n|===\n",
"= Doc\n\n[#f,float]\n=== Ordinary Section\n",
] {
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::LegacyFloatDiscreteHeading)),
"input {input:?} should not raise the legacy-float warning"
);
}
Ok(())
}
#[test]
fn test_standard_author_line_no_warning() -> Result<(), Error> {
let input = "= Doc Title\nRoberto Avanzi\n:foo: bar\n\nBody.\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::NonStandardAuthorLine { .. })),
"structured author line should not warn"
);
Ok(())
}
#[test]
fn test_incomplete_final_row_emits_dropping_warning() -> Result<(), Error> {
let input = "[cols=\"3*\"]\n|===\n|a |b |c\n|d |e |f\n|g\n|===\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
let warning = warnings
.iter()
.find(|w| matches!(&w.kind, WarningKind::TableIncompleteRow))
.expect("expected dropping-cells warning");
let loc = warning
.source_location()
.expect("warning should carry a location");
assert_eq!(loc.location.start.line, 5);
Ok(())
}
#[test]
fn test_first_section_without_doc_title_does_not_warn() -> Result<(), Error> {
let input = "=== No title above me\n\nContent\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::SectionLevelOutOfSequence { .. })),
"should not warn without doc title, got: {warnings:?}",
);
Ok(())
}
#[test]
fn test_first_section_level_1_no_warning() -> Result<(), Error> {
let input = "= Doc Title\n\n== Good\n\n=== Nested\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::SectionLevelOutOfSequence { .. })),
"should not warn for valid structure, got: {warnings:?}",
);
Ok(())
}
#[test]
fn test_unterminated_pipe_table_emits_warning() -> Result<(), Error> {
let input = "|===\n| A | B\n| C | D\n";
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
let warning = warnings
.iter()
.find(|w| {
matches!(
&w.kind,
WarningKind::UnterminatedTable { delimiter } if delimiter == "|===",
)
})
.expect("expected unterminated table warning");
let loc = warning
.source_location()
.expect("warning should carry a location");
assert_eq!(loc.location.start.line, 1);
let has_table = doc.blocks.iter().any(|b| {
matches!(
b,
Block::DelimitedBlock(DelimitedBlock {
inner: DelimitedBlockType::DelimitedTable(_),
..
})
)
});
assert!(has_table, "expected a table block in the document");
Ok(())
}
#[test]
fn test_unterminated_excl_table_emits_warning() -> Result<(), Error> {
let input = "!===\n! A ! B\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings.iter().any(|w| matches!(
&w.kind,
WarningKind::UnterminatedTable { delimiter } if delimiter == "!===",
)),
"expected unterminated table warning with `!===` delimiter, got: {warnings:?}",
);
Ok(())
}
#[test]
fn test_warning_in_ascii_cell_points_at_inner_token() -> Result<(), Error> {
let input = "[cols=\"1a\"]\n|===\na|\n!===\n! Inner A ! Inner B\n|===\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
let warning = warnings
.iter()
.find(|w| {
matches!(
&w.kind,
WarningKind::UnterminatedTable { delimiter } if delimiter == "!===",
)
})
.expect("expected unterminated inner-table warning");
let loc = warning
.source_location()
.expect("warning should carry a location");
let line = loc.location.start.line;
assert_eq!(
line, 4,
"warning should point at line 4 (the `!===`), not the `a|` line; got {line}",
);
Ok(())
}
#[test]
fn test_terminated_table_does_not_warn() -> Result<(), Error> {
let input = "|===\n| A | B\n|===\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::UnterminatedTable { .. })),
"should not warn for a properly closed table, got: {warnings:?}",
);
Ok(())
}
#[test]
fn test_unterminated_pipe_table_with_no_content_emits_warning() -> Result<(), Error> {
let input = "|===\n";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings.iter().any(|w| matches!(
&w.kind,
WarningKind::UnterminatedTable { delimiter } if delimiter == "|===",
)),
"expected unterminated table warning for empty open, got: {warnings:?}",
);
Ok(())
}
#[test]
fn test_unterminated_pipe_table_empty_through_parse_entry() {
let opts = Options::default();
let res = parse("|===\n", &opts).expect("parse should succeed");
let has_warning = res.warnings().iter().any(|w| {
matches!(
&w.kind,
WarningKind::UnterminatedTable { delimiter } if delimiter == "|===",
)
});
assert!(
has_warning,
"expected unterminated table warning through parse(), got: {:?}",
res.warnings(),
);
}
#[test]
fn test_unterminated_delimited_blocks_emit_warning() -> Result<(), Error> {
let cases = [
("====\ntext", "example", "===="),
("----\ntext", "listing", "----"),
("....\ntext", "literal", "...."),
("****\ntext", "sidebar", "****"),
("____\ntext", "quote", "____"),
("--\ntext", "open", "--"),
("////\ntext", "comment", "////"),
("++++\ntext", "pass", "++++"),
("```\ntext", "listing", "```"),
];
for (block, want_kind, want_delim) in cases {
let input = &format!("para\n\n{block}");
let mut state = ParserState::new_for_test(input);
let doc = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
warnings.iter().any(|w| matches!(
&w.kind,
WarningKind::UnterminatedDelimitedBlock { kind, delimiter }
if *kind == want_kind && delimiter == want_delim,
)),
"expected unterminated {want_kind} warning for input {input:?}, got: {warnings:?}",
);
assert!(
doc.blocks.iter().any(|b| matches!(
b,
Block::DelimitedBlock(d) if d.close_delimiter_location.is_none(),
)),
"expected an unterminated delimited block for input {input:?}, got: {:?}",
doc.blocks,
);
}
Ok(())
}
#[test]
fn test_terminated_delimited_block_no_warning() -> Result<(), Error> {
let input = "====\ntext\n====";
let mut state = ParserState::new_for_test(input);
let _ = document_parser::document(input, &mut state)??;
let warnings = state.warnings.borrow();
assert!(
!warnings
.iter()
.any(|w| matches!(&w.kind, WarningKind::UnterminatedDelimitedBlock { .. },)),
"a closed example block should not warn, got: {warnings:?}",
);
Ok(())
}
#[test]
fn test_unterminated_example_through_parse_entry() {
let opts = Options::default();
let res = parse("====\ntext\n", &opts).expect("parse should succeed");
let has_warning = res.warnings().iter().any(|w| {
matches!(
&w.kind,
WarningKind::UnterminatedDelimitedBlock { kind, delimiter }
if *kind == "example" && delimiter == "====",
)
});
assert!(
has_warning,
"expected unterminated example warning through parse(), got: {:?}",
res.warnings(),
);
}
}