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