use std::collections::HashMap;
#[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,
}
#[derive(Debug, Clone, Copy)]
struct WideChar {
start: usize,
end: usize,
utf16_len: u8,
}
impl WideChar {
fn utf8_len(&self) -> usize {
self.end - self.start
}
}
#[derive(Debug, Clone)]
pub struct LineIndex {
line_starts: Vec<usize>,
line_ends: Vec<usize>,
line_wide_chars: HashMap<usize, Vec<WideChar>>,
len: usize,
encoding: PositionEncoding,
}
impl LineIndex {
pub fn new(text: &str) -> Self {
Self::with_encoding(text, PositionEncoding::Utf16)
}
pub fn with_encoding(text: &str, encoding: PositionEncoding) -> Self {
let len = text.len();
let mut line_starts = vec![0];
let mut line_ends = Vec::new();
let mut line_wide_chars: HashMap<usize, Vec<WideChar>> = HashMap::new();
let mut line = 0usize;
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);
line += 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 += 1;
}
_ if ch.len_utf8() > 1 => {
line_wide_chars.entry(line).or_default().push(WideChar {
start: i,
end: i + ch.len_utf8(),
utf16_len: ch.len_utf16() as u8,
});
}
_ => {}
}
}
line_ends.push(len);
Self {
line_starts,
line_ends,
line_wide_chars,
len,
encoding,
}
}
fn line_of(&self, offset: usize) -> usize {
match self.line_starts.binary_search(&offset) {
Ok(line) => line,
Err(next) => next - 1,
}
}
fn wide_chars(&self, line: usize) -> &[WideChar] {
self.line_wide_chars
.get(&line)
.map_or(&[][..], Vec::as_slice)
}
pub fn line_col(&self, offset: usize) -> LineCol {
let offset = offset.min(self.len);
let line = self.line_of(offset);
let start = self.line_starts[line];
let extra: usize = self
.wide_chars(line)
.iter()
.take_while(|w| w.end <= offset)
.map(|w| w.utf8_len() - 1)
.sum();
let column = (offset - start) - extra + 1;
LineCol {
line: line + 1,
column,
}
}
pub fn position(&self, offset: usize) -> (u32, u32) {
let offset = offset.min(self.len);
let line = self.line_of(offset);
let start = self.line_starts[line];
let byte_col = offset - start;
let character = match self.encoding {
PositionEncoding::Utf8 => byte_col,
PositionEncoding::Utf16 => {
let surplus: usize = self
.wide_chars(line)
.iter()
.take_while(|w| w.end <= offset)
.map(|w| w.utf8_len() - w.utf16_len as usize)
.sum();
byte_col - surplus
}
};
(line as u32, character as u32)
}
pub fn offset_at(&self, line: u32, character: u32) -> usize {
let line = line as usize;
let Some(&start) = self.line_starts.get(line) else {
return self.len;
};
let line_end = self.line_ends[line];
let character = character as usize;
let wides = self.wide_chars(line);
match self.encoding {
PositionEncoding::Utf8 => {
let mut offset = line_end.min(start + character);
if let Some(w) = wides.iter().find(|w| w.start < offset && offset < w.end) {
offset = w.end;
}
offset
}
PositionEncoding::Utf16 => {
let mut byte = start;
let mut units = 0usize;
for w in wides {
let gap = w.start - byte; if character <= units + gap {
return (byte + (character - units)).min(line_end);
}
units += gap;
let w_units = w.utf16_len as usize;
if character < units + w_units {
return w.end.min(line_end);
}
byte = w.end;
units += w_units;
}
(byte + character.saturating_sub(units)).min(line_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());
}
}