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>,
all_ascii: bool,
}
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,
all_ascii: content.is_ascii(),
}
}
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];
let prefix = &self.content[line_start..clamped];
let column = if self.all_ascii {
prefix.len() + 1
} else {
prefix.encode_utf16().count() + 1
};
Position {
line: line_index + 1,
column,
}
}
fn floor_to_boundary(&self, mut offset: usize) -> usize {
while offset > 0 && !self.content.is_char_boundary(offset) {
offset -= 1;
}
offset
}
}
#[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 }
);
}
#[test]
fn the_ascii_fast_path_agrees_with_the_counted_path() {
let ascii = "abc\ndef";
let index = PositionIndex::new(ascii);
assert!(index.all_ascii);
for offset in 0..=ascii.len() {
let line_index = index.line_starts.partition_point(|&start| start <= offset) - 1;
let line_start = index.line_starts[line_index];
assert_eq!(
index.at(offset),
Position {
line: line_index + 1,
column: ascii[line_start..offset].encode_utf16().count() + 1,
},
"at offset {offset}"
);
}
}
#[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 });
}
}