#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ColumnUnit {
Utf8,
Char,
Utf16,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct LineCol {
pub line: usize,
pub column: usize,
}
impl LineCol {
#[must_use]
pub const fn new(line: usize, column: usize) -> Self {
Self { line, column }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct Wide {
at: usize,
len: usize,
extra_chars: usize,
extra_utf16: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct LineIndex {
starts: Vec<usize>,
wide: Vec<Wide>,
len: usize,
}
impl LineIndex {
#[must_use]
pub fn new(src: &str) -> Self {
let mut starts = vec![0];
let mut wide = Vec::new();
let (mut extra_chars, mut extra_utf16) = (0usize, 0usize);
let mut chars = src.char_indices().peekable();
while let Some((at, c)) = chars.next() {
let len = c.len_utf8();
let next = at.saturating_add(len);
match c {
'\n' => starts.push(next),
'\r' if !matches!(chars.peek(), Some((_, '\n'))) => starts.push(next),
_ if len > 1 => {
extra_chars = extra_chars.saturating_add(len.saturating_sub(1));
extra_utf16 = extra_utf16.saturating_add(len.saturating_sub(c.len_utf16()));
wide.push(Wide {
at,
len,
extra_chars,
extra_utf16,
});
}
_ => {}
}
}
Self {
starts,
wide,
len: src.len(),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.len
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len == 0
}
#[must_use]
pub fn line_count(&self) -> usize {
self.starts.len()
}
#[must_use]
pub fn line_start(&self, line: usize) -> Option<usize> {
self.starts.get(line).copied()
}
fn extra_before(&self, offset: usize) -> Option<(usize, usize)> {
let n = self.wide.partition_point(|w| w.at < offset);
let Some(last) = n.checked_sub(1).and_then(|i| self.wide.get(i)) else {
return Some((0, 0));
};
if offset < last.at.saturating_add(last.len) {
return None;
}
Some((last.extra_chars, last.extra_utf16))
}
#[must_use]
pub fn line_col(&self, offset: usize, unit: ColumnUnit) -> Option<LineCol> {
if offset > self.len {
return None;
}
let line = self
.starts
.partition_point(|s| *s <= offset)
.checked_sub(1)?;
let start = *self.starts.get(line)?;
let bytes = offset.checked_sub(start)?;
let (at_chars, at_utf16) = self.extra_before(offset)?;
let (start_chars, start_utf16) = self.extra_before(start)?;
let column = match unit {
ColumnUnit::Char => bytes.checked_sub(at_chars.checked_sub(start_chars)?)?,
ColumnUnit::Utf16 => bytes.checked_sub(at_utf16.checked_sub(start_utf16)?)?,
ColumnUnit::Utf8 => bytes,
};
Some(LineCol { line, column })
}
#[must_use]
pub fn span(&self, span: crate::Span, unit: ColumnUnit) -> Option<(LineCol, LineCol)> {
Some((
self.line_col(span.start, unit)?,
self.line_col(span.end, unit)?,
))
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]
use super::*;
fn naive(src: &str, offset: usize, unit: ColumnUnit) -> Option<LineCol> {
let before = src.get(..offset)?;
let (mut line, mut column) = (0usize, 0usize);
let mut prev_cr = false;
for c in before.chars() {
match c {
'\n' if prev_cr => {
column = 0;
}
'\n' => {
line += 1;
column = 0;
}
'\r' => {
line += 1;
column = 0;
}
_ => {
column += match unit {
ColumnUnit::Utf8 => c.len_utf8(),
ColumnUnit::Char => 1,
ColumnUnit::Utf16 => c.len_utf16(),
}
}
}
prev_cr = c == '\r';
}
Some(LineCol { line, column })
}
#[test]
fn agrees_with_a_walk_everywhere() {
let src = "a\r\nb\rc\nd\u{e9}x\u{1F600}\u{4e2d}\r\n\n\u{e9}";
let idx = LineIndex::new(src);
assert_eq!(idx.len(), src.len());
assert!(!idx.is_empty());
assert_eq!(idx.line_count(), 6);
for offset in 0..=src.len() + 2 {
for unit in [ColumnUnit::Utf8, ColumnUnit::Char, ColumnUnit::Utf16] {
let crlf_nl = src.get(offset..).is_some_and(|r| r.starts_with('\n'))
&& src.get(..offset).is_some_and(|r| r.ends_with('\r'));
if crlf_nl {
let at = idx.line_col(offset, unit).unwrap();
let before = idx.line_col(offset - 1, unit).unwrap();
assert_eq!(at.line, before.line);
assert_eq!(at.column, before.column + 1);
continue;
}
assert_eq!(
idx.line_col(offset, unit),
naive(src, offset, unit),
"{offset} {unit:?}"
);
}
}
assert_eq!(idx.line_start(1), Some(3));
assert_eq!(idx.line_start(99), None);
let e = src.find('x').unwrap();
assert_eq!(
idx.span(crate::Span::new(e, e + 1), ColumnUnit::Char),
Some((
LineCol { line: 3, column: 2 },
LineCol { line: 3, column: 3 }
))
);
assert_eq!(idx.span(crate::Span::new(0, 999), ColumnUnit::Char), None);
}
#[test]
fn empty_and_ascii() {
let idx = LineIndex::new("");
assert!(idx.is_empty());
assert_eq!(
idx.line_col(0, ColumnUnit::Utf16),
Some(LineCol { line: 0, column: 0 })
);
assert_eq!(idx.line_col(1, ColumnUnit::Utf16), None);
let idx = LineIndex::new("ab\n");
assert_eq!(
idx.line_col(3, ColumnUnit::Char),
Some(LineCol { line: 1, column: 0 })
);
assert_eq!(idx.line_col(usize::MAX, ColumnUnit::Char), None);
}
}