use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) struct Position {
pub(crate) line: usize,
pub(crate) column: usize,
}
pub(crate) struct PositionIndex<'a> {
content: &'a str,
line_starts: Vec<usize>,
checkpoints: Vec<(usize, usize)>,
}
const CHECKPOINT_BYTES: usize = 1024;
impl<'a> PositionIndex<'a> {
pub(crate) fn new(content: &'a str) -> Self {
let mut line_starts = vec![0];
line_starts.extend(
content
.bytes()
.enumerate()
.filter(|&(_, byte)| byte == b'\n')
.map(|(index, _)| index + 1),
);
Self {
content,
line_starts,
checkpoints: checkpoints(content),
}
}
pub(crate) fn at(&self, offset: usize) -> Position {
let clamped = self.floor_to_boundary(offset.min(self.content.len()));
let line_index = self.line_starts.partition_point(|&start| start <= clamped) - 1;
let line_start = self.line_starts[line_index];
Position {
line: line_index + 1,
column: self.units_before(clamped) - self.units_before(line_start) + 1,
}
}
fn units_before(&self, offset: usize) -> usize {
let after = self.checkpoints.partition_point(|(at, _)| *at <= offset);
let Some(&(byte, units)) = after
.checked_sub(1)
.and_then(|index| self.checkpoints.get(index))
else {
return offset;
};
units + self.content[byte..offset].encode_utf16().count()
}
fn floor_to_boundary(&self, mut offset: usize) -> usize {
while offset > 0 && !self.content.is_char_boundary(offset) {
offset -= 1;
}
offset
}
}
fn checkpoints(content: &str) -> Vec<(usize, usize)> {
if content.is_ascii() {
return Vec::new();
}
let mut out = vec![(0, 0)];
let mut units = 0;
let mut next = CHECKPOINT_BYTES;
for (offset, character) in content.char_indices() {
if offset >= next {
out.push((offset, units));
next = offset + CHECKPOINT_BYTES;
}
units += character.len_utf16();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_character_is_line_one_column_one() {
assert_eq!(
PositionIndex::new("abc").at(0),
Position { line: 1, column: 1 }
);
}
#[test]
fn a_newline_starts_the_next_line() {
let index = PositionIndex::new("ab\ncd");
assert_eq!(index.at(3), Position { line: 2, column: 1 });
assert_eq!(index.at(4), Position { line: 2, column: 2 });
}
#[test]
fn an_empty_document_still_answers() {
assert_eq!(
PositionIndex::new("").at(0),
Position { line: 1, column: 1 }
);
}
#[test]
fn an_offset_past_the_end_clamps() {
assert_eq!(
PositionIndex::new("ab").at(999),
Position { line: 1, column: 3 }
);
}
#[test]
fn a_two_byte_character_counts_as_one_column() {
assert_eq!(
PositionIndex::new("é!").at(2),
Position { line: 1, column: 2 }
);
}
#[test]
fn an_astral_character_counts_as_two_columns() {
assert_eq!(
PositionIndex::new("🎯!").at(4),
Position { line: 1, column: 3 }
);
}
#[test]
fn an_offset_inside_a_character_floors_to_its_start() {
assert_eq!(
PositionIndex::new("é!").at(1),
Position { line: 1, column: 1 }
);
}
fn counted(content: &str, offset: usize) -> Position {
let line = content[..offset]
.bytes()
.filter(|byte| *byte == b'\n')
.count();
let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1);
Position {
line: line + 1,
column: content[line_start..offset].encode_utf16().count() + 1,
}
}
#[test]
fn the_indexed_path_agrees_with_the_counted_path_everywhere() {
let ascii = "abc\ndef\nghi";
let mut long = String::new();
for row in 0..400 {
use std::fmt::Write as _;
let _ = writeln!(long, "café {row} 🎯");
}
for content in [ascii, long.as_str()] {
let index = PositionIndex::new(content);
assert!(
content.is_ascii() || index.checkpoints.len() > 3,
"the long document must cross several checkpoints"
);
for offset in 0..=content.len() {
if !content.is_char_boundary(offset) {
continue;
}
assert_eq!(index.at(offset), counted(content, offset), "at {offset}");
}
}
}
#[test]
fn an_ascii_document_needs_no_checkpoints() {
assert!(PositionIndex::new("abc\ndef").checkpoints.is_empty());
}
#[test]
fn a_long_line_carrying_one_non_ascii_character_is_still_bounded() {
let content = format!("é{}", "a".repeat(100_000));
let index = PositionIndex::new(&content);
assert!(
index.checkpoints.len() > 90,
"a checkpoint roughly every {CHECKPOINT_BYTES} bytes"
);
assert_eq!(
index.at(content.len()),
Position {
line: 1,
column: 100_002,
}
);
}
#[test]
fn a_carriage_return_does_not_start_a_line() {
let index = PositionIndex::new("a\r\nb");
assert_eq!(index.at(1), Position { line: 1, column: 2 });
assert_eq!(index.at(3), Position { line: 2, column: 1 });
}
}