#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LineColumn {
pub line: usize,
pub column: usize,
}
impl LineColumn {
pub const fn new(line: usize, column: usize) -> Self {
Self { line, column }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Utf16Position {
pub line: u32,
pub character: u32,
}
impl Utf16Position {
pub const fn new(line: u32, character: u32) -> Self {
Self { line, character }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Utf8Position {
pub line: u32,
pub character: u32,
}
impl Utf8Position {
pub const fn new(line: u32, character: u32) -> Self {
Self { line, character }
}
}
pub struct LineIndex<'a> {
text: &'a str,
line_starts: Vec<usize>,
}
impl<'a> LineIndex<'a> {
pub fn new(text: &'a str) -> Self {
let mut line_starts = vec![0];
line_starts.extend(
text.bytes()
.enumerate()
.filter(|&(_, b)| b == b'\n')
.map(|(i, _)| i + 1),
);
Self { text, line_starts }
}
pub fn line_column(&self, offset: usize) -> LineColumn {
let offset = self.clamp_offset(offset);
let line = self.line_for_offset(offset);
let line_start = self.line_starts[line];
let column = self.text[line_start..offset].chars().count() + 1;
LineColumn::new(line + 1, column)
}
pub fn utf16_position(&self, offset: usize) -> Utf16Position {
let offset = self.clamp_offset(offset);
let line = self.line_for_offset(offset);
let line_start = self.line_starts[line];
let character = utf16_len(&self.text[line_start..offset]);
Utf16Position::new(line as u32, character)
}
pub fn utf8_position(&self, offset: usize) -> Utf8Position {
let offset = self.clamp_offset(offset);
let line = self.line_for_offset(offset);
let line_start = self.line_starts[line];
Utf8Position::new(line as u32, (offset - line_start) as u32)
}
pub fn end_utf16_position(&self) -> Utf16Position {
self.utf16_position(self.text.len())
}
pub fn end_utf8_position(&self) -> Utf8Position {
self.utf8_position(self.text.len())
}
pub fn offset_for_utf16_position(&self, position: Utf16Position) -> usize {
let line = position.line as usize;
let Some(&line_start) = self.line_starts.get(line) else {
return self.text.len();
};
let mut remaining = position.character as usize;
let mut offset = line_start;
for ch in self.text[line_start..].chars() {
let width = ch.len_utf16();
if remaining < width || ch == '\n' {
break;
}
remaining -= width;
offset += ch.len_utf8();
}
offset
}
pub fn offset_for_utf8_position(&self, position: Utf8Position) -> usize {
let line = position.line as usize;
let Some(&line_start) = self.line_starts.get(line) else {
return self.text.len();
};
self.clamp_offset(line_start.saturating_add(position.character as usize))
}
fn line_for_offset(&self, offset: usize) -> usize {
match self.line_starts.binary_search(&offset) {
Ok(line) => line,
Err(next) => next - 1,
}
}
fn clamp_offset(&self, offset: usize) -> usize {
let mut offset = offset.min(self.text.len());
while !self.text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
}
pub fn utf16_len(text: &str) -> u32 {
text.chars().map(|ch| ch.len_utf16() as u32).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_offsets_to_one_based_line_columns() {
let text = "abc\ndefg\nhi";
let index = LineIndex::new(text);
assert_eq!(index.line_column(0), LineColumn::new(1, 1));
assert_eq!(index.line_column(4), LineColumn::new(2, 1));
assert_eq!(index.line_column(6), LineColumn::new(2, 3));
assert_eq!(index.line_column(999), LineColumn::new(3, 3));
}
#[test]
fn maps_offsets_to_utf16_positions() {
let text = "SELECT a\nFROM 芋;\n";
let index = LineIndex::new(text);
assert_eq!(index.utf16_position(0), Utf16Position::new(0, 0));
assert_eq!(index.utf16_position(7), Utf16Position::new(0, 7));
assert_eq!(
index.utf16_position(text.find("FROM").unwrap()),
Utf16Position::new(1, 0)
);
assert_eq!(
index.utf16_position(text.find(';').unwrap()),
Utf16Position::new(1, 6)
);
}
#[test]
fn maps_offsets_to_utf8_positions() {
let text = "SELECT '長芋'\nFROM t";
let index = LineIndex::new(text);
assert_eq!(index.utf8_position(0), Utf8Position::new(0, 0));
assert_eq!(
index.utf8_position(text.find("FROM").unwrap()),
Utf8Position::new(1, 0)
);
let newline = text.find('\n').unwrap();
assert_eq!(
index.utf8_position(newline),
Utf8Position::new(0, newline as u32)
);
}
#[test]
fn maps_utf16_positions_back_to_offsets() {
let text = "SELECT a\nFROM 芋;\nSELECT 😀;\n";
let index = LineIndex::new(text);
for offset in [
0usize,
7,
text.find("FROM").unwrap(),
text.find(';').unwrap(),
text.find("😀").unwrap(),
] {
assert_eq!(
index.offset_for_utf16_position(index.utf16_position(offset)),
offset
);
}
}
#[test]
fn maps_utf8_positions_back_to_offsets() {
let text = "SELECT '長芋'\nFROM t";
let index = LineIndex::new(text);
for offset in [
0usize,
7,
text.find("FROM").unwrap(),
text.find('\n').unwrap(),
] {
assert_eq!(
index.offset_for_utf8_position(index.utf8_position(offset)),
offset
);
}
}
#[test]
fn clamps_mid_character_offsets() {
let text = "a😀b";
let index = LineIndex::new(text);
assert_eq!(index.line_column(2), LineColumn::new(1, 2));
assert_eq!(index.utf16_position(2), Utf16Position::new(0, 1));
}
}