Skip to main content

cranpose_foundation/text/
range.rs

1/// Represents a range in text, used for cursor position and selection.
2///
3/// When `start == end`, this represents a cursor position (collapsed selection).
4/// When `start != end`, this represents a text selection.
5///
6/// # Invariants
7///
8/// - Indices are in UTF-8 byte offsets (matching Rust's `String`)
9/// - `start` can be greater than `end` for reverse selections
10/// - Use `min()` and `max()` for ordered access
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
12pub struct TextRange {
13    /// Start index of the range (can be > end for reverse selection)
14    pub start: usize,
15    /// End index of the range
16    pub end: usize,
17}
18
19impl TextRange {
20    /// Creates a new text range.
21    pub const fn new(start: usize, end: usize) -> Self {
22        Self { start, end }
23    }
24
25    /// Creates a collapsed range (cursor) at the given position.
26    pub const fn cursor(position: usize) -> Self {
27        Self {
28            start: position,
29            end: position,
30        }
31    }
32
33    /// Creates a range from 0 to 0 (cursor at start).
34    pub const fn zero() -> Self {
35        Self { start: 0, end: 0 }
36    }
37
38    /// Returns true if this range is collapsed (cursor, not selection).
39    pub const fn collapsed(&self) -> bool {
40        self.start == self.end
41    }
42
43    /// Returns the length of the selection in characters.
44    pub fn length(&self) -> usize {
45        self.end.abs_diff(self.start)
46    }
47
48    /// Returns the minimum (leftmost) index.
49    pub fn min(&self) -> usize {
50        self.start.min(self.end)
51    }
52
53    /// Returns the maximum (rightmost) index.
54    pub fn max(&self) -> usize {
55        self.start.max(self.end)
56    }
57
58    /// Returns true if this range contains the given index.
59    pub fn contains(&self, index: usize) -> bool {
60        index >= self.min() && index < self.max()
61    }
62
63    /// Coerces the range to be within [0, max].
64    pub fn coerce_in(&self, max: usize) -> Self {
65        Self {
66            start: self.start.min(max),
67            end: self.end.min(max),
68        }
69    }
70
71    /// Returns a range covering the entire text of given length.
72    pub const fn all(length: usize) -> Self {
73        Self {
74            start: 0,
75            end: length,
76        }
77    }
78
79    /// Safely slices the text, clamping to valid UTF-8 char boundaries.
80    ///
81    /// This handles edge cases where:
82    /// - Range extends beyond text length
83    /// - Range indices are not on char boundaries (e.g., in middle of multi-byte UTF-8)
84    ///
85    /// Returns an empty string if the range is invalid.
86    pub fn safe_slice<'a>(&self, text: &'a str) -> &'a str {
87        if text.is_empty() {
88            return "";
89        }
90
91        let start = self.min().min(text.len());
92        let end = self.max().min(text.len());
93
94        let start = if text.is_char_boundary(start) {
95            start
96        } else {
97            (0..start)
98                .rev()
99                .find(|&i| text.is_char_boundary(i))
100                .unwrap_or(0)
101        };
102
103        let end = if text.is_char_boundary(end) {
104            end
105        } else {
106            (end..=text.len())
107                .find(|&i| text.is_char_boundary(i))
108                .unwrap_or(text.len())
109        };
110
111        if start <= end { &text[start..end] } else { "" }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn cursor_is_collapsed() {
121        let cursor = TextRange::cursor(5);
122        assert!(cursor.collapsed());
123        assert_eq!(cursor.length(), 0);
124        assert_eq!(cursor.start, 5);
125        assert_eq!(cursor.end, 5);
126    }
127
128    #[test]
129    fn selection_is_not_collapsed() {
130        let selection = TextRange::new(2, 7);
131        assert!(!selection.collapsed());
132        assert_eq!(selection.length(), 5);
133    }
134
135    #[test]
136    fn reverse_selection_length() {
137        let reverse = TextRange::new(7, 2);
138        assert_eq!(reverse.length(), 5);
139        assert_eq!(reverse.min(), 2);
140        assert_eq!(reverse.max(), 7);
141    }
142
143    #[test]
144    fn coerce_in_bounds() {
145        let range = TextRange::new(5, 100);
146        let coerced = range.coerce_in(10);
147        assert_eq!(coerced.start, 5);
148        assert_eq!(coerced.end, 10);
149    }
150
151    #[test]
152    fn contains_index() {
153        let range = TextRange::new(2, 5);
154        assert!(!range.contains(1));
155        assert!(range.contains(2));
156        assert!(range.contains(3));
157        assert!(range.contains(4));
158        assert!(!range.contains(5));
159    }
160
161    #[test]
162    fn safe_slice_basic() {
163        let range = TextRange::new(0, 5);
164        assert_eq!(range.safe_slice("Hello World"), "Hello");
165    }
166
167    #[test]
168    fn safe_slice_beyond_bounds() {
169        let range = TextRange::new(0, 100);
170        assert_eq!(range.safe_slice("Hello"), "Hello");
171    }
172
173    #[test]
174    fn safe_slice_unicode() {
175        let text = "Hello 🌍";
176        let range = TextRange::new(0, 7);
177        let slice = range.safe_slice(text);
178        assert!(slice == "Hello " || slice == "Hello 🌍");
179    }
180}