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