use crate::span::BytePos;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct LineCol {
pub line: u32,
pub col: u32,
}
#[derive(Clone, Debug)]
pub struct LineMap {
line_starts: Vec<u32>,
len: u32,
text: Vec<u8>,
}
impl LineMap {
pub fn new(text: &str) -> LineMap {
let mut line_starts = vec![0u32];
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\n' {
push_start(&mut line_starts, i + 1);
} else if b == b'\r' {
let next_is_lf = bytes.get(i + 1) == Some(&b'\n');
let after = if next_is_lf { i + 2 } else { i + 1 };
push_start(&mut line_starts, after);
i = after;
continue;
}
i += 1;
}
let len = u32::try_from(bytes.len()).expect("source files must be < 4 GiB");
LineMap {
line_starts,
len,
text: bytes.to_vec(),
}
}
pub fn offset_to_linecol(&self, offset: BytePos) -> LineCol {
let pos = offset.to_u32().min(self.len);
let line = self.line_index(pos);
let line_start = self.line_starts[line];
LineCol {
line: line as u32 + 1,
col: pos - line_start,
}
}
pub fn linecol_to_offset(&self, lc: LineCol) -> Option<BytePos> {
if lc.line == 0 {
return None;
}
let line_idx = (lc.line - 1) as usize;
let line_start = *self.line_starts.get(line_idx)?;
let next_start = self
.line_starts
.get(line_idx + 1)
.copied()
.unwrap_or(self.len);
let content_end =
Self::trim_line_terminator(&self.text, BytePos(line_start), BytePos(next_start));
let col = lc.col.min(content_end.saturating_sub(BytePos(line_start)));
Some(BytePos(line_start + col))
}
pub fn line_count(&self) -> usize {
self.line_starts.len()
}
fn line_index(&self, pos: u32) -> usize {
match self.line_starts.binary_search_by(|&start| start.cmp(&pos)) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
pub fn line_range(&self, line: u32) -> Option<(BytePos, BytePos)> {
if line == 0 {
return None;
}
let idx = (line - 1) as usize;
let start = *self.line_starts.get(idx)?;
let next_start = self.line_starts.get(idx + 1).copied().unwrap_or(self.len);
Some((BytePos(start), BytePos(next_start)))
}
pub fn trim_line_terminator(text: &[u8], start: BytePos, end: BytePos) -> BytePos {
let s = start.to_usize();
let e = end.to_usize().min(text.len());
if e <= s {
return start;
}
let gap = &text[s..e];
if gap.ends_with(b"\r\n") {
BytePos(end.to_u32() - 2)
} else if gap.ends_with(b"\n") || gap.ends_with(b"\r") {
BytePos(end.to_u32() - 1)
} else {
end
}
}
}
fn push_start(line_starts: &mut Vec<u32>, offset: usize) {
let offset = u32::try_from(offset).expect("source files must be < 4 GiB");
if line_starts.last() != Some(&offset) {
line_starts.push(offset);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_line_no_terminator() {
let map = LineMap::new("hello");
assert_eq!(map.line_count(), 1);
assert_eq!(
map.offset_to_linecol(BytePos(0)),
LineCol { line: 1, col: 0 }
);
assert_eq!(
map.offset_to_linecol(BytePos(4)),
LineCol { line: 1, col: 4 }
);
}
#[test]
fn unix_newlines() {
let map = LineMap::new("aa\nbb\ncc");
assert_eq!(map.line_count(), 3);
assert_eq!(
map.offset_to_linecol(BytePos(0)),
LineCol { line: 1, col: 0 }
);
assert_eq!(
map.offset_to_linecol(BytePos(3)),
LineCol { line: 2, col: 0 }
);
assert_eq!(
map.offset_to_linecol(BytePos(6)),
LineCol { line: 3, col: 0 }
);
}
#[test]
fn crlf_counts_as_one_break() {
let map = LineMap::new("a\r\nb");
assert_eq!(map.line_count(), 2, "CRLF must be a single line break");
assert_eq!(
map.offset_to_linecol(BytePos(3)),
LineCol { line: 2, col: 0 }
);
}
#[test]
fn bare_cr_counts_as_break() {
let map = LineMap::new("a\rb");
assert_eq!(map.line_count(), 2);
assert_eq!(
map.offset_to_linecol(BytePos(2)),
LineCol { line: 2, col: 0 }
);
}
#[test]
fn trailing_newline_yields_no_phantom_line() {
let map = LineMap::new("a\n");
assert_eq!(map.line_count(), 2);
assert_eq!(
map.offset_to_linecol(BytePos(0)),
LineCol { line: 1, col: 0 }
);
assert_eq!(
map.offset_to_linecol(BytePos(1)),
LineCol { line: 1, col: 1 }
);
}
#[test]
fn round_trip_linecol_offset() {
let map = LineMap::new("out(1)\nout(2)\n");
for offset in 0..12u32 {
let lc = map.offset_to_linecol(BytePos(offset));
let back = map
.linecol_to_offset(lc)
.expect("round trip should succeed");
assert_eq!(
back,
BytePos(offset),
"round trip failed at offset {offset} -> {lc:?}"
);
}
}
#[test]
fn round_trip_with_multibyte_utf8() {
let map = LineMap::new("λx\nλy");
assert_eq!(
map.offset_to_linecol(BytePos(0)),
LineCol { line: 1, col: 0 }
);
assert_eq!(
map.offset_to_linecol(BytePos(2)),
LineCol { line: 1, col: 2 }
);
assert_eq!(
map.offset_to_linecol(BytePos(3)),
LineCol { line: 1, col: 3 }
);
assert_eq!(
map.offset_to_linecol(BytePos(4)),
LineCol { line: 2, col: 0 }
);
for offset in 0..6u32 {
let lc = map.offset_to_linecol(BytePos(offset));
let back = map.linecol_to_offset(lc).unwrap();
assert_eq!(back, BytePos(offset), "utf8 round trip at {offset}");
}
}
#[test]
fn offset_past_end_clamps() {
let map = LineMap::new("ab");
let lc = map.offset_to_linecol(BytePos(99));
assert_eq!(lc, LineCol { line: 1, col: 2 }, "clamps to last byte");
}
#[test]
fn linecol_zero_line_is_none() {
let map = LineMap::new("ab\ncd");
assert!(map.linecol_to_offset(LineCol { line: 0, col: 0 }).is_none());
}
#[test]
fn linecol_past_last_line_is_none() {
let map = LineMap::new("ab\ncd");
assert!(
map.linecol_to_offset(LineCol { line: 99, col: 0 })
.is_none()
);
}
#[test]
fn linecol_column_clamps_to_line_end() {
let map = LineMap::new("ab\ncd");
let off = map.linecol_to_offset(LineCol { line: 1, col: 50 }).unwrap();
assert_eq!(off, BytePos(2), "column past line end clamps");
}
#[test]
fn trimming_takes_exactly_one_terminator() {
for (text, start, end, want) in [
("a\r\nb", 0u32, 3u32, 1u32),
("a\nb", 0, 2, 1),
("a\rb", 0, 2, 1),
("ab", 0, 2, 2),
("a\n", 2, 2, 2),
] {
assert_eq!(
LineMap::trim_line_terminator(text.as_bytes(), BytePos(start), BytePos(end)),
BytePos(want),
"trimming {text:?}[{start}..{end}]"
);
}
}
#[test]
fn empty_source_has_one_line() {
let map = LineMap::new("");
assert_eq!(map.line_count(), 1);
assert_eq!(
map.offset_to_linecol(BytePos(0)),
LineCol { line: 1, col: 0 }
);
}
}