#![deny(unsafe_code)]
#![warn(rust_2018_idioms)]
#![warn(missing_docs)]
#![warn(clippy::all)]
#[derive(Clone, Debug)]
pub struct LineIndex {
line_starts: Vec<usize>,
text_len: usize,
}
impl LineIndex {
#[must_use]
pub fn new(text: &str) -> Self {
let mut line_starts = vec![0];
for (idx, ch) in text.char_indices() {
if ch == '\n' {
line_starts.push(idx + 1);
}
}
Self { line_starts, text_len: text.len() }
}
#[must_use]
pub fn byte_to_position(&self, byte: usize) -> (usize, usize) {
let line = self.line_starts.binary_search(&byte).unwrap_or_else(|i| i.saturating_sub(1));
let column = byte - self.line_starts[line];
(line, column)
}
#[must_use]
pub fn position_to_byte(&self, line: usize, column: usize) -> Option<usize> {
let start = *self.line_starts.get(line)?;
let line_end = self
.line_starts
.get(line + 1)
.map_or(self.text_len, |next_start| next_start.saturating_sub(1));
let max_column = line_end.saturating_sub(start);
if column > max_column {
return None;
}
Some(start + column)
}
#[must_use]
pub fn position_to_byte_checked(&self, line: usize, column: usize) -> Option<usize> {
let start = *self.line_starts.get(line)?;
let line_end = self
.line_starts
.get(line + 1)
.map_or(self.text_len, |next_start| next_start.saturating_sub(1));
let max_column = line_end.saturating_sub(start);
if column > max_column {
return None;
}
Some(start + column)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_has_one_line() {
let idx = LineIndex::new("");
assert_eq!(idx.byte_to_position(0), (0, 0));
assert_eq!(idx.position_to_byte(0, 0), Some(0));
assert_eq!(idx.position_to_byte(1, 0), None);
}
#[test]
fn single_line_no_newline() {
let idx = LineIndex::new("hello");
assert_eq!(idx.byte_to_position(0), (0, 0));
assert_eq!(idx.byte_to_position(4), (0, 4));
assert_eq!(idx.position_to_byte(0, 0), Some(0));
assert_eq!(idx.position_to_byte(0, 4), Some(4));
assert_eq!(idx.position_to_byte(0, 5), Some(5));
assert_eq!(idx.position_to_byte(0, 6), None);
}
#[test]
fn two_lines_byte_to_position() {
let idx = LineIndex::new("ab\ncd");
assert_eq!(idx.byte_to_position(0), (0, 0));
assert_eq!(idx.byte_to_position(1), (0, 1));
assert_eq!(idx.byte_to_position(2), (0, 2)); assert_eq!(idx.byte_to_position(3), (1, 0));
assert_eq!(idx.byte_to_position(4), (1, 1));
}
#[test]
fn two_lines_position_to_byte() {
let idx = LineIndex::new("ab\ncd");
assert_eq!(idx.position_to_byte(0, 0), Some(0));
assert_eq!(idx.position_to_byte(0, 2), Some(2)); assert_eq!(idx.position_to_byte(1, 0), Some(3));
assert_eq!(idx.position_to_byte(1, 1), Some(4));
assert_eq!(idx.position_to_byte(1, 2), Some(5)); assert_eq!(idx.position_to_byte(1, 3), None); assert_eq!(idx.position_to_byte(2, 0), None); }
#[test]
fn position_to_byte_checked_excludes_newline_as_next_line_start() {
let idx = LineIndex::new("ab\ncd");
assert_eq!(idx.position_to_byte_checked(0, 2), Some(2));
assert_eq!(idx.position_to_byte_checked(0, 3), None);
assert_eq!(idx.position_to_byte_checked(1, 0), Some(3));
assert_eq!(idx.position_to_byte_checked(2, 0), None);
}
#[test]
fn trailing_newline_creates_empty_last_line() {
let idx = LineIndex::new("foo\n");
assert_eq!(idx.byte_to_position(3), (0, 3)); assert_eq!(idx.byte_to_position(4), (1, 0)); assert_eq!(idx.position_to_byte(1, 0), Some(4));
}
#[test]
fn multiple_lines_roundtrip() {
let text = "line0\nline1\nline2";
let idx = LineIndex::new(text);
for (byte, _) in text.char_indices() {
let (line, col) = idx.byte_to_position(byte);
assert_eq!(idx.position_to_byte(line, col), Some(byte));
}
}
}