#![allow(clippy::too_many_arguments)]
use crate::{
Admonition, AdmonitionVariant, Anchor, AttributeValue, Attribution, Audio, Author, Block,
BlockMetadata, CalloutList, CalloutListItem, CalloutRef, CiteTitle, Comment, DelimitedBlock,
DelimitedBlockType, DescriptionList, DescriptionListItem, DiscreteHeader, Document,
DocumentAttribute, DocumentAttributes, Error, Header, Image, InlineNode, ListItem,
ListItemCheckedStatus, Location, OrderedList, PageBreak, Paragraph, Plain, Raw, Section,
Source, SourceLocation, StemContent, StemNotation, Subtitle, Table, TableOfContents, TableRow,
ThematicBreak, Title, UnorderedList, Verbatim, Video,
grammar::{
ParserState,
attributes::AttributeEntry,
author::derive_author_attrs,
doctype::{is_book_doctype, is_manpage_doctype},
inline_preprocessing,
inline_preprocessor::InlinePreprocessorParserState,
inline_processing::{
adjust_and_log_parse_error, parse_inlines, preprocess_inline_content, process_inlines,
},
location_mapping::map_inline_locations,
manpage::{derive_manpage_header_attrs, derive_name_section_attrs, extract_plain_text},
revision::{RevisionInfo, process_revision_info},
table::parse_table_cell,
},
model::{
LeveloffsetRange, ListLevel, Locateable, SectionLevel, UNNUMBERED_SECTION_STYLES,
strip_quotes,
substitution::{HEADER, parse_subs_attribute},
},
};
use super::helpers::{
AttributeProcessingMode, BlockMetadataLine, BlockParsingMetadata, HeaderMetadataLine,
PositionWithOffset, RESERVED_NAMED_ATTRIBUTE_ID, RESERVED_NAMED_ATTRIBUTE_OPTIONS,
RESERVED_NAMED_ATTRIBUTE_ROLE, RESERVED_NAMED_ATTRIBUTE_SUBS, Shorthand,
process_attribute_list, strip_url_backslash_escapes, title_looks_like_description_list,
};
use super::setext;
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 get_literal_paragraph(
state: &ParserState,
content: &str,
start: usize,
end: usize,
offset: usize,
block_metadata: &BlockParsingMetadata,
) -> Block {
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".to_string());
let location = state.create_block_location(start, end, offset);
let lines: Vec<&str> = content.lines().collect();
let all_lines_have_leading_space = lines
.iter()
.all(|line| line.is_empty() || line.starts_with(' '));
let content = if all_lines_have_leading_space {
lines
.iter()
.map(|line| line.strip_prefix(' ').unwrap_or(line))
.collect::<Vec<_>>()
.join("\n")
} else {
content.to_string()
};
tracing::debug!(
content,
all_lines_have_leading_space,
"created literal paragraph"
);
Block::Paragraph(Paragraph {
content: vec![InlineNode::PlainText(Plain {
content,
location: location.clone(),
escaped: false,
})],
metadata,
title: block_metadata.title.clone(),
location,
})
}
fn assemble_principal_text(first_line: &str, continuation_lines: &[&str]) -> String {
if continuation_lines.is_empty() {
first_line.to_string()
} else {
format!("{first_line}\n{}", continuation_lines.join("\n"))
}
}
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
.get_string("leveloffset")
.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
}
}
struct TableParseParams<'a> {
start: usize,
offset: usize,
table_start: usize,
content_start: usize,
content_end: usize,
end: usize,
open_delim: &'a str,
close_delim: &'a str,
content: &'a str,
default_separator: &'a str,
close_start: usize,
}
#[allow(clippy::too_many_lines)]
fn parse_table_block_impl(
params: &TableParseParams<'_>,
state: &mut ParserState,
block_metadata: &BlockParsingMetadata,
) -> Result<Block, Error> {
let &TableParseParams {
start,
offset,
table_start,
content_start,
content_end: _content_end,
end,
open_delim,
close_delim,
content,
default_separator,
close_start,
} = params;
check_delimiters(
open_delim,
close_delim,
"table",
state.create_error_source_location(state.create_block_location(start, end, offset)),
)?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
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 = state.create_block_location(close_start, end, offset);
let separator = if let Some(AttributeValue::String(sep)) =
block_metadata.metadata.attributes.get("separator")
{
sep.clone()
} else if let Some(AttributeValue::String(format)) =
block_metadata.metadata.attributes.get("format")
{
match format.as_str() {
"csv" => ",",
"dsv" => ":",
"tsv" => "\t",
unknown_format => {
state.add_warning(format!(
"unknown table format '{unknown_format}', using default separator"
));
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(1);
(mult, &s[pos + 1..])
} else {
(1, s)
};
let mut halign = crate::HorizontalAlignment::default();
let mut valign = crate::VerticalAlignment::default();
let mut width = crate::ColumnWidth::default();
let mut style = crate::ColumnStyle::default();
let spec_str = if let Some(last_char) = spec_str.chars().last() {
match last_char {
'a' => {
style = crate::ColumnStyle::AsciiDoc;
&spec_str[..spec_str.len() - 1]
}
'd' => {
style = crate::ColumnStyle::Default;
&spec_str[..spec_str.len() - 1]
}
'e' => {
style = crate::ColumnStyle::Emphasis;
&spec_str[..spec_str.len() - 1]
}
'h' => {
style = crate::ColumnStyle::Header;
&spec_str[..spec_str.len() - 1]
}
'l' => {
style = crate::ColumnStyle::Literal;
&spec_str[..spec_str.len() - 1]
}
'm' => {
style = crate::ColumnStyle::Monospace;
&spec_str[..spec_str.len() - 1]
}
's' => {
style = crate::ColumnStyle::Strong;
&spec_str[..spec_str.len() - 1]
}
_ => spec_str,
}
} else {
spec_str
};
if spec_str.contains(".<") {
valign = crate::VerticalAlignment::Top;
} else if spec_str.contains(".^") {
valign = crate::VerticalAlignment::Middle;
} else if spec_str.contains(".>") {
valign = crate::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 = crate::HorizontalAlignment::Left,
'^' => halign = crate::HorizontalAlignment::Center,
'>' => halign = crate::HorizontalAlignment::Right,
_ => {}
}
}
let width_str: String = spec_str
.chars()
.filter(|c| !matches!(c, '<' | '^' | '>' | '.'))
.collect();
if !width_str.is_empty() {
if width_str == "~" {
width = crate::ColumnWidth::Auto;
} else if width_str.ends_with('%') {
if let Ok(pct) = width_str.trim_end_matches('%').parse::<u32>() {
width = crate::ColumnWidth::Percentage(pct);
}
} else if let Ok(prop) = width_str.parse::<u32>() {
width = crate::ColumnWidth::Proportional(prop);
}
}
let spec = crate::ColumnFormat {
halign,
valign,
width,
style,
};
for _ in 0..multiplier {
specs.push(spec.clone());
}
}
(Some(specs.len()), specs)
} else {
(None, Vec::new())
};
let mut has_header = block_metadata
.metadata
.options
.contains(&String::from("header"));
let raw_rows = Table::parse_rows_with_positions(
content,
&separator,
&mut has_header,
content_start + offset,
ncols,
);
if block_metadata
.metadata
.options
.contains(&String::from("noheader"))
{
has_header = false;
}
let has_footer = block_metadata
.metadata
.options
.contains(&String::from("footer"));
let mut header = None;
let mut footer = None;
let mut rows = Vec::new();
let mut active_rowspans: Vec<(usize, usize, usize)> = Vec::new();
for (i, row) in raw_rows.iter().enumerate() {
let mut columns = Vec::new();
let mut col_idx = 0; for cell in row {
let effective_cell = if cell.style.is_none()
&& let Some(col_format) = column_formats.get(col_idx)
&& col_format.style != crate::ColumnStyle::Default
{
let mut cell_with_style = cell.clone();
cell_with_style.style = Some(col_format.style);
cell_with_style
} else {
cell.clone()
};
let parsed = parse_table_cell(
&effective_cell.content,
state,
effective_cell.start,
block_metadata.parent_section_level,
&effective_cell,
)?;
if effective_cell.is_duplication && effective_cell.duplication_count > 1 {
for _ in 0..effective_cell.duplication_count {
columns.push(parsed.clone());
}
col_idx += effective_cell.duplication_count * effective_cell.colspan;
} else {
columns.push(parsed);
col_idx += effective_cell.colspan;
}
}
let row_line = if let Some(first) = row.first() {
state.create_location(first.start, first.end).start.line
} else {
table_location.start.line };
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 let Some(ncols) = ncols
&& logical_col_count != ncols
{
let has_overflow = columns.iter().any(|c| c.colspan > ncols);
if has_overflow {
state.add_warning(format!("dropping cell because it exceeds specified number of columns: actual={logical_col_count}, expected={ncols}, line={row_line}"));
} else {
state.add_warning(format!("table row has incorrect column count: actual={logical_col_count}, expected={ncols}, occupied_from_rowspans={occupied_from_rowspans}, line={row_line}"));
}
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 has_header {
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 {
header,
footer,
rows,
columns: column_formats,
location: table_location.clone(),
};
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedTable(table),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
peg::parser! {
pub(crate) grammar document_parser(state: &mut ParserState) for str {
use std::str::FromStr;
use crate::model::{substitute, Substitution};
use crate::grammar::inlines::inline_parser;
pub(crate) rule document() -> Result<Document, Error>
= eol()* start:position() comments_before_header:comment_line_block(0)* header_result:header() blocks:blocks(0, None) end:position() (eol()* / ![_]) {
let header = header_result?;
let blocks = comments_before_header.into_iter().collect::<Result<Vec<_>, Error>>()?.into_iter().chain(blocks?).collect();
let mut document_end_offset = end.offset;
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) {
(
crate::Position { line: 1, column: 0 },
crate::Position { line: 1, column: 0 }
)
} else {
(
start.position,
state.line_map.offset_to_position(absolute_end, &state.input)
)
};
Ok(Document {
name: "document".to_string(),
r#type: "block".to_string(),
header,
location: Location {
absolute_start,
absolute_end,
start: start_position,
end: end_position,
},
attributes: state.document_attributes.clone(),
blocks,
footnotes: state.footnote_tracker.footnotes.clone(),
toc_entries: state.toc_tracker.entries.clone(),
})
}
pub(crate) rule header() -> Result<Option<Header>, Error>
= start:position!()
((document_attribute() / comment()) (eol()+ / ![_]))*
metadata:header_metadata()
title_authors:(title_authors:title_authors() { title_authors })?
(eol()+ (document_attribute() / 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(&mut header, &mut state.document_attributes);
if is_manpage_doctype(&state.document_attributes) {
derive_manpage_header_attrs(
Some(&header),
&mut state.document_attributes,
state.options.strict,
state.current_file.as_deref(),
)?;
}
Ok(Some(header))
} else {
tracing::info!("No title or authors found in the document header.");
Ok(None)
}
}
rule header_metadata() -> BlockMetadata
= lines:(
anchor:anchor() { HeaderMetadataLine::Anchor(anchor) }
/ attr:attributes_line() { HeaderMetadataLine::Attributes((attr.0, Box::new(attr.1))) }
)+ &document_title()
{
let mut metadata = BlockMetadata::default();
for line in lines {
match line {
HeaderMetadataLine::Anchor(anchor) => metadata.anchors.push(anchor),
HeaderMetadataLine::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);
metadata.attributes = attr_metadata.attributes;
metadata.positional_attributes = attr_metadata.positional_attributes;
}
}
}
metadata
}
/ { BlockMetadata::default() }
pub(crate) rule title_authors() -> (Title, Option<Subtitle>, Vec<Author>)
= title_and_subtitle:document_title() eol() authors:authors_and_revision() &(eol()+ / ![_])
{
let (title, subtitle) = title_and_subtitle;
tracing::info!(?title, ?subtitle, ?authors, "Found title and authors in the document header.");
(title, subtitle, authors)
}
/ title_and_subtitle:document_title() &eol() {
let (title, subtitle) = title_and_subtitle;
tracing::info!(?title, ?subtitle, "Found title in the document header without authors.");
(title, subtitle, vec![])
}
pub(crate) rule document_title() -> (Title, Option<Subtitle>)
= document_title_atx()
/ document_title_setext()
rule document_title_atx() -> (Title, Option<Subtitle>)
= 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.offset + 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.offset + 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, 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, Option<Subtitle>)
= start:position() 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, &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 = start.offset + title_text.len();
let inlines = process_inlines(state, &block_metadata, &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 = start.offset + 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, 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, &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>
= author_line:$([^'\n']+) (eol() revision_pre_substitution())? {?
let substituted = substitute(author_line.trim(), HEADER, &state.document_attributes);
tracing::debug!(?author_line, ?substituted, "Processing author line with substitution");
let mut temp_state = ParserState::new(&substituted);
temp_state.document_attributes = state.document_attributes.clone();
match document_parser::authors(&substituted, &mut temp_state) {
Ok(authors) => {
tracing::info!(?authors, "Parsed authors from line");
Ok(authors)
}
Err(_) => Err("line did not parse as authors")
}
}
pub(crate) rule authors() -> Vec<Author>
= authors:(author() ++ (";" whitespace()*)) {
authors
}
pub(crate) rule author() -> Author
= name:author_name() email:author_email()? {
let mut author = name;
if let Some(email_addr) = email {
author.email = Some(email_addr.to_string());
}
author
}
rule author_name() -> Author
= first:name_part() whitespace()+ middle:name_part() whitespace()+ last:$(name_part() ++ whitespace()) {
Author::new(first, Some(middle), Some(last))
}
/ first:name_part() whitespace()+ last:name_part() {
Author::new(first, None, Some(last))
}
/ first:name_part() {
Author::new(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() -> ()
= number:$("v"? digits() ++ ".") date:revision_date()? remark:revision_remark()? {
let revision_info = RevisionInfo {
number: number.to_string(),
date: date.map(ToString::to_string),
remark: remark.map(ToString::to_string),
};
if revision_info.number.is_empty() {
return;
}
process_revision_info(revision_info, &mut state.document_attributes);
}
rule revision_pre_substitution() -> ()
= rev_line:$([^'\n']+) {?
let substituted = substitute(rev_line.trim(), HEADER, &state.document_attributes);
tracing::debug!(?rev_line, ?substituted, "Processing revision line with substitution");
let mut temp_state = ParserState::new(&substituted);
temp_state.document_attributes = state.document_attributes.clone();
match document_parser::revision(&substituted, &mut temp_state) {
Ok(()) => {
for key in ["revnumber", "revdate", "revremark"] {
if let Some(value) = temp_state.document_attributes.get(key) {
state.document_attributes.insert(key.into(), value.clone());
}
}
tracing::info!("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() -> ()
= att:document_attribute_match() (&eol() / ![_])
{
let AttributeEntry{key, value, set} = att;
tracing::info!(%set, %key, %value, "Found document attribute in the document header");
let value = match value {
AttributeValue::String(s) => {
let substituted = substitute(&s, HEADER, &state.document_attributes);
AttributeValue::String(substituted)
}
AttributeValue::Bool(_) | AttributeValue::None => value,
};
state.document_attributes.set(key.into(), value);
}
pub(crate) rule blocks(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Vec<Block>, Error>
= blocks:block(offset, parent_section_level)*
{
blocks.into_iter().collect::<Result<Vec<_>, Error>>()
}
pub(crate) rule blocks_for_table_cell(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Vec<Block>, Error>
= eol()*
blocks:(
comment_line_block(offset) /
block_generic_for_table_cell(offset, parent_section_level)
)*
{
blocks.into_iter().collect::<Result<Vec<_>, Error>>()
}
pub(crate) rule block(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block, Error>
= eol()*
!same_or_higher_level_section(offset, parent_section_level)
block:(
comment_line_block(offset) /
document_attribute_block(offset) /
&"[discrete" dh:discrete_header(offset) { dh } /
section:section(offset, parent_section_level) { section } /
section_setext:section_setext(offset, parent_section_level) { section_setext } /
block_generic(offset, parent_section_level)
)
{
block
}
rule comment_line_block(offset: usize) -> Result<Block, Error>
= start:position!() "//" !("/") content:$([^'\n']*) end:position!() (eol() / ![_])
{
Ok(Block::Comment(Comment {
content: content.to_string(),
location: state.create_location(start + offset, end + offset),
}))
}
rule same_or_higher_level_section(offset: usize, parent_section_level: Option<SectionLevel>) -> ()
= (anchor() / attributes_line() / document_attribute_line() / title_line(offset))*
(
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 discrete_header(offset: usize) -> Result<Block, Error>
= start:position!()
block_metadata:(bm:block_metadata(offset, None) {?
bm.map_err(|e| {
tracing::error!(?e, "error parsing block metadata in discrete_header");
"block metadata parse error"
})
})
section_level:section_level(offset, None) whitespace()
title_start:position!() title:section_title(offset, &block_metadata) title_end:position!() end:position!() &(eol()*<1,2> / ![_])
{
let title = title?;
tracing::info!(?block_metadata, ?title, ?title_start, ?title_end, "parsing discrete header block");
let level = section_level.1;
let location = state.create_block_location(start, end, offset);
Ok(Block::DiscreteHeader(DiscreteHeader {
metadata: block_metadata.metadata,
title,
level,
location,
}))
}
pub(crate) rule document_attribute_block(offset: usize) -> Result<Block, Error>
= start:position!() att:document_attribute_match() end:position!()
{
let AttributeEntry{ key, value, .. } = att;
let value = match value {
AttributeValue::String(s) => {
let substituted = substitute(&s, HEADER, &state.document_attributes);
AttributeValue::String(substituted)
}
AttributeValue::Bool(_) | AttributeValue::None => value,
};
state.document_attributes.set(key.into(), value.clone());
Ok(Block::DocumentAttribute(DocumentAttribute {
name: key.into(),
value,
location: state.create_location(start+offset, end+offset)
}))
}
pub(crate) rule section(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block, Error>
= start:position!()
block_metadata:(bm:block_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 = title?;
let section_id = Section::generate_id(&block_metadata.metadata, &title).to_string();
let xreflabel = block_metadata.metadata.anchors.last().and_then(|a| a.xreflabel.clone());
let numbered = !block_metadata.metadata.style.as_ref()
.is_some_and(|s| UNNUMBERED_SECTION_STYLES.contains(&s.as_str()));
state.toc_tracker.register_section(title.clone(), section_level.1, section_id.clone(), xreflabel, numbered, block_metadata.metadata.style.clone());
Ok::<(Title, String), Error>((title, section_id))
})
content:section_content(offset, Some(section_level.1+1))? end:position!()
{
let (title, section_id) = section_header?;
tracing::info!(?offset, ?block_metadata, ?title, "parsing section block");
if let Some(parent_level) = parent_section_level && (
section_level.1 < parent_level || section_level.1+1 > parent_level+1 || 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,
));
}
let level = section_level.1;
let location = state.create_block_location(start, end, offset);
if level == 1 && is_manpage_doctype(&state.document_attributes) {
let title_text = extract_plain_text(&title);
if title_text.eq_ignore_ascii_case("NAME")
&& let Some(Ok(ref blocks)) = content
&& let Some(Block::Paragraph(para)) = blocks.first()
{
let para_text = extract_plain_text(¶.content);
derive_name_section_attrs(¶_text, &mut state.document_attributes);
}
}
Ok(Block::Section(Section {
metadata: block_metadata.metadata,
title,
level,
content: content.unwrap_or(Ok(Vec::new()))?,
location
}))
}
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)
}
pub(crate) rule section_setext(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block, Error>
= start:position!()
!check_line_is_description_list()
block_metadata:(bm:block_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:({
match process_inlines(state, &block_metadata, &title_start, title_end, offset, title) {
Ok(processed_title) => {
let processed_title = Title::new(processed_title);
let section_id = Section::generate_id(&block_metadata.metadata, &processed_title).to_string();
let xreflabel = block_metadata.metadata.anchors.last().and_then(|a| a.xreflabel.clone());
let numbered = !block_metadata.metadata.style.as_ref()
.is_some_and(|s| UNNUMBERED_SECTION_STYLES.contains(&s.as_str()));
state.toc_tracker.register_section(processed_title.clone(), setext_level, section_id.clone(), xreflabel, numbered, block_metadata.metadata.style.clone());
Ok::<(Title, String), Error>((processed_title, section_id))
}
Err(e) => Err(e),
}
})
content:section_content(offset, Some(setext_level + 1))? end:position!()
{
let (title, _section_id) = section_header?;
let location = state.create_block_location(start, end, offset);
if setext_level == 1 && is_manpage_doctype(&state.document_attributes) {
let title_text = extract_plain_text(&title);
if title_text.eq_ignore_ascii_case("NAME")
&& let Some(Ok(ref blocks)) = content
&& let Some(Block::Paragraph(para)) = blocks.first()
{
let para_text = extract_plain_text(¶.content);
derive_name_section_attrs(¶_text, &mut state.document_attributes);
}
}
Ok(Block::Section(Section {
metadata: block_metadata.metadata,
title,
level: setext_level,
content: content.unwrap_or(Ok(Vec::new()))?,
location,
}))
}
rule block_metadata(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<BlockParsingMetadata, Error>
= meta_start:position!() lines:(
anchor:anchor() { Ok::<BlockMetadataLine<'input>, Error>(BlockMetadataLine::Anchor(anchor)) }
/ attr:attributes_line() { Ok::<BlockMetadataLine<'input>, Error>(BlockMetadataLine::Attributes((attr.0, Box::new(attr.1)))) }
/ doc_attr:document_attribute_line() { Ok::<BlockMetadataLine<'input>, Error>(BlockMetadataLine::DocumentAttribute(doc_attr.key, doc_attr.value)) }
/ title:title_line(offset) { title.map(BlockMetadataLine::Title) }
)* meta_end:position!()
{
let mut metadata = BlockMetadata::default();
let mut discrete = false;
let mut title = Title::default();
for line in lines {
let Ok(value) = line else {
state.add_warning(format!("failed to parse block metadata line, skipping: {line:?}"));
continue
};
match value {
BlockMetadataLine::Anchor(value) => metadata.anchors.push(value),
BlockMetadataLine::Attributes((attr_discrete, attr_metadata)) => {
let attr_metadata = *attr_metadata;
discrete = attr_discrete;
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 (k, v) in attr_metadata.attributes.iter() {
metadata.attributes.insert(k.clone(), v.clone());
}
metadata.positional_attributes.extend(attr_metadata.positional_attributes);
if attr_metadata.substitutions.is_some() {
metadata.substitutions = attr_metadata.substitutions;
}
if attr_metadata.attribution.is_some() {
metadata.attribution = attr_metadata.attribution;
}
if attr_metadata.citetitle.is_some() {
metadata.citetitle = attr_metadata.citetitle;
}
},
BlockMetadataLine::DocumentAttribute(key, value) => {
let value = match value {
AttributeValue::String(s) => {
let substituted = substitute(&s, HEADER, &state.document_attributes);
AttributeValue::String(substituted)
}
AttributeValue::Bool(_) | AttributeValue::None => value,
};
state.document_attributes.set(key.into(), value);
},
BlockMetadataLine::Title(inner) => {
title = inner;
}
}
}
if meta_start != meta_end {
metadata.location = Some(state.create_block_location(meta_start, meta_end, offset));
}
let (macros_enabled, attributes_enabled) = if cfg!(feature = "pre-spec-subs") {
(
metadata.substitutions.as_ref().is_none_or(|spec| !spec.macros_disabled()),
metadata.substitutions.as_ref().is_none_or(|spec| !spec.attributes_disabled()),
)
} else {
(true, true)
};
Ok(BlockParsingMetadata {
metadata,
title,
parent_section_level,
macros_enabled,
attributes_enabled,
})
}
rule title_line(offset: usize) -> Result<Title, Error>
= period() start:position() title:$(![' ' | '\t' | '\n' | '\r' | '.'] [^'\n']*) end:position!() eol()
{
tracing::info!(?title, ?start, ?end, "Found title line in block metadata");
let block_metadata = BlockParsingMetadata::default();
let title = process_inlines(state, &block_metadata, &start, end, offset, title)?;
Ok(title.into())
}
rule document_attribute_line() -> AttributeEntry<'input>
= attr:document_attribute_match() eol()
{
tracing::info!(?attr, "Found document attribute in block metadata");
attr
}
rule section_level(offset: usize, parent_section_level: Option<SectionLevel>) -> (&'input str, SectionLevel)
= start:position() level:$(("=" / "#")*<1,6>) end:position!()
{
let base_level: SectionLevel = level.len().try_into().unwrap_or(1) - 1;
let byte_offset = start.offset + offset;
(level, apply_leveloffset(base_level, byte_offset, &state.leveloffset_ranges, &state.document_attributes))
}
rule section_level_at_line_start(offset: usize, parent_section_level: Option<SectionLevel>) -> (&'input str, SectionLevel)
= start:position() level:$(("=" / "#")*<1,6>) end:position!()
{?
let absolute_pos = start.offset + 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("section level must be at line start");
}
let base_level: SectionLevel = level.len().try_into().unwrap_or(1) - 1;
let byte_offset = start.offset + offset;
Ok((level, apply_leveloffset(base_level, byte_offset, &state.leveloffset_ranges, &state.document_attributes)))
}
rule section_title(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Title, Error>
= title_start:position() title:$([^'\n']*) end:position!()
{
tracing::info!(?title, ?title_start, ?end, offset, "Found section title");
let content = process_inlines(state, block_metadata, &title_start, end, offset, title)?;
Ok(Title::new(content))
}
rule section_content(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Vec<Block>, Error>
= blocks(offset, parent_section_level) / { Ok(vec![]) }
pub(crate) rule block_generic(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block, 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 }
/ 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 }
) {
block
}
rule block_in_continuation(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block, 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_in_continuation");
"block metadata parse error"
})
})
block:(
delimited_block:delimited_block(start, offset, &block_metadata) { delimited_block }
/ 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 }
) {
block
}
rule block_generic_for_table_cell(offset: usize, parent_section_level: Option<SectionLevel>) -> Result<Block, 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_for_table_cell");
"block metadata parse error"
})
})
block:(
image:image(start, offset, &block_metadata) { image }
/ audio:audio(start, offset, &block_metadata) { audio }
/ video:video(start, offset, &block_metadata) { video }
/ thematic_break:thematic_break(start, offset, &block_metadata) { thematic_break }
/ quoted_paragraph:quoted_paragraph(start, offset, &block_metadata) { quoted_paragraph }
/ paragraph:paragraph(start, offset, &block_metadata) { paragraph }
) {
block
}
rule delimited_block(
start: usize,
offset: usize,
block_metadata: &BlockParsingMetadata,
) -> Result<Block, Error>
= comment_block(start, offset, block_metadata)
/ example_block(start, offset, block_metadata)
/ listing_block(start, offset, block_metadata)
/ literal_block(start, offset, block_metadata)
/ open_block(start, offset, block_metadata)
/ sidebar_block(start, offset, block_metadata)
/ table_block(start, offset, block_metadata)
/ pass_block(start, offset, block_metadata)
/ quote_block(start, offset, block_metadata)
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 exact_comment_delimiter(expected: &str) -> &'input str
= delim:comment_delimiter() {? if delim == expected { Ok(delim) } else { Err("comment delimiter mismatch") } }
rule exact_example_delimiter(expected: &str) -> &'input str
= delim:example_delimiter() {? if delim == expected { Ok(delim) } else { Err("example delimiter mismatch") } }
rule exact_listing_delimiter(expected: &str) -> &'input str
= delim:listing_delimiter() {? if delim == expected { Ok(delim) } else { Err("listing delimiter mismatch") } }
rule exact_literal_delimiter(expected: &str) -> &'input str
= delim:literal_delimiter() {? if delim == expected { Ok(delim) } else { Err("literal delimiter mismatch") } }
rule exact_open_delimiter(expected: &str) -> &'input str
= delim:open_delimiter() {? if delim == expected { Ok(delim) } else { Err("open delimiter mismatch") } }
rule exact_sidebar_delimiter(expected: &str) -> &'input str
= delim:sidebar_delimiter() {? if delim == expected { Ok(delim) } else { Err("sidebar delimiter mismatch") } }
rule exact_pass_delimiter(expected: &str) -> &'input str
= delim:pass_delimiter() {? if delim == expected { Ok(delim) } else { Err("pass delimiter mismatch") } }
rule exact_markdown_code_delimiter(expected: &str) -> &'input str
= delim:markdown_code_delimiter() {? if delim == expected { Ok(delim) } else { Err("markdown code delimiter mismatch") } }
rule exact_quote_delimiter(expected: &str) -> &'input str
= delim:quote_delimiter() {? if delim == expected { Ok(delim) } else { Err("quote delimiter mismatch") } }
rule until_comment_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_comment_delimiter(expected)) [_])*) { content }
rule until_example_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_example_delimiter(expected)) [_])*) { content }
rule until_listing_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_listing_delimiter(expected)) [_])*) { content }
rule until_literal_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_literal_delimiter(expected)) [_])*) { content }
rule until_open_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_open_delimiter(expected)) [_])*) { content }
rule until_sidebar_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_sidebar_delimiter(expected)) [_])*) { content }
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 until_pass_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_pass_delimiter(expected)) [_])*) { content }
rule until_quote_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_quote_delimiter(expected)) [_])*) { content }
rule until_markdown_code_delimiter(expected: &str) -> &'input str
= content:$((!(eol() exact_markdown_code_delimiter(expected)) [_])*) { content }
rule markdown_language() -> &'input str
= lang:$((['a'..='z'] / ['A'..='Z'] / ['0'..='9'] / "_" / "+" / "-")+) { lang }
rule example_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:example_delimiter() eol()
content_start:position!() content:until_example_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:example_delimiter() end:position!()
{
tracing::info!(?start, ?offset, ?content_start, ?block_metadata, ?content, "Parsing example block");
check_delimiters(open_delim, close_delim, "example", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, 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).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start+offset, state, "Error parsing example content as blocks in example block");
Ok(Vec::new())
})?
};
if let Some(ref style) = block_metadata.metadata.style &&
let Ok(admonition_variant) = AdmonitionVariant::from_str(style) {
tracing::debug!(?admonition_variant, "Detected admonition block with variant");
metadata.style = None; return Ok(Block::Admonition(Admonition::new(admonition_variant, blocks, location).with_metadata(metadata).with_title(block_metadata.title.clone())));
}
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata, delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedExample(blocks),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule comment_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:comment_delimiter() eol()
content_start:position!() content:until_comment_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:comment_delimiter() end:position!()
{
check_delimiters(open_delim, close_delim, "comment", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let content_location = state.create_block_location(content_start, content_end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, end, offset);
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata,
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedComment(vec![InlineNode::PlainText(Plain {
content: content.to_string(),
location: content_location,
escaped: false,
})]),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule listing_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= traditional_listing_block(start, offset, block_metadata)
/ markdown_listing_block(start, offset, block_metadata)
rule traditional_listing_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:listing_delimiter() eol()
content_start:position!() content:until_listing_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:listing_delimiter() end:position!()
{
check_delimiters(open_delim, close_delim, "listing", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let content_location = state.create_block_location(content_start, content_end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, end, offset);
let (inlines, callouts) = resolve_verbatim_callouts(content, content_location);
state.last_block_was_verbatim = true;
state.last_verbatim_callouts = callouts;
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedListing(inlines),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule markdown_listing_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:markdown_code_delimiter() lang:markdown_language()? eol()
content_start:position!() content:until_markdown_code_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:markdown_code_delimiter() end:position!()
{
check_delimiters(open_delim, close_delim, "listing", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
if let Some(language) = lang {
metadata.positional_attributes.insert(0, language.to_string());
metadata.style = Some("source".to_string());
}
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let content_location = state.create_block_location(content_start, content_end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, end, offset);
let (inlines, callouts) = resolve_verbatim_callouts(content, content_location);
state.last_block_was_verbatim = true;
state.last_verbatim_callouts = callouts;
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedListing(inlines),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
pub(crate) rule literal_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
=
open_start:position!()
open_delim:literal_delimiter()
eol()
content_start:position!() content:until_literal_delimiter(open_delim) content_end:position!()
eol()
close_start:position!()
close_delim:literal_delimiter()
end:position!()
{
check_delimiters(open_delim, close_delim, "literal", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let content_location = state.create_block_location(content_start, content_end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, end, offset);
let (inlines, callouts) = resolve_verbatim_callouts(content, content_location);
state.last_block_was_verbatim = true;
state.last_verbatim_callouts = callouts;
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata,
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedLiteral(inlines),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule open_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:open_delimiter() eol()
content_start:position!() content:until_open_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:open_delimiter() end:position!()
{
check_delimiters(open_delim, close_delim, "open", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, 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).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start+offset, state, "Error parsing content as blocks in open block");
Ok(Vec::new())
})?
};
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedOpen(blocks),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule sidebar_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:sidebar_delimiter() eol()
content_start:position!() content:until_sidebar_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:sidebar_delimiter() end:position!()
{
tracing::info!(?start, ?offset, ?content_start, ?block_metadata, ?content, "Parsing sidebar block");
check_delimiters(open_delim, close_delim, "sidebar", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, 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).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start+offset, state, "Error parsing sidebar content as blocks");
Ok(Vec::new())
})?
};
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner: DelimitedBlockType::DelimitedSidebar(blocks),
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, 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)
rule pipe_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, 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() end:position!()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end,
open_delim, close_delim, content, default_separator: "|",
close_start,
},
state,
block_metadata,
)
}
rule excl_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, 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() end:position!()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end,
open_delim, close_delim, content, default_separator: "!",
close_start,
},
state,
block_metadata,
)
}
rule comma_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, 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() end:position!()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end,
open_delim, close_delim, content, default_separator: ",",
close_start,
},
state,
block_metadata,
)
}
rule colon_table_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, 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() end:position!()
{
parse_table_block_impl(
&TableParseParams {
start, offset, table_start, content_start, content_end, end,
open_delim, close_delim, content, default_separator: ":",
close_start,
},
state,
block_metadata,
)
}
rule pass_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:pass_delimiter() eol()
content_start:position!() content:until_pass_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:pass_delimiter() end:position!()
{
check_delimiters(open_delim, close_delim, "pass", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let content_location = state.create_block_location(content_start, content_end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, end, offset);
let inner = if let Some(ref style) = metadata.style {
if style == "stem" {
let notation = match state.document_attributes.get("stem") {
Some(AttributeValue::String(s)) => {
StemNotation::from_str(s).unwrap_or(StemNotation::Latexmath)
}
Some(AttributeValue::Bool(true) | AttributeValue::None) => {
StemNotation::Latexmath
}
_ => StemNotation::Latexmath,
};
metadata.style = None; DelimitedBlockType::DelimitedStem(StemContent {
content: content.to_string(),
notation,
})
} else {
DelimitedBlockType::DelimitedPass(vec![InlineNode::RawText(Raw {
content: content.to_string(),
location: content_location,
subs: vec![],
})])
}
} else {
DelimitedBlockType::DelimitedPass(vec![InlineNode::RawText(Raw {
content: content.to_string(),
location: content_location,
subs: vec![],
})])
};
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner,
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule quote_block(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= open_start:position!() open_delim:quote_delimiter() eol()
content_start:position!() content:until_quote_delimiter(open_delim) content_end:position!()
eol() close_start:position!() close_delim:quote_delimiter() end:position!()
{
fn needs_inline_processing(content: &str) -> bool {
content.contains("://") || content.contains('[') || content.contains('{')
|| content.contains('*') || content.contains('_') || content.contains('`')
|| content.contains("<<") || content.contains("link:") || content.contains("mailto:")
}
check_delimiters(open_delim, close_delim, "quote", state.create_error_source_location(state.create_block_location(start, end, offset)))?;
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
let location = state.create_block_location(start, end, offset);
let content_location = state.create_block_location(content_start, content_end, offset);
let open_delimiter_location = state.create_location(
open_start + offset,
open_start + offset + open_delim.len().saturating_sub(1),
);
let close_delimiter_location = state.create_block_location(close_start, end, offset);
if let Some(ref attr) = metadata.attribution
&& let Some(InlineNode::PlainText(plain)) = attr.first()
&& needs_inline_processing(&plain.content)
{
let attr_pos = PositionWithOffset {
offset: plain.location.absolute_start.saturating_sub(offset),
position: plain.location.start.clone(),
};
let attr_end = plain.location.absolute_end.saturating_sub(offset);
if let Ok(inlines) = process_inlines(state, block_metadata, &attr_pos, attr_end, offset, &plain.content) && !inlines.is_empty() {
metadata.attribution = Some(Attribution::new(inlines));
}
}
if let Some(ref cite) = metadata.citetitle
&& let Some(InlineNode::PlainText(plain)) = cite.first()
&& needs_inline_processing(&plain.content)
{
let cite_pos = PositionWithOffset {
offset: plain.location.absolute_start.saturating_sub(offset),
position: plain.location.start.clone(),
};
let cite_end = plain.location.absolute_end.saturating_sub(offset);
if let Ok(inlines) = process_inlines(state, block_metadata, &cite_pos, cite_end, offset, &plain.content) && !inlines.is_empty() {
metadata.citetitle = Some(CiteTitle::new(inlines));
}
}
let inner = if let Some(ref style) = metadata.style {
if style == "verse" {
DelimitedBlockType::DelimitedVerse(vec![InlineNode::PlainText(Plain {
content: content.to_string(),
location: content_location.clone(),
escaped: false,
})])
} else {
let blocks = document_parser::blocks(content, state, content_start+offset, block_metadata.parent_section_level).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start+offset, state, "Error parsing example content as blocks in quote block");
Ok(Vec::new())
})?;
DelimitedBlockType::DelimitedQuote(blocks)
}
} else {
let blocks = if content.trim().is_empty() {
Vec::new()
} else {
document_parser::blocks(content, state, content_start+offset, block_metadata.parent_section_level).unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, content, content_start+offset, state, "Error parsing content as blocks in quote block");
Ok(Vec::new())
})?
};
DelimitedBlockType::DelimitedQuote(blocks)
};
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata: metadata.clone(),
delimiter: open_delim.to_string(),
inner,
title: block_metadata.title.clone(),
location,
open_delimiter_location: Some(open_delimiter_location),
close_delimiter_location: Some(close_delimiter_location),
}))
}
rule toc(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, 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::info!("Found Table of Contents block");
Ok(Block::TableOfContents(TableOfContents {
metadata,
location: state.create_location(start+offset, end+offset),
}))
}
rule image(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= "image::" source:source() attributes:macro_attributes() end:position!()
trailing:$([^'\n']*)
{
state.warn_trailing_macro_content("image", 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);
if let Some(style) = metadata.style {
metadata.style = None; metadata.attributes.insert("alt".into(), AttributeValue::String(style.clone()));
}
if metadata.positional_attributes.len() >= 2 {
metadata.attributes.insert("height".into(), AttributeValue::String(metadata.positional_attributes.remove(1)));
}
if !metadata.positional_attributes.is_empty() {
metadata.attributes.insert("width".into(), AttributeValue::String(metadata.positional_attributes.remove(0)));
}
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) -> Result<Block, 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) -> Result<Block, Error>
= "video::" sources:(source() ** comma()) attributes:macro_attributes() end:position!()
trailing:$([^'\n']*)
{
state.warn_trailing_macro_content("video", 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);
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.insert(style.clone(), AttributeValue::Bool(true));
} else {
tracing::debug!(?metadata, "transforming video metadata style into attribute, assuming poster");
metadata.attributes.insert("poster".into(), AttributeValue::String(style.clone()));
}
}
if metadata.positional_attributes.len() >= 2 {
metadata.attributes.insert("height".into(), AttributeValue::String(metadata.positional_attributes.remove(1)));
}
if !metadata.positional_attributes.is_empty() {
metadata.attributes.insert("width".into(), AttributeValue::String(metadata.positional_attributes.remove(0)));
}
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) -> Result<Block, Error>
= ("'''"
/ "---"
/ "- - -"
/ "***"
/ "* * *"
) end:position!()
{
tracing::info!("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, end, offset),
}))
}
rule page_break(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= "<<<" end:position!() &eol()*<2,2>
{
tracing::info!("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, end+offset),
}))
}
rule list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= list_with_continuation(start, offset, block_metadata, true)
rule list_with_continuation(start: usize, offset: usize, block_metadata: &BlockParsingMetadata, allow_continuation: bool) -> Result<Block, 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)
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 at_list_item_start() = whitespace()* (unordered_list_marker() / ordered_list_marker()) whitespace()
rule at_section_start() = (anchor() / attributes_line())* ("=" / "#")+ " "
rule at_ordered_marker_ahead() = eol()+ whitespace()* ordered_list_marker()
rule at_unordered_marker_ahead() = eol()+ whitespace()* unordered_list_marker()
rule at_root_ordered_marker() = !whitespace() ordered_list_marker()
rule at_root_unordered_marker() = !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 at_dlist_block_boundary()
= eol()*<2,> &(
("[" ![']' | '['] [^']' | '\n']+ "]" whitespace()* eol())
/ ("[[" [^']']+ "]]" whitespace()* eol())
)
rule unordered_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata, parent_ordered_marker: Option<&'input str>, allow_continuation: bool, is_nested: bool) -> Result<Block, 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))*
end:position!()
{
tracing::info!("Found unordered list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(end, |(_, item_end)| *item_end);
let items: Vec<ListItem> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or(String::new(), |item| item.marker.clone());
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, allow_continuation: bool, marker: &'input str, marker_start: usize, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= item:unordered_list_item_with_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_ordered_marker) {? if allow_continuation { Ok(item) } else { Err("skip") } }
/ item:unordered_list_item_no_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_ordered_marker) { item }
rule unordered_list_rest_item(offset: usize, block_metadata: &BlockParsingMetadata, parent_ordered_marker: Option<&'input str>, allow_continuation: bool, base_marker: &str) -> Result<(ListItem, usize), Error>
= !at_list_separator() !eol() comment_line()* !at_ordered_marker_ahead() item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{?
if parent_ordered_marker.is_some() {
Ok(item)
} else {
Err("skip")
}
}
/ !at_list_separator() !eol() comment_line()* item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{?
if parent_ordered_marker.is_some() {
Err("skip")
} else {
Ok(item)
}
}
/ !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)
{?
if parent_ordered_marker.is_some() {
Ok(item)
} else {
Err("skip")
}
}
/ !at_list_separator() eol()+ comment_line()* !at_shallower_unordered_marker(base_marker) item:unordered_list_item(offset, block_metadata, allow_continuation, parent_ordered_marker)
{?
if parent_ordered_marker.is_some() {
Err("skip")
} else {
Ok(item)
}
}
rule ordered_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata, parent_unordered_marker: Option<&'input str>, allow_continuation: bool, is_nested: bool) -> Result<Block, 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))*
end:position!()
{
tracing::info!("Found ordered list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(end, |(_, item_end)| *item_end);
let items: Vec<ListItem> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or(String::new(), |item| item.marker.clone());
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, allow_continuation: bool, marker: &'input str, marker_start: usize, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= item:ordered_list_item_with_continuation_after_marker(offset, block_metadata, marker, marker_start, parent_unordered_marker) {? if allow_continuation { Ok(item) } else { Err("skip") } }
/ 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, parent_unordered_marker: Option<&'input str>, allow_continuation: bool, base_marker: &str) -> Result<(ListItem, usize), Error>
= !at_list_separator() !eol() comment_line()* !at_unordered_marker_ahead() item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{?
if parent_unordered_marker.is_some() {
Ok(item)
} else {
Err("skip")
}
}
/ !at_list_separator() !eol() comment_line()* item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{?
if parent_unordered_marker.is_some() {
Err("skip")
} else {
Ok(item)
}
}
/ !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)
{?
if parent_unordered_marker.is_some() {
Ok(item)
} else {
Err("skip")
}
}
/ !at_list_separator() eol()+ comment_line()* !at_shallower_ordered_marker(base_marker) item:ordered_list_item(offset, block_metadata, allow_continuation, parent_unordered_marker)
{?
if parent_unordered_marker.is_some() {
Err("skip")
} else {
Ok(item)
}
}
rule unordered_list_item(offset: usize, block_metadata: &BlockParsingMetadata, allow_continuation: bool, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= item:unordered_list_item_with_continuation(offset, block_metadata, parent_ordered_marker) {? if allow_continuation { Ok(item) } else { Err("skip") } }
/ item:unordered_list_item_no_continuation(offset, block_metadata, parent_ordered_marker) { item }
rule unordered_list_item_with_continuation(offset: usize, block_metadata: &BlockParsingMetadata, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()*
marker:unordered_list_marker()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:unordered_list_item_nested_content(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 })*
end:position!()
{
tracing::info!(%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 principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
location: state.create_location(start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_no_continuation(offset: usize, block_metadata: &BlockParsingMetadata, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()*
marker:unordered_list_marker()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:unordered_list_item_nested_content(offset, block_metadata, marker, parent_ordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
end:position!()
{
tracing::info!(%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 principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
location: state.create_location(start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_with_continuation_after_marker(offset: usize, block_metadata: &BlockParsingMetadata, marker: &'input str, marker_start: usize, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:unordered_list_item_nested_content(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 })*
end:position!()
{
tracing::info!(%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 principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
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, marker: &'input str, marker_start: usize, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:unordered_list_item_nested_content(offset, block_metadata, marker, parent_ordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
end:position!()
{
tracing::info!(%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 principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
location: state.create_location(marker_start+offset, actual_end+offset),
}, actual_end))
}
rule unordered_list_item_nested_content(offset: usize, block_metadata: &BlockParsingMetadata, current_marker: &'input str, parent_ordered_marker: Option<&'input str>) -> Option<Result<Block, Error>>
= !at_root_ordered_marker() !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)
{
Some(list)
}
rule unordered_list_nested(start: usize, offset: usize, block_metadata: &BlockParsingMetadata, parent_marker: &str, parent_ordered_marker: Option<&'input str>) -> Result<Block, 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))*
end:position!()
{
tracing::info!(?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(end, |(_, item_end)| *item_end);
let items: Vec<ListItem> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or(String::new(), |item| item.marker.clone());
Ok(Block::UnorderedList(UnorderedList {
title: Title::default(),
metadata: BlockMetadata::default(),
items,
marker,
location: state.create_location(start+offset, end+offset),
}))
}
rule unordered_list_nested_rest_item(offset: usize, block_metadata: &BlockParsingMetadata, parent_marker: &str, base_marker: &str, parent_ordered_marker: Option<&'input str>) -> Result<(ListItem, 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, allow_continuation: bool, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= item:ordered_list_item_with_continuation(offset, block_metadata, parent_unordered_marker) {? if allow_continuation { Ok(item) } else { Err("skip") } }
/ item:ordered_list_item_no_continuation(offset, block_metadata, parent_unordered_marker) { item }
rule ordered_list_item_with_continuation(offset: usize, block_metadata: &BlockParsingMetadata, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()*
marker:ordered_list_marker()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:ordered_list_item_nested_content(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 })*
end:position!()
{
tracing::info!(%first_line, ?continuation_lines, %marker, ?checked, "found ordered list item");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
location: state.create_location(start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_no_continuation(offset: usize, block_metadata: &BlockParsingMetadata, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()*
marker:ordered_list_marker()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:ordered_list_item_nested_content(offset, block_metadata, marker, parent_unordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
end:position!()
{
tracing::info!(%first_line, ?continuation_lines, %marker, ?checked, "found ordered list item (immediate continuation only)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
location: state.create_location(start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_with_continuation_after_marker(offset: usize, block_metadata: &BlockParsingMetadata, marker: &'input str, marker_start: usize, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:ordered_list_item_nested_content(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 })*
end:position!()
{
tracing::info!(%first_line, ?continuation_lines, %marker, ?checked, "found ordered list item (after marker)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
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, marker: &'input str, marker_start: usize, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, usize), Error>
= start:position!()
whitespace()
checked:checklist_item()?
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(eol() !(&eol() / &at_list_item_start() / &"+" / &at_section_start() / &at_list_separator_content()) cont_line:$((!(eol()) [_])*) { cont_line })*
first_line_end:position!()
nested:(!at_list_separator() eol()+ nested_content:ordered_list_item_nested_content(offset, block_metadata, marker, parent_unordered_marker) { nested_content })?
immediate_continuations:(!at_list_separator() cont:list_explicit_continuation_immediate(offset, block_metadata) { cont })*
end:position!()
{
tracing::info!(%first_line, ?continuation_lines, %marker, ?checked, "found ordered list item (after marker, immediate only)");
let level = ListLevel::try_from(ListItem::parse_depth_from_marker(marker).unwrap_or(1))?;
let principal_text = assemble_principal_text(first_line, &continuation_lines);
let item_end = calculate_item_end(principal_text.is_empty(), start, first_line_end);
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
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 { end.saturating_sub(1) };
Ok((ListItem {
principal,
blocks,
level,
marker: marker.to_string(),
checked,
location: state.create_location(marker_start+offset, actual_end+offset),
}, actual_end))
}
rule ordered_list_item_nested_content(offset: usize, block_metadata: &BlockParsingMetadata, current_marker: &'input str, parent_unordered_marker: Option<&'input str>) -> Option<Result<Block, Error>>
= !at_root_unordered_marker() !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)
{
Some(list)
}
rule ordered_list_nested(start: usize, offset: usize, block_metadata: &BlockParsingMetadata, parent_marker: &str, parent_unordered_marker: Option<&'input str>) -> Result<Block, 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))*
end:position!()
{
tracing::info!(?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(end, |(_, item_end)| *item_end);
let items: Vec<ListItem> = content.into_iter().map(|(item, _)| item).collect();
let marker = items.first().map_or(String::new(), |item| item.marker.clone());
Ok(Block::OrderedList(OrderedList {
title: Title::default(),
metadata: BlockMetadata::default(),
items,
marker,
location: state.create_location(start+offset, end+offset),
}))
}
rule ordered_list_nested_rest_item(offset: usize, block_metadata: &BlockParsingMetadata, parent_marker: &str, base_marker: &str, parent_unordered_marker: Option<&'input str>) -> Result<(ListItem, 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 not_after_verbatim_block() -> ()
= {?
if state.last_block_was_verbatim {
Err("is_after_verbatim")
} else {
Ok(())
}
}
rule callout_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= !not_after_verbatim_block()
&(whitespace()* callout_list_marker() whitespace())
first:callout_list_item(offset, block_metadata)
rest:(callout_list_rest_item(offset, block_metadata))*
end:position!()
{
tracing::info!("Found callout list block");
let mut content = vec![first?];
for item in rest {
content.push(item?);
}
let end = content.last().map_or(end, |(_, _, item_end)| *item_end);
let mut auto_number = 1usize;
let mut items: Vec<CalloutListItem> = Vec::with_capacity(content.len());
for (mut item, marker, _end) in content {
if marker == "<.>" {
item.callout = CalloutRef::auto(auto_number, item.callout.location.clone());
auto_number += 1;
}
items.push(item);
}
let mut expected_number = 1;
for item in &items {
let actual_number = item.callout.number;
let (file_name, line) = state.resolve_source_location(item.location.absolute_start);
if actual_number != expected_number {
state.add_warning(format!("{file_name}: line {line}: callout list item index: expected {expected_number}, got {actual_number}"));
}
let callout_exists = state.last_verbatim_callouts.iter().any(|c| c.number == expected_number);
if !callout_exists {
state.add_warning(format!("{file_name}: line {line}: no callout found for <{expected_number}>"));
}
expected_number += 1;
}
state.last_block_was_verbatim = false;
state.last_verbatim_callouts.clear();
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) -> Result<(CalloutListItem, String, usize), Error>
= eol()+ item:callout_list_item(offset, block_metadata)
{?
Ok(item)
}
rule callout_list_item(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<(CalloutListItem, String, usize), Error>
= start:position!()
whitespace()*
marker:callout_list_marker()
whitespace()
first_line_start:position()
first_line:$((!(eol()) [_])*)
continuation_lines:(
eol()
!(whitespace()* (callout_list_marker() / unordered_list_marker() / ordered_list_marker() / section_level_marker() whitespace() / "[" / eol()))
line:$((!(eol()) [_])*)
{ line }
)*
first_line_end:position!()
{
let principal_text = if continuation_lines.is_empty() {
first_line.to_string()
} else {
let mut text = first_line.to_string();
for cont_line in continuation_lines {
text.push('\n');
text.push_str(cont_line);
}
text
};
let item_end = if principal_text.is_empty() {
start
} else {
first_line_end.saturating_sub(1)
};
let principal = if principal_text.trim().is_empty() {
vec![]
} else {
process_inlines(state, block_metadata, &first_line_start, first_line_end, offset, &principal_text)?
};
let blocks = vec![];
let location = state.create_location(start+offset, item_end+offset);
let callout = if marker == "<.>" {
CalloutRef::auto(0, location.clone()) } else {
let number = extract_callout_number(marker).unwrap_or(0);
CalloutRef::explicit(number, location.clone())
};
Ok((CalloutListItem {
callout,
principal,
blocks,
location,
}, marker.to_string(), item_end))
}
rule checklist_item() -> ListItemCheckedStatus
= checked:(("[x]" / "[X]" / "[*]") { ListItemCheckedStatus::Checked } / "[ ]" { ListItemCheckedStatus::Unchecked }) whitespace()
{
checked
}
rule check_start_of_description_list()
= &((!(description_list_marker() (eol() / " ")) [_])+ description_list_marker())
rule check_line_is_description_list()
= &((!(description_list_marker() (eol() / " " / ![_])) [^'\n'])+ description_list_marker())
rule description_list(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= check_start_of_description_list()
first_item:description_list_item(offset, block_metadata)
additional_items:description_list_additional_items(offset, block_metadata)*
end:position!()
{
tracing::info!("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(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,
location: state.create_location(start+offset, actual_end+offset),
}))
}
rule description_list_additional_items(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<DescriptionListItem, Error>
= !at_dlist_block_boundary()
eol()*
check_start_of_description_list()
item:description_list_item(offset, block_metadata)
{
tracing::info!("Found additional description list item");
item
}
rule description_list_item(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<DescriptionListItem, Error>
= start:position!()
term:$((!(description_list_marker() (eol() / " ") / eol()*<2,2>) [_])+)
delim_start:position!() delimiter:description_list_marker() delim_end:position!()
whitespace()?
principal_start:position()
principal_content:$(
(!eol() [_])*
(eol()
!eol() !(&((!(description_list_marker() (eol() / " ") / eol()) [_])+ description_list_marker())) !(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()
!attributes_line() !((anchor() / attributes_line())* section_level_at_line_start(offset, None) (whitespace() / eol() / ![_])) (!eol() [_])+ )*
)
attached_content:description_list_attached_content(offset, block_metadata)*
end:position!()
{
tracing::info!(%term, %delimiter, "parsing description list item with auto-attachment");
let term = inline_parser::inlines(term.trim(), state, start+offset, block_metadata)
.unwrap_or_else(|e| {
adjust_and_log_parse_error(&e, term.trim(), start+offset, state, "Error parsing term as inline content");
vec![]
});
let principal_end = principal_start.offset + principal_content.len();
let principal_text = if principal_content.trim().is_empty() {
Vec::new()
} else {
process_inlines(state, block_metadata, &principal_start, principal_end, offset, principal_content.trim())?
};
let mut description = Vec::new();
for content in attached_content {
match content {
Ok(blocks) => description.extend(blocks),
Err(e) => {
tracing::error!(?e, "Error processing attached content");
}
}
}
let actual_end = description.last().map_or_else(
|| {
if principal_content.is_empty() {
principal_start.offset
} else {
principal_start.offset + 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.to_string(),
delimiter_location: Some(delimiter_location),
principal_text,
description,
location: state.create_location(start+offset, actual_end+offset),
})
}
rule description_list_attached_content(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Vec<Block>, 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) -> Result<Vec<Block>, Error>
= 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::info!("Auto-attaching list to description list item");
Ok(vec![list?])
}
rule description_list_explicit_continuation(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Vec<Block>, Error>
= continuations:(
eol()* "+" eol()
block:block_in_continuation(offset, block_metadata.parent_section_level)
{ block }
)+
{
tracing::info!(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) -> Result<Block, Error>
= eol() !eol() "+" eol()
block:block_in_continuation(offset, block_metadata.parent_section_level)
{
tracing::info!("List immediate continuation block (0 empty lines)");
block
}
rule list_explicit_continuation_ancestor(offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= eol() eol()+ "+" eol()
block:block_in_continuation(offset, block_metadata.parent_section_level)
{
tracing::info!("List ancestor continuation block (1+ empty lines)");
block
}
rule quoted_paragraph(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= content_start:position!()
"\"" quoted_content:$((!"\"" [_])+) "\""
eol()
"-- " attr_start:position() attribution_line:$([^'\n']+)
end:position!()
{
tracing::info!(?quoted_content, ?attribution_line, "found quoted paragraph");
let (attr_str, cite_str) = match attribution_line.split_once(',') {
Some((attr, cite)) => (attr.trim().to_string(), Some(cite.trim().to_string())),
None => (attribution_line.trim().to_string(), None),
};
let attr_end_offset = attr_start.offset + attr_str.len();
let (attr_location, attr_processed) = preprocess_inline_content(
state,
&attr_start,
attr_end_offset,
offset,
&attr_str,
block_metadata.macros_enabled,
true,
)?;
let attr_inlines = parse_inlines(&attr_processed, state, block_metadata, &attr_location)?;
let attr_inlines = map_inline_locations(state, &attr_processed, &attr_inlines, &attr_location)?;
let cite_inlines = if let Some(ref cite) = cite_str {
let cite_offset_in_line = attribution_line.find(',').unwrap_or(0) + 1;
let cite_raw_start = attr_start.offset + 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_location, cite_processed) = preprocess_inline_content(
state,
&cite_pos,
cite_raw_start + cite.len(),
offset,
cite,
block_metadata.macros_enabled,
true,
)?;
let inlines = parse_inlines(&cite_processed, state, block_metadata, &cite_location)?;
Some(map_inline_locations(state, &cite_processed, &inlines, &cite_location)?)
} else {
None
};
let blocks = document_parser::blocks(quoted_content, state, content_start + offset, block_metadata.parent_section_level).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".to_string());
metadata.attribution = Some(Attribution::new(attr_inlines));
if let Some(inlines) = cite_inlines {
metadata.citetitle = Some(CiteTitle::new(inlines));
}
Ok(Block::DelimitedBlock(DelimitedBlock {
metadata,
delimiter: "\"".to_string(),
inner: DelimitedBlockType::DelimitedQuote(blocks),
title: block_metadata.title.clone(),
location: state.create_block_location(start, end, offset),
open_delimiter_location: None,
close_delimiter_location: None,
}))
}
rule markdown_blockquote(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= lines:markdown_blockquote_content_line()+ attribution:markdown_blockquote_attribution()? end:position!()
{
tracing::info!(?lines, ?attribution, "found markdown blockquote");
let content = lines.join("\n");
let content_start = start;
let mut metadata = block_metadata.metadata.clone();
metadata.style = Some("quote".to_string());
if let Some((author, author_start, citation)) = attribution {
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_location, attr_processed) = preprocess_inline_content(
state,
&author_pos,
attr_end_offset,
offset,
&author,
block_metadata.macros_enabled,
true,
)?;
let attr_inlines = parse_inlines(&attr_processed, state, block_metadata, &attr_location)?;
let attr_inlines = map_inline_locations(state, &attr_processed, &attr_inlines, &attr_location)?;
metadata.attribution = Some(Attribution::new(attr_inlines));
if let Some((cite, cite_start)) = citation {
let cite_pos = PositionWithOffset {
offset: cite_start,
position: state.line_map.offset_to_position(cite_start, &state.input),
};
let (cite_location, cite_processed) = preprocess_inline_content(
state,
&cite_pos,
cite_start + cite.len(),
offset,
&cite,
block_metadata.macros_enabled,
true,
)?;
let cite_inlines = parse_inlines(&cite_processed, state, block_metadata, &cite_location)?;
let cite_inlines = map_inline_locations(state, &cite_processed, &cite_inlines, &cite_location)?;
metadata.citetitle = Some(CiteTitle::new(cite_inlines));
}
}
let location = state.create_block_location(start, 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).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 {
metadata,
delimiter: ">".to_string(),
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 paragraph(start: usize, offset: usize, block_metadata: &BlockParsingMetadata) -> Result<Block, Error>
= admonition:admonition()?
content_start:position()
content:$((!(
eol()*<2,>
/ eol()* ![_]
/ eol() &attributes_line()
/ 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() list(start, offset, block_metadata)
/ eol() &("+" (whitespace() / eol() / ![_])) / eol()* &((anchor() / attributes_line())* section_level_at_line_start(offset, None) (whitespace() / eol() / ![_]))
) [_])+)
end:position!()
{
state.last_block_was_verbatim = false;
if content.starts_with(' ') {
return Ok(get_literal_paragraph(state, content, start, end, offset, block_metadata));
}
let (location, processed) = preprocess_inline_content(state, &content_start, end, offset, content, block_metadata.macros_enabled, block_metadata.attributes_enabled)?;
let content = parse_inlines(&processed, state, block_metadata, &location)?;
let content = map_inline_locations(state, &processed, &content, &location)?;
let title: Title = if let Some(AttributeValue::String(title)) = block_metadata.metadata.attributes.get("title") {
vec![InlineNode::PlainText(Plain {
content: 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::info!(%variant, "found admonition block with variant");
Ok(Block::Admonition(Admonition{
metadata: block_metadata.metadata.clone(),
title,
blocks: vec![Block::Paragraph(Paragraph {
content,
metadata: block_metadata.metadata.clone(),
title: Title::default(),
location: state.create_block_location(content_start.offset, end, offset),
})],
location: state.create_block_location(start, end, offset),
variant: parsed_variant,
}))
} else {
let mut metadata = block_metadata.metadata.clone();
metadata.move_positional_attributes_to_attributes();
tracing::info!(?content, ?location, "found paragraph block");
Ok(Block::Paragraph(Paragraph {
content,
metadata,
title,
location: state.create_block_location(start, end, offset),
}))
}
}
rule admonition() -> (String, usize, usize)
= start:position!() variant:$("NOTE" / "WARNING" / "TIP" / "IMPORTANT" / "CAUTION") ": " end:position!()
{
(variant.to_string(), start, end)
}
rule warn_anchor_id_with_whitespace() -> ()
= start:position!()
&(
id:$([^'\'' | ',' | ']' | '.' | '#']+)
end:position!()
{?
if id.chars().any(char::is_whitespace) {
state.add_warning(format!("anchor id '{id}' contains whitespace which is not allowed, treating as literal text"));
}
Err::<(), &'static str>("")
}
)
rule anchor() -> Anchor
= start:position!()
result:(
double_open_square_bracket() warn_anchor_id_with_whitespace()? id:$([^'\'' | ',' | ']' | ' ' | '\t' | '\n' | '\r']+) comma() reftext:$([^']']+) double_close_square_bracket() {
(id, Some(reftext))
} /
start:position!() double_open_square_bracket() warn_anchor_id_with_whitespace()? id:$([^'\'' | ',' | ']' | ' ' | '\t' | '\n' | '\r']+) double_close_square_bracket() {
(id, None)
} /
start:position!() open_square_bracket() "#" warn_anchor_id_with_whitespace()? id:$([^'\'' | ',' | ']' | '.' | '%' | ' ' | '\t' | '\n' | '\r']+) comma() reftext:$([^']']+) close_square_bracket() {
(id, Some(reftext))
} /
start:position!() 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 = substitute(id, HEADER, &state.document_attributes);
let substituted_reftext = reftext.map(|rt| substitute(rt, HEADER, &state.document_attributes));
Anchor {
id: substituted_id,
xreflabel: substituted_reftext,
location: state.create_location(start, end)
}
}
rule inline_anchor(offset: usize) -> InlineNode
= start:position!()
double_open_square_bracket()
warn_anchor_id_with_whitespace()?
id:$([^'\'' | ',' | ']' | '.' | ' ' | '\t' | '\n' | '\r']+)
reftext:(
comma() reftext:$([^']']+) {
Some(reftext)
} /
{
None
}
)
double_close_square_bracket()
end:position!()
{
let substituted_id = substitute(id, HEADER, &state.document_attributes);
let substituted_reftext = reftext.map(|rt| substitute(rt, HEADER, &state.document_attributes));
InlineNode::InlineAnchor(Anchor {
id: substituted_id,
xreflabel: substituted_reftext,
location: state.create_block_location(start, end, offset)
})
}
rule inline_anchor_match() -> ()
= double_open_square_bracket() [^'\'' | ',' | ']' | '.' | ' ' | '\t' | '\n' | '\r']+ (comma() [^']']+)? double_close_square_bracket()
rule bibliography_anchor(offset: usize) -> InlineNode
= start:position!()
"[[["
warn_anchor_id_with_whitespace()?
id:$([^'\'' | ',' | ']' | '[' | '.' | ' ' | '\t' | '\n' | '\r']+)
reftext:(comma() reftext:$([^']']+) { Some(reftext) } / { None })
"]]]"
end:position!()
{
let substituted_id = substitute(id, HEADER, &state.document_attributes);
let substituted_reftext = reftext.map(|rt| substitute(rt, HEADER, &state.document_attributes));
InlineNode::InlineAnchor(Anchor {
id: substituted_id,
xreflabel: substituted_reftext,
location: state.create_block_location(start, end, offset)
})
}
rule attributes_line() -> (bool, BlockMetadata)
= !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, Option<(usize, usize)>)
= start:position!() open_square_bracket() content:(
attributes:(comma() att:attribute() { att })+ {
tracing::info!(?attributes, "Found empty style with attributes");
(true, None, attributes)
} /
style:block_style() attributes:(comma() att:attribute() { att })+ {
tracing::info!(?style, ?attributes, "Found block style with attributes");
(false, Some(style), attributes)
} /
style:block_style() {
tracing::info!(?style, "Found block style");
(false, Some(style), vec![])
} /
attributes:(att:attribute() comma()? { att })* {
tracing::info!(?attributes, "Found attributes");
(false, None, attributes)
})
close_square_bracket() end:position!() {
let mut discrete = false;
let (_empty, maybe_style, attributes) = content;
let mut metadata = BlockMetadata::default();
if let Some((maybe_style_name, id, roles, options)) = maybe_style {
if let Some(style_name) = maybe_style_name {
if style_name == "discrete" {
discrete = true;
} else if metadata.style.is_none() {
metadata.style = Some(style_name);
} else {
metadata.attributes.insert(style_name, AttributeValue::None);
}
}
metadata.id = id;
metadata.roles.extend(roles);
metadata.options.extend(options);
}
let title_position = process_attribute_list(
attributes.iter().cloned(),
&mut metadata,
state,
start,
end,
AttributeProcessingMode::BLOCK,
);
if cfg!(feature = "pre-spec-subs") {
for (k, v, _pos) in attributes.iter().flatten() {
if *k == RESERVED_NAMED_ATTRIBUTE_SUBS && let AttributeValue::String(v) = v {
state.add_warning("The subs= attribute is experimental and may change when the AsciiDoc specification is finalized. See: https://gitlab.eclipse.org/eclipse/asciidoc-lang/asciidoc-lang/-/issues/16".to_string());
metadata.substitutions = Some(parse_subs_attribute(v));
}
}
}
if metadata.style.as_deref() == Some("quote") || metadata.style.as_deref() == Some("verse") {
let positional_positions: Vec<Option<(usize, usize)>> = attributes.iter()
.flatten()
.filter(|(_, v, _)| *v == AttributeValue::None)
.map(|(_, _, pos)| *pos)
.collect();
if metadata.positional_attributes.len() >= 2 {
let cite = metadata.positional_attributes.remove(1).trim().to_string();
if !cite.is_empty() {
let loc = positional_positions.get(1).copied().flatten()
.map_or_else(Location::default, |(s, e)| state.create_location(s, e));
metadata.citetitle = Some(CiteTitle::new(vec![InlineNode::PlainText(Plain {
content: cite,
location: loc,
escaped: false,
})]));
}
}
if !metadata.positional_attributes.is_empty() {
let attr = metadata.positional_attributes.remove(0).trim().to_string();
if !attr.is_empty() {
let loc = positional_positions.first().copied().flatten()
.map_or_else(Location::default, |(s, e)| state.create_location(s, e));
metadata.attribution = Some(Attribution::new(vec![InlineNode::PlainText(Plain {
content: attr,
location: loc,
escaped: false,
})]));
}
}
}
(discrete, metadata, title_position)
}
pub(crate) rule macro_attributes() -> (bool, BlockMetadata, Option<(usize, usize)>)
= start:position!() open_square_bracket()
attrs:(att:macro_attribute() comma()? { att })*
close_square_bracket() end:position!()
{
let mut metadata = BlockMetadata::default();
let title_position = process_attribute_list(
attrs,
&mut metadata,
state,
start,
end,
AttributeProcessingMode::MACRO,
);
(false, metadata, title_position)
}
rule macro_positional_value() -> Option<String>
= quoted:inner_attribute_value() {
let trimmed = strip_quotes("ed);
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}
/ s:$([^('"' | ',' | ']' | '=')]+) {
let trimmed = s.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}
rule macro_attribute() -> Option<(String, AttributeValue, Option<(usize, usize)>)>
= whitespace()* att:named_attribute() { att }
/ val:macro_positional_value() {
val.map(|v| (v, AttributeValue::None, None))
}
rule open_square_bracket() = "["
rule close_square_bracket() = "]"
rule double_open_square_bracket() = "[["
rule double_close_square_bracket() = "]]"
rule comma() = ","
rule period() = "."
rule empty_style() = ""
rule role() -> &'input str = $([^(',' | ']' | '#' | '.' | '%')]+)
rule shorthand() -> Shorthand
= "#" id:block_style_id() { Shorthand::Id(id.to_string()) }
/ "." role:role() { Shorthand::Role(role.to_string()) }
/ "%" option:option() { Shorthand::Option(option.to_string()) }
rule option() -> &'input str =
$(("\"" [^('"' | ']' | '#' | '.' | '%')]+ "\"") / ([^('"' | ',' | ']' | '#' | '.' | '%')]+))
rule attribute_name() -> &'input str = $((['A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_'])+)
pub(crate) rule attribute() -> Option<(String, AttributeValue, Option<(usize, usize)>)>
= whitespace()* att:named_attribute() { att }
/ whitespace()* start:position!() att:positional_attribute_value() end:position!() {
let substituted = substitute(&att, &[Substitution::Attributes], &state.document_attributes);
Some((substituted, AttributeValue::None, Some((start, end))))
}
rule id() -> String
= id:$((['A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_'])+) { id.to_string() }
rule named_attribute() -> Option<(String, AttributeValue, Option<(usize, usize)>)>
= "id" "=" start:position!() id:id() end:position!()
{ Some((RESERVED_NAMED_ATTRIBUTE_ID.to_string(), AttributeValue::String(id), Some((start, end)))) }
/ ("role" / "roles") "=" value:named_attribute_value()
{ Some((RESERVED_NAMED_ATTRIBUTE_ROLE.to_string(), AttributeValue::String(value), None)) }
/ ("options" / "opts") "=" value:named_attribute_value()
{ Some((RESERVED_NAMED_ATTRIBUTE_OPTIONS.to_string(), AttributeValue::String(value), None)) }
/ name:attribute_name() "=" start:position!() value:named_attribute_value() end:position!()
{
let substituted_value = substitute(&value, &[Substitution::Attributes], &state.document_attributes);
Some((name.to_string(), AttributeValue::String(substituted_value), Some((start, end))))
}
pub(crate) rule block_style() -> (Option<String>, Option<Anchor>, Vec<String>, Vec<String>)
= start:position!() content:(
style:positional_attribute_value() shorthands:(
"#" id_start:position!() id:block_style_id() id_end:position!() {
(Shorthand::Id(id.to_string()), Some((id_start, id_end)))
}
/ s:shorthand() { (s, None) }
)+ {
(Some(style), shorthands)
} /
style:positional_attribute_value() !"=" {
tracing::info!(%style, "Found block style without shorthands");
(Some(style), Vec::new())
} /
shorthands:(
"#" id_start:position!() id:block_style_id() id_end:position!() {
(Shorthand::Id(id.to_string()), Some((id_start, id_end)))
}
/ s:shorthand() { (s, None) }
)+ {
(None, shorthands)
}
)
end:position!() {
let (style, shorthands) = content;
let mut maybe_anchor = None;
let mut roles = Vec::new();
let mut options = Vec::new();
for (shorthand, pos) in shorthands {
match shorthand {
Shorthand::Id(id) => {
let (id_start, id_end) = pos.unwrap_or((start, end));
maybe_anchor = Some(Anchor {
id,
xreflabel: None,
location: state.create_location(id_start, id_end)
});
},
Shorthand::Role(role) => roles.push(role),
Shorthand::Option(option) => options.push(option),
}
}
(style, maybe_anchor, roles, options)
}
rule id_start_char() = ['A'..='Z' | 'a'..='z' | '_']
rule block_style_id() -> &'input str = $(id_start_char() block_style_id_subsequent_char()*)
rule block_style_id_subsequent_char() =
['A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-']
rule named_attribute_value() -> String
= &("\"" / "'") inner:inner_attribute_value()
{
let trimmed = strip_quotes(&inner);
tracing::debug!(%inner, %trimmed, "Found named attribute value (inner)");
trimmed.to_string()
}
/ s:$([^(',' | '"' | '\'' | ']')]+)
{
tracing::debug!(%s, "Found named attribute value");
s.to_string()
}
rule positional_attribute_value() -> String
= quoted:inner_attribute_value() {
let trimmed = strip_quotes("ed);
tracing::debug!(%quoted, %trimmed, "Found quoted positional attribute value");
trimmed.to_string()
}
/ s:$([^('"' | ',' | ']' | '#' | '.' | '%')] [^(',' | ']' | '#' | '.' | '%' | '=')]*)
{
let trimmed = s.trim();
tracing::debug!(%s, %trimmed, "Found unquoted positional attribute value");
trimmed.to_string()
}
rule inner_attribute_value() -> String
= s:$("\"" [^'"']* "\"") { s.to_string() }
/ s:$("'" [^'\'']* "'") { s.to_string() }
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:$(['A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '.' | '_' | '~' | ':' | '/' | '?' | '#' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | '%' | '\\' ]+)
{?
let inline_state = InlinePreprocessorParserState::new_all_enabled(
path,
state.line_map.clone(),
&state.input,
);
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"
})?;
for warning in inline_state.drain_warnings() {
state.add_warning(warning);
}
Ok(strip_url_backslash_escapes(&processed.text))
}
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,
);
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"
})?;
for warning in inline_state.drain_warnings() {
state.add_warning(warning);
}
Ok(strip_url_backslash_escapes(&processed.text))
}
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:$(['A'..='Z' | 'a'..='z' | '0'..='9' | '{' | '}' | '_' | '-' | '.' | '/' | '\\' ]+)
{?
let inline_state = InlinePreprocessorParserState::new_all_enabled(
path,
state.line_map.clone(),
&state.input,
);
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"
})?;
for warning in inline_state.drain_warnings() {
state.add_warning(warning);
}
Ok(processed.text)
}
pub rule source() -> Source
= source:
(
u:url() {?
Source::from_str(&u).map_err(|_| "failed to parse URL")
}
/ p:path() {?
Source::from_str(&p).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() -> String
= " " lines:document_attribute_value_lines()
{
lines.join("\n")
}
rule document_attribute_value_lines() -> Vec<&'input str>
= backslash_continuation_lines() / single_line:$([^'\n']+) { vec![single_line] }
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() -> AttributeEntry<'input>
= ":"
key_entry:(
"!" key:$([^':']+) { (false, key) }
/ key:$([^('!' | ':')]+) "!" { (false, key) }
/ key:$([^':']+) { (true, key) }
)
":" &" "?
value:document_attribute_value()?
{
let (set, key) = key_entry;
AttributeEntry::new(key, set, value.as_deref())
}
/ 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(
text: &str,
base_location: Location,
) -> (Vec<InlineNode>, Vec<CalloutRef>) {
let mut inlines = Vec::new();
let mut callouts = Vec::new();
let mut auto_number = 1usize;
let mut current_text = String::new();
for (line_idx, line) in text.lines().enumerate() {
if line_idx > 0 {
current_text.push('\n');
}
let trimmed_end = line.trim_end();
if let Some(pos) = trimmed_end.rfind("<.>") {
current_text.push_str(&line[..pos]);
if !current_text.is_empty() {
inlines.push(InlineNode::VerbatimText(Verbatim {
content: std::mem::take(&mut current_text),
location: base_location.clone(),
}));
}
let callout_ref = CalloutRef::auto(auto_number, base_location.clone());
inlines.push(InlineNode::CalloutRef(callout_ref.clone()));
callouts.push(callout_ref);
auto_number += 1;
let after_marker = &line[pos + 3..];
if !after_marker.is_empty() {
current_text.push_str(after_marker);
}
} else if let Some((number, marker_start)) =
extract_callout_number_with_position(trimmed_end)
{
current_text.push_str(&line[..marker_start]);
if !current_text.is_empty() {
inlines.push(InlineNode::VerbatimText(Verbatim {
content: std::mem::take(&mut current_text),
location: base_location.clone(),
}));
}
let callout_ref = CalloutRef::explicit(number, base_location.clone());
inlines.push(InlineNode::CalloutRef(callout_ref.clone()));
callouts.push(callout_ref);
if let Some(marker_end_relative) = trimmed_end[marker_start..].find('>') {
let marker_end = marker_start + marker_end_relative + 1;
let after_marker = &line[marker_end..];
if !after_marker.is_empty() {
current_text.push_str(after_marker);
}
}
} else {
current_text.push_str(line);
}
}
if !current_text.is_empty() {
inlines.push(InlineNode::VerbatimText(Verbatim {
content: current_text,
location: base_location,
}));
}
(inlines, callouts)
}
fn extract_callout_number_with_position(line: &str) -> Option<(usize, usize)> {
if line.ends_with('>')
&& let Some(start) = line.rfind('<')
{
let number_str = &line[start + 1..line.len() - 1];
number_str.parse().ok().map(|n| (n, start))
} else {
None
}
}
fn extract_callout_number(line: &str) -> Option<usize> {
if line.ends_with('>')
&& let Some(start) = line.rfind('<')
{
let number_str = &line[start + 1..line.len() - 1];
number_str.parse().ok()
} else {
None
}
}
#[cfg(test)]
#[allow(
clippy::indexing_slicing,
clippy::unwrap_used,
clippy::expect_used,
clippy::unreachable
)]
mod tests {
use super::*;
#[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(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".to_string(),
location: Location {
absolute_start: 34,
absolute_end: 47,
start: crate::Position { line: 2, column: 3 },
end: crate::Position {
line: 2,
column: 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.".to_string()));
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".to_string())
);
assert_eq!(header.authors[1].first_name, "Norberto");
assert_eq!(header.authors[1].middle_name, Some("M.".to_string()));
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".to_string())
);
assert_eq!(
state.document_attributes.get("revnumber"),
Some(&AttributeValue::String("v2.9".into()))
);
assert_eq!(
state.document_attributes.get("revdate"),
Some(&AttributeValue::String("01-09-2024".into()))
);
assert_eq!(
state.document_attributes.get("revremark"),
Some(&AttributeValue::String("Fall incarnation".into()))
);
assert_eq!(
state.document_attributes.get("description"),
Some(&AttributeValue::String(
"The document's description.".into()
))
);
assert_eq!(
state.document_attributes.get("sectanchors"),
Some(&AttributeValue::Bool(true))
);
assert_eq!(
state.document_attributes.get("url-repo"),
Some(&AttributeValue::String("https://my-git-repo.com".into()))
);
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(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.".to_string()));
assert_eq!(result[0].last_name, "Lee");
assert_eq!(result[0].initials, "LRL");
assert_eq!(result[0].email, Some("kismet@asciidoctor.org".to_string()));
assert_eq!(result[1].first_name, "Norberto");
assert_eq!(result[1].middle_name, Some("M.".to_string()));
assert_eq!(result[1].last_name, "Lopes");
assert_eq!(result[1].initials, "NML");
assert_eq!(result[1].email, Some("nlopesml@gmail.com".to_string()));
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(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "Norberto");
assert_eq!(result.middle_name, Some("M.".to_string()));
assert_eq!(result.last_name, "Lopes supa dough");
assert_eq!(result.initials, "NML");
assert_eq!(result.email, Some("nlopesml@gmail.com".to_string()));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_compound_first_name() -> Result<(), Error> {
let input = "Ann_Marie Jenson";
let mut state = ParserState::new(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(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(input);
let result = document_parser::author(input, &mut state)?;
assert_eq!(result.first_name, "First");
assert_eq!(result.middle_name, Some("Middle Name".to_string()));
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(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(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(input);
document_parser::revision(input, &mut state)?;
assert_eq!(
state.document_attributes.get("revnumber"),
Some(&AttributeValue::String("v2.9".into()))
);
assert_eq!(
state.document_attributes.get("revdate"),
Some(&AttributeValue::String("01-09-2024".into()))
);
assert_eq!(
state.document_attributes.get("revremark"),
Some(&AttributeValue::String("Fall incarnation".into()))
);
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(input);
document_parser::revision(input, &mut state)?;
assert_eq!(
state.document_attributes.get("revnumber"),
Some(&AttributeValue::String("v2.9".into()))
);
assert_eq!(
state.document_attributes.get("revdate"),
Some(&AttributeValue::String("01-09-2024".into()))
);
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(input);
document_parser::revision(input, &mut state)?;
assert_eq!(
state.document_attributes.get("revnumber"),
Some(&AttributeValue::String("v2.9".into()))
);
assert_eq!(state.document_attributes.get("revdate"), None);
assert_eq!(
state.document_attributes.get("revremark"),
Some(&AttributeValue::String("Fall incarnation".into()))
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_revision_no_date_no_remark() -> Result<(), Error> {
let input = "v2.9";
let mut state = ParserState::new(input);
document_parser::revision(input, &mut state)?;
assert_eq!(
state.document_attributes.get("revnumber"),
Some(&AttributeValue::String("v2.9".into()))
);
assert_eq!(state.document_attributes.get("revdate"), None);
assert_eq!(state.document_attributes.get("revremark"), None);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_title() -> Result<(), Error> {
let input = "= Document Title";
let mut state = ParserState::new(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".to_string(),
location: Location {
absolute_start: 2,
absolute_end: 15,
start: crate::Position { line: 1, column: 3 },
end: crate::Position {
line: 1,
column: 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(input);
let result = document_parser::document_title(input, &mut state)?;
assert_eq!(
result,
(
Title::new(vec![InlineNode::PlainText(Plain {
content: "Document Title".to_string(),
location: Location {
absolute_start: 2,
absolute_end: 15,
start: crate::Position { line: 1, column: 3 },
end: crate::Position {
line: 1,
column: 16,
},
},
escaped: false,
})]),
Some(Subtitle::new(vec![InlineNode::PlainText(Plain {
content: "And a subtitle".to_string(),
location: Location {
absolute_start: 18,
absolute_end: 31,
start: crate::Position {
line: 1,
column: 19,
},
end: crate::Position {
line: 1,
column: 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(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".to_string(),
location: Location {
absolute_start: 2,
absolute_end: 15,
start: crate::Position { line: 1, column: 3 },
end: crate::Position {
line: 1,
column: 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.".to_string()));
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".to_string())
);
assert_eq!(result.authors[1].first_name, "Norberto");
assert_eq!(result.authors[1].middle_name, Some("M.".to_string()));
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".to_string())
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_document_empty_attribute_list() -> Result<(), Error> {
let input = "[]";
let mut state = ParserState::new(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!(metadata.roles.is_empty());
assert!(metadata.options.is_empty());
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(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!(metadata.roles.is_empty());
assert!(metadata.options.is_empty());
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(
metadata.id,
Some(Anchor {
id: "my-id".to_string(),
xreflabel: None,
location: Location {
absolute_start: 4,
absolute_end: 9,
start: crate::Position { line: 1, column: 5 },
end: crate::Position {
line: 1,
column: 10,
}
}
})
);
assert_eq!(metadata.style, None);
assert!(metadata.roles.contains(&"admin".to_string()));
assert!(metadata.options.contains(&"read".to_string()));
assert!(metadata.options.contains(&"write".to_string()));
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(
metadata.id,
Some(Anchor {
id: "myid".to_string(),
xreflabel: None,
location: Location {
absolute_start: 8,
absolute_end: 12,
start: crate::Position { line: 1, column: 9 },
end: crate::Position {
line: 1,
column: 13,
}
}
})
);
assert_eq!(metadata.style, Some("astyle".to_string()));
assert!(metadata.roles.contains(&"admin".to_string()));
assert!(metadata.options.contains(&"read".to_string()));
assert!(metadata.options.contains(&"write".to_string()));
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete); assert_eq!(
metadata.id,
Some(Anchor {
id: "myid".to_string(),
xreflabel: None,
location: Location {
absolute_start: 8,
absolute_end: 12,
start: crate::Position { line: 1, column: 9 },
end: crate::Position {
line: 1,
column: 13,
}
}
})
);
assert_eq!(metadata.style, Some("astyle".to_string()));
assert!(metadata.roles.contains(&"admin".to_string()));
assert!(metadata.options.contains(&"read".to_string()));
assert!(metadata.options.contains(&"write".to_string()));
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(
metadata.id,
Some(Anchor {
id: "bracket-id".to_string(),
xreflabel: None,
location: Location {
absolute_start: 2,
absolute_end: 12,
start: crate::Position { line: 1, column: 3 },
end: crate::Position {
line: 1,
column: 13,
}
}
})
);
assert_eq!(metadata.style, None);
assert!(metadata.roles.contains(&"some-role".to_string()));
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(
metadata.id,
Some(Anchor {
id: "my-id".to_string(),
xreflabel: None,
location: Location {
absolute_start: 2,
absolute_end: 7,
start: crate::Position { line: 1, column: 3 },
end: crate::Position { line: 1, column: 8 }
}
})
);
assert_eq!(metadata.style, None);
assert!(metadata.roles.contains(&"my-role".to_string()));
assert!(metadata.options.contains(&"my-option".to_string()));
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(metadata.id.as_ref().map(|a| a.id.as_str()), Some("my-id"));
assert!(metadata.roles.contains(&"role-one".to_string()));
assert!(metadata.roles.contains(&"role-two".to_string()));
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(input);
let (discrete, metadata, _title_position) = document_parser::attributes(input, &mut state)?;
assert!(!discrete);
assert_eq!(metadata.id.as_ref().map(|a| a.id.as_str()), Some("my-id"));
assert_eq!(metadata.style, Some("quote".to_string()));
assert!(metadata.roles.contains(&"my-role".to_string()));
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_shorthand_just_roles() -> Result<(), Error> {
let input = "[.role-one.role-two]";
let mut state = ParserState::new(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".to_string()));
assert!(metadata.roles.contains(&"role-two".to_string()));
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(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(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_toc_empty_document() -> Result<(), Error> {
let input = "= Document Title\n\nJust some content without sections.";
let mut state = ParserState::new(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(input);
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(input);
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(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(input);
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(input);
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(input);
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(input);
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(input);
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(input);
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(input);
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 = crate::Options::builder().with_setext().build();
let result = crate::parse(input, &options)?;
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(),
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(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() && it.term() == "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(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() && it.term() == "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(doc: &Document) -> &Image {
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(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.".to_string()
)),
"Trailing period should be preserved in alt text"
);
let input = "image::photo.jpg[.role]";
let mut state = ParserState::new(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".to_string())),
".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(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".to_string())),
"#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(input);
let result = document_parser::document(input, &mut state)??;
let img = get_image(&result);
assert_eq!(
img.metadata.roles,
vec!["thumbnail".to_string()],
"Named role= attribute should work"
);
Ok(())
}
#[test]
#[tracing_test::traced_test]
fn test_block_macro_trailing_content_emits_warning() -> Result<(), Error> {
let input = "image::foo.svg[role=inline][100,100]\n\n[.lead]\nHello\n";
let mut state = ParserState::new(input);
let result = document_parser::document(input, &mut state)??;
assert!(
result.blocks.iter().any(|b| matches!(b, Block::Image(_))),
"document should contain an image block"
);
assert!(
result
.blocks
.iter()
.any(|b| matches!(b, Block::Paragraph(_))),
"document should contain a paragraph block"
);
assert!(
state.warnings.iter().any(|w| w.contains("input: line")
&& w.contains("unexpected content after image macro")
&& w.contains("[100,100]")),
"should warn about trailing content with 'input' as file, got: {:?}",
state.warnings
);
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(&input);
state.current_file = Some(PathBuf::from("/docs/main.adoc"));
state.source_ranges = vec![SourceRange {
start_offset: 28, end_offset: 60,
file: PathBuf::from("/docs/sponsor.adoc"),
start_line: 1,
}];
state.warn_trailing_macro_content("image", "[100,100]", 40, 0);
assert_eq!(state.warnings.len(), 1);
assert!(
state.warnings[0].contains("sponsor.adoc"),
"should reference included file, got: {}",
state.warnings[0]
);
assert!(
state.warnings[0].contains("line 4"),
"should reference line 4 in included file, got: {}",
state.warnings[0]
);
}
#[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(input);
state.current_file = Some(PathBuf::from("/docs/main.adoc"));
state.source_ranges = vec![SourceRange {
start_offset: 100, end_offset: 200,
file: PathBuf::from("/docs/other.adoc"),
start_line: 1,
}];
state.warn_trailing_macro_content("image", "extra", 17, 0);
assert_eq!(state.warnings.len(), 1);
assert!(
state.warnings[0].contains("main.adoc"),
"should reference entry-point file, got: {}",
state.warnings[0]
);
}
}