use fc_core::{FcError, normalize_range};
#[cfg(feature = "bindgen")]
use wasm_bindgen::prelude::*;
#[cfg_attr(feature = "bindgen", wasm_bindgen)]
#[derive(Debug, Clone, Default)]
pub struct TextBuffer {
text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Range16 {
pub start: usize,
pub end: usize,
}
impl Range16 {
pub fn len(&self) -> usize {
self.end - self.start
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
}
fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
let mut units = 0usize;
for (byte_idx, ch) in text.char_indices() {
if units >= utf16_idx {
return byte_idx;
}
units += ch.len_utf16();
}
text.len()
}
impl TextBuffer {
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
pub fn as_str(&self) -> &str {
&self.text
}
pub fn len16(&self) -> usize {
self.text.chars().map(char::len_utf16).sum()
}
pub fn char_count(&self) -> usize {
self.text.chars().count()
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn slice16(&self, start: usize, end: usize) -> Result<String, FcError> {
let (start, end) = normalize_range(start, end)?;
if end > self.len16() {
return Err(FcError::OutOfBounds {
message: format!("end {end} exceeds buffer length {}", self.len16()),
});
}
let b0 = utf16_to_byte(&self.text, start);
let b1 = utf16_to_byte(&self.text, end);
Ok(self.text[b0..b1].to_string())
}
pub fn replace16(
&mut self,
start: usize,
end: usize,
replacement: &str,
) -> Result<Range16, FcError> {
let (start, end) = normalize_range(start, end)?;
if end > self.len16() {
return Err(FcError::OutOfBounds {
message: format!("end {end} exceeds buffer length {}", self.len16()),
});
}
let b0 = utf16_to_byte(&self.text, start);
let b1 = utf16_to_byte(&self.text, end);
let start = self.text[..b0].encode_utf16().count();
self.text.replace_range(b0..b1, replacement);
let inserted: usize = replacement.chars().map(char::len_utf16).sum();
Ok(Range16 { start, end: start + inserted })
}
pub fn lines16(&self) -> Vec<Range16> {
let mut lines = Vec::new();
let mut line_start = 0usize;
let mut units = 0usize;
for ch in self.text.chars() {
units += ch.len_utf16();
if ch == '\n' {
lines.push(Range16 { start: line_start, end: units - 1 });
line_start = units;
}
}
lines.push(Range16 { start: line_start, end: units });
lines
}
pub fn line_at(&self, offset: usize) -> Result<Range16, FcError> {
if offset > self.len16() {
return Err(FcError::OutOfBounds {
message: format!("offset {offset} exceeds buffer length {}", self.len16()),
});
}
self.lines16()
.into_iter()
.find(|l| offset >= l.start && offset <= l.end)
.ok_or(FcError::OutOfBounds {
message: format!("no line contains offset {offset}"),
})
}
pub fn count16(&self, start: usize, end: usize) -> Result<usize, FcError> {
Ok(self.slice16(start, end)?.chars().count())
}
}
fn range_to_js(r: Range16) -> Vec<u32> {
vec![r.start as u32, r.end as u32]
}
#[cfg_attr(feature = "bindgen", wasm_bindgen)]
impl TextBuffer {
#[cfg_attr(feature = "bindgen", wasm_bindgen(constructor))]
pub fn js_from(text: String) -> TextBuffer {
TextBuffer::new(text)
}
#[cfg_attr(feature = "bindgen", wasm_bindgen(getter))]
pub fn text(&self) -> String {
self.text.clone()
}
#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = length16))]
pub fn js_len16(&self) -> usize {
self.len16()
}
#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = charCount))]
pub fn js_char_count(&self) -> usize {
self.char_count()
}
#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = lines16))]
pub fn js_lines16(&self) -> Vec<u32> {
self.lines16().into_iter().flat_map(range_to_js).collect()
}
#[cfg(feature = "bindgen")]
#[wasm_bindgen(js_name = slice16)]
pub fn js_slice16(&self, start: usize, end: usize) -> Result<String, JsValue> {
self.slice16(start, end).map_err(Into::into)
}
#[cfg(feature = "bindgen")]
#[wasm_bindgen(js_name = replace16)]
pub fn js_replace16(
&mut self,
start: usize,
end: usize,
replacement: &str,
) -> Result<Vec<u32>, JsValue> {
self.replace16(start, end, replacement)
.map(range_to_js)
.map_err(Into::into)
}
#[cfg(feature = "bindgen")]
#[wasm_bindgen(js_name = lineAt)]
pub fn js_line_at(&self, offset: usize) -> Result<Vec<u32>, JsValue> {
self.line_at(offset).map(range_to_js).map_err(Into::into)
}
#[cfg(feature = "bindgen")]
#[wasm_bindgen(js_name = countChars)]
pub fn js_count_chars(&self, start: usize, end: usize) -> Result<usize, JsValue> {
self.count16(start, end).map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MIXED: &str = "aé😀b";
#[test]
fn utf16_length_matches_javascript_semantics() {
assert_eq!(TextBuffer::new(MIXED).len16(), 5);
assert_eq!(TextBuffer::new(MIXED).char_count(), 4);
assert_eq!(MIXED.len(), 8);
}
#[test]
fn slicing_uses_utf16_offsets() {
let buf = TextBuffer::new(MIXED);
assert_eq!(buf.slice16(0, 1).unwrap(), "a");
assert_eq!(buf.slice16(1, 2).unwrap(), "é");
assert_eq!(buf.slice16(2, 4).unwrap(), "😀");
assert_eq!(buf.slice16(2, 5).unwrap(), "😀b");
}
#[test]
fn a_slice_landing_inside_a_surrogate_pair_clamps_rather_than_splitting() {
let buf = TextBuffer::new(MIXED);
let partial = buf.slice16(2, 3).unwrap();
assert!(partial.is_char_boundary(0));
assert!(
partial.chars().all(|c| c != '\u{FFFD}'),
"must not produce a replacement character"
);
assert!(partial == "😀" || partial.is_empty());
}
#[test]
fn slicing_past_the_end_is_an_error() {
let buf = TextBuffer::new("abc");
assert_eq!(
buf.slice16(0, 99).unwrap_err().kind(),
"outOfBounds",
"an out-of-range slice must be reported, not silently clamped"
);
}
#[test]
fn backwards_range_is_rejected() {
assert_eq!(
TextBuffer::new("abc").slice16(2, 1).unwrap_err().kind(),
"invalidInput"
);
}
#[test]
fn replace_returns_the_new_selection() {
let mut buf = TextBuffer::new("hello world");
let r = buf.replace16(6, 11, "🌍").unwrap();
assert_eq!(buf.as_str(), "hello 🌍");
assert_eq!(r.start, 6);
assert_eq!(r.end, 8);
}
#[test]
fn replace_can_insert_into_empty_buffer() {
let mut buf = TextBuffer::default();
let r = buf.replace16(0, 0, "abc").unwrap();
assert_eq!(buf.as_str(), "abc");
assert_eq!((r.start, r.end), (0, 3));
}
#[test]
fn lines_exclude_terminators() {
let buf = TextBuffer::new("one\ntwo\nthree");
let lines: Vec<String> = buf
.lines16()
.into_iter()
.map(|l| buf.slice16(l.start, l.end).unwrap())
.collect();
assert_eq!(lines, vec!["one", "two", "three"]);
}
#[test]
fn trailing_newline_produces_a_final_empty_line() {
let buf = TextBuffer::new("a\n");
let lines = buf.lines16();
assert_eq!(lines.len(), 2);
assert_eq!(buf.slice16(lines[1].start, lines[1].end).unwrap(), "");
}
#[test]
fn empty_buffer_has_one_empty_line() {
assert_eq!(TextBuffer::default().lines16().len(), 1);
}
#[test]
fn line_at_locates_the_containing_line() {
let buf = TextBuffer::new("one\ntwo\nthree");
let l = buf.line_at(5).unwrap();
assert_eq!(buf.slice16(l.start, l.end).unwrap(), "two");
}
#[test]
fn line_at_the_exact_newline_offset_returns_the_preceding_line() {
let buf = TextBuffer::new("one\ntwo");
let l = buf.line_at(3).unwrap();
assert_eq!(buf.slice16(l.start, l.end).unwrap(), "one");
}
#[test]
fn character_counts_are_not_utf16_or_byte_counts() {
let buf = TextBuffer::new(MIXED);
assert_eq!(buf.count16(0, 5).unwrap(), 4, "4 characters, not 5 units");
}
#[test]
fn multibyte_editing_preserves_surrounding_text() {
let mut buf = TextBuffer::new("🌍 earth");
buf.replace16(0, 2, "🌎").unwrap();
assert_eq!(buf.as_str(), "🌎 earth");
assert_eq!(buf.slice16(0, 2).unwrap(), "🌎");
}
}