use std::collections::HashSet;
use pulldown_cmark::{BrokenLink, CowStr, Event, Options, Parser, Tag, TagEnd};
use super::parser::TextRun;
use super::renderer::util::link_fallback;
use super::segment_break;
#[derive(Debug, Clone, Default)]
pub struct RefLabels {
links: HashSet<String>,
footnotes: HashSet<String>,
}
impl RefLabels {
pub fn new<'a>(
links: impl IntoIterator<Item = &'a str>,
footnotes: impl IntoIterator<Item = &'a str>,
) -> Self {
Self {
links: links.into_iter().map(normalize_label).collect(),
footnotes: footnotes.into_iter().map(normalize_label).collect(),
}
}
fn has_link(&self, label: &str) -> bool {
self.links.contains(&normalize_label(label))
}
fn has_footnote(&self, label: &str) -> bool {
self.footnotes.contains(&normalize_label(label))
}
}
fn normalize_label(label: &str) -> String {
label
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
pub fn strip_atx_closing(content: &str) -> &str {
let trimmed = content.trim_end_matches([' ', '\t']);
let before = trimmed.trim_end_matches('#');
if before.len() == trimmed.len() {
return content;
}
if before.is_empty() || before.ends_with([' ', '\t']) {
before
} else {
content
}
}
#[derive(Debug, Clone)]
pub struct InlineColMap {
rendered_to_raw: Vec<usize>,
breaks: Vec<usize>,
vanished: Vec<(usize, usize)>,
raw_to_rendered: Vec<usize>,
rendered_len: usize,
raw_len: usize,
}
struct Walked {
entries: Vec<(usize, Option<char>)>,
breaks: Vec<usize>,
soft: Vec<usize>,
}
impl InlineColMap {
pub fn build(raw_line: &str) -> Self {
let raw_len = raw_line.chars().count();
Self::from_walk(Self::walk(raw_line, None), raw_len)
}
fn walk(raw_line: &str, labels: Option<&RefLabels>) -> Walked {
let mut walk = CharMapWalk::new(raw_line);
let opts = Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_SMART_PUNCTUATION
| Options::ENABLE_MATH;
let resolve = |link: BrokenLink<'_>| {
labels
.filter(|l| l.has_link(&link.reference))
.map(|_| (CowStr::Borrowed(""), CowStr::Borrowed("")))
};
let parser = Parser::new_with_broken_link_callback(raw_line, opts, Some(resolve));
let mut link = None;
let mut text: Option<TextRun<'_>> = None;
for (event, range) in parser.into_offset_iter() {
if !matches!(event, Event::Text(_)) {
if let Some(run) = text.take() {
walk.push_text(raw_line, run.range.clone(), run.text());
}
}
match event {
Event::Start(Tag::Link { dest_url, .. }) => {
link = Some((walk.mark(), dest_url, range));
}
Event::End(TagEnd::Link) => {
if let Some((mark, url, range)) = link.take() {
walk.end_link(raw_line, mark, &url, range);
}
}
Event::Text(piece) => {
let raw = raw_line.get(range.clone()).unwrap_or("");
if let Some(run) = TextRun::feed(&mut text, piece, range, raw) {
walk.push_text(raw_line, run.range.clone(), run.text());
}
}
Event::Code(s) => walk.push_code(raw_line, &s, range),
Event::InlineMath(s) => walk.push_math(&s, "$", range),
Event::DisplayMath(s) => walk.push_math(&s, "$$", range),
Event::SoftBreak => walk.push_break(range.start, true),
Event::HardBreak => walk.push_break(range.start, false),
Event::InlineHtml(s)
if !crate::markdown::parser::post_pass::is_html_comment_only(&s) =>
{
let raw = raw_line.get(range.clone()).unwrap_or("");
walk.push_text(raw_line, range, raw)
}
_ => {}
}
}
if let Some(run) = text {
walk.push_text(raw_line, run.range.clone(), run.text());
}
let mut entries = walk.map;
collapse_footnote_refs(raw_line, labels, &mut entries);
Walked {
entries,
breaks: walk.breaks,
soft: walk.soft,
}
}
pub fn build_inline(text: &str, labels: &RefLabels) -> Self {
const PIN: &str = "a ";
let pinned = Self::walk(&format!("{PIN}{text}"), Some(labels));
let pin = PIN.len();
let unpin = |raws: Vec<usize>| raws.into_iter().map(|raw| raw - pin).collect();
let walked = Walked {
entries: pinned
.entries
.into_iter()
.filter(|&(raw, _)| raw >= pin)
.map(|(raw, ch)| (raw - pin, ch))
.collect(),
breaks: unpin(pinned.breaks),
soft: unpin(pinned.soft),
};
Self::from_walk(walked, text.chars().count())
}
fn from_walk(walked: Walked, raw_len: usize) -> Self {
let Walked {
entries,
mut breaks,
soft,
} = walked;
let chars: Vec<char> = entries.iter().filter_map(|&(_, c)| c).collect();
let mut rendered_to_raw = Vec::with_capacity(entries.len() + 1);
let mut vanished = Vec::new();
let mut k = 0usize;
for &(raw, ch) in &entries {
if ch.is_none() {
vanished.push((rendered_to_raw.len(), raw));
continue;
}
let neighbors = k
.checked_sub(1)
.and_then(|p| chars.get(p))
.zip(chars.get(k + 1));
k += 1;
let vanishes = soft.binary_search(&raw).is_ok()
&& neighbors
.is_some_and(|(&before, &after)| segment_break::vanishes(before, after));
if vanishes {
vanished.push((rendered_to_raw.len(), raw));
} else {
rendered_to_raw.push(raw);
}
}
breaks.retain(|&raw| vanished.binary_search_by_key(&raw, |&(_, v)| v).is_err());
rendered_to_raw.push(raw_len);
let rendered_len = rendered_to_raw.len() - 1;
let mut raw_to_rendered = vec![usize::MAX; raw_len + 1];
for (rendered_idx, &raw_char_idx) in rendered_to_raw.iter().enumerate() {
if raw_char_idx <= raw_len && raw_to_rendered[raw_char_idx] == usize::MAX {
raw_to_rendered[raw_char_idx] = rendered_idx;
}
}
raw_to_rendered[raw_len] = rendered_len;
for i in (0..raw_len).rev() {
if raw_to_rendered[i] == usize::MAX {
raw_to_rendered[i] = raw_to_rendered[i + 1];
}
}
Self {
rendered_to_raw,
breaks,
vanished,
raw_to_rendered,
rendered_len,
raw_len,
}
}
#[cfg(test)]
pub fn rendered_to_raw(&self, rendered_char: usize) -> usize {
let idx = rendered_char.min(self.rendered_len);
self.rendered_to_raw[idx]
}
pub fn raw_to_rendered(&self, raw_char: usize) -> usize {
let idx = raw_char.min(self.raw_len);
self.raw_to_rendered[idx]
}
pub fn raw_to_rendered_checked(
&self,
raw_char: usize,
actual_rendered_count: usize,
) -> Option<usize> {
if self.rendered_len != actual_rendered_count {
return None;
}
Some(self.raw_to_rendered(raw_char))
}
pub fn rendered_len(&self) -> usize {
self.rendered_len
}
pub fn raw_len(&self) -> usize {
self.raw_len
}
pub fn rendered_to_raw_vec(&self) -> &[usize] {
&self.rendered_to_raw
}
pub fn is_break(&self, rendered_char: usize) -> bool {
self.rendered_to_raw
.get(rendered_char)
.is_some_and(|raw| self.breaks.binary_search(raw).is_ok())
}
pub fn vanished_breaks(&self) -> &[(usize, usize)] {
&self.vanished
}
}
struct CharMapWalk {
byte_to_char: Vec<usize>,
total_chars: usize,
map: Vec<(usize, Option<char>)>,
breaks: Vec<usize>,
soft: Vec<usize>,
}
impl CharMapWalk {
fn new(raw_line: &str) -> Self {
let mut byte_to_char = vec![0usize; raw_line.len() + 1];
let mut char_idx = 0usize;
for (byte_idx, _) in raw_line.char_indices() {
byte_to_char[byte_idx] = char_idx;
char_idx += 1;
}
byte_to_char[raw_line.len()] = char_idx;
Self {
byte_to_char,
total_chars: char_idx,
map: Vec::new(),
breaks: Vec::new(),
soft: Vec::new(),
}
}
fn mark(&self) -> (usize, usize, usize) {
(self.map.len(), self.breaks.len(), self.soft.len())
}
fn end_link(
&mut self,
raw_line: &str,
mark: (usize, usize, usize),
url: &str,
range: std::ops::Range<usize>,
) {
let (map_len, breaks_len, soft_len) = mark;
if !self.map[map_len..]
.iter()
.all(|&(_, c)| c.is_none_or(char::is_whitespace))
{
return;
}
self.map.truncate(map_len);
self.map
.extend(self.breaks.drain(breaks_len..).map(|raw| (raw, None)));
self.soft.truncate(soft_len);
let fallback = link_fallback(url);
let raw = raw_line.get(range.clone()).unwrap_or("");
let dest = raw.find("](").map(|open| open + 2);
match dest.and_then(|d| raw[d..].find(fallback.as_str()).map(|at| d + at)) {
Some(at) => {
self.push_chars(&fallback, range.start + at);
}
None => {
let first = self.lookup(range.start);
self.map.extend(fallback.chars().map(|c| (first, Some(c))));
}
}
}
fn lookup(&self, byte: usize) -> usize {
self.byte_to_char
.get(byte.min(self.byte_to_char.len().saturating_sub(1)))
.copied()
.unwrap_or(self.total_chars)
}
fn push_chars(&mut self, text: &str, mut byte: usize) -> usize {
for c in text.chars() {
self.map.push((self.lookup(byte), Some(c)));
byte += c.len_utf8();
}
byte
}
fn push_text(&mut self, raw_line: &str, range: std::ops::Range<usize>, text: &str) {
let slice_end = range.end.min(raw_line.len());
let raw_slice = raw_line.get(range.start..slice_end).unwrap_or("");
let mut marks = highlight_marks(raw_slice).into_iter().peekable();
let mut at = 0usize;
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if marks.next_if_eq(&at).is_some() {
chars.next();
at += 2;
continue;
}
let rest = raw_slice.get(at..).unwrap_or("");
let run = match ch {
'…' if rest.starts_with("...") => 3,
'—' if rest.starts_with("---") => 3,
'–' if rest.starts_with("--") => 2,
_ => rest.chars().next().map_or(0, char::len_utf8),
};
self.map.push((self.lookup(range.start + at), Some(ch)));
at += run;
}
}
fn push_code(&mut self, raw_line: &str, inner: &str, range: std::ops::Range<usize>) {
let slice_end = range.end.min(raw_line.len());
let slice = &raw_line[range.start..slice_end];
let delim = slice.bytes().take_while(|&b| b == b'`').count();
let body_end = slice.len().saturating_sub(delim).max(delim);
let body = &slice[delim..body_end];
let stripped = body != inner
&& body
.strip_prefix(' ')
.and_then(|b| b.strip_suffix(' '))
.is_some_and(|b| b == inner);
let content_start = range.start + delim + usize::from(stripped);
self.push_chars(inner, content_start);
}
fn push_math(&mut self, source: &str, delim: &str, range: std::ops::Range<usize>) {
self.push_chars(delim, range.start);
self.push_chars(source, range.start + delim.len());
self.push_chars(delim, range.end.saturating_sub(delim.len()));
}
fn push_break(&mut self, byte: usize, soft: bool) {
let raw = self.lookup(byte);
self.map.push((raw, Some(' ')));
self.breaks.push(raw);
if soft {
self.soft.push(raw);
}
}
}
fn highlight_marks(raw: &str) -> Vec<usize> {
let mut marks = Vec::new();
let mut from = 0usize;
while let Some(open) = raw[from..].find("==").map(|i| from + i) {
let Some(close) = raw[open + 2..].find("==").map(|i| open + 2 + i) else {
break;
};
marks.extend([open, close]);
from = close + 2;
}
marks
}
fn collapse_footnote_refs(
raw_line: &str,
labels: Option<&RefLabels>,
entries: &mut Vec<(usize, Option<char>)>,
) {
let dropped = footnote_collapse_char_indices(raw_line, labels);
if dropped.is_empty() {
return;
}
entries.retain(|(raw_char, _)| !dropped.contains(raw_char));
}
fn footnote_collapse_char_indices(raw_line: &str, labels: Option<&RefLabels>) -> Vec<usize> {
let bytes = raw_line.as_bytes();
let mut dropped = Vec::new();
let mut prev_end: Option<usize> = None;
let mut char_idx = 0usize; let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'[' && bytes.get(i + 1) == Some(&b'^') {
let mut j = i + 2;
while j < bytes.len() && bytes[j] != b']' && bytes[j] != b'\n' {
j += 1;
}
let defined = || labels.is_none_or(|l| l.has_footnote(&raw_line[i + 2..j]));
if j < bytes.len() && bytes[j] == b']' && j > i + 2 && defined() {
dropped.push(char_idx + 1);
if prev_end == Some(i) {
dropped.push(char_idx);
}
prev_end = Some(j + 1);
}
}
i += 1;
if raw_line.is_char_boundary(i) {
char_idx += 1;
}
}
dropped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_inline_reads_a_slice_as_paragraph_text() {
for (text, rendered) in [
("2. a", 4),
("# b *c*", 5),
(" x", 5),
("> q", 3),
("", 0),
] {
let map = InlineColMap::build_inline(text, &RefLabels::default());
assert_eq!(map.rendered_len(), rendered, "{text:?}");
assert_eq!(map.raw_len(), text.chars().count(), "{text:?}");
}
let map = InlineColMap::build_inline("# b *c*", &RefLabels::default());
assert_eq!(map.rendered_to_raw(4), 5, "the `c` past its `*`");
assert_eq!(map.raw_to_rendered(0), 0);
}
#[test]
fn build_inline_resolves_the_documents_references() {
let labels = RefLabels::new(["Ref One"], ["n"]);
for (text, rendered) in [
("see [it][ref one] now", 10),
("see [ref one] now", 15),
("see [it][nope] now", 18),
("x[^N] y", 6),
("x[^z] y", 7),
] {
let map = InlineColMap::build_inline(text, &labels);
assert_eq!(map.rendered_len(), rendered, "{text:?}");
}
let map = InlineColMap::build_inline("see [it][ref one] now", &labels);
assert_eq!(map.rendered_to_raw(4), 5, "the `i` past the `[`");
assert_eq!(map.rendered_to_raw(7), 18, "the `n` past the label");
}
#[test]
fn a_soft_break_between_cjk_glyphs_vanishes_from_the_map() {
let labels = RefLabels::default();
let map = InlineColMap::build_inline("日本\n*語*\nabc", &labels);
assert_eq!(map.rendered_len(), 7);
assert_eq!(map.vanished_breaks(), [(2, 2)]);
assert_eq!(map.rendered_to_raw(2), 4, "`語` past its `*`");
assert!(map.is_break(3) && !map.is_break(2));
assert_eq!(
map.raw_to_rendered(2),
2,
"the vanished newline maps to `語`"
);
let map = InlineColMap::build_inline("日本\\\n語", &labels);
assert_eq!(map.rendered_len(), 4, "a hard break stays");
assert!(map.vanished_breaks().is_empty());
let map = InlineColMap::build_inline("한국\n어", &labels);
assert_eq!(map.rendered_len(), 4, "Hangul keeps its space");
}
#[test]
fn strip_atx_closing_drops_only_a_closing_sequence() {
for (content, stripped) in [
("Title ##", "Title "),
("Title # ", "Title "),
("Title\t#", "Title\t"),
("##", ""),
("C#", "C#"),
("foo \\#", "foo \\#"),
("Title", "Title"),
("Title ", "Title "),
] {
assert_eq!(strip_atx_closing(content), stripped, "{content:?}");
}
}
#[test]
fn inline_html_maps_like_text_and_a_comment_like_nothing() {
let map = InlineColMap::build("a <b>x</b>");
assert_eq!(map.rendered_len(), 10);
assert_eq!(map.rendered_to_raw(2), 2);
let map = InlineColMap::build("a <!-- c --> x");
assert_eq!(map.rendered_len(), 4);
}
#[test]
fn plain_text_maps_one_to_one() {
let map = InlineColMap::build("hello world");
assert_eq!(map.rendered_len(), 11);
assert_eq!(map.raw_len(), 11);
for i in 0..=11 {
assert_eq!(map.rendered_to_raw(i), i);
assert_eq!(map.raw_to_rendered(i), i);
}
}
#[test]
fn bold_text_skips_markers() {
let map = InlineColMap::build("**Bold text**");
assert_eq!(map.rendered_len(), 9);
assert_eq!(map.raw_len(), 13);
assert_eq!(map.rendered_to_raw(0), 2); assert_eq!(map.rendered_to_raw(8), 10); assert_eq!(map.rendered_to_raw(9), 13); assert_eq!(map.raw_to_rendered(0), 0); assert_eq!(map.raw_to_rendered(1), 0);
assert_eq!(map.raw_to_rendered(2), 0); assert_eq!(map.raw_to_rendered(10), 8); assert_eq!(map.raw_to_rendered(11), 9); assert_eq!(map.raw_to_rendered(12), 9);
assert_eq!(map.raw_to_rendered(13), 9); }
#[test]
fn italic_text_skips_markers() {
let map = InlineColMap::build("*Italic*");
assert_eq!(map.rendered_len(), 6);
assert_eq!(map.rendered_to_raw(0), 1);
assert_eq!(map.rendered_to_raw(5), 6);
}
#[test]
fn underscore_emphasis_skips_markers() {
let map = InlineColMap::build("_under_");
assert_eq!(map.rendered_len(), 5);
assert_eq!(map.rendered_to_raw(0), 1);
}
#[test]
fn strikethrough_skips_markers() {
let map = InlineColMap::build("~~strike~~");
assert_eq!(map.rendered_len(), 6);
assert_eq!(map.rendered_to_raw(0), 2);
assert_eq!(map.rendered_to_raw(5), 7);
}
#[test]
fn highlight_skips_markers() {
let map = InlineColMap::build("alpha ==beta== gamma");
assert_eq!(map.rendered_len(), 16);
assert_eq!(&map.rendered_to_raw_vec()[..6], &[0, 1, 2, 3, 4, 5]);
assert_eq!(&map.rendered_to_raw_vec()[6..10], &[8, 9, 10, 11]);
assert_eq!(
&map.rendered_to_raw_vec()[10..16],
&[14, 15, 16, 17, 18, 19]
);
assert_eq!(map.rendered_to_raw_vec()[16], 20);
}
#[test]
fn math_splits_the_text_around_it() {
for text in ["y== $a$ ==x", "y== $$a$$ ==x"] {
let map = InlineColMap::build(text);
let identity: Vec<usize> = (0..=text.len()).collect();
assert_eq!(map.rendered_to_raw_vec(), identity, "{text:?}");
}
}
#[test]
fn nested_bold_italic() {
let map = InlineColMap::build("**_Bold and italic_**");
assert_eq!(map.rendered_len(), 15);
assert_eq!(map.rendered_to_raw(0), 3);
}
#[test]
fn code_span_skips_backticks() {
let map = InlineColMap::build("`code`");
assert_eq!(map.rendered_len(), 4);
assert_eq!(map.rendered_to_raw(0), 1);
assert_eq!(map.rendered_to_raw(3), 4);
assert_eq!(map.raw_to_rendered(0), 0);
assert_eq!(map.raw_to_rendered(5), 4);
}
#[test]
fn double_backtick_code_span_skips_both_delimiters() {
let map = InlineColMap::build("``a`b``");
assert_eq!(map.rendered_len(), 3);
assert_eq!(map.rendered_to_raw(0), 2);
assert_eq!(map.rendered_to_raw(1), 3); assert_eq!(map.rendered_to_raw(2), 4);
}
#[test]
fn code_span_with_stripped_spaces_stays_aligned() {
let map = InlineColMap::build("` x `");
assert_eq!(map.rendered_len(), 1);
assert_eq!(map.rendered_to_raw(0), 2);
}
#[test]
fn code_span_round_trips() {
let map = InlineColMap::build("see `fn main()` here");
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
assert_eq!(
map.raw_to_rendered(raw_col),
rendered_col,
"round-trip failed at rendered col {rendered_col}"
);
}
}
#[test]
fn link_collapses_url() {
let map = InlineColMap::build("[File link](./plan.md)");
assert_eq!(map.rendered_len(), 9);
assert_eq!(
&map.rendered_to_raw_vec()[..9],
&[1, 2, 3, 4, 5, 6, 7, 8, 9]
);
assert_eq!(map.rendered_to_raw_vec()[9], 22); }
#[test]
fn blank_link_text_maps_its_url_fallback() {
let map = InlineColMap::build("a [](u) b");
assert_eq!(map.rendered_len(), 5, "a ␠ u ␠ b");
assert_eq!(map.rendered_to_raw(2), 5, "`u` onto the destination");
let map = InlineColMap::build("[ ](./dir/plan.md \"plan.md\")");
assert_eq!(
map.rendered_len(),
7,
"the file name, the blank text dropped"
);
assert_eq!(
map.rendered_to_raw(0),
10,
"`p` of the destination's file name"
);
let map = InlineColMap::build("[](a\\_b)");
assert_eq!(
map.rendered_len(),
3,
"an escaped destination renders unescaped"
);
assert!(
(0..3).all(|k| map.rendered_to_raw(k) == 0),
"onto the link's `[`"
);
}
#[test]
fn a_blank_links_breaks_vanish_before_its_fallback() {
let map = InlineColMap::build("日本\n[\n](https://x)\nabc");
assert_eq!(map.rendered_len(), 16);
assert_eq!(map.vanished_breaks(), [(3, 4)]);
assert!(map.is_break(2) && map.is_break(12) && !map.is_break(3));
assert_eq!(map.rendered_to_raw(3), 7, "`h` onto the destination");
}
#[test]
fn link_round_trip() {
let map = InlineColMap::build("[File link](./plan.md)");
for rendered_col in 0..9 {
let raw_col = map.rendered_to_raw(rendered_col);
let back = map.raw_to_rendered(raw_col);
assert_eq!(
back, rendered_col,
"round-trip failed at rendered col {rendered_col}"
);
}
}
#[test]
fn highlight_round_trip() {
let map = InlineColMap::build("alpha ==beta== gamma");
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
let back = map.raw_to_rendered(raw_col);
assert_eq!(
back, rendered_col,
"round-trip failed at rendered col {rendered_col}"
);
}
}
#[test]
fn highlight_across_smart_punctuation() {
let map = InlineColMap::build("==it's== x");
assert_eq!(map.rendered_len(), 6);
assert_eq!(map.rendered_to_raw(0), 2, "the `i` past the opening marker");
assert_eq!(map.rendered_to_raw(2), 4, "the `’` on its `'`");
assert_eq!(
map.rendered_to_raw(4),
8,
"the space past the closing marker"
);
}
#[test]
fn highlight_across_dash_run() {
let map = InlineColMap::build("==a ---- b==");
assert_eq!(map.rendered_len(), 6);
assert_eq!(map.rendered_to_raw(2), 4, "the first `–` on its `--`");
assert_eq!(map.rendered_to_raw(3), 6, "the second `–` on its `--`");
assert_eq!(
map.rendered_to_raw(5),
9,
"the `b` before the closing marker"
);
}
#[test]
fn heading_well_formedness_mismatch() {
let map = InlineColMap::build("## Heading");
assert_eq!(map.rendered_len(), 7);
assert_eq!(map.raw_to_rendered_checked(5, 9), None);
assert!(map.raw_to_rendered(5) <= map.rendered_len());
}
#[test]
fn blockquote_well_formedness_mismatch() {
let map = InlineColMap::build("> blockquoted text");
assert_eq!(map.raw_to_rendered_checked(3, 18), None);
}
#[test]
fn marker_byte_maps_to_next_visible() {
let map = InlineColMap::build("[link](url)");
assert_eq!(map.raw_to_rendered(0), 0); assert_eq!(map.raw_to_rendered(5), 4);
}
#[test]
fn list_prefix_backward_fills() {
let map = InlineColMap::build("- **bold** item");
assert_eq!(map.raw_to_rendered(0), 0);
assert_eq!(map.raw_to_rendered(1), 0);
assert_eq!(map.raw_to_rendered(4), 0);
}
#[test]
fn multi_char_smart_punct_maps_each_glyph_to_its_run() {
for (raw, rendered, glyph_raw) in [
("hello...", 6, 5), ("a---b", 3, 1), ("a--b", 3, 1), ] {
let map = InlineColMap::build(raw);
assert_eq!(map.rendered_len(), rendered, "{raw:?}");
assert_eq!(
map.raw_to_rendered(glyph_raw),
glyph_raw,
"{raw:?}: the run's first char"
);
assert_eq!(map.raw_to_rendered(glyph_raw + 1), glyph_raw + 1, "{raw:?}");
}
let map = InlineColMap::build("a--b");
assert_eq!(map.rendered_to_raw(2), 3, "the `b` past the dash");
}
#[test]
fn smart_punct_collapses_beside_highlight_markers() {
let map = InlineColMap::build("x == y...");
assert_eq!(map.rendered_len(), 7);
assert_eq!(map.rendered_to_raw(6), 6, "the `…` on its run's first `.`");
let map = InlineColMap::build("a ==hi== b...");
assert_eq!(map.rendered_len(), 7);
assert_eq!(map.rendered_to_raw(2), 4, "the `h` past the opening marker");
assert_eq!(map.rendered_to_raw(5), 9, "the `b` past the closing marker");
assert_eq!(map.rendered_to_raw(6), 10);
}
#[test]
fn curly_quote_substitution_round_trips() {
let map = InlineColMap::build("\"hi\"");
assert_eq!(map.rendered_len(), 4);
assert_eq!(map.raw_len(), 4);
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
assert_eq!(map.raw_to_rendered(raw_col), rendered_col);
}
assert_eq!(map.raw_to_rendered_checked(0, 4), Some(0));
}
#[test]
fn unicode_text_round_trip() {
let map = InlineColMap::build("café résumé");
assert_eq!(map.rendered_len(), map.raw_len());
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
assert_eq!(
map.raw_to_rendered(raw_col),
rendered_col,
"round-trip failed at rendered col {rendered_col}",
);
}
}
#[test]
fn empty_line() {
let map = InlineColMap::build("");
assert_eq!(map.rendered_len(), 0);
assert_eq!(map.raw_len(), 0);
assert_eq!(map.raw_to_rendered(0), 0);
assert_eq!(map.raw_to_rendered_checked(0, 0), Some(0));
assert_eq!(map.raw_to_rendered_checked(0, 2), None);
}
#[test]
fn raw_to_rendered_clamps_past_end() {
let map = InlineColMap::build("hi");
assert_eq!(map.raw_to_rendered(2), 2);
assert_eq!(map.raw_to_rendered(999), 2);
assert_eq!(map.raw_to_rendered_checked(999, 2), Some(2));
}
#[test]
fn checked_accepts_plain_rejects_prefix_mismatch() {
let map = InlineColMap::build("plain text");
assert_eq!(map.raw_to_rendered_checked(0, 10), Some(0));
assert_eq!(map.raw_to_rendered_checked(5, 10), Some(5));
assert_eq!(map.raw_to_rendered_checked(0, 12), None);
assert_eq!(map.raw_to_rendered_checked(0, 11), None);
assert_eq!(map.raw_to_rendered_checked(0, 9), None);
}
#[test]
fn footnote_reference_line_matches_renderer_and_projects_exactly() {
let map = InlineColMap::build("see[^1] here");
let actual_rendered = "see[1] here".chars().count(); assert_eq!(map.rendered_len(), actual_rendered);
assert_eq!(map.raw_to_rendered_checked(0, actual_rendered), Some(0));
assert_eq!(map.raw_to_rendered(3), 3);
assert_eq!(map.raw_to_rendered(5), 4);
assert_eq!(map.raw_to_rendered(8), 7);
assert_eq!(map.raw_to_rendered(4), 4);
}
#[test]
fn footnote_reference_round_trips() {
let map = InlineColMap::build("a[^12]b"); assert_eq!(map.rendered_len(), 6);
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
assert_eq!(
map.raw_to_rendered(raw_col),
rendered_col,
"round-trip failed at rendered col {rendered_col}",
);
}
}
#[test]
fn named_footnote_reference_collapses_the_caret_only() {
let map = InlineColMap::build("x[^note]y");
assert_eq!(map.rendered_len(), "x[note]y".chars().count());
}
#[test]
fn adjacent_footnote_references_collapse_into_one_marker() {
let map = InlineColMap::build("Two.[^1][^2] more");
let actual_rendered = "Two.[1,2] more".chars().count();
assert_eq!(map.rendered_len(), actual_rendered);
assert_eq!(map.raw_to_rendered_checked(0, actual_rendered), Some(0));
assert_eq!(map.raw_to_rendered(4), 4); assert_eq!(map.raw_to_rendered(6), 5); assert_eq!(map.raw_to_rendered(7), 6); assert_eq!(map.raw_to_rendered(10), 7); assert_eq!(map.raw_to_rendered(11), 8); assert_eq!(map.raw_to_rendered(13), 10); assert_eq!(map.raw_to_rendered(8), 7);
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
assert_eq!(
map.raw_to_rendered(raw_col),
rendered_col,
"round-trip failed at rendered col {rendered_col}",
);
}
}
#[test]
fn spaced_footnote_references_stay_separate() {
let map = InlineColMap::build("Two.[^1] [^2]");
assert_eq!(map.rendered_len(), "Two.[1] [2]".chars().count());
}
#[test]
fn three_adjacent_footnote_references_fuse() {
let map = InlineColMap::build("x[^1][^2][^3]y");
assert_eq!(map.rendered_len(), "x[1,2,3]y".chars().count());
}
#[test]
fn undefined_reference_falls_back_to_1_1() {
let map = InlineColMap::build("Two.[^1][^2] more");
let actual_rendered = "Two.[1][^2] more".chars().count();
assert_ne!(
map.rendered_len(),
actual_rendered,
"the definition-blind scan is expected to disagree here",
);
assert_eq!(
map.raw_to_rendered_checked(0, actual_rendered),
None,
"the length check must decline so the caller falls back to 1:1",
);
}
#[test]
fn mixed_formatting() {
let raw = "**bold** *italic* `code` [link](url)";
let map = InlineColMap::build(raw);
for rendered_col in 0..map.rendered_len() {
let raw_col = map.rendered_to_raw(rendered_col);
let back = map.raw_to_rendered(raw_col);
assert_eq!(
back, rendered_col,
"round-trip failed at rendered col {rendered_col} (raw {raw_col})"
);
}
}
}