use std::borrow::Cow;
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineCol {
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PositionEncoding {
Utf8,
#[default]
Utf16,
}
fn ends_line(bytes: &[u8], at: usize) -> bool {
bytes[at] == b'\n' || bytes.get(at + 1) != Some(&b'\n')
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineTable {
line_starts: Vec<usize>,
wide_lines: Vec<bool>,
}
impl LineTable {
pub fn new(text: &str) -> Self {
let bytes = text.as_bytes();
let mut line_starts = Vec::with_capacity(text.len() / 40 + 1);
line_starts.push(0);
line_starts.extend(
memchr::memchr2_iter(b'\n', b'\r', bytes)
.filter(|&at| ends_line(bytes, at))
.map(|at| at + 1),
);
let mut table = Self {
wide_lines: vec![false; line_starts.len()],
line_starts,
};
if !bytes.is_ascii() {
for line in 0..table.line_starts.len() {
let wide = table.is_wide(text, line);
table.wide_lines[line] = wide;
}
}
table
}
fn line_span(&self, len: usize, line: usize) -> Range<usize> {
let start = self.line_starts[line];
let end = self.line_starts.get(line + 1).copied().unwrap_or(len);
start..end
}
fn is_wide(&self, text: &str, line: usize) -> bool {
!text.as_bytes()[self.line_span(text.len(), line)].is_ascii()
}
pub fn patch(&mut self, range: Range<usize>, insert_len: usize, new: &str) {
let Range { start, end } = range;
debug_assert!(start <= end, "reversed edit range {start}..{end}");
let bytes = new.as_bytes();
let delta = insert_len as isize - (end - start) as isize;
let first = self.line_starts.partition_point(|&at| at < start).max(1);
let last = self.line_starts.partition_point(|&at| at <= end);
if delta != 0 {
for at in &mut self.line_starts[last..] {
*at = at.wrapping_add_signed(delta);
}
}
let lo = start.saturating_sub(1);
let hi = start + insert_len;
let derived: Vec<usize> = memchr::memchr2_iter(b'\n', b'\r', &bytes[lo..hi])
.map(|at| lo + at)
.filter(|&at| ends_line(bytes, at))
.map(|at| at + 1)
.collect();
let derived_len = derived.len();
self.line_starts.splice(first..last, derived);
self.wide_lines
.splice(first..last, std::iter::repeat_n(false, derived_len));
for line in (first - 1)..(first + derived_len) {
let wide = self.is_wide(new, line);
self.wide_lines[line] = wide;
}
}
}
#[derive(Debug, Clone)]
pub struct LineIndex<'a> {
text: &'a str,
table: Cow<'a, LineTable>,
encoding: PositionEncoding,
}
impl<'a> LineIndex<'a> {
pub fn new(text: &'a str) -> Self {
Self::with_encoding(text, PositionEncoding::Utf16)
}
pub fn with_encoding(text: &'a str, encoding: PositionEncoding) -> Self {
Self {
text,
table: Cow::Owned(LineTable::new(text)),
encoding,
}
}
pub fn with_table(text: &'a str, table: &'a LineTable, encoding: PositionEncoding) -> Self {
Self {
text,
table: Cow::Borrowed(table),
encoding,
}
}
fn line_of(&self, offset: usize) -> usize {
match self.table.line_starts.binary_search(&offset) {
Ok(line) => line,
Err(next) => next - 1,
}
}
pub fn line_start(&self, line: usize) -> usize {
self.table
.line_starts
.get(line)
.copied()
.unwrap_or(self.text.len())
}
fn line_content_end(&self, line: usize) -> usize {
let start = self.table.line_starts[line];
let Some(&next) = self.table.line_starts.get(line + 1) else {
return self.text.len();
};
let bytes = self.text.as_bytes();
let end = next - 1;
if bytes[end] == b'\n' && end > start && bytes[end - 1] == b'\r' {
return end - 1;
}
end
}
pub fn line_col(&self, offset: usize) -> LineCol {
let offset = offset.min(self.text.len());
let line = self.line_of(offset);
let start = self.table.line_starts[line];
let points = if self.table.wide_lines[line] {
self.chars_before(start, offset).count()
} else {
offset - start
};
LineCol {
line: line + 1,
column: points + 1,
}
}
pub fn position(&self, offset: usize) -> (u32, u32) {
let offset = offset.min(self.text.len());
let line = self.line_of(offset);
let start = self.table.line_starts[line];
let byte_col = offset - start;
let character = match self.encoding {
PositionEncoding::Utf8 => byte_col,
PositionEncoding::Utf16 if !self.table.wide_lines[line] => byte_col,
PositionEncoding::Utf16 => self.chars_before(start, offset).map(char::len_utf16).sum(),
};
(line as u32, character as u32)
}
fn chars_before(&self, start: usize, offset: usize) -> impl Iterator<Item = char> + '_ {
self.text[start..]
.char_indices()
.take_while(move |&(at, _)| start + at < offset)
.map(|(_, ch)| ch)
}
pub fn offset_at(&self, line: u32, character: u32) -> usize {
let line = line as usize;
let Some(&start) = self.table.line_starts.get(line) else {
return self.text.len();
};
let content_end = self.line_content_end(line);
let character = character as usize;
if !self.table.wide_lines[line] {
return content_end.min(start + character);
}
let mut units = 0usize;
for (at, ch) in self.text[start..content_end].char_indices() {
if units >= character {
return start + at;
}
units += match self.encoding {
PositionEncoding::Utf8 => ch.len_utf8(),
PositionEncoding::Utf16 => ch.len_utf16(),
};
}
content_end
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_col_basic() {
let text = "ab\ncde\n";
let idx = LineIndex::new(text);
assert_eq!(idx.line_col(0), LineCol { line: 1, column: 1 });
assert_eq!(idx.line_col(1), LineCol { line: 1, column: 2 });
assert_eq!(idx.line_col(3), LineCol { line: 2, column: 1 });
assert_eq!(idx.line_col(5), LineCol { line: 2, column: 3 });
}
#[test]
fn utf16_counts_surrogates() {
let text = "a𝕏b";
let idx = LineIndex::new(text);
let off = "a𝕏".len(); assert_eq!(idx.position(off), (0, 3));
}
#[test]
fn utf8_counts_bytes() {
let text = "a𝕏b";
let idx = LineIndex::with_encoding(text, PositionEncoding::Utf8);
let off = "a𝕏".len();
assert_eq!(idx.position(off), (0, 5));
assert_eq!(idx.offset_at(0, 5), off);
}
#[test]
fn crlf_line_starts() {
let text = "a\r\nb";
let idx = LineIndex::new(text);
assert_eq!(idx.line_col(3), LineCol { line: 2, column: 1 });
}
#[test]
fn multiple_wide_chars_on_a_line() {
let text = "a£b€c𝕏d";
let off = |s: &str| s.len();
let after_a = off("a");
let after_pound = off("a£");
let after_b = off("a£b");
let after_euro = off("a£b€");
let after_c = off("a£b€c");
let after_astral = off("a£b€c𝕏");
let after_d = off("a£b€c𝕏d");
let cp = LineIndex::new(text);
assert_eq!(cp.line_col(after_a).column, 2);
assert_eq!(cp.line_col(after_pound).column, 3);
assert_eq!(cp.line_col(after_b).column, 4);
assert_eq!(cp.line_col(after_euro).column, 5);
assert_eq!(cp.line_col(after_c).column, 6);
assert_eq!(cp.line_col(after_astral).column, 7);
assert_eq!(cp.line_col(after_d).column, 8);
let u16 = LineIndex::with_encoding(text, PositionEncoding::Utf16);
assert_eq!(u16.position(after_a), (0, 1));
assert_eq!(u16.position(after_pound), (0, 2));
assert_eq!(u16.position(after_b), (0, 3));
assert_eq!(u16.position(after_euro), (0, 4));
assert_eq!(u16.position(after_c), (0, 5));
assert_eq!(u16.position(after_astral), (0, 7));
assert_eq!(u16.position(after_d), (0, 8));
let u8 = LineIndex::with_encoding(text, PositionEncoding::Utf8);
assert_eq!(u8.position(after_astral), (0, after_astral as u32));
for encoding in [PositionEncoding::Utf16, PositionEncoding::Utf8] {
let idx = LineIndex::with_encoding(text, encoding);
for offset in (0..=text.len()).filter(|&o| text.is_char_boundary(o)) {
let (line, character) = idx.position(offset);
assert_eq!(
idx.offset_at(line, character),
offset,
"offset {offset} ({encoding:?})"
);
}
}
}
#[test]
fn offset_at_round_trips_positions_in_both_encodings() {
let text = "a𝕏b\ncd";
for encoding in [PositionEncoding::Utf16, PositionEncoding::Utf8] {
let idx = LineIndex::with_encoding(text, encoding);
for offset in (0..=text.len()).filter(|&o| text.is_char_boundary(o)) {
let (line, character) = idx.position(offset);
assert_eq!(
idx.offset_at(line, character),
offset,
"offset {offset} ({encoding:?})"
);
}
}
}
#[test]
fn offset_at_crlf_terminator() {
let text = "ab\r\ncd";
let idx = LineIndex::new(text);
assert_eq!(idx.offset_at(0, 2), 2); assert_eq!(idx.offset_at(1, 0), 4); }
#[test]
fn offset_at_clamps_out_of_range() {
let text = "ab\ncde\n";
let idx = LineIndex::new(text);
assert_eq!(idx.offset_at(0, 99), 2);
assert_eq!(idx.offset_at(2, 0), 7);
assert_eq!(idx.offset_at(99, 0), text.len());
}
#[test]
fn offset_at_inside_surrogate_pair_snaps_to_code_point_end() {
let text = "𝕏";
let idx = LineIndex::new(text);
assert_eq!(idx.offset_at(0, 1), text.len());
}
#[test]
fn offset_at_inside_utf8_sequence_snaps_to_code_point_end() {
let text = "𝕏";
let idx = LineIndex::with_encoding(text, PositionEncoding::Utf8);
assert_eq!(idx.offset_at(0, 2), text.len());
assert_eq!(idx.offset_at(0, 99), text.len());
}
#[test]
fn line_col_inside_a_code_point_counts_that_code_point() {
let idx = LineIndex::new("𝕏b");
assert_eq!(idx.line_col(0).column, 1);
assert_eq!(idx.line_col(2).column, 2);
assert_eq!(idx.line_col(4).column, 2);
}
const AWKWARD: &[&str] = &[
"",
"\n",
"\r",
"\n\n",
"\r\n",
"\n\r",
"\r\r",
"abc",
"ab\ncd\nef\n",
"a\r\nb\r\n",
"a\rb\n",
"a\r\r\nb",
"\u{1F600}\nx\n",
"café\r\nx",
"ä",
"ä\r\nö\rü\n",
];
fn reference_scan(text: &str) -> (Vec<usize>, Vec<usize>) {
let len = text.len();
let mut line_starts = vec![0];
let mut line_ends = Vec::new();
let bytes = text.as_bytes();
let mut skip_lf = false;
for (i, ch) in text.char_indices() {
match ch {
'\n' if skip_lf => skip_lf = false,
'\n' => {
line_ends.push(i);
line_starts.push(i + 1);
}
'\r' => {
line_ends.push(i);
if bytes.get(i + 1) == Some(&b'\n') {
line_starts.push(i + 2);
skip_lf = true;
} else {
line_starts.push(i + 1);
}
}
_ => {}
}
}
line_ends.push(len);
(line_starts, line_ends)
}
fn assert_matches_reference(text: &str, label: &str) {
let (starts, ends) = reference_scan(text);
let table = LineTable::new(text);
assert_eq!(table.line_starts, starts, "line starts of {label}");
let idx = LineIndex::new(text);
let derived: Vec<usize> = (0..starts.len()).map(|l| idx.line_content_end(l)).collect();
assert_eq!(derived, ends, "line content ends of {label}");
}
#[test]
fn the_scan_matches_the_char_by_char_reference() {
for text in AWKWARD {
assert_matches_reference(text, &format!("{text:?}"));
}
let doc = include_str!("../../benches/documents/small.tex");
assert_matches_reference(doc, "small.tex");
assert_matches_reference(&doc.replace('\n', "\r\n"), "small.tex as CRLF");
}
const INSERTS: &[&str] = &["", "z", "\n", "\r", "\r\n", "\n\r", "\n\n", "x\ny\n", "é"];
#[test]
fn patching_matches_a_rescan() {
for text in AWKWARD {
for start in (0..=text.len()).filter(|&at| text.is_char_boundary(at)) {
for end in (start..=text.len()).filter(|&at| text.is_char_boundary(at)) {
for insert in INSERTS {
let mut edited = text.to_string();
edited.replace_range(start..end, insert);
let mut patched = LineTable::new(text);
patched.patch(start..end, insert.len(), &edited);
assert_eq!(
patched,
LineTable::new(&edited),
"patching {text:?}[{start}..{end}] with {insert:?} \
diverged from a rescan of {edited:?}"
);
}
}
}
}
}
#[test]
fn an_edit_that_splits_a_crlf_makes_two_lines() {
let mut table = LineTable::new("a\r\nb");
assert_eq!(table.line_starts, vec![0, 3]);
table.patch(2..2, 1, "a\rx\nb");
assert_eq!(table.line_starts, vec![0, 2, 4]);
}
#[test]
fn an_edit_that_joins_a_cr_and_an_lf_makes_one_line() {
let mut table = LineTable::new("a\rb");
assert_eq!(table.line_starts, vec![0, 2]);
table.patch(2..3, 1, "a\r\n");
assert_eq!(table.line_starts, vec![0, 3]);
}
#[test]
fn the_ascii_fast_path_and_the_wide_walk_agree() {
for text in AWKWARD {
for &encoding in &[PositionEncoding::Utf16, PositionEncoding::Utf8] {
let idx = LineIndex::with_encoding(text, encoding);
for offset in (0..=text.len()).filter(|&o| text.is_char_boundary(o)) {
let (line, character) = idx.position(offset);
let start = idx.line_start(line as usize);
let walked: usize = text[start..]
.char_indices()
.take_while(|&(at, _)| start + at < offset)
.map(|(_, ch)| match encoding {
PositionEncoding::Utf8 => ch.len_utf8(),
PositionEncoding::Utf16 => ch.len_utf16(),
})
.sum();
assert_eq!(
character as usize, walked,
"position({offset}) of {text:?} ({encoding:?})"
);
let points = text[start..]
.char_indices()
.take_while(|&(at, _)| start + at < offset)
.count();
assert_eq!(
idx.line_col(offset).column,
points + 1,
"line_col({offset}) of {text:?}"
);
}
}
}
}
}