Skip to main content

ftui_text/
cluster_map.rs

1#![forbid(unsafe_code)]
2
3//! Bidirectional cluster↔cell mapping for shaped text.
4//!
5//! This module defines the [`ClusterMap`] — a precomputed index that maps
6//! between source byte offsets, grapheme indices, and visual cell columns
7//! in both directions. It enables correct cursor movement, selection,
8//! copy extraction, and search highlighting over shaped text.
9//!
10//! # Invariants
11//!
12//! The cluster map guarantees:
13//!
14//! 1. **Round-trip preservation**: `byte → cell → byte` returns the original
15//!    cluster start (never a mid-cluster position).
16//! 2. **Monotonicity**: visual cell offsets increase with byte offsets.
17//! 3. **Boundary alignment**: lookups always snap to grapheme cluster
18//!    boundaries — never splitting a grapheme or shaped glyph cluster.
19//! 4. **Continuation cell handling**: wide characters that span 2+ cells
20//!    map back to the same source byte offset.
21//! 5. **Completeness**: every source byte offset and every visual cell
22//!    column has a defined mapping.
23//!
24//! # Example
25//!
26//! ```
27//! use ftui_text::cluster_map::ClusterMap;
28//!
29//! // Build a cluster map from plain text
30//! let map = ClusterMap::from_text("Hello 世界!");
31//!
32//! // Forward: byte offset → visual cell column
33//! assert_eq!(map.byte_to_cell(0), 0);  // 'H' at cell 0
34//! assert_eq!(map.byte_to_cell(6), 6);  // '世' at cell 6
35//! assert_eq!(map.byte_to_cell(9), 8);  // '界' at cell 8
36//!
37//! // Reverse: visual cell column → byte offset
38//! assert_eq!(map.cell_to_byte(0), 0);  // cell 0 → 'H'
39//! assert_eq!(map.cell_to_byte(6), 6);  // cell 6 → '世'
40//! assert_eq!(map.cell_to_byte(7), 6);  // cell 7 → '世' (continuation)
41//!
42//! // Selection: cell range → byte range
43//! let (start, end) = map.cell_range_to_byte_range(6, 10);
44//! assert_eq!(start, 6);   // '世'
45//! assert_eq!(end, 12);    // end of '界'
46//! ```
47
48use crate::shaping::ShapedRun;
49use unicode_segmentation::UnicodeSegmentation;
50
51// ---------------------------------------------------------------------------
52// ClusterEntry — per-grapheme-cluster record
53// ---------------------------------------------------------------------------
54
55/// A single entry in the cluster map, representing one grapheme cluster.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ClusterEntry {
58    /// Start byte offset in the source string (inclusive).
59    pub byte_start: u32,
60    /// End byte offset in the source string (exclusive).
61    pub byte_end: u32,
62    /// Grapheme index (0-based position among graphemes).
63    pub grapheme_index: u32,
64    /// Start visual cell column (inclusive).
65    pub cell_start: u32,
66    /// Display width in cells (1 for normal, 2 for wide CJK/emoji).
67    pub cell_width: u8,
68}
69
70impl ClusterEntry {
71    /// The byte range of this cluster.
72    #[inline]
73    pub fn byte_range(&self) -> std::ops::Range<usize> {
74        self.byte_start as usize..self.byte_end as usize
75    }
76
77    /// The cell range of this cluster (start..start+width).
78    #[inline]
79    pub fn cell_range(&self) -> std::ops::Range<usize> {
80        self.cell_start as usize..(self.cell_start as usize + self.cell_width as usize)
81    }
82
83    /// End cell column (exclusive).
84    #[inline]
85    pub fn cell_end(&self) -> u32 {
86        self.cell_start + self.cell_width as u32
87    }
88}
89
90// ---------------------------------------------------------------------------
91// ClusterMap
92// ---------------------------------------------------------------------------
93
94/// Bidirectional mapping between source byte offsets and visual cell columns.
95///
96/// Built from text (optionally with shaped glyph data) and provides O(log n)
97/// lookups in both directions via binary search over the sorted cluster array.
98///
99/// # Construction
100///
101/// - [`from_text`](Self::from_text) — build from plain text (uses grapheme
102///   cluster widths, suitable for terminal/monospace rendering).
103/// - [`from_shaped_run`](Self::from_shaped_run) — build from a shaped glyph
104///   run (uses glyph cluster byte offsets and advances, suitable for
105///   proportional/shaped rendering).
106#[derive(Debug, Clone)]
107pub struct ClusterMap {
108    /// Sorted by byte_start (and equivalently by cell_start due to monotonicity).
109    entries: Vec<ClusterEntry>,
110    /// Total visual width in cells.
111    total_cells: u32,
112    /// Total byte length of the source text.
113    total_bytes: u32,
114}
115
116impl ClusterMap {
117    /// Build a cluster map from plain text using grapheme cluster boundaries.
118    ///
119    /// Each grapheme cluster maps to 1 or 2 cells based on `display_width`.
120    /// This is the appropriate constructor for terminal/monospace rendering.
121    pub fn from_text(text: &str) -> Self {
122        if text.is_empty() {
123            return Self {
124                entries: Vec::new(),
125                total_cells: 0,
126                total_bytes: 0,
127            };
128        }
129
130        let mut entries = Vec::with_capacity(text.len());
131        let mut cell_offset = 0u32;
132
133        for (grapheme_idx, (byte_offset, grapheme)) in text.grapheme_indices(true).enumerate() {
134            let width = crate::grapheme_width(grapheme) as u8;
135            let byte_end = byte_offset + grapheme.len();
136
137            entries.push(ClusterEntry {
138                byte_start: byte_offset as u32,
139                byte_end: byte_end as u32,
140                grapheme_index: grapheme_idx as u32,
141                cell_start: cell_offset,
142                cell_width: width,
143            });
144
145            cell_offset += width as u32;
146        }
147
148        Self {
149            entries,
150            total_cells: cell_offset,
151            total_bytes: text.len() as u32,
152        }
153    }
154
155    /// Build a cluster map from a shaped glyph run.
156    ///
157    /// Uses glyph cluster byte offsets from the `ShapedRun` to determine
158    /// cluster boundaries, with advances determining cell widths.
159    ///
160    /// For terminal rendering (NoopShaper), each glyph maps to one grapheme
161    /// cluster. For proportional rendering, multiple glyphs may share a
162    /// cluster (ligatures) or one glyph may span multiple characters.
163    pub fn from_shaped_run(text: &str, run: &ShapedRun) -> Self {
164        if text.is_empty() || run.is_empty() {
165            return Self {
166                entries: Vec::new(),
167                total_cells: 0,
168                total_bytes: 0,
169            };
170        }
171
172        // Group glyphs by cluster (byte offset).
173        // Shaped glyphs share a `cluster` value when they form a ligature
174        // or complex glyph group.
175        let mut entries = Vec::new();
176        let mut cell_offset = 0u32;
177        let mut grapheme_idx = 0u32;
178
179        let mut i = 0;
180        while i < run.glyphs.len() {
181            let cluster_byte = run.glyphs[i].cluster as usize;
182            let mut cluster_advance = 0i32;
183
184            // Accumulate all glyphs sharing this cluster.
185            let mut j = i;
186            while j < run.glyphs.len() && run.glyphs[j].cluster as usize == cluster_byte {
187                cluster_advance += run.glyphs[j].x_advance;
188                j += 1;
189            }
190
191            // Find the next cluster's byte offset to determine this cluster's byte range.
192            let next_byte = if j < run.glyphs.len() {
193                run.glyphs[j].cluster as usize
194            } else {
195                text.len()
196            };
197
198            // Use advance as cell width (for terminal, this is already in cells).
199            let width = cluster_advance.unsigned_abs().min(255) as u8;
200
201            entries.push(ClusterEntry {
202                byte_start: cluster_byte as u32,
203                byte_end: next_byte as u32,
204                grapheme_index: grapheme_idx,
205                cell_start: cell_offset,
206                cell_width: width,
207            });
208
209            cell_offset += width as u32;
210            grapheme_idx += 1;
211            i = j;
212        }
213
214        Self {
215            entries,
216            total_cells: cell_offset,
217            total_bytes: text.len() as u32,
218        }
219    }
220
221    // -----------------------------------------------------------------------
222    // Forward lookups (byte → cell)
223    // -----------------------------------------------------------------------
224
225    /// Map a byte offset to its visual cell column.
226    ///
227    /// If the byte offset falls mid-cluster, it snaps to the cluster's
228    /// start cell. Returns `total_cells` for offsets at or past the end.
229    pub fn byte_to_cell(&self, byte_offset: usize) -> usize {
230        if self.entries.is_empty() || byte_offset >= self.total_bytes as usize {
231            return self.total_cells as usize;
232        }
233
234        match self
235            .entries
236            .binary_search_by_key(&(byte_offset as u32), |e| e.byte_start)
237        {
238            Ok(idx) => self.entries[idx].cell_start as usize,
239            Err(idx) => {
240                // byte_offset is mid-cluster — snap to containing cluster.
241                if idx > 0 {
242                    self.entries[idx - 1].cell_start as usize
243                } else {
244                    0
245                }
246            }
247        }
248    }
249
250    /// Map a byte offset to the containing `ClusterEntry`.
251    ///
252    /// Returns `None` for empty maps or offsets past the end.
253    pub fn byte_to_entry(&self, byte_offset: usize) -> Option<&ClusterEntry> {
254        if self.entries.is_empty() {
255            return None;
256        }
257
258        match self
259            .entries
260            .binary_search_by_key(&(byte_offset as u32), |e| e.byte_start)
261        {
262            Ok(idx) => Some(&self.entries[idx]),
263            Err(idx) => {
264                if idx > 0 && (byte_offset as u32) < self.entries[idx - 1].byte_end {
265                    Some(&self.entries[idx - 1])
266                } else {
267                    None
268                }
269            }
270        }
271    }
272
273    /// Map a byte range to a visual cell range.
274    ///
275    /// Returns `(cell_start, cell_end)` covering all clusters that overlap
276    /// the given byte range.
277    pub fn byte_range_to_cell_range(&self, byte_start: usize, byte_end: usize) -> (usize, usize) {
278        if self.entries.is_empty() || byte_start >= byte_end {
279            return (0, 0);
280        }
281
282        let start_cell = self.byte_to_cell(byte_start);
283
284        // Find the cell_end for the cluster containing byte_end - 1.
285        let end_cell = if byte_end >= self.total_bytes as usize {
286            self.total_cells as usize
287        } else {
288            match self
289                .entries
290                .binary_search_by_key(&(byte_end as u32), |e| e.byte_start)
291            {
292                Ok(idx) => self.entries[idx].cell_start as usize,
293                Err(idx) => {
294                    if idx > 0 {
295                        self.entries[idx - 1].cell_end() as usize
296                    } else {
297                        0
298                    }
299                }
300            }
301        };
302
303        (start_cell, end_cell)
304    }
305
306    // -----------------------------------------------------------------------
307    // Reverse lookups (cell → byte)
308    // -----------------------------------------------------------------------
309
310    /// Map a visual cell column to a source byte offset.
311    ///
312    /// Continuation cells (cells within a wide character) map back to the
313    /// cluster's start byte. Returns `total_bytes` for cells at or past
314    /// the total width.
315    pub fn cell_to_byte(&self, cell_col: usize) -> usize {
316        if self.entries.is_empty() || cell_col >= self.total_cells as usize {
317            return self.total_bytes as usize;
318        }
319
320        // Lower bound: first entry whose `cell_start >= cell_col`. Zero-width
321        // clusters (combining marks, ZWSP/ZWJ) share a `cell_start` with the
322        // following cluster, so `cell_start` is non-decreasing but NOT unique.
323        // `binary_search_by_key` would return an arbitrary match among the
324        // duplicates; we must resolve to the FIRST cluster owning the column so
325        // that `byte → cell → byte` snaps to the cluster start (invariant #1).
326        let idx = self
327            .entries
328            .partition_point(|e| (e.cell_start as usize) < cell_col);
329        if idx < self.entries.len() && self.entries[idx].cell_start as usize == cell_col {
330            self.entries[idx].byte_start as usize
331        } else {
332            // cell_col is a continuation cell — snap to the containing cluster.
333            // `idx > 0` holds because `entries[0].cell_start == 0 <= cell_col`.
334            self.entries[idx - 1].byte_start as usize
335        }
336    }
337
338    /// Map a visual cell column to the containing `ClusterEntry`.
339    ///
340    /// Returns `None` for empty maps or cells past the total width.
341    pub fn cell_to_entry(&self, cell_col: usize) -> Option<&ClusterEntry> {
342        if self.entries.is_empty() || cell_col >= self.total_cells as usize {
343            return None;
344        }
345
346        // Lower bound over the (non-unique, due to zero-width clusters)
347        // `cell_start` key — resolve to the FIRST cluster owning the column.
348        // See `cell_to_byte` for why `binary_search_by_key` is unsafe here.
349        let idx = self
350            .entries
351            .partition_point(|e| (e.cell_start as usize) < cell_col);
352        if idx < self.entries.len() && self.entries[idx].cell_start as usize == cell_col {
353            Some(&self.entries[idx])
354        } else {
355            // Continuation cell — snap to the containing (previous) cluster if
356            // the column falls within its width. `idx > 0` because
357            // `entries[0].cell_start == 0 <= cell_col`.
358            let entry = &self.entries[idx - 1];
359            if (cell_col as u32) < entry.cell_end() {
360                Some(entry)
361            } else {
362                None
363            }
364        }
365    }
366
367    /// Map a visual cell range to a source byte range.
368    ///
369    /// Returns `(byte_start, byte_end)` covering all clusters that overlap
370    /// the given cell range. Continuation cells are resolved to their
371    /// owning cluster.
372    pub fn cell_range_to_byte_range(&self, cell_start: usize, cell_end: usize) -> (usize, usize) {
373        if self.entries.is_empty() || cell_start >= cell_end {
374            return (0, 0);
375        }
376
377        let start_byte = self.cell_to_byte(cell_start);
378
379        let end_byte = if cell_end >= self.total_cells as usize {
380            self.total_bytes as usize
381        } else {
382            // Find the cluster containing the last included cell and use its
383            // byte_end as the exclusive bound. This ensures wide characters
384            // partially covered by the cell range are fully included.
385            match self.cell_to_entry(cell_end.saturating_sub(1)) {
386                Some(entry) => entry.byte_end as usize,
387                None => self.total_bytes as usize,
388            }
389        };
390
391        (start_byte, end_byte.max(start_byte))
392    }
393
394    // -----------------------------------------------------------------------
395    // Grapheme-level accessors
396    // -----------------------------------------------------------------------
397
398    /// Map a grapheme index to a visual cell column.
399    pub fn grapheme_to_cell(&self, grapheme_index: usize) -> usize {
400        self.entries
401            .get(grapheme_index)
402            .map_or(self.total_cells as usize, |e| e.cell_start as usize)
403    }
404
405    /// Map a visual cell column to a grapheme index.
406    pub fn cell_to_grapheme(&self, cell_col: usize) -> usize {
407        self.cell_to_entry(cell_col)
408            .map_or(self.entries.len(), |e| e.grapheme_index as usize)
409    }
410
411    /// Map a grapheme index to a byte offset.
412    pub fn grapheme_to_byte(&self, grapheme_index: usize) -> usize {
413        self.entries
414            .get(grapheme_index)
415            .map_or(self.total_bytes as usize, |e| e.byte_start as usize)
416    }
417
418    /// Map a byte offset to a grapheme index.
419    pub fn byte_to_grapheme(&self, byte_offset: usize) -> usize {
420        self.byte_to_entry(byte_offset)
421            .map_or(self.entries.len(), |e| e.grapheme_index as usize)
422    }
423
424    // -----------------------------------------------------------------------
425    // Aggregate accessors
426    // -----------------------------------------------------------------------
427
428    /// Total visual width in cells.
429    #[inline]
430    pub fn total_cells(&self) -> usize {
431        self.total_cells as usize
432    }
433
434    /// Total byte length of the source text.
435    #[inline]
436    pub fn total_bytes(&self) -> usize {
437        self.total_bytes as usize
438    }
439
440    /// Number of grapheme clusters.
441    #[inline]
442    pub fn cluster_count(&self) -> usize {
443        self.entries.len()
444    }
445
446    /// Whether the map is empty.
447    #[inline]
448    pub fn is_empty(&self) -> bool {
449        self.entries.is_empty()
450    }
451
452    /// Iterate over all cluster entries.
453    #[inline]
454    pub fn entries(&self) -> &[ClusterEntry] {
455        &self.entries
456    }
457
458    /// Get the cluster entry at a grapheme index.
459    #[inline]
460    pub fn get(&self, grapheme_index: usize) -> Option<&ClusterEntry> {
461        self.entries.get(grapheme_index)
462    }
463
464    /// Extract text from the source string for a cell range.
465    ///
466    /// Returns the substring covering all clusters that overlap the
467    /// given visual cell range.
468    pub fn extract_text_for_cells<'a>(
469        &self,
470        source: &'a str,
471        cell_start: usize,
472        cell_end: usize,
473    ) -> &'a str {
474        let (byte_start, byte_end) = self.cell_range_to_byte_range(cell_start, cell_end);
475        if byte_start >= source.len() {
476            return "";
477        }
478        let end = byte_end.min(source.len());
479        &source[byte_start..end]
480    }
481}
482
483// ===========================================================================
484// Tests
485// ===========================================================================
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    // -----------------------------------------------------------------------
492    // Construction tests
493    // -----------------------------------------------------------------------
494
495    #[test]
496    fn empty_text() {
497        let map = ClusterMap::from_text("");
498        assert!(map.is_empty());
499        assert_eq!(map.total_cells(), 0);
500        assert_eq!(map.total_bytes(), 0);
501        assert_eq!(map.cluster_count(), 0);
502    }
503
504    #[test]
505    fn ascii_text() {
506        let map = ClusterMap::from_text("Hello");
507        assert_eq!(map.cluster_count(), 5);
508        assert_eq!(map.total_cells(), 5);
509        assert_eq!(map.total_bytes(), 5);
510
511        // Each ASCII char is 1 byte, 1 cell.
512        for i in 0..5 {
513            let e = map.get(i).unwrap();
514            assert_eq!(e.byte_start, i as u32);
515            assert_eq!(e.byte_end, (i + 1) as u32);
516            assert_eq!(e.cell_start, i as u32);
517            assert_eq!(e.cell_width, 1);
518        }
519    }
520
521    #[test]
522    fn wide_chars() {
523        // "世界" — 2 CJK chars, each 3 bytes and 2 cells wide
524        let text = "\u{4E16}\u{754C}";
525        let map = ClusterMap::from_text(text);
526
527        assert_eq!(map.cluster_count(), 2);
528        assert_eq!(map.total_bytes(), 6);
529        assert_eq!(map.total_cells(), 4);
530
531        let e0 = map.get(0).unwrap();
532        assert_eq!(e0.byte_start, 0);
533        assert_eq!(e0.byte_end, 3);
534        assert_eq!(e0.cell_start, 0);
535        assert_eq!(e0.cell_width, 2);
536
537        let e1 = map.get(1).unwrap();
538        assert_eq!(e1.byte_start, 3);
539        assert_eq!(e1.byte_end, 6);
540        assert_eq!(e1.cell_start, 2);
541        assert_eq!(e1.cell_width, 2);
542    }
543
544    #[test]
545    fn mixed_ascii_and_wide() {
546        // "Hi世界!" — 2 ASCII + 2 CJK + 1 ASCII
547        let text = "Hi\u{4E16}\u{754C}!";
548        let map = ClusterMap::from_text(text);
549
550        assert_eq!(map.cluster_count(), 5);
551        assert_eq!(map.total_bytes(), 9); // 2 + 3 + 3 + 1
552        assert_eq!(map.total_cells(), 7); // 1+1+2+2+1
553
554        // Verify cell starts.
555        assert_eq!(map.get(0).unwrap().cell_start, 0); // 'H'
556        assert_eq!(map.get(1).unwrap().cell_start, 1); // 'i'
557        assert_eq!(map.get(2).unwrap().cell_start, 2); // '世'
558        assert_eq!(map.get(3).unwrap().cell_start, 4); // '界'
559        assert_eq!(map.get(4).unwrap().cell_start, 6); // '!'
560    }
561
562    #[test]
563    fn zero_width_clusters_round_trip_to_first_owner() {
564        // ZWSP (U+200B) is a zero-width grapheme cluster on its own. Two of them
565        // between real chars create *duplicate* `cell_start` values, which used
566        // to make the reverse lookups land on an arbitrary (often the LAST)
567        // cluster sharing a column via `binary_search_by_key`. Regression for
568        // the `fuzz_text_cluster_map` panic: byte -> cell -> byte must snap to
569        // the FIRST cluster owning the column (invariant #1), so the round-trip
570        // never overshoots (`back <= byte_start`).
571        let text = "a\u{200B}\u{200B}b";
572        let map = ClusterMap::from_text(text);
573
574        // The input must actually exercise zero-width / duplicate columns, else
575        // this regression guard would be vacuous.
576        assert!(
577            map.entries().iter().any(|e| e.cell_width == 0),
578            "expected at least one zero-width cluster"
579        );
580        let cell_starts: Vec<u32> = map.entries().iter().map(|e| e.cell_start).collect();
581        assert!(
582            cell_starts.windows(2).any(|w| w[0] == w[1]),
583            "expected duplicate cell_start columns"
584        );
585
586        // Round-trip invariant — exactly what the fuzz target asserts.
587        for e in map.entries() {
588            let cell = map.byte_to_cell(e.byte_start as usize);
589            let back = map.cell_to_byte(cell);
590            assert!(
591                back <= e.byte_start as usize,
592                "round-trip overshoot: byte {} -> cell {} -> byte {}",
593                e.byte_start,
594                cell,
595                back
596            );
597        }
598
599        // The shared column resolves to the FIRST (lowest-byte) owning cluster,
600        // and `cell_to_entry` agrees with `cell_to_byte`.
601        let first_at_col1 = map
602            .entries()
603            .iter()
604            .find(|e| e.cell_start == 1)
605            .expect("a cluster at cell column 1");
606        assert_eq!(map.cell_to_byte(1), first_at_col1.byte_start as usize);
607        assert_eq!(
608            map.cell_to_entry(1).map(|e| e.byte_start),
609            Some(first_at_col1.byte_start)
610        );
611    }
612
613    #[test]
614    fn fuzz_crash_control_chars_round_trip() {
615        // The exact minimized input that crashed `fuzz_text_cluster_map` in CI:
616        // bytes 0x32 0x03 0x0a = '2' + U+0003 (ETX, zero-width) + '\n' (width 1).
617        // The two clusters at byte 1 and byte 2 share cell column 1, so the old
618        // `binary_search_by_key` resolved cell 1 to the LAST cluster (byte 2),
619        // making `cell_to_byte(byte_to_cell(1)) == 2 > 1` and tripping the
620        // round-trip assertion. The lower-bound fix resolves cell 1 to byte 1.
621        let text = "2\u{03}\n";
622        let map = ClusterMap::from_text(text);
623
624        for e in map.entries() {
625            let cell = map.byte_to_cell(e.byte_start as usize);
626            let back = map.cell_to_byte(cell);
627            assert!(
628                back <= e.byte_start as usize,
629                "round-trip overshoot: byte {} -> cell {} -> byte {}",
630                e.byte_start,
631                cell,
632                back
633            );
634        }
635
636        // Cell column 1 is owned by the first (lowest-byte) cluster there.
637        assert_eq!(map.cell_to_byte(1), 1);
638    }
639
640    #[test]
641    fn combining_marks() {
642        // "é" as e + combining acute: single grapheme, 2 bytes, 1 cell
643        let text = "e\u{0301}";
644        let map = ClusterMap::from_text(text);
645
646        assert_eq!(map.cluster_count(), 1);
647        assert_eq!(map.total_bytes(), 3); // 'e' (1) + U+0301 (2)
648        assert_eq!(map.total_cells(), 1);
649
650        let e = map.get(0).unwrap();
651        assert_eq!(e.byte_start, 0);
652        assert_eq!(e.byte_end, 3);
653        assert_eq!(e.cell_width, 1);
654    }
655
656    // -----------------------------------------------------------------------
657    // Forward lookup tests (byte → cell)
658    // -----------------------------------------------------------------------
659
660    #[test]
661    fn byte_to_cell_ascii() {
662        let map = ClusterMap::from_text("Hello");
663        for i in 0..5 {
664            assert_eq!(map.byte_to_cell(i), i);
665        }
666        assert_eq!(map.byte_to_cell(5), 5); // past end
667    }
668
669    #[test]
670    fn byte_to_cell_wide() {
671        let text = "Hi\u{4E16}\u{754C}!";
672        let map = ClusterMap::from_text(text);
673
674        assert_eq!(map.byte_to_cell(0), 0); // 'H'
675        assert_eq!(map.byte_to_cell(1), 1); // 'i'
676        assert_eq!(map.byte_to_cell(2), 2); // '世' start
677        assert_eq!(map.byte_to_cell(5), 4); // '界' start
678        assert_eq!(map.byte_to_cell(8), 6); // '!'
679    }
680
681    #[test]
682    fn byte_to_cell_mid_cluster_snaps() {
683        let text = "\u{4E16}"; // '世' is 3 bytes
684        let map = ClusterMap::from_text(text);
685
686        // Mid-byte offsets snap to cluster start.
687        assert_eq!(map.byte_to_cell(0), 0);
688        assert_eq!(map.byte_to_cell(1), 0); // mid-cluster → cluster start
689        assert_eq!(map.byte_to_cell(2), 0); // mid-cluster → cluster start
690    }
691
692    #[test]
693    fn byte_to_entry() {
694        let text = "AB\u{4E16}C";
695        let map = ClusterMap::from_text(text);
696
697        let e = map.byte_to_entry(0).unwrap();
698        assert_eq!(e.byte_start, 0); // 'A'
699
700        let e = map.byte_to_entry(2).unwrap();
701        assert_eq!(e.byte_start, 2); // '世'
702
703        // Mid-cluster lookup.
704        let e = map.byte_to_entry(3).unwrap();
705        assert_eq!(e.byte_start, 2); // still '世'
706
707        assert!(map.byte_to_entry(100).is_none());
708    }
709
710    // -----------------------------------------------------------------------
711    // Reverse lookup tests (cell → byte)
712    // -----------------------------------------------------------------------
713
714    #[test]
715    fn cell_to_byte_ascii() {
716        let map = ClusterMap::from_text("Hello");
717        for i in 0..5 {
718            assert_eq!(map.cell_to_byte(i), i);
719        }
720        assert_eq!(map.cell_to_byte(5), 5); // past end
721    }
722
723    #[test]
724    fn cell_to_byte_wide() {
725        let text = "Hi\u{4E16}\u{754C}!";
726        let map = ClusterMap::from_text(text);
727
728        assert_eq!(map.cell_to_byte(0), 0); // 'H'
729        assert_eq!(map.cell_to_byte(1), 1); // 'i'
730        assert_eq!(map.cell_to_byte(2), 2); // '世'
731        assert_eq!(map.cell_to_byte(3), 2); // continuation → same '世'
732        assert_eq!(map.cell_to_byte(4), 5); // '界'
733        assert_eq!(map.cell_to_byte(5), 5); // continuation → same '界'
734        assert_eq!(map.cell_to_byte(6), 8); // '!'
735    }
736
737    #[test]
738    fn cell_to_entry_continuation() {
739        let text = "\u{4E16}"; // '世' — 2 cells
740        let map = ClusterMap::from_text(text);
741
742        // Both cells map to the same entry.
743        let e0 = map.cell_to_entry(0).unwrap();
744        let e1 = map.cell_to_entry(1).unwrap();
745        assert_eq!(e0, e1);
746        assert_eq!(e0.byte_start, 0);
747        assert_eq!(e0.cell_width, 2);
748    }
749
750    // -----------------------------------------------------------------------
751    // Range conversion tests
752    // -----------------------------------------------------------------------
753
754    #[test]
755    fn byte_range_to_cell_range_ascii() {
756        let map = ClusterMap::from_text("Hello World");
757        assert_eq!(map.byte_range_to_cell_range(0, 5), (0, 5)); // "Hello"
758        assert_eq!(map.byte_range_to_cell_range(6, 11), (6, 11)); // "World"
759    }
760
761    #[test]
762    fn byte_range_to_cell_range_wide() {
763        let text = "Hi\u{4E16}\u{754C}!"; // cells: H(0) i(1) 世(2,3) 界(4,5) !(6)
764        let map = ClusterMap::from_text(text);
765
766        // Byte range covering '世界' (bytes 2..8)
767        assert_eq!(map.byte_range_to_cell_range(2, 8), (2, 6));
768    }
769
770    #[test]
771    fn cell_range_to_byte_range_ascii() {
772        let map = ClusterMap::from_text("Hello World");
773        assert_eq!(map.cell_range_to_byte_range(0, 5), (0, 5));
774    }
775
776    #[test]
777    fn cell_range_to_byte_range_wide() {
778        let text = "Hi\u{4E16}\u{754C}!";
779        let map = ClusterMap::from_text(text);
780
781        // Cell range [2, 6) covers 世界
782        assert_eq!(map.cell_range_to_byte_range(2, 6), (2, 8));
783
784        // Cell range [3, 5) starts on continuation → snaps to 世, ends including 界
785        assert_eq!(map.cell_range_to_byte_range(3, 5), (2, 8));
786    }
787
788    // -----------------------------------------------------------------------
789    // Grapheme-level accessors
790    // -----------------------------------------------------------------------
791
792    #[test]
793    fn grapheme_to_cell_and_back() {
794        let text = "Hi\u{4E16}\u{754C}!";
795        let map = ClusterMap::from_text(text);
796
797        assert_eq!(map.grapheme_to_cell(0), 0); // 'H'
798        assert_eq!(map.grapheme_to_cell(2), 2); // '世'
799        assert_eq!(map.grapheme_to_cell(4), 6); // '!'
800        assert_eq!(map.grapheme_to_cell(5), 7); // past end
801
802        assert_eq!(map.cell_to_grapheme(0), 0); // 'H'
803        assert_eq!(map.cell_to_grapheme(2), 2); // '世'
804        assert_eq!(map.cell_to_grapheme(3), 2); // continuation → '世'
805    }
806
807    #[test]
808    fn grapheme_to_byte_and_back() {
809        let text = "A\u{4E16}B";
810        let map = ClusterMap::from_text(text);
811
812        assert_eq!(map.grapheme_to_byte(0), 0); // 'A'
813        assert_eq!(map.grapheme_to_byte(1), 1); // '世'
814        assert_eq!(map.grapheme_to_byte(2), 4); // 'B'
815
816        assert_eq!(map.byte_to_grapheme(0), 0); // 'A'
817        assert_eq!(map.byte_to_grapheme(1), 1); // '世'
818        assert_eq!(map.byte_to_grapheme(4), 2); // 'B'
819    }
820
821    // -----------------------------------------------------------------------
822    // Extract text
823    // -----------------------------------------------------------------------
824
825    #[test]
826    fn extract_text_for_cells_ascii() {
827        let text = "Hello World";
828        let map = ClusterMap::from_text(text);
829        assert_eq!(map.extract_text_for_cells(text, 0, 5), "Hello");
830        assert_eq!(map.extract_text_for_cells(text, 6, 11), "World");
831    }
832
833    #[test]
834    fn extract_text_for_cells_wide() {
835        let text = "Hi\u{4E16}\u{754C}!";
836        let map = ClusterMap::from_text(text);
837
838        // Extract just the CJK chars (cells 2..6).
839        assert_eq!(map.extract_text_for_cells(text, 2, 6), "\u{4E16}\u{754C}");
840
841        // Extract including continuation cell.
842        assert_eq!(map.extract_text_for_cells(text, 3, 5), "\u{4E16}\u{754C}");
843    }
844
845    #[test]
846    fn extract_text_empty_range() {
847        let text = "Hello";
848        let map = ClusterMap::from_text(text);
849        assert_eq!(map.extract_text_for_cells(text, 3, 3), "");
850    }
851
852    // -----------------------------------------------------------------------
853    // Invariant: round-trip
854    // -----------------------------------------------------------------------
855
856    #[test]
857    fn roundtrip_byte_cell_byte() {
858        let texts = [
859            "Hello",
860            "\u{4E16}\u{754C}",
861            "Hi\u{4E16}\u{754C}!",
862            "e\u{0301}f",
863            "\u{05E9}\u{05DC}\u{05D5}\u{05DD}",
864            "",
865        ];
866
867        for text in texts {
868            let map = ClusterMap::from_text(text);
869
870            for entry in map.entries() {
871                let byte = entry.byte_start as usize;
872                let cell = map.byte_to_cell(byte);
873                let back = map.cell_to_byte(cell);
874                assert_eq!(
875                    back, byte,
876                    "Round-trip failed for text={text:?} byte={byte}"
877                );
878            }
879        }
880    }
881
882    #[test]
883    fn roundtrip_cell_byte_cell() {
884        let texts = [
885            "Hello",
886            "\u{4E16}\u{754C}",
887            "Hi\u{4E16}\u{754C}!",
888            "e\u{0301}f",
889        ];
890
891        for text in texts {
892            let map = ClusterMap::from_text(text);
893
894            for entry in map.entries() {
895                let cell = entry.cell_start as usize;
896                let byte = map.cell_to_byte(cell);
897                let back = map.byte_to_cell(byte);
898                assert_eq!(
899                    back, cell,
900                    "Round-trip failed for text={text:?} cell={cell}"
901                );
902            }
903        }
904    }
905
906    // -----------------------------------------------------------------------
907    // Invariant: monotonicity
908    // -----------------------------------------------------------------------
909
910    #[test]
911    fn monotonicity() {
912        let texts = [
913            "Hello World",
914            "Hi\u{4E16}\u{754C}! \u{05E9}\u{05DC}\u{05D5}\u{05DD}",
915            "e\u{0301}\u{0302}",
916        ];
917
918        for text in texts {
919            let map = ClusterMap::from_text(text);
920
921            for window in map.entries().windows(2) {
922                assert!(
923                    window[0].byte_start < window[1].byte_start,
924                    "Byte monotonicity violated: {:?}",
925                    window
926                );
927                assert!(
928                    window[0].cell_start < window[1].cell_start,
929                    "Cell monotonicity violated: {:?}",
930                    window
931                );
932            }
933        }
934    }
935
936    // -----------------------------------------------------------------------
937    // Invariant: contiguity
938    // -----------------------------------------------------------------------
939
940    #[test]
941    fn contiguity() {
942        let text = "Hi\u{4E16}\u{754C}!";
943        let map = ClusterMap::from_text(text);
944
945        // Byte ranges are contiguous.
946        for window in map.entries().windows(2) {
947            assert_eq!(
948                window[0].byte_end, window[1].byte_start,
949                "Byte gap: {:?}",
950                window
951            );
952        }
953
954        // Cell ranges are contiguous.
955        for window in map.entries().windows(2) {
956            assert_eq!(
957                window[0].cell_end(),
958                window[1].cell_start,
959                "Cell gap: {:?}",
960                window
961            );
962        }
963
964        // First entry starts at 0.
965        assert_eq!(map.entries()[0].byte_start, 0);
966        assert_eq!(map.entries()[0].cell_start, 0);
967
968        // Last entry ends at total.
969        let last = map.entries().last().unwrap();
970        assert_eq!(last.byte_end, map.total_bytes() as u32);
971        assert_eq!(last.cell_end(), map.total_cells() as u32);
972    }
973
974    // -----------------------------------------------------------------------
975    // Shaped run integration
976    // -----------------------------------------------------------------------
977
978    #[test]
979    fn from_shaped_run_noop() {
980        use crate::script_segmentation::{RunDirection, Script};
981        use crate::shaping::{FontFeatures, NoopShaper, TextShaper};
982
983        let text = "Hi\u{4E16}!";
984        let shaper = NoopShaper;
985        let ff = FontFeatures::default();
986        let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
987
988        let map = ClusterMap::from_shaped_run(text, &run);
989
990        // Same result as from_text for NoopShaper.
991        let text_map = ClusterMap::from_text(text);
992
993        assert_eq!(map.cluster_count(), text_map.cluster_count());
994        assert_eq!(map.total_cells(), text_map.total_cells());
995        assert_eq!(map.total_bytes(), text_map.total_bytes());
996    }
997
998    #[test]
999    fn from_shaped_run_empty() {
1000        use crate::shaping::ShapedRun;
1001
1002        let map = ClusterMap::from_shaped_run(
1003            "",
1004            &ShapedRun {
1005                glyphs: vec![],
1006                total_advance: 0,
1007            },
1008        );
1009        assert!(map.is_empty());
1010    }
1011
1012    #[test]
1013    fn from_shaped_run_ligature_cluster_boundaries() {
1014        use crate::shaping::{ShapedGlyph, ShapedRun};
1015
1016        let text = "file";
1017        let run = ShapedRun {
1018            glyphs: vec![
1019                ShapedGlyph {
1020                    glyph_id: 1,
1021                    cluster: 0, // "fi" cluster
1022                    x_advance: 2,
1023                    y_advance: 0,
1024                    x_offset: 0,
1025                    y_offset: 0,
1026                },
1027                ShapedGlyph {
1028                    glyph_id: 2,
1029                    cluster: 2,
1030                    x_advance: 1,
1031                    y_advance: 0,
1032                    x_offset: 0,
1033                    y_offset: 0,
1034                },
1035                ShapedGlyph {
1036                    glyph_id: 3,
1037                    cluster: 3,
1038                    x_advance: 1,
1039                    y_advance: 0,
1040                    x_offset: 0,
1041                    y_offset: 0,
1042                },
1043            ],
1044            total_advance: 4,
1045        };
1046
1047        let map = ClusterMap::from_shaped_run(text, &run);
1048        assert_eq!(map.cluster_count(), 3);
1049        assert_eq!(map.total_cells(), 4);
1050
1051        // Mid-byte in "fi" snaps to ligature cluster start.
1052        assert_eq!(map.byte_to_cell(0), 0);
1053        assert_eq!(map.byte_to_cell(1), 0);
1054        assert_eq!(map.byte_to_cell(2), 2);
1055
1056        // Continuation cell snaps back to cluster start for interaction mapping.
1057        assert_eq!(map.cell_to_byte(1), 0);
1058        assert_eq!(map.cell_to_byte(2), 2);
1059
1060        // Copy extraction over the ligature cluster preserves canonical text.
1061        assert_eq!(map.extract_text_for_cells(text, 0, 2), "fi");
1062    }
1063}