use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use carta_ast::{
Alignment, Attr, Block, Caption, Cell as TableCell, Citation, CitationMode, ColSpec, ColWidth,
Inline, MathType, QuoteType, Row, Table, TableBody, TableFoot, TableHead, Target, Text,
};
use carta_core::{Extension, Extensions};
use super::attr;
use super::block::is_format_name_char;
use super::identifiers::HeaderNumbering;
use super::scan::{
escape_uri, is_ascii_punctuation, normalize_label, scan_autolink, scan_entity,
scan_following_label, scan_html_tag, scan_inline_target, unescape_string,
};
use super::{ExampleMap, FootnoteDefs, IrBlock, LinkDef, RefMap, para, plain};
use crate::emoji;
use crate::inline_scan::{fold_dash_run, fold_ellipsis_run, is_unicode_whitespace};
const TASK_UNCHECKED: &str = "\u{2610}";
const TASK_CHECKED: &str = "\u{2612}";
#[derive(Clone, Copy)]
struct RefContext<'a> {
defined: &'a BTreeSet<String>,
by_id: &'a BTreeMap<String, Vec<Block>>,
in_definition: bool,
markdown: bool,
examples: &'a ExampleMap,
cite_count: &'a Cell<i32>,
}
type HeaderParseCache = BTreeMap<String, VecDeque<Vec<Inline>>>;
fn heading_content_is_context_independent(content: &str) -> bool {
!content.contains(['[', '@', '^'])
}
pub(crate) fn resolve_document(
ir: &[IrBlock],
mut refs: RefMap,
footnotes: &FootnoteDefs,
examples: &ExampleMap,
ext: Extensions,
markdown: bool,
) -> Vec<Block> {
let defined: BTreeSet<String> = footnotes.keys().cloned().collect();
let empty = BTreeMap::new();
let scratch_count = Cell::new(0);
let body_count = Cell::new(0);
let mut header_parse_cache: HeaderParseCache = BTreeMap::new();
if ext.contains(Extension::ImplicitHeaderReferences) {
let probe = RefContext {
defined: &defined,
by_id: &empty,
in_definition: false,
markdown,
examples,
cite_count: &scratch_count,
};
register_header_references(ir, &mut refs, probe, ext, &mut header_parse_cache);
}
let in_def = RefContext {
defined: &defined,
by_id: &empty,
in_definition: true,
markdown,
examples,
cite_count: &scratch_count,
};
let by_id: BTreeMap<String, Vec<Block>> = footnotes
.iter()
.map(|(key, body)| {
(
key.clone(),
resolve_blocks(body, &refs, in_def, ext, &mut header_parse_cache),
)
})
.collect();
let top = RefContext {
defined: &defined,
by_id: &by_id,
in_definition: false,
markdown,
examples,
cite_count: &body_count,
};
let mut blocks = resolve_blocks(ir, &refs, top, ext, &mut header_parse_cache);
super::identifiers::assign_header_identifiers(&mut blocks, ext, markdown);
blocks
}
fn register_header_references(
ir: &[IrBlock],
refs: &mut RefMap,
notes: RefContext,
ext: Extensions,
cache: &mut HeaderParseCache,
) {
let mut numbering = HeaderNumbering::new(ext, notes.markdown);
gather_headers(ir, refs, notes, ext, &mut numbering, cache);
}
fn gather_headers(
ir: &[IrBlock],
refs: &mut RefMap,
notes: RefContext,
ext: Extensions,
numbering: &mut HeaderNumbering,
cache: &mut HeaderParseCache,
) {
for block in ir {
match block {
IrBlock::Heading(_, text) => {
let (content, attr) = split_header_attr(text, ext);
let inlines = parse_inlines(content, refs, notes, ext);
let id = numbering.id_for(&attr.id, &inlines);
refs.entry(normalize_label(content)).or_insert(LinkDef {
url: format!("#{id}"),
title: String::new(),
});
if heading_content_is_context_independent(content) {
cache
.entry(content.to_owned())
.or_default()
.push_back(inlines);
}
}
IrBlock::Div(_, children) | IrBlock::BlockQuote(children) => {
gather_headers(children, refs, notes, ext, numbering, cache);
}
IrBlock::BulletList(items) | IrBlock::OrderedList(_, items) => {
for item in items {
gather_headers(item, refs, notes, ext, numbering, cache);
}
}
IrBlock::DefinitionList(items) => {
for item in items {
for definition in &item.definitions {
gather_headers(definition, refs, notes, ext, numbering, cache);
}
}
}
_ => {}
}
}
}
fn resolve_blocks(
ir: &[IrBlock],
refs: &RefMap,
notes: RefContext,
ext: Extensions,
cache: &mut HeaderParseCache,
) -> Vec<Block> {
let mut out = Vec::with_capacity(ir.len());
for block in ir {
resolve_block(block, refs, notes, ext, cache, &mut out);
}
out
}
fn resolve_block(
block: &IrBlock,
refs: &RefMap,
notes: RefContext,
ext: Extensions,
cache: &mut HeaderParseCache,
out: &mut Vec<Block>,
) {
match block {
IrBlock::Para(text) => {
let inlines = parse_inlines(text, refs, notes, ext);
out.push(para_or_figure(inlines, ext));
}
IrBlock::Plain(text) => out.push(plain(parse_inlines(text, refs, notes, ext))),
IrBlock::Heading(level, text) => {
let (content, attr) = split_header_attr(text, ext);
let inlines = if heading_content_is_context_independent(content) {
cache
.get_mut(content)
.and_then(VecDeque::pop_front)
.unwrap_or_else(|| parse_inlines(content, refs, notes, ext))
} else {
parse_inlines(content, refs, notes, ext)
};
out.push(Block::Header(*level, Box::new(attr), inlines));
}
IrBlock::CodeBlock(attr, text) => {
out.push(Block::CodeBlock(
Box::new(attr.clone()),
text.clone().into(),
));
}
IrBlock::RawHtml(text) => {
if notes.markdown && !ext.contains(Extension::RawHtml) {
out.push(Block::Para(parse_inlines(text, refs, notes, ext)));
} else {
out.push(Block::RawBlock(
carta_ast::Format("html".into()),
text.clone().into(),
));
}
}
IrBlock::RawBlock(format, text) => {
out.push(Block::RawBlock(format.clone(), text.clone().into()));
}
IrBlock::ThematicBreak => out.push(Block::HorizontalRule),
IrBlock::Div(attr, children) => {
out.push(Block::Div(
Box::new(attr.clone()),
resolve_blocks(children, refs, notes, ext, cache),
));
}
IrBlock::BlockQuote(children) => {
out.push(Block::BlockQuote(resolve_blocks(
children, refs, notes, ext, cache,
)));
}
IrBlock::LineBlock(lines) => out.push(Block::LineBlock(
lines
.iter()
.map(|line| parse_inlines(line, refs, notes, ext))
.collect(),
)),
IrBlock::DefinitionList(items) => out.push(Block::DefinitionList(
items
.iter()
.map(|item| {
let term = parse_inlines(&item.term, refs, notes, ext);
let definitions = item
.definitions
.iter()
.map(|blocks| resolve_blocks(blocks, refs, notes, ext, cache))
.collect();
(term, definitions)
})
.collect(),
)),
IrBlock::BulletList(items) => resolve_bullet_list(items, refs, notes, ext, cache, out),
IrBlock::OrderedList(attrs, items) => out.push(Block::OrderedList(
attrs.clone(),
items
.iter()
.map(|i| resolve_blocks(i, refs, notes, ext, cache))
.collect(),
)),
IrBlock::Table {
alignments,
header,
rows,
caption,
attr,
} => out.push(resolve_table(
alignments,
header,
rows,
caption.as_deref(),
attr,
refs,
notes,
ext,
)),
IrBlock::GridTable(table) => out.push(resolve_grid_table(table, refs, notes, ext)),
IrBlock::TextTable(table) => out.push(resolve_text_table(table, refs, notes, ext)),
}
}
fn para_or_figure(inlines: Vec<Inline>, ext: Extensions) -> Block {
if !ext.contains(Extension::ImplicitFigures) {
return para(inlines);
}
let one: Result<[Inline; 1], Vec<Inline>> = inlines.try_into();
match one {
Ok([Inline::Image(mut attr, alt, target)]) if !alt.is_empty() => {
let figure_attr = Attr {
id: std::mem::take(&mut attr.id),
classes: Vec::new(),
attributes: Vec::new(),
};
let caption = Caption {
short: None,
long: vec![Block::Plain(alt.clone())],
};
let image = Inline::Image(attr, alt, target);
Block::Figure(
Box::new(figure_attr),
Box::new(caption),
vec![Block::Plain(vec![image])],
)
}
Ok([only]) => para(vec![only]),
Err(inlines) => para(inlines),
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_table(
alignments: &[Alignment],
header: &[String],
rows: &[Vec<String>],
caption: Option<&str>,
attr: &Attr,
refs: &RefMap,
notes: RefContext,
ext: Extensions,
) -> Block {
let col_specs = alignments
.iter()
.map(|align| ColSpec {
align: align.clone(),
width: ColWidth::ColWidthDefault,
})
.collect();
let make_row = |cells: &[String]| Row {
attr: Attr::default(),
cells: cells
.iter()
.map(|text| make_cell(text, refs, notes, ext))
.collect(),
};
let caption = match caption {
Some(text) => make_caption(text, refs, notes, ext),
None => Caption::default(),
};
Block::Table(Box::new(Table {
attr: attr.clone(),
caption,
col_specs,
head: TableHead {
attr: Attr::default(),
rows: vec![make_row(header)],
},
bodies: vec![TableBody {
attr: Attr::default(),
row_head_columns: 0,
head: Vec::new(),
body: rows.iter().map(|cells| make_row(cells)).collect(),
}],
foot: TableFoot::default(),
}))
}
fn make_caption(text: &str, refs: &RefMap, notes: RefContext, ext: Extensions) -> Caption {
let inlines = parse_inlines(text, refs, notes, ext);
Caption {
short: None,
long: if inlines.is_empty() {
Vec::new()
} else {
vec![Block::Plain(inlines)]
},
}
}
fn make_cell(text: &str, refs: &RefMap, notes: RefContext, ext: Extensions) -> TableCell {
let content = if text.is_empty() {
Vec::new()
} else {
vec![Block::Plain(parse_inlines(text, refs, notes, ext))]
};
TableCell {
attr: Attr::default(),
align: Alignment::AlignDefault,
row_span: 1,
col_span: 1,
content,
}
}
fn resolve_grid_table(
table: &super::grid::GridTable,
refs: &RefMap,
notes: RefContext,
ext: Extensions,
) -> Block {
let col_specs = table
.columns
.iter()
.map(|column| ColSpec {
align: column.align.clone(),
width: ColWidth::ColWidth(column.width),
})
.collect();
let make_row = |row: &super::grid::Row| Row {
attr: Attr::default(),
cells: row
.cells
.iter()
.map(|cell| make_grid_cell(cell, ext, notes.markdown))
.collect(),
};
let caption = match &table.caption {
Some(text) => make_caption(text, refs, notes, ext),
None => Caption::default(),
};
Block::Table(Box::new(Table {
attr: table.attr.clone(),
caption,
col_specs,
head: TableHead {
attr: Attr::default(),
rows: table.head.iter().map(make_row).collect(),
},
bodies: vec![TableBody {
attr: Attr::default(),
row_head_columns: 0,
head: Vec::new(),
body: table.body.iter().map(make_row).collect(),
}],
foot: TableFoot {
attr: Attr::default(),
rows: table.foot.iter().map(make_row).collect(),
},
}))
}
fn resolve_text_table(
table: &super::texttable::TextTable,
refs: &RefMap,
notes: RefContext,
ext: Extensions,
) -> Block {
let col_specs = table
.columns
.iter()
.map(|column| ColSpec {
align: column.align.clone(),
width: match column.width {
Some(width) => ColWidth::ColWidth(width),
None => ColWidth::ColWidthDefault,
},
})
.collect();
let make_row = |cells: &[String]| Row {
attr: Attr::default(),
cells: cells
.iter()
.map(|text| make_cell(text, refs, notes, ext))
.collect(),
};
let head_rows = if table.head.is_empty() {
Vec::new()
} else {
vec![make_row(&table.head)]
};
let caption = match &table.caption {
Some(text) => make_caption(text, refs, notes, ext),
None => Caption::default(),
};
Block::Table(Box::new(Table {
attr: table.attr.clone(),
caption,
col_specs,
head: TableHead {
attr: Attr::default(),
rows: head_rows,
},
bodies: vec![TableBody {
attr: Attr::default(),
row_head_columns: 0,
head: Vec::new(),
body: table.body.iter().map(|cells| make_row(cells)).collect(),
}],
foot: TableFoot::default(),
}))
}
fn make_grid_cell(cell: &super::grid::Cell, ext: Extensions, markdown: bool) -> TableCell {
TableCell {
attr: Attr::default(),
align: Alignment::AlignDefault,
row_span: 1,
col_span: 1,
content: super::parse_table_cell(&cell.text, cell.tight, ext, markdown),
}
}
fn resolve_bullet_list(
items: &[Vec<IrBlock>],
refs: &RefMap,
notes: RefContext,
ext: Extensions,
cache: &mut HeaderParseCache,
out: &mut Vec<Block>,
) {
let task_lists = ext.contains(Extension::TaskLists);
let mut run: Vec<Vec<Block>> = Vec::new();
let mut run_is_task: Option<bool> = None;
for item in items {
let marker = if task_lists {
item.first().and_then(task_marker_block)
} else {
None
};
let is_task = marker.is_some();
if run_is_task.is_some_and(|previous| previous != is_task) {
out.push(Block::BulletList(std::mem::take(&mut run)));
}
run_is_task = Some(is_task);
run.push(resolve_item(item, marker.as_ref(), refs, notes, ext, cache));
}
if !run.is_empty() {
out.push(Block::BulletList(run));
}
}
fn resolve_item(
item: &[IrBlock],
marker: Option<&IrBlock>,
refs: &RefMap,
notes: RefContext,
ext: Extensions,
cache: &mut HeaderParseCache,
) -> Vec<Block> {
let mut out = Vec::new();
let mut blocks = item.iter();
if let Some(first) = blocks.next() {
resolve_block(marker.unwrap_or(first), refs, notes, ext, cache, &mut out);
}
for block in blocks {
resolve_block(block, refs, notes, ext, cache, &mut out);
}
out
}
fn task_marker_block(block: &IrBlock) -> Option<IrBlock> {
match block {
IrBlock::Para(text) => task_marker_replacement(text).map(IrBlock::Para),
IrBlock::Plain(text) => task_marker_replacement(text).map(IrBlock::Plain),
_ => None,
}
}
fn task_marker_replacement(text: &str) -> Option<String> {
let (marker, rest) = text
.strip_prefix("[ ]")
.map(|rest| (TASK_UNCHECKED, rest))
.or_else(|| text.strip_prefix("[x]").map(|rest| (TASK_CHECKED, rest)))
.or_else(|| text.strip_prefix("[X]").map(|rest| (TASK_CHECKED, rest)))?;
if rest.is_empty() || rest.starts_with(' ') {
Some(format!("{marker}{rest}"))
} else {
None
}
}
fn split_header_attr(text: &str, ext: Extensions) -> (&str, Attr) {
if ext.contains(Extension::HeaderAttributes) || ext.contains(Extension::Attributes) {
let trimmed = text.trim_end();
if trimmed.ends_with('}') {
for (start, ch) in trimmed.char_indices().rev() {
if ch != '{' {
continue;
}
let preceded_by_space =
start == 0 || char_before(trimmed, start).is_some_and(is_unicode_whitespace);
if preceded_by_space
&& let Some((attr, end)) = attr::parse_attributes_bytes(trimmed, start)
&& end == trimmed.len()
&& attr::is_non_empty(&attr)
{
let content = text.get(..start).unwrap_or(text).trim_end();
return (content, attr);
}
}
}
}
if ext.contains(Extension::MmdHeaderIdentifiers)
&& let Some((content, id)) = split_mmd_header_id(text)
{
return (
content,
Attr {
id: id.into(),
..Attr::default()
},
);
}
(text, Attr::default())
}
fn split_mmd_header_id(text: &str) -> Option<(&str, String)> {
let trimmed = text.trim_end();
if !trimmed.ends_with(']') {
return None;
}
let close = trimmed.len().checked_sub(1)?;
let mut depth = 0i32;
let mut open = None;
for (i, ch) in trimmed.char_indices().rev() {
match ch {
']' => depth += 1,
'[' => {
depth -= 1;
if depth == 0 {
open = Some(i);
break;
}
}
_ => {}
}
}
let open = open?;
let mut before = open;
while let Some(c) = char_before(trimmed, before) {
if c.is_whitespace() {
before -= c.len_utf8();
} else {
break;
}
}
if char_before(trimmed, before) == Some(']') {
return None;
}
let id: String = trimmed
.get(open + 1..close)?
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(char::to_lowercase)
.collect();
let content = text.get(..open).unwrap_or(text).trim_end();
Some((content, id))
}
#[derive(Debug, Clone)]
enum Node {
Text(String),
Inline(Inline),
SoftBreak,
LineBreak,
Delimiter(Delimiter),
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
struct Delimiter {
ch: u8,
count: usize,
can_open: bool,
can_close: bool,
image: bool,
text_start: usize,
active: bool,
cite_count_at_open: i32,
}
enum Explicit {
Target(Target, usize),
Failed,
None,
}
fn resolve_inline_nodes(mut nodes: Vec<Node>, ext: Extensions, markdown: bool) -> Vec<Inline> {
if ext.contains(Extension::Mark) {
resolve_mark(&mut nodes, ext, markdown);
}
process_emphasis(&mut nodes, 0, ext, markdown);
collapse(nodes)
}
fn char_at(text: &str, at: usize) -> Option<char> {
text.get(at..).and_then(|rest| rest.chars().next())
}
fn char_before(text: &str, at: usize) -> Option<char> {
text.get(..at).and_then(|head| head.chars().next_back())
}
#[allow(clippy::similar_names)]
fn parse_inlines(text: &str, refs: &RefMap, notes: RefContext, ext: Extensions) -> Vec<Inline> {
let mut parser = InlineParser {
text,
pos: 0,
nodes: Vec::new(),
refs,
notes,
ext,
bracket_stack: Vec::new(),
interesting: interesting_chars(ext),
backtick_runs: None,
};
parser.run();
let mut inlines = resolve_inline_nodes(parser.nodes, ext, notes.markdown);
if ext.contains(Extension::Autolink) {
super::autolink::autolink_inlines(&mut inlines, notes.markdown);
}
if ext.contains(Extension::NativeSpans) {
inlines = pair_native_spans(inlines);
}
inlines
}
pub(crate) fn parse_meta_inlines(text: &str, ext: Extensions, markdown: bool) -> Vec<Inline> {
let defined = BTreeSet::new();
let by_id = BTreeMap::new();
let examples = ExampleMap::new();
let refs = RefMap::new();
let cite_count = Cell::new(0);
let notes = RefContext {
defined: &defined,
by_id: &by_id,
in_definition: false,
markdown,
examples: &examples,
cite_count: &cite_count,
};
parse_inlines(text, &refs, notes, ext)
}
struct InlineParser<'a> {
text: &'a str,
pos: usize,
nodes: Vec<Node>,
refs: &'a RefMap,
notes: RefContext<'a>,
ext: Extensions,
bracket_stack: Vec<usize>,
interesting: [bool; 128],
backtick_runs: Option<BTreeMap<usize, Vec<usize>>>,
}
fn interesting_chars(ext: Extensions) -> [bool; 128] {
let smart = ext.contains(Extension::Smart);
core::array::from_fn(|code| {
let Ok(byte) = u8::try_from(code) else {
return false;
};
match char::from(byte) {
'\\' | '`' | '<' | '&' | '\n' | '*' | '_' | '[' | ']' | '!' => true,
'$' => ext.contains(Extension::TexMathDollars),
'~' => ext.contains(Extension::Subscript) || ext.contains(Extension::Strikeout),
'^' => ext.contains(Extension::InlineNotes) || ext.contains(Extension::Superscript),
'=' => ext.contains(Extension::Mark),
'@' => ext.contains(Extension::ExampleLists) || ext.contains(Extension::Citations),
':' => ext.contains(Extension::Emoji),
'\'' | '"' | '-' | '.' => smart,
_ => false,
}
})
}
impl InlineParser<'_> {
fn peek(&self) -> Option<char> {
char_at(self.text, self.pos)
}
fn at(&self, offset: usize) -> Option<char> {
self.text
.get(self.pos..)
.and_then(|rest| rest.chars().nth(offset))
}
fn is_interesting(&self, ch: char) -> bool {
usize::try_from(u32::from(ch))
.ok()
.and_then(|code| self.interesting.get(code))
.copied()
.unwrap_or(false)
}
fn interesting_byte(&self, byte: u8) -> bool {
byte < 128
&& self
.interesting
.get(usize::from(byte))
.copied()
.unwrap_or(false)
}
fn run(&mut self) {
while let Some(ch) = self.peek() {
if !self.is_interesting(ch) {
let start = self.pos;
let bytes = self.text.as_bytes();
while let Some(&next) = bytes.get(self.pos) {
if self.interesting_byte(next) {
break;
}
self.pos += 1;
}
if let Some(run) = self.text.get(start..self.pos) {
self.push_str(run);
}
continue;
}
match ch {
'\\' => self.backslash(),
'`' => self.code_span(),
'$' if self.ext.contains(Extension::TexMathDollars) => self.dollar_math(),
'<' => self.left_angle(),
'&' => self.entity(),
'\n' => self.line_ending(),
'*' | '_' => self.emphasis_run(ch as u8),
'~' if self.ext.contains(Extension::Subscript)
&& self.ext.contains(Extension::ShortSubsuperscripts)
&& self.try_short_script('~') => {}
'~' if self.ext.contains(Extension::Subscript)
|| self.ext.contains(Extension::Strikeout) =>
{
self.emphasis_run(b'~');
}
'^' if self.ext.contains(Extension::InlineNotes)
&& self.at(1) == Some('[')
&& self.try_inline_note() => {}
'^' if self.ext.contains(Extension::Superscript)
&& self.ext.contains(Extension::ShortSubsuperscripts)
&& self.try_short_script('^') => {}
'^' if self.ext.contains(Extension::Superscript) => self.emphasis_run(b'^'),
'=' if self.ext.contains(Extension::Mark) => self.emphasis_run(b'='),
'@' if self.ext.contains(Extension::ExampleLists)
|| self.ext.contains(Extension::Citations) =>
{
self.at_sign();
}
':' if self.ext.contains(Extension::Emoji) && self.try_emoji() => {}
'\'' | '"' if self.ext.contains(Extension::Smart) => self.emphasis_run(ch as u8),
'-' if self.ext.contains(Extension::Smart) => self.smart_dash(),
'.' if self.ext.contains(Extension::Smart) => self.smart_ellipsis(),
'[' => {
self.pos += 1;
self.push_open_bracket(false);
}
'!' if self.at(1) == Some('[') => {
self.pos += 2;
self.push_open_bracket(true);
}
']' => self.close_bracket(),
_ => {
self.pos += ch.len_utf8();
self.push_text(ch);
}
}
}
}
fn push_text(&mut self, ch: char) {
if let Some(Node::Text(text)) = self.nodes.last_mut() {
text.push(ch);
} else {
self.nodes.push(Node::Text(ch.to_string()));
}
}
fn push_str(&mut self, value: &str) {
if let Some(Node::Text(text)) = self.nodes.last_mut() {
text.push_str(value);
} else {
self.nodes.push(Node::Text(value.to_owned()));
}
}
fn at_sign(&mut self) {
if self.ext.contains(Extension::ExampleLists) && self.try_example_ref() {
return;
}
if self.ext.contains(Extension::Citations) && self.try_bare_citation() {
return;
}
self.pos += 1;
self.push_text('@');
}
fn try_example_ref(&mut self) -> bool {
let name_start = self.pos + 1;
let mut end = name_start;
while matches!(
char_at(self.text, end),
Some('0'..='9' | 'a'..='z' | 'A'..='Z' | '-' | '_')
) {
end += 1;
}
if end == name_start {
return false;
}
let Some(label) = self.text.get(name_start..end) else {
return false;
};
if let Some(number) = self.notes.examples.get(label) {
self.pos = end;
self.push_str(&number.to_string());
return true;
}
false
}
fn try_bare_citation(&mut self) -> bool {
if matches!(char_before(self.text, self.pos), Some(c) if is_citation_word(c)) {
return false;
}
let Some((id, next)) = scan_citation_id(self.text, self.pos + 1) else {
return false;
};
let note_num = self.bump_cite_count();
self.pos = next;
let citation = Citation {
id: id.clone().into(),
prefix: Vec::new(),
suffix: Vec::new(),
mode: CitationMode::AuthorInText,
note_num,
hash: 0,
};
self.nodes.push(Node::Inline(Inline::Cite(
vec![citation],
vec![Inline::Str(format!("@{id}").into())],
)));
true
}
fn bump_cite_count(&self) -> i32 {
let next = self.notes.cite_count.get().saturating_add(1);
self.notes.cite_count.set(next);
next
}
fn try_emoji(&mut self) -> bool {
let name_start = self.pos + 1;
let mut index = name_start;
while matches!(
char_at(self.text, index),
Some('0'..='9' | 'a'..='z' | 'A'..='Z' | '_' | '+' | '-')
) {
index += 1;
}
if index == name_start || char_at(self.text, index) != Some(':') {
return false;
}
let Some(name) = self.text.get(name_start..index) else {
return false;
};
let Some(codepoints) = emoji::lookup(name) else {
return false;
};
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["emoji".into()],
attributes: vec![("data-emoji".into(), name.into())],
};
self.pos = index + 1;
self.nodes.push(Node::Inline(Inline::Span(
Box::new(attr),
vec![Inline::Str(codepoints.into())],
)));
true
}
fn try_short_script(&mut self, delimiter: char) -> bool {
let mut preceding = 0usize;
let mut behind = self.pos;
while let Some(ch) = char_before(self.text, behind) {
if ch.is_whitespace() {
break;
}
if ch == delimiter {
preceding += 1;
}
behind -= ch.len_utf8();
}
if preceding % 2 == 1 {
return false;
}
let mut ahead = self.pos + 1;
while let Some(ch) = char_at(self.text, ahead) {
if ch.is_whitespace() {
break;
}
if ch == delimiter {
return false;
}
ahead += ch.len_utf8();
}
let start = self.pos + 1;
let mut end = start;
while let Some(ch) = char_at(self.text, end) {
if ch.is_alphanumeric() {
end += ch.len_utf8();
} else {
break;
}
}
let content = match self.text.get(start..end) {
Some(slice) if !slice.is_empty() => slice,
_ => return false,
};
let inner = vec![Inline::Str(content.into())];
let node = if delimiter == '^' {
Inline::Superscript(inner)
} else {
Inline::Subscript(inner)
};
self.nodes.push(Node::Inline(node));
self.pos = end;
true
}
fn backslash(&mut self) {
if self.try_backslash_math() || self.try_raw_tex() {
return;
}
self.pos += 1;
let broad = !self.notes.markdown || self.ext.contains(Extension::AllSymbolsEscapable);
match self.peek() {
Some('\n')
if self.notes.markdown && !self.ext.contains(Extension::EscapedLineBreaks) =>
{
self.push_text('\\');
}
Some('\n') => {
self.pos += 1;
while matches!(self.peek(), Some(' ' | '\t')) {
self.pos += 1;
}
self.nodes.push(Node::LineBreak);
}
Some(' ') if self.notes.markdown && broad => {
self.pos += 1;
self.push_text('\u{a0}');
}
Some(ch) if broad && is_ascii_punctuation(ch) => {
self.pos += 1;
self.push_text(ch);
}
Some(ch) if is_classic_markdown_escapable(ch) => {
self.pos += 1;
self.push_text(ch);
}
_ => self.push_text('\\'),
}
}
fn try_backslash_math(&mut self) -> bool {
if self.ext.contains(Extension::TexMathDoubleBackslash)
&& char_at(self.text, self.pos) == Some('\\')
&& char_at(self.text, self.pos + 1) == Some('\\')
&& self.scan_backslash_math(2)
{
return true;
}
if self.ext.contains(Extension::TexMathSingleBackslash)
&& char_at(self.text, self.pos) == Some('\\')
&& self.scan_backslash_math(1)
{
return true;
}
false
}
fn scan_backslash_math(&mut self, slashes: usize) -> bool {
match crate::inline_scan::scan_backslash_math_bytes(self.text, self.pos, slashes) {
Some((math_type, content, next)) => {
self.pos = next;
self.nodes
.push(Node::Inline(Inline::Math(math_type, content.into())));
true
}
None => false,
}
}
fn try_raw_tex(&mut self) -> bool {
if !self.ext.contains(Extension::RawTex) {
return false;
}
if char_at(self.text, self.pos) != Some('\\') {
return false;
}
let mut i = self.pos + 1;
if !char_at(self.text, i).is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
i += 1;
let mut name_all_letters = true;
while let Some(ch) = char_at(self.text, i) {
if ch.is_ascii_alphabetic() {
i += 1;
} else if ch.is_ascii_digit() {
name_all_letters = false;
i += 1;
} else {
break;
}
}
let name = self.text.get(self.pos + 1..i);
if name == Some("begin") {
return self.try_raw_tex_environment(i);
}
if name == Some("end") {
return false;
}
let mut had_group = false;
loop {
match char_at(self.text, i) {
Some('{') => match self.scan_balanced_group(i, '{', '}') {
Some(end) => {
i = end;
had_group = true;
}
None => return false,
},
Some('[') => match self.scan_balanced_group(i, '[', ']') {
Some(end) => {
i = end;
had_group = true;
}
None => break,
},
_ => break,
}
}
if !had_group && name_all_letters {
while matches!(char_at(self.text, i), Some(' ' | '\t')) {
i += 1;
}
}
let source = match self.text.get(self.pos..i) {
Some(slice) => slice.to_owned(),
None => return false,
};
self.pos = i;
self.nodes.push(Node::Inline(Inline::RawInline(
carta_ast::Format("tex".into()),
source.into(),
)));
true
}
fn try_raw_tex_environment(&mut self, name_end: usize) -> bool {
if char_at(self.text, name_end) != Some('{') {
return false;
}
let Some(group_end) = self.scan_balanced_group(name_end, '{', '}') else {
return false;
};
let Some(env) = self.text.get(name_end + 1..group_end - 1) else {
return false;
};
let Some(end) = self.scan_environment_close(group_end, env) else {
return false;
};
let source = match self.text.get(self.pos..end) {
Some(slice) => slice.to_owned(),
None => return false,
};
self.pos = end;
self.nodes.push(Node::Inline(Inline::RawInline(
carta_ast::Format("tex".into()),
source.into(),
)));
true
}
fn scan_environment_close(&self, from: usize, env: &str) -> Option<usize> {
let mut depth = 1usize;
let mut i = from;
while let Some(ch) = char_at(self.text, i) {
if ch == '\\' {
if let Some(after) = self.match_environment_marker(i, "begin", env) {
depth += 1;
i = after;
continue;
}
if let Some(after) = self.match_environment_marker(i, "end", env) {
depth -= 1;
if depth == 0 {
return Some(after);
}
i = after;
continue;
}
}
i += ch.len_utf8();
}
None
}
fn match_environment_marker(&self, at: usize, keyword: &str, env: &str) -> Option<usize> {
let mut i = at;
if char_at(self.text, i) != Some('\\') {
return None;
}
i += 1;
for kc in keyword.chars() {
if char_at(self.text, i) != Some(kc) {
return None;
}
i += kc.len_utf8();
}
if char_at(self.text, i) != Some('{') {
return None;
}
i += 1;
for ec in env.chars() {
if char_at(self.text, i) != Some(ec) {
return None;
}
i += ec.len_utf8();
}
if char_at(self.text, i) != Some('}') {
return None;
}
Some(i + 1)
}
fn scan_balanced_group(&self, start: usize, open: char, close: char) -> Option<usize> {
let mut depth = 0usize;
let mut i = start;
while let Some(ch) = char_at(self.text, i) {
if ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
i += ch.len_utf8();
}
None
}
fn code_span(&mut self) {
let start = self.pos;
let open = backtick_run_len(self.text, self.pos);
self.pos += open;
if let Some(close) = self.next_backtick_run(open, self.pos) {
let content = self
.text
.get(self.pos..close)
.map(str::to_owned)
.unwrap_or_default();
self.pos = close + open;
if let Some((format, next)) = self.scan_raw_format() {
self.pos = next;
self.nodes.push(Node::Inline(Inline::RawInline(
carta_ast::Format(format.into()),
normalize_code(&content, self.notes.markdown).into(),
)));
return;
}
let attr = self.take_code_attr();
self.nodes.push(Node::Inline(Inline::Code(
Box::new(attr),
normalize_code(&content, self.notes.markdown).into(),
)));
return;
}
let literal = self
.text
.get(start..self.pos)
.map(str::to_owned)
.unwrap_or_default();
self.push_str(&literal);
}
fn next_backtick_run(&mut self, len: usize, from: usize) -> Option<usize> {
let text = self.text;
let runs = self.backtick_runs.get_or_insert_with(|| {
let mut index: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
let bytes = text.as_bytes();
let mut scan = 0;
while scan < bytes.len() {
if bytes.get(scan) == Some(&b'`') {
let run = backtick_run_len(text, scan);
index.entry(run).or_default().push(scan);
scan += run;
} else {
scan += 1;
}
}
index
});
let positions = runs.get(&len)?;
let at = positions.partition_point(|&p| p < from);
positions.get(at).copied()
}
fn dollar_math(&mut self) {
if self.at(1) == Some('$') {
if let Some((content, next)) =
crate::inline_scan::scan_display_math_bytes(self.text, self.pos)
{
self.pos = next;
self.nodes.push(Node::Inline(Inline::Math(
MathType::DisplayMath,
content.into(),
)));
return;
}
} else if let Some((content, next)) =
crate::inline_scan::scan_inline_math_bytes(self.text, self.pos)
{
self.pos = next;
self.nodes.push(Node::Inline(Inline::Math(
MathType::InlineMath,
content.into(),
)));
return;
}
self.pos += 1;
self.push_text('$');
}
fn left_angle(&mut self) {
if let Some((inline, next)) = scan_autolink(self.text, self.pos) {
self.pos = next;
let inline = if self.notes.markdown {
escape_link_destination(classify_angle_autolink(inline))
} else {
inline
};
self.nodes.push(Node::Inline(inline));
return;
}
if let Some((html, next)) = scan_html_tag(self.text, self.pos) {
self.pos = next;
if self.notes.markdown && !self.ext.contains(Extension::RawHtml) {
self.push_str(&html);
} else {
self.nodes.push(Node::Inline(Inline::RawInline(
carta_ast::Format("html".into()),
html.into(),
)));
}
return;
}
self.pos += 1;
self.push_text('<');
}
fn entity(&mut self) {
if let Some((decoded, next)) = scan_entity(self.text, self.pos) {
self.pos = next;
self.push_str(&decoded);
} else {
self.pos += 1;
self.push_text('&');
}
}
fn line_ending(&mut self) {
let hard = matches!(self.nodes.last(), Some(Node::Text(t)) if t.ends_with(" "));
let backslash_hard = matches!(self.nodes.last(), Some(Node::LineBreak));
if let Some(Node::Text(text)) = self.nodes.last_mut() {
let trimmed = text.trim_end_matches(' ').to_owned();
*text = trimmed;
if text.is_empty() {
self.nodes.pop();
}
}
self.pos += 1;
while matches!(self.peek(), Some(' ' | '\t')) {
self.pos += 1;
}
if hard || backslash_hard || self.ext.contains(Extension::HardLineBreaks) {
self.nodes.push(Node::LineBreak);
} else {
self.nodes.push(Node::SoftBreak);
}
}
fn emphasis_run(&mut self, ch: u8) {
let start = self.pos;
while self.peek() == Some(ch as char) {
self.pos += 1;
}
let count = self.pos - start;
let before = char_before(self.text, start);
let after = self.peek();
let relax_underscore =
self.notes.markdown && !self.ext.contains(Extension::IntrawordUnderscores);
let (can_open, can_close) = run_flanking(ch, before, after, relax_underscore);
self.nodes.push(Node::Delimiter(Delimiter {
ch,
count,
can_open,
can_close,
image: false,
text_start: self.pos,
active: false,
cite_count_at_open: 0,
}));
}
fn smart_dash(&mut self) {
let mut len = 0;
while self.peek() == Some('-') {
self.pos += 1;
len += 1;
}
if len == 1 {
self.push_text('-');
return;
}
let out = fold_dash_run(len);
self.push_str(&out);
}
fn smart_ellipsis(&mut self) {
let mut len = 0;
while self.peek() == Some('.') {
self.pos += 1;
len += 1;
}
let out = fold_ellipsis_run(len);
self.push_str(&out);
}
fn push_open_bracket(&mut self, image: bool) {
let node_index = self.nodes.len();
self.bracket_stack.push(node_index);
self.nodes.push(Node::Delimiter(Delimiter {
ch: b'[',
count: 1,
can_open: true,
can_close: false,
image,
text_start: self.pos,
active: true,
cite_count_at_open: self.notes.cite_count.get(),
}));
}
fn close_bracket(&mut self) {
self.pos += 1;
let Some(&opener_index) = self.bracket_stack.last() else {
self.push_text(']');
return;
};
let (is_image, is_active) = match self.nodes.get(opener_index) {
Some(Node::Delimiter(d)) => (d.image, d.active),
_ => (false, false),
};
if is_active
&& self.ext.contains(Extension::Footnotes)
&& self.try_footnote(opener_index, is_image)
{
return;
}
if is_active {
match self.resolve_explicit(opener_index) {
Explicit::Target(target, next) => {
self.finish_link(opener_index, is_image, target, next);
return;
}
Explicit::Failed => {
self.bracket_stack.pop();
self.literalize_bracket(opener_index);
self.push_text(']');
return;
}
Explicit::None => {}
}
}
if !is_image
&& self.ext.contains(Extension::BracketedSpans)
&& let Some((attr, next)) = self.scan_attr_block()
{
self.bracket_stack.pop();
self.pos = next;
self.build_span(opener_index, attr);
return;
}
if is_active {
let key = normalize_label(&self.raw_label(opener_index));
if let Some(target) = self.refs.get(&key).map(def_target) {
self.finish_link(opener_index, is_image, target, self.pos);
return;
}
}
if self.ext.contains(Extension::Citations)
&& self.try_bracket_citation(opener_index, is_image)
{
return;
}
self.bracket_stack.pop();
self.literalize_bracket(opener_index);
self.push_text(']');
}
fn try_bracket_citation(&mut self, opener_index: usize, is_image: bool) -> bool {
let raw = self.raw_label(opener_index);
let Some(segments) = split_citation_segments(&raw) else {
return false;
};
if let Some(Node::Delimiter(d)) = self.nodes.get(opener_index) {
self.notes.cite_count.set(d.cite_count_at_open);
}
self.bump_cite_count();
let mut citations = Vec::with_capacity(segments.len());
for segment in &segments {
let Some(entry) = self.parse_citation_entry(&raw, segment.clone()) else {
return false;
};
citations.push(entry);
}
let group_num = self.notes.cite_count.get();
for citation in &mut citations {
citation.note_num = group_num;
}
let fallback = citation_fallback_inlines(&format!("[{raw}]"));
self.nodes.truncate(opener_index);
self.bracket_stack.retain(|&ni| ni < opener_index);
if is_image {
self.push_text('!');
}
self.nodes
.push(Node::Inline(Inline::Cite(citations, fallback)));
true
}
fn parse_citation_entry(&self, raw: &str, range: std::ops::Range<usize>) -> Option<Citation> {
let key = find_citation_key(raw, range.clone())?;
let prefix_end = if key.suppress { key.dash } else { key.at };
let prefix_src = raw.get(range.start..prefix_end)?;
let suffix_src = raw.get(key.id_end..range.end)?;
let mode = if key.suppress {
CitationMode::SuppressAuthor
} else {
CitationMode::NormalCitation
};
Some(Citation {
id: key.id.into(),
prefix: parse_inlines(prefix_src.trim(), self.refs, self.notes, self.ext),
suffix: parse_inlines(suffix_src.trim_end(), self.refs, self.notes, self.ext),
mode,
note_num: 0,
hash: 0,
})
}
fn finish_link(&mut self, opener_index: usize, is_image: bool, target: Target, next: usize) {
self.bracket_stack.pop();
self.pos = next;
let attr = self.take_link_attr();
self.build_link(opener_index, is_image, target, attr);
if !is_image {
self.deactivate_earlier_brackets(opener_index);
}
}
fn scan_attr_block(&self) -> Option<(Attr, usize)> {
let (mut merged, mut next) = attr::parse_attributes_bytes(self.text, self.pos)?;
while let Some((more, after)) = attr::parse_attributes_bytes(self.text, next) {
attr::merge(&mut merged, more);
next = after;
}
attr::is_non_empty(&merged).then_some((merged, next))
}
fn scan_raw_format(&self) -> Option<(String, usize)> {
if !self.ext.contains(Extension::RawAttribute) {
return None;
}
if char_at(self.text, self.pos) != Some('{') {
return None;
}
let mut index = self.pos + 1;
while let Some(ch) = char_at(self.text, index) {
if ch == ' ' || ch == '\t' {
index += 1;
} else {
break;
}
}
if char_at(self.text, index) != Some('=') {
return None;
}
index += 1;
let format_start = index;
while let Some(ch) = char_at(self.text, index) {
if is_format_name_char(ch) {
index += ch.len_utf8();
} else {
break;
}
}
if index == format_start {
return None;
}
let format = self.text.get(format_start..index)?.to_owned();
while let Some(ch) = char_at(self.text, index) {
if ch == ' ' || ch == '\t' {
index += 1;
} else {
break;
}
}
if char_at(self.text, index) != Some('}') {
return None;
}
Some((format, index + 1))
}
fn take_code_attr(&mut self) -> Attr {
if (self.ext.contains(Extension::InlineCodeAttributes)
|| self.ext.contains(Extension::Attributes))
&& let Some((parsed, next)) = self.scan_attr_block()
{
self.pos = next;
return parsed;
}
Attr::default()
}
fn take_link_attr(&mut self) -> Attr {
if (self.ext.contains(Extension::LinkAttributes)
|| self.ext.contains(Extension::Attributes))
&& let Some((parsed, next)) = self.scan_attr_block()
{
self.pos = next;
return parsed;
}
Attr::default()
}
fn build_span(&mut self, opener_index: usize, attr: Attr) {
let inner: Vec<Node> = self.nodes.split_off(opener_index + 1);
self.nodes.pop(); self.bracket_stack.retain(|&ni| ni < opener_index);
let content = resolve_inline_nodes(inner, self.ext, self.notes.markdown);
self.nodes
.push(Node::Inline(Inline::Span(Box::new(attr), content)));
}
fn literalize_bracket(&mut self, opener_index: usize) {
if let Some(node) = self.nodes.get_mut(opener_index)
&& let Node::Delimiter(d) = node
{
let literal = if d.image { "![" } else { "[" };
*node = Node::Text(literal.to_owned());
}
}
fn deactivate_earlier_brackets(&mut self, before: usize) {
for &ni in &self.bracket_stack {
if ni >= before {
continue;
}
if let Some(Node::Delimiter(d)) = self.nodes.get_mut(ni)
&& !d.image
{
d.active = false;
}
}
}
fn resolve_explicit(&self, opener_index: usize) -> Explicit {
if self.at(0) == Some('(') {
let scanned = if self.notes.markdown {
scan_markdown_inline_target(self.text, self.pos)
} else {
scan_inline_target(self.text, self.pos)
};
if let Some((target, next)) = scanned {
return Explicit::Target(target, next);
}
}
let mut label_start = self.pos;
if self.ext.contains(Extension::SpacedReferenceLinks) {
while matches!(char_at(self.text, label_start), Some(' ' | '\t' | '\n')) {
label_start += 1;
}
}
if let Some((label, next)) = scan_following_label(self.text, label_start) {
let key = if label.is_empty() {
normalize_label(&self.raw_label(opener_index))
} else {
normalize_label(&label)
};
return match self.refs.get(&key).map(def_target) {
Some(target) => Explicit::Target(target, next),
None => Explicit::Failed,
};
}
Explicit::None
}
fn raw_label(&self, opener_index: usize) -> String {
let start = match self.nodes.get(opener_index) {
Some(Node::Delimiter(d)) => d.text_start,
_ => return String::new(),
};
self.text
.get(start..self.pos.saturating_sub(1))
.map(str::to_owned)
.unwrap_or_default()
}
fn try_footnote(&mut self, opener_index: usize, is_image: bool) -> bool {
let raw = self.raw_label(opener_index);
let Some(label) = raw.strip_prefix('^') else {
return false;
};
if label.is_empty() || label.contains('[') || label.contains(']') {
return false;
}
let key = normalize_label(label);
if !self.notes.defined.contains(&key) {
return false;
}
self.nodes.truncate(opener_index);
self.bracket_stack.retain(|&ni| ni < opener_index);
if is_image {
self.push_text('!');
}
let note = if self.notes.in_definition {
Inline::Str(carta_ast::Text::default())
} else {
Inline::Note(self.notes.by_id.get(&key).cloned().unwrap_or_default())
};
self.nodes.push(Node::Inline(note));
true
}
fn try_inline_note(&mut self) -> bool {
let mut depth = 0usize;
let mut index = self.pos + 1;
let mut end = None;
while let Some(ch) = char_at(self.text, index) {
match ch {
'\\' => index += 1 + char_at(self.text, index + 1).map_or(0, char::len_utf8),
'[' => {
depth += 1;
index += 1;
}
']' => {
depth -= 1;
index += 1;
if depth == 0 {
end = Some(index);
break;
}
}
_ => index += ch.len_utf8(),
}
}
let Some(end) = end else {
return false;
};
let inner = self
.text
.get(self.pos + 2..end.saturating_sub(1))
.map(str::to_owned)
.unwrap_or_default();
let inlines = parse_inlines(&inner, self.refs, self.notes, self.ext);
self.pos = end;
self.nodes
.push(Node::Inline(Inline::Note(vec![para(inlines)])));
true
}
fn build_link(&mut self, opener_index: usize, is_image: bool, mut target: Target, attr: Attr) {
if self.notes.markdown {
target.url = escape_uri(&target.url).into();
}
let inner: Vec<Node> = self.nodes.split_off(opener_index + 1);
self.nodes.pop(); self.bracket_stack.retain(|&ni| ni < opener_index);
let content = resolve_inline_nodes(inner, self.ext, self.notes.markdown);
let inline = if is_image {
Inline::Image(Box::new(attr), content, Box::new(target))
} else {
Inline::Link(Box::new(attr), content, Box::new(target))
};
self.nodes.push(Node::Inline(inline));
}
}
fn is_citation_word(ch: char) -> bool {
ch.is_alphanumeric()
}
fn citation_fallback_inlines(raw: &str) -> Vec<Inline> {
let mut out = Vec::new();
let mut word = String::new();
let mut had_space = false;
let mut had_newline = false;
let flush_word = |out: &mut Vec<Inline>, word: &mut String| {
if !word.is_empty() {
out.push(Inline::Str(std::mem::take(word).into()));
}
};
for ch in raw.chars() {
if ch.is_whitespace() {
flush_word(&mut out, &mut word);
had_space = true;
had_newline |= ch == '\n';
} else {
if had_space {
out.push(if had_newline {
Inline::SoftBreak
} else {
Inline::Space
});
had_space = false;
had_newline = false;
}
word.push(ch);
}
}
flush_word(&mut out, &mut word);
if had_space {
out.push(if had_newline {
Inline::SoftBreak
} else {
Inline::Space
});
}
out
}
fn is_citation_key_start(ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
fn scan_citation_id(text: &str, start: usize) -> Option<(String, usize)> {
let first = char_at(text, start)?;
if !is_citation_key_start(first) {
return None;
}
let mut end = start + first.len_utf8();
while let Some(ch) = char_at(text, end) {
if is_citation_key_start(ch) {
end += ch.len_utf8();
} else if matches!(ch, '-' | '.' | ':' | '/')
&& matches!(char_at(text, end + 1), Some(next) if is_citation_key_start(next))
{
end += 1 + char_at(text, end + 1).map_or(0, char::len_utf8);
} else {
break;
}
}
let id = text.get(start..end)?.to_owned();
Some((id, end))
}
fn step_citation_scan(text: &str, index: usize, depth: &mut usize) -> Option<usize> {
match char_at(text, index) {
Some('\\') => Some(index + 1 + char_at(text, index + 1).map_or(0, char::len_utf8)),
Some('`') => {
let run = backtick_run_len(text, index);
Some(skip_code_span(text, index, run))
}
Some('[') => {
*depth += 1;
Some(index + 1)
}
Some(']') => {
*depth = depth.saturating_sub(1);
Some(index + 1)
}
_ => None,
}
}
fn split_citation_segments(text: &str) -> Option<Vec<std::ops::Range<usize>>> {
if !text.contains('@') {
return None;
}
let mut segments = Vec::new();
let mut start = 0;
let mut index = 0;
let mut depth = 0usize;
while index < text.len() {
if let Some(next) = step_citation_scan(text, index, &mut depth) {
index = next;
} else if char_at(text, index) == Some(';') && depth == 0 {
segments.push(start..index);
start = index + 1;
index += 1;
} else {
index += char_at(text, index).map_or(1, char::len_utf8);
}
}
segments.push(start..text.len());
for segment in &segments {
if text
.get(segment.clone())
.is_none_or(|s| s.chars().all(char::is_whitespace))
{
return None;
}
}
Some(segments)
}
fn backtick_run_len(text: &str, index: usize) -> usize {
let bytes = text.as_bytes();
let mut len = 0;
while bytes.get(index + len) == Some(&b'`') {
len += 1;
}
len
}
fn skip_code_span(text: &str, index: usize, run: usize) -> usize {
let bytes = text.as_bytes();
let mut scan = index + run;
while scan < bytes.len() {
if bytes.get(scan) == Some(&b'`') {
let closer = backtick_run_len(text, scan);
if closer == run {
return scan + closer;
}
scan += closer;
} else {
scan += 1;
}
}
index + run
}
struct CitationKey {
at: usize,
dash: usize,
id: String,
id_end: usize,
suppress: bool,
}
fn find_citation_key(text: &str, range: std::ops::Range<usize>) -> Option<CitationKey> {
let mut index = range.start;
let mut depth = 0usize;
while index < range.end {
if let Some(next) = step_citation_scan(text, index, &mut depth) {
index = next;
continue;
}
if depth == 0
&& char_at(text, index) == Some('@')
&& let Some((id, id_end)) = scan_citation_id(text, index + 1)
{
let dash_before = index > range.start && char_before(text, index) == Some('-');
let dash_anchored = dash_before
&& (index - 1 == range.start
|| char_before(text, index - 1).is_some_and(char::is_whitespace));
let suppress = dash_anchored;
return Some(CitationKey {
at: index,
dash: if suppress { index - 1 } else { index },
id,
id_end,
suppress,
});
}
index += char_at(text, index).map_or(1, char::len_utf8);
}
None
}
fn def_target(def: &LinkDef) -> Target {
Target {
url: def.url.clone().into(),
title: def.title.clone().into(),
}
}
#[derive(Debug, Clone)]
struct DelimEntry {
node_index: usize,
ch: u8,
count: usize,
can_open: bool,
can_close: bool,
}
#[allow(clippy::similar_names, clippy::too_many_lines)]
fn process_emphasis(nodes: &mut Vec<Node>, stack_bottom: usize, ext: Extensions, markdown: bool) {
let mut delims: Vec<DelimEntry> = nodes
.iter()
.enumerate()
.skip(stack_bottom)
.filter_map(|(ni, node)| match node {
Node::Delimiter(d) if is_delimiter_char(d.ch) => Some(DelimEntry {
node_index: ni,
ch: d.ch,
count: d.count,
can_open: d.can_open,
can_close: d.can_close,
}),
_ => None,
})
.collect();
let mut openers_bottom = std::collections::BTreeMap::<(u8, usize, bool, bool), usize>::new();
let mut current = 0usize;
while current < delims.len() {
let Some(current_entry) = delims.get(current) else {
break;
};
let (closer_ch, closer_count, closer_can_open, closer_can_close) = (
current_entry.ch,
current_entry.count,
current_entry.can_open,
current_entry.can_close,
);
let closer_ni = current_entry.node_index;
if !closer_can_close {
current += 1;
continue;
}
let bucket = (
closer_ch,
closer_count % 3,
closer_can_open,
closer_count >= 2,
);
let bottom = *openers_bottom.get(&bucket).unwrap_or(&0);
let mut found: Option<usize> = None; let mut scan = current;
while scan > bottom {
scan -= 1;
let Some(entry) = delims.get(scan) else {
break;
};
if !entry.can_open || entry.ch != closer_ch {
continue;
}
if markdown && markdown_opener_inert(closer_ch, entry.count) {
continue;
}
let Some(use_count) =
match_use_count_md(entry.count, closer_count, closer_ch, ext, markdown)
else {
continue;
};
let ni = entry.node_index;
let rule_ok = match nodes.get(ni) {
Some(Node::Delimiter(d)) => emphasis_match(d, nodes, closer_ni),
_ => false,
};
if rule_ok {
if markdown
&& rejects_inner_space(closer_ch, use_count)
&& nodes.get(ni + 1..closer_ni).is_some_and(nodes_carry_break)
{
continue;
}
if markdown && markdown_emphasis_runs_mismatch(closer_ch, entry.count, closer_count)
{
continue;
}
found = Some(scan);
break;
}
}
let Some(opener_di) = found else {
openers_bottom.insert(bucket, current);
if !closer_can_open {
convert_delimiter_to_text(nodes, closer_ni);
}
current += 1;
continue;
};
let Some(opener_entry) = delims.get(opener_di) else {
break;
};
let (opener_ni, opener_count) = (opener_entry.node_index, opener_entry.count);
let use_count =
match_use_count_md(opener_count, closer_count, closer_ch, ext, markdown).unwrap_or(1);
let inner: Vec<Node> = nodes.drain(opener_ni + 1..closer_ni).collect();
let content = collapse(inner);
let wrapped = wrap_emphasis(closer_ch, use_count, content);
nodes.insert(opener_ni + 1, Node::Inline(wrapped));
let new_closer_ni = opener_ni + 2;
decrement_delimiter(nodes, new_closer_ni, use_count);
decrement_delimiter(nodes, opener_ni, use_count);
let new_closer_count = closer_count.saturating_sub(use_count);
let new_opener_count = opener_count.saturating_sub(use_count);
if let Some(e) = delims.get_mut(current) {
e.count = new_closer_count;
}
if let Some(e) = delims.get_mut(opener_di) {
e.count = new_opener_count;
}
let closer_empty = new_closer_count == 0;
let opener_empty = new_opener_count == 0;
if closer_empty {
nodes.remove(new_closer_ni);
}
if opener_empty {
nodes.remove(opener_ni);
}
let above_shift = 2_isize + (opener_ni.cast_signed() - closer_ni.cast_signed())
- isize::from(closer_empty)
- isize::from(opener_empty);
let final_closer_ni = opener_ni + 1 + usize::from(!opener_empty);
delims.drain(opener_di + 1..current);
let current_di_after = opener_di + 1;
if closer_empty {
delims.remove(current_di_after);
}
if opener_empty {
delims.remove(opener_di);
}
let first_after_di = match (opener_empty, closer_empty) {
(true, true) => opener_di,
(false, true) => opener_di + 1, (true, false) => {
if let Some(e) = delims.get_mut(opener_di) {
e.node_index = final_closer_ni;
}
opener_di + 1
}
(false, false) => {
if let Some(e) = delims.get_mut(opener_di + 1) {
e.node_index = final_closer_ni;
}
opener_di + 2
}
};
if above_shift != 0 {
for entry in delims.get_mut(first_after_di..).into_iter().flatten() {
entry.node_index =
usize::try_from(entry.node_index.cast_signed() + above_shift).unwrap_or(0);
}
}
let inner_drain = current - opener_di - 1;
let endpoint_removes = usize::from(closer_empty) + usize::from(opener_empty);
for v in openers_bottom.values_mut() {
if *v > opener_di && *v < current {
*v = opener_di;
} else if *v >= current {
*v = v.saturating_sub(inner_drain + endpoint_removes);
}
}
current = opener_di;
}
for entry in &delims {
convert_delimiter_to_text(nodes, entry.node_index);
}
}
fn resolve_mark(nodes: &mut Vec<Node>, ext: Extensions, markdown: bool) {
let mut current = 0usize;
while current < nodes.len() {
let is_closer = matches!(
nodes.get(current),
Some(Node::Delimiter(d)) if d.ch == b'=' && d.can_close && d.count >= 2
);
if !is_closer {
current += 1;
continue;
}
let mut opener = None;
for i in (0..current).rev() {
if matches!(
nodes.get(i),
Some(Node::Delimiter(d)) if d.ch == b'=' && d.can_open && d.count >= 2
) {
opener = Some(i);
break;
}
}
let Some(opener_ni) = opener else {
current += 1;
continue;
};
let inner: Vec<Node> = nodes.drain(opener_ni + 1..current).collect();
let mut inner = inner;
process_emphasis(&mut inner, 0, ext, markdown);
let content = collapse(inner);
let span = Inline::Span(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["mark".into()],
attributes: Vec::new(),
}),
content,
);
let closer_ni = opener_ni + 1;
nodes.insert(closer_ni, Node::Inline(span));
let closer_ni = opener_ni + 2;
consume_mark_side(nodes, closer_ni);
consume_mark_side(nodes, opener_ni);
current = opener_ni;
}
for i in 0..nodes.len() {
if matches!(nodes.get(i), Some(Node::Delimiter(d)) if d.ch == b'=') {
convert_delimiter_to_text(nodes, i);
}
}
}
fn consume_mark_side(nodes: &mut Vec<Node>, index: usize) {
let remainder = match nodes.get(index) {
Some(Node::Delimiter(d)) => d.count.saturating_sub(2),
_ => return,
};
if remainder == 0 {
nodes.remove(index);
} else if let Some(node) = nodes.get_mut(index) {
*node = Node::Text("=".repeat(remainder));
}
}
fn is_delimiter_char(ch: u8) -> bool {
matches!(ch, b'*' | b'_' | b'~' | b'^' | b'\'' | b'"' | b'=')
}
fn is_quote(ch: u8) -> bool {
matches!(ch, b'\'' | b'"')
}
fn run_flanking(
ch: u8,
before: Option<char>,
after: Option<char>,
relax_underscore: bool,
) -> (bool, bool) {
if is_quote(ch) {
quote_flanking(ch, before, after)
} else if ch == b'_' && relax_underscore {
flanking(b'*', before, after)
} else {
flanking(ch, before, after)
}
}
fn match_use_count(
opener_count: usize,
closer_count: usize,
ch: u8,
ext: Extensions,
) -> Option<usize> {
let both_at_least_two = opener_count >= 2 && closer_count >= 2;
match ch {
b'*' | b'_' => Some(if both_at_least_two { 2 } else { 1 }),
b'^' | b'\'' | b'"' => Some(1),
b'~' => {
if both_at_least_two && ext.contains(Extension::Strikeout) {
Some(2)
} else if ext.contains(Extension::Subscript) {
Some(1)
} else {
None
}
}
_ => None,
}
}
fn scan_markdown_inline_target(text: &str, pos: usize) -> Option<(Target, usize)> {
let mut index = pos + 1;
skip_target_whitespace(text, &mut index);
if char_at(text, index) == Some('<') {
return scan_inline_target(text, pos);
}
let mut url = String::new();
let mut title = String::new();
let mut depth: usize = 0;
loop {
match char_at(text, index) {
None => return None,
Some(')') if depth == 0 => {
index += 1;
break;
}
Some(')') => {
depth -= 1;
url.push(')');
index += 1;
}
Some('(') => {
depth += 1;
url.push('(');
index += 1;
}
Some('\\') if matches!(char_at(text, index + 1), Some(' ' | '\t')) => {
url.push_str("%20");
index += 2;
}
Some('\\') if char_at(text, index + 1).is_some_and(is_ascii_punctuation) => {
if let Some(next) = char_at(text, index + 1) {
url.push('\\');
url.push(next);
index += 1 + next.len_utf8();
}
}
Some(ch) if ch == ' ' || ch == '\t' => {
let mut after = index;
skip_target_whitespace(text, &mut after);
match char_at(text, after) {
Some(')') if depth == 0 => {
index = after;
}
Some('"' | '\'') if depth == 0 => {
let (parsed, mut close) = scan_target_title(text, after)?;
title = parsed;
skip_target_whitespace(text, &mut close);
if char_at(text, close) != Some(')') {
return None;
}
index = close + 1;
break;
}
Some(_) => {
url.push_str("%20");
index = after;
}
None => return None,
}
}
Some(ch) => {
url.push(ch);
index += ch.len_utf8();
}
}
}
Some((
Target {
url: unescape_string(&url).into(),
title: unescape_string(&title).into(),
},
index,
))
}
fn skip_target_whitespace(text: &str, index: &mut usize) {
while matches!(char_at(text, *index), Some(' ' | '\t')) {
*index += 1;
}
}
fn scan_target_title(text: &str, start: usize) -> Option<(String, usize)> {
let close = char_at(text, start)?;
if close != '"' && close != '\'' {
return None;
}
let mut index = start + 1;
let mut out = String::new();
while let Some(ch) = char_at(text, index) {
if ch == close {
return Some((out, index + 1));
}
if ch == '\\'
&& let Some(next) = char_at(text, index + 1)
&& is_ascii_punctuation(next)
{
out.push('\\');
out.push(next);
index += 1 + next.len_utf8();
continue;
}
out.push(ch);
index += ch.len_utf8();
}
None
}
fn classify_angle_autolink(inline: Inline) -> Inline {
let Inline::Link(mut attr, text, target) = inline else {
return inline;
};
let is_email = matches!(text.first(), Some(Inline::Str(shown)) if *shown != target.url);
attr.classes
.push(if is_email { "email" } else { "uri" }.into());
Inline::Link(attr, text, target)
}
fn escape_link_destination(inline: Inline) -> Inline {
let Inline::Link(attr, text, mut target) = inline else {
return inline;
};
target.url = escape_uri(&target.url).into();
Inline::Link(attr, text, target)
}
fn markdown_opener_inert(ch: u8, count: usize) -> bool {
matches!(ch, b'*' | b'_') && count > 3
}
fn markdown_emphasis_runs_mismatch(ch: u8, opener_count: usize, closer_count: usize) -> bool {
matches!(ch, b'*' | b'_')
&& ((opener_count == 1 && closer_count == 2) || (opener_count == 2 && closer_count == 1))
}
fn match_use_count_md(
opener_count: usize,
closer_count: usize,
ch: u8,
ext: Extensions,
markdown: bool,
) -> Option<usize> {
if markdown && matches!(ch, b'*' | b'_') && opener_count >= 3 && closer_count >= 3 {
return Some(1);
}
if markdown
&& ch == b'~'
&& ext.contains(Extension::Subscript)
&& opener_count == closer_count
&& opener_count >= 3
&& opener_count % 2 == 1
{
return Some(opener_count);
}
match_use_count(opener_count, closer_count, ch, ext)
}
fn wrap_emphasis(ch: u8, use_count: usize, content: Vec<Inline>) -> Inline {
match (ch, use_count) {
(b'\'', _) => Inline::Quoted(QuoteType::SingleQuote, content),
(b'"', _) => Inline::Quoted(QuoteType::DoubleQuote, content),
(b'~', 2) => Inline::Strikeout(content),
(b'~', _) => Inline::Subscript(content),
(b'^', _) => Inline::Superscript(content),
(_, 2) => Inline::Strong(content),
(_, _) => Inline::Emph(content),
}
}
fn rejects_inner_space(ch: u8, use_count: usize) -> bool {
ch == b'^' || (ch == b'~' && use_count == 1)
}
fn nodes_carry_break(nodes: &[Node]) -> bool {
nodes.iter().any(|node| match node {
Node::Text(text) => text.chars().any(|c| c == ' ' || c == '\t'),
Node::SoftBreak | Node::LineBreak => true,
Node::Inline(inline) => inline_carries_break(inline),
Node::Delimiter(_) => false,
})
}
fn inline_carries_break(inline: &Inline) -> bool {
match inline {
Inline::Space | Inline::SoftBreak | Inline::LineBreak => true,
Inline::Str(text) => text.chars().any(|c| c == ' ' || c == '\t'),
Inline::Emph(content)
| Inline::Underline(content)
| Inline::Strong(content)
| Inline::Strikeout(content)
| Inline::Superscript(content)
| Inline::Subscript(content)
| Inline::SmallCaps(content)
| Inline::Quoted(_, content)
| Inline::Cite(_, content)
| Inline::Link(_, content, _)
| Inline::Image(_, content, _)
| Inline::Span(_, content) => content.iter().any(inline_carries_break),
_ => false,
}
}
fn emphasis_match(opener: &Delimiter, nodes: &[Node], closer: usize) -> bool {
let Some(Node::Delimiter(closer_delim)) = nodes.get(closer) else {
return false;
};
let either_both =
(opener.can_open && opener.can_close) || (closer_delim.can_open && closer_delim.can_close);
if either_both {
let sum = opener.count + closer_delim.count;
if sum.is_multiple_of(3)
&& (!opener.count.is_multiple_of(3) || !closer_delim.count.is_multiple_of(3))
{
return false;
}
}
true
}
fn delimiter_literal(ch: u8, count: usize) -> String {
match ch {
b'\'' => "\u{2019}".repeat(count),
b'"' => "\u{201c}".repeat(count),
_ => std::iter::repeat_n(ch as char, count).collect(),
}
}
fn decrement_delimiter(nodes: &mut [Node], index: usize, by: usize) {
if let Some(Node::Delimiter(d)) = nodes.get_mut(index) {
d.count = d.count.saturating_sub(by);
}
}
fn convert_delimiter_to_text(nodes: &mut [Node], index: usize) {
if let Some(node) = nodes.get_mut(index)
&& let Node::Delimiter(d) = node
&& is_delimiter_char(d.ch)
{
*node = Node::Text(delimiter_literal(d.ch, d.count));
}
}
fn pair_native_spans(inlines: Vec<Inline>) -> Vec<Inline> {
let mut input = inlines.into_iter().peekable();
pair_spans_level(&mut input, false)
}
fn pair_spans_level(
input: &mut std::iter::Peekable<std::vec::IntoIter<Inline>>,
stop_at_close: bool,
) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::new();
while let Some(item) = input.peek() {
if let Inline::RawInline(format, text) = item
&& format.0 == "html"
{
match classify_span_tag(text) {
SpanTag::Open(attr) => {
let _ = input.next();
let inner = pair_spans_level(input, true);
if matches!(input.peek(), Some(Inline::RawInline(f, t))
if f.0 == "html" && matches!(classify_span_tag(t), SpanTag::Close))
{
let _ = input.next();
out.push(Inline::Span(Box::new(attr), inner));
} else {
out.push(Inline::RawInline(
carta_ast::Format("html".into()),
open_tag_raw(&attr).into(),
));
out.extend(inner);
}
continue;
}
SpanTag::Close if stop_at_close => break,
_ => {}
}
}
if let Some(next) = input.next() {
out.push(recurse_span_children(next));
}
}
out
}
fn recurse_span_children(inline: Inline) -> Inline {
match inline {
Inline::Emph(c) => Inline::Emph(pair_native_spans(c)),
Inline::Underline(c) => Inline::Underline(pair_native_spans(c)),
Inline::Strong(c) => Inline::Strong(pair_native_spans(c)),
Inline::Strikeout(c) => Inline::Strikeout(pair_native_spans(c)),
Inline::Superscript(c) => Inline::Superscript(pair_native_spans(c)),
Inline::Subscript(c) => Inline::Subscript(pair_native_spans(c)),
Inline::SmallCaps(c) => Inline::SmallCaps(pair_native_spans(c)),
Inline::Quoted(q, c) => Inline::Quoted(q, pair_native_spans(c)),
Inline::Cite(cites, c) => Inline::Cite(cites, pair_native_spans(c)),
Inline::Link(a, c, t) => Inline::Link(a, pair_native_spans(c), t),
Inline::Image(a, c, t) => Inline::Image(a, pair_native_spans(c), t),
Inline::Span(a, c) => Inline::Span(a, pair_native_spans(c)),
other => other,
}
}
enum SpanTag {
Open(Attr),
Close,
Other,
}
fn opens_span_tag(raw: &str) -> bool {
let Some(after_lt) = raw.strip_prefix('<') else {
return false;
};
let candidate = after_lt.strip_prefix('/').unwrap_or(after_lt);
candidate
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("span"))
}
fn classify_span_tag(raw: &str) -> SpanTag {
if !opens_span_tag(raw) {
return SpanTag::Other;
}
if char_at(raw, 1) == Some('/') {
let mut i = 2;
if !matches_name(raw, &mut i, "span") {
return SpanTag::Other;
}
while matches!(char_at(raw, i), Some(' ' | '\t' | '\n')) {
i += 1;
}
if char_at(raw, i) == Some('>') && i + 1 == raw.len() {
return SpanTag::Close;
}
return SpanTag::Other;
}
let mut i = 1;
if !matches_name(raw, &mut i, "span") {
return SpanTag::Other;
}
if matches!(char_at(raw, i), Some(c) if c.is_ascii_alphanumeric() || c == '-') {
return SpanTag::Other;
}
match parse_span_attributes(raw, i) {
Some(attr) => SpanTag::Open(attr),
None => SpanTag::Other,
}
}
fn matches_name(text: &str, i: &mut usize, name: &str) -> bool {
for (offset, expected) in name.chars().enumerate() {
match char_at(text, *i + offset) {
Some(c) if c.eq_ignore_ascii_case(&expected) => {}
_ => return false,
}
}
*i += name.len();
true
}
fn parse_span_attributes(text: &str, start: usize) -> Option<Attr> {
let mut attr = Attr::default();
let mut seen_class = false;
let mut i = start;
loop {
let ws_start = i;
while matches!(char_at(text, i), Some(' ' | '\t' | '\n')) {
i += 1;
}
match char_at(text, i) {
Some('>') if i + 1 == text.len() => return Some(attr),
Some('/') => return None,
_ => {}
}
if i == ws_start {
return None;
}
let name_start = i;
while matches!(
char_at(text, i),
Some(c) if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.')
) {
i += 1;
}
if i == name_start {
return None;
}
let name = text.get(name_start..i)?.to_owned();
let mut value = String::new();
let mut after = i;
while matches!(char_at(text, after), Some(' ' | '\t' | '\n')) {
after += 1;
}
if char_at(text, after) == Some('=') {
after += 1;
while matches!(char_at(text, after), Some(' ' | '\t' | '\n')) {
after += 1;
}
let (parsed, next) = read_attr_value(text, after)?;
value = parsed;
i = next;
} else {
i = after;
}
match name.as_str() {
"id" => {
if attr.id.is_empty() {
attr.id = value.into();
}
}
"class" => {
if !seen_class {
seen_class = true;
attr.classes = value.split_whitespace().map(Into::into).collect();
}
}
_ => attr.attributes.push((name.into(), value.into())),
}
}
}
fn read_attr_value(text: &str, start: usize) -> Option<(String, usize)> {
let quote = char_at(text, start);
if matches!(quote, Some('"' | '\'')) {
let quote = quote?;
let mut i = start + 1;
let mut out = String::new();
loop {
match char_at(text, i) {
Some(c) if c == quote => return Some((out, i + 1)),
Some('&') => {
if let Some((decoded, next)) = scan_entity(text, i) {
out.push_str(&decoded);
i = next;
} else {
out.push('&');
i += 1;
}
}
Some(c) => {
out.push(c);
i += c.len_utf8();
}
None => return None,
}
}
}
let mut i = start;
let mut out = String::new();
while let Some(c) = char_at(text, i) {
if matches!(c, ' ' | '\t' | '\n' | '"' | '\'' | '=' | '<' | '>' | '`') {
break;
}
if c == '&'
&& let Some((decoded, next)) = scan_entity(text, i)
{
out.push_str(&decoded);
i = next;
continue;
}
out.push(c);
i += c.len_utf8();
}
if out.is_empty() {
return None;
}
Some((out, i))
}
fn open_tag_raw(attr: &Attr) -> String {
let mut s = String::from("<span");
if !attr.id.is_empty() {
s.push_str(" id=\"");
s.push_str(&attr.id);
s.push('"');
}
if !attr.classes.is_empty() {
s.push_str(" class=\"");
s.push_str(&attr.classes.join(" "));
s.push('"');
}
for (k, v) in &attr.attributes {
s.push(' ');
s.push_str(k);
s.push_str("=\"");
s.push_str(v);
s.push('"');
}
s.push('>');
s
}
fn collapse(nodes: Vec<Node>) -> Vec<Inline> {
let mut text = String::new();
let mut out: Vec<Inline> = Vec::new();
let flush = |text: &mut String, out: &mut Vec<Inline>| {
if !text.is_empty() {
push_text_inlines(out, text);
text.clear();
}
};
for node in nodes {
match node {
Node::Text(t) => text.push_str(&t),
Node::Delimiter(d) => {
if d.image {
text.push('!');
}
text.push_str(&delimiter_literal(d.ch, d.count));
}
Node::Inline(inline) => {
flush(&mut text, &mut out);
out.push(inline);
}
Node::SoftBreak => {
flush(&mut text, &mut out);
out.push(Inline::SoftBreak);
}
Node::LineBreak => {
flush(&mut text, &mut out);
out.push(Inline::LineBreak);
}
}
}
flush(&mut text, &mut out);
out
}
fn push_text_inlines(out: &mut Vec<Inline>, text: &str) {
let mut chars = text.chars().peekable();
let mut word = Text::default();
while let Some(ch) = chars.next() {
if ch == ' ' {
if !word.is_empty() {
out.push(Inline::Str(std::mem::take(&mut word)));
}
while chars.peek() == Some(&' ') {
chars.next();
}
out.push(Inline::Space);
} else {
word.push(ch);
}
}
if !word.is_empty() {
out.push(Inline::Str(word));
}
}
fn flanking(ch: u8, before: Option<char>, after: Option<char>) -> (bool, bool) {
let before_ws = before.is_none_or(is_unicode_whitespace);
let after_ws = after.is_none_or(is_unicode_whitespace);
let before_punct = before.is_some_and(is_punctuation);
let after_punct = after.is_some_and(is_punctuation);
let left_flanking = !after_ws && (!after_punct || before_ws || before_punct);
let right_flanking = !before_ws && (!before_punct || after_ws || after_punct);
match ch {
b'_' => {
let can_open = left_flanking && (!right_flanking || before_punct);
let can_close = right_flanking && (!left_flanking || after_punct);
(can_open, can_close)
}
b'~' | b'^' => (!after_ws, !before_ws),
_ => (left_flanking, right_flanking),
}
}
fn quote_flanking(_ch: u8, before: Option<char>, after: Option<char>) -> (bool, bool) {
let before_ws = before.is_none_or(is_unicode_whitespace);
let after_ws = after.is_none_or(is_unicode_whitespace);
let before_punct = before.is_some_and(is_punctuation);
let after_punct = after.is_some_and(is_punctuation);
let before_alnum = before.is_some_and(char::is_alphanumeric);
let after_alnum = after.is_some_and(char::is_alphanumeric);
let left_flanking = !after_ws && (!after_punct || before_ws || before_punct);
let right_flanking = !before_ws && (!before_punct || after_ws || after_punct);
let can_open = left_flanking && !before_alnum;
let can_close = right_flanking && !after_alnum;
(can_open, can_close)
}
fn is_classic_markdown_escapable(ch: char) -> bool {
matches!(
ch,
'\\' | '`'
| '*'
| '_'
| '{'
| '}'
| '['
| ']'
| '('
| ')'
| '>'
| '#'
| '+'
| '-'
| '.'
| '!'
)
}
fn is_punctuation(ch: char) -> bool {
use unicode_general_category::GeneralCategory::{
ClosePunctuation, ConnectorPunctuation, CurrencySymbol, DashPunctuation, FinalPunctuation,
InitialPunctuation, MathSymbol, ModifierSymbol, OpenPunctuation, OtherPunctuation,
OtherSymbol,
};
if ch.is_ascii() {
return is_ascii_punctuation(ch);
}
matches!(
unicode_general_category::get_general_category(ch),
ConnectorPunctuation
| DashPunctuation
| OpenPunctuation
| ClosePunctuation
| InitialPunctuation
| FinalPunctuation
| OtherPunctuation
| MathSymbol
| CurrencySymbol
| ModifierSymbol
| OtherSymbol
)
}
fn normalize_code(content: &str, markdown: bool) -> String {
let collapsed: String = content
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect();
if markdown {
return collapsed.trim().to_owned();
}
let bytes = collapsed.as_bytes();
if collapsed.len() >= 2
&& bytes.first() == Some(&b' ')
&& bytes.last() == Some(&b' ')
&& !collapsed.chars().all(|c| c == ' ')
{
collapsed
.get(1..collapsed.len() - 1)
.unwrap_or("")
.to_owned()
} else {
collapsed
}
}
#[cfg(test)]
mod tests {
use super::{
TASK_CHECKED, TASK_UNCHECKED, delimiter_literal, emoji, flanking, fold_dash_run,
fold_ellipsis_run, interesting_chars, match_use_count, parse_meta_inlines, quote_flanking,
split_header_attr, task_marker_replacement,
};
use carta_ast::{Attr, Inline, Target};
use carta_core::{Extension, Extensions};
fn is_interesting(table: &[bool; 128], ch: char) -> bool {
usize::try_from(u32::from(ch))
.ok()
.and_then(|code| table.get(code))
.copied()
.unwrap_or(false)
}
#[test]
fn interesting_set_tracks_active_extensions() {
let base = interesting_chars(Extensions::empty());
assert!(is_interesting(&base, '*'));
assert!(is_interesting(&base, '['));
assert!(is_interesting(&base, '\n'));
assert!(!is_interesting(&base, 'q'));
assert!(!is_interesting(&base, '-'));
assert!(!is_interesting(&base, ':'));
assert!(!is_interesting(&base, 'é'));
let gated = interesting_chars(exts(&[Extension::Smart, Extension::Emoji]));
assert!(is_interesting(&gated, '-'));
assert!(is_interesting(&gated, ':'));
assert!(is_interesting(&gated, '*'));
assert!(!is_interesting(&gated, 'q'));
assert!(!is_interesting(&gated, 'é'));
}
fn exts(list: &[Extension]) -> Extensions {
Extensions::from_list(list)
}
fn emoji_span(name: &str, text: &str) -> Inline {
Inline::Span(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["emoji".to_owned().into()],
attributes: vec![("data-emoji".to_owned().into(), name.to_owned().into())],
}),
vec![Inline::Str(text.to_owned().into())],
)
}
#[test]
fn emoji_table_is_sorted_for_binary_search() {
let on = exts(&[Extension::Emoji]);
assert_eq!(emoji::lookup("smile"), Some("\u{1f604}"));
assert_eq!(emoji::lookup("+1"), Some("\u{1f44d}"));
assert_eq!(emoji::lookup("-1"), Some("\u{1f44e}"));
assert_eq!(emoji::lookup("heart"), Some("\u{2764}\u{fe0f}"));
assert_eq!(emoji::lookup("not_an_emoji_name"), None);
assert_eq!(
parse_meta_inlines(":rocket:", on, false),
vec![emoji_span("rocket", "\u{1f680}")]
);
}
#[test]
fn emoji_resolves_known_shortcodes() {
let on = exts(&[Extension::Emoji]);
assert_eq!(
parse_meta_inlines(":smile:", on, false),
vec![emoji_span("smile", "\u{1f604}")]
);
assert_eq!(
parse_meta_inlines(":+1:", on, false),
vec![emoji_span("+1", "\u{1f44d}")]
);
}
#[test]
fn emoji_unknown_name_stays_literal() {
let on = exts(&[Extension::Emoji]);
assert_eq!(
parse_meta_inlines(":unknown_xyz:", on, false),
vec![Inline::Str(":unknown_xyz:".to_owned().into())]
);
assert_eq!(
parse_meta_inlines("::", on, false),
vec![Inline::Str("::".to_owned().into())]
);
}
#[test]
fn emoji_requires_extension() {
let off = Extensions::empty();
assert_eq!(
parse_meta_inlines(":smile:", off, false),
vec![Inline::Str(":smile:".to_owned().into())]
);
}
fn mark_span(content: Vec<Inline>) -> Inline {
Inline::Span(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["mark".to_owned().into()],
attributes: Vec::new(),
}),
content,
)
}
#[test]
fn mark_resolves_inside_link_label() {
let on = exts(&[Extension::Mark]);
assert_eq!(
parse_meta_inlines("[==hi==](u)", on, false),
vec![Inline::Link(
Box::default(),
vec![mark_span(vec![Inline::Str("hi".to_owned().into())])],
Box::new(Target {
url: "u".to_owned().into(),
title: carta_ast::Text::default(),
}),
)]
);
}
#[test]
fn mark_resolves_inside_bracketed_span_label() {
let on = exts(&[Extension::Mark, Extension::BracketedSpans]);
let span_attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["x".to_owned().into()],
attributes: Vec::new(),
};
assert_eq!(
parse_meta_inlines("[a ==b== c]{.x}", on, false),
vec![Inline::Span(
Box::new(span_attr),
vec![
Inline::Str("a".to_owned().into()),
Inline::Space,
mark_span(vec![Inline::Str("b".to_owned().into())]),
Inline::Space,
Inline::Str("c".to_owned().into()),
],
)]
);
}
#[test]
fn mark_in_label_requires_extension() {
let off = Extensions::empty();
assert_eq!(
parse_meta_inlines("[==hi==](u)", off, false),
vec![Inline::Link(
Box::default(),
vec![Inline::Str("==hi==".to_owned().into())],
Box::new(Target {
url: "u".to_owned().into(),
title: carta_ast::Text::default(),
}),
)]
);
}
#[test]
fn header_attr_split_requires_extension_and_trailing_block() {
let on = exts(&[Extension::HeaderAttributes]);
let (content, attr) = split_header_attr("Title {#id .cls}", on);
assert_eq!(content, "Title");
assert_eq!(attr.id, "id");
assert_eq!(attr.classes, ["cls"]);
assert_eq!(split_header_attr("Title{#id}", on).0, "Title{#id}");
assert_eq!(split_header_attr("Title {}", on).0, "Title {}");
let (content, attr) = split_header_attr("Title {#id}", Extensions::empty());
assert_eq!(content, "Title {#id}");
assert!(attr.id.is_empty());
}
#[test]
fn mmd_header_identifier_split() {
let on = exts(&[Extension::MmdHeaderIdentifiers]);
let (content, attr) = split_header_attr("Heading [myid]", on);
assert_eq!(content, "Heading");
assert_eq!(attr.id, "myid");
assert_eq!(split_header_attr("Foo [My Id]", on).1.id, "myid");
let (content, attr) = split_header_attr("Foo[b]", on);
assert_eq!((content, attr.id.as_str()), ("Foo", "b"));
let (content, attr) = split_header_attr("Foo [a] bar [b]", on);
assert_eq!((content, attr.id.as_str()), ("Foo [a] bar", "b"));
assert_eq!(split_header_attr("See [ref][myid]", on).1.id, "");
assert_eq!(split_header_attr("Foo [a] [b]", on).1.id, "");
assert_eq!(
split_header_attr("Heading []", on),
("Heading", Attr::default())
);
assert_eq!(
split_header_attr("Heading [myid]", Extensions::empty()).0,
"Heading [myid]"
);
}
#[test]
fn subscript_superscript_flanking_anchors_only_on_whitespace() {
for ch in [b'~', b'^'] {
assert_eq!(flanking(ch, None, Some('a')), (true, false));
assert_eq!(flanking(ch, Some('a'), None), (false, true));
assert_eq!(flanking(ch, Some('.'), Some('a')), (true, true));
assert_eq!(flanking(ch, Some('a'), Some('!')), (true, true));
assert_eq!(flanking(ch, Some(' '), Some('a')), (true, false));
assert_eq!(flanking(ch, Some('a'), Some(' ')), (false, true));
}
}
#[test]
fn asterisk_flanking_keeps_full_rules() {
assert_eq!(flanking(b'*', Some('a'), Some('!')), (false, true));
assert_eq!(flanking(b'_', Some('a'), Some('b')), (false, false));
}
#[test]
fn use_count_maps_tilde_by_enabled_extension() {
let strike = exts(&[Extension::Strikeout]);
let sub = exts(&[Extension::Subscript]);
let both = exts(&[Extension::Strikeout, Extension::Subscript]);
assert_eq!(match_use_count(2, 2, b'~', strike), Some(2));
assert_eq!(match_use_count(2, 2, b'~', sub), Some(1));
assert_eq!(match_use_count(2, 2, b'~', both), Some(2));
assert_eq!(match_use_count(1, 2, b'~', strike), None);
assert_eq!(match_use_count(1, 2, b'~', sub), Some(1));
assert_eq!(match_use_count(2, 2, b'~', Extensions::empty()), None);
}
#[test]
fn use_count_for_caret_and_emphasis() {
assert_eq!(match_use_count(1, 1, b'^', Extensions::empty()), Some(1));
assert_eq!(match_use_count(3, 3, b'^', Extensions::empty()), Some(1));
assert_eq!(match_use_count(2, 2, b'*', Extensions::empty()), Some(2));
assert_eq!(match_use_count(1, 2, b'_', Extensions::empty()), Some(1));
}
#[test]
fn dash_runs_fold_em_heavy() {
let em = '\u{2014}';
let en = '\u{2013}';
assert_eq!(fold_dash_run(2), en.to_string());
assert_eq!(fold_dash_run(3), em.to_string());
assert_eq!(fold_dash_run(4), format!("{en}{en}"));
assert_eq!(fold_dash_run(6), format!("{em}{em}"));
assert_eq!(fold_dash_run(5), format!("{em}{en}"));
assert_eq!(fold_dash_run(7), format!("{em}{en}{en}"));
assert_eq!(fold_dash_run(11), format!("{em}{em}{em}{en}"));
assert_eq!(fold_dash_run(13), format!("{em}{em}{em}{en}{en}"));
assert_eq!(fold_dash_run(17), format!("{em}{em}{em}{em}{em}{en}"));
for len in 2..=40 {
let folded = fold_dash_run(len);
let width: usize = folded.chars().map(|c| if c == em { 3 } else { 2 }).sum();
assert_eq!(width, len, "len={len} folded={folded}");
}
}
#[test]
fn ellipsis_runs_fold_in_threes() {
assert_eq!(fold_ellipsis_run(0), "");
assert_eq!(fold_ellipsis_run(1), ".");
assert_eq!(fold_ellipsis_run(2), "..");
assert_eq!(fold_ellipsis_run(3), "\u{2026}");
assert_eq!(fold_ellipsis_run(4), "\u{2026}.");
assert_eq!(fold_ellipsis_run(7), "\u{2026}\u{2026}.");
}
#[test]
fn unmatched_smart_quotes_become_curly() {
assert_eq!(delimiter_literal(b'\'', 1), "\u{2019}");
assert_eq!(delimiter_literal(b'"', 1), "\u{201c}");
assert_eq!(delimiter_literal(b'\'', 2), "\u{2019}\u{2019}");
assert_eq!(delimiter_literal(b'*', 3), "***");
}
#[test]
fn quote_flanking_blocks_intraword_pairing() {
assert_eq!(quote_flanking(b'\'', Some('n'), Some('t')), (false, false));
assert_eq!(quote_flanking(b'"', Some(' '), Some('a')), (true, false));
assert_eq!(quote_flanking(b'"', Some('a'), Some(' ')), (false, true));
assert_eq!(quote_flanking(b'\'', Some('('), Some('a')), (true, false));
}
#[test]
fn task_marker_replacement_recognizes_only_bounded_markers() {
assert_eq!(
task_marker_replacement("[ ] todo").as_deref(),
Some(&*format!("{TASK_UNCHECKED} todo"))
);
assert_eq!(
task_marker_replacement("[x] done").as_deref(),
Some(&*format!("{TASK_CHECKED} done"))
);
assert_eq!(
task_marker_replacement("[X]").as_deref(),
Some(TASK_CHECKED)
);
assert_eq!(task_marker_replacement("[ ]todo"), None);
assert_eq!(task_marker_replacement("[y] no"), None);
assert_eq!(task_marker_replacement("plain"), None);
}
}
#[cfg(test)]
mod inline_tests {
use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use carta_ast::{Attr, Block, Citation, CitationMode, Inline, Target};
use super::{
ExampleMap, HeaderNumbering, HeaderParseCache, IrBlock, LinkDef, RefContext, RefMap,
gather_headers, heading_content_is_context_independent, parse_inlines, resolve_block,
};
use carta_core::{Extension, Extensions};
static NO_DEFINED: BTreeSet<String> = BTreeSet::new();
static NO_BY_ID: BTreeMap<String, Vec<Block>> = BTreeMap::new();
static NO_EXAMPLES: ExampleMap = BTreeMap::new();
fn no_notes() -> RefContext<'static> {
RefContext {
defined: &NO_DEFINED,
by_id: &NO_BY_ID,
in_definition: false,
markdown: false,
examples: &NO_EXAMPLES,
cite_count: Box::leak(Box::new(Cell::new(0))),
}
}
fn no_ext() -> Extensions {
Extensions::empty()
}
fn exts(list: &[Extension]) -> Extensions {
Extensions::from_list(list)
}
fn empty_refs() -> RefMap {
BTreeMap::new()
}
fn ref_map(entries: &[(&str, &str)]) -> RefMap {
let mut m = BTreeMap::new();
for (k, v) in entries {
m.insert(
k.to_string(),
LinkDef {
url: v.to_string(),
title: String::new(),
},
);
}
m
}
fn p(text: &str) -> Vec<Inline> {
parse_inlines(text, &empty_refs(), no_notes(), no_ext())
}
fn pe(text: &str, ext: Extensions) -> Vec<Inline> {
parse_inlines(text, &empty_refs(), no_notes(), ext)
}
fn md_notes() -> RefContext<'static> {
RefContext {
markdown: true,
..no_notes()
}
}
fn pm(text: &str, ext: Extensions) -> Vec<Inline> {
parse_inlines(text, &empty_refs(), md_notes(), ext)
}
fn str(s: &str) -> Inline {
Inline::Str(s.to_owned().into())
}
fn link(content: Vec<Inline>, url: &str) -> Inline {
Inline::Link(
Box::default(),
content,
Box::new(Target {
url: url.to_owned().into(),
title: carta_ast::Text::default(),
}),
)
}
fn image(alt: Vec<Inline>, url: &str) -> Inline {
Inline::Image(
Box::default(),
alt,
Box::new(Target {
url: url.to_owned().into(),
title: carta_ast::Text::default(),
}),
)
}
#[test]
fn nested_emphasis_and_strong() {
assert_eq!(
p("*a **b** c*"),
vec![Inline::Emph(vec![
str("a"),
Inline::Space,
Inline::Strong(vec![str("b")]),
Inline::Space,
str("c"),
])]
);
}
#[test]
fn mixed_asterisk_and_underscore() {
assert_eq!(
p("*a _b_ c*"),
vec![Inline::Emph(vec![
str("a"),
Inline::Space,
Inline::Emph(vec![str("b")]),
Inline::Space,
str("c"),
])]
);
}
#[test]
fn triple_asterisk_produces_emph_of_strong() {
assert_eq!(
p("***a***"),
vec![Inline::Emph(vec![Inline::Strong(vec![str("a")])])]
);
}
#[test]
fn rule_of_3_prevents_outer_strong() {
assert_eq!(p("**a*b**"), vec![Inline::Strong(vec![str("a*b")])]);
}
#[test]
fn rule_of_3_prevents_inner_strong() {
assert_eq!(p("*a**b*"), vec![Inline::Emph(vec![str("a**b")])]);
}
#[test]
fn unmatched_openers_become_literal() {
assert_eq!(p("*a"), vec![str("*a")]);
assert_eq!(p("a*"), vec![str("a*")]);
assert_eq!(p("**a*"), vec![str("*"), Inline::Emph(vec![str("a")])]);
}
#[test]
fn underscore_intraword_stays_literal() {
assert_eq!(p("a_b_c"), vec![str("a_b_c")]);
assert_eq!(p("_a_b"), vec![str("_a_b")]);
}
#[test]
fn emphasis_flanks_across_multi_byte_neighbors() {
assert_eq!(
p("α*β*γ"),
vec![str("α"), Inline::Emph(vec![str("β")]), str("γ")]
);
}
#[test]
fn emphasis_with_multi_byte_content_at_input_edges() {
assert_eq!(p("*β*"), vec![Inline::Emph(vec![str("β")])]);
}
#[test]
fn emphasis_between_emoji_neighbors() {
assert_eq!(
p("😀*a*😀"),
vec![str("😀"), Inline::Emph(vec![str("a")]), str("😀")]
);
}
#[test]
fn underscore_intraword_stays_literal_with_multi_byte_neighbors() {
assert_eq!(p("α_β_γ"), vec![str("α_β_γ")]);
}
#[test]
fn empty_input_parses_to_nothing() {
assert_eq!(p(""), Vec::new());
}
#[test]
fn inline_link_and_image() {
assert_eq!(p("[a](u)"), vec![link(vec![str("a")], "u")]);
assert_eq!(p(""), vec![image(vec![str("i")], "u")]);
}
#[test]
fn unmatched_image_opener_keeps_its_bang() {
assert_eq!(p("![x"), vec![str("![x")]);
assert_eq!(p("![[a]x"), vec![str("![[a]x")]);
}
#[test]
fn reference_link_with_and_without_ref() {
assert_eq!(p("[a][r]"), vec![str("[a][r]")]);
let refs = ref_map(&[("r", "http://r")]);
let result = parse_inlines("[a][r]", &refs, no_notes(), no_ext());
assert_eq!(result, vec![link(vec![str("a")], "http://r")]);
}
#[test]
fn spaced_reference_link_allows_whitespace_before_the_label() {
let refs = ref_map(&[("ref", "http://r"), ("text", "http://t")]);
let ext = exts(&[Extension::SpacedReferenceLinks]);
assert_eq!(
parse_inlines("[text] [ref]", &refs, no_notes(), ext),
vec![link(vec![str("text")], "http://r")]
);
assert_eq!(
parse_inlines("[text]\n[ref]", &refs, no_notes(), ext),
vec![link(vec![str("text")], "http://r")]
);
assert_eq!(
parse_inlines("[text] []", &refs, no_notes(), ext),
vec![link(vec![str("text")], "http://t")]
);
let only_text = ref_map(&[("text", "http://t")]);
assert_eq!(
parse_inlines("[text] [ref]", &only_text, no_notes(), ext),
vec![str("[text]"), Inline::Space, str("[ref]")]
);
assert_eq!(
parse_inlines("[text] [ref]", &refs, no_notes(), no_ext()),
vec![
link(vec![str("text")], "http://t"),
Inline::Space,
link(vec![str("ref")], "http://r"),
]
);
}
#[test]
fn nested_bracket_in_link_text() {
assert_eq!(p("[[a]](u)"), vec![link(vec![str("[a]")], "u")]);
}
#[test]
fn unmatched_brackets_are_literal() {
assert_eq!(p("]]]"), vec![str("]]]")]);
}
#[test]
fn link_suppresses_earlier_bracket_openers() {
assert_eq!(
p("[a [b](u) c](v)"),
vec![
str("[a"),
Inline::Space,
link(vec![str("b")], "u"),
Inline::Space,
str("c](v)"),
]
);
}
#[test]
fn emphasis_inside_link_text() {
assert_eq!(
p("[*a*](u)"),
vec![link(vec![Inline::Emph(vec![str("a")])], "u")]
);
}
#[test]
fn strikeout_double_tilde() {
assert_eq!(
pe("~~a~~", exts(&[Extension::Strikeout])),
vec![Inline::Strikeout(vec![str("a")])]
);
}
#[test]
fn subscript_single_tilde() {
assert_eq!(
pe("~a~", exts(&[Extension::Subscript])),
vec![Inline::Subscript(vec![str("a")])]
);
}
#[test]
fn superscript_caret() {
assert_eq!(
pe("^a^", exts(&[Extension::Superscript])),
vec![Inline::Superscript(vec![str("a")])]
);
}
#[test]
fn markdown_escaped_space_becomes_non_breaking() {
assert_eq!(
pm("a\\ b", exts(&[Extension::AllSymbolsEscapable])),
vec![str("a\u{a0}b")]
);
assert_eq!(
pm("a\\ b", no_ext()),
vec![str("a\\"), Inline::Space, str("b")]
);
assert_eq!(p("a\\ b"), vec![str("a\\"), Inline::Space, str("b")]);
}
#[test]
fn broad_escape_set_is_gated_on_all_symbols_escapable() {
let broad = exts(&[Extension::AllSymbolsEscapable]);
assert_eq!(pm("x\\|y", broad), vec![str("x|y")]);
assert_eq!(pm("x\\~y", broad), vec![str("x~y")]);
assert_eq!(pm("x\\<y", broad), vec![str("x<y")]);
assert_eq!(pm("x\\|y", no_ext()), vec![str("x\\|y")]);
assert_eq!(pm("x\\~y", no_ext()), vec![str("x\\~y")]);
assert_eq!(pm("x\\<y", no_ext()), vec![str("x\\<y")]);
assert_eq!(pm("x\\!y", no_ext()), vec![str("x!y")]);
assert_eq!(pm("x\\*y", no_ext()), vec![str("x*y")]);
assert_eq!(p("x\\|y"), vec![str("x|y")]);
}
#[test]
fn markdown_superscript_rejects_inner_space() {
let ext = exts(&[Extension::Superscript, Extension::AllSymbolsEscapable]);
assert_eq!(pm("^a b^", ext), vec![str("^a"), Inline::Space, str("b^")]);
assert_eq!(
pm("^a\\ b^", ext),
vec![Inline::Superscript(vec![str("a\u{a0}b")])]
);
assert_eq!(pm("^ab^", ext), vec![Inline::Superscript(vec![str("ab")])]);
}
#[test]
fn short_subsuperscripts_consume_an_alphanumeric_run() {
let ext = exts(&[
Extension::Superscript,
Extension::Subscript,
Extension::ShortSubsuperscripts,
]);
assert_eq!(
pm("x^2y", ext),
vec![str("x"), Inline::Superscript(vec![str("2y")])]
);
assert_eq!(
pm("H~2O", ext),
vec![str("H"), Inline::Subscript(vec![str("2O")])]
);
assert_eq!(
pm("x^2.5", ext),
vec![str("x"), Inline::Superscript(vec![str("2")]), str(".5")]
);
assert_eq!(
pm("a^b^c", ext),
vec![str("a"), Inline::Superscript(vec![str("b")]), str("c")]
);
assert_eq!(
pm("a^b^c^d", ext),
vec![
str("a"),
Inline::Superscript(vec![str("b")]),
str("c"),
Inline::Superscript(vec![str("d")]),
]
);
assert_eq!(pm("x^(2)", ext), vec![str("x^(2)")]);
assert_eq!(pm("foo^", ext), vec![str("foo^")]);
let off = exts(&[Extension::Superscript, Extension::Subscript]);
assert_eq!(pm("x^2y", off), vec![str("x^2y")]);
}
#[test]
fn markdown_subscript_rejects_inner_space_but_strikeout_allows_it() {
assert_eq!(
pm("~a b~", exts(&[Extension::Subscript])),
vec![str("~a"), Inline::Space, str("b~")]
);
assert_eq!(
pm("~~a b~~", exts(&[Extension::Strikeout])),
vec![Inline::Strikeout(vec![str("a"), Inline::Space, str("b")])]
);
}
#[test]
fn markdown_superscript_rejects_space_in_nested_span() {
let ext = exts(&[Extension::Superscript]);
assert_eq!(
pm("^*a b*^", ext),
vec![
str("^"),
Inline::Emph(vec![str("a"), Inline::Space, str("b")]),
str("^"),
]
);
}
#[test]
fn markdown_code_span_trims_surrounding_space() {
assert_eq!(pm("` a `", no_ext()), vec![code("a")]);
assert_eq!(p("` a `"), vec![code("a")]);
assert_eq!(p("` a `"), vec![code(" a ")]);
}
#[test]
fn inline_note_parses_bracket_content_as_paragraph() {
assert_eq!(
pe("x^[a *b*] y", exts(&[Extension::InlineNotes])),
vec![
str("x"),
Inline::Note(vec![Block::Para(vec![
str("a"),
Inline::Space,
Inline::Emph(vec![str("b")]),
])]),
Inline::Space,
str("y"),
]
);
}
#[test]
fn inline_note_allows_nested_brackets() {
assert_eq!(
pe("^[outer [inner] end]", exts(&[Extension::InlineNotes])),
vec![Inline::Note(vec![Block::Para(vec![
str("outer"),
Inline::Space,
str("[inner]"),
Inline::Space,
str("end"),
])])]
);
}
#[test]
fn empty_inline_note_is_an_empty_paragraph() {
assert_eq!(
pe("^[]", exts(&[Extension::InlineNotes])),
vec![Inline::Note(vec![Block::Para(vec![])])]
);
}
#[test]
fn unclosed_inline_note_stays_literal() {
assert_eq!(
pe("^[unclosed", exts(&[Extension::InlineNotes])),
vec![str("^[unclosed")]
);
}
#[test]
fn inline_note_syntax_is_literal_when_extension_off() {
assert_eq!(
pe("x^[a] y", Extensions::empty()),
vec![str("x^[a]"), Inline::Space, str("y")]
);
}
#[test]
fn inline_note_wins_over_superscript_for_bracket() {
assert_eq!(
pe(
"y^[n]",
exts(&[Extension::InlineNotes, Extension::Superscript])
),
vec![str("y"), Inline::Note(vec![Block::Para(vec![str("n")])])]
);
}
#[test]
fn double_tilde_with_subscript_only_becomes_nested_subscript() {
assert_eq!(
pe("~~a~~", exts(&[Extension::Subscript])),
vec![Inline::Subscript(vec![Inline::Subscript(vec![str("a")])])]
);
}
#[test]
fn single_tilde_skipped_when_strikeout_only() {
assert_eq!(
pe("~a~~b~~", exts(&[Extension::Strikeout])),
vec![str("~a"), Inline::Strikeout(vec![str("b")])]
);
}
#[test]
fn unmatched_tilde_run_stays_literal_when_strikeout_only() {
assert_eq!(pe("~~a~", exts(&[Extension::Strikeout])), vec![str("~~a~")]);
}
#[test]
fn mixed_asterisk_and_strikeout() {
assert_eq!(
pe("*a ~~b~~ c*", exts(&[Extension::Strikeout])),
vec![Inline::Emph(vec![
str("a"),
Inline::Space,
Inline::Strikeout(vec![str("b")]),
Inline::Space,
str("c"),
])]
);
}
fn math_inline(content: &str) -> Inline {
Inline::Math(carta_ast::MathType::InlineMath, content.to_owned().into())
}
fn math_display(content: &str) -> Inline {
Inline::Math(carta_ast::MathType::DisplayMath, content.to_owned().into())
}
fn math() -> Extensions {
exts(&[Extension::TexMathDollars])
}
#[test]
fn inline_and_display_math() {
assert_eq!(pe("$a+b$", math()), vec![math_inline("a+b")]);
assert_eq!(pe("$$x=y$$", math()), vec![math_display("x=y")]);
assert_eq!(pe("$$ x $$", math()), vec![math_display(" x ")]);
}
#[test]
fn dollar_amounts_are_not_math() {
assert_eq!(
pe("$5 and $10", math()),
vec![
str("$5"),
Inline::Space,
str("and"),
Inline::Space,
str("$10")
]
);
assert_eq!(pe("$a$5", math()), vec![str("$a$5")]);
assert_eq!(pe("$ a$", math()), vec![str("$"), Inline::Space, str("a$")]);
}
#[test]
fn math_content_is_verbatim_but_honors_backslash_escape() {
assert_eq!(pe("$x_1*y*$", math()), vec![math_inline("x_1*y*")]);
assert_eq!(pe(r"$a\$b$", math()), vec![math_inline(r"a\$b")]);
}
#[test]
fn failed_display_falls_back_to_inline() {
assert_eq!(pe("$$x$", math()), vec![str("$"), math_inline("x")]);
}
#[test]
fn dollar_is_literal_without_the_extension() {
assert_eq!(p("$a+b$"), vec![str("$a+b$")]);
}
fn span(attr: Attr, content: Vec<Inline>) -> Inline {
Inline::Span(Box::new(attr), content)
}
fn attr(id: &str, classes: &[&str], kv: &[(&str, &str)]) -> Attr {
Attr {
id: id.to_owned().into(),
classes: classes.iter().map(|c| (*c).into()).collect(),
attributes: kv.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn attrs() -> Extensions {
exts(&[Extension::Attributes])
}
#[test]
fn bracketed_span_carries_attributes() {
assert_eq!(
pe("[text]{.cls #id}", exts(&[Extension::BracketedSpans])),
vec![span(attr("id", &["cls"], &[]), vec![str("text")])]
);
}
#[test]
fn empty_attribute_block_is_not_a_span() {
assert_eq!(
pe("[text]{}", exts(&[Extension::BracketedSpans])),
vec![str("[text]{}")]
);
}
#[test]
fn consecutive_attribute_blocks_merge_first_id_wins() {
assert_eq!(
pe(
"[x]{#one .a}{#two .b k=v}",
exts(&[Extension::BracketedSpans])
),
vec![span(
attr("one", &["a", "b"], &[("k", "v")]),
vec![str("x")]
)]
);
}
#[test]
fn span_wins_over_shortcut_reference() {
let refs = ref_map(&[("text", "http://r")]);
let ext = exts(&[Extension::BracketedSpans]);
assert_eq!(
parse_inlines("[text]{.c}", &refs, no_notes(), ext),
vec![span(attr("", &["c"], &[]), vec![str("text")])]
);
}
#[test]
fn inline_code_takes_attributes() {
assert_eq!(
pe("`code`{.rust #x}", attrs()),
vec![Inline::Code(
Box::new(attr("x", &["rust"], &[])),
"code".to_owned().into()
)]
);
assert_eq!(
pe("`code` x", attrs()),
vec![
Inline::Code(Box::default(), "code".to_owned().into()),
Inline::Space,
str("x")
]
);
}
#[test]
fn link_and_image_take_attributes() {
let link_with_attr = Inline::Link(
Box::new(attr("home", &["external"], &[])),
vec![str("t")],
Box::new(Target {
url: "u".to_owned().into(),
title: carta_ast::Text::default(),
}),
);
assert_eq!(pe("[t](u){.external #home}", attrs()), vec![link_with_attr]);
let image_with_attr = Inline::Image(
Box::new(attr("", &[], &[("width", "200")])),
vec![str("a")],
Box::new(Target {
url: "i".to_owned().into(),
title: carta_ast::Text::default(),
}),
);
assert_eq!(pe("{width=200}", attrs()), vec![image_with_attr]);
}
#[test]
fn attributes_require_the_extension() {
assert_eq!(p("[text]{.cls}"), vec![str("[text]{.cls}")]);
}
#[test]
fn nested_image_with_inner_link_and_deactivated_bracket() {
assert_eq!(
p("](uri2)](uri3)"),
vec![image(
vec![str("["), link(vec![str("foo")], "uri1"), str("](uri2)"),],
"uri3",
)]
);
}
fn raw(format: &str, text: &str) -> Inline {
Inline::RawInline(
carta_ast::Format(format.to_owned().into()),
text.to_owned().into(),
)
}
fn code(text: &str) -> Inline {
Inline::Code(Box::default(), text.to_owned().into())
}
#[test]
fn raw_attribute_turns_code_span_into_raw_inline() {
let ext = exts(&[Extension::RawAttribute]);
assert_eq!(pe("`<b>`{=html}", ext), vec![raw("html", "<b>")]);
assert_eq!(pe("`\\x`{=latex}", ext), vec![raw("latex", "\\x")]);
}
#[test]
fn raw_attribute_format_token_allows_word_chars_dash_underscore() {
let ext = exts(&[Extension::RawAttribute]);
assert_eq!(pe("`x`{=my-format}", ext), vec![raw("my-format", "x")]);
assert_eq!(pe("`x`{=my_fmt}", ext), vec![raw("my_fmt", "x")]);
assert_eq!(pe("`x`{=3d}", ext), vec![raw("3d", "x")]);
}
#[test]
fn raw_attribute_tolerates_whitespace_around_marker() {
let ext = exts(&[Extension::RawAttribute]);
assert_eq!(pe("`x`{ =html }", ext), vec![raw("html", "x")]);
assert_eq!(pe("`x`{=html }", ext), vec![raw("html", "x")]);
assert_eq!(pe("`x`{ =html}", ext), vec![raw("html", "x")]);
}
#[test]
fn raw_attribute_normalizes_code_content() {
let ext = exts(&[Extension::RawAttribute]);
assert_eq!(pe("` x `{=html}", ext), vec![raw("html", "x")]);
}
#[test]
fn raw_attribute_requires_a_pure_format_marker() {
let ext = exts(&[Extension::RawAttribute]);
assert_eq!(
pe("`x`{= html}", ext),
vec![code("x"), str("{="), Inline::Space, str("html}"),]
);
assert_eq!(pe("`x`{=}", ext), vec![code("x"), str("{=}")]);
assert_eq!(pe("`x`{=a.b}", ext), vec![code("x"), str("{=a.b}")]);
}
#[test]
fn plain_attribute_block_on_code_span_is_not_raw() {
let ext = exts(&[Extension::RawAttribute, Extension::InlineCodeAttributes]);
assert_eq!(
pe("`x`{.c}", ext),
vec![Inline::Code(
Box::new(Attr {
classes: vec!["c".to_owned().into()],
..Attr::default()
}),
"x".to_owned().into()
)]
);
}
#[test]
fn raw_attribute_off_leaves_marker_literal() {
assert_eq!(p("`<b>`{=html}"), vec![code("<b>"), str("{=html}")]);
}
#[test]
fn code_span_matches_equal_length_closer() {
assert_eq!(p("`a`"), vec![code("a")]);
}
#[test]
fn code_span_with_no_closer_stays_literal() {
assert_eq!(p("`a"), vec![str("`a")]);
}
#[test]
fn code_span_failed_search_does_not_mask_a_different_length_match() {
assert_eq!(p("`a ``b``"), vec![str("`a"), Inline::Space, code("b")]);
}
#[test]
fn code_span_opener_is_a_run_suffix_stays_literal() {
assert_eq!(
p("\\``` x \\``` x"),
vec![
str("```"),
Inline::Space,
str("x"),
Inline::Space,
str("```"),
Inline::Space,
str("x"),
]
);
}
#[test]
fn code_span_distinct_run_lengths_all_resolve() {
assert_eq!(
p("`a ``b ```c"),
vec![
str("`a"),
Inline::Space,
str("``b"),
Inline::Space,
str("```c"),
]
);
}
#[test]
fn code_span_close_before_cursor_is_not_reused() {
assert_eq!(p("`a` `b`"), vec![code("a"), Inline::Space, code("b")]);
}
#[test]
fn code_span_runs_at_buffer_ends_match() {
assert_eq!(p("``a``"), vec![code("a")]);
}
#[test]
fn code_span_index_matches_scan_on_tricky_buffers() {
assert_eq!(p("``x`y``"), vec![code("x`y")]);
assert_eq!(p("`a ``b`` c`"), vec![code("a ``b`` c")]);
assert_eq!(p("``a` b``"), vec![code("a` b")]);
}
fn tex(source: &str) -> Inline {
Inline::RawInline(
carta_ast::Format("tex".to_owned().into()),
source.to_owned().into(),
)
}
fn raw_tex() -> Extensions {
exts(&[Extension::RawTex])
}
fn single_math() -> Extensions {
exts(&[Extension::TexMathSingleBackslash])
}
fn double_math() -> Extensions {
exts(&[Extension::TexMathDoubleBackslash])
}
#[test]
fn raw_tex_commands_with_argument_groups() {
assert_eq!(
pe(r"\textbf{b}\emph{c}", raw_tex()),
vec![tex(r"\textbf{b}"), tex(r"\emph{c}")]
);
assert_eq!(pe(r"\sqrt[3]{8}", raw_tex()), vec![tex(r"\sqrt[3]{8}")]);
assert_eq!(pe(r"\foo{a{b}c}", raw_tex()), vec![tex(r"\foo{a{b}c}")]);
}
#[test]
fn raw_tex_bare_command_absorbs_trailing_blanks() {
assert_eq!(pe(r"\alpha y", raw_tex()), vec![tex(r"\alpha "), str("y")]);
assert_eq!(
pe(r"\foo{a} y", raw_tex()),
vec![tex(r"\foo{a}"), Inline::Space, str("y")]
);
assert_eq!(
pe(r"\foo1 y", raw_tex()),
vec![tex(r"\foo1"), Inline::Space, str("y")]
);
assert_eq!(pe(r"\1foo", raw_tex()), vec![str(r"\1foo")]);
}
#[test]
fn raw_tex_unbalanced_brace_reverts_whole_command() {
assert_eq!(
pe(r"\foo{a y", raw_tex()),
vec![str(r"\foo{a"), Inline::Space, str("y")]
);
assert_eq!(
pe(r"\foo[a y", raw_tex()),
vec![tex(r"\foo"), str("[a"), Inline::Space, str("y")]
);
}
#[test]
fn raw_tex_off_leaves_escape_behavior() {
assert_eq!(p(r"\textbf{b}"), vec![str(r"\textbf{b}")]);
assert_eq!(pe(r"\*", raw_tex()), vec![str("*")]);
}
#[test]
fn raw_tex_environment_captured_as_one_inline() {
assert_eq!(
pe("\\begin{equation}\nx\n\\end{equation}", raw_tex()),
vec![tex("\\begin{equation}\nx\n\\end{equation}")]
);
assert_eq!(
pe(r"a \begin{eq} z \end{eq} b", raw_tex()),
vec![
str("a"),
Inline::Space,
tex(r"\begin{eq} z \end{eq}"),
Inline::Space,
str("b"),
]
);
assert_eq!(
pe(r"\begin{equation*} x \end{equation*}", raw_tex()),
vec![tex(r"\begin{equation*} x \end{equation*}")]
);
}
#[test]
fn raw_tex_environment_balances_nested_begins() {
assert_eq!(
pe(r"\begin{eq}\begin{eq}a\end{eq}\end{eq}", raw_tex()),
vec![tex(r"\begin{eq}\begin{eq}a\end{eq}\end{eq}")]
);
assert_eq!(
pe(
r"\begin{align}\begin{matrix}a\end{matrix}\end{align}",
raw_tex()
),
vec![tex(r"\begin{align}\begin{matrix}a\end{matrix}\end{align}")]
);
}
#[test]
fn raw_tex_unmatched_environment_reverts_to_text() {
assert_eq!(
pe("\\begin{equation}\nx", raw_tex()),
vec![str(r"\begin{equation}"), Inline::SoftBreak, str("x")]
);
assert_eq!(
pe(r"\begin x", raw_tex()),
vec![str(r"\begin"), Inline::Space, str("x")]
);
assert_eq!(
pe(r"\end{equation}", raw_tex()),
vec![str(r"\end{equation}")]
);
assert_eq!(
pe(r"\begin{equation} x \end{align}", raw_tex()),
vec![
str(r"\begin{equation}"),
Inline::Space,
str("x"),
Inline::Space,
str(r"\end{align}"),
]
);
}
#[test]
fn single_backslash_math() {
assert_eq!(
pe(r"\(x\) \[y\]", single_math()),
vec![math_inline("x"), Inline::Space, math_display("y")]
);
assert_eq!(pe(r"\( x \)", single_math()), vec![math_inline("x")]);
assert_eq!(
pe(r"\[ x = y \]", single_math()),
vec![math_display(" x = y ")]
);
}
#[test]
fn single_backslash_math_empty_and_unclosed_fall_back() {
assert_eq!(pe(r"\(\)", single_math()), vec![str("()")]);
assert_eq!(pe(r"\(x", single_math()), vec![str("(x")]);
assert_eq!(pe(r"\( \)", single_math()), vec![math_inline("")]);
}
#[test]
fn single_backslash_math_escapes_inside_content() {
assert_eq!(pe(r"\(a\\)b\)", single_math()), vec![math_inline(r"a\\)b")]);
}
#[test]
fn double_backslash_math() {
assert_eq!(
pe(r"\\(x\\) \\[y\\]", double_math()),
vec![math_inline("x"), Inline::Space, math_display("y")]
);
}
#[test]
fn backslash_math_off_leaves_escape_behavior() {
assert_eq!(p(r"\(x\)"), vec![str("(x)")]);
}
fn native() -> Extensions {
exts(&[Extension::NativeSpans])
}
#[test]
fn native_span_carries_id_class_and_pairs() {
assert_eq!(
pe(
r#"<span id="i" class="a b" data-x="y">hi *there*</span>"#,
native()
),
vec![span(
attr("i", &["a", "b"], &[("data-x", "y")]),
vec![str("hi"), Inline::Space, Inline::Emph(vec![str("there")])]
)]
);
}
#[test]
fn native_span_without_attributes() {
assert_eq!(
pe("a <span>x</span> b", native()),
vec![
str("a"),
Inline::Space,
span(attr("", &[], &[]), vec![str("x")]),
Inline::Space,
str("b"),
]
);
}
#[test]
fn native_span_empty_content() {
assert_eq!(
pe("<span></span>", native()),
vec![span(attr("", &[], &[]), vec![])]
);
}
#[test]
fn native_span_nests_innermost_first() {
assert_eq!(
pe(
r#"<span class="o"><span class="i">x</span></span>"#,
native()
),
vec![span(
attr("", &["o"], &[]),
vec![span(attr("", &["i"], &[]), vec![str("x")])]
)]
);
}
#[test]
fn native_span_tag_name_is_case_insensitive() {
assert_eq!(
pe(r#"<SPAN class="a">x</SPAN>"#, native()),
vec![span(attr("", &["a"], &[]), vec![str("x")])]
);
}
#[test]
fn native_span_keeps_non_span_tags_raw() {
assert_eq!(
pe(r#"<span class="a">x <b>y</b></span>"#, native()),
vec![span(
attr("", &["a"], &[]),
vec![
str("x"),
Inline::Space,
raw("html", "<b>"),
str("y"),
raw("html", "</b>"),
]
)]
);
}
#[test]
fn native_span_attribute_values_and_booleans() {
assert_eq!(
pe("<span data-x='y z'>q</span>", native()),
vec![span(attr("", &[], &[("data-x", "y z")]), vec![str("q")])]
);
assert_eq!(
pe("<span flag>q</span>", native()),
vec![span(attr("", &[], &[("flag", "")]), vec![str("q")])]
);
assert_eq!(
pe(
r#"<span id="a" id="b" class="c" class="d">q</span>"#,
native()
),
vec![span(attr("a", &["c"], &[]), vec![str("q")])]
);
}
#[test]
fn native_span_decodes_entities_in_attribute_values() {
assert_eq!(
pe(r#"<span title="a & b">q</span>"#, native()),
vec![span(attr("", &[], &[("title", "a & b")]), vec![str("q")])]
);
}
#[test]
fn native_span_self_closing_stays_raw() {
assert_eq!(
pe("a <span/> b", native()),
vec![
str("a"),
Inline::Space,
raw("html", "<span/>"),
Inline::Space,
str("b"),
]
);
}
#[test]
fn native_span_unclosed_opener_reverts_to_raw() {
assert_eq!(
pe(r#"<span class="a">no close"#, native()),
vec![
raw("html", "<span class=\"a\">"),
str("no"),
Inline::Space,
str("close"),
]
);
}
#[test]
fn native_span_pairs_inside_emphasis() {
assert_eq!(
pe("*x <span>y</span> z*", native()),
vec![Inline::Emph(vec![
str("x"),
Inline::Space,
span(attr("", &[], &[]), vec![str("y")]),
Inline::Space,
str("z"),
])]
);
}
#[test]
fn native_span_off_leaves_tags_raw() {
assert_eq!(
p(r#"<span class="a">x</span>"#),
vec![
raw("html", "<span class=\"a\">"),
str("x"),
raw("html", "</span>"),
]
);
}
fn mark(content: Vec<Inline>) -> Inline {
span(attr("", &["mark"], &[]), content)
}
#[test]
fn mark_wraps_a_double_equals_run() {
let on = exts(&[Extension::Mark]);
assert_eq!(
pe("a ==x== b", on),
vec![
str("a"),
Inline::Space,
mark(vec![str("x")]),
Inline::Space,
str("b"),
]
);
}
#[test]
fn mark_resolves_inner_emphasis() {
let on = exts(&[Extension::Mark]);
assert_eq!(
pe("==x *y*==", on),
vec![mark(vec![
str("x"),
Inline::Space,
Inline::Emph(vec![str("y")]),
])]
);
}
#[test]
fn mark_off_leaves_double_equals_literal() {
assert_eq!(
pe("a ==x== b", no_ext()),
vec![
str("a"),
Inline::Space,
str("==x=="),
Inline::Space,
str("b"),
]
);
}
#[test]
fn mark_opener_needs_no_following_space() {
let on = exts(&[Extension::Mark]);
assert_eq!(pe("== x==", on), vec![str("=="), Inline::Space, str("x==")]);
assert_eq!(pe("==x ==", on), vec![str("==x"), Inline::Space, str("==")]);
}
#[test]
fn mark_lone_equals_stays_literal() {
let on = exts(&[Extension::Mark]);
assert_eq!(
pe("a = b", on),
vec![str("a"), Inline::Space, str("="), Inline::Space, str("b")]
);
}
#[test]
fn mark_run_pairs_once_and_leaves_excess_literal() {
let on = exts(&[Extension::Mark]);
assert_eq!(
pe("====x====", on),
vec![str("=="), mark(vec![str("x")]), str("==")]
);
assert_eq!(pe("==x====", on), vec![mark(vec![str("x")]), str("==")]);
}
fn cites() -> Extensions {
exts(&[Extension::Citations])
}
fn cite(citations: Vec<Citation>, fallback: Vec<Inline>) -> Inline {
Inline::Cite(citations, fallback)
}
fn citation(
id: &str,
prefix: Vec<Inline>,
suffix: Vec<Inline>,
mode: CitationMode,
note_num: i32,
) -> Citation {
Citation {
id: id.to_owned().into(),
prefix,
suffix,
mode,
note_num,
hash: 0,
}
}
#[test]
fn bare_citation_is_author_in_text() {
assert_eq!(
pe("@doe2020", cites()),
vec![cite(
vec![citation(
"doe2020",
vec![],
vec![],
CitationMode::AuthorInText,
1
)],
vec![str("@doe2020")],
)]
);
}
#[test]
fn bare_citation_needs_a_non_word_before_the_at() {
assert_eq!(pe("foo@bar", cites()), vec![str("foo@bar")]);
assert_eq!(
pe("a @b", cites()),
vec![
str("a"),
Inline::Space,
cite(
vec![citation("b", vec![], vec![], CitationMode::AuthorInText, 1)],
vec![str("@b")],
),
]
);
}
#[test]
fn bracket_citation_carries_prefix_and_suffix() {
assert_eq!(
pe("[see @doe2020 and more]", cites()),
vec![cite(
vec![citation(
"doe2020",
vec![str("see")],
vec![Inline::Space, str("and"), Inline::Space, str("more")],
CitationMode::NormalCitation,
1,
)],
vec![
str("[see"),
Inline::Space,
str("@doe2020"),
Inline::Space,
str("and"),
Inline::Space,
str("more]"),
],
)]
);
}
#[test]
fn dash_before_at_suppresses_author() {
assert_eq!(
pe("[-@k]", cites()),
vec![cite(
vec![citation(
"k",
vec![],
vec![],
CitationMode::SuppressAuthor,
1
)],
vec![str("[-@k]")],
)]
);
assert_eq!(
pe("[a-@b]", cites()),
vec![cite(
vec![citation(
"b",
vec![str("a-")],
vec![],
CitationMode::NormalCitation,
1
)],
vec![str("[a-@b]")],
)]
);
}
#[test]
fn semicolon_separates_entries_sharing_one_number() {
assert_eq!(
pe("[@a; @b]", cites()),
vec![cite(
vec![
citation("a", vec![], vec![], CitationMode::NormalCitation, 1),
citation("b", vec![], vec![], CitationMode::NormalCitation, 1),
],
vec![str("[@a;"), Inline::Space, str("@b]")],
)]
);
}
#[test]
fn comma_nests_a_bare_citation_in_the_suffix() {
assert_eq!(
pe("[@a, @b]", cites()),
vec![cite(
vec![citation(
"a",
vec![],
vec![
str(","),
Inline::Space,
cite(
vec![citation("b", vec![], vec![], CitationMode::AuthorInText, 2)],
vec![str("@b")],
),
],
CitationMode::NormalCitation,
2,
)],
vec![str("[@a,"), Inline::Space, str("@b]")],
)]
);
}
#[test]
fn document_order_numbers_each_group() {
let out = pe("@a and [@b]", cites());
let nums: Vec<i32> = out
.iter()
.filter_map(|inline| match inline {
Inline::Cite(citations, _) => citations.first().map(|c| c.note_num),
_ => None,
})
.collect();
assert_eq!(nums, vec![1, 2]);
}
#[test]
fn malformed_bracket_falls_back_to_inline_citations() {
assert_eq!(
pe("[@a;]", cites()),
vec![
str("["),
cite(
vec![citation("a", vec![], vec![], CitationMode::AuthorInText, 1)],
vec![str("@a")],
),
str(";]"),
]
);
}
#[test]
fn segment_without_a_key_is_not_a_citation_list() {
assert_eq!(
pe("[no key; @b]", cites()),
vec![
str("[no"),
Inline::Space,
str("key;"),
Inline::Space,
cite(
vec![citation("b", vec![], vec![], CitationMode::AuthorInText, 1)],
vec![str("@b")],
),
str("]"),
]
);
}
#[test]
fn key_charset_keeps_internal_punctuation() {
assert_eq!(
pe("[@foo_bar:baz-qux.v/1]", cites()),
vec![cite(
vec![citation(
"foo_bar:baz-qux.v/1",
vec![],
vec![],
CitationMode::NormalCitation,
1,
)],
vec![str("[@foo_bar:baz-qux.v/1]")],
)]
);
assert_eq!(
pe("[@a-]", cites()),
vec![cite(
vec![citation(
"a",
vec![],
vec![str("-")],
CitationMode::NormalCitation,
1
)],
vec![str("[@a-]")],
)]
);
}
#[test]
fn citations_off_leaves_the_syntax_literal() {
assert_eq!(
pe("See [@a] and @b.", no_ext()),
vec![
str("See"),
Inline::Space,
str("[@a]"),
Inline::Space,
str("and"),
Inline::Space,
str("@b."),
]
);
}
#[test]
fn escaped_at_is_not_a_citation() {
assert_eq!(pe(r"[\@a]", cites()), vec![str("[@a]")]);
}
#[test]
fn citation_does_not_steal_a_link() {
assert_eq!(
pe("[@a](http://x.com)", cites()),
vec![link(
vec![cite(
vec![citation("a", vec![], vec![], CitationMode::AuthorInText, 1)],
vec![str("@a")],
)],
"http://x.com",
)]
);
}
#[test]
fn heading_content_is_context_independent_gates_on_ref_trigger_chars() {
assert!(heading_content_is_context_independent("Installation"));
assert!(heading_content_is_context_independent("API reference"));
assert!(!heading_content_is_context_independent("About @doe99"));
assert!(!heading_content_is_context_independent("Title[^1]"));
assert!(!heading_content_is_context_independent("See [spec]"));
}
#[test]
fn a_context_independent_heading_is_parsed_once_and_reused_by_the_body_pass() {
let ir = vec![IrBlock::Heading(1, "Installation".to_owned())];
let mut refs = empty_refs();
let mut cache: HeaderParseCache = BTreeMap::new();
let mut numbering = HeaderNumbering::new(no_ext(), false);
gather_headers(
&ir,
&mut refs,
no_notes(),
no_ext(),
&mut numbering,
&mut cache,
);
let queued = cache
.get("Installation")
.expect("the pre-pass should have cached the heading's parse");
assert_eq!(queued.len(), 1);
let heading = ir.first().expect("one heading in the IR");
let mut out = Vec::new();
resolve_block(heading, &refs, no_notes(), no_ext(), &mut cache, &mut out);
assert!(cache.get("Installation").is_none_or(VecDeque::is_empty));
assert_eq!(
out,
vec![Block::Header(1, Box::default(), p("Installation"))]
);
}
#[test]
fn a_second_identical_heading_pops_its_own_queued_parse() {
let ir = vec![
IrBlock::Heading(1, "Dup".to_owned()),
IrBlock::Heading(1, "Dup".to_owned()),
];
let mut refs = empty_refs();
let mut cache: HeaderParseCache = BTreeMap::new();
let mut numbering = HeaderNumbering::new(no_ext(), false);
gather_headers(
&ir,
&mut refs,
no_notes(),
no_ext(),
&mut numbering,
&mut cache,
);
assert_eq!(cache.get("Dup").map(VecDeque::len), Some(2));
let mut out = Vec::new();
for block in &ir {
resolve_block(block, &refs, no_notes(), no_ext(), &mut cache, &mut out);
}
assert!(cache.get("Dup").is_none_or(VecDeque::is_empty));
assert_eq!(
out,
vec![
Block::Header(1, Box::default(), p("Dup")),
Block::Header(1, Box::default(), p("Dup")),
]
);
}
}