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