Skip to main content

indigo_kernel/
grapheme.rs

1//! Unicode grapheme cluster boundaries.
2//!
3//! <https://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries>
4
5use ropey::{RopeSlice, iter::Chunks};
6use std::iter::FusedIterator;
7use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
8
9#[must_use]
10pub fn is_char_boundary(rope: &RopeSlice, byte_offset: usize) -> bool {
11    assert!(
12        byte_offset <= rope.len(),
13        "byte offset {byte_offset} is out of bounds (rope length is {})",
14        rope.len()
15    );
16    if byte_offset == 0 || byte_offset == rope.len() {
17        return true;
18    }
19    let (chunk, chunk_byte_offset) = rope.chunk(byte_offset);
20    chunk.is_char_boundary(byte_offset - chunk_byte_offset)
21}
22
23#[must_use]
24pub fn floor_char_boundary(rope: &RopeSlice, byte_offset: usize) -> usize {
25    assert!(
26        byte_offset <= rope.len(),
27        "byte offset {byte_offset} is out of bounds (rope length is {})",
28        rope.len()
29    );
30    if byte_offset == rope.len() {
31        return byte_offset;
32    }
33    let (chunk, chunk_byte_offset) = rope.chunk(byte_offset);
34    chunk_byte_offset + chunk.floor_char_boundary(byte_offset - chunk_byte_offset)
35}
36
37#[must_use]
38pub fn ceil_char_boundary(rope: &RopeSlice, byte_offset: usize) -> usize {
39    assert!(
40        byte_offset <= rope.len(),
41        "byte offset {byte_offset} is out of bounds (rope length is {})",
42        rope.len()
43    );
44    if byte_offset == rope.len() {
45        return byte_offset;
46    }
47    let (chunk, chunk_byte_offset) = rope.chunk(byte_offset);
48    chunk_byte_offset + chunk.ceil_char_boundary(byte_offset - chunk_byte_offset)
49}
50
51#[must_use]
52pub fn is_grapheme_boundary(rope: &RopeSlice, byte_offset: usize) -> bool {
53    assert!(
54        byte_offset <= rope.len(),
55        "byte offset {byte_offset} is out of bounds (rope length is {})",
56        rope.len()
57    );
58    if !is_char_boundary(rope, byte_offset) {
59        return false;
60    }
61    let (chunk, chunk_byte_offset) = rope.chunk(byte_offset);
62    let mut cursor = GraphemeCursor::new(byte_offset, rope.len(), true);
63    loop {
64        match cursor.is_boundary(chunk, chunk_byte_offset) {
65            Ok(is_boundary) => return is_boundary,
66            Err(GraphemeIncomplete::PreContext(byte_offset)) => {
67                let (prev_chunk, prev_chunk_byte_offset) = rope.chunk(byte_offset - 1);
68                cursor.provide_context(prev_chunk, prev_chunk_byte_offset);
69            }
70            _ => unreachable!(),
71        }
72    }
73}
74
75#[must_use]
76pub fn is_grapheme_start(rope: &RopeSlice, byte_index: usize) -> bool {
77    assert!(
78        byte_index <= rope.len(),
79        "byte index {byte_index} is out of bounds (rope length is {})",
80        rope.len()
81    );
82    if byte_index == rope.len() {
83        return false;
84    }
85    is_grapheme_boundary(rope, byte_index)
86}
87
88#[must_use]
89pub fn prev_grapheme_boundary(rope: &RopeSlice, byte_offset: usize) -> Option<usize> {
90    assert!(
91        byte_offset <= rope.len(),
92        "byte offset {byte_offset} is out of bounds (rope length is {})",
93        rope.len()
94    );
95    if byte_offset == 0 {
96        return None;
97    }
98    // `GraphemeCursor` requires a `char` boundary to start from
99    let start = floor_char_boundary(rope, byte_offset);
100    if start < byte_offset && is_grapheme_boundary(rope, start) {
101        return Some(start);
102    }
103    let (mut chunk, mut chunk_byte_offset) = rope.chunk(start);
104    let mut cursor = GraphemeCursor::new(start, rope.len(), true);
105    loop {
106        match cursor.prev_boundary(chunk, chunk_byte_offset) {
107            Ok(boundary) => return boundary,
108            Err(GraphemeIncomplete::PrevChunk) => {
109                (chunk, chunk_byte_offset) = rope.chunk(chunk_byte_offset - 1);
110            }
111            Err(GraphemeIncomplete::PreContext(byte_offset)) => {
112                let (prev_chunk, prev_chunk_byte_offset) = rope.chunk(byte_offset - 1);
113                cursor.provide_context(prev_chunk, prev_chunk_byte_offset);
114            }
115            _ => unreachable!(),
116        }
117    }
118}
119
120#[must_use]
121pub fn next_grapheme_boundary(rope: &RopeSlice, byte_offset: usize) -> Option<usize> {
122    assert!(
123        byte_offset <= rope.len(),
124        "byte offset {byte_offset} is out of bounds (rope length is {})",
125        rope.len()
126    );
127    if byte_offset == rope.len() {
128        return None;
129    }
130    // `GraphemeCursor` requires a `char` boundary to start from
131    let start = ceil_char_boundary(rope, byte_offset);
132    if start > byte_offset && is_grapheme_boundary(rope, start) {
133        return Some(start);
134    }
135    let (mut chunk, mut chunk_byte_offset) = rope.chunk(start);
136    let mut cursor = GraphemeCursor::new(start, rope.len(), true);
137    loop {
138        match cursor.next_boundary(chunk, chunk_byte_offset) {
139            Ok(boundary) => return boundary,
140            Err(GraphemeIncomplete::NextChunk) => {
141                (chunk, chunk_byte_offset) = rope.chunk(chunk_byte_offset + chunk.len());
142            }
143            Err(GraphemeIncomplete::PreContext(byte_offset)) => {
144                let (prev_chunk, prev_chunk_byte_offset) = rope.chunk(byte_offset - 1);
145                cursor.provide_context(prev_chunk, prev_chunk_byte_offset);
146            }
147            _ => unreachable!(),
148        }
149    }
150}
151
152#[must_use]
153pub fn floor_grapheme_boundary(rope: &RopeSlice, byte_offset: usize) -> usize {
154    assert!(
155        byte_offset <= rope.len(),
156        "byte offset {byte_offset} is out of bounds (rope length is {})",
157        rope.len()
158    );
159    let byte_offset = floor_char_boundary(rope, byte_offset);
160    if is_grapheme_boundary(rope, byte_offset) {
161        return byte_offset;
162    }
163    prev_grapheme_boundary(rope, byte_offset)
164        .expect("only fails at start of text, which is a grapheme boundary")
165}
166
167#[must_use]
168pub fn ceil_grapheme_boundary(rope: &RopeSlice, byte_offset: usize) -> usize {
169    assert!(
170        byte_offset <= rope.len(),
171        "byte offset {byte_offset} is out of bounds (rope length is {})",
172        rope.len()
173    );
174    let byte_offset = ceil_char_boundary(rope, byte_offset);
175    if is_grapheme_boundary(rope, byte_offset) {
176        return byte_offset;
177    }
178    next_grapheme_boundary(rope, byte_offset)
179        .expect("only fails at end of text, which is a grapheme boundary")
180}
181
182#[derive(Clone, Debug)]
183pub struct Graphemes<'a> {
184    rope: RopeSlice<'a>,
185    chunks: Chunks<'a>,
186    chunk: &'a str,
187    chunk_byte_offset: usize,
188    cursor: GraphemeCursor,
189}
190
191impl<'a> Graphemes<'a> {
192    #[must_use]
193    pub fn new(rope: &RopeSlice<'a>) -> Self {
194        let mut chunks = rope.chunks();
195        let chunk = chunks.next().unwrap_or("");
196        Graphemes {
197            rope: *rope,
198            chunks,
199            chunk,
200            chunk_byte_offset: 0,
201            cursor: GraphemeCursor::new(0, rope.len(), true),
202        }
203    }
204}
205
206impl<'a> Iterator for Graphemes<'a> {
207    type Item = RopeSlice<'a>;
208
209    fn next(&mut self) -> Option<RopeSlice<'a>> {
210        let start = self.cursor.cur_cursor();
211        let end;
212        loop {
213            match self
214                .cursor
215                .next_boundary(self.chunk, self.chunk_byte_offset)
216            {
217                Ok(None) => return None,
218                Ok(Some(boundary)) => {
219                    end = boundary;
220                    break;
221                }
222                Err(GraphemeIncomplete::NextChunk) => {
223                    self.chunk_byte_offset += self.chunk.len();
224                    self.chunk = self
225                        .chunks
226                        .next()
227                        .expect("only requested when the cursor is not at the end of the rope");
228
229                    // Note [Grapheme cursor bug workaround]
230                    // BEGIN WORKAROUND
231                    let crossing = self.cursor.cur_cursor();
232                    self.cursor = GraphemeCursor::new(crossing, self.rope.len(), true);
233                    if crossing > start && is_grapheme_boundary(&self.rope, crossing) {
234                        end = crossing;
235                        break;
236                    }
237                    // END WORKAROUND
238                }
239                Err(GraphemeIncomplete::PreContext(byte_offset)) => {
240                    let (prev_chunk, prev_chunk_byte_offset) = self.rope.chunk(byte_offset - 1);
241                    self.cursor
242                        .provide_context(prev_chunk, prev_chunk_byte_offset);
243                }
244                _ => unreachable!(),
245            }
246        }
247        if start < self.chunk_byte_offset {
248            // Grapheme spans multiple chunks, so requires going through the rope
249            Some(self.rope.slice(start..end))
250        } else {
251            // Grapheme fits within the current chunk, so requires no copying
252            let start_in_chunk = start - self.chunk_byte_offset;
253            let end_in_chunk = end - self.chunk_byte_offset;
254            Some((&self.chunk[start_in_chunk..end_in_chunk]).into())
255        }
256    }
257
258    fn size_hint(&self) -> (usize, Option<usize>) {
259        // Graphemes are at least one byte long
260        let remaining_bytes = self.rope.len() - self.cursor.cur_cursor();
261        (usize::from(remaining_bytes > 0), Some(remaining_bytes))
262    }
263}
264
265impl FusedIterator for Graphemes<'_> {}
266
267/*
268Note [Grapheme cursor bug workaround]
269--------------------------------------
270
271Claude: `unicode-segmentation` 1.13.3's `GraphemeCursor` produces false grapheme boundaries in
272flag-emoji (regional indicator) runs when a long-lived cursor crosses a chunk boundary: at the
273hand-off, `handle_regional`'s backward pre-context scan is seeded with the forward-accumulated
274`ris_count`, counting the same indicators twice and flipping the pairing parity.
275
276The workaround decides each chunk crossing with a fresh cursor (via `is_grapheme_boundary`) and
277rebuilds the iterator's cursor there, so no stale state survives the hand-off.
278*/
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use hegel::{TestCase, generators as gs};
284    use ropey::Rope;
285    use unicode_segmentation::UnicodeSegmentation as _;
286
287    // Note [Grapheme cursor bug workaround]
288    #[test]
289    fn test_flag_run_across_chunks() {
290        let text = "\u{1f1e6}\u{1f1e7}".repeat(1024 / 8 + 1); // should be more than 1 chunk
291        let rope = Rope::from_str(&text);
292        for start in [0, 4] {
293            let expected: Vec<&str> = text[start..].graphemes(true).collect();
294            let actual: Vec<String> = Graphemes::new(&rope.slice(start..))
295                .map(|grapheme| grapheme.to_string())
296                .collect();
297            assert_eq!(actual, expected, "slice starting at {start}");
298        }
299    }
300
301    #[hegel::test(test_cases = 10_000)]
302    fn test_is_char_boundary(tc: TestCase) {
303        let string = tc.draw(gs::text());
304        let input = tc.draw(gs::integers().max_value(string.len()));
305        let ropey = {
306            let rope = ropey::Rope::from(string.as_str());
307            is_char_boundary(&rope.slice(..), input)
308        };
309        let crop = {
310            let rope = crop::Rope::from(string.as_str());
311            rope.is_char_boundary(input)
312        };
313        assert_eq!(ropey, crop);
314    }
315
316    #[hegel::test(test_cases = 10_000)]
317    fn test_is_grapheme_boundary(tc: TestCase) {
318        let string = tc.draw(gs::text());
319        let input = tc.draw(gs::integers().max_value(string.len()));
320        let ropey = {
321            let rope = ropey::Rope::from(string.as_str());
322            is_grapheme_boundary(&rope.slice(..), input)
323        };
324        let crop = {
325            let rope = crop::Rope::from(string.as_str());
326            rope.is_grapheme_boundary(input)
327        };
328        assert_eq!(ropey, crop);
329    }
330
331    #[hegel::test(test_cases = 10_000)]
332    fn test_graphemes(tc: TestCase) {
333        let string = tc.draw(gs::text());
334        let ropey = {
335            let rope = ropey::Rope::from(string.as_str());
336            Graphemes::new(&rope.slice(..))
337                .map(|slice| slice.to_string())
338                .collect::<Vec<_>>()
339        };
340        let str = string
341            .graphemes(true)
342            .map(str::to_string)
343            .collect::<Vec<_>>();
344        assert_eq!(ropey, str);
345    }
346}