Skip to main content

fc_model/
lib.rs

1//! fc-model — shared text buffer primitives.
2//!
3//! # Why a text buffer needs care
4//!
5//! Almost every "simple" text editor bug is a UTF-8 bug. JavaScript string
6//! indices and Rust `str` indices are *byte* offsets; a user perceives *character*
7//! positions. For ASCII they coincide, which is why the bug survives development
8//! and appears in production for the first user typing an emoji or a combining
9//! accent.
10//!
11//! Slicing a Rust `str` at a non-character-boundary index panics rather than
12//! producing garbage, and on `wasm32-unknown-unknown` that panic is an abort. So
13//! this crate owns every offset conversion, and the JS-facing API speaks **UTF-16
14//! code-unit offsets** exclusively, because that is what JavaScript's own
15//! indexing, selection APIs, and `String.prototype.slice` use. Converting once,
16//! here, is what keeps the rest of the workspace simple.
17
18use fc_core::{FcError, normalize_range};
19#[cfg(feature = "bindgen")]
20use wasm_bindgen::prelude::*;
21
22/// A UTF-8 text buffer with UTF-16-indexed access.
23///
24/// All public offsets are UTF-16 code units, matching the JavaScript side.
25#[cfg_attr(feature = "bindgen", wasm_bindgen)]
26#[derive(Debug, Clone, Default)]
27pub struct TextBuffer {
28    text: String,
29}
30
31/// A half-open range in UTF-16 code units.
32#[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
48/// Maps a UTF-16 offset to a UTF-8 byte offset, clamping to the nearest
49/// character boundary at or before it.
50///
51/// Clamping downward rather than erroring is deliberate: JavaScript ranges are
52/// frequently produced by arithmetic on visible offsets, and one that lands
53/// mid-surrogate-pair is a recoverable imprecision. Panicking would turn a
54/// cosmetic edge case into a crash. The boundary at or before is chosen so the
55/// visible text never grows past what the caller asked for.
56fn 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    /// Length in UTF-16 code units, matching JS `String.prototype.length`.
77    pub fn len16(&self) -> usize {
78        self.text.chars().map(char::len_utf16).sum()
79    }
80
81    /// Length in Unicode scalar values. Differs from [`Self::len16`] wherever a
82    /// character outside the BMP occurs.
83    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    /// Extracts a substring using UTF-16 offsets.
92    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    /// Replaces a UTF-16 range, returning the range the inserted text now
105    /// occupies so a caller can place the caret after an edit without
106    /// recomputing it.
107    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        // The returned caret range must start where the boundary-clamped edit did.
122        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    /// Splits into lines, reporting each line's range in UTF-16 units.
129    ///
130    /// Terminators are not included, and a trailing newline produces a final
131    /// empty line -- the behaviour an editor needs so the caret can sit on the
132    /// line after a final newline.
133    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    /// Returns the UTF-16 range of the line containing `offset`.
150    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    /// Counts characters in a UTF-16 range, for the character counts a UI
165    /// displays -- counting bytes or code units there would be visibly wrong.
166    pub fn count16(&self, start: usize, end: usize) -> Result<usize, FcError> {
167        Ok(self.slice16(start, end)?.chars().count())
168    }
169}
170
171// ---- JS bindings --------------------------------------------------------
172//
173// The WASM surface is intentionally narrow. Buffer contents and lengths cross
174// the boundary; per-character iteration does not, because a JS loop over a
175// 100,000-character document would be orders of magnitude slower than one call
176// into Rust.
177
178// Typed coordinates avoid JS object allocation and preserve exact UTF-16 offsets.
179fn 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    /// Constructs a buffer from JS.
186    ///
187    /// `constructor` rather than a static factory: it makes the type
188    /// instantiable from JavaScript, which wasm_bindgen otherwise refuses
189    /// because it only generates ABI glue for values it can move across the
190    /// boundary.
191    #[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    /// Returns flat `[start, end, ...]` coordinates, one pair per line.
212    #[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    /// Replaces a range and returns the `[start, end]` the inserted text now
224    /// occupies, so the caller can place the caret without recomputing it.
225    #[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    /// Counts characters, not bytes or UTF-16 units, in a range.
245    #[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    /// "aĆ©šŸ˜€b" -- a 2-byte, 4-byte, and 1-byte character in one string, which is
256    /// the minimal case that distinguishes byte, char, and UTF-16 offsets.
257    const MIXED: &str = "aĆ©šŸ˜€b";
258
259    #[test]
260    fn utf16_length_matches_javascript_semantics() {
261        // JS: "aĆ©šŸ˜€b".length === 5 (the emoji is a surrogate pair).
262        assert_eq!(TextBuffer::new(MIXED).len16(), 5);
263        // But there are only 4 characters, and 8 bytes in UTF-8.
264        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        // "aĆ©šŸ˜€b" occupies UTF-16 units: a=0, Ć©=1, šŸ˜€=2-3, b=4. Note that Ć© is
271        // two *bytes* but a single UTF-16 unit, because it is inside the BMP --
272        // the distinction that makes hand-written offset arithmetic go wrong.
273        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        // JavaScript's own slice(2,3) on this string returns "šŸ˜€" with its
283        // trailing surrogate detached -- valid JS, but not a valid character.
284        // Rust cannot represent that: slicing a str mid-codepoint panics, and on
285        // wasm32 that panic is an abort.
286        //
287        // So this implementation deliberately DIVERGES from JavaScript here: it
288        // clamps to the nearest character boundary and returns a valid string.
289        // That is the safer failure mode, and it is why the JS side is
290        // documented to round offsets to a boundary before calling in.
291        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        // The clamp yields the whole character, never a half of one.
299        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        // Replace "world" with an emoji, which changes both length and units.
324        let r = buf.replace16(6, 11, "šŸŒ").unwrap();
325        assert_eq!(buf.as_str(), "hello šŸŒ");
326        // The caret lands after the inserted text: 6 + 2 UTF-16 units.
327        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        // An editor needs this so the caret can sit on the line after the
353        // final newline.
354        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        // Offset 3 is the "\n" itself. Attributing it to the line it terminates
375        // keeps Enter-key handling simple for the caller.
376        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        // The regression this crate exists to prevent: editing after a
390        // multi-byte character must not corrupt it.
391        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}