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    ///
282    /// Outstanding [`GraphemeId`]s are invalidated: every slot's generation
283    /// is bumped, so an ID held across `clear()` resolves to `None` exactly
284    /// like a freed slot. (Truncating the generation table instead would let
285    /// a pre-clear ID alias whatever is interned into the same slot next โ€”
286    /// silent visual corruption.)
287    pub fn clear(&mut self) {
288        self.lookup.clear();
289        self.free_list.clear();
290        self.slots.fill(None);
291        for generation in &mut self.generations {
292            *generation = generation.wrapping_add(1) & GraphemeId::MAX_GENERATION;
293        }
294        // Descending push so reuse allocates from slot 0 upward, matching a
295        // fresh pool's allocation order.
296        self.free_list.extend((0..self.slots.len() as u32).rev());
297    }
298
299    /// Allocate a slot index, reusing from free list if possible.
300    fn alloc_slot(&mut self) -> u32 {
301        if let Some(idx) = self.free_list.pop() {
302            idx
303        } else {
304            let idx = self.slots.len() as u32;
305            assert!(
306                idx <= GraphemeId::MAX_SLOT,
307                "grapheme pool capacity exceeded"
308            );
309            idx
310        }
311    }
312
313    /// Garbage collect graphemes not referenced by the given buffers.
314    ///
315    /// This implements a Mark-and-Sweep algorithm:
316    /// 1. Reset all internal refcounts to 0.
317    /// 2. Scan provided buffers and increment refcounts for referenced graphemes.
318    /// 3. Free any slots that remain with refcount 0.
319    ///
320    /// This should be called periodically (e.g. every N frames) passing the
321    /// current front and back buffers to prevent memory leaks in long-running apps.
322    pub fn gc(&mut self, buffers: &[&Buffer]) {
323        // 1. Reset
324        for slot in self.slots.iter_mut().flatten() {
325            slot.refcount = 0;
326        }
327
328        // 2. Mark
329        for buf in buffers {
330            for cell in buf.cells() {
331                if let Some(id) = cell.content.grapheme_id() {
332                    // We access via slot index directly.
333                    // Note: id.slot() returns usize.
334                    let slot_idx = id.slot();
335                    if let Some(Some(slot)) = self.slots.get_mut(slot_idx) {
336                        // Only mark if the generation matches, otherwise it's a stale reference
337                        if let Some(&slot_gen) = self.generations.get(slot_idx)
338                            && slot_gen == id.generation()
339                        {
340                            slot.refcount = slot.refcount.saturating_add(1);
341                        }
342                    }
343                }
344            }
345        }
346
347        // 3. Sweep
348        // We collect keys to remove to avoid borrow conflicts with self.lookup
349        let mut keys_to_remove = Vec::new();
350
351        for (idx, slot_opt) in self.slots.iter_mut().enumerate() {
352            // Check refcount without holding a mutable borrow for too long
353            let should_free = slot_opt.as_ref().is_some_and(|s| s.refcount == 0);
354
355            if should_free {
356                // Take the slot to own the string (no clone needed)
357                if let Some(dead_slot) = slot_opt.take() {
358                    keys_to_remove.push(dead_slot.text);
359                    // Mask like intern's reuse path so stored generations
360                    // stay within the ID-encodable range.
361                    self.generations[idx] =
362                        self.generations[idx].wrapping_add(1) & GraphemeId::MAX_GENERATION;
363                    self.free_list.push(idx as u32);
364                }
365            }
366        }
367
368        for text in keys_to_remove {
369            self.lookup.remove(&text);
370        }
371    }
372}
373
374impl Default for GraphemePool {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn intern_and_get() {
386        let mut pool = GraphemePool::new();
387        let id = pool.intern("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", 2);
388
389        assert_eq!(pool.get(id), Some("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"));
390        assert_eq!(id.width(), 2);
391    }
392
393    #[test]
394    fn deduplication() {
395        let mut pool = GraphemePool::new();
396        let id1 = pool.intern("๐ŸŽ‰", 2);
397        let id2 = pool.intern("๐ŸŽ‰", 2);
398
399        // Same ID returned
400        assert_eq!(id1, id2);
401        // Refcount is 2
402        assert_eq!(pool.refcount(id1), 2);
403        // Only one slot used
404        assert_eq!(pool.len(), 1);
405    }
406
407    #[test]
408    fn retain_and_release() {
409        let mut pool = GraphemePool::new();
410        let id = pool.intern("๐Ÿš€", 2);
411        assert_eq!(pool.refcount(id), 1);
412
413        pool.retain(id);
414        assert_eq!(pool.refcount(id), 2);
415
416        pool.release(id);
417        assert_eq!(pool.refcount(id), 1);
418
419        pool.release(id);
420        // Slot is now freed
421        assert_eq!(pool.get(id), None);
422        assert_eq!(pool.len(), 0);
423    }
424
425    #[test]
426    fn slot_reuse() {
427        let mut pool = GraphemePool::new();
428
429        // Intern and release
430        let id1 = pool.intern("A", 1);
431        pool.release(id1);
432        assert_eq!(pool.len(), 0);
433
434        // Intern again - should reuse the slot
435        let id2 = pool.intern("B", 1);
436        assert_eq!(id1.slot(), id2.slot());
437        assert_eq!(pool.get(id2), Some("B"));
438    }
439
440    #[test]
441    fn empty_pool() {
442        let pool = GraphemePool::new();
443        assert!(pool.is_empty());
444        assert_eq!(pool.len(), 0);
445    }
446
447    #[test]
448    fn multiple_graphemes() {
449        let mut pool = GraphemePool::new();
450
451        let id1 = pool.intern("๐Ÿ‘จโ€๐Ÿ’ป", 2);
452        let id2 = pool.intern("๐Ÿ‘ฉโ€๐Ÿ”ฌ", 2);
453        let id3 = pool.intern("๐Ÿง‘๐Ÿฝโ€๐Ÿš€", 2);
454
455        assert_eq!(pool.len(), 3);
456        assert_ne!(id1, id2);
457        assert_ne!(id2, id3);
458
459        assert_eq!(pool.get(id1), Some("๐Ÿ‘จโ€๐Ÿ’ป"));
460        assert_eq!(pool.get(id2), Some("๐Ÿ‘ฉโ€๐Ÿ”ฌ"));
461        assert_eq!(pool.get(id3), Some("๐Ÿง‘๐Ÿฝโ€๐Ÿš€"));
462    }
463
464    #[test]
465    fn width_preserved() {
466        let mut pool = GraphemePool::new();
467
468        // Various widths
469        let id1 = pool.intern("๐Ÿ‘‹", 2);
470        let id2 = pool.intern("A", 1);
471        let id3 = pool.intern("ๆ—ฅ", 2);
472
473        assert_eq!(id1.width(), 2);
474        assert_eq!(id2.width(), 1);
475        assert_eq!(id3.width(), 2);
476    }
477
478    #[test]
479    fn clear_pool() {
480        let mut pool = GraphemePool::new();
481        pool.intern("A", 1);
482        pool.intern("B", 1);
483        pool.intern("C", 1);
484
485        assert_eq!(pool.len(), 3);
486
487        pool.clear();
488        assert!(pool.is_empty());
489    }
490
491    #[test]
492    fn invalid_id_returns_none() {
493        let pool = GraphemePool::new();
494        let fake_id = GraphemeId::new(999, 0, 1);
495        assert_eq!(pool.get(fake_id), None);
496    }
497
498    #[test]
499    fn release_invalid_id_is_safe() {
500        let mut pool = GraphemePool::new();
501        let fake_id = GraphemeId::new(999, 0, 1);
502        pool.release(fake_id); // Should not panic
503    }
504
505    #[test]
506    fn retain_invalid_id_is_safe() {
507        let mut pool = GraphemePool::new();
508        let fake_id = GraphemeId::new(999, 0, 1);
509        pool.retain(fake_id); // Should not panic
510    }
511
512    #[test]
513    fn stale_generation_returns_none() {
514        let mut pool = GraphemePool::new();
515        let id1 = pool.intern("A", 1);
516        pool.release(id1);
517
518        // Reallocate slot with new generation
519        let id2 = pool.intern("B", 1);
520        assert_eq!(id1.slot(), id2.slot());
521        assert_ne!(id1.generation(), id2.generation());
522
523        // Old ID should be invalid
524        assert_eq!(pool.get(id1), None);
525        assert_eq!(pool.get(id2), Some("B"));
526    }
527
528    #[test]
529    #[should_panic(expected = "width overflow")]
530    fn width_overflow_panics() {
531        let mut pool = GraphemePool::new();
532        pool.intern("X", GraphemeId::MAX_WIDTH + 1);
533    }
534
535    #[test]
536    fn with_capacity() {
537        let pool = GraphemePool::with_capacity(100);
538        assert!(pool.capacity() >= 100);
539        assert!(pool.is_empty());
540    }
541
542    mod gc_tests {
543        use super::*;
544        use crate::buffer::Buffer;
545        use crate::cell::{Cell, CellContent};
546
547        /// Helper: create a buffer with a grapheme cell at (0,0).
548        fn buf_with_grapheme(id: GraphemeId) -> Buffer {
549            let mut buf = Buffer::new(4, 1);
550            let content = CellContent::from_grapheme(id);
551            buf.set(0, 0, Cell::new(content));
552            buf
553        }
554
555        #[test]
556        fn gc_retains_referenced_grapheme() {
557            let mut pool = GraphemePool::new();
558            let id = pool.intern("๐Ÿš€", 2);
559
560            let buf = buf_with_grapheme(id);
561            pool.gc(&[&buf]);
562
563            assert_eq!(pool.get(id), Some("๐Ÿš€"));
564            assert_eq!(pool.refcount(id), 1);
565        }
566
567        #[test]
568        fn gc_frees_unreferenced_grapheme() {
569            let mut pool = GraphemePool::new();
570            let id = pool.intern("๐Ÿš€", 2);
571
572            // Empty buffer โ€” no references
573            let buf = Buffer::new(4, 1);
574            pool.gc(&[&buf]);
575
576            assert_eq!(pool.get(id), None);
577            assert_eq!(pool.refcount(id), 0);
578            assert!(pool.is_empty());
579        }
580
581        #[test]
582        fn gc_with_multiple_buffers() {
583            let mut pool = GraphemePool::new();
584            let id1 = pool.intern("๐ŸŽ‰", 2);
585            let id2 = pool.intern("๐Ÿงช", 2);
586            let id3 = pool.intern("๐Ÿ”ฅ", 2);
587
588            // buf1 references id1, buf2 references id3
589            let buf1 = buf_with_grapheme(id1);
590            let buf2 = buf_with_grapheme(id3);
591
592            pool.gc(&[&buf1, &buf2]);
593
594            assert_eq!(pool.get(id1), Some("๐ŸŽ‰"));
595            assert_eq!(pool.get(id2), None); // freed
596            assert_eq!(pool.get(id3), Some("๐Ÿ”ฅ"));
597            assert_eq!(pool.len(), 2);
598        }
599
600        #[test]
601        fn gc_with_multiple_references_in_buffer() {
602            let mut pool = GraphemePool::new();
603            let id = pool.intern("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", 2);
604
605            // Buffer with the same grapheme in two cells
606            let mut buf = Buffer::new(4, 1);
607            let content = CellContent::from_grapheme(id);
608            buf.set(0, 0, Cell::new(content));
609            buf.set(2, 0, Cell::new(content));
610
611            pool.gc(&[&buf]);
612
613            assert_eq!(pool.get(id), Some("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"));
614            assert_eq!(pool.refcount(id), 2);
615        }
616
617        #[test]
618        fn gc_with_empty_pool() {
619            let mut pool = GraphemePool::new();
620            let buf = Buffer::new(4, 1);
621            pool.gc(&[&buf]); // should not panic
622            assert!(pool.is_empty());
623        }
624
625        #[test]
626        fn gc_with_no_buffers() {
627            let mut pool = GraphemePool::new();
628            let id = pool.intern("test", 1);
629            pool.gc(&[]);
630            // No buffers means no references โ€” everything freed
631            assert_eq!(pool.get(id), None);
632            assert!(pool.is_empty());
633        }
634
635        #[test]
636        fn gc_freed_slots_are_reusable() {
637            let mut pool = GraphemePool::new();
638            let id1 = pool.intern("A", 1);
639            let _id2 = pool.intern("B", 1);
640            let slot1 = id1.slot();
641
642            // Keep only id1
643            let buf = buf_with_grapheme(id1);
644            pool.gc(&[&buf]);
645
646            // B was freed, its slot should be reusable
647            let id3 = pool.intern("C", 1);
648            // The freed slot from B should be reused (it was at slot index 1)
649            assert_eq!(pool.get(id3), Some("C"));
650            assert_eq!(pool.len(), 2); // A and C
651
652            // id1 should still work
653            assert_eq!(pool.get(id1), Some("A"));
654            assert_eq!(id1.slot(), slot1);
655        }
656
657        #[test]
658        fn gc_resets_refcounts_accurately() {
659            let mut pool = GraphemePool::new();
660            let id = pool.intern("๐Ÿš€", 2);
661
662            // Artificially inflate refcount
663            pool.retain(id);
664            pool.retain(id);
665            assert_eq!(pool.refcount(id), 3);
666
667            // Buffer has one reference
668            let buf = buf_with_grapheme(id);
669            pool.gc(&[&buf]);
670
671            // GC resets then counts actual references
672            assert_eq!(pool.refcount(id), 1);
673        }
674
675        #[test]
676        fn gc_lookup_table_stays_consistent() {
677            let mut pool = GraphemePool::new();
678            let _id1 = pool.intern("A", 1);
679            let id2 = pool.intern("B", 1);
680
681            // Keep only B
682            let buf = buf_with_grapheme(id2);
683            pool.gc(&[&buf]);
684
685            // A was freed from lookup, so interning A again should work
686            let id_new = pool.intern("A", 1);
687            assert_eq!(pool.get(id_new), Some("A"));
688
689            // B should still be deduped
690            let id_b2 = pool.intern("B", 1);
691            assert_eq!(id_b2, id2);
692        }
693    }
694
695    mod property {
696        use super::*;
697        use proptest::prelude::*;
698
699        /// Generate a non-empty string suitable for interning.
700        fn arb_grapheme() -> impl Strategy<Value = String> {
701            prop::string::string_regex(".{1,8}")
702                .unwrap()
703                .prop_filter("non-empty", |s| !s.is_empty())
704        }
705
706        /// Generate a valid width (0..=GraphemeId::MAX_WIDTH).
707        fn arb_width() -> impl Strategy<Value = u8> {
708            0u8..=GraphemeId::MAX_WIDTH
709        }
710
711        proptest! {
712            #![proptest_config(ProptestConfig::with_cases(256))]
713
714            /// Intern followed by get always returns the original string.
715            #[test]
716            fn intern_get_roundtrip(s in arb_grapheme(), w in arb_width()) {
717                let mut pool = GraphemePool::new();
718                let id = pool.intern(&s, w);
719                prop_assert_eq!(pool.get(id), Some(s.as_str()));
720            }
721
722            /// Width is preserved through intern.
723            #[test]
724            fn intern_preserves_width(s in arb_grapheme(), w in arb_width()) {
725                let mut pool = GraphemePool::new();
726                let id = pool.intern(&s, w);
727                prop_assert_eq!(id.width(), w as usize);
728            }
729
730            /// Interning the same string twice returns the same id.
731            #[test]
732            fn deduplication_same_id(s in arb_grapheme(), w in arb_width()) {
733                let mut pool = GraphemePool::new();
734                let id1 = pool.intern(&s, w);
735                let id2 = pool.intern(&s, w);
736                prop_assert_eq!(id1, id2);
737                prop_assert_eq!(pool.len(), 1);
738            }
739
740            /// After N interns of the same string, refcount equals N.
741            #[test]
742            fn deduplication_refcount(s in arb_grapheme(), w in arb_width(), extra in 0u32..10) {
743                let mut pool = GraphemePool::new();
744                let id = pool.intern(&s, w);
745                for _ in 0..extra {
746                    pool.intern(&s, w);
747                }
748                prop_assert_eq!(pool.refcount(id), 1 + extra);
749            }
750
751            /// Retain increments refcount, release decrements it.
752            #[test]
753            fn retain_release_refcount(
754                s in arb_grapheme(),
755                w in arb_width(),
756                retains in 0u32..10,
757                releases in 0u32..10
758            ) {
759                let mut pool = GraphemePool::new();
760                let id = pool.intern(&s, w);
761                // Start at refcount 1
762                for _ in 0..retains {
763                    pool.retain(id);
764                }
765                let expected_after_retain = 1 + retains;
766                prop_assert_eq!(pool.refcount(id), expected_after_retain);
767
768                let actual_releases = releases.min(expected_after_retain - 1);
769                for _ in 0..actual_releases {
770                    pool.release(id);
771                }
772                prop_assert_eq!(pool.refcount(id), expected_after_retain - actual_releases);
773                // Entry should still be alive
774                prop_assert_eq!(pool.get(id), Some(s.as_str()));
775            }
776
777            /// Releasing all references frees the slot.
778            #[test]
779            fn release_to_zero_frees(s in arb_grapheme(), w in arb_width(), extra in 0u32..5) {
780                let mut pool = GraphemePool::new();
781                let id = pool.intern(&s, w);
782                for _ in 0..extra {
783                    pool.retain(id);
784                }
785                // Release all: 1 (initial) + extra (retains)
786                for _ in 0..=extra {
787                    pool.release(id);
788                }
789                prop_assert_eq!(pool.get(id), None);
790                prop_assert_eq!(pool.refcount(id), 0);
791                prop_assert!(pool.is_empty());
792            }
793
794            /// Freed slots are reused by subsequent interns.
795            #[test]
796            fn slot_reuse_after_free(
797                s1 in arb_grapheme(),
798                s2 in arb_grapheme(),
799                w in arb_width()
800            ) {
801                let mut pool = GraphemePool::new();
802                let id1 = pool.intern(&s1, w);
803                let slot1 = id1.slot();
804                pool.release(id1);
805
806                // s2 should reuse slot1's index
807                let id2 = pool.intern(&s2, w);
808                prop_assert_eq!(id2.slot(), slot1);
809                prop_assert_eq!(pool.get(id2), Some(s2.as_str()));
810            }
811
812            /// len() tracks active entries correctly across operations.
813            #[test]
814            fn len_invariant(count in 1usize..20) {
815                let mut pool = GraphemePool::new();
816                let mut ids = Vec::new();
817                for i in 0..count {
818                    let s = format!("g{i}");
819                    ids.push(pool.intern(&s, 1));
820                }
821                prop_assert_eq!(pool.len(), count);
822
823                // Release half
824                let release_count = count / 2;
825                for id in &ids[..release_count] {
826                    pool.release(*id);
827                }
828                prop_assert_eq!(pool.len(), count - release_count);
829            }
830
831            /// Multiple distinct strings produce distinct ids.
832            #[test]
833            fn distinct_strings_distinct_ids(count in 2usize..15) {
834                let mut pool = GraphemePool::new();
835                let mut ids = Vec::new();
836                for i in 0..count {
837                    let s = format!("unique_{i}");
838                    ids.push(pool.intern(&s, 1));
839                }
840                // All ids should be distinct
841                for i in 0..ids.len() {
842                    for j in (i + 1)..ids.len() {
843                        prop_assert_ne!(ids[i], ids[j]);
844                    }
845                }
846            }
847
848            /// Clear resets the pool entirely regardless of contents.
849            #[test]
850            fn clear_resets_all(count in 1usize..20) {
851                let mut pool = GraphemePool::new();
852                let mut ids = Vec::new();
853                for i in 0..count {
854                    let s = format!("c{i}");
855                    ids.push(pool.intern(&s, 1));
856                }
857                pool.clear();
858                prop_assert!(pool.is_empty());
859                prop_assert_eq!(pool.len(), 0);
860                for id in &ids {
861                    prop_assert_eq!(pool.get(*id), None);
862                }
863            }
864
865            // --- Executable Invariant Tests (bd-10i.13.2) ---
866
867            /// Invariant: refcount > 0 implies get() returns Some (slot is valid).
868            #[test]
869            fn positive_refcount_implies_valid_slot(
870                count in 1usize..10,
871                retains in proptest::collection::vec(0u32..5, 1..10),
872            ) {
873                let mut pool = GraphemePool::new();
874                let mut ids = Vec::new();
875                for i in 0..count {
876                    let s = format!("inv_{i}");
877                    ids.push(pool.intern(&s, 1));
878                }
879
880                // Apply random retains
881                for (i, &extra) in retains.iter().enumerate() {
882                    let id = ids[i % count];
883                    for _ in 0..extra {
884                        pool.retain(id);
885                    }
886                }
887
888                // Invariant check: every id with refcount > 0 must be gettable
889                for (i, &id) in ids.iter().enumerate() {
890                    let rc = pool.refcount(id);
891                    if rc > 0 {
892                        prop_assert!(pool.get(id).is_some(),
893                            "slot {} has refcount {} but get() returned None", i, rc);
894                    }
895                }
896            }
897
898            /// Invariant: each release() decrements refcount by exactly 1.
899            #[test]
900            fn release_decrements_by_one(s in arb_grapheme(), w in arb_width(), retains in 1u32..8) {
901                let mut pool = GraphemePool::new();
902                let id = pool.intern(&s, w);
903                for _ in 0..retains {
904                    pool.retain(id);
905                }
906                let rc_before = pool.refcount(id);
907                pool.release(id);
908                let rc_after = pool.refcount(id);
909                prop_assert_eq!(rc_after, rc_before - 1,
910                    "release should decrement refcount by exactly 1");
911            }
912
913            /// Invariant: releasing a freed slot does not corrupt pool state.
914            #[test]
915            fn over_release_does_not_corrupt(count in 1usize..5) {
916                let mut pool = GraphemePool::new();
917                let mut ids = Vec::new();
918                for i in 0..count {
919                    let s = format!("or_{i}");
920                    ids.push(pool.intern(&s, 1));
921                }
922
923                // Free the first entry
924                let victim = ids[0];
925                pool.release(victim);
926                prop_assert_eq!(pool.refcount(victim), 0);
927                prop_assert_eq!(pool.get(victim), None);
928
929                // Double-release should be safe (saturating)
930                pool.release(victim);
931                prop_assert_eq!(pool.refcount(victim), 0);
932
933                // Other entries must be unaffected
934                for &id in &ids[1..] {
935                    prop_assert!(pool.get(id).is_some(),
936                        "over-release corrupted unrelated slot");
937                    prop_assert!(pool.refcount(id) > 0);
938                }
939            }
940
941            /// Invariant: GraphemeId from one pool is not valid in a different pool.
942            #[test]
943            fn cross_pool_id_is_invalid(s in arb_grapheme(), w in arb_width()) {
944                let mut pool_a = GraphemePool::new();
945                let pool_b = GraphemePool::new();
946                let id = pool_a.intern(&s, w);
947
948                // id from pool_a should not resolve in empty pool_b
949                prop_assert_eq!(pool_b.get(id), None,
950                    "GraphemeId from pool A should not be valid in pool B");
951            }
952        }
953    }
954
955    // --- Edge-case tests ---
956
957    #[test]
958    fn pool_debug_and_clone() {
959        let mut pool = GraphemePool::new();
960        pool.intern("๐Ÿš€", 2);
961        let dbg = format!("{:?}", pool);
962        assert!(dbg.contains("GraphemePool"), "Debug: {dbg}");
963        let cloned = pool.clone();
964        assert_eq!(cloned.len(), 1);
965        // Cloned pool is independent
966        let id = cloned.lookup.values().next().copied().unwrap();
967        assert_eq!(cloned.get(id), Some("๐Ÿš€"));
968    }
969
970    #[test]
971    fn pool_default_is_new() {
972        let pool = GraphemePool::default();
973        assert!(pool.is_empty());
974        assert_eq!(pool.len(), 0);
975    }
976
977    #[test]
978    fn intern_width_zero() {
979        let mut pool = GraphemePool::new();
980        let id = pool.intern("zero-width", 0);
981        assert_eq!(id.width(), 0);
982        assert_eq!(pool.get(id), Some("zero-width"));
983    }
984
985    #[test]
986    fn intern_width_max() {
987        let mut pool = GraphemePool::new();
988        let id = pool.intern("max-width", GraphemeId::MAX_WIDTH);
989        assert_eq!(id.width(), GraphemeId::MAX_WIDTH as usize);
990        assert_eq!(pool.get(id), Some("max-width"));
991    }
992
993    #[test]
994    fn intern_empty_string() {
995        let mut pool = GraphemePool::new();
996        let id = pool.intern("", 0);
997        assert_eq!(pool.get(id), Some(""));
998    }
999
1000    #[test]
1001    fn intern_long_string() {
1002        let mut pool = GraphemePool::new();
1003        let long = "a".repeat(1000);
1004        let id = pool.intern(&long, 1);
1005        assert_eq!(pool.get(id), Some(long.as_str()));
1006    }
1007
1008    #[test]
1009    fn clear_then_intern_reuses_from_scratch() {
1010        let mut pool = GraphemePool::new();
1011        pool.intern("A", 1);
1012        pool.intern("B", 1);
1013        pool.clear();
1014        assert!(pool.is_empty());
1015        // After clear, allocation starts from slot 0 like a fresh pool.
1016        let id = pool.intern("C", 1);
1017        assert_eq!(id.slot(), 0);
1018        assert_eq!(pool.get(id), Some("C"));
1019        assert_eq!(pool.len(), 1);
1020    }
1021
1022    #[test]
1023    fn id_held_across_clear_never_aliases_new_entry() {
1024        // Regression: clear() used to truncate the generation table, so a
1025        // pre-clear ID (slot 0, gen 0) matched the fresh slot-0/gen-0 entry
1026        // interned afterwards and resolved to the WRONG grapheme.
1027        let mut pool = GraphemePool::new();
1028        let old_id = pool.intern("A", 1);
1029        pool.clear();
1030
1031        let new_id = pool.intern("B", 1);
1032        assert_eq!(old_id.slot(), new_id.slot(), "same slot reused");
1033        assert_ne!(old_id.generation(), new_id.generation());
1034        assert_eq!(pool.get(old_id), None, "stale ID must not resolve");
1035        assert_eq!(pool.get(new_id), Some("B"));
1036
1037        // Stale IDs are also inert for refcount mutation.
1038        pool.retain(old_id);
1039        pool.release(old_id);
1040        assert_eq!(pool.refcount(new_id), 1);
1041    }
1042
1043    #[test]
1044    fn with_capacity_then_intern() {
1045        let mut pool = GraphemePool::with_capacity(50);
1046        for i in 0..50 {
1047            pool.intern(&format!("g{i}"), 1);
1048        }
1049        assert_eq!(pool.len(), 50);
1050    }
1051
1052    #[test]
1053    fn refcount_of_freed_slot_is_zero() {
1054        let mut pool = GraphemePool::new();
1055        let id = pool.intern("temp", 1);
1056        pool.release(id);
1057        assert_eq!(pool.refcount(id), 0);
1058    }
1059
1060    #[test]
1061    fn refcount_of_invalid_id_is_zero() {
1062        let pool = GraphemePool::new();
1063        assert_eq!(pool.refcount(GraphemeId::new(0, 0, 1)), 0);
1064        assert_eq!(pool.refcount(GraphemeId::new(999, 0, 1)), 0);
1065    }
1066
1067    #[test]
1068    fn retain_freed_slot_is_noop() {
1069        let mut pool = GraphemePool::new();
1070        let id = pool.intern("temp", 1);
1071        pool.release(id);
1072        // Slot is now freed (None)
1073        pool.retain(id); // Should not panic, noop
1074        assert_eq!(pool.refcount(id), 0);
1075        assert_eq!(pool.get(id), None);
1076    }
1077
1078    #[test]
1079    fn double_release_is_safe() {
1080        let mut pool = GraphemePool::new();
1081        let id = pool.intern("temp", 1);
1082        pool.release(id); // refcount 0, freed
1083        pool.release(id); // noop on None slot
1084        assert_eq!(pool.refcount(id), 0);
1085    }
1086
1087    #[test]
1088    fn multiple_slot_reuse_cycles() {
1089        let mut pool = GraphemePool::new();
1090        for cycle in 0..5 {
1091            let id = pool.intern(&format!("cycle{cycle}"), 1);
1092            assert_eq!(id.slot(), 0); // Always reuses slot 0
1093            assert_eq!(pool.get(id), Some(format!("cycle{cycle}").as_str()));
1094            pool.release(id);
1095        }
1096        assert!(pool.is_empty());
1097    }
1098
1099    #[test]
1100    fn free_list_ordering() {
1101        let mut pool = GraphemePool::new();
1102        let id0 = pool.intern("A", 1);
1103        let id1 = pool.intern("B", 1);
1104        let id2 = pool.intern("C", 1);
1105        assert_eq!(id0.slot(), 0);
1106        assert_eq!(id1.slot(), 1);
1107        assert_eq!(id2.slot(), 2);
1108
1109        // Release in order: 0, 2 (skip 1)
1110        pool.release(id0);
1111        pool.release(id2);
1112        assert_eq!(pool.len(), 1); // Only B remains
1113
1114        // Free list is LIFO: next alloc gets slot 2 (last freed), then slot 0
1115        let new1 = pool.intern("D", 1);
1116        assert_eq!(new1.slot(), 2);
1117        let new2 = pool.intern("E", 1);
1118        assert_eq!(new2.slot(), 0);
1119    }
1120
1121    #[test]
1122    fn intern_after_release_deduplicates_correctly() {
1123        let mut pool = GraphemePool::new();
1124        let id1 = pool.intern("X", 1);
1125        pool.release(id1);
1126        // "X" is now freed from both slot and lookup
1127        assert_eq!(pool.get(id1), None);
1128
1129        // Interning "X" again should work (creates new slot)
1130        let id2 = pool.intern("X", 1);
1131        assert_eq!(pool.get(id2), Some("X"));
1132        assert_eq!(pool.refcount(id2), 1);
1133    }
1134
1135    #[test]
1136    fn clone_independence() {
1137        let mut pool = GraphemePool::new();
1138        let id = pool.intern("shared", 1);
1139
1140        let mut cloned = pool.clone();
1141        // Modify original
1142        pool.release(id);
1143        assert_eq!(pool.get(id), None);
1144
1145        // Clone should be unaffected
1146        assert_eq!(cloned.get(id), Some("shared"));
1147        assert_eq!(cloned.refcount(id), 1);
1148
1149        // Modify clone
1150        cloned.retain(id);
1151        assert_eq!(cloned.refcount(id), 2);
1152        // Original still freed
1153        assert_eq!(pool.refcount(id), 0);
1154    }
1155
1156    #[test]
1157    fn gc_double_run_idempotent() {
1158        use crate::buffer::Buffer;
1159        use crate::cell::{Cell, CellContent};
1160
1161        let mut pool = GraphemePool::new();
1162        let id = pool.intern("keep", 1);
1163        let _id2 = pool.intern("drop", 1);
1164
1165        let mut buf = Buffer::new(4, 1);
1166        buf.set(0, 0, Cell::new(CellContent::from_grapheme(id)));
1167
1168        pool.gc(&[&buf]);
1169        assert_eq!(pool.len(), 1);
1170        assert_eq!(pool.get(id), Some("keep"));
1171
1172        // Second GC with same buffer should be idempotent
1173        pool.gc(&[&buf]);
1174        assert_eq!(pool.len(), 1);
1175        assert_eq!(pool.refcount(id), 1);
1176    }
1177
1178    #[test]
1179    fn gc_with_already_freed_slots() {
1180        use crate::buffer::Buffer;
1181
1182        let mut pool = GraphemePool::new();
1183        let id1 = pool.intern("A", 1);
1184        let id2 = pool.intern("B", 1);
1185
1186        // Manually free id1 before GC
1187        pool.release(id1);
1188        assert_eq!(pool.len(), 1);
1189
1190        // GC with empty buffer โ€” should free id2 as well
1191        let buf = Buffer::new(4, 1);
1192        pool.gc(&[&buf]);
1193
1194        assert!(pool.is_empty());
1195        assert_eq!(pool.get(id2), None);
1196    }
1197
1198    #[test]
1199    fn stress_100_graphemes() {
1200        let mut pool = GraphemePool::new();
1201        let mut ids = Vec::new();
1202        for i in 0..100 {
1203            ids.push(pool.intern(&format!("g{i:03}"), 1));
1204        }
1205        assert_eq!(pool.len(), 100);
1206
1207        // All accessible
1208        for (i, &id) in ids.iter().enumerate() {
1209            assert_eq!(pool.get(id), Some(format!("g{i:03}").as_str()));
1210        }
1211
1212        // Release even-indexed
1213        for i in (0..100).step_by(2) {
1214            pool.release(ids[i]);
1215        }
1216        assert_eq!(pool.len(), 50);
1217
1218        // Odd-indexed still valid
1219        for i in (1..100).step_by(2) {
1220            assert_eq!(pool.get(ids[i]), Some(format!("g{i:03}").as_str()));
1221        }
1222    }
1223
1224    #[test]
1225    fn capacity_grows_with_interns() {
1226        let mut pool = GraphemePool::new();
1227        let cap_before = pool.capacity();
1228        for i in 0..20 {
1229            pool.intern(&format!("grow{i}"), 1);
1230        }
1231        // Capacity should have grown
1232        assert!(pool.capacity() >= 20);
1233        assert!(pool.capacity() >= cap_before);
1234    }
1235
1236    #[test]
1237    fn len_after_mixed_operations() {
1238        let mut pool = GraphemePool::new();
1239        assert_eq!(pool.len(), 0);
1240
1241        let a = pool.intern("A", 1);
1242        assert_eq!(pool.len(), 1);
1243
1244        let b = pool.intern("B", 1);
1245        assert_eq!(pool.len(), 2);
1246
1247        // Dedup: same string doesn't increase len
1248        pool.intern("A", 1);
1249        assert_eq!(pool.len(), 2);
1250
1251        pool.release(a);
1252        // A still has refcount 1 (was retained by dedup intern)
1253        assert_eq!(pool.len(), 2);
1254
1255        pool.release(a);
1256        // Now A is freed
1257        assert_eq!(pool.len(), 1);
1258
1259        pool.release(b);
1260        assert_eq!(pool.len(), 0);
1261        assert!(pool.is_empty());
1262    }
1263
1264    #[test]
1265    fn generation_overflow_handling() {
1266        let mut pool = GraphemePool::new();
1267        // Intern one item to occupy slot 0
1268        let id = pool.intern("initial", 0);
1269        pool.release(id); // refcount 0, slot 0 is free
1270
1271        // Cycle slot 0 2048 times to push generation to 2048 (if not masked).
1272        // MAX_GENERATION is 2047.
1273        for i in 0..=GraphemeId::MAX_GENERATION {
1274            let s = format!("g{}", i);
1275            let id = pool.intern(&s, 0);
1276            assert_eq!(id.slot(), 0);
1277            pool.release(id);
1278        }
1279
1280        // Intern one more time.
1281        // If bug exists: generation=2048 sets bit 27.
1282        // width=0.
1283        // Result: bit 27 set -> width becomes 1.
1284        let id_overflow = pool.intern("overflow", 0);
1285
1286        // Should retrieve correctly
1287        assert_eq!(pool.get(id_overflow), Some("overflow"));
1288
1289        // Width should remain 0
1290        assert_eq!(id_overflow.width(), 0);
1291    }
1292}