use alloc::boxed::Box;
use alloc::vec::Vec;
use crate::{BytePos, LineCol, Span};
#[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))
}
#[must_use]
pub fn line_span(&self, line: u32) -> Option<Span> {
let line_idx = line.checked_sub(1)? as usize;
let start = *self.line_starts.get(line_idx)? as usize;
let mut end = self
.line_starts
.get(line_idx + 1)
.map_or(self.src.len(), |&next| next as usize);
let bytes = self.src.as_bytes();
if end > start && bytes[end - 1] == b'\n' {
end -= 1;
if end > start && bytes[end - 1] == b'\r' {
end -= 1;
}
}
Some(Span::new(start as u32, end 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);
}
#[test]
fn test_lone_cr_is_not_a_line_break() {
let index = LineIndex::new("a\rb");
assert_eq!(index.line_count(), 1);
assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(1, 3));
}
#[test]
fn test_consecutive_newlines_are_separate_empty_lines() {
let index = LineIndex::new("\n\n\n");
assert_eq!(index.line_count(), 4);
assert_eq!(index.line_col(BytePos::new(1)), LineCol::new(2, 1));
assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(3, 1));
}
#[test]
fn test_only_newline_is_two_lines() {
let index = LineIndex::new("\n");
assert_eq!(index.line_count(), 2);
assert_eq!(index.offset(LineCol::new(2, 1)), Some(BytePos::new(1)));
}
#[test]
fn test_line_span_excludes_lf_and_crlf_terminators() {
let src = "first\r\nsecond\nthird";
let index = LineIndex::new(src);
let text = |span: Span| &src[span.start().to_usize()..span.end().to_usize()];
assert_eq!(text(index.line_span(1).unwrap()), "first");
assert_eq!(text(index.line_span(2).unwrap()), "second");
assert_eq!(text(index.line_span(3).unwrap()), "third");
}
#[test]
fn test_line_span_of_trailing_empty_line_is_empty() {
let src = "a\n";
let index = LineIndex::new(src);
let line2 = index.line_span(2).unwrap();
assert!(line2.is_empty());
assert_eq!(line2.start(), BytePos::new(2));
}
#[test]
fn test_line_span_rejects_out_of_range_lines() {
let index = LineIndex::new("a\nb");
assert_eq!(index.line_span(0), None);
assert_eq!(index.line_span(3), None);
}
#[test]
fn test_line_span_start_matches_first_column_offset() {
let src = "αβ\r\nγ\nδε\n";
let index = LineIndex::new(src);
for line in 1..=index.line_count() as u32 {
let span = index.line_span(line).expect("line in range");
assert_eq!(Some(span.start()), index.offset(LineCol::new(line, 1)));
let text = &src[span.start().to_usize()..span.end().to_usize()];
assert!(!text.contains('\n'));
}
}
}