use pulldown_cmark::{Event, Options, Parser};
#[derive(Debug, Clone)]
pub struct InlineColMap {
rendered_to_raw: Vec<usize>,
raw_to_rendered: Vec<usize>,
rendered_len: usize,
raw_len: usize,
}
impl InlineColMap {
pub fn build(raw_line: &str) -> Self {
let raw_len = raw_line.chars().count();
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;
for (event, range) in Parser::new_ext(raw_line, opts).into_offset_iter() {
match event {
Event::Text(_) => walk.push_text(raw_line, range),
Event::Code(s) => walk.push_code(raw_line, &s, range),
Event::SoftBreak | Event::HardBreak => walk.push_break(range.start),
_ => {}
}
}
let mut rendered_to_raw = walk.finish();
collapse_footnote_refs(raw_line, &mut rendered_to_raw);
let rendered_len = rendered_to_raw.len().saturating_sub(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,
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
}
}
struct CharMapWalk {
byte_to_char: Vec<usize>,
total_chars: usize,
map: 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(),
}
}
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));
byte += c.len_utf8();
}
byte
}
fn push_text(&mut self, raw_line: &str, range: std::ops::Range<usize>) {
let slice_end = range.end.min(raw_line.len());
let raw_slice = &raw_line[range.start..slice_end];
let mut byte = range.start;
let mut rest = raw_slice;
while let Some(start) = rest.find("==") {
let after_open = &rest[start + 2..];
let Some(rel_end) = after_open.find("==") else {
break;
};
byte = self.push_chars(&rest[..start], byte);
byte += 2; byte = self.push_chars(&after_open[..rel_end], byte);
byte += 2; rest = &after_open[rel_end + 2..];
}
self.push_chars(rest, byte);
}
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_break(&mut self, byte: usize) {
self.map.push(self.lookup(byte));
}
fn finish(mut self) -> Vec<usize> {
self.map.push(self.total_chars);
self.map
}
}
fn collapse_footnote_refs(raw_line: &str, rendered_to_raw: &mut Vec<usize>) {
let dropped = footnote_collapse_char_indices(raw_line);
if dropped.is_empty() {
return;
}
rendered_to_raw.retain(|&raw_char| !dropped.contains(&raw_char));
}
fn footnote_collapse_char_indices(raw_line: &str) -> 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;
}
if j < bytes.len() && bytes[j] == b']' && j > i + 2 {
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 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 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 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 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_triggers_checked_fallback() {
for (raw, actual_rendered) in [
("hello...", 6), ("a---b", 3), ("a--b", 3), ] {
let map = InlineColMap::build(raw);
assert_eq!(
map.raw_to_rendered_checked(0, actual_rendered),
None,
"smart-punct in {raw:?}: expected fallback (None) but walker matched",
);
let _ = map.raw_to_rendered(0);
}
}
#[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})"
);
}
}
}