Skip to main content

ftui_text/
rope.rs

1#![forbid(unsafe_code)]
2
3//! Rope-backed text storage with line/column helpers.
4//!
5//! This is a thin wrapper around `ropey::Rope` with a stable API and
6//! convenience methods for line/column and grapheme-aware operations.
7
8use std::borrow::Cow;
9use std::fmt;
10use std::ops::{Bound, RangeBounds};
11use std::str::FromStr;
12
13use ropey::{Rope as InnerRope, RopeSlice};
14use unicode_segmentation::UnicodeSegmentation;
15
16/// Rope-backed text storage.
17#[derive(Clone, Debug, Default)]
18pub struct Rope {
19    rope: InnerRope,
20}
21
22impl Rope {
23    /// Create an empty rope.
24    #[must_use]
25    pub fn new() -> Self {
26        Self {
27            rope: InnerRope::new(),
28        }
29    }
30
31    /// Create a rope from a string slice.
32    ///
33    /// This is a convenience method. You can also use `.parse()` or `From<&str>`.
34    #[must_use]
35    pub fn from_text(s: &str) -> Self {
36        Self {
37            rope: InnerRope::from_str(s),
38        }
39    }
40
41    /// Total length in bytes.
42    #[inline]
43    #[must_use]
44    pub fn len_bytes(&self) -> usize {
45        self.rope.len_bytes()
46    }
47
48    /// Total length in Unicode scalar values.
49    #[inline]
50    #[must_use]
51    pub fn len_chars(&self) -> usize {
52        self.rope.len_chars()
53    }
54
55    /// Total number of lines (newline count + 1).
56    #[inline]
57    #[must_use]
58    pub fn len_lines(&self) -> usize {
59        self.rope.len_lines()
60    }
61
62    /// Returns `true` if the rope is empty.
63    #[inline]
64    #[must_use]
65    pub fn is_empty(&self) -> bool {
66        self.rope.len_bytes() == 0
67    }
68
69    /// Get a line by index.
70    #[must_use]
71    pub fn line(&self, idx: usize) -> Option<Cow<'_, str>> {
72        if idx < self.len_lines() {
73            Some(cow_from_slice(self.rope.line(idx)))
74        } else {
75            None
76        }
77    }
78
79    /// Iterate over all lines.
80    pub fn lines(&self) -> impl Iterator<Item = Cow<'_, str>> + '_ {
81        self.rope.lines().map(cow_from_slice)
82    }
83
84    /// Get a slice of the rope by character range.
85    #[must_use]
86    pub fn slice<R>(&self, range: R) -> Cow<'_, str>
87    where
88        R: RangeBounds<usize>,
89    {
90        self.rope
91            .get_slice(range)
92            .map(cow_from_slice)
93            .unwrap_or_else(|| Cow::Borrowed(""))
94    }
95
96    /// Insert text at a character index.
97    pub fn insert(&mut self, char_idx: usize, text: &str) {
98        if text.len() >= 10_000 {
99            tracing::debug!(len = text.len(), "rope insert large text");
100        }
101        let idx = char_idx.min(self.len_chars());
102        self.rope.insert(idx, text);
103    }
104
105    /// Insert text at a grapheme index.
106    pub fn insert_grapheme(&mut self, grapheme_idx: usize, text: &str) {
107        let char_idx = self.grapheme_to_char_idx(grapheme_idx);
108        self.insert(char_idx, text);
109    }
110
111    /// Remove a character range.
112    pub fn remove<R>(&mut self, range: R)
113    where
114        R: RangeBounds<usize>,
115    {
116        let (start, end) = normalize_range(range, self.len_chars());
117        if start < end {
118            self.rope.remove(start..end);
119        }
120    }
121
122    /// Remove a grapheme range.
123    pub fn remove_grapheme_range<R>(&mut self, range: R)
124    where
125        R: RangeBounds<usize>,
126    {
127        let (start, end) = normalize_range(range, self.grapheme_count());
128        if start < end {
129            let char_start = self.grapheme_to_char_idx(start);
130            let char_end = self.grapheme_to_char_idx(end);
131            self.rope.remove(char_start..char_end);
132        }
133    }
134
135    /// Replace the entire contents.
136    pub fn replace(&mut self, text: &str) {
137        if text.len() >= 10_000 {
138            tracing::debug!(len = text.len(), "rope replace large text");
139        }
140        self.rope = InnerRope::from(text);
141    }
142
143    /// Append text to the end.
144    pub fn append(&mut self, text: &str) {
145        let len = self.len_chars();
146        self.insert(len, text);
147    }
148
149    /// Clear all content.
150    pub fn clear(&mut self) {
151        self.rope = InnerRope::new();
152    }
153
154    /// Convert a character index to a byte index.
155    #[inline]
156    #[must_use]
157    pub fn char_to_byte(&self, char_idx: usize) -> usize {
158        self.rope.char_to_byte(char_idx.min(self.len_chars()))
159    }
160
161    /// Convert a byte index to a character index.
162    #[inline]
163    #[must_use]
164    pub fn byte_to_char(&self, byte_idx: usize) -> usize {
165        self.rope.byte_to_char(byte_idx.min(self.len_bytes()))
166    }
167
168    /// Convert a character index to a line index.
169    #[inline]
170    #[must_use]
171    pub fn char_to_line(&self, char_idx: usize) -> usize {
172        self.rope.char_to_line(char_idx.min(self.len_chars()))
173    }
174
175    /// Get the character index at the start of a line.
176    #[inline]
177    #[must_use]
178    pub fn line_to_char(&self, line_idx: usize) -> usize {
179        if line_idx >= self.len_lines() {
180            self.len_chars()
181        } else {
182            self.rope.line_to_char(line_idx)
183        }
184    }
185
186    /// Convert a byte index to (line, column) in characters.
187    #[inline]
188    #[must_use]
189    pub fn byte_to_line_col(&self, byte_idx: usize) -> (usize, usize) {
190        let char_idx = self.byte_to_char(byte_idx);
191        let line = self.char_to_line(char_idx);
192        let line_start = self.line_to_char(line);
193        (line, char_idx.saturating_sub(line_start))
194    }
195
196    /// Convert (line, column) in characters to a byte index.
197    #[inline]
198    #[must_use]
199    pub fn line_col_to_byte(&self, line_idx: usize, col: usize) -> usize {
200        let line_start = self.line_to_char(line_idx);
201        let char_idx = line_start.saturating_add(col).min(self.len_chars());
202        self.char_to_byte(char_idx)
203    }
204
205    /// Iterate over all characters.
206    pub fn chars(&self) -> impl Iterator<Item = char> + '_ {
207        self.rope.chars()
208    }
209
210    /// Return all graphemes as owned strings.
211    #[must_use]
212    pub fn graphemes(&self) -> Vec<String> {
213        self.to_string()
214            .graphemes(true)
215            .map(str::to_string)
216            .collect()
217    }
218
219    /// Count grapheme clusters.
220    #[must_use]
221    pub fn grapheme_count(&self) -> usize {
222        self.to_string().graphemes(true).count()
223    }
224
225    fn grapheme_to_char_idx(&self, grapheme_idx: usize) -> usize {
226        let mut g_count = 0;
227        let mut char_count = 0;
228
229        for line in self.lines() {
230            let line_g_count = line.graphemes(true).count();
231            if g_count + line_g_count > grapheme_idx {
232                let offset = grapheme_idx - g_count;
233                for (current_g, g) in line.graphemes(true).enumerate() {
234                    if current_g == offset {
235                        return char_count;
236                    }
237                    char_count += g.chars().count();
238                }
239            }
240            g_count += line_g_count;
241            char_count += line.chars().count();
242        }
243        self.len_chars()
244    }
245}
246
247impl fmt::Display for Rope {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        for chunk in self.rope.chunks() {
250            f.write_str(chunk)?;
251        }
252        Ok(())
253    }
254}
255
256impl FromStr for Rope {
257    type Err = std::convert::Infallible;
258
259    fn from_str(s: &str) -> Result<Self, Self::Err> {
260        Ok(Self::from_text(s))
261    }
262}
263
264impl From<&str> for Rope {
265    fn from(s: &str) -> Self {
266        Self::from_text(s)
267    }
268}
269
270impl From<String> for Rope {
271    fn from(s: String) -> Self {
272        Self::from_text(&s)
273    }
274}
275
276fn cow_from_slice(slice: RopeSlice<'_>) -> Cow<'_, str> {
277    match slice.as_str() {
278        Some(s) => Cow::Borrowed(s),
279        None => Cow::Owned(slice.to_string()),
280    }
281}
282
283fn normalize_range<R>(range: R, max: usize) -> (usize, usize)
284where
285    R: RangeBounds<usize>,
286{
287    let start = match range.start_bound() {
288        Bound::Included(&s) => s,
289        Bound::Excluded(&s) => s.saturating_add(1),
290        Bound::Unbounded => 0,
291    };
292    let end = match range.end_bound() {
293        Bound::Included(&e) => e.saturating_add(1),
294        Bound::Excluded(&e) => e,
295        Bound::Unbounded => max,
296    };
297
298    let start = start.min(max);
299    let end = end.min(max);
300    if end < start {
301        (start, start)
302    } else {
303        (start, end)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use proptest::prelude::*;
311
312    #[test]
313    fn rope_basic_counts() {
314        let rope = Rope::from("Hello, world!");
315        assert_eq!(rope.len_chars(), 13);
316        assert_eq!(rope.len_lines(), 1);
317    }
318
319    #[test]
320    fn rope_multiline_lines() {
321        let rope = Rope::from("Line 1\nLine 2\nLine 3");
322        assert_eq!(rope.len_lines(), 3);
323        assert_eq!(rope.line(0).unwrap(), "Line 1\n");
324        assert_eq!(rope.line(2).unwrap(), "Line 3");
325    }
326
327    #[test]
328    fn rope_insert_remove_replace() {
329        let mut rope = Rope::from("Hello!");
330        rope.insert(5, ", world");
331        assert_eq!(rope.to_string(), "Hello, world!");
332
333        rope.remove(5..12);
334        assert_eq!(rope.to_string(), "Hello!");
335
336        rope.replace("Replaced");
337        assert_eq!(rope.to_string(), "Replaced");
338    }
339
340    #[test]
341    fn rope_append_clear() {
342        let mut rope = Rope::from("Hi");
343        rope.append(" there");
344        assert_eq!(rope.to_string(), "Hi there");
345        rope.clear();
346        assert!(rope.is_empty());
347        assert_eq!(rope.len_lines(), 1);
348    }
349
350    #[test]
351    fn rope_char_byte_conversions() {
352        let s = "a\u{1F600}b";
353        let rope = Rope::from(s);
354        assert_eq!(rope.len_chars(), 3);
355        assert_eq!(rope.char_to_byte(0), 0);
356        assert_eq!(rope.char_to_byte(1), "a".len());
357        assert_eq!(rope.byte_to_char(rope.len_bytes()), 3);
358    }
359
360    #[test]
361    fn rope_line_col_conversions() {
362        let rope = Rope::from("ab\ncde\n");
363        let (line, col) = rope.byte_to_line_col(4);
364        assert_eq!(line, 1);
365        assert_eq!(col, 1);
366
367        let byte = rope.line_col_to_byte(1, 2);
368        assert_eq!(byte, 5);
369    }
370
371    #[test]
372    fn rope_grapheme_ops() {
373        let mut rope = Rope::from("e\u{301}");
374        assert_eq!(rope.grapheme_count(), 1);
375        rope.insert_grapheme(1, "!");
376        assert_eq!(rope.to_string(), "e\u{301}!");
377
378        let mut rope = Rope::from("a\u{1F600}b");
379        rope.remove_grapheme_range(1..2);
380        assert_eq!(rope.to_string(), "ab");
381    }
382
383    proptest! {
384        #[test]
385        fn insert_remove_roundtrip(s in any::<String>(), insert in any::<String>(), idx in 0usize..200) {
386            let mut rope = Rope::from(s.as_str());
387            let insert_len = insert.chars().count();
388            let pos = idx.min(rope.len_chars());
389            rope.insert(pos, &insert);
390            rope.remove(pos..pos.saturating_add(insert_len));
391            prop_assert_eq!(rope.to_string(), s);
392        }
393
394        #[test]
395        fn line_count_matches_newlines(s in "[^\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}]*") {
396            // Exclude all line separators except \n (CR, VT, FF, NEL, LS, PS)
397            // ropey treats these as line breaks but we only count \n
398            let rope = Rope::from(s.as_str());
399            let newlines = s.as_bytes().iter().filter(|&&b| b == b'\n').count();
400            prop_assert_eq!(rope.len_lines(), newlines + 1);
401        }
402    }
403
404    // ====== Empty rope tests ======
405
406    #[test]
407    fn empty_rope_properties() {
408        let rope = Rope::new();
409        assert!(rope.is_empty());
410        assert_eq!(rope.len_bytes(), 0);
411        assert_eq!(rope.len_chars(), 0);
412        assert_eq!(rope.len_lines(), 1); // ropey: empty string = 1 line
413        assert_eq!(rope.grapheme_count(), 0);
414        assert_eq!(rope.to_string(), "");
415    }
416
417    #[test]
418    fn empty_rope_line_access() {
419        let rope = Rope::new();
420        assert!(rope.line(0).is_some()); // empty string is line 0
421        assert!(rope.line(1).is_none());
422    }
423
424    #[test]
425    fn empty_rope_slice() {
426        let rope = Rope::new();
427        assert_eq!(rope.slice(0..0), "");
428        assert_eq!(rope.slice(..), "");
429    }
430
431    #[test]
432    fn empty_rope_conversions() {
433        let rope = Rope::new();
434        assert_eq!(rope.char_to_byte(0), 0);
435        assert_eq!(rope.byte_to_char(0), 0);
436        assert_eq!(rope.char_to_line(0), 0);
437        assert_eq!(rope.line_to_char(0), 0);
438    }
439
440    // ====== From impls ======
441
442    #[test]
443    fn from_str_impl() {
444        let rope: Rope = "hello".into();
445        assert_eq!(rope.to_string(), "hello");
446    }
447
448    #[test]
449    fn from_string_impl() {
450        let rope: Rope = String::from("hello").into();
451        assert_eq!(rope.to_string(), "hello");
452    }
453
454    #[test]
455    fn from_str_parse() {
456        let rope: Rope = "hello".parse().unwrap();
457        assert_eq!(rope.to_string(), "hello");
458    }
459
460    #[test]
461    fn display_impl() {
462        let rope = Rope::from("hello world");
463        assert_eq!(format!("{rope}"), "hello world");
464    }
465
466    // ====== Line access ======
467
468    #[test]
469    fn line_out_of_bounds() {
470        let rope = Rope::from("a\nb");
471        assert!(rope.line(0).is_some());
472        assert!(rope.line(1).is_some());
473        assert!(rope.line(2).is_none());
474        assert!(rope.line(100).is_none());
475    }
476
477    #[test]
478    fn trailing_newline_creates_empty_last_line() {
479        let rope = Rope::from("a\n");
480        assert_eq!(rope.len_lines(), 2);
481        assert_eq!(rope.line(0).unwrap(), "a\n");
482        assert_eq!(rope.line(1).unwrap(), "");
483    }
484
485    #[test]
486    fn multiple_newlines() {
487        let rope = Rope::from("\n\n\n");
488        assert_eq!(rope.len_lines(), 4);
489    }
490
491    #[test]
492    fn lines_iterator() {
493        let rope = Rope::from("a\nb\nc");
494        let lines: Vec<String> = rope.lines().map(|c| c.to_string()).collect();
495        assert_eq!(lines.len(), 3);
496        assert_eq!(lines[0], "a\n");
497        assert_eq!(lines[1], "b\n");
498        assert_eq!(lines[2], "c");
499    }
500
501    // ====== Slice ======
502
503    #[test]
504    fn slice_basic() {
505        let rope = Rope::from("hello world");
506        assert_eq!(rope.slice(0..5), "hello");
507        assert_eq!(rope.slice(6..11), "world");
508        assert_eq!(rope.slice(6..), "world");
509        assert_eq!(rope.slice(..5), "hello");
510    }
511
512    #[test]
513    fn slice_out_of_bounds_returns_empty() {
514        let rope = Rope::from("hi");
515        assert_eq!(rope.slice(100..200), "");
516    }
517
518    // ====== Insert edge cases ======
519
520    #[test]
521    fn insert_at_beginning() {
522        let mut rope = Rope::from("world");
523        rope.insert(0, "hello ");
524        assert_eq!(rope.to_string(), "hello world");
525    }
526
527    #[test]
528    fn insert_at_end() {
529        let mut rope = Rope::from("hello");
530        rope.insert(5, " world");
531        assert_eq!(rope.to_string(), "hello world");
532    }
533
534    #[test]
535    fn insert_beyond_length_clamps() {
536        let mut rope = Rope::from("hi");
537        rope.insert(100, "!");
538        assert_eq!(rope.to_string(), "hi!");
539    }
540
541    #[test]
542    fn insert_empty_string() {
543        let mut rope = Rope::from("hello");
544        rope.insert(2, "");
545        assert_eq!(rope.to_string(), "hello");
546    }
547
548    // ====== Remove edge cases ======
549
550    #[test]
551    fn remove_empty_range() {
552        let mut rope = Rope::from("hello");
553        rope.remove(2..2);
554        assert_eq!(rope.to_string(), "hello");
555    }
556
557    #[test]
558    fn remove_entire_content() {
559        let mut rope = Rope::from("hello");
560        rope.remove(..);
561        assert!(rope.is_empty());
562    }
563
564    #[test]
565    #[allow(clippy::reversed_empty_ranges)]
566    fn remove_inverted_range_is_noop() {
567        let mut rope = Rope::from("hello");
568        rope.remove(3..1); // end < start
569        assert_eq!(rope.to_string(), "hello");
570    }
571
572    // ====== Grapheme operations ======
573
574    #[test]
575    fn grapheme_insert_at_beginning() {
576        let mut rope = Rope::from("bc");
577        rope.insert_grapheme(0, "a");
578        assert_eq!(rope.to_string(), "abc");
579    }
580
581    #[test]
582    fn grapheme_insert_with_combining() {
583        let mut rope = Rope::from("e\u{301}x"); // é x
584        assert_eq!(rope.grapheme_count(), 2);
585        rope.insert_grapheme(1, "y");
586        assert_eq!(rope.to_string(), "e\u{301}yx");
587    }
588
589    #[test]
590    fn grapheme_remove_range() {
591        let mut rope = Rope::from("abcd");
592        rope.remove_grapheme_range(1..3);
593        assert_eq!(rope.to_string(), "ad");
594    }
595
596    #[test]
597    fn grapheme_remove_empty_range() {
598        let mut rope = Rope::from("abc");
599        rope.remove_grapheme_range(1..1);
600        assert_eq!(rope.to_string(), "abc");
601    }
602
603    #[test]
604    fn graphemes_returns_correct_list() {
605        let rope = Rope::from("ae\u{301}b"); // a é b
606        let gs = rope.graphemes();
607        assert_eq!(gs.len(), 3);
608        assert_eq!(gs[0], "a");
609        assert_eq!(gs[1], "e\u{301}");
610        assert_eq!(gs[2], "b");
611    }
612
613    // ====== Char/byte/line conversions ======
614
615    #[test]
616    fn char_to_byte_with_multibyte() {
617        let rope = Rope::from("a\u{1F600}b"); // a 😀 b
618        assert_eq!(rope.char_to_byte(0), 0); // 'a'
619        assert_eq!(rope.char_to_byte(1), 1); // start of emoji
620        assert_eq!(rope.char_to_byte(2), 5); // 'b' (1 + 4 bytes for emoji)
621    }
622
623    #[test]
624    fn byte_to_char_clamps() {
625        let rope = Rope::from("hi");
626        assert_eq!(rope.byte_to_char(100), 2);
627    }
628
629    #[test]
630    fn char_to_byte_clamps() {
631        let rope = Rope::from("hi");
632        assert_eq!(rope.char_to_byte(100), 2);
633    }
634
635    #[test]
636    fn line_to_char_out_of_bounds() {
637        let rope = Rope::from("a\nb");
638        assert_eq!(rope.line_to_char(0), 0);
639        assert_eq!(rope.line_to_char(1), 2);
640        assert_eq!(rope.line_to_char(100), 3); // len_chars
641    }
642
643    #[test]
644    fn byte_to_line_col_basic() {
645        let rope = Rope::from("abc\ndef");
646        let (line, col) = rope.byte_to_line_col(5); // 'e' in "def"
647        assert_eq!(line, 1);
648        assert_eq!(col, 1);
649    }
650
651    #[test]
652    fn line_col_to_byte_basic() {
653        let rope = Rope::from("abc\ndef");
654        let byte = rope.line_col_to_byte(1, 1);
655        assert_eq!(byte, 5); // 'e'
656    }
657
658    // ====== Chars iterator ======
659
660    #[test]
661    fn chars_iterator() {
662        let rope = Rope::from("ab");
663        let chars: Vec<char> = rope.chars().collect();
664        assert_eq!(chars, vec!['a', 'b']);
665    }
666
667    // ====== normalize_range helper ======
668
669    #[test]
670    fn normalize_range_basic() {
671        assert_eq!(normalize_range(2..5, 10), (2, 5));
672        assert_eq!(normalize_range(0..10, 10), (0, 10));
673        assert_eq!(normalize_range(.., 10), (0, 10));
674    }
675
676    #[test]
677    fn normalize_range_clamps_to_max() {
678        assert_eq!(normalize_range(0..100, 5), (0, 5));
679        assert_eq!(normalize_range(50..100, 5), (5, 5));
680    }
681
682    #[test]
683    #[allow(clippy::reversed_empty_ranges)]
684    fn normalize_range_inverted_becomes_empty() {
685        assert_eq!(normalize_range(5..2, 10), (5, 5));
686    }
687
688    #[test]
689    fn normalize_range_inclusive() {
690        assert_eq!(normalize_range(1..=3, 10), (1, 4));
691    }
692
693    // ====== Property tests ======
694
695    proptest! {
696        #[test]
697        fn append_then_len_grows(s in "\\PC{0,50}", suffix in "\\PC{0,50}") {
698            let mut rope = Rope::from(s.as_str());
699            let before = rope.len_chars();
700            let suffix_len = suffix.chars().count();
701            rope.append(&suffix);
702            prop_assert_eq!(rope.len_chars(), before + suffix_len);
703        }
704
705        #[test]
706        fn replace_yields_new_content(s in "\\PC{0,50}", replacement in "\\PC{0,50}") {
707            let mut rope = Rope::from(s.as_str());
708            rope.replace(&replacement);
709            prop_assert_eq!(rope.to_string(), replacement);
710        }
711
712        #[test]
713        fn clear_always_empty(s in "\\PC{0,100}") {
714            let mut rope = Rope::from(s.as_str());
715            rope.clear();
716            prop_assert!(rope.is_empty());
717            prop_assert_eq!(rope.len_bytes(), 0);
718            prop_assert_eq!(rope.len_chars(), 0);
719        }
720
721        #[test]
722        fn display_matches_to_string(s in "\\PC{0,100}") {
723            let rope = Rope::from(s.as_str());
724            prop_assert_eq!(format!("{rope}"), rope.to_string());
725        }
726
727        #[test]
728        fn char_byte_roundtrip(s in "\\PC{1,50}", idx in 0usize..50) {
729            let rope = Rope::from(s.as_str());
730            let char_idx = idx.min(rope.len_chars());
731            let byte_idx = rope.char_to_byte(char_idx);
732            let back = rope.byte_to_char(byte_idx);
733            prop_assert_eq!(back, char_idx);
734        }
735
736        #[test]
737        fn grapheme_count_leq_char_count(s in "\\PC{0,100}") {
738            let rope = Rope::from(s.as_str());
739            prop_assert!(rope.grapheme_count() <= rope.len_chars());
740        }
741    }
742}