Skip to main content

ftui_render/
grapheme_pool.rs

1#![forbid(unsafe_code)]
2
3//! Grapheme pooling and interning.
4//!
5//! The `GraphemePool` stores complex grapheme clusters (emoji, ZWJ sequences, etc.)
6//! that don't fit in `CellContent`'s 4-byte inline storage. It provides:
7//!
8//! - Compact `GraphemeId` references (4 bytes) instead of heap strings per cell
9//! - Reference counting for automatic cleanup
10//! - Deduplication via hash lookup
11//! - Slot reuse via free list
12//!
13//! # When to Use
14//!
15//! Most cells use simple characters that fit inline in `CellContent`. The pool
16//! is only needed for:
17//! - Multi-codepoint emoji (๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ, ๐Ÿง‘๐Ÿฝโ€๐Ÿ’ป, etc.)
18//! - ZWJ sequences
19//! - Complex combining character sequences
20//!
21//! # Usage
22//!
23//! ```
24//! use ftui_render::grapheme_pool::GraphemePool;
25//!
26//! let mut pool = GraphemePool::new();
27//!
28//! // Intern a grapheme
29//! let id = pool.intern("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", 2); // Family emoji, width 2
30//!
31//! // Look it up
32//! assert_eq!(pool.get(id), Some("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"));
33//! assert_eq!(id.width(), 2);
34//!
35//! // Increment reference count when copied to another cell
36//! pool.retain(id);
37//!
38//! // Release when cell is overwritten
39//! pool.release(id);
40//! pool.release(id);
41//!
42//! // After all references released, slot is freed
43//! assert_eq!(pool.get(id), None);
44//! ```
45
46use crate::buffer::Buffer;
47use crate::cell::GraphemeId;
48use ahash::AHashMap;
49
50/// A slot in the grapheme pool.
51#[derive(Debug, Clone)]
52struct GraphemeSlot {
53    /// The grapheme cluster string.
54    text: String,
55    /// Display width (cached from GraphemeId).
56    /// Note: Width is also embedded in GraphemeId, but kept here for debugging.
57    #[allow(dead_code)]
58    width: u8,
59    /// Reference count.
60    refcount: u32,
61}
62
63/// A reference-counted pool for complex grapheme clusters.
64///
65/// Stores multi-codepoint strings and returns compact `GraphemeId` references.
66#[derive(Debug, Clone)]
67pub struct GraphemePool {
68    /// Slot storage. `None` indicates a free slot.
69    slots: Vec<Option<GraphemeSlot>>,
70    /// Generation counters for each slot to detect stale accesses.
71    generations: Vec<u16>,
72    /// Lookup table for deduplication.
73    lookup: AHashMap<String, GraphemeId>,
74    /// Free slot indices for reuse.
75    free_list: Vec<u32>,
76}
77
78impl GraphemePool {
79    /// Create a new empty grapheme pool.
80    pub fn new() -> Self {
81        Self {
82            slots: Vec::new(),
83            generations: Vec::new(),
84            lookup: AHashMap::new(),
85            free_list: Vec::new(),
86        }
87    }
88
89    /// Create a pool with pre-allocated capacity.
90    pub fn with_capacity(capacity: usize) -> Self {
91        Self {
92            slots: Vec::with_capacity(capacity),
93            generations: Vec::with_capacity(capacity),
94            lookup: AHashMap::with_capacity(capacity),
95            free_list: Vec::new(),
96        }
97    }
98
99    /// Number of active (non-free) slots.
100    #[inline]
101    pub fn len(&self) -> usize {
102        self.slots.len().saturating_sub(self.free_list.len())
103    }
104
105    /// Check if the pool is empty.
106    #[inline]
107    pub fn is_empty(&self) -> bool {
108        self.len() == 0
109    }
110
111    /// Total capacity (including free slots).
112    #[inline]
113    pub fn capacity(&self) -> usize {
114        self.slots.capacity()
115    }
116
117    /// Intern a grapheme string and return its ID.
118    ///
119    /// If the string is already interned, returns the existing ID and
120    /// increments the reference count.
121    ///
122    /// # Parameters
123    ///
124    /// - `text`: The grapheme cluster string
125    /// - `width`: Display width (0-15)
126    ///
127    /// # Panics
128    ///
129    /// Panics if width > 15 or if the pool exceeds capacity (64K slots).
130    pub fn intern(&mut self, text: &str, width: u8) -> GraphemeId {
131        assert!(width <= GraphemeId::MAX_WIDTH, "width overflow");
132
133        // Check if already interned
134        if let Some(&id) = self.lookup.get(text) {
135            // Verify generation matches current slot generation
136            debug_assert_eq!(
137                id.generation(),
138                self.generations[id.slot()],
139                "intern lookup returned stale ID"
140            );
141            debug_assert_eq!(
142                id.width() as u8,
143                width,
144                "intern() called with different width for the same text {:?}: existing={}, new={}",
145                text,
146                id.width(),
147                width
148            );
149            self.retain(id);
150            return id;
151        }
152
153        // Allocate a new slot
154        let slot_idx = self.alloc_slot();
155        let generation;
156
157        if (slot_idx as usize) < self.generations.len() {
158            // Reuse: increment generation to invalidate old IDs
159            self.generations[slot_idx as usize] =
160                self.generations[slot_idx as usize].wrapping_add(1) & GraphemeId::MAX_GENERATION;
161            generation = self.generations[slot_idx as usize];
162        } else {
163            // New slot
164            generation = 0;
165            self.generations.push(0);
166        }
167
168        let id = GraphemeId::new(slot_idx, generation, width);
169
170        // Store the grapheme
171        let slot = GraphemeSlot {
172            text: text.to_string(),
173            width,
174            refcount: 1,
175        };
176
177        if (slot_idx as usize) < self.slots.len() {
178            self.slots[slot_idx as usize] = Some(slot);
179        } else {
180            debug_assert_eq!(slot_idx as usize, self.slots.len());
181            self.slots.push(Some(slot));
182        }
183
184        self.lookup.insert(text.to_string(), id);
185        id
186    }
187
188    /// Get the string for a grapheme ID.
189    ///
190    /// Returns `None` if the ID is invalid, freed, or from a different generation.
191    #[must_use]
192    pub fn get(&self, id: GraphemeId) -> Option<&str> {
193        let slot_idx = id.slot();
194        // Check bounds and generation
195        if let Some(&slot_gen) = self.generations.get(slot_idx) {
196            if slot_gen != id.generation() {
197                return None;
198            }
199        } else {
200            return None;
201        }
202
203        self.slots
204            .get(slot_idx)
205            .and_then(|slot| slot.as_ref())
206            .map(|slot| slot.text.as_str())
207    }
208
209    /// Increment the reference count for a grapheme.
210    ///
211    /// Call this when a cell containing this grapheme is copied.
212    pub fn retain(&mut self, id: GraphemeId) {
213        let slot_idx = id.slot();
214        // Check generation to avoid modifying wrong slot
215        if let Some(&slot_gen) = self.generations.get(slot_idx) {
216            if slot_gen != id.generation() {
217                return;
218            }
219        } else {
220            return;
221        }
222
223        if let Some(Some(slot)) = self.slots.get_mut(slot_idx) {
224            slot.refcount = slot.refcount.saturating_add(1);
225        }
226    }
227
228    /// Decrement the reference count for a grapheme.
229    ///
230    /// Call this when a cell containing this grapheme is overwritten or freed.
231    /// When the reference count reaches zero, the slot is freed for reuse.
232    pub fn release(&mut self, id: GraphemeId) {
233        let slot_idx = id.slot();
234        // Check generation
235        if let Some(&slot_gen) = self.generations.get(slot_idx) {
236            if slot_gen != id.generation() {
237                return;
238            }
239        } else {
240            return;
241        }
242
243        if let Some(Some(slot)) = self.slots.get_mut(slot_idx) {
244            if slot.refcount == 0 {
245                debug_assert!(false, "double-free of grapheme slot {slot_idx}");
246                return;
247            }
248            slot.refcount -= 1;
249            if slot.refcount == 0 {
250                // Remove from lookup
251                self.lookup.remove(&slot.text);
252                // Clear the slot
253                self.slots[slot_idx] = None;
254                // Add to free list
255                self.free_list.push(slot_idx as u32);
256            }
257        }
258    }
259
260    /// Get the reference count for a grapheme.
261    ///
262    /// Returns 0 if the ID is invalid or freed.
263    pub fn refcount(&self, id: GraphemeId) -> u32 {
264        let slot_idx = id.slot();
265        if let Some(&slot_gen) = self.generations.get(slot_idx) {
266            if slot_gen != id.generation() {
267                return 0;
268            }
269        } else {
270            return 0;
271        }
272
273        self.slots
274            .get(slot_idx)
275            .and_then(|slot| slot.as_ref())
276            .map(|slot| slot.refcount)
277            .unwrap_or(0)
278    }
279
280    /// Clear all entries from the pool.
281    pub fn clear(&mut self) {
282        self.slots.clear();
283        self.generations.clear();
284        self.lookup.clear();
285        self.free_list.clear();
286    }
287
288    /// Allocate a slot index, reusing from free list if possible.
289    fn alloc_slot(&mut self) -> u32 {
290        if let Some(idx) = self.free_list.pop() {
291            idx
292        } else {
293            let idx = self.slots.len() as u32;
294            assert!(
295                idx <= GraphemeId::MAX_SLOT,
296                "grapheme pool capacity exceeded"
297            );
298            idx
299        }
300    }
301
302    /// Garbage collect graphemes not referenced by the given buffers.
303    ///
304    /// This implements a Mark-and-Sweep algorithm:
305    /// 1. Reset all internal refcounts to 0.
306    /// 2. Scan provided buffers and increment refcounts for referenced graphemes.
307    /// 3. Free any slots that remain with refcount 0.
308    ///
309    /// This should be called periodically (e.g. every N frames) passing the
310    /// current front and back buffers to prevent memory leaks in long-running apps.
311    pub fn gc(&mut self, buffers: &[&Buffer]) {
312        // 1. Reset
313        for slot in self.slots.iter_mut().flatten() {
314            slot.refcount = 0;
315        }
316
317        // 2. Mark
318        for buf in buffers {
319            for cell in buf.cells() {
320                if let Some(id) = cell.content.grapheme_id() {
321                    // We access via slot index directly.
322                    // Note: id.slot() returns usize.
323                    let slot_idx = id.slot();
324                    if let Some(Some(slot)) = self.slots.get_mut(slot_idx) {
325                        // Only mark if the generation matches, otherwise it's a stale reference
326                        if let Some(&slot_gen) = self.generations.get(slot_idx)
327                            && slot_gen == id.generation()
328                        {
329                            slot.refcount = slot.refcount.saturating_add(1);
330                        }
331                    }
332                }
333            }
334        }
335
336        // 3. Sweep
337        // We collect keys to remove to avoid borrow conflicts with self.lookup
338        let mut keys_to_remove = Vec::new();
339
340        for (idx, slot_opt) in self.slots.iter_mut().enumerate() {
341            // Check refcount without holding a mutable borrow for too long
342            let should_free = slot_opt.as_ref().is_some_and(|s| s.refcount == 0);
343
344            if should_free {
345                // Take the slot to own the string (no clone needed)
346                if let Some(dead_slot) = slot_opt.take() {
347                    keys_to_remove.push(dead_slot.text);
348                    self.generations[idx] = self.generations[idx].wrapping_add(1);
349                    self.free_list.push(idx as u32);
350                }
351            }
352        }
353
354        for text in keys_to_remove {
355            self.lookup.remove(&text);
356        }
357    }
358}
359
360impl Default for GraphemePool {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn intern_and_get() {
372        let mut pool = GraphemePool::new();
373        let id = pool.intern("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", 2);
374
375        assert_eq!(pool.get(id), Some("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"));
376        assert_eq!(id.width(), 2);
377    }
378
379    #[test]
380    fn deduplication() {
381        let mut pool = GraphemePool::new();
382        let id1 = pool.intern("๐ŸŽ‰", 2);
383        let id2 = pool.intern("๐ŸŽ‰", 2);
384
385        // Same ID returned
386        assert_eq!(id1, id2);
387        // Refcount is 2
388        assert_eq!(pool.refcount(id1), 2);
389        // Only one slot used
390        assert_eq!(pool.len(), 1);
391    }
392
393    #[test]
394    fn retain_and_release() {
395        let mut pool = GraphemePool::new();
396        let id = pool.intern("๐Ÿš€", 2);
397        assert_eq!(pool.refcount(id), 1);
398
399        pool.retain(id);
400        assert_eq!(pool.refcount(id), 2);
401
402        pool.release(id);
403        assert_eq!(pool.refcount(id), 1);
404
405        pool.release(id);
406        // Slot is now freed
407        assert_eq!(pool.get(id), None);
408        assert_eq!(pool.len(), 0);
409    }
410
411    #[test]
412    fn slot_reuse() {
413        let mut pool = GraphemePool::new();
414
415        // Intern and release
416        let id1 = pool.intern("A", 1);
417        pool.release(id1);
418        assert_eq!(pool.len(), 0);
419
420        // Intern again - should reuse the slot
421        let id2 = pool.intern("B", 1);
422        assert_eq!(id1.slot(), id2.slot());
423        assert_eq!(pool.get(id2), Some("B"));
424    }
425
426    #[test]
427    fn empty_pool() {
428        let pool = GraphemePool::new();
429        assert!(pool.is_empty());
430        assert_eq!(pool.len(), 0);
431    }
432
433    #[test]
434    fn multiple_graphemes() {
435        let mut pool = GraphemePool::new();
436
437        let id1 = pool.intern("๐Ÿ‘จโ€๐Ÿ’ป", 2);
438        let id2 = pool.intern("๐Ÿ‘ฉโ€๐Ÿ”ฌ", 2);
439        let id3 = pool.intern("๐Ÿง‘๐Ÿฝโ€๐Ÿš€", 2);
440
441        assert_eq!(pool.len(), 3);
442        assert_ne!(id1, id2);
443        assert_ne!(id2, id3);
444
445        assert_eq!(pool.get(id1), Some("๐Ÿ‘จโ€๐Ÿ’ป"));
446        assert_eq!(pool.get(id2), Some("๐Ÿ‘ฉโ€๐Ÿ”ฌ"));
447        assert_eq!(pool.get(id3), Some("๐Ÿง‘๐Ÿฝโ€๐Ÿš€"));
448    }
449
450    #[test]
451    fn width_preserved() {
452        let mut pool = GraphemePool::new();
453
454        // Various widths
455        let id1 = pool.intern("๐Ÿ‘‹", 2);
456        let id2 = pool.intern("A", 1);
457        let id3 = pool.intern("ๆ—ฅ", 2);
458
459        assert_eq!(id1.width(), 2);
460        assert_eq!(id2.width(), 1);
461        assert_eq!(id3.width(), 2);
462    }
463
464    #[test]
465    fn clear_pool() {
466        let mut pool = GraphemePool::new();
467        pool.intern("A", 1);
468        pool.intern("B", 1);
469        pool.intern("C", 1);
470
471        assert_eq!(pool.len(), 3);
472
473        pool.clear();
474        assert!(pool.is_empty());
475    }
476
477    #[test]
478    fn invalid_id_returns_none() {
479        let pool = GraphemePool::new();
480        let fake_id = GraphemeId::new(999, 0, 1);
481        assert_eq!(pool.get(fake_id), None);
482    }
483
484    #[test]
485    fn release_invalid_id_is_safe() {
486        let mut pool = GraphemePool::new();
487        let fake_id = GraphemeId::new(999, 0, 1);
488        pool.release(fake_id); // Should not panic
489    }
490
491    #[test]
492    fn retain_invalid_id_is_safe() {
493        let mut pool = GraphemePool::new();
494        let fake_id = GraphemeId::new(999, 0, 1);
495        pool.retain(fake_id); // Should not panic
496    }
497
498    #[test]
499    fn stale_generation_returns_none() {
500        let mut pool = GraphemePool::new();
501        let id1 = pool.intern("A", 1);
502        pool.release(id1);
503
504        // Reallocate slot with new generation
505        let id2 = pool.intern("B", 1);
506        assert_eq!(id1.slot(), id2.slot());
507        assert_ne!(id1.generation(), id2.generation());
508
509        // Old ID should be invalid
510        assert_eq!(pool.get(id1), None);
511        assert_eq!(pool.get(id2), Some("B"));
512    }
513
514    #[test]
515    #[should_panic(expected = "width overflow")]
516    fn width_overflow_panics() {
517        let mut pool = GraphemePool::new();
518        pool.intern("X", GraphemeId::MAX_WIDTH + 1);
519    }
520
521    #[test]
522    fn with_capacity() {
523        let pool = GraphemePool::with_capacity(100);
524        assert!(pool.capacity() >= 100);
525        assert!(pool.is_empty());
526    }
527
528    mod gc_tests {
529        use super::*;
530        use crate::buffer::Buffer;
531        use crate::cell::{Cell, CellContent};
532
533        /// Helper: create a buffer with a grapheme cell at (0,0).
534        fn buf_with_grapheme(id: GraphemeId) -> Buffer {
535            let mut buf = Buffer::new(4, 1);
536            let content = CellContent::from_grapheme(id);
537            buf.set(0, 0, Cell::new(content));
538            buf
539        }
540
541        #[test]
542        fn gc_retains_referenced_grapheme() {
543            let mut pool = GraphemePool::new();
544            let id = pool.intern("๐Ÿš€", 2);
545
546            let buf = buf_with_grapheme(id);
547            pool.gc(&[&buf]);
548
549            assert_eq!(pool.get(id), Some("๐Ÿš€"));
550            assert_eq!(pool.refcount(id), 1);
551        }
552
553        #[test]
554        fn gc_frees_unreferenced_grapheme() {
555            let mut pool = GraphemePool::new();
556            let id = pool.intern("๐Ÿš€", 2);
557
558            // Empty buffer โ€” no references
559            let buf = Buffer::new(4, 1);
560            pool.gc(&[&buf]);
561
562            assert_eq!(pool.get(id), None);
563            assert_eq!(pool.refcount(id), 0);
564            assert!(pool.is_empty());
565        }
566
567        #[test]
568        fn gc_with_multiple_buffers() {
569            let mut pool = GraphemePool::new();
570            let id1 = pool.intern("๐ŸŽ‰", 2);
571            let id2 = pool.intern("๐Ÿงช", 2);
572            let id3 = pool.intern("๐Ÿ”ฅ", 2);
573
574            // buf1 references id1, buf2 references id3
575            let buf1 = buf_with_grapheme(id1);
576            let buf2 = buf_with_grapheme(id3);
577
578            pool.gc(&[&buf1, &buf2]);
579
580            assert_eq!(pool.get(id1), Some("๐ŸŽ‰"));
581            assert_eq!(pool.get(id2), None); // freed
582            assert_eq!(pool.get(id3), Some("๐Ÿ”ฅ"));
583            assert_eq!(pool.len(), 2);
584        }
585
586        #[test]
587        fn gc_with_multiple_references_in_buffer() {
588            let mut pool = GraphemePool::new();
589            let id = pool.intern("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", 2);
590
591            // Buffer with the same grapheme in two cells
592            let mut buf = Buffer::new(4, 1);
593            let content = CellContent::from_grapheme(id);
594            buf.set(0, 0, Cell::new(content));
595            buf.set(2, 0, Cell::new(content));
596
597            pool.gc(&[&buf]);
598
599            assert_eq!(pool.get(id), Some("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"));
600            assert_eq!(pool.refcount(id), 2);
601        }
602
603        #[test]
604        fn gc_with_empty_pool() {
605            let mut pool = GraphemePool::new();
606            let buf = Buffer::new(4, 1);
607            pool.gc(&[&buf]); // should not panic
608            assert!(pool.is_empty());
609        }
610
611        #[test]
612        fn gc_with_no_buffers() {
613            let mut pool = GraphemePool::new();
614            let id = pool.intern("test", 1);
615            pool.gc(&[]);
616            // No buffers means no references โ€” everything freed
617            assert_eq!(pool.get(id), None);
618            assert!(pool.is_empty());
619        }
620
621        #[test]
622        fn gc_freed_slots_are_reusable() {
623            let mut pool = GraphemePool::new();
624            let id1 = pool.intern("A", 1);
625            let _id2 = pool.intern("B", 1);
626            let slot1 = id1.slot();
627
628            // Keep only id1
629            let buf = buf_with_grapheme(id1);
630            pool.gc(&[&buf]);
631
632            // B was freed, its slot should be reusable
633            let id3 = pool.intern("C", 1);
634            // The freed slot from B should be reused (it was at slot index 1)
635            assert_eq!(pool.get(id3), Some("C"));
636            assert_eq!(pool.len(), 2); // A and C
637
638            // id1 should still work
639            assert_eq!(pool.get(id1), Some("A"));
640            assert_eq!(id1.slot(), slot1);
641        }
642
643        #[test]
644        fn gc_resets_refcounts_accurately() {
645            let mut pool = GraphemePool::new();
646            let id = pool.intern("๐Ÿš€", 2);
647
648            // Artificially inflate refcount
649            pool.retain(id);
650            pool.retain(id);
651            assert_eq!(pool.refcount(id), 3);
652
653            // Buffer has one reference
654            let buf = buf_with_grapheme(id);
655            pool.gc(&[&buf]);
656
657            // GC resets then counts actual references
658            assert_eq!(pool.refcount(id), 1);
659        }
660
661        #[test]
662        fn gc_lookup_table_stays_consistent() {
663            let mut pool = GraphemePool::new();
664            let _id1 = pool.intern("A", 1);
665            let id2 = pool.intern("B", 1);
666
667            // Keep only B
668            let buf = buf_with_grapheme(id2);
669            pool.gc(&[&buf]);
670
671            // A was freed from lookup, so interning A again should work
672            let id_new = pool.intern("A", 1);
673            assert_eq!(pool.get(id_new), Some("A"));
674
675            // B should still be deduped
676            let id_b2 = pool.intern("B", 1);
677            assert_eq!(id_b2, id2);
678        }
679    }
680
681    mod property {
682        use super::*;
683        use proptest::prelude::*;
684
685        /// Generate a non-empty string suitable for interning.
686        fn arb_grapheme() -> impl Strategy<Value = String> {
687            prop::string::string_regex(".{1,8}")
688                .unwrap()
689                .prop_filter("non-empty", |s| !s.is_empty())
690        }
691
692        /// Generate a valid width (0..=GraphemeId::MAX_WIDTH).
693        fn arb_width() -> impl Strategy<Value = u8> {
694            0u8..=GraphemeId::MAX_WIDTH
695        }
696
697        proptest! {
698            #![proptest_config(ProptestConfig::with_cases(256))]
699
700            /// Intern followed by get always returns the original string.
701            #[test]
702            fn intern_get_roundtrip(s in arb_grapheme(), w in arb_width()) {
703                let mut pool = GraphemePool::new();
704                let id = pool.intern(&s, w);
705                prop_assert_eq!(pool.get(id), Some(s.as_str()));
706            }
707
708            /// Width is preserved through intern.
709            #[test]
710            fn intern_preserves_width(s in arb_grapheme(), w in arb_width()) {
711                let mut pool = GraphemePool::new();
712                let id = pool.intern(&s, w);
713                prop_assert_eq!(id.width(), w as usize);
714            }
715
716            /// Interning the same string twice returns the same id.
717            #[test]
718            fn deduplication_same_id(s in arb_grapheme(), w in arb_width()) {
719                let mut pool = GraphemePool::new();
720                let id1 = pool.intern(&s, w);
721                let id2 = pool.intern(&s, w);
722                prop_assert_eq!(id1, id2);
723                prop_assert_eq!(pool.len(), 1);
724            }
725
726            /// After N interns of the same string, refcount equals N.
727            #[test]
728            fn deduplication_refcount(s in arb_grapheme(), w in arb_width(), extra in 0u32..10) {
729                let mut pool = GraphemePool::new();
730                let id = pool.intern(&s, w);
731                for _ in 0..extra {
732                    pool.intern(&s, w);
733                }
734                prop_assert_eq!(pool.refcount(id), 1 + extra);
735            }
736
737            /// Retain increments refcount, release decrements it.
738            #[test]
739            fn retain_release_refcount(
740                s in arb_grapheme(),
741                w in arb_width(),
742                retains in 0u32..10,
743                releases in 0u32..10
744            ) {
745                let mut pool = GraphemePool::new();
746                let id = pool.intern(&s, w);
747                // Start at refcount 1
748                for _ in 0..retains {
749                    pool.retain(id);
750                }
751                let expected_after_retain = 1 + retains;
752                prop_assert_eq!(pool.refcount(id), expected_after_retain);
753
754                let actual_releases = releases.min(expected_after_retain - 1);
755                for _ in 0..actual_releases {
756                    pool.release(id);
757                }
758                prop_assert_eq!(pool.refcount(id), expected_after_retain - actual_releases);
759                // Entry should still be alive
760                prop_assert_eq!(pool.get(id), Some(s.as_str()));
761            }
762
763            /// Releasing all references frees the slot.
764            #[test]
765            fn release_to_zero_frees(s in arb_grapheme(), w in arb_width(), extra in 0u32..5) {
766                let mut pool = GraphemePool::new();
767                let id = pool.intern(&s, w);
768                for _ in 0..extra {
769                    pool.retain(id);
770                }
771                // Release all: 1 (initial) + extra (retains)
772                for _ in 0..=extra {
773                    pool.release(id);
774                }
775                prop_assert_eq!(pool.get(id), None);
776                prop_assert_eq!(pool.refcount(id), 0);
777                prop_assert!(pool.is_empty());
778            }
779
780            /// Freed slots are reused by subsequent interns.
781            #[test]
782            fn slot_reuse_after_free(
783                s1 in arb_grapheme(),
784                s2 in arb_grapheme(),
785                w in arb_width()
786            ) {
787                let mut pool = GraphemePool::new();
788                let id1 = pool.intern(&s1, w);
789                let slot1 = id1.slot();
790                pool.release(id1);
791
792                // s2 should reuse slot1's index
793                let id2 = pool.intern(&s2, w);
794                prop_assert_eq!(id2.slot(), slot1);
795                prop_assert_eq!(pool.get(id2), Some(s2.as_str()));
796            }
797
798            /// len() tracks active entries correctly across operations.
799            #[test]
800            fn len_invariant(count in 1usize..20) {
801                let mut pool = GraphemePool::new();
802                let mut ids = Vec::new();
803                for i in 0..count {
804                    let s = format!("g{i}");
805                    ids.push(pool.intern(&s, 1));
806                }
807                prop_assert_eq!(pool.len(), count);
808
809                // Release half
810                let release_count = count / 2;
811                for id in &ids[..release_count] {
812                    pool.release(*id);
813                }
814                prop_assert_eq!(pool.len(), count - release_count);
815            }
816
817            /// Multiple distinct strings produce distinct ids.
818            #[test]
819            fn distinct_strings_distinct_ids(count in 2usize..15) {
820                let mut pool = GraphemePool::new();
821                let mut ids = Vec::new();
822                for i in 0..count {
823                    let s = format!("unique_{i}");
824                    ids.push(pool.intern(&s, 1));
825                }
826                // All ids should be distinct
827                for i in 0..ids.len() {
828                    for j in (i + 1)..ids.len() {
829                        prop_assert_ne!(ids[i], ids[j]);
830                    }
831                }
832            }
833
834            /// Clear resets the pool entirely regardless of contents.
835            #[test]
836            fn clear_resets_all(count in 1usize..20) {
837                let mut pool = GraphemePool::new();
838                let mut ids = Vec::new();
839                for i in 0..count {
840                    let s = format!("c{i}");
841                    ids.push(pool.intern(&s, 1));
842                }
843                pool.clear();
844                prop_assert!(pool.is_empty());
845                prop_assert_eq!(pool.len(), 0);
846                for id in &ids {
847                    prop_assert_eq!(pool.get(*id), None);
848                }
849            }
850
851            // --- Executable Invariant Tests (bd-10i.13.2) ---
852
853            /// Invariant: refcount > 0 implies get() returns Some (slot is valid).
854            #[test]
855            fn positive_refcount_implies_valid_slot(
856                count in 1usize..10,
857                retains in proptest::collection::vec(0u32..5, 1..10),
858            ) {
859                let mut pool = GraphemePool::new();
860                let mut ids = Vec::new();
861                for i in 0..count {
862                    let s = format!("inv_{i}");
863                    ids.push(pool.intern(&s, 1));
864                }
865
866                // Apply random retains
867                for (i, &extra) in retains.iter().enumerate() {
868                    let id = ids[i % count];
869                    for _ in 0..extra {
870                        pool.retain(id);
871                    }
872                }
873
874                // Invariant check: every id with refcount > 0 must be gettable
875                for (i, &id) in ids.iter().enumerate() {
876                    let rc = pool.refcount(id);
877                    if rc > 0 {
878                        prop_assert!(pool.get(id).is_some(),
879                            "slot {} has refcount {} but get() returned None", i, rc);
880                    }
881                }
882            }
883
884            /// Invariant: each release() decrements refcount by exactly 1.
885            #[test]
886            fn release_decrements_by_one(s in arb_grapheme(), w in arb_width(), retains in 1u32..8) {
887                let mut pool = GraphemePool::new();
888                let id = pool.intern(&s, w);
889                for _ in 0..retains {
890                    pool.retain(id);
891                }
892                let rc_before = pool.refcount(id);
893                pool.release(id);
894                let rc_after = pool.refcount(id);
895                prop_assert_eq!(rc_after, rc_before - 1,
896                    "release should decrement refcount by exactly 1");
897            }
898
899            /// Invariant: releasing a freed slot does not corrupt pool state.
900            #[test]
901            fn over_release_does_not_corrupt(count in 1usize..5) {
902                let mut pool = GraphemePool::new();
903                let mut ids = Vec::new();
904                for i in 0..count {
905                    let s = format!("or_{i}");
906                    ids.push(pool.intern(&s, 1));
907                }
908
909                // Free the first entry
910                let victim = ids[0];
911                pool.release(victim);
912                prop_assert_eq!(pool.refcount(victim), 0);
913                prop_assert_eq!(pool.get(victim), None);
914
915                // Double-release should be safe (saturating)
916                pool.release(victim);
917                prop_assert_eq!(pool.refcount(victim), 0);
918
919                // Other entries must be unaffected
920                for &id in &ids[1..] {
921                    prop_assert!(pool.get(id).is_some(),
922                        "over-release corrupted unrelated slot");
923                    prop_assert!(pool.refcount(id) > 0);
924                }
925            }
926
927            /// Invariant: GraphemeId from one pool is not valid in a different pool.
928            #[test]
929            fn cross_pool_id_is_invalid(s in arb_grapheme(), w in arb_width()) {
930                let mut pool_a = GraphemePool::new();
931                let pool_b = GraphemePool::new();
932                let id = pool_a.intern(&s, w);
933
934                // id from pool_a should not resolve in empty pool_b
935                prop_assert_eq!(pool_b.get(id), None,
936                    "GraphemeId from pool A should not be valid in pool B");
937            }
938        }
939    }
940
941    // --- Edge-case tests ---
942
943    #[test]
944    fn pool_debug_and_clone() {
945        let mut pool = GraphemePool::new();
946        pool.intern("๐Ÿš€", 2);
947        let dbg = format!("{:?}", pool);
948        assert!(dbg.contains("GraphemePool"), "Debug: {dbg}");
949        let cloned = pool.clone();
950        assert_eq!(cloned.len(), 1);
951        // Cloned pool is independent
952        let id = cloned.lookup.values().next().copied().unwrap();
953        assert_eq!(cloned.get(id), Some("๐Ÿš€"));
954    }
955
956    #[test]
957    fn pool_default_is_new() {
958        let pool = GraphemePool::default();
959        assert!(pool.is_empty());
960        assert_eq!(pool.len(), 0);
961    }
962
963    #[test]
964    fn intern_width_zero() {
965        let mut pool = GraphemePool::new();
966        let id = pool.intern("zero-width", 0);
967        assert_eq!(id.width(), 0);
968        assert_eq!(pool.get(id), Some("zero-width"));
969    }
970
971    #[test]
972    fn intern_width_max() {
973        let mut pool = GraphemePool::new();
974        let id = pool.intern("max-width", GraphemeId::MAX_WIDTH);
975        assert_eq!(id.width(), GraphemeId::MAX_WIDTH as usize);
976        assert_eq!(pool.get(id), Some("max-width"));
977    }
978
979    #[test]
980    fn intern_empty_string() {
981        let mut pool = GraphemePool::new();
982        let id = pool.intern("", 0);
983        assert_eq!(pool.get(id), Some(""));
984    }
985
986    #[test]
987    fn intern_long_string() {
988        let mut pool = GraphemePool::new();
989        let long = "a".repeat(1000);
990        let id = pool.intern(&long, 1);
991        assert_eq!(pool.get(id), Some(long.as_str()));
992    }
993
994    #[test]
995    fn clear_then_intern_reuses_from_scratch() {
996        let mut pool = GraphemePool::new();
997        pool.intern("A", 1);
998        pool.intern("B", 1);
999        pool.clear();
1000        assert!(pool.is_empty());
1001        // After clear, free_list is also cleared, so slots start fresh
1002        let id = pool.intern("C", 1);
1003        assert_eq!(id.slot(), 0);
1004        assert_eq!(pool.get(id), Some("C"));
1005        assert_eq!(pool.len(), 1);
1006    }
1007
1008    #[test]
1009    fn with_capacity_then_intern() {
1010        let mut pool = GraphemePool::with_capacity(50);
1011        for i in 0..50 {
1012            pool.intern(&format!("g{i}"), 1);
1013        }
1014        assert_eq!(pool.len(), 50);
1015    }
1016
1017    #[test]
1018    fn refcount_of_freed_slot_is_zero() {
1019        let mut pool = GraphemePool::new();
1020        let id = pool.intern("temp", 1);
1021        pool.release(id);
1022        assert_eq!(pool.refcount(id), 0);
1023    }
1024
1025    #[test]
1026    fn refcount_of_invalid_id_is_zero() {
1027        let pool = GraphemePool::new();
1028        assert_eq!(pool.refcount(GraphemeId::new(0, 0, 1)), 0);
1029        assert_eq!(pool.refcount(GraphemeId::new(999, 0, 1)), 0);
1030    }
1031
1032    #[test]
1033    fn retain_freed_slot_is_noop() {
1034        let mut pool = GraphemePool::new();
1035        let id = pool.intern("temp", 1);
1036        pool.release(id);
1037        // Slot is now freed (None)
1038        pool.retain(id); // Should not panic, noop
1039        assert_eq!(pool.refcount(id), 0);
1040        assert_eq!(pool.get(id), None);
1041    }
1042
1043    #[test]
1044    fn double_release_is_safe() {
1045        let mut pool = GraphemePool::new();
1046        let id = pool.intern("temp", 1);
1047        pool.release(id); // refcount 0, freed
1048        pool.release(id); // noop on None slot
1049        assert_eq!(pool.refcount(id), 0);
1050    }
1051
1052    #[test]
1053    fn multiple_slot_reuse_cycles() {
1054        let mut pool = GraphemePool::new();
1055        for cycle in 0..5 {
1056            let id = pool.intern(&format!("cycle{cycle}"), 1);
1057            assert_eq!(id.slot(), 0); // Always reuses slot 0
1058            assert_eq!(pool.get(id), Some(format!("cycle{cycle}").as_str()));
1059            pool.release(id);
1060        }
1061        assert!(pool.is_empty());
1062    }
1063
1064    #[test]
1065    fn free_list_ordering() {
1066        let mut pool = GraphemePool::new();
1067        let id0 = pool.intern("A", 1);
1068        let id1 = pool.intern("B", 1);
1069        let id2 = pool.intern("C", 1);
1070        assert_eq!(id0.slot(), 0);
1071        assert_eq!(id1.slot(), 1);
1072        assert_eq!(id2.slot(), 2);
1073
1074        // Release in order: 0, 2 (skip 1)
1075        pool.release(id0);
1076        pool.release(id2);
1077        assert_eq!(pool.len(), 1); // Only B remains
1078
1079        // Free list is LIFO: next alloc gets slot 2 (last freed), then slot 0
1080        let new1 = pool.intern("D", 1);
1081        assert_eq!(new1.slot(), 2);
1082        let new2 = pool.intern("E", 1);
1083        assert_eq!(new2.slot(), 0);
1084    }
1085
1086    #[test]
1087    fn intern_after_release_deduplicates_correctly() {
1088        let mut pool = GraphemePool::new();
1089        let id1 = pool.intern("X", 1);
1090        pool.release(id1);
1091        // "X" is now freed from both slot and lookup
1092        assert_eq!(pool.get(id1), None);
1093
1094        // Interning "X" again should work (creates new slot)
1095        let id2 = pool.intern("X", 1);
1096        assert_eq!(pool.get(id2), Some("X"));
1097        assert_eq!(pool.refcount(id2), 1);
1098    }
1099
1100    #[test]
1101    fn clone_independence() {
1102        let mut pool = GraphemePool::new();
1103        let id = pool.intern("shared", 1);
1104
1105        let mut cloned = pool.clone();
1106        // Modify original
1107        pool.release(id);
1108        assert_eq!(pool.get(id), None);
1109
1110        // Clone should be unaffected
1111        assert_eq!(cloned.get(id), Some("shared"));
1112        assert_eq!(cloned.refcount(id), 1);
1113
1114        // Modify clone
1115        cloned.retain(id);
1116        assert_eq!(cloned.refcount(id), 2);
1117        // Original still freed
1118        assert_eq!(pool.refcount(id), 0);
1119    }
1120
1121    #[test]
1122    fn gc_double_run_idempotent() {
1123        use crate::buffer::Buffer;
1124        use crate::cell::{Cell, CellContent};
1125
1126        let mut pool = GraphemePool::new();
1127        let id = pool.intern("keep", 1);
1128        let _id2 = pool.intern("drop", 1);
1129
1130        let mut buf = Buffer::new(4, 1);
1131        buf.set(0, 0, Cell::new(CellContent::from_grapheme(id)));
1132
1133        pool.gc(&[&buf]);
1134        assert_eq!(pool.len(), 1);
1135        assert_eq!(pool.get(id), Some("keep"));
1136
1137        // Second GC with same buffer should be idempotent
1138        pool.gc(&[&buf]);
1139        assert_eq!(pool.len(), 1);
1140        assert_eq!(pool.refcount(id), 1);
1141    }
1142
1143    #[test]
1144    fn gc_with_already_freed_slots() {
1145        use crate::buffer::Buffer;
1146
1147        let mut pool = GraphemePool::new();
1148        let id1 = pool.intern("A", 1);
1149        let id2 = pool.intern("B", 1);
1150
1151        // Manually free id1 before GC
1152        pool.release(id1);
1153        assert_eq!(pool.len(), 1);
1154
1155        // GC with empty buffer โ€” should free id2 as well
1156        let buf = Buffer::new(4, 1);
1157        pool.gc(&[&buf]);
1158
1159        assert!(pool.is_empty());
1160        assert_eq!(pool.get(id2), None);
1161    }
1162
1163    #[test]
1164    fn stress_100_graphemes() {
1165        let mut pool = GraphemePool::new();
1166        let mut ids = Vec::new();
1167        for i in 0..100 {
1168            ids.push(pool.intern(&format!("g{i:03}"), 1));
1169        }
1170        assert_eq!(pool.len(), 100);
1171
1172        // All accessible
1173        for (i, &id) in ids.iter().enumerate() {
1174            assert_eq!(pool.get(id), Some(format!("g{i:03}").as_str()));
1175        }
1176
1177        // Release even-indexed
1178        for i in (0..100).step_by(2) {
1179            pool.release(ids[i]);
1180        }
1181        assert_eq!(pool.len(), 50);
1182
1183        // Odd-indexed still valid
1184        for i in (1..100).step_by(2) {
1185            assert_eq!(pool.get(ids[i]), Some(format!("g{i:03}").as_str()));
1186        }
1187    }
1188
1189    #[test]
1190    fn capacity_grows_with_interns() {
1191        let mut pool = GraphemePool::new();
1192        let cap_before = pool.capacity();
1193        for i in 0..20 {
1194            pool.intern(&format!("grow{i}"), 1);
1195        }
1196        // Capacity should have grown
1197        assert!(pool.capacity() >= 20);
1198        assert!(pool.capacity() >= cap_before);
1199    }
1200
1201    #[test]
1202    fn len_after_mixed_operations() {
1203        let mut pool = GraphemePool::new();
1204        assert_eq!(pool.len(), 0);
1205
1206        let a = pool.intern("A", 1);
1207        assert_eq!(pool.len(), 1);
1208
1209        let b = pool.intern("B", 1);
1210        assert_eq!(pool.len(), 2);
1211
1212        // Dedup: same string doesn't increase len
1213        pool.intern("A", 1);
1214        assert_eq!(pool.len(), 2);
1215
1216        pool.release(a);
1217        // A still has refcount 1 (was retained by dedup intern)
1218        assert_eq!(pool.len(), 2);
1219
1220        pool.release(a);
1221        // Now A is freed
1222        assert_eq!(pool.len(), 1);
1223
1224        pool.release(b);
1225        assert_eq!(pool.len(), 0);
1226        assert!(pool.is_empty());
1227    }
1228
1229    #[test]
1230    fn generation_overflow_handling() {
1231        let mut pool = GraphemePool::new();
1232        // Intern one item to occupy slot 0
1233        let id = pool.intern("initial", 0);
1234        pool.release(id); // refcount 0, slot 0 is free
1235
1236        // Cycle slot 0 2048 times to push generation to 2048 (if not masked).
1237        // MAX_GENERATION is 2047.
1238        for i in 0..=GraphemeId::MAX_GENERATION {
1239            let s = format!("g{}", i);
1240            let id = pool.intern(&s, 0);
1241            assert_eq!(id.slot(), 0);
1242            pool.release(id);
1243        }
1244
1245        // Intern one more time.
1246        // If bug exists: generation=2048 sets bit 27.
1247        // width=0.
1248        // Result: bit 27 set -> width becomes 1.
1249        let id_overflow = pool.intern("overflow", 0);
1250
1251        // Should retrieve correctly
1252        assert_eq!(pool.get(id_overflow), Some("overflow"));
1253
1254        // Width should remain 0
1255        assert_eq!(id_overflow.width(), 0);
1256    }
1257}