use alloc::boxed::Box;
use alloc::vec::Vec;
use crate::{BytePos, LineCol};
#[derive(Debug, Clone)]
pub struct LineIndex<'src> {
src: &'src str,
line_starts: Box<[u32]>,
}
impl<'src> LineIndex<'src> {
#[must_use]
pub fn new(src: &'src str) -> Self {
let mut line_starts = Vec::with_capacity(src.len() / 24 + 1);
line_starts.push(0);
for (i, &byte) in src.as_bytes().iter().enumerate() {
if byte == b'\n' {
line_starts.push(i as u32 + 1);
}
}
Self {
src,
line_starts: line_starts.into_boxed_slice(),
}
}
#[inline]
#[must_use]
pub fn line_count(&self) -> usize {
self.line_starts.len()
}
#[must_use]
pub fn line_col(&self, pos: BytePos) -> LineCol {
let mut at = pos.to_usize().min(self.src.len());
while at > 0 && !self.src.is_char_boundary(at) {
at -= 1;
}
let at_u32 = at as u32;
let line_idx = self.line_starts.partition_point(|&start| start <= at_u32) - 1;
let line_start = self.line_starts[line_idx] as usize;
let segment = &self.src[line_start..at];
let col = if segment.is_ascii() {
segment.len()
} else {
segment.chars().count()
};
LineCol::new(
(line_idx as u32).saturating_add(1),
(col as u32).saturating_add(1),
)
}
#[must_use]
pub fn offset(&self, line_col: LineCol) -> Option<BytePos> {
let line_idx = line_col.line.checked_sub(1)? as usize;
let col = line_col.col.checked_sub(1)? as usize;
let line_start = *self.line_starts.get(line_idx)? as usize;
let line_end = self
.line_starts
.get(line_idx + 1)
.map_or(self.src.len(), |&start| start as usize);
let segment = &self.src[line_start..line_end];
if segment.is_ascii() {
return if col <= segment.len() {
Some(BytePos::new((line_start + col) as u32))
} else {
None
};
}
let mut offset = line_start;
let mut remaining = col;
for ch in segment.chars() {
if remaining == 0 {
break;
}
offset += ch.len_utf8();
remaining -= 1;
}
if remaining != 0 {
return None;
}
Some(BytePos::new(offset as u32))
}
}
#[cfg(test)]
mod tests {
extern crate alloc;
use alloc::string::String;
use super::*;
fn naive_line_col(src: &str, offset: usize) -> LineCol {
let mut at = offset.min(src.len());
while at > 0 && !src.is_char_boundary(at) {
at -= 1;
}
let mut line = 1u32;
let mut col = 1u32;
for (i, ch) in src.char_indices() {
if i >= at {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
LineCol::new(line, col)
}
#[test]
fn test_line_col_matches_naive_on_mixed_source() {
let src = "fn main() {\r\n let π = 3;\n}\n";
let index = LineIndex::new(src);
for offset in 0..=src.len() {
assert_eq!(
index.line_col(BytePos::new(offset as u32)),
naive_line_col(src, offset),
"offset {offset}"
);
}
}
#[test]
fn test_round_trip_every_offset_on_mixed_source() {
let src = "αβγ\r\nδ\nε😀ζ\n";
let index = LineIndex::new(src);
for offset in 0..=src.len() {
if !src.is_char_boundary(offset) {
continue;
}
let lc = index.line_col(BytePos::new(offset as u32));
assert_eq!(
index.offset(lc),
Some(BytePos::new(offset as u32)),
"offset {offset} via {lc}"
);
}
}
#[test]
fn test_empty_source_is_one_line() {
let index = LineIndex::new("");
assert_eq!(index.line_count(), 1);
assert_eq!(index.line_col(BytePos::new(0)), LineCol::new(1, 1));
assert_eq!(index.offset(LineCol::new(1, 1)), Some(BytePos::new(0)));
}
#[test]
fn test_crlf_counts_as_one_break() {
let index = LineIndex::new("a\r\nb");
assert_eq!(index.line_count(), 2);
assert_eq!(index.line_col(BytePos::new(3)), LineCol::new(2, 1));
}
#[test]
fn test_no_trailing_newline_has_final_line() {
let index = LineIndex::new("a\nb");
assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(2, 1));
assert_eq!(index.offset(LineCol::new(2, 1)), Some(BytePos::new(2)));
}
#[test]
fn test_offset_rejects_positions_outside_source() {
let index = LineIndex::new("abc\ndef");
assert_eq!(index.offset(LineCol::new(0, 1)), None);
assert_eq!(index.offset(LineCol::new(1, 0)), None);
assert_eq!(index.offset(LineCol::new(3, 1)), None);
assert_eq!(index.offset(LineCol::new(1, 99)), None);
}
#[test]
fn test_line_col_clamps_out_of_range_offset() {
let src = "abc";
let index = LineIndex::new(src);
let end = index.line_col(BytePos::new(3));
assert_eq!(index.line_col(BytePos::new(1000)), end);
}
#[test]
fn test_line_col_floors_interior_byte_to_char_start() {
let src = "αβ";
let index = LineIndex::new(src);
assert_eq!(
index.line_col(BytePos::new(1)),
index.line_col(BytePos::new(0))
);
}
#[test]
fn test_line_count_matches_newline_count_plus_one() {
let src = String::from("a\nb\nc\n");
let index = LineIndex::new(&src);
assert_eq!(index.line_count(), 4);
}
}