use std::ops::{Deref, Range};
use std::sync::{Arc, OnceLock};
use super::line_index::{LineIndex, LineTable, PositionEncoding};
#[derive(Debug)]
pub struct TextBuffer {
text: Arc<str>,
encoding: PositionEncoding,
table: OnceLock<LineTable>,
}
impl TextBuffer {
pub fn new(text: impl Into<Arc<str>>, encoding: PositionEncoding) -> Self {
Self {
text: text.into(),
encoding,
table: OnceLock::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn text_arc(&self) -> Arc<str> {
Arc::clone(&self.text)
}
pub fn encoding(&self) -> PositionEncoding {
self.encoding
}
pub fn line_index(&self) -> LineIndex<'_> {
LineIndex::with_table(&self.text, self.line_table(), self.encoding)
}
pub(crate) fn line_table(&self) -> &LineTable {
self.table.get_or_init(|| LineTable::new(&self.text))
}
pub fn with_replacement(&self, range: Range<usize>, insert: &str) -> Self {
let removed = self.text[range.clone()].len();
let mut new = String::with_capacity(self.text.len() - removed + insert.len());
new.push_str(&self.text[..range.start]);
new.push_str(insert);
new.push_str(&self.text[range.end..]);
let text: Arc<str> = Arc::from(new);
let table = OnceLock::new();
if let Some(current) = self.table.get() {
let mut patched = current.clone();
patched.patch(range, insert.len(), &text);
debug_assert!(
patched == LineTable::new(&text),
"the patched line table drifted from the text it indexes"
);
let _ = table.set(patched);
}
Self {
text,
encoding: self.encoding,
table,
}
}
}
impl Deref for TextBuffer {
type Target = str;
fn deref(&self) -> &str {
&self.text
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buffer(text: &str) -> TextBuffer {
TextBuffer::new(text, PositionEncoding::Utf16)
}
#[test]
fn the_line_table_is_built_once_and_shared() {
let buf = buffer("ab\ncd\nef");
let first = buf.line_table();
assert_eq!(buf.line_index().line_start(1), 3);
assert!(std::ptr::eq(buf.line_table(), first));
}
#[test]
fn the_index_answers_in_the_buffers_encoding() {
let utf16 = TextBuffer::new("a𝕏b", PositionEncoding::Utf16);
let utf8 = TextBuffer::new("a𝕏b", PositionEncoding::Utf8);
let off = "a𝕏".len();
assert_eq!(utf16.line_index().position(off), (0, 3));
assert_eq!(utf8.line_index().position(off), (0, 5));
}
#[test]
fn an_edit_leaves_earlier_handles_alone() {
let before = buffer("ab\ncd");
let handle = before.text_arc();
assert!(Arc::ptr_eq(&handle, &before.text_arc()));
let after = before.with_replacement(2..2, "\nxy");
assert_eq!(&*handle, "ab\ncd");
assert_eq!(after.text(), "ab\nxy\ncd");
assert!(!Arc::ptr_eq(&handle, &after.text_arc()));
assert_eq!(after.line_index().line_start(1), 3);
}
#[test]
fn an_edit_patches_the_table_onto_the_new_buffer() {
let before = buffer("alpha\nbeta\ngamma\n");
assert_eq!(before.line_index().offset_at(1, 0), 6);
let after = before.with_replacement(6..6, "x\ny\n");
assert!(
after.table.get().is_some(),
"the edited buffer must arrive with a table, not rebuild one"
);
assert_eq!(after.text(), "alpha\nx\ny\nbeta\ngamma\n");
assert_eq!(after.line_index().line_start(3), 10);
assert_eq!(after.line_index().offset_at(4, 2), 17);
}
#[test]
fn an_edit_to_an_unindexed_buffer_builds_no_table() {
let before = buffer("alpha\nbeta\n");
let after = before.with_replacement(0..0, "x");
assert!(after.table.get().is_none());
assert_eq!(after.text(), "xalpha\nbeta\n");
}
#[test]
fn a_chain_of_edits_keeps_patching() {
let mut buf = buffer("one\ntwo\n");
assert_eq!(buf.line_index().line_start(1), 4);
for insert in ["\r\n", "x", "\r"] {
buf = buf.with_replacement(4..4, insert);
assert!(buf.table.get().is_some());
}
assert_eq!(buf.text(), "one\n\rx\r\ntwo\n");
}
#[test]
#[should_panic(expected = "byte range starts at 4 but ends at 2")]
#[expect(
clippy::reversed_empty_ranges,
reason = "the malformed range is the subject of the test"
)]
fn a_reversed_edit_range_panics() {
buffer("abcdefgh").with_replacement(4..2, "Z");
}
#[test]
#[should_panic(expected = "out of bounds")]
fn an_out_of_bounds_edit_range_panics() {
buffer("abcdefgh").with_replacement(0..99, "Z");
}
#[test]
#[should_panic(expected = "not a char boundary")]
fn an_edit_range_off_a_char_boundary_panics() {
buffer("\u{1F600}x").with_replacement(1..2, "Z");
}
}