use std::borrow::Cow;
use std::ops::{Deref, Range};
use lsp_types::Position;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PositionEncoding {
Utf8,
#[default]
Utf16,
}
impl PositionEncoding {
fn units_of(self, ch: char) -> u32 {
match self {
PositionEncoding::Utf8 => ch.len_utf8() as u32,
PositionEncoding::Utf16 => ch.len_utf16() as u32,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineCol {
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineStarts(Vec<usize>);
impl Default for LineStarts {
fn default() -> Self {
Self(vec![0])
}
}
impl LineStarts {
pub fn new(text: &str) -> Self {
let mut starts = Vec::with_capacity(text.len() / 40 + 1);
starts.push(0);
starts.extend(memchr::memchr_iter(b'\n', text.as_bytes()).map(|at| at + 1));
Self(starts)
}
pub fn patch(&mut self, range: Range<usize>, insert: &str) {
let Range { start, end } = range;
debug_assert!(start <= end, "reversed edit range {start}..{end}");
let first = self.0.partition_point(|&at| at <= start);
let last = self.0.partition_point(|&at| at <= end);
let delta = insert.len() as isize - (end - start) as isize;
if delta != 0 {
for at in &mut self.0[last..] {
*at = at.wrapping_add_signed(delta);
}
}
let inserted = memchr::memchr_iter(b'\n', insert.as_bytes()).map(|at| start + at + 1);
drop(self.0.splice(first..last, inserted));
}
}
impl Deref for LineStarts {
type Target = [usize];
fn deref(&self) -> &[usize] {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct LineIndex<'a> {
text: &'a str,
line_starts: Cow<'a, LineStarts>,
}
impl<'a> LineIndex<'a> {
pub fn new(text: &'a str) -> Self {
Self {
text,
line_starts: Cow::Owned(LineStarts::new(text)),
}
}
pub fn with_starts(text: &'a str, line_starts: &'a LineStarts) -> Self {
Self {
text,
line_starts: Cow::Borrowed(line_starts),
}
}
pub fn byte_to_lc(&self, offset: usize) -> LineCol {
let clamped = offset.min(self.text.len());
let line_idx = self.line_index_for(clamped);
let line_start = self.line_starts[line_idx];
let column = self.text[line_start..clamped].chars().count() + 1;
LineCol {
line: line_idx + 1,
column,
}
}
pub fn byte_to_position(&self, offset: usize, encoding: PositionEncoding) -> Position {
let clamped = offset.min(self.text.len());
let line_idx = self.line_index_for(clamped);
let line_start = self.line_starts[line_idx];
let prefix = &self.text[line_start..clamped];
let character = match encoding {
PositionEncoding::Utf8 => prefix.len() as u32,
PositionEncoding::Utf16 => prefix.encode_utf16().count() as u32,
};
Position::new(line_idx as u32, character)
}
pub fn position_to_byte(&self, position: Position, encoding: PositionEncoding) -> usize {
let line = position.line as usize;
let Some(&line_start) = self.line_starts.get(line) else {
return self.text.len();
};
let line_end = self
.line_starts
.get(line + 1)
.copied()
.unwrap_or(self.text.len());
let line_text = self.text[line_start..line_end]
.trim_end_matches('\n')
.trim_end_matches('\r');
let mut units = 0u32;
for (byte_off, ch) in line_text.char_indices() {
if units >= position.character {
return line_start + byte_off;
}
units += encoding.units_of(ch);
}
line_start + line_text.len()
}
pub fn line_count(&self) -> usize {
self.line_starts.len()
}
fn line_index_for(&self, offset: usize) -> usize {
match self.line_starts.binary_search(&offset) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const UTF8: PositionEncoding = PositionEncoding::Utf8;
const UTF16: PositionEncoding = PositionEncoding::Utf16;
#[test]
fn patching_matches_a_rescan() {
let texts = [
"",
"\n",
"\n\n",
"abc",
"ab\ncd\nef\n",
"a\r\nb\r\n",
"\u{1F600}\nx\n",
];
let inserts = ["", "z", "\n", "\n\n", "x\ny\n", "\r\n", "\u{1F600}"];
for text in texts {
for start in 0..=text.len() {
for end in start..=text.len() {
if !text.is_char_boundary(start) || !text.is_char_boundary(end) {
continue;
}
for insert in inserts {
let mut patched = LineStarts::new(text);
patched.patch(start..end, insert);
let mut edited = text.to_string();
edited.replace_range(start..end, insert);
assert_eq!(
patched,
LineStarts::new(&edited),
"{text:?} [{start}..{end}] -> {insert:?} gives {edited:?}"
);
}
}
}
}
}
#[test]
fn a_borrowed_table_indexes_the_same_as_a_scanned_one() {
let text = "ab\ncd\u{1F600}\nef";
let starts = LineStarts::new(text);
let borrowed = LineIndex::with_starts(text, &starts);
let scanned = LineIndex::new(text);
for offset in 0..=text.len() {
if !text.is_char_boundary(offset) {
continue;
}
assert_eq!(
borrowed.byte_to_position(offset, UTF16),
scanned.byte_to_position(offset, UTF16),
);
assert_eq!(borrowed.byte_to_lc(offset), scanned.byte_to_lc(offset));
}
}
#[test]
fn empty_string() {
let idx = LineIndex::new("");
assert_eq!(idx.byte_to_lc(0), LineCol { line: 1, column: 1 });
assert_eq!(idx.byte_to_position(0, UTF16), Position::new(0, 0));
assert_eq!(idx.byte_to_position(0, UTF8), Position::new(0, 0));
}
#[test]
fn multi_line() {
let idx = LineIndex::new("ab\ncd\nef");
assert_eq!(idx.byte_to_lc(0), LineCol { line: 1, column: 1 });
assert_eq!(idx.byte_to_lc(3), LineCol { line: 2, column: 1 });
assert_eq!(idx.byte_to_position(6, UTF16), Position::new(2, 0));
assert_eq!(idx.byte_to_position(6, UTF8), Position::new(2, 0));
}
#[test]
fn encodings_diverge_after_a_surrogate_pair() {
let idx = LineIndex::new("\u{1F600}x");
assert_eq!(idx.byte_to_lc(4), LineCol { line: 1, column: 2 });
assert_eq!(idx.byte_to_position(4, UTF16), Position::new(0, 2));
assert_eq!(idx.byte_to_position(4, UTF8), Position::new(0, 4));
assert_eq!(idx.position_to_byte(Position::new(0, 2), UTF16), 4);
assert_eq!(idx.position_to_byte(Position::new(0, 4), UTF8), 4);
}
#[test]
fn position_to_byte_clamps_before_line_terminator() {
let idx = LineIndex::new("ab\ncd");
assert_eq!(idx.position_to_byte(Position::new(0, 9), UTF16), 2);
assert_eq!(idx.position_to_byte(Position::new(9, 0), UTF16), 5);
assert_eq!(idx.position_to_byte(Position::new(0, 9), UTF8), 2);
let idx = LineIndex::new("ab\r\ncd");
assert_eq!(idx.position_to_byte(Position::new(0, 9), UTF16), 2);
assert_eq!(idx.position_to_byte(Position::new(0, 9), UTF8), 2);
}
#[test]
fn position_inside_a_code_point_rounds_up() {
let idx = LineIndex::new("\u{00E9}x");
assert_eq!(idx.position_to_byte(Position::new(0, 1), UTF8), 2);
let idx = LineIndex::new("\u{1F600}x");
assert_eq!(idx.position_to_byte(Position::new(0, 1), UTF16), 4);
}
#[test]
fn position_to_byte_round_trips() {
let text = "ab\ncd\u{00E9}\u{1F600}\nf";
let idx = LineIndex::new(text);
for encoding in [UTF8, UTF16] {
for offset in 0..=text.len() {
if !text.is_char_boundary(offset) {
continue;
}
let pos = idx.byte_to_position(offset, encoding);
assert_eq!(
idx.position_to_byte(pos, encoding),
offset,
"offset {offset} ({encoding:?})"
);
}
}
}
}