use serde::{Deserialize, Serialize};
use crate::{
entities::entity_at,
math::{MathRenderer, MathView},
};
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct InlineRun {
pub text: String,
#[serde(skip_serializing_if = "is_false")]
pub bold: bool,
#[serde(skip_serializing_if = "is_false")]
pub italic: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub strike: bool,
#[serde(skip_serializing_if = "is_false")]
pub code: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub link: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub sub: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub sup: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub ins: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub math: Option<MathView>,
}
#[derive(Default)]
pub struct DisplayProjector {
renderer: MathRenderer,
definitions: Option<std::sync::Arc<LinkDefinitions>>,
}
impl DisplayProjector {
pub fn with_definitions<I, S>(labels: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
Self {
definitions: Some(std::sync::Arc::new(LinkDefinitions::new(labels))),
..Self::default()
}
}
pub fn set_definitions(&mut self, definitions: std::sync::Arc<LinkDefinitions>) {
self.definitions = Some(definitions);
}
pub fn project(&mut self, text: &str) -> Vec<InlineRun> {
project_text(
text,
Some(&mut self.renderer),
false,
self.definitions.as_deref(),
)
}
pub fn project_context(&mut self, text: &str) -> Vec<InlineRun> {
project_text(
text,
Some(&mut self.renderer),
true,
self.definitions.as_deref(),
)
}
pub(crate) fn project_display_math(&mut self, source: &str) -> Vec<InlineRun> {
let math = self.renderer.view(source, true, false);
vec![InlineRun {
text: source.to_string(),
math: Some(math),
..InlineRun::default()
}]
}
#[cfg(test)]
pub(crate) fn render_count(&self) -> usize {
self.renderer.render_count()
}
}
#[derive(Clone, Copy)]
struct Glyph {
ch: char,
raw_index: usize,
escaped: bool,
code: bool,
link: bool,
subset: Option<usize>,
math: Option<usize>,
entity_end: Option<usize>,
}
#[derive(Clone, Copy)]
struct Delimiter {
start: usize,
len: usize,
marker: char,
can_open: bool,
can_close: bool,
}
pub fn parse_inline(text: &str) -> Vec<InlineRun> {
DisplayProjector::default().project(text)
}
pub fn parse_inline_with(text: &str, definitions: &LinkDefinitions) -> Vec<InlineRun> {
project_text(text, None, false, Some(definitions))
}
pub fn strip_inline(text: &str) -> String {
project_text(text, None, false, None)
.into_iter()
.map(|run| run.text)
.collect()
}
pub fn strip_inline_with(text: &str, definitions: &LinkDefinitions) -> String {
parse_inline_with(text, definitions)
.into_iter()
.map(|run| run.text)
.collect()
}
#[derive(Debug, Default)]
pub struct LinkDefinitions(std::collections::HashSet<String>);
impl LinkDefinitions {
pub fn new<I, S>(labels: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
Self(
labels
.into_iter()
.map(|label| fold_label(label.as_ref()))
.collect(),
)
}
fn contains(&self, candidate: &str) -> bool {
self.0.contains(&fold_label(candidate))
}
}
fn fold_label(label: &str) -> String {
caseless::default_case_fold_str(&crate::parser::collapse(label))
}
pub fn is_display_math_line(text: &str) -> bool {
display_math_span(&text.chars().collect::<Vec<_>>()).is_some()
}
#[derive(Clone, Copy)]
struct MathSpan {
content_start: usize,
content_end: usize,
display: bool,
delimiter_len: usize,
}
fn project_text(
text: &str,
mut renderer: Option<&mut MathRenderer>,
context: bool,
definitions: Option<&LinkDefinitions>,
) -> Vec<InlineRun> {
let mut runs = Vec::new();
for chunk in text.split_inclusive('\n') {
let (line, newline) = chunk
.strip_suffix('\n')
.map_or((chunk, false), |line| (line, true));
let mut line_runs = project_line(line, renderer.as_deref_mut(), context, definitions);
append_runs(&mut runs, &mut line_runs);
if newline {
push_run(
&mut runs,
InlineRun {
text: "\n".to_string(),
..InlineRun::default()
},
);
}
}
runs
}
struct LineClassification {
spans: Vec<MathSpan>,
glyphs: Vec<Glyph>,
bold: Vec<bool>,
italic: Vec<bool>,
strike: Vec<bool>,
removed: Vec<bool>,
}
fn classify_line(
text: &str,
style_links: bool,
definitions: Option<&LinkDefinitions>,
) -> LineClassification {
let chars: Vec<char> = text.chars().collect();
let spans = math_spans(&chars);
let mut glyphs = scan_glyphs(&chars, &spans);
let mut labels = Vec::new();
if style_links {
let links = bracket_links(&chars, &glyphs, definitions);
let mut dropped = vec![false; glyphs.len()];
let mut label = vec![false; glyphs.len()];
for link in &links {
dropped[link.open] = true;
dropped[link.close..=link.syntax_end]
.iter_mut()
.for_each(|flag| *flag = true);
label[link.open + 1..link.close]
.iter_mut()
.for_each(|flag| *flag = true);
}
let mut kept = Vec::with_capacity(glyphs.len());
for (index, glyph) in glyphs.into_iter().enumerate() {
if dropped[index] {
continue;
}
if label[index] {
labels.push(kept.len());
}
kept.push(glyph);
}
glyphs = kept;
}
let delimiters = emphasis_delimiters(&glyphs);
let mut bold = vec![false; glyphs.len()];
let mut italic = vec![false; glyphs.len()];
let mut strike = vec![false; glyphs.len()];
let mut removed = vec![false; glyphs.len()];
let mut remaining: Vec<usize> = delimiters.iter().map(|delimiter| delimiter.len).collect();
let mut consumed_left = vec![0; delimiters.len()];
let mut consumed_right = vec![0; delimiters.len()];
let mut open: Vec<usize> = Vec::new();
for (delimiter_index, delimiter) in delimiters.iter().enumerate() {
while delimiter.can_close && remaining[delimiter_index] > 0 {
let Some(open_pos) = open.iter().rposition(|candidate| {
let opener = delimiters[*candidate];
opener.marker == delimiter.marker && remaining[*candidate] > 0
}) else {
break;
};
let opener_index = open[open_pos];
let strike_pair = delimiter.marker == '~';
while remaining[opener_index] >= 2 && remaining[delimiter_index] >= 2 {
consume_delimiters(
&delimiters,
opener_index,
delimiter_index,
2,
&mut remaining,
&mut consumed_left,
&mut consumed_right,
&mut removed,
if strike_pair { &mut strike } else { &mut bold },
);
}
if !strike_pair && remaining[opener_index] > 0 && remaining[delimiter_index] > 0 {
consume_delimiters(
&delimiters,
opener_index,
delimiter_index,
1,
&mut remaining,
&mut consumed_left,
&mut consumed_right,
&mut removed,
&mut italic,
);
}
if remaining[opener_index] == 0 {
open.remove(open_pos);
}
}
if delimiter.can_open && remaining[delimiter_index] > 0 {
open.push(delimiter_index);
}
}
for index in labels {
glyphs[index].link = true;
}
LineClassification {
spans,
glyphs,
bold,
italic,
strike,
removed,
}
}
fn project_line(
text: &str,
mut renderer: Option<&mut MathRenderer>,
context: bool,
definitions: Option<&LinkDefinitions>,
) -> Vec<InlineRun> {
let LineClassification {
spans,
glyphs,
bold,
italic,
strike,
removed,
} = classify_line(text, true, definitions);
let mut runs = Vec::new();
let mut index = 0;
while index < glyphs.len() {
if let Some(span_index) = glyphs[index].math {
let start = index;
while index < glyphs.len() && glyphs[index].math == Some(span_index) {
index += 1;
}
let source: String = glyphs[start..index].iter().map(|glyph| glyph.ch).collect();
let math = renderer
.as_deref_mut()
.map(|renderer| renderer.view(&source, spans[span_index].display, context));
push_run(
&mut runs,
InlineRun {
text: source,
math,
..InlineRun::default()
},
);
continue;
}
let glyph = glyphs[index];
if removed[index] {
index += 1;
continue;
}
push_run(
&mut runs,
InlineRun {
text: glyph.ch.to_string(),
bold: bold[index],
italic: italic[index],
strike: strike[index],
code: glyph.code,
link: glyph.link,
sub: glyph.subset == Some(0),
sup: glyph.subset == Some(1),
ins: glyph.subset == Some(2),
math: None,
},
);
index += 1;
}
runs
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct LinePiece {
pub text: String,
pub starts: Vec<usize>,
pub ends: Vec<usize>,
pub bold: bool,
pub italic: bool,
pub code: bool,
pub math: bool,
}
pub(crate) fn line_pieces(text: &str) -> Vec<LinePiece> {
let LineClassification {
glyphs,
bold,
italic,
removed,
..
} = classify_line(text, false, None);
let chars: Vec<char> = text.chars().collect();
let linked = link_syntax_mask(&chars, &glyphs);
let mut pieces: Vec<LinePiece> = Vec::new();
let mut gap = false;
let mut last_math: Option<usize> = None;
for (index, glyph) in glyphs.iter().enumerate() {
if glyph.math.is_none() && removed[index] {
continue;
}
if linked[index] {
gap = true;
continue;
}
let (splice_start, splice_end) = match glyph.entity_end {
Some(end) => (glyph.raw_index, end),
None => (
glyph.raw_index - usize::from(glyph.escaped && !glyph.code),
glyph.raw_index + 1,
),
};
if let Some(span_index) = glyph.math {
match pieces.last_mut() {
Some(piece) if piece.math && last_math == Some(span_index) && !gap => {
piece.text.push(glyph.ch);
piece.starts.push(glyph.raw_index);
piece.ends.push(glyph.raw_index + 1);
}
_ => {
pieces.push(LinePiece {
text: glyph.ch.to_string(),
starts: vec![glyph.raw_index],
ends: vec![glyph.raw_index + 1],
bold: false,
italic: false,
code: false,
math: true,
});
last_math = Some(span_index);
}
}
gap = false;
continue;
}
let style = (bold[index], italic[index], glyph.code);
match pieces.last_mut() {
Some(piece)
if !piece.math && (piece.bold, piece.italic, piece.code) == style && !gap =>
{
piece.text.push(glyph.ch);
piece.starts.push(splice_start);
piece.ends.push(splice_end);
}
_ => {
pieces.push(LinePiece {
text: glyph.ch.to_string(),
starts: vec![splice_start],
ends: vec![splice_end],
bold: style.0,
italic: style.1,
code: style.2,
math: false,
});
last_math = None;
}
}
gap = false;
}
pieces
}
struct BracketLink {
open: usize,
close: usize,
syntax_end: usize,
}
fn bracket_links(
chars: &[char],
glyphs: &[Glyph],
definitions: Option<&LinkDefinitions>,
) -> Vec<BracketLink> {
let plain = |glyph: &Glyph| !glyph.escaped && !glyph.code && glyph.math.is_none();
let bracket_close = |from: usize| {
(from..glyphs.len())
.find(|&at| plain(&glyphs[at]) && matches!(glyphs[at].ch, ']' | '['))
.filter(|&at| glyphs[at].ch == ']')
};
let raw_span = |open: usize, shut: usize| -> String {
chars[glyphs[open].raw_index + 1..glyphs[shut].raw_index]
.iter()
.collect()
};
let mut links: Vec<BracketLink> = Vec::new();
let mut openers: Vec<(usize, bool)> = Vec::new();
let mut index = 0;
while index < glyphs.len() {
if !plain(&glyphs[index]) {
index += 1;
continue;
}
match glyphs[index].ch {
'[' => {
let image = index
.checked_sub(1)
.and_then(|before| glyphs.get(before))
.is_some_and(|glyph| {
plain(glyph) && glyph.ch == '!' && authored_adjacent(glyph, &glyphs[index])
});
openers.push((index, image));
index += 1;
}
']' => {
let Some((open, image)) = openers.pop() else {
index += 1;
continue;
};
let close = index;
if image {
index = close + 1;
continue;
}
if glyphs.get(close + 1).is_some_and(|glyph| {
plain(glyph) && glyph.ch == '(' && authored_adjacent(&glyphs[close], glyph)
}) && let Some(close_paren) = link_tail_end(glyphs, close + 2)
{
links.push(BracketLink {
open,
close,
syntax_end: close_paren,
});
openers.retain(|(_, image)| *image);
index = close_paren + 1;
continue;
}
let Some(definitions) = definitions else {
index = close + 1;
continue;
};
let label = raw_span(open, close);
if glyphs.get(close + 1).is_some_and(|glyph| {
plain(glyph) && glyph.ch == '[' && authored_adjacent(&glyphs[close], glyph)
}) {
let Some(reference_close) = bracket_close(close + 2) else {
index = close + 1;
continue;
};
let reference = raw_span(close + 1, reference_close);
let name = if reference.trim().is_empty() {
&label
} else {
&reference
};
if definitions.contains(name) {
links.push(BracketLink {
open,
close,
syntax_end: reference_close,
});
openers.retain(|(_, image)| *image);
index = reference_close + 1;
} else {
index = close + 1;
}
continue;
}
if definitions.contains(&label) {
links.push(BracketLink {
open,
close,
syntax_end: close,
});
openers.retain(|(_, image)| *image);
}
index = close + 1;
}
_ => index += 1,
}
}
links.sort_by_key(|link| link.open);
links
}
fn link_tail_end(glyphs: &[Glyph], from: usize) -> Option<usize> {
let raw = |at: usize| {
glyphs
.get(at)
.filter(|glyph| glyph.entity_end.is_none() && (glyph.code || !glyph.escaped))
.map(|glyph| glyph.ch)
};
let skip_spaces = |mut at: usize| {
let start = at;
while raw(at).is_some_and(|ch| crate::parser::WHITESPACE.contains(&ch)) {
at += 1;
}
(at, at > start)
};
let (mut at, _) = skip_spaces(from);
if raw(at) == Some('<') {
at += 1;
while at < glyphs.len() && !matches!(raw(at), Some('<' | '>')) {
at += 1;
}
if raw(at) != Some('>') {
return None;
}
at += 1;
} else {
let mut depth = 0usize;
loop {
let ch = glyphs.get(at).map(|glyph| glyph.ch)?;
match raw(at) {
Some(')') if depth == 0 => break,
Some(')') => depth -= 1,
Some('(') => depth += 1,
Some(space) if crate::parser::WHITESPACE.contains(&space) => break,
_ if ch.is_ascii_control() => return None,
_ => {}
}
at += 1;
}
if depth != 0 {
return None;
}
}
let (after, separated) = skip_spaces(at);
at = after;
match raw(at) {
Some(')') => Some(at),
Some(open @ ('"' | '\'' | '(')) => {
if !separated {
return None;
}
let closer = if open == '(' { ')' } else { open };
at += 1;
while at < glyphs.len()
&& !matches!(raw(at), Some(ch) if ch == closer || (open == '(' && ch == '('))
{
at += 1;
}
if raw(at) != Some(closer) {
return None;
}
let (after_title, _) = skip_spaces(at + 1);
(raw(after_title) == Some(')')).then_some(after_title)
}
_ => None,
}
}
fn link_syntax_mask(chars: &[char], glyphs: &[Glyph]) -> Vec<bool> {
let mut mask = vec![false; glyphs.len()];
for link in bracket_links(chars, glyphs, None) {
mask[link.open] = true;
mask[link.close..=link.syntax_end]
.iter_mut()
.for_each(|dropped| *dropped = true);
}
mask
}
fn append_runs(target: &mut Vec<InlineRun>, source: &mut Vec<InlineRun>) {
for run in source.drain(..) {
push_run(target, run);
}
}
fn push_run(runs: &mut Vec<InlineRun>, run: InlineRun) {
if run.text.is_empty() {
return;
}
if run.math.is_none()
&& let Some(previous) = runs.last_mut()
&& previous.math.is_none()
&& (
previous.bold,
previous.italic,
previous.strike,
previous.code,
previous.link,
previous.sub,
previous.sup,
previous.ins,
) == (
run.bold, run.italic, run.strike, run.code, run.link, run.sub, run.sup, run.ins,
)
{
previous.text.push_str(&run.text);
} else {
runs.push(run);
}
}
pub fn math_encloses(text: &str, marker: &str) -> bool {
let Some(byte_index) = text.find(marker) else {
return false;
};
let index = text[..byte_index].chars().count();
let chars: Vec<char> = text.chars().collect();
math_spans(&chars)
.iter()
.any(|span| span.content_start <= index && index < span.content_end)
}
fn math_spans(chars: &[char]) -> Vec<MathSpan> {
if let Some(display) = display_math_span(chars) {
return vec![display];
}
let mut spans = Vec::new();
let mut index = 0;
while index < chars.len() {
if chars[index] == '`'
&& !is_escaped(chars, index)
&& let Some(end) = find_unescaped(chars, index + 1, '`')
{
index = end + 1;
continue;
}
if chars[index] != '$' || is_escaped(chars, index) {
index += 1;
continue;
}
if chars.get(index + 1) == Some(&'$') {
index = find_double_close(chars, index + 2).map_or(index + 2, |end| end + 2);
continue;
}
if chars.get(index + 1) == Some(&'`') {
if let Some(close) = backtick_anchored_close(chars, index + 2) {
spans.push(MathSpan {
content_start: index + 2,
content_end: close,
display: false,
delimiter_len: 2,
});
index = close + 2;
} else {
index += 1;
}
continue;
}
if chars.get(index + 1).is_none_or(|next| next.is_whitespace()) {
index += 1;
continue;
}
let Some(close) = find_inline_close(chars, index + 1) else {
index += 1;
continue;
};
spans.push(MathSpan {
content_start: index + 1,
content_end: close,
display: false,
delimiter_len: 1,
});
index = close + 1;
}
spans
}
fn backtick_anchored_close(chars: &[char], body_start: usize) -> Option<usize> {
let close = (body_start..chars.len()).find(|&index| chars[index] == '`')?;
if chars.get(close + 1) != Some(&'$') {
return None;
}
let body = &chars[body_start..close];
(!body.iter().all(|ch| ch.is_whitespace())).then_some(close)
}
fn display_math_span(chars: &[char]) -> Option<MathSpan> {
let start = chars.iter().position(|ch| !ch.is_whitespace())?;
let end = chars.iter().rposition(|ch| !ch.is_whitespace())? + 1;
if end.saturating_sub(start) < 5
|| chars.get(start) != Some(&'$')
|| chars.get(start + 1) != Some(&'$')
|| chars.get(end - 2) != Some(&'$')
|| chars.get(end - 1) != Some(&'$')
|| chars.get(start + 2).is_none_or(|ch| ch.is_whitespace())
|| chars.get(end - 3).is_none_or(|ch| ch.is_whitespace())
{
return None;
}
let close = find_double_close(chars, start + 2)?;
(close + 2 == end).then_some(MathSpan {
content_start: start + 2,
content_end: close,
display: true,
delimiter_len: 2,
})
}
fn find_inline_close(chars: &[char], start: usize) -> Option<usize> {
let mut index = start;
while index < chars.len() {
if chars[index] != '$' || is_escaped(chars, index) {
index += 1;
continue;
}
if chars.get(index + 1) == Some(&'$') || index > 0 && chars[index - 1] == '$' {
index += 1;
continue;
}
let previous = chars.get(index.wrapping_sub(1));
let next = chars.get(index + 1);
if previous.is_some_and(|ch| !ch.is_whitespace())
&& next.is_none_or(|ch| !ch.is_ascii_digit())
{
return Some(index);
}
index += 1;
}
None
}
fn find_double_close(chars: &[char], start: usize) -> Option<usize> {
(start..chars.len().saturating_sub(1)).find(|index| {
chars[*index] == '$'
&& chars[*index + 1] == '$'
&& !is_escaped(chars, *index)
&& chars
.get(index.wrapping_sub(1))
.is_some_and(|ch| !ch.is_whitespace())
})
}
fn find_unescaped(chars: &[char], start: usize, needle: char) -> Option<usize> {
(start..chars.len()).find(|index| chars[*index] == needle && !is_escaped(chars, *index))
}
pub(crate) fn tag_shape_column(text: &str) -> Option<usize> {
let trimmed =
text.trim_matches(|ch: char| matches!(ch, '\t' | '\n' | '\x0B' | '\x0C' | '\r' | ' '));
if let Some(body) = trimmed
.strip_prefix("<!--")
.and_then(|rest| rest.strip_suffix("-->"))
&& !body.contains("-->")
{
return None;
}
let chars: Vec<char> = text.chars().collect();
angle_scan(&chars).error_column
}
struct SubsetSpan {
slot: usize,
open_start: usize,
interior_start: usize,
interior_end: usize,
close_end: usize,
}
#[derive(Default)]
struct AngleScan {
error_column: Option<usize>,
subsets: Vec<SubsetSpan>,
}
fn angle_scan(chars: &[char]) -> AngleScan {
let mut scan = AngleScan::default();
let mut open_subset: Option<(usize, usize)> = None;
let mut index = 0;
while index < chars.len() {
if chars[index] == '`' && !is_escaped(chars, index) {
index = code_span_end(chars, index);
continue;
}
if chars[index] == ']'
&& !is_escaped(chars, index)
&& let Some(end) = image_tail_end(chars, index)
{
index = end + 1;
continue;
}
if chars[index] != '<' || is_escaped(chars, index) {
index += 1;
continue;
}
if let Some(end) = autolink_end(chars, index) {
index = end + 1;
continue;
}
if let Some((slot, len, closing)) = subset_tag(chars, index) {
match (open_subset, closing) {
(Some((open_slot, open_start)), true) if open_slot == slot => {
open_subset = None;
scan.subsets.push(SubsetSpan {
slot,
open_start,
interior_start: open_start + slot_open_len(slot),
interior_end: index,
close_end: index + len,
});
index += len;
continue;
}
(None, false) => {
open_subset = Some((slot, index));
index += len;
continue;
}
_ => {
scan.error_column = Some(index + 1);
return scan;
}
}
}
let tag = match chars.get(index + 1) {
Some('/') => chars
.get(index + 2)
.is_some_and(|ch| ch.is_ascii_alphabetic()),
Some(ch) => ch.is_ascii_alphabetic(),
None => false,
};
if tag {
scan.error_column = Some(index + 1);
return scan;
}
index += 1;
}
scan.error_column = open_subset.map(|(_, start)| start + 1);
scan
}
fn slot_open_len(slot: usize) -> usize {
SUBSET_TAGS[slot].len() + 2
}
fn image_tail_end(chars: &[char], close: usize) -> Option<usize> {
if chars.get(close + 1) != Some(&'(') {
return None;
}
let mut label = close;
loop {
if label == 0 {
return None;
}
label -= 1;
match chars[label] {
']' => return None,
'[' if label >= 1 && chars[label - 1] == '!' && !is_escaped(chars, label - 1) => break,
_ => {}
}
}
let mut at = close + 2;
if chars.get(at) == Some(&'<') {
at += 1;
loop {
match chars.get(at)? {
'\\' if matches!(chars.get(at + 1), Some('<' | '>' | '\\')) => at += 2,
'<' | '\n' => return None,
'>' => {
at += 1;
break;
}
_ => at += 1,
}
}
} else {
let mut depth = 0usize;
loop {
match chars.get(at)? {
'\\' if matches!(chars.get(at + 1), Some('(' | ')' | '\\')) => at += 2,
')' if depth == 0 => break,
')' => {
depth -= 1;
at += 1;
}
'(' => {
depth += 1;
at += 1;
}
ch if crate::parser::WHITESPACE.contains(ch) => {
if depth != 0 {
return None;
}
break;
}
_ => at += 1,
}
}
}
image_title_tail(chars, at)
}
fn image_title_tail(chars: &[char], from: usize) -> Option<usize> {
let whitespace = |at: usize| {
chars
.get(at)
.is_some_and(|ch| crate::parser::WHITESPACE.contains(ch))
};
let mut at = from;
while whitespace(at) {
at += 1;
}
let separated = at > from;
match chars.get(at).copied() {
Some(')') => Some(at),
Some(open @ ('"' | '\'' | '(')) if separated => {
let closer = if open == '(' { ')' } else { open };
at += 1;
loop {
match chars.get(at).copied() {
None => return None,
Some('\\') => at += 2,
Some(ch) if ch == closer => {
at += 1;
break;
}
Some('(') if open == '(' => return None,
Some(_) => at += 1,
}
}
while whitespace(at) {
at += 1;
}
(chars.get(at) == Some(&')')).then_some(at)
}
_ => None,
}
}
fn code_span_end(chars: &[char], start: usize) -> usize {
let run = chars[start..].iter().take_while(|ch| **ch == '`').count();
let mut index = start + run;
while index < chars.len() {
if chars[index] != '`' {
index += 1;
continue;
}
let close = chars[index..].iter().take_while(|ch| **ch == '`').count();
index += close;
if close == run {
return index;
}
}
start + run
}
fn matches_at(chars: &[char], start: usize, needle: &str) -> bool {
needle
.chars()
.enumerate()
.all(|(offset, ch)| chars.get(start + offset) == Some(&ch))
}
const SUBSET_TAGS: [&str; 3] = ["sub", "sup", "ins"];
fn subset_tag(chars: &[char], start: usize) -> Option<(usize, usize, bool)> {
let closing = chars.get(start + 1) == Some(&'/');
let name_start = if closing { start + 2 } else { start + 1 };
for (slot, name) in SUBSET_TAGS.iter().enumerate() {
if matches_at(chars, name_start, name) && chars.get(name_start + name.len()) == Some(&'>') {
return Some((slot, name.len() + if closing { 3 } else { 2 }, closing));
}
}
None
}
fn autolink_end(chars: &[char], start: usize) -> Option<usize> {
let close = (start + 1..chars.len()).find(|&index| chars[index] == '>')?;
let body: String = chars[start + 1..close].iter().collect();
(is_uri_autolink(&body) || is_email_autolink(&body)).then_some(close)
}
fn is_uri_autolink(body: &str) -> bool {
let Some((scheme, rest)) = body.split_once(':') else {
return false;
};
(2..=32).contains(&scheme.len())
&& scheme.starts_with(|ch: char| ch.is_ascii_alphabetic())
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '.' | '-'))
&& !rest.is_empty()
&& rest.chars().all(|ch| {
!crate::parser::WHITESPACE.contains(&ch)
&& !ch.is_ascii_control()
&& ch != '<'
&& ch != '>'
})
}
fn is_email_autolink(body: &str) -> bool {
let Some((local, domain)) = body.split_once('@') else {
return false;
};
let local_ok = !local.is_empty()
&& local
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(ch));
let label_ok = |label: &str| {
!label.is_empty()
&& label.len() <= 63
&& label
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
&& !label.starts_with('-')
&& !label.ends_with('-')
};
local_ok && !domain.is_empty() && domain.split('.').all(label_ok)
}
fn is_escaped(chars: &[char], index: usize) -> bool {
chars[..index]
.iter()
.rev()
.take_while(|ch| **ch == '\\')
.count()
% 2
== 1
}
#[expect(
clippy::too_many_arguments,
reason = "delimiter state updates stay atomic"
)]
fn consume_delimiters(
delimiters: &[Delimiter],
opener_index: usize,
closer_index: usize,
count: usize,
remaining: &mut [usize],
consumed_left: &mut [usize],
consumed_right: &mut [usize],
removed: &mut [bool],
style: &mut [bool],
) {
let opener = delimiters[opener_index];
let opener_end = opener.start + opener.len - consumed_right[opener_index];
removed[opener_end - count..opener_end].fill(true);
consumed_right[opener_index] += count;
remaining[opener_index] -= count;
let closer = delimiters[closer_index];
let closer_start = closer.start + consumed_left[closer_index];
removed[closer_start..closer_start + count].fill(true);
consumed_left[closer_index] += count;
remaining[closer_index] -= count;
style[opener_end..closer_start].fill(true);
}
fn scan_glyphs(chars: &[char], spans: &[MathSpan]) -> Vec<Glyph> {
let mut math = vec![None; chars.len()];
let mut removed = vec![false; chars.len()];
let mut subset_marker = vec![false; chars.len()];
let mut subset_slot: Vec<Option<usize>> = vec![None; chars.len()];
for span in angle_scan(chars).subsets {
subset_marker[span.open_start..span.interior_start].fill(true);
subset_marker[span.interior_end..span.close_end].fill(true);
subset_slot[span.interior_start..span.interior_end].fill(Some(span.slot));
}
if spans.first().is_some_and(|span| span.display) {
removed.fill(true);
}
for (span_index, span) in spans.iter().enumerate() {
math[span.content_start..span.content_end].fill(Some(span_index));
removed[span.content_start..span.content_end].fill(false);
if !span.display {
removed[span.content_start - span.delimiter_len..span.content_start].fill(true);
removed[span.content_end..span.content_end + span.delimiter_len].fill(true);
}
}
let mut glyphs = Vec::with_capacity(chars.len());
let mut index = 0;
while index < chars.len() {
if removed[index] {
index += 1;
continue;
}
if let Some(span_index) = math[index] {
glyphs.push(Glyph {
ch: chars[index],
raw_index: index,
escaped: false,
code: false,
link: false,
subset: None,
math: Some(span_index),
entity_end: None,
});
index += 1;
continue;
}
if subset_marker[index] {
index += 1;
continue;
}
if chars[index] == '\\'
&& let Some(next) = chars.get(index + 1)
&& next.is_ascii_punctuation()
&& math[index + 1].is_none()
&& !removed[index + 1]
{
glyphs.push(Glyph {
ch: *next,
raw_index: index + 1,
escaped: true,
code: false,
link: false,
subset: subset_slot[index + 1],
math: None,
entity_end: None,
});
index += 2;
continue;
}
if chars[index] == '<'
&& let Some(close) = autolink_end(chars, index)
&& (index..=close).all(|inner| math[inner].is_none() && !removed[inner])
{
glyphs.extend((index + 1..close).map(|raw_index| Glyph {
ch: chars[raw_index],
raw_index,
escaped: false,
code: false,
link: true,
subset: subset_slot[raw_index],
math: None,
entity_end: None,
}));
index = close + 1;
continue;
}
if chars[index] == '`' {
let run = chars[index..].iter().take_while(|ch| **ch == '`').count();
let end = code_span_end(chars, index);
if end > index + run {
glyphs.extend((index + run..end - run).map(|raw_index| Glyph {
ch: chars[raw_index],
raw_index,
escaped: true,
code: true,
link: false,
subset: None,
math: None,
entity_end: None,
}));
} else {
glyphs.extend((index..index + run).map(|raw_index| Glyph {
ch: chars[raw_index],
raw_index,
escaped: false,
code: false,
link: false,
subset: subset_slot[raw_index],
math: None,
entity_end: None,
}));
}
index = end;
continue;
}
if chars[index] == '&' {
let bound = (index + 40).min(chars.len());
let tail: String = chars[index..bound].iter().collect();
if let Some((consumed, replacement)) = entity_at(&tail) {
glyphs.extend(replacement.chars().map(|ch| Glyph {
ch,
raw_index: index,
escaped: true,
code: false,
link: false,
subset: subset_slot[index],
math: None,
entity_end: Some(index + consumed),
}));
index += consumed;
continue;
}
}
glyphs.push(Glyph {
ch: chars[index],
raw_index: index,
escaped: false,
code: false,
link: false,
subset: subset_slot[index],
math: None,
entity_end: None,
});
index += 1;
}
glyphs
}
fn footprint_end(glyph: &Glyph) -> usize {
glyph.entity_end.unwrap_or(glyph.raw_index + 1)
}
fn footprint_start(glyph: &Glyph) -> usize {
if glyph.entity_end.is_some() {
return glyph.raw_index;
}
glyph.raw_index - usize::from(glyph.escaped && !glyph.code)
}
fn authored_adjacent(before: &Glyph, after: &Glyph) -> bool {
footprint_end(before) == footprint_start(after)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Neighbour {
Whitespace,
Punctuation,
Other,
}
fn neighbour_class(glyph: Option<&Glyph>, adjacent: bool) -> Neighbour {
let Some(glyph) = glyph else {
return Neighbour::Whitespace;
};
if !adjacent || glyph.entity_end.is_some() {
return Neighbour::Punctuation;
}
if glyph.ch.is_whitespace() {
Neighbour::Whitespace
} else if glyph.ch.is_alphanumeric() {
Neighbour::Other
} else {
Neighbour::Punctuation
}
}
fn emphasis_delimiters(glyphs: &[Glyph]) -> Vec<Delimiter> {
let mut delimiters = Vec::new();
let mut index = 0;
while index < glyphs.len() {
let glyph = glyphs[index];
if glyph.escaped
|| glyph.code
|| glyph.link
|| glyph.math.is_some()
|| !matches!(glyph.ch, '*' | '_' | '~')
{
index += 1;
continue;
}
let mut end = index + 1;
while end < glyphs.len()
&& glyphs[end].ch == glyph.ch
&& !glyphs[end].escaped
&& !glyphs[end].code
&& glyphs[end].math.is_none()
&& glyphs[end].raw_index == glyphs[end - 1].raw_index + 1
{
end += 1;
}
let len = end - index;
if glyph.ch == '~' && len != 2 {
index = end;
continue;
}
let previous = index.checked_sub(1).and_then(|pos| glyphs.get(pos));
let next = glyphs.get(end);
let previous_adjacent =
previous.is_some_and(|item| authored_adjacent(item, &glyphs[index]));
let next_adjacent = next.is_some_and(|item| authored_adjacent(&glyphs[end - 1], item));
let before = neighbour_class(previous, previous_adjacent);
let after = neighbour_class(next, next_adjacent);
let left_flanking = after != Neighbour::Whitespace
&& (after != Neighbour::Punctuation || before != Neighbour::Other);
let right_flanking = before != Neighbour::Whitespace
&& (before != Neighbour::Punctuation || after != Neighbour::Other);
let (can_open, can_close) = if glyph.ch == '_' {
(
left_flanking && (!right_flanking || before == Neighbour::Punctuation),
right_flanking && (!left_flanking || after == Neighbour::Punctuation),
)
} else {
(left_flanking, right_flanking)
};
delimiters.push(Delimiter {
start: index,
len,
marker: glyph.ch,
can_open,
can_close,
});
index = end;
}
delimiters
}
#[cfg(test)]
mod tests {
#[test]
fn line_pieces_map_plain_text_identically() {
let pieces = line_pieces("plain text");
assert_eq!(1, pieces.len());
assert_eq!("plain text", pieces[0].text);
assert_eq!((0..10).collect::<Vec<_>>(), pieces[0].starts);
assert_eq!((1..11).collect::<Vec<_>>(), pieces[0].ends);
assert!(!pieces[0].bold && !pieces[0].code && !pieces[0].math);
}
#[test]
fn line_pieces_split_styled_from_plain_and_skip_the_markers() {
let pieces = line_pieces("**New** York");
assert_eq!(2, pieces.len());
assert_eq!(("New", true), (pieces[0].text.as_str(), pieces[0].bold));
assert_eq!(vec![2, 3, 4], pieces[0].starts);
assert_eq!((" York", false), (pieces[1].text.as_str(), pieces[1].bold));
assert_eq!(vec![7, 8, 9, 10, 11], pieces[1].starts);
}
#[test]
fn line_pieces_anchor_an_escaped_char_at_its_backslash() {
let pieces = line_pieces("a\\*b");
assert_eq!(1, pieces.len(), "{pieces:?}");
assert_eq!("a*b", pieces[0].text);
assert_eq!(
vec![0, 1, 3],
pieces[0].starts,
"the * splices from its backslash"
);
assert_eq!(vec![1, 3, 4], pieces[0].ends);
}
#[test]
fn line_pieces_drop_link_syntax_and_keep_the_label_as_its_own_piece() {
let pieces = line_pieces("see [the RFC](https://x) now");
assert_eq!(3, pieces.len(), "{pieces:?}");
assert_eq!("see ", pieces[0].text);
assert_eq!("the RFC", pieces[1].text);
assert_eq!(
vec![5, 6, 7, 8, 9, 10, 11],
pieces[1].starts,
"the label maps to its authored bytes inside the brackets"
);
assert_eq!(
" now", pieces[2].text,
"the destination and parens are gone"
);
}
#[test]
fn an_incomplete_link_pattern_stays_ordinary_prose() {
let pieces = line_pieces("just [brackets] here");
assert_eq!(1, pieces.len(), "{pieces:?}");
assert_eq!("just [brackets] here", pieces[0].text);
}
#[test]
fn line_pieces_mark_code_contents_and_exclude_backticks() {
let pieces = line_pieces("a `code` b");
assert_eq!(3, pieces.len(), "{pieces:?}");
assert_eq!(("code", true), (pieces[1].text.as_str(), pieces[1].code));
assert_eq!(vec![3, 4, 5, 6], pieces[1].starts);
}
#[test]
fn line_pieces_mark_math_source_and_exclude_dollar_delimiters() {
let pieces = line_pieces("sum $x+y$ here");
assert_eq!(3, pieces.len(), "{pieces:?}");
assert_eq!(("x+y", true), (pieces[1].text.as_str(), pieces[1].math));
assert_eq!(vec![5, 6, 7], pieces[1].starts);
assert!(!pieces[0].math && !pieces[2].math);
}
use proptest::prelude::*;
use super::*;
fn inline_text() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 ,.;:!?]{1,16}"
}
fn inline_code_body() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 *_$]{1,24}"
}
fn formatted_text() -> impl Strategy<Value = String> {
("[a-zA-Z0-9]", "[a-zA-Z0-9 ,.;:!?]{0,14}", "[a-zA-Z0-9]")
.prop_map(|(first, middle, last)| format!("{first}{middle}{last}"))
}
fn inline_fragment() -> impl Strategy<Value = (String, String)> {
prop_oneof![
4 => inline_text().prop_map(|text| (text.clone(), text)),
2 => formatted_text().prop_map(|text| (format!("**{text}**"), text)),
2 => formatted_text().prop_map(|text| (format!("*{text}*"), text)),
2 => formatted_text().prop_map(|text| (format!("_{text}_"), text)),
2 => inline_code_body().prop_map(|text| (format!("`{text}`"), text)),
2 => formatted_text().prop_map(|text| (format!("~~{text}~~"), text)),
1 => "[a-z0-9]{1,8}"
.prop_map(|host| (format!("<https://{host}.io>"), format!("https://{host}.io"))),
1 => (
"[a-zA-Z]{1,8}",
prop::sample::select(vec!["https://alix.study", "#anchor", "guide/ch3.md"]),
)
.prop_map(|(label, dest)| (format!("[{label}]({dest})"), label)),
1 => (
prop::sample::select(vec!["sub", "sup", "ins"]),
formatted_text(),
)
.prop_map(|(tag, text)| (format!("<{tag}>{text}</{tag}>"), text)),
1 => prop::sample::select(vec!['*', '_', '$', '`', '\\'])
.prop_map(|marker| (format!("\\{marker}"), marker.to_string())),
]
}
fn inline_source() -> impl Strategy<Value = (String, String)> {
prop::collection::vec(inline_fragment(), 0..10).prop_map(|parts| {
let mut source = String::new();
let mut expected = String::new();
for (index, (markup, content)) in parts.into_iter().enumerate() {
if index > 0 {
source.push('|');
expected.push('|');
}
source.push_str(&markup);
expected.push_str(&content);
}
(source, expected)
})
}
fn math_atom() -> impl Strategy<Value = String> {
prop_oneof![
Just("x".to_string()),
Just("y".to_string()),
Just("n".to_string()),
Just("2".to_string()),
Just("x^2".to_string()),
Just(r"\alpha".to_string()),
Just(r"\frac{1}{2}".to_string()),
]
}
fn math_operator() -> impl Strategy<Value = String> {
prop_oneof![
Just(" + ".to_string()),
Just(" - ".to_string()),
Just(r" \cdot ".to_string()),
]
}
fn math_formula() -> impl Strategy<Value = String> {
(
math_atom(),
prop::collection::vec((math_operator(), math_atom()), 0..4),
)
.prop_map(|(first, rest)| {
rest.into_iter().fold(first, |formula, (operator, atom)| {
format!("{formula}{operator}{atom}")
})
})
}
fn plain(s: &str) -> InlineRun {
InlineRun {
text: s.into(),
bold: false,
italic: false,
strike: false,
code: false,
link: false,
sub: false,
sup: false,
ins: false,
math: None,
}
}
fn bold(s: &str) -> InlineRun {
InlineRun {
text: s.into(),
bold: true,
italic: false,
strike: false,
code: false,
link: false,
sub: false,
sup: false,
ins: false,
math: None,
}
}
fn italic(s: &str) -> InlineRun {
InlineRun {
text: s.into(),
bold: false,
italic: true,
strike: false,
code: false,
link: false,
sub: false,
sup: false,
ins: false,
math: None,
}
}
fn code(s: &str) -> InlineRun {
InlineRun {
text: s.into(),
bold: false,
italic: false,
strike: false,
code: true,
link: false,
sub: false,
sup: false,
ins: false,
math: None,
}
}
fn strike(s: &str) -> InlineRun {
InlineRun {
text: s.to_string(),
strike: true,
..InlineRun::default()
}
}
fn strike_bold(s: &str) -> InlineRun {
InlineRun {
text: s.to_string(),
strike: true,
bold: true,
..InlineRun::default()
}
}
fn bold_italic(s: &str) -> InlineRun {
InlineRun {
text: s.into(),
bold: true,
italic: true,
strike: false,
code: false,
link: false,
sub: false,
sup: false,
ins: false,
math: None,
}
}
#[test]
fn plain_text_is_one_run() {
assert_eq!(vec![plain("plain text")], parse_inline("plain text"));
}
#[test]
fn bold_italic_code_render() {
assert_eq!(vec![bold("Paris")], parse_inline("**Paris**"));
assert_eq!(vec![italic("x")], parse_inline("*x*"));
assert_eq!(vec![italic("x")], parse_inline("_x_"));
assert_eq!(vec![code("HashMap")], parse_inline("`HashMap`"));
}
#[test]
fn emphasis_splits_surrounding_text() {
assert_eq!(
vec![plain("The capital is "), bold("Paris"), plain(".")],
parse_inline("The capital is **Paris**."),
);
}
#[test]
fn inline_code_is_verbatim() {
assert_eq!(vec![code("**x**")], parse_inline("`**x**`"));
}
#[test]
fn spaced_stars_do_not_emphasize() {
assert_eq!(vec![plain("a * b * c")], parse_inline("a * b * c"));
}
#[test]
fn tight_stars_do_emphasize() {
assert_eq!(
vec![plain("2"), italic("3"), plain("4")],
parse_inline("2*3*4")
);
}
#[test]
fn intraword_underscore_is_literal() {
assert_eq!(
vec![plain("snake_case_word")],
parse_inline("snake_case_word")
);
}
#[test]
fn underscore_delimiters_require_flanking_and_keep_punctuation_exceptions() {
assert_eq!(vec![plain(" _ x_")], parse_inline(" _ x_"));
assert_eq!(vec![plain("_x _ ")], parse_inline("_x _ "));
assert_eq!(vec![plain("."), italic(".")], parse_inline("._._"));
assert_eq!(vec![italic("."), plain(".")], parse_inline("_._."));
}
#[test]
fn double_underscore_is_bold() {
assert_eq!(vec![bold("bold")], parse_inline("__bold__"));
}
#[test]
fn intraword_double_underscore_is_literal() {
assert_eq!(vec![plain("a__b__c")], parse_inline("a__b__c"));
}
#[test]
fn triple_marker_is_bold_and_italic() {
assert_eq!(vec![bold_italic("x")], parse_inline("***x***"));
assert_eq!(vec![bold_italic("x")], parse_inline("___x___"));
}
#[test]
fn strong_and_emphasis_still_compose() {
assert_eq!(
vec![bold("bold "), bold_italic("and italic")],
parse_inline("**bold _and italic_**"),
);
assert_eq!(
vec![italic("a "), bold_italic("b"), italic(" c")],
parse_inline("*a **b** c*"),
);
assert_eq!(
vec![plain("a"), bold("b"), plain("c")],
parse_inline("a**b**c"),
);
}
#[test]
fn nesting_combines_flags() {
assert_eq!(
vec![bold("a "), bold_italic("b"), bold(" c")],
parse_inline("**a _b_ c**"),
);
}
#[test]
fn double_tilde_strikes_and_the_markers_leave_content() {
assert_eq!(vec![strike("gone")], parse_inline("~~gone~~"));
assert_eq!("gone", strip_inline("~~gone~~"));
}
#[test]
fn a_strike_boundary_survives_run_merging() {
assert_eq!(
vec![strike("ab"), plain("cd")],
parse_inline("~~ab~~cd"),
"trailing plain text must not inherit the strike"
);
assert_eq!(
vec![plain("ab"), strike("cd")],
parse_inline("ab~~cd~~"),
"a struck tail must not flatten into the plain head"
);
}
#[test]
fn strikethrough_nests_with_emphasis() {
assert_eq!(
vec![strike("a "), strike_bold("b"), strike(" c")],
parse_inline("~~a **b** c~~"),
);
}
#[test]
fn other_tilde_runs_stay_literal() {
for (label, input) in [
("a single pair", "~one~"),
("a triple pair", "~~~three~~~"),
("an unclosed opener", "a ~~ b"),
] {
assert_eq!(vec![plain(input)], parse_inline(input), "{label}");
}
}
#[test]
fn an_escaped_tilde_breaks_the_strike_pair() {
assert_eq!(vec![plain("~~kept~~")], parse_inline("\\~~kept~~"));
}
#[test]
fn backslash_before_any_ascii_punctuation_yields_the_literal() {
for ch in (0u8..=127)
.map(char::from)
.filter(char::is_ascii_punctuation)
{
let input = format!("a\\{ch}b");
let expected = format!("a{ch}b");
assert_eq!(
vec![plain(&expected)],
parse_inline(&input),
"backslash-{ch} must yield the literal {ch}"
);
}
}
#[test]
fn backslash_before_anything_else_stays_literal() {
for (label, input, expected) in [
("a letter keeps \\blank intact", "\\blank", "\\blank"),
("a digit", "a\\1b", "a\\1b"),
("a space", "a\\ b", "a\\ b"),
("line end", "tail\\", "tail\\"),
] {
assert_eq!(vec![plain(expected)], parse_inline(input), "{label}");
}
}
#[test]
fn backslash_escapes_a_marker() {
assert_eq!(vec![plain("*literal*")], parse_inline("\\*literal\\*"));
}
#[test]
fn unmatched_marker_is_literal() {
assert_eq!(
vec![plain("func(**kwargs)")],
parse_inline("func(**kwargs)")
);
}
#[test]
fn strip_inline_is_the_content_projection() {
assert_eq!("Paris", strip_inline("**Paris**"));
assert_eq!(
"The capital is Paris.",
strip_inline("The capital is **Paris**.")
);
assert_eq!("**x**", strip_inline("`**x**`"));
assert_eq!("234", strip_inline("2*3*4"));
assert_eq!("", strip_inline(""));
}
#[test]
fn inline_math_keeps_source_and_carries_svg() {
let runs = parse_inline("Why does $a^2 + b^2 = c^2$ hold?");
assert_eq!(runs.len(), 3);
assert_eq!(runs[1].text, "a^2 + b^2 = c^2");
let math = runs[1].math.as_ref().unwrap();
assert!(!math.display);
assert!(
math.svg
.as_deref()
.is_some_and(|svg| svg.starts_with("<svg"))
);
assert!(math.error.is_none());
assert!(!runs[1].bold && !runs[1].italic && !runs[1].code);
}
#[test]
fn tag_shapes_error_and_their_neighbors_stay_legal() {
let error_rows = [
("<div>", 1, "an open tag"),
("</div>", 1, "a close tag"),
(
"before <span class=x> after",
8,
"a mid-line tag with attributes",
),
("<B>", 1, "uppercase is still a letter"),
("<sub>unclosed", 1, "an unpaired subset open"),
("</sub>", 1, "a bare subset close"),
("<sub><sub>x</sub></sub>", 6, "a doubled subset open"),
("<sub><div></sub>", 6, "a tag inside a subset pair"),
("<SUB>x</SUB>", 1, "the subset is lowercase-exact"),
("<http//no-colon>", 1, "a malformed autolink near-miss"),
("<mailto:with space>", 1, "whitespace kills the uri form"),
(",
(
"<sub>i<sup>2</sup></sub>",
7,
"a nested distinct subset is a nested form",
),
(
"<sub><sup>x</sub></sup>",
6,
"cross-nesting dies at the inner opener",
),
("<sub>x</sup>", 7, "a mismatched subset close"),
(
"text <!-- <div> inside --> tail",
11,
"a tag inside inline comment prose",
),
(
"text <!-- <div> unclosed",
11,
"an unclosed inline comment hides nothing",
),
(
"<!-- a --> <div> <!-- b -->",
12,
"a tag between two comments on one line",
),
("", 6, "a destination not sealed by the paren"),
(r"", 6, "an escaped close leaves the form open"),
(
"",
6,
"a raw inner angle breaks the image destination",
),
(
"",
6,
"a backslash cannot hide a newline in an image destination",
),
(
"[t](<a b c>)",
5,
"a link destination is not the image form",
),
(r"\", 7, "an escaped image marker is prose"),
("![a]b](<x.png>)", 8, "a second bracket breaks the label"),
];
for (text, column, why) in error_rows {
assert_eq!(tag_shape_column(text), Some(column), "{why}: {text}");
}
let legal_rows = [
("a < b", "spaced comparison"),
("a<3 and 2<4", "digits after the bracket"),
("<div>", "an angle entity is never a tag shape"),
("x <= y", "an operator"),
("<", "a lone bracket at line end"),
(r"\<div>", "the escaped bracket"),
("`<div>`", "a code span"),
("``<div> and <span>``", "a double-backtick code span"),
("<!-- cards -->", "the comment channel"),
(
"<!-- <div> disabled -->",
"a whole-line comment is the channel",
),
(" <!-- <q>? -->", "an indented channel line"),
(
"text <!-- note --> tail",
"inline comment prose without a tag",
),
("<https://alix.study/deck>", "a uri autolink"),
("<mailto:hi@alix.study>", "a mailto autolink"),
("<hi@alix.study>", "an email autolink"),
("<sub>2</sub>", "a paired subscript"),
("<sup>2</sup>", "a paired superscript"),
("<ins>new</ins>", "a paired insertion"),
(
"H<sub>2</sub>O and E=mc<sup>2</sup>",
"two pairs on one line",
),
("", "an image destination in angles"),
(
"",
"a titled image destination in angles",
),
(
")",
"a paren-titled image destination in angles",
),
(
"",
"an angle run inside a bare-destination image title",
),
(
"",
"an angle run inside a bracketed-destination image title",
),
(
r"",
"an escaped close stays inside the image destination",
),
(
r"",
"an escaped backslash leaves the following close structural",
),
];
for (text, why) in legal_rows {
assert_eq!(tag_shape_column(text), None, "{why}: {text}");
}
}
#[test]
fn subset_pairs_render_as_styled_runs_without_their_tags() {
let runs = parse_inline("H<sub>2</sub>O and E=mc<sup>2</sup>");
let flat: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(flat, "H2O and E=mc2", "tag glyphs are markers, not content");
assert!(
runs.iter().any(|run| run.sub && run.text == "2"),
"{runs:?}"
);
assert!(
runs.iter().any(|run| run.sup && run.text == "2"),
"{runs:?}"
);
let ins = parse_inline("<ins>new</ins>");
assert_eq!(ins.len(), 1, "{ins:?}");
assert!(ins[0].ins && !ins[0].sub && !ins[0].sup);
assert_eq!(ins[0].text, "new");
assert_eq!(
strip_inline("H<sub>2</sub>O"),
"H2O",
"grading sees inner text"
);
assert_eq!(
strip_inline("Tom & Jerry"),
"Tom & Jerry",
"grading equates an authored entity with the typed literal"
);
}
#[test]
fn subset_interiors_keep_normal_inline_scanning() {
let styled = parse_inline("<sub>**x**</sub>");
assert_eq!(styled.len(), 1, "{styled:?}");
assert!(
styled[0].sub && styled[0].bold,
"emphasis works inside a pair"
);
assert_eq!(styled[0].text, "x");
let coded = parse_inline("`<sub>x</sub>`");
assert_eq!(coded.len(), 1, "{coded:?}");
assert!(
coded[0].code && !coded[0].sub,
"a code span keeps the tags literal"
);
assert_eq!(coded[0].text, "<sub>x</sub>");
let unpaired = parse_inline("<sub>unclosed");
assert!(
unpaired.iter().all(|run| !run.sub),
"an unpaired open stays literal in projection: {unpaired:?}"
);
let flat: String = unpaired.iter().map(|run| run.text.as_str()).collect();
assert_eq!(flat, "<sub>unclosed");
}
#[test]
fn autolinks_render_as_inert_link_runs() {
let runs = parse_inline("see <https://alix.study/deck> now");
assert_eq!(runs.len(), 3, "{runs:?}");
assert_eq!(runs[0], plain("see "));
assert_eq!(
runs[1].text, "https://alix.study/deck",
"the brackets are markers, not content"
);
assert!(runs[1].link && !runs[1].bold && !runs[1].italic && !runs[1].code);
assert_eq!(runs[2], plain(" now"));
let email = parse_inline("<hi@alix.study>");
assert_eq!(email.len(), 1, "{email:?}");
assert!(email[0].link);
assert_eq!(email[0].text, "hi@alix.study");
}
#[test]
fn autolink_boundaries_stay_literal_or_protected() {
for (text, why) in [
(
r"\<https://alix.study>",
"an escaped bracket kills the form",
),
("<http//no-colon>", "a near-miss stays prose"),
("a < b and x<3", "non-tag brackets unaffected"),
(
"<https://alix.study/a b>",
"an ASCII space is not an absolute URI character",
),
] {
let runs = parse_inline(text);
assert!(runs.iter().all(|run| !run.link), "{why}: {runs:?}");
}
let code = parse_inline("`<https://alix.study>`");
assert_eq!(code.len(), 1, "{code:?}");
assert!(
code[0].code && !code[0].link,
"a code span keeps the brackets"
);
assert_eq!(code[0].text, "<https://alix.study>");
let underscored = parse_inline("<https://a_b_c.io>");
assert_eq!(underscored.len(), 1, "{underscored:?}");
assert!(
underscored[0].link && !underscored[0].italic,
"URL underscores are not emphasis"
);
assert_eq!(underscored[0].text, "https://a_b_c.io");
let unencoded = parse_inline("<https://alix.study/a\u{a0}b>");
assert!(
unencoded.iter().any(|run| run.link),
"only ASCII whitespace and controls are barred from an absolute \
URI, so a no-break space is ordinary URI content: {unencoded:?}"
);
}
#[test]
fn bracket_links_render_as_styled_labels_with_inert_destinations() {
let runs = parse_inline("see [the docs](https://alix.study) now");
assert_eq!(runs.len(), 3, "{runs:?}");
assert_eq!(runs[0], plain("see "));
assert_eq!(
runs[1].text, "the docs",
"the label is the content; brackets, parens, and destination drop"
);
assert!(runs[1].link && !runs[1].bold && !runs[1].italic && !runs[1].code);
assert_eq!(runs[2], plain(" now"));
for (source, label, why) in [
(
"[a](#anchor)",
"a",
"an anchor destination styles like any link",
),
(
"[a](guide/ch3.md)",
"a",
"a relative destination is the same display",
),
] {
let runs = parse_inline(source);
assert_eq!(runs.len(), 1, "{why}: {runs:?}");
assert!(runs[0].link, "{why}");
assert_eq!(runs[0].text, label, "{why}");
}
let inner = parse_inline("[*x*](d)");
assert_eq!(inner.len(), 1, "{inner:?}");
assert!(
inner[0].link && inner[0].italic,
"emphasis inside a label still renders"
);
assert_eq!(inner[0].text, "x");
let across = parse_inline("*a [b](c) d*");
assert!(
across.iter().all(|run| run.italic),
"emphasis spans across a link: {across:?}"
);
assert!(
across.iter().any(|run| run.link && run.text == "b"),
"the label inside keeps its link styling: {across:?}"
);
}
#[test]
fn bracket_link_boundaries_stay_literal_or_protected() {
for (text, why) in [
(r"\[a](b)", "an escaped opening bracket kills the form"),
("just [brackets] here", "no destination means prose"),
("[a] (b)", "a space before the parens breaks the form"),
] {
let runs = parse_inline(text);
assert!(runs.iter().all(|run| !run.link), "{why}: {runs:?}");
let joined: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(joined, text.replace('\\', ""), "{why}");
}
let spans = parse_inline("`[a](b)`");
assert_eq!(vec")], spans, "a code span keeps the syntax");
}
#[test]
fn bracket_link_grammar_keeps_nested_brackets_in_the_label() {
let runs = parse_inline("[array[index]](guide)");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!("array[index]", text, "the full nested label is content");
assert!(
runs.iter().all(|run| run.link),
"the valid GFM link label stays styled: {runs:?}"
);
}
#[test]
fn an_inner_link_prevents_an_outer_link_from_forming() {
let runs = parse_inline("[foo [bar](/inner)](/outer)");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!("[foo bar](/outer)", text);
assert_eq!(
runs.iter()
.filter(|run| run.link)
.map(|run| run.text.as_str())
.collect::<String>(),
"bar"
);
}
#[test]
fn a_link_tail_must_be_adjacent_in_the_authored_stream() {
for (source, shown) in [
("Use [array] (1).", "Use [array] (1)."),
("Use [array]x(1).", "Use [array]x(1)."),
("Use [array]<sup>(1)</sup>.", "Use [array](1)."),
] {
let runs = parse_inline(source);
assert_eq!(
shown,
runs.iter().map(|run| run.text.as_str()).collect::<String>(),
"a separated tail is display content, not link syntax ({source}): {runs:?}"
);
assert!(
runs.iter().all(|run| !run.link),
"nothing links when the tail is not authored-adjacent ({source}): {runs:?}"
);
}
let runs = parse_inline("Use [array](1).");
assert_eq!(
"Use array.",
runs.iter().map(|run| run.text.as_str()).collect::<String>(),
"the law still lets a real, adjacent tail link: {runs:?}"
);
for (source, shown, linked) in [
(
"Claim [one] [citation].",
"Claim [one] citation.",
"citation",
),
(
"Claim [one]x[citation].",
"Claim [one]xcitation.",
"citation",
),
(
"Claim [one]<sup>[citation]</sup>.",
"Claim [one]citation.",
"citation",
),
("Claim [one][citation].", "Claim one.", "one"),
] {
let mut projector = DisplayProjector::with_definitions(["citation"]);
let runs = projector.project(source);
assert_eq!(
shown,
runs.iter().map(|run| run.text.as_str()).collect::<String>(),
"({source}): {runs:?}"
);
assert_eq!(
linked,
runs.iter()
.filter(|run| run.link)
.map(|run| run.text.as_str())
.collect::<String>(),
"({source}): {runs:?}"
);
}
}
#[test]
fn every_glyph_names_an_ordered_authored_range() {
for source in [
"𝔸_x_",
"≂̸",
"_ x_",
"a \\* b",
"Tom & Jerry",
"x<sup>_2_</sup>",
"`code` A *em*",
"**energy $E=mc^2$**",
"$$ \\sum_i x_i $$",
"`` a ` b `` c",
" [t](u) <https://x.test>",
] {
let chars: Vec<char> = source.chars().collect();
let glyphs = scan_glyphs(&chars, &[]);
let mut previous: Option<(usize, usize)> = None;
for glyph in &glyphs {
let start = footprint_start(glyph);
let end = footprint_end(glyph);
assert!(
start < end,
"a glyph covers at least one authored char ({source}): {start}..{end}"
);
assert!(
end <= chars.len(),
"and never runs past the line ({source}): {end} > {}",
chars.len()
);
if let Some(before) = previous {
assert!(
start >= before.1 || (start, end) == before,
"ranges run forward, sharing one only when a single entity decoded \
to several scalars ({source}): {start}..{end} after {before:?}"
);
}
previous = Some((start, end));
}
}
}
#[test]
fn an_entity_flanks_as_the_punctuation_it_was_authored_as() {
for (source, shown, why) in [
(
"𝔸_x_",
"\u{1D538}x",
"a decoded LETTER must not make the run intraword",
),
(
"_ x_",
"\u{a0}x",
"and a decoded SPACE must not stop the run flanking",
),
(
"_x _",
"x\u{a0}",
"the same on the closing side, where the boundary is `;`",
),
] {
let runs = parse_inline(source);
assert_eq!(
shown,
runs.iter().map(|run| run.text.as_str()).collect::<String>(),
"{why} ({source}): {runs:?}"
);
assert!(
runs.iter()
.filter(|run| run.text.contains('x'))
.all(|run| run.italic),
"{why} ({source}): {runs:?}"
);
}
}
#[test]
#[ignore = "needs a Unicode general-category table: a dependency decision"]
fn a_category_m_neighbour_is_ordinary_text_beside_a_delimiter() {
let mut wrong = Vec::new();
for (source, subject, italic, why) in [
(
"*cafe\u{301}*s",
'c',
true,
"the combining acute is category M, so the run closes against it",
),
(
"\u{2615}_b_",
'b',
true,
"U+2615 is category S, so the underscore opens against it",
),
(
"\u{2615}\u{fe0f}_b_",
'b',
false,
"U+FE0F is category M, so the same underscore does not",
),
] {
let runs = parse_inline(source);
let got = runs
.iter()
.filter(|run| run.text.contains(subject))
.all(|run| run.italic);
if got != italic {
wrong.push(format!("{why}: {runs:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
#[test]
fn an_accent_away_from_the_delimiter_leaves_the_run_alone() {
let mut wrong = Vec::new();
for (source, subject, where_it_sits) in [
(
"*caf\u{e9}*s",
'c',
"precomposed, so the neighbour is a letter",
),
(
"*cafe\u{301} au lait*",
'c',
"decomposed, but not beside a marker",
),
("*H\u{e4}user*n", 'H', "decomposed, inside the run"),
] {
let runs = parse_inline(source);
if !runs
.iter()
.filter(|run| run.text.contains(subject))
.all(|run| run.italic)
{
wrong.push(format!("{where_it_sits}: {runs:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
#[test]
fn punctuation_does_not_flank_against_a_letter_on_the_other_side() {
let mut misflanked = Vec::new();
for (source, subject, boundary) in [
("a**$y$ tail**", "tail", "opening math delimiter"),
("a**`c` tail**", "tail", "opening code delimiter"),
(
"a**<https://e.test> tail**",
"tail",
"opening autolink angle",
),
("a**[t](u) tail**", "tail", "opening link bracket"),
("a**<sup>z</sup> tail**", "tail", "opening subset-tag angle"),
("**head $y$**b", "head", "closing math delimiter"),
("**head `c`**b", "head", "closing code delimiter"),
(
"**head <https://e.test>**b",
"head",
"closing autolink angle",
),
("**head [t](u)**b", "head", "closing link tail"),
("**head <sup>z</sup>**b", "head", "closing subset-tag angle"),
(
"a**.tail**",
"tail",
"ordinary adjacent punctuation, opening",
),
(
"**head.**b",
"head",
"ordinary adjacent punctuation, closing",
),
] {
let runs = parse_inline(source);
if runs
.iter()
.filter(|run| run.text.contains(subject))
.any(|run| run.bold)
{
misflanked.push(format!("{boundary} ({source}): {runs:?}"));
}
}
assert!(
misflanked.is_empty(),
"a punctuation neighbour flanks only when the run's other side is \
whitespace or punctuation too: {misflanked:#?}"
);
}
#[test]
fn every_removed_boundary_flanks_as_authored_punctuation() {
let mut unflanked = Vec::new();
for (source, boundary) in [
("**a $y$**", "closing math delimiter"),
("**$y$ a**", "opening math delimiter"),
("**a `c`**", "closing code delimiter"),
("**`c` a**", "opening code delimiter"),
("**a <https://e.test>**", "closing autolink angle"),
("**<https://e.test> a**", "opening autolink angle"),
("**a [t](u)**", "closing link tail"),
("**[t](u) a**", "opening link bracket"),
("**a <sup>z</sup>**", "closing subset-tag angle"),
("**<sup>z</sup> a**", "opening subset-tag angle"),
] {
let runs = parse_inline(source);
if !runs
.iter()
.filter(|run| run.text.contains('a'))
.all(|run| run.bold)
{
unflanked.push(format!("{boundary} ({source}): {runs:?}"));
}
}
assert!(
unflanked.is_empty(),
"a removed character is a punctuation neighbour, so the run still \
pairs across it: {unflanked:#?}"
);
}
#[test]
fn underscore_flanking_is_judged_on_the_authored_stream() {
for (source, shown) in [("x<sup>_2_</sup>", "x2"), ("<sup>_2_</sup>x", "2x")] {
let runs = parse_inline(source);
assert_eq!(
shown,
runs.iter().map(|run| run.text.as_str()).collect::<String>(),
"the tag boundary keeps the underscore delimiter out of an intraword run: {source:?}: {runs:?}"
);
assert!(
runs.iter()
.filter(|run| run.text.contains('2'))
.all(|run| run.italic && run.sup),
"nested supported styles compose across authored tag boundaries: {source:?}: {runs:?}"
);
}
}
#[test]
fn an_image_opener_neither_forms_a_link_nor_deactivates_the_one_around_it() {
for (source, shown, linked, why) in [
("", "", "", "authored adjacency IS an image"),
(
"",
"!x",
"x",
"an entity is not a structural bang",
),
(
"*!*[x](/u)",
"!x",
"x",
"dropped emphasis markers are not adjacency",
),
(
"!`c`[x](/u)",
"!cx",
"x",
"a code span between them is not adjacency",
),
(
"Wow!<sup>[1](/source)</sup>",
"Wow!1",
"1",
"dropped tag markers are not adjacency",
),
] {
let runs = parse_inline(source);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(shown, text, "{why} ({source}): {runs:?}");
assert_eq!(
linked,
runs.iter()
.filter(|run| run.link)
.map(|run| run.text.as_str())
.collect::<String>(),
"{why} ({source}): {runs:?}"
);
}
let runs = parse_inline("[](/uri)");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"", text,
"the outer link owns the whole image as its label: {runs:?}"
);
assert!(
runs.iter().all(|run| run.link),
"and the label is what displays as the link: {runs:?}"
);
let runs = parse_inline("");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"", text,
"a bare image is not a link and keeps its authored syntax: {runs:?}"
);
assert!(
runs.iter().all(|run| !run.link),
"nothing about an image is a link: {runs:?}"
);
let runs = parse_inline("[a](/one) and [](/two)");
assert_eq!(
2,
runs.iter().filter(|run| run.link).count(),
"an image between links leaves both of them formed: {runs:?}"
);
let runs = parse_inline("](/d)");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"", text,
"an image label may still hold a working link: {runs:?}"
);
assert_eq!(
1,
runs.iter().filter(|run| run.link).count(),
"exactly the inner one: {runs:?}"
);
let source = "\\";
let runs = parse_inline(source);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"!not an image", text,
"an escaped bang is literal, so the bracket is an ordinary link \
opener: {runs:?}"
);
}
#[test]
fn an_angle_destination_title_requires_separating_whitespace() {
let rows = [
"[label](<destination>\"title\")",
"[label](<>\"title\")",
"[label](<>'title')",
"[label](<>(title))",
];
for source in rows {
let runs = parse_inline(source);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(source, text, "an unseparated title leaves {source} literal");
assert!(
runs.iter().all(|run| !run.link),
"an unseparated title is not a link: {source}"
);
}
}
#[test]
fn backticks_do_not_hide_unbalanced_destination_parentheses() {
let source = "[label](`x(y`)";
let runs = parse_inline(source);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!("[label](x(y)", text);
assert!(runs.iter().all(|run| !run.link));
}
#[test]
fn backticks_keep_balanced_destination_parentheses_legal() {
let runs = parse_inline("[label](`x(y)`)");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!("label", text);
assert!(runs.iter().all(|run| run.link));
}
#[test]
fn bracket_link_grammar_rejects_raw_destination_spaces() {
let source = "[ATP](energy currency)";
let runs = parse_inline(source);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(source, text, "parenthesized prose must remain visible");
assert!(
runs.iter().all(|run| !run.link),
"a raw-space destination is not a GFM link: {runs:?}"
);
}
#[test]
fn bracket_link_grammar_accepts_the_angle_title_and_paren_tails() {
let rows = [
(
"[a](<dest with space>)",
"a",
"an angle destination may hold spaces",
),
(
"[a](url \"the title\")",
"a",
"a quoted title follows a bare destination",
),
(
"[a](url 'the title')",
"a",
"a single-quoted title works the same",
),
("[a](url (the title))", "a", "a paren title works the same"),
(
"[a](path(x))",
"a",
"balanced parens stay legal in a bare destination",
),
("[a]()", "a", "an empty destination is a valid link"),
(
"[a](<> \"the title\")",
"a",
"an explicit empty destination still separates its title",
),
(
"[a](url\u{0B}\"the title\")",
"a",
"line tabulation is grammar whitespace, so it separates a title",
),
];
for (line, label, law) in rows {
let runs = parse_inline(line);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(label, text, "{law}");
assert!(runs.iter().all(|run| run.link), "{law}: {runs:?}");
}
let rows = [
(
"[a](<unclosed)",
"an unclosed angle destination stays literal",
),
("[a](url \"unclosed)", "an unclosed title stays literal"),
(
"[a](url title)",
"a bare word after the destination is no title",
),
("[a](x(y)", "unbalanced destination parens stay literal"),
(
"[a](url\u{a0}\"the title\")",
"a no-break space is destination content, never a separator",
),
];
for (line, law) in rows {
let runs = parse_inline(line);
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(line, text, "{law}");
assert!(runs.iter().all(|run| !run.link), "{law}: {runs:?}");
}
}
#[test]
fn bracket_link_grammar_rejects_an_escaped_opening_parenthesis() {
let runs = parse_inline(r"[a]\(b)");
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"[a](b)", text,
"the escape makes the parenthesis literal, not link syntax"
);
assert!(
runs.iter().all(|run| !run.link),
"an escaped opening parenthesis cannot start a link destination: {runs:?}"
);
}
#[test]
fn typed_grading_compares_the_link_label_alone() {
assert_eq!(
strip_inline("see [the docs](https://alix.study)"),
"see the docs"
);
}
#[test]
fn reference_links_render_as_styled_labels_when_their_label_is_defined() {
let defs = LinkDefinitions::new(["r", "Spaced Label"]);
for (source, label, why) in [
(
"see [the ref][r] here",
"the ref",
"the full form styles its text",
),
("see [r][] here", "r", "the collapsed form is its own label"),
("see [r] here", "r", "the shortcut form is its own label"),
("see [R] here", "R", "labels match by case folding"),
(
"see [spaced label] here",
"spaced label",
"interior whitespace collapses before matching",
),
] {
let runs = parse_inline_with(source, &defs);
assert_eq!(runs.len(), 3, "{why}: {runs:?}");
assert_eq!(runs[0], plain("see "));
assert!(runs[1].link, "{why}");
assert_eq!(runs[1].text, label, "{why}");
assert_eq!(runs[2], plain(" here"));
assert_eq!(
strip_inline_with(source, &defs),
format!("see {label} here"),
"{why}: grading follows the same projection"
);
}
for (source, why) in [
("see [nope] here", "an undefined shortcut stays prose"),
(
"see [text][nope] here",
"an undefined reference keeps the whole form prose",
),
(
"see [nope][] here",
"an undefined collapsed form stays prose",
),
] {
let runs = parse_inline_with(source, &defs);
assert!(runs.iter().all(|run| !run.link), "{why}: {runs:?}");
let joined: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(joined, source, "{why}");
}
let inline_wins = parse_inline_with("[r](https://alix.study)", &defs);
assert_eq!(inline_wins.len(), 1, "{inline_wins:?}");
assert_eq!(
inline_wins[0].text, "r",
"the inline form owns its destination even when the label is defined"
);
let bare = parse_inline("just [r] alone");
assert!(
bare.iter().all(|run| !run.link),
"without definitions nothing changes: {bare:?}"
);
}
#[test]
fn a_projector_carries_its_deck_definitions_into_every_projection() {
let mut projector = DisplayProjector::with_definitions(["r"]);
let runs = projector.project("see [the ref][r] here");
assert!(
runs.iter().any(|run| run.link && run.text == "the ref"),
"{runs:?}"
);
let context = projector.project_context("[r] leads");
assert!(
context.iter().any(|run| run.link && run.text == "r"),
"{context:?}"
);
}
#[test]
fn reference_label_matching_uses_commonmark_unicode_case_folding() {
let defs = LinkDefinitions::new(["STRASSE", "Σ"]);
for (source, label, why) in [
(
"[straße]",
"straße",
"German sharp s folds with uppercase SS",
),
("[Ï‚]", "Ï‚", "Greek final sigma folds with capital sigma"),
] {
let runs = parse_inline_with(source, &defs);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(label, projected, "{why}");
assert!(
runs.iter().all(|run| run.link),
"{why}: the CommonMark-equivalent label resolves: {runs:?}"
);
}
}
#[test]
fn reference_label_matching_does_not_collapse_non_breaking_space() {
let source = "[a\u{a0}b]";
let defs = LinkDefinitions::new(["a b"]);
let runs = parse_inline_with(source, &defs);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(source, projected);
assert!(runs.iter().all(|run| !run.link));
}
#[test]
fn reference_label_matching_collapses_exactly_the_grammar_whitespace_class() {
for gap in ['\t', '\x0B', '\x0C', ' '] {
let source = format!("[a{gap}b]");
let runs = parse_inline_with(&source, &LinkDefinitions::new(["a b"]));
assert!(
runs.iter().any(|run| run.link),
"U+{:04X} is grammar whitespace and collapses to the definition",
gap as u32
);
}
for content in ['\u{a0}', '\u{2003}', '\u{3000}'] {
let source = format!("[a{content}b]");
let runs = parse_inline_with(&source, &LinkDefinitions::new(["a b"]));
assert!(
runs.iter().all(|run| !run.link),
"U+{:04X} is label content, so it never unifies two labels",
content as u32
);
}
}
#[test]
fn reference_label_matching_uses_the_same_escape_and_entity_form_on_both_sides() {
for (definition, source, visible, why) in [
(
r"a\*b",
r"[a\*b]",
"a*b",
"the same backslash escape matches",
),
(
"a&b",
"[a&b]",
"a&b",
"the same entity spelling matches",
),
] {
let defs = LinkDefinitions::new([definition]);
let runs = parse_inline_with(source, &defs);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(visible, projected, "{why}");
assert!(
runs.iter().all(|run| run.link),
"{why}: authored-equivalent labels resolve: {runs:?}"
);
}
}
#[test]
fn reference_label_matching_rejects_an_escaped_opener_without_deleting_its_suffix() {
let defs = LinkDefinitions::new(["text", "nope"]);
let runs = parse_inline_with(r"[text]\[nope]", &defs);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"text[nope]", projected,
"the escape makes the second bracket literal reference-like prose"
);
assert!(runs.iter().any(|run| run.link && run.text == "text"));
assert!(
runs.iter().any(|run| !run.link && run.text == "[nope]"),
"the literal suffix remains visible and unstyled: {runs:?}"
);
}
#[test]
fn reference_label_matching_keeps_distinct_authored_spellings_distinct() {
for (definition, source, why) in [
(
"a&b",
"[a&b]",
"an entity definition does not match a raw candidate",
),
(
"a&b",
"[a&b]",
"a raw definition does not match an entity candidate",
),
(
r"a\*b",
"[a*b]",
"an escaped definition does not match a bare candidate",
),
(
r"a*b",
r"[a\*b]",
"a bare definition does not match an escaped candidate",
),
] {
let defs = LinkDefinitions::new([definition]);
let runs = parse_inline_with(source, &defs);
assert!(
runs.iter().all(|run| !run.link),
"{why} (cmark 0.31 agrees): {runs:?}"
);
}
}
#[test]
fn a_protected_reference_opener_never_opens_a_full_reference() {
let defs = LinkDefinitions::new(["text", "nope"]);
for (source, why) in [
(r"[text]\[nope]", "an escaped opener"),
("[text]`[nope]`", "a code-span opener"),
("[text]$[nope]$", "a math opener"),
("[text][nope]", "an entity-generated opener"),
] {
let runs = parse_inline_with(source, &defs);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
assert!(
projected.contains("nope"),
"{why} keeps its suffix visible: {projected:?}"
);
assert!(
runs.iter().any(|run| run.link && run.text == "text"),
"{why} still resolves the shortcut before it: {runs:?}"
);
}
}
#[test]
fn an_autolink_inside_a_double_backtick_code_span_stays_code() {
assert_eq!(
vec![code("<https://alix.study>")],
parse_inline("``<https://alix.study>``"),
"the code-span delimiter owns its body before autolink recognition"
);
}
#[test]
fn entities_decode_in_the_projection_but_never_become_syntax() {
for (source, expected, why) in [
("Tom & Jerry", "Tom & Jerry", "a named entity decodes"),
("AAB", "AAB", "a decimal entity decodes"),
("AAB", "AAB", "a hex entity decodes"),
(
"&bogus; &#; &#xG; &",
"&bogus; &#; &#xG; &",
"invalid forms stay literal",
),
(r"\&", "&", "an escaped ampersand kills the entity"),
(
"*x*",
"*x*",
"a decoded marker is content, not emphasis",
),
] {
let runs = parse_inline(source);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
assert_eq!(expected, projected, "{why}: {source:?}");
assert!(
runs.iter().all(|run| !run.bold && !run.italic),
"{why}: no styling from decoded characters: {runs:?}"
);
assert_eq!(expected, strip_inline(source), "{why} (strip): {source:?}");
}
assert_eq!(
vec![code("&")],
parse_inline("`&`"),
"a code span keeps the entity literal"
);
let math = parse_inline("$a & b$");
assert_eq!(math.len(), 1, "{math:?}");
assert_eq!(
math[0].text, "a & b",
"math source is verbatim, entities included"
);
let angles = parse_inline("<div>");
let projected: String = angles.iter().map(|run| run.text.as_str()).collect();
assert_eq!(
"<div>", projected,
"angle entities decode to inert brackets"
);
}
#[test]
fn code_span_runs_close_on_their_exact_length_in_the_projection() {
for (source, expected, why) in [
("`x`", vec![code("x")], "a single-backtick span"),
("``x``", vec![code("x")], "a double-backtick span"),
("```x```", vec![code("x")], "a triple-backtick span"),
(
"``a`b``",
vec![code("a`b")],
"a shorter run inside the body stays body",
),
(
"`` `x` ``",
vec![code(" `x` ")],
"a fully backtick-wrapped body stays body",
),
(
"a``b",
vec![plain("a``b")],
"an unmatched double run stays literal text",
),
(
"`a``",
vec![plain("`a``")],
"a longer run never closes a shorter opener",
),
] {
assert_eq!(expected, parse_inline(source), "{why}: {source:?}");
}
}
#[test]
fn backtick_anchored_inline_math_renders_like_bare_dollars() {
let runs = parse_inline("Why does $`a^2 + b^2 = c^2`$ hold?");
assert_eq!(runs.len(), 3, "prose, math, prose: {runs:?}");
assert_eq!(runs[1].text, "a^2 + b^2 = c^2");
let math = runs[1].math.as_ref().unwrap();
assert!(!math.display);
assert!(
math.svg
.as_deref()
.is_some_and(|svg| svg.starts_with("<svg"))
);
assert_eq!(
strip_inline("Why does $`a^2 + b^2 = c^2`$ hold?"),
"Why does a^2 + b^2 = c^2 hold?",
"the four anchor chars are markers, not content"
);
let styled = parse_inline("$`**x**`$");
assert_eq!(styled.len(), 1);
assert_eq!(styled[0].text, "**x**", "the body is verbatim math source");
assert!(styled[0].math.is_some());
assert!(!styled[0].bold, "math is protected from emphasis");
}
#[test]
fn backtick_anchored_math_boundaries_stay_literal() {
assert_eq!(parse_inline("$`x"), vec![plain("$`x")]);
for text in ["$``$", "$` `$", "$`x` y", r"\$`x`$"] {
let runs = parse_inline(text);
assert!(
runs.iter().all(|run| run.math.is_none()),
"an empty or whitespace anchored body is not math: {runs:?}"
);
assert!(
runs.first().is_some_and(|run| run.text.starts_with('$')),
"the dollars stay literal text: {runs:?}"
);
}
let code_first = parse_inline("`a $` b `$ c`");
assert!(
code_first.iter().all(|run| run.math.is_none()),
"a code span opening before the dollar keeps priority: {code_first:?}"
);
}
#[test]
fn a_marker_starting_at_the_closing_delimiter_is_outside_math() {
assert!(!math_encloses("$x$outside", "$outside"));
}
#[test]
fn whole_trimmed_line_is_display_math() {
let runs = parse_inline(" $$x_1$$ ");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "x_1");
assert!(runs[0].math.as_ref().unwrap().display);
assert_eq!(strip_inline(" $$x_1$$ "), "x_1");
assert!(is_display_math_line(" $$x_1$$ "));
}
#[test]
fn embedded_display_pair_is_wholly_literal() {
let text = "Compare $$x_1$$ now";
assert_eq!(parse_inline(text), vec![plain(text)]);
assert_eq!(strip_inline(text), text);
assert!(!is_display_math_line(text));
}
#[test]
fn delimiter_rules_avoid_currency_and_whitespace() {
for text in [
"The price is $5",
"$5 and $10",
"$5 and x$10",
"$ x $",
"$x $",
"$ x$",
] {
assert_eq!(parse_inline(text), vec![plain(text)], "{text}");
}
assert_eq!(parse_inline(r"\$x"), vec![plain("$x")]);
}
#[test]
fn unmatched_and_empty_math_stay_literal() {
for text in ["$x", "x$", "$$", "$$$$", "$ $", "before $$x"] {
assert_eq!(parse_inline(text), vec![plain(text)], "{text}");
}
}
#[test]
fn math_is_protected_from_emphasis_but_surrounding_emphasis_pairs() {
let runs = parse_inline("**energy $E=mc^2$**");
assert_eq!(runs.len(), 2);
assert_eq!(runs[0], bold("energy "));
assert_eq!(runs[1].text, "E=mc^2");
assert!(runs[1].math.is_some());
assert!(!runs[1].bold);
let formula = parse_inline("$x_i * y_j$");
assert_eq!(formula.len(), 1);
assert_eq!(formula[0].text, "x_i * y_j");
assert!(formula[0].math.is_some());
}
#[test]
fn dollars_inside_code_are_literal() {
assert_eq!(parse_inline("`$x$`"), vec![code("$x$")]);
assert_eq!(strip_inline("`$x$`"), "$x$");
}
#[test]
fn malformed_recognized_math_is_one_error_run() {
let runs = parse_inline(r"$\frac{1$");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, r"\frac{1");
let math = runs[0].math.as_ref().unwrap();
assert!(math.svg.is_none());
assert!(math.error.is_some());
}
#[test]
fn strip_inline_never_invokes_ratex() {
let before = crate::math::thread_render_count();
assert_eq!(
strip_inline(r"Answer $x^2$ and $$y^2$$"),
r"Answer x^2 and $$y^2$$"
);
assert_eq!(crate::math::thread_render_count(), before);
}
#[test]
fn logical_lines_recognize_display_math_independently() {
let runs = parse_inline("before\n$$x^2$$\nafter");
assert_eq!(runs.len(), 3);
assert_eq!(runs[0], plain("before\n"));
assert_eq!(runs[1].text, "x^2");
assert!(runs[1].math.as_ref().unwrap().display);
assert_eq!(runs[2], plain("\nafter"));
}
#[test]
fn repeated_formula_sources_render_once_per_projector() {
let mut projector = DisplayProjector::default();
projector.project("$x^2$ and $x^2$");
projector.project("$$x^2$$");
assert_eq!(projector.render_count(), 1);
}
#[test]
fn empty_input_yields_no_runs() {
assert!(parse_inline("").is_empty());
}
#[test]
fn math_scanner_resumes_at_the_exact_delimiter_boundary() {
let rows = [
("``$a$`", "a", "a span opening right after a code fence"),
("$$$a$", "a", "a span opening one byte into a display run"),
("x $$ab$$ $c$", "c", "the byte after a closed display run"),
("$`ab`$ $c$", "c", "the byte after a span holding code"),
("$`ab`$ $c$ `d`", "c", "the same, with code following"),
];
for (text, inner, why) in rows {
assert!(
math_encloses(text, inner),
"{why}: {text:?} must enclose {inner:?}"
);
}
assert_eq!(
1,
math_spans(&"$a$a$".chars().collect::<Vec<_>>()).len(),
"a closing delimiter is not reused as the next opener"
);
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
#[test]
fn projected_runs_are_a_normalized_content_projection(
(source, expected) in inline_source()
) {
let runs = parse_inline(&source);
let projected: String = runs.iter().map(|run| run.text.as_str()).collect();
let stripped = strip_inline(&source);
prop_assert_eq!(
projected.as_str(),
expected.as_str(),
"source: {:?}; runs: {:?}",
source,
runs
);
prop_assert_eq!(
stripped.as_str(),
expected.as_str(),
"source: {:?}",
source
);
prop_assert!(
runs.iter().all(|run| !run.text.is_empty()),
"empty run for source: {source:?}; runs: {runs:?}"
);
for pair in runs.windows(2) {
let mergeable = pair[0].math.is_none()
&& pair[1].math.is_none()
&& (
pair[0].bold,
pair[0].italic,
pair[0].strike,
pair[0].code,
pair[0].link,
pair[0].sub,
pair[0].sup,
pair[0].ins,
) == (
pair[1].bold,
pair[1].italic,
pair[1].strike,
pair[1].code,
pair[1].link,
pair[1].sub,
pair[1].sup,
pair[1].ins,
);
prop_assert!(
!mergeable,
"adjacent runs should have coalesced for source: {source:?}; pair: {pair:?}"
);
}
}
#[test]
fn inline_code_preserves_generated_markup_verbatim(body in inline_code_body()) {
let source = format!("`{body}`");
prop_assert_eq!(vec![code(&body)], parse_inline(&source));
}
#[test]
fn escaping_preserves_a_generated_marker_as_plain_text(
marker in prop::sample::select(vec!['*', '_', '$', '`', '\\']),
word in "[a-zA-Z]{1,12}",
) {
let source = format!("\\{marker}{word}\\{marker}");
let expected = format!("{marker}{word}{marker}");
prop_assert_eq!(vec![plain(&expected)], parse_inline(&source));
}
#[test]
fn generated_math_keeps_its_source_as_one_rendered_run(formula in math_formula()) {
let source = format!("before ${formula}$ after");
let runs = parse_inline(&source);
let math_runs: Vec<&InlineRun> =
runs.iter().filter(|run| run.math.is_some()).collect();
prop_assert_eq!(
1,
math_runs.len(),
"source: {:?}; runs: {:?}",
source,
runs
);
prop_assert_eq!(formula.as_str(), math_runs[0].text.as_str());
prop_assert!(
math_runs[0].math.as_ref().and_then(|math| math.svg.as_ref()).is_some(),
"generated formula did not render: {formula:?}; run: {:?}",
math_runs[0]
);
prop_assert_eq!(
format!("before {formula} after"),
strip_inline(&source)
);
}
#[test]
fn generated_math_boundaries_classify_the_same_marker_inside_and_outside(
formula in math_formula()
) {
let marker = crate::parser::BLANK;
let inside = format!("${formula} + {marker}$");
let outside = format!("${formula}$ + {marker}");
let code = format!("`${formula} + {marker}$`");
prop_assert!(math_encloses(&inside, marker), "inside: {inside:?}");
prop_assert!(!math_encloses(&outside, marker), "outside: {outside:?}");
prop_assert!(!math_encloses(&code, marker), "code: {code:?}");
}
}
}