1use fc_core::{FcError, normalize_range};
19#[cfg(feature = "bindgen")]
20use wasm_bindgen::prelude::*;
21
22#[cfg_attr(feature = "bindgen", wasm_bindgen)]
26#[derive(Debug, Clone, Default)]
27pub struct TextBuffer {
28 text: String,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Range16 {
34 pub start: usize,
35 pub end: usize,
36}
37
38impl Range16 {
39 pub fn len(&self) -> usize {
40 self.end - self.start
41 }
42
43 pub fn is_empty(&self) -> bool {
44 self.start == self.end
45 }
46}
47
48fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
57 let mut units = 0usize;
58 for (byte_idx, ch) in text.char_indices() {
59 if units >= utf16_idx {
60 return byte_idx;
61 }
62 units += ch.len_utf16();
63 }
64 text.len()
65}
66
67impl TextBuffer {
68 pub fn new(text: impl Into<String>) -> Self {
69 Self { text: text.into() }
70 }
71
72 pub fn as_str(&self) -> &str {
73 &self.text
74 }
75
76 pub fn len16(&self) -> usize {
78 self.text.chars().map(char::len_utf16).sum()
79 }
80
81 pub fn char_count(&self) -> usize {
84 self.text.chars().count()
85 }
86
87 pub fn is_empty(&self) -> bool {
88 self.text.is_empty()
89 }
90
91 pub fn slice16(&self, start: usize, end: usize) -> Result<String, FcError> {
93 let (start, end) = normalize_range(start, end)?;
94 if end > self.len16() {
95 return Err(FcError::OutOfBounds {
96 message: format!("end {end} exceeds buffer length {}", self.len16()),
97 });
98 }
99 let b0 = utf16_to_byte(&self.text, start);
100 let b1 = utf16_to_byte(&self.text, end);
101 Ok(self.text[b0..b1].to_string())
102 }
103
104 pub fn replace16(
108 &mut self,
109 start: usize,
110 end: usize,
111 replacement: &str,
112 ) -> Result<Range16, FcError> {
113 let (start, end) = normalize_range(start, end)?;
114 if end > self.len16() {
115 return Err(FcError::OutOfBounds {
116 message: format!("end {end} exceeds buffer length {}", self.len16()),
117 });
118 }
119 let b0 = utf16_to_byte(&self.text, start);
120 let b1 = utf16_to_byte(&self.text, end);
121 let start = self.text[..b0].encode_utf16().count();
123 self.text.replace_range(b0..b1, replacement);
124 let inserted: usize = replacement.chars().map(char::len_utf16).sum();
125 Ok(Range16 { start, end: start + inserted })
126 }
127
128 pub fn lines16(&self) -> Vec<Range16> {
134 let mut lines = Vec::new();
135 let mut line_start = 0usize;
136 let mut units = 0usize;
137
138 for ch in self.text.chars() {
139 units += ch.len_utf16();
140 if ch == '\n' {
141 lines.push(Range16 { start: line_start, end: units - 1 });
142 line_start = units;
143 }
144 }
145 lines.push(Range16 { start: line_start, end: units });
146 lines
147 }
148
149 pub fn line_at(&self, offset: usize) -> Result<Range16, FcError> {
151 if offset > self.len16() {
152 return Err(FcError::OutOfBounds {
153 message: format!("offset {offset} exceeds buffer length {}", self.len16()),
154 });
155 }
156 self.lines16()
157 .into_iter()
158 .find(|l| offset >= l.start && offset <= l.end)
159 .ok_or(FcError::OutOfBounds {
160 message: format!("no line contains offset {offset}"),
161 })
162 }
163
164 pub fn count16(&self, start: usize, end: usize) -> Result<usize, FcError> {
167 Ok(self.slice16(start, end)?.chars().count())
168 }
169}
170
171fn range_to_js(r: Range16) -> Vec<u32> {
180 vec![r.start as u32, r.end as u32]
181}
182
183#[cfg_attr(feature = "bindgen", wasm_bindgen)]
184impl TextBuffer {
185 #[cfg_attr(feature = "bindgen", wasm_bindgen(constructor))]
192 pub fn js_from(text: String) -> TextBuffer {
193 TextBuffer::new(text)
194 }
195
196 #[cfg_attr(feature = "bindgen", wasm_bindgen(getter))]
197 pub fn text(&self) -> String {
198 self.text.clone()
199 }
200
201 #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = length16))]
202 pub fn js_len16(&self) -> usize {
203 self.len16()
204 }
205
206 #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = charCount))]
207 pub fn js_char_count(&self) -> usize {
208 self.char_count()
209 }
210
211 #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = lines16))]
213 pub fn js_lines16(&self) -> Vec<u32> {
214 self.lines16().into_iter().flat_map(range_to_js).collect()
215 }
216
217 #[cfg(feature = "bindgen")]
218 #[wasm_bindgen(js_name = slice16)]
219 pub fn js_slice16(&self, start: usize, end: usize) -> Result<String, JsValue> {
220 self.slice16(start, end).map_err(Into::into)
221 }
222
223 #[cfg(feature = "bindgen")]
226 #[wasm_bindgen(js_name = replace16)]
227 pub fn js_replace16(
228 &mut self,
229 start: usize,
230 end: usize,
231 replacement: &str,
232 ) -> Result<Vec<u32>, JsValue> {
233 self.replace16(start, end, replacement)
234 .map(range_to_js)
235 .map_err(Into::into)
236 }
237
238 #[cfg(feature = "bindgen")]
239 #[wasm_bindgen(js_name = lineAt)]
240 pub fn js_line_at(&self, offset: usize) -> Result<Vec<u32>, JsValue> {
241 self.line_at(offset).map(range_to_js).map_err(Into::into)
242 }
243
244 #[cfg(feature = "bindgen")]
246 #[wasm_bindgen(js_name = countChars)]
247 pub fn js_count_chars(&self, start: usize, end: usize) -> Result<usize, JsValue> {
248 self.count16(start, end).map_err(Into::into)
249 }
250}
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 const MIXED: &str = "aĆ©šb";
258
259 #[test]
260 fn utf16_length_matches_javascript_semantics() {
261 assert_eq!(TextBuffer::new(MIXED).len16(), 5);
263 assert_eq!(TextBuffer::new(MIXED).char_count(), 4);
265 assert_eq!(MIXED.len(), 8);
266 }
267
268 #[test]
269 fn slicing_uses_utf16_offsets() {
270 let buf = TextBuffer::new(MIXED);
274 assert_eq!(buf.slice16(0, 1).unwrap(), "a");
275 assert_eq!(buf.slice16(1, 2).unwrap(), "Ć©");
276 assert_eq!(buf.slice16(2, 4).unwrap(), "š");
277 assert_eq!(buf.slice16(2, 5).unwrap(), "šb");
278 }
279
280 #[test]
281 fn a_slice_landing_inside_a_surrogate_pair_clamps_rather_than_splitting() {
282 let buf = TextBuffer::new(MIXED);
292 let partial = buf.slice16(2, 3).unwrap();
293 assert!(partial.is_char_boundary(0));
294 assert!(
295 partial.chars().all(|c| c != '\u{FFFD}'),
296 "must not produce a replacement character"
297 );
298 assert!(partial == "š" || partial.is_empty());
300 }
301
302 #[test]
303 fn slicing_past_the_end_is_an_error() {
304 let buf = TextBuffer::new("abc");
305 assert_eq!(
306 buf.slice16(0, 99).unwrap_err().kind(),
307 "outOfBounds",
308 "an out-of-range slice must be reported, not silently clamped"
309 );
310 }
311
312 #[test]
313 fn backwards_range_is_rejected() {
314 assert_eq!(
315 TextBuffer::new("abc").slice16(2, 1).unwrap_err().kind(),
316 "invalidInput"
317 );
318 }
319
320 #[test]
321 fn replace_returns_the_new_selection() {
322 let mut buf = TextBuffer::new("hello world");
323 let r = buf.replace16(6, 11, "š").unwrap();
325 assert_eq!(buf.as_str(), "hello š");
326 assert_eq!(r.start, 6);
328 assert_eq!(r.end, 8);
329 }
330
331 #[test]
332 fn replace_can_insert_into_empty_buffer() {
333 let mut buf = TextBuffer::default();
334 let r = buf.replace16(0, 0, "abc").unwrap();
335 assert_eq!(buf.as_str(), "abc");
336 assert_eq!((r.start, r.end), (0, 3));
337 }
338
339 #[test]
340 fn lines_exclude_terminators() {
341 let buf = TextBuffer::new("one\ntwo\nthree");
342 let lines: Vec<String> = buf
343 .lines16()
344 .into_iter()
345 .map(|l| buf.slice16(l.start, l.end).unwrap())
346 .collect();
347 assert_eq!(lines, vec!["one", "two", "three"]);
348 }
349
350 #[test]
351 fn trailing_newline_produces_a_final_empty_line() {
352 let buf = TextBuffer::new("a\n");
355 let lines = buf.lines16();
356 assert_eq!(lines.len(), 2);
357 assert_eq!(buf.slice16(lines[1].start, lines[1].end).unwrap(), "");
358 }
359
360 #[test]
361 fn empty_buffer_has_one_empty_line() {
362 assert_eq!(TextBuffer::default().lines16().len(), 1);
363 }
364
365 #[test]
366 fn line_at_locates_the_containing_line() {
367 let buf = TextBuffer::new("one\ntwo\nthree");
368 let l = buf.line_at(5).unwrap();
369 assert_eq!(buf.slice16(l.start, l.end).unwrap(), "two");
370 }
371
372 #[test]
373 fn line_at_the_exact_newline_offset_returns_the_preceding_line() {
374 let buf = TextBuffer::new("one\ntwo");
377 let l = buf.line_at(3).unwrap();
378 assert_eq!(buf.slice16(l.start, l.end).unwrap(), "one");
379 }
380
381 #[test]
382 fn character_counts_are_not_utf16_or_byte_counts() {
383 let buf = TextBuffer::new(MIXED);
384 assert_eq!(buf.count16(0, 5).unwrap(), 4, "4 characters, not 5 units");
385 }
386
387 #[test]
388 fn multibyte_editing_preserves_surrounding_text() {
389 let mut buf = TextBuffer::new("š earth");
392 buf.replace16(0, 2, "š").unwrap();
393 assert_eq!(buf.as_str(), "š earth");
394 assert_eq!(buf.slice16(0, 2).unwrap(), "š");
395 }
396}