Skip to main content

sqry_core/graph/unified/storage/
arena.rs

1//! `NodeArena`: Contiguous node storage with generational indices.
2//!
3//! This module implements `NodeArena`, the primary node storage for the
4//! unified graph architecture. It provides:
5//!
6//! - **O(1) access**: Direct indexing into contiguous storage
7//! - **Generational safety**: Stale IDs return `None` instead of wrong data
8//! - **Free list reuse**: Deleted slots are recycled efficiently
9//!
10//! # Design
11//!
12//! The arena uses a slot-based design:
13//! - Each slot is either occupied (with data) or vacant (with next-free pointer)
14//! - Occupied slots store `NodeEntry` with generation counter
15//! - Vacant slots form a singly-linked free list
16//! - Generation counter increments on each reuse to detect stale IDs
17
18use std::fmt;
19
20use serde::{Deserialize, Serialize};
21
22use super::super::file::id::FileId;
23use super::super::node::id::{GenerationOverflowError, NodeId};
24use super::super::node::kind::NodeKind;
25use super::super::string::id::StringId;
26use crate::graph::body_hash::BodyHash128;
27
28/// Error type for arena allocation operations.
29///
30/// This enum covers all possible failure modes during node allocation,
31/// providing graceful error handling instead of panics.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ArenaError {
34    /// Generation counter would overflow, risking stale ID collision.
35    GenerationOverflow(GenerationOverflowError),
36
37    /// Free list is corrupted: an occupied slot was found in the free list.
38    ///
39    /// This indicates a serious internal consistency error, likely caused by
40    /// concurrent modification without proper synchronization, memory corruption,
41    /// or a bug in the arena implementation.
42    FreeListCorruption {
43        /// Index of the corrupted slot.
44        index: u32,
45    },
46
47    /// Arena capacity exceeded: cannot allocate more than `u32::MAX` nodes.
48    CapacityExceeded,
49}
50
51impl fmt::Display for ArenaError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            ArenaError::GenerationOverflow(e) => write!(f, "{e}"),
55            ArenaError::FreeListCorruption { index } => {
56                write!(
57                    f,
58                    "free list corruption: occupied slot found at index {index}"
59                )
60            }
61            ArenaError::CapacityExceeded => {
62                write!(
63                    f,
64                    "arena capacity exceeded: cannot allocate more than {} nodes",
65                    u32::MAX
66                )
67            }
68        }
69    }
70}
71
72impl std::error::Error for ArenaError {
73    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74        match self {
75            ArenaError::GenerationOverflow(e) => Some(e),
76            _ => None,
77        }
78    }
79}
80
81impl From<GenerationOverflowError> for ArenaError {
82    fn from(e: GenerationOverflowError) -> Self {
83        ArenaError::GenerationOverflow(e)
84    }
85}
86
87/// State of a slot in the arena.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub enum SlotState<T> {
90    /// Slot contains an occupied entry.
91    Occupied(T),
92    /// Slot is vacant, contains next free index (or None if end of list).
93    Vacant {
94        /// Index of the next free slot in the free list, or None if end.
95        next_free: Option<u32>,
96    },
97}
98
99/// A slot in the arena with generation tracking.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Slot<T> {
102    /// Current generation for this slot.
103    generation: u64,
104    /// Slot state (occupied or vacant).
105    state: SlotState<T>,
106}
107
108impl<T> Slot<T> {
109    /// Creates a new vacant slot with the given generation and next free.
110    #[allow(dead_code)] // Used by Compaction (Step 15) + V13→V14 arena translation
111    pub(crate) fn new_vacant(generation: u64, next_free: Option<u32>) -> Self {
112        Self {
113            generation,
114            state: SlotState::Vacant { next_free },
115        }
116    }
117
118    /// Creates a new occupied slot with the given generation and data.
119    pub(crate) fn new_occupied(generation: u64, data: T) -> Self {
120        Self {
121            generation,
122            state: SlotState::Occupied(data),
123        }
124    }
125
126    /// Returns true if this slot is occupied.
127    #[inline]
128    pub fn is_occupied(&self) -> bool {
129        matches!(self.state, SlotState::Occupied(_))
130    }
131
132    /// Returns true if this slot is vacant.
133    #[inline]
134    pub fn is_vacant(&self) -> bool {
135        matches!(self.state, SlotState::Vacant { .. })
136    }
137
138    /// Returns the generation of this slot.
139    #[inline]
140    pub fn generation(&self) -> u64 {
141        self.generation
142    }
143
144    /// Returns a reference to the occupied data, if any.
145    pub fn get(&self) -> Option<&T> {
146        match &self.state {
147            SlotState::Occupied(data) => Some(data),
148            SlotState::Vacant { .. } => None,
149        }
150    }
151
152    /// Returns a reference to the slot state (occupied or vacant).
153    #[inline]
154    pub fn state(&self) -> &SlotState<T> {
155        &self.state
156    }
157
158    /// Returns a mutable reference to the occupied data, if any.
159    pub fn get_mut(&mut self) -> Option<&mut T> {
160        match &mut self.state {
161            SlotState::Occupied(data) => Some(data),
162            SlotState::Vacant { .. } => None,
163        }
164    }
165}
166
167/// A node entry stored in the arena.
168///
169/// This is the actual data stored for each node in the graph.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct NodeEntry {
172    /// The kind of code entity this node represents.
173    pub kind: NodeKind,
174    /// Interned name of the symbol.
175    pub name: StringId,
176    /// File containing this node.
177    pub file: FileId,
178    /// Start byte offset in the file.
179    pub start_byte: u32,
180    /// End byte offset in the file.
181    pub end_byte: u32,
182    /// Start line (1-indexed).
183    pub start_line: u32,
184    /// Start column (0-indexed).
185    pub start_column: u32,
186    /// End line (1-indexed).
187    pub end_line: u32,
188    /// End column (0-indexed).
189    pub end_column: u32,
190    /// Optional signature/type information.
191    pub signature: Option<StringId>,
192    /// Optional docstring.
193    pub doc: Option<StringId>,
194    /// Optional qualified name (e.g., module.Class.method).
195    pub qualified_name: Option<StringId>,
196    /// Optional visibility modifier (public, private, protected, etc.).
197    pub visibility: Option<StringId>,
198    /// Whether this is an async function/method.
199    pub is_async: bool,
200    /// Whether this is a static member.
201    pub is_static: bool,
202    /// 128-bit body hash for duplicate detection.
203    ///
204    /// Computed from the raw body bytes of hashable symbol kinds:
205    /// Function, Method, Class, Struct, Enum, Interface, Trait, Module.
206    ///
207    /// CRITICAL: This field MUST remain BEFORE `is_unsafe` for
208    /// postcard serialization compatibility with older snapshots.
209    /// Uses `#[serde(default)]` to read older V2 snapshots that lack this field.
210    /// DO NOT use `skip_serializing_if` - postcard is positional and skipping corrupts the stream.
211    #[serde(default)]
212    pub body_hash: Option<BodyHash128>,
213    /// Whether this is an unsafe function (Rust, C, etc.).
214    ///
215    /// For FFI functions, this indicates whether the function is declared with
216    /// `unsafe` modifier (Haskell), `unsafe` keyword (Rust), or similar safety
217    /// markers in other languages.
218    ///
219    /// CRITICAL: This field is placed AFTER `body_hash` to maintain postcard
220    /// serialization compatibility. Adding fields before `body_hash` would
221    /// corrupt existing snapshots due to positional deserialization.
222    /// Uses `#[serde(default)]` to read older snapshots that lack this field.
223    #[serde(default)]
224    pub is_unsafe: bool,
225}
226
227impl NodeEntry {
228    /// Creates a new node entry with minimal required fields.
229    #[must_use]
230    pub fn new(kind: NodeKind, name: StringId, file: FileId) -> Self {
231        Self {
232            kind,
233            name,
234            file,
235            start_byte: 0,
236            end_byte: 0,
237            start_line: 0,
238            start_column: 0,
239            end_line: 0,
240            end_column: 0,
241            signature: None,
242            doc: None,
243            qualified_name: None,
244            visibility: None,
245            is_async: false,
246            is_static: false,
247            is_unsafe: false,
248            body_hash: None,
249        }
250    }
251
252    /// Sets the byte range for this node.
253    #[must_use]
254    pub fn with_byte_range(mut self, start: u32, end: u32) -> Self {
255        self.start_byte = start;
256        self.end_byte = end;
257        self
258    }
259
260    /// Sets the line/column range for this node.
261    #[must_use]
262    pub fn with_location(
263        mut self,
264        start_line: u32,
265        start_column: u32,
266        end_line: u32,
267        end_column: u32,
268    ) -> Self {
269        self.start_line = start_line;
270        self.start_column = start_column;
271        self.end_line = end_line;
272        self.end_column = end_column;
273        self
274    }
275
276    /// Whether this arena entry is a Phase 4c-prime cross-file
277    /// unification loser (an "inert" merged duplicate).
278    ///
279    /// Phase 4c-prime merges cross-file stubs that share a qualified
280    /// name and a call-compatible kind into a single canonical winner,
281    /// folding metadata + flags into the winner and calling
282    /// [`crate::graph::unified::build::unification::merge_node_into`] on
283    /// the loser. The loser's arena slot stays `Occupied` (so
284    /// `NodeArena::slot_count()` stays stable for CSR row_ptr sizing),
285    /// but its `name` is cleared to [`StringId::INVALID`] and its
286    /// `qualified_name` to `None`. That sentinel is what this method
287    /// detects.
288    ///
289    /// Callers that iterate `NodeArena::iter()` for publish-visible
290    /// purposes (symbol listings, document-symbol enumeration,
291    /// find_by_pattern-style fanout) MUST skip entries where
292    /// `is_unified_loser()` returns `true`. Callers working on rebuild
293    /// internals (Gate 0d residue checks, CSR compaction) must continue
294    /// to include them.
295    #[inline]
296    #[must_use]
297    pub fn is_unified_loser(&self) -> bool {
298        self.name == StringId::INVALID
299    }
300
301    /// Whether this entry is a synthetic placeholder according to its name shape.
302    ///
303    /// C_SUPPRESS introduced an explicit `NodeFlags::SYNTHETIC` flag
304    /// stored in [`crate::graph::unified::storage::metadata::NodeMetadataStore`];
305    /// see
306    /// [`crate::graph::unified::storage::metadata::NodeMetadataStore::is_synthetic`]
307    /// for the authoritative O(1) lookup that publish-visible callers
308    /// (`find_by_pattern`, MCP tool surfaces, CLI `search --exact`) use.
309    ///
310    /// This helper is the **fallback name-shape check** that runs alongside
311    /// the metadata bit so the filter remains correct in three boundary cases:
312    ///
313    /// 1. **V10 snapshots written before the synthetic bit existed** —
314    ///    pre-C_SUPPRESS Go indexes have no metadata entries flagging
315    ///    these placeholders, but their names still follow the canonical
316    ///    `<...>` (angle-bracket-wrapped pseudo-identifier) and
317    ///    `<ident>@<digit>` (per-binding-site offset suffix) shapes the
318    ///    Go plugin emits via
319    ///    `process_field_access_unified` and
320    ///    `sqry-lang-go/src/relations/local_scopes.rs`.
321    /// 2. **Cross-file unification losers** that retained their original
322    ///    synthetic name but lost their metadata entry during the
323    ///    rebuild's metadata retention pass.
324    /// 3. **Future plugins** that emit similarly-shaped placeholders
325    ///    without remembering to flip the bit — the name shape is a
326    ///    structural invariant Go identifiers cannot satisfy
327    ///    (Go identifiers are `[a-zA-Z_][a-zA-Z0-9_]*`, so `<` and `@`
328    ///    can never appear in a real Go symbol name).
329    ///
330    /// Callers that want the canonical "is this node user-visible"
331    /// answer should OR this against the metadata-store check.
332    /// `find_by_pattern` and the MCP analysis surfaces wire both checks
333    /// together via a single helper so the filter stays in lockstep.
334    ///
335    /// `display_name` is the simple-name string the interner returns
336    /// for `NodeEntry.name`; passing the qualified name instead is
337    /// also valid because the synthetic shape appears in both
338    /// (the Go plugin builds the qualified name from the same shape).
339    #[inline]
340    #[must_use]
341    pub fn is_synthetic_placeholder_name(display_name: &str) -> bool {
342        is_synthetic_placeholder_name_shape(display_name)
343    }
344
345    /// Sets the signature for this node.
346    #[must_use]
347    pub fn with_signature(mut self, signature: StringId) -> Self {
348        self.signature = Some(signature);
349        self
350    }
351
352    /// Sets the docstring for this node.
353    #[must_use]
354    pub fn with_doc(mut self, doc: StringId) -> Self {
355        self.doc = Some(doc);
356        self
357    }
358
359    /// Sets the qualified name for this node.
360    #[must_use]
361    pub fn with_qualified_name(mut self, qualified_name: StringId) -> Self {
362        self.qualified_name = Some(qualified_name);
363        self
364    }
365
366    /// Sets the visibility modifier for this node.
367    #[must_use]
368    pub fn with_visibility(mut self, visibility: StringId) -> Self {
369        self.visibility = Some(visibility);
370        self
371    }
372
373    /// Sets the async flag for this node.
374    #[must_use]
375    pub fn with_async(mut self, is_async: bool) -> Self {
376        self.is_async = is_async;
377        self
378    }
379
380    /// Sets the static flag for this node.
381    #[must_use]
382    pub fn with_static(mut self, is_static: bool) -> Self {
383        self.is_static = is_static;
384        self
385    }
386
387    /// Sets the unsafe flag for this node.
388    #[must_use]
389    pub fn with_unsafe(mut self, is_unsafe: bool) -> Self {
390        self.is_unsafe = is_unsafe;
391        self
392    }
393
394    /// Sets the body hash for this node.
395    ///
396    /// The body hash is a 128-bit hash computed from the raw body bytes
397    /// of the symbol. Used for duplicate code detection.
398    #[must_use]
399    pub fn with_body_hash(mut self, hash: BodyHash128) -> Self {
400        self.body_hash = Some(hash);
401        self
402    }
403}
404
405/// Contiguous node storage with generational indices.
406///
407/// `NodeArena` provides O(1) access to nodes by `NodeId`, with stale
408/// reference detection via generation counters.
409///
410/// # Free List Management
411///
412/// When a node is removed, its slot is added to the free list for reuse.
413/// This avoids memory fragmentation and keeps indices stable.
414///
415/// # Example
416///
417/// ```rust,ignore
418/// let mut arena = NodeArena::new();
419/// let id = arena.alloc(NodeEntry::new(NodeKind::Function, name, file))?;
420///
421/// let node = arena.get(id).expect("node exists");
422/// assert_eq!(node.kind, NodeKind::Function);
423///
424/// arena.remove(id)?;
425/// assert!(arena.get(id).is_none()); // Stale ID returns None
426/// ```
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct NodeArena {
429    /// Slots for node storage.
430    slots: Vec<Slot<NodeEntry>>,
431    /// Head of the free list (index of first free slot).
432    free_head: Option<u32>,
433    /// Number of currently occupied slots.
434    len: usize,
435}
436
437impl NodeArena {
438    /// Creates a new empty arena.
439    #[must_use]
440    pub fn new() -> Self {
441        Self {
442            slots: Vec::new(),
443            free_head: None,
444            len: 0,
445        }
446    }
447
448    /// Creates a new arena with the specified initial capacity.
449    #[must_use]
450    pub fn with_capacity(capacity: usize) -> Self {
451        Self {
452            slots: Vec::with_capacity(capacity),
453            free_head: None,
454            len: 0,
455        }
456    }
457
458    /// Reconstructs an arena from its raw component parts.
459    ///
460    /// Used by the V13→V14 snapshot upconvert to rebuild the live arena from
461    /// translated slots while preserving slot indices, generations, and the
462    /// free list exactly (so existing `NodeId` references in edges stay
463    /// valid). The caller is responsible for the slot/free-list invariants;
464    /// this is a thin structural constructor, not a validating one.
465    #[must_use]
466    pub(crate) fn from_raw_parts(
467        slots: Vec<Slot<NodeEntry>>,
468        free_head: Option<u32>,
469        len: usize,
470    ) -> Self {
471        Self {
472            slots,
473            free_head,
474            len,
475        }
476    }
477
478    /// Returns the number of occupied slots.
479    #[inline]
480    #[must_use]
481    pub fn len(&self) -> usize {
482        self.len
483    }
484
485    /// Returns true if the arena is empty.
486    #[inline]
487    #[must_use]
488    pub fn is_empty(&self) -> bool {
489        self.len == 0
490    }
491
492    /// Returns the allocated capacity (slots that can be stored without reallocation).
493    #[inline]
494    #[must_use]
495    pub fn capacity(&self) -> usize {
496        self.slots.capacity()
497    }
498
499    /// Returns the total number of slots (occupied + vacant).
500    ///
501    /// This is different from `len()` which counts only occupied slots,
502    /// and `capacity()` which counts pre-allocated slots.
503    #[inline]
504    #[must_use]
505    pub fn slot_count(&self) -> usize {
506        self.slots.len()
507    }
508
509    /// Allocates a new node and returns its `NodeId`.
510    ///
511    /// If there are free slots available, one is reused. Otherwise, a new
512    /// slot is appended to the storage.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`ArenaError`] if:
517    /// - Generation counter would overflow (`GenerationOverflow`)
518    /// - Free list is corrupted (`FreeListCorruption`)
519    /// - Arena capacity exceeded (`CapacityExceeded`)
520    pub fn alloc(&mut self, entry: NodeEntry) -> Result<NodeId, ArenaError> {
521        self.len += 1;
522
523        if let Some(free_idx) = self.free_head {
524            // Check bounds before accessing slot - corrupted free list could point out of bounds
525            let slot_index = free_idx as usize;
526            if slot_index >= self.slots.len() {
527                self.len -= 1; // Rollback
528                return Err(ArenaError::FreeListCorruption { index: free_idx });
529            }
530
531            // Reuse a free slot (bounds now guaranteed valid)
532            let slot = &mut self.slots[slot_index];
533
534            // Update free list head
535            self.free_head = match &slot.state {
536                SlotState::Vacant { next_free } => *next_free,
537                SlotState::Occupied(_) => {
538                    // Free list corruption detected - return error instead of panicking
539                    self.len -= 1; // Rollback
540                    return Err(ArenaError::FreeListCorruption { index: free_idx });
541                }
542            };
543
544            // Increment generation for reuse detection (respect MAX_GENERATION limit)
545            // Use NodeId::try_increment_generation which checks against MAX_GENERATION, not u64::MAX
546            let temp_id = NodeId::new(free_idx, slot.generation);
547            let new_generation = temp_id.try_increment_generation().map_err(|mut e| {
548                self.len -= 1; // Rollback
549                e.index = free_idx; // Ensure index is correct
550                ArenaError::GenerationOverflow(e)
551            })?;
552
553            slot.generation = new_generation;
554            slot.state = SlotState::Occupied(entry);
555
556            Ok(NodeId::new(free_idx, new_generation))
557        } else {
558            // Append a new slot
559            let index = self.slots.len();
560
561            // Check capacity before allocation using checked conversion
562            let Ok(index_u32) = u32::try_from(index) else {
563                self.len -= 1; // Rollback
564                return Err(ArenaError::CapacityExceeded);
565            };
566
567            let slot = Slot::new_occupied(1, entry);
568            self.slots.push(slot);
569
570            Ok(NodeId::new(index_u32, 1))
571        }
572    }
573
574    /// Returns a reference to the node with the given ID.
575    ///
576    /// Returns `None` if the ID is invalid or stale (generation mismatch).
577    #[must_use]
578    pub fn get(&self, id: NodeId) -> Option<&NodeEntry> {
579        if id.is_invalid() {
580            return None;
581        }
582
583        let index = id.index() as usize;
584        let slot = self.slots.get(index)?;
585
586        // Check generation match
587        if slot.generation != id.generation() {
588            return None;
589        }
590
591        slot.get()
592    }
593
594    /// Returns a mutable reference to the node with the given ID.
595    ///
596    /// Returns `None` if the ID is invalid or stale.
597    #[must_use]
598    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut NodeEntry> {
599        if id.is_invalid() {
600            return None;
601        }
602
603        let index = id.index() as usize;
604        let slot = self.slots.get_mut(index)?;
605
606        // Check generation match
607        if slot.generation != id.generation() {
608            return None;
609        }
610
611        slot.get_mut()
612    }
613
614    /// Checks if the given ID is valid (not stale).
615    #[must_use]
616    pub fn contains(&self, id: NodeId) -> bool {
617        self.get(id).is_some()
618    }
619
620    /// Removes the node with the given ID.
621    ///
622    /// Returns the removed entry, or `None` if the ID was invalid/stale.
623    /// This method is idempotent: calling it twice with the same ID will
624    /// return `None` on the second call without corrupting the arena.
625    pub fn remove(&mut self, id: NodeId) -> Option<NodeEntry> {
626        if id.is_invalid() {
627            return None;
628        }
629
630        let index = id.index() as usize;
631        let slot = self.slots.get_mut(index)?;
632
633        // Check generation match
634        if slot.generation != id.generation() {
635            return None;
636        }
637
638        // Only proceed if slot is occupied - this makes remove idempotent
639        // and prevents double-decrement of len / double-enqueue to free list
640        if let SlotState::Occupied(_) = &slot.state {
641            let old_state = std::mem::replace(
642                &mut slot.state,
643                SlotState::Vacant {
644                    next_free: self.free_head,
645                },
646            );
647
648            // Update free list and len only when actually removing
649            self.free_head = Some(id.index());
650            self.len -= 1;
651
652            if let SlotState::Occupied(entry) = old_state {
653                return Some(entry);
654            }
655        }
656
657        None
658    }
659
660    /// Iterates over all occupied entries with their IDs.
661    pub fn iter(&self) -> impl Iterator<Item = (NodeId, &NodeEntry)> {
662        self.slots.iter().enumerate().filter_map(|(index, slot)| {
663            if let SlotState::Occupied(entry) = &slot.state {
664                let index_u32 = u32::try_from(index).ok()?;
665                Some((NodeId::new(index_u32, slot.generation), entry))
666            } else {
667                None
668            }
669        })
670    }
671
672    /// Iterates over all occupied entries with mutable access.
673    pub fn iter_mut(&mut self) -> impl Iterator<Item = (NodeId, &mut NodeEntry)> {
674        self.slots
675            .iter_mut()
676            .enumerate()
677            .filter_map(|(index, slot)| {
678                let generation = slot.generation;
679                if let SlotState::Occupied(entry) = &mut slot.state {
680                    let index_u32 = u32::try_from(index).ok()?;
681                    Some((NodeId::new(index_u32, generation), entry))
682                } else {
683                    None
684                }
685            })
686    }
687
688    /// Clears all nodes from the arena.
689    ///
690    /// This resets the arena to an empty state, but retains allocated capacity.
691    pub fn clear(&mut self) {
692        self.slots.clear();
693        self.free_head = None;
694        self.len = 0;
695    }
696
697    /// Reserves capacity for at least `additional` more nodes.
698    pub fn reserve(&mut self, additional: usize) {
699        self.slots.reserve(additional);
700    }
701
702    /// Returns the slot at the given index, if any.
703    ///
704    /// This is a low-level method for internal use. Prefer `get()` for normal access.
705    #[must_use]
706    pub fn slot(&self, index: u32) -> Option<&Slot<NodeEntry>> {
707        self.slots.get(index as usize)
708    }
709
710    /// Pre-allocates a contiguous range of occupied slots for parallel commit.
711    ///
712    /// Returns the start index of the allocated range. All slots are initialized
713    /// as `Occupied` with clones of the `placeholder` entry at generation 1.
714    /// The free list is **not** touched, preserving its invariants.
715    ///
716    /// A `count` of zero is a no-op and returns the current slot count as the
717    /// start index.
718    ///
719    /// # Errors
720    ///
721    /// Returns [`ArenaError::CapacityExceeded`] if the resulting slot count
722    /// would exceed `u32::MAX`.
723    pub fn alloc_range(&mut self, count: u32, placeholder: &NodeEntry) -> Result<u32, ArenaError> {
724        if count == 0 {
725            // No-op: return current slot count as start index (safe because
726            // slot_count <= u32::MAX is maintained as an invariant).
727            return Ok(u32::try_from(self.slots.len())
728                .unwrap_or_else(|_| unreachable!("slot_count invariant violated")));
729        }
730
731        let start = self.slots.len();
732        let new_total = start
733            .checked_add(count as usize)
734            .ok_or(ArenaError::CapacityExceeded)?;
735
736        // Verify new total fits in u32 (arena index space).
737        if new_total > u32::MAX as usize + 1 {
738            return Err(ArenaError::CapacityExceeded);
739        }
740
741        let start_u32 = u32::try_from(start).map_err(|_| ArenaError::CapacityExceeded)?;
742
743        // Reserve and fill in one go.
744        self.slots.reserve(count as usize);
745        for _ in 0..count {
746            self.slots.push(Slot::new_occupied(1, placeholder.clone()));
747        }
748
749        // Increment occupied count immediately — arena state is always consistent.
750        self.len += count as usize;
751
752        Ok(start_u32)
753    }
754
755    /// Returns a mutable sub-slice of the internal slot storage.
756    ///
757    /// This is intended for parallel writes after [`alloc_range`](Self::alloc_range):
758    /// each thread can write to its own non-overlapping portion of the slice.
759    ///
760    /// # Panics
761    ///
762    /// Panics if `start + count` exceeds the slot count.
763    #[must_use]
764    pub fn bulk_slice_mut(&mut self, start: u32, count: u32) -> &mut [Slot<NodeEntry>] {
765        let begin = start as usize;
766        let end = begin + count as usize;
767        &mut self.slots[begin..end]
768    }
769
770    /// Truncates the slot storage to `saved_slot_count` slots.
771    ///
772    /// Used to roll back a failed bulk allocation. Any slots beyond
773    /// `saved_slot_count` are dropped, and `len` is adjusted to reflect
774    /// only the occupied slots that remain.
775    ///
776    /// # Panics
777    ///
778    /// Panics if `saved_slot_count` is greater than the current slot count
779    /// (cannot grow via truncation).
780    pub fn truncate_to(&mut self, saved_slot_count: usize) {
781        assert!(
782            saved_slot_count <= self.slots.len(),
783            "truncate_to({saved_slot_count}) exceeds current slot count ({})",
784            self.slots.len(),
785        );
786
787        // Count occupied slots that will be dropped.
788        let dropped_occupied = self.slots[saved_slot_count..]
789            .iter()
790            .filter(|s| s.is_occupied())
791            .count();
792
793        self.slots.truncate(saved_slot_count);
794        self.len -= dropped_occupied;
795
796        // Rebuild free list: remove any entries that pointed into the truncated
797        // region. We walk the chain and keep only links to retained slots.
798        self.rebuild_free_list_after_truncate(saved_slot_count);
799    }
800
801    /// Rebuilds the free list after a truncation, dropping any links to slots
802    /// at or beyond `cutoff`.
803    ///
804    /// Scans retained slots for vacant entries and re-links them into a fresh
805    /// free list. This is O(cutoff) but `truncate_to` is a rare rollback path.
806    fn rebuild_free_list_after_truncate(&mut self, cutoff: usize) {
807        // Simple approach: scan retained slots for vacant entries.
808        // This avoids issues with interleaved chains (e.g., head -> 6 -> 0)
809        // where truncated indices appear mid-chain.
810        let mut new_head: Option<u32> = None;
811        // Walk backwards so the resulting list is in ascending index order
812        // (head = lowest free index), matching the pattern of fresh arenas.
813        for i in (0..cutoff).rev() {
814            if self.slots[i].is_vacant() {
815                let i_u32 = u32::try_from(i)
816                    .unwrap_or_else(|_| unreachable!("free-list index exceeds u32 invariant"));
817                self.slots[i].state = SlotState::Vacant {
818                    next_free: new_head,
819                };
820                new_head = Some(i_u32);
821            }
822        }
823        self.free_head = new_head;
824    }
825
826    /// Returns a read-only view of all slots (occupied and vacant).
827    ///
828    /// Useful for post-build passes that need to scan every slot without
829    /// the overhead of generation checks.
830    #[must_use]
831    pub fn slots(&self) -> &[Slot<NodeEntry>] {
832        &self.slots
833    }
834}
835
836impl Default for NodeArena {
837    fn default() -> Self {
838        Self::new()
839    }
840}
841
842impl fmt::Display for NodeArena {
843    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
844        write!(
845            f,
846            "NodeArena(len={}, capacity={})",
847            self.len,
848            self.capacity()
849        )
850    }
851}
852
853impl crate::graph::unified::memory::GraphMemorySize for NodeArena {
854    fn heap_bytes(&self) -> usize {
855        self.slots.capacity() * std::mem::size_of::<Slot<NodeEntry>>()
856    }
857}
858
859/// Returns `true` for the canonical synthetic-placeholder name shapes
860/// emitted by language plugins for binding-plane scaffolding.
861///
862/// See [`NodeEntry::is_synthetic_placeholder_name`] for the full
863/// rationale. Patterns recognised:
864///
865/// - **Angle-bracket pseudo-identifiers**: `<...>` — used for
866///   `<field:operand.field>` (Go field-access fallback) and similar
867///   shadows. Real source-language identifiers cannot start with `<`.
868/// - **Per-binding-site offset suffix**: `<ident>@<digits>` — used
869///   for the Go local-scope resolver's per-use Variable nodes. Real
870///   Go identifiers cannot contain `@` (Go's identifier grammar is
871///   `[a-zA-Z_][a-zA-Z0-9_]*`).
872fn is_synthetic_placeholder_name_shape(name: &str) -> bool {
873    if name.starts_with('<') {
874        return true;
875    }
876    // `ident@<digits>` shape: split on '@' and require digits after.
877    if let Some((_, rest)) = name.rsplit_once('@')
878        && !rest.is_empty()
879        && rest.bytes().all(|b| b.is_ascii_digit())
880    {
881        return true;
882    }
883    false
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    fn test_file() -> FileId {
891        FileId::new(1)
892    }
893
894    fn test_name() -> StringId {
895        StringId::new(1)
896    }
897
898    fn test_entry(kind: NodeKind) -> NodeEntry {
899        NodeEntry::new(kind, test_name(), test_file())
900    }
901
902    #[test]
903    fn test_arena_new() {
904        let arena = NodeArena::new();
905        assert_eq!(arena.len(), 0);
906        assert!(arena.is_empty());
907        assert_eq!(arena.capacity(), 0);
908    }
909
910    #[test]
911    fn synthetic_placeholder_name_shape_recognises_known_synthetics() {
912        // Go field-access fallback (process_field_access_unified)
913        assert!(is_synthetic_placeholder_name_shape("<field:s.NeedTags>"));
914        assert!(is_synthetic_placeholder_name_shape(
915            "<field:selector.NeedTags>"
916        ));
917        // Go type-assertion placeholder
918        assert!(is_synthetic_placeholder_name_shape("<type:main.Foo>"));
919        // Go local-scope per-binding-site Variable (graph_builder + local_scopes)
920        assert!(is_synthetic_placeholder_name_shape("NeedTags@469"));
921        assert!(is_synthetic_placeholder_name_shape("NeedTags@508"));
922        assert!(is_synthetic_placeholder_name_shape("foo@0"));
923        // The same check as exposed on NodeEntry
924        assert!(NodeEntry::is_synthetic_placeholder_name("<field:x.y>"));
925        assert!(NodeEntry::is_synthetic_placeholder_name("x@123"));
926    }
927
928    #[test]
929    fn synthetic_placeholder_name_shape_passes_real_identifiers() {
930        // Real Go identifiers (and qualified names) must NOT be flagged.
931        assert!(!is_synthetic_placeholder_name_shape("NeedTags"));
932        assert!(!is_synthetic_placeholder_name_shape(
933            "main.SelectorSource.NeedTags"
934        ));
935        assert!(!is_synthetic_placeholder_name_shape("main.useSelector"));
936        assert!(!is_synthetic_placeholder_name_shape("foo"));
937        assert!(!is_synthetic_placeholder_name_shape(""));
938        // `@` followed by non-digits — does not match the binding-site shape.
939        assert!(!is_synthetic_placeholder_name_shape("foo@bar"));
940        // `@` with empty suffix — degenerate, not synthetic.
941        assert!(!is_synthetic_placeholder_name_shape("foo@"));
942    }
943
944    #[test]
945    fn test_arena_with_capacity() {
946        let arena = NodeArena::with_capacity(100);
947        assert_eq!(arena.len(), 0);
948        assert!(arena.capacity() >= 100);
949    }
950
951    #[test]
952    fn test_alloc_and_get() {
953        let mut arena = NodeArena::new();
954        let entry = test_entry(NodeKind::Function);
955
956        let id = arena.alloc(entry).unwrap();
957        assert!(!id.is_invalid());
958        assert_eq!(arena.len(), 1);
959
960        let node = arena.get(id).unwrap();
961        assert_eq!(node.kind, NodeKind::Function);
962    }
963
964    #[test]
965    fn test_alloc_multiple() {
966        let mut arena = NodeArena::new();
967
968        let id1 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
969        let id2 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
970        let id3 = arena.alloc(test_entry(NodeKind::Method)).unwrap();
971
972        assert_eq!(arena.len(), 3);
973        assert_ne!(id1, id2);
974        assert_ne!(id2, id3);
975
976        assert_eq!(arena.get(id1).unwrap().kind, NodeKind::Function);
977        assert_eq!(arena.get(id2).unwrap().kind, NodeKind::Class);
978        assert_eq!(arena.get(id3).unwrap().kind, NodeKind::Method);
979    }
980
981    #[test]
982    fn test_get_mut() {
983        let mut arena = NodeArena::new();
984        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
985
986        {
987            let node = arena.get_mut(id).unwrap();
988            node.start_line = 42;
989        }
990
991        assert_eq!(arena.get(id).unwrap().start_line, 42);
992    }
993
994    #[test]
995    fn test_contains() {
996        let mut arena = NodeArena::new();
997        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
998
999        assert!(arena.contains(id));
1000        assert!(!arena.contains(NodeId::INVALID));
1001    }
1002
1003    #[test]
1004    fn test_remove() {
1005        let mut arena = NodeArena::new();
1006        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1007
1008        assert_eq!(arena.len(), 1);
1009        assert!(arena.contains(id));
1010
1011        let removed = arena.remove(id).unwrap();
1012        assert_eq!(removed.kind, NodeKind::Function);
1013        assert_eq!(arena.len(), 0);
1014        assert!(!arena.contains(id));
1015    }
1016
1017    #[test]
1018    fn test_stale_id_after_remove() {
1019        let mut arena = NodeArena::new();
1020        let id1 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1021
1022        // Remove and reallocate
1023        arena.remove(id1);
1024        let id2 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1025
1026        // Old ID should be stale
1027        assert!(arena.get(id1).is_none());
1028        // New ID should work
1029        assert_eq!(arena.get(id2).unwrap().kind, NodeKind::Class);
1030        // They should have the same index but different generations
1031        assert_eq!(id1.index(), id2.index());
1032        assert_ne!(id1.generation(), id2.generation());
1033    }
1034
1035    #[test]
1036    fn test_remove_idempotent() {
1037        let mut arena = NodeArena::new();
1038        let id1 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1039        let id2 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1040
1041        // First remove should succeed
1042        assert!(arena.remove(id1).is_some());
1043        assert_eq!(arena.len(), 1);
1044
1045        // Second remove of same ID should be idempotent (no side effects)
1046        assert!(arena.remove(id1).is_none());
1047        assert_eq!(arena.len(), 1); // len unchanged
1048
1049        // Third remove should still be safe
1050        assert!(arena.remove(id1).is_none());
1051        assert_eq!(arena.len(), 1); // len still unchanged
1052
1053        // Other entries should be unaffected
1054        assert!(arena.contains(id2));
1055
1056        // New allocation should work correctly
1057        let id3 = arena.alloc(test_entry(NodeKind::Method)).unwrap();
1058        assert_eq!(id3.index(), id1.index()); // Reuses the removed slot
1059        assert_eq!(arena.len(), 2);
1060    }
1061
1062    #[test]
1063    fn test_free_list_reuse() {
1064        let mut arena = NodeArena::new();
1065
1066        // Allocate 3 nodes
1067        let id1 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1068        let id2 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1069        let id3 = arena.alloc(test_entry(NodeKind::Method)).unwrap();
1070
1071        // Remove middle node
1072        arena.remove(id2);
1073        assert_eq!(arena.len(), 2);
1074        assert_eq!(arena.slot_count(), 3);
1075
1076        // Allocate new node - should reuse id2's slot
1077        let id4 = arena.alloc(test_entry(NodeKind::Variable)).unwrap();
1078        assert_eq!(id4.index(), id2.index());
1079        assert_eq!(arena.len(), 3);
1080        assert_eq!(arena.slot_count(), 3); // No growth - slot reused
1081
1082        // Old id2 is stale
1083        assert!(arena.get(id2).is_none());
1084        // New id4 works
1085        assert_eq!(arena.get(id4).unwrap().kind, NodeKind::Variable);
1086        // Other IDs still valid
1087        assert!(arena.contains(id1));
1088        assert!(arena.contains(id3));
1089    }
1090
1091    #[test]
1092    fn test_invalid_id() {
1093        let arena = NodeArena::new();
1094        assert!(arena.get(NodeId::INVALID).is_none());
1095    }
1096
1097    #[test]
1098    fn test_out_of_bounds_id() {
1099        let arena = NodeArena::new();
1100        let fake_id = NodeId::new(999, 1);
1101        assert!(arena.get(fake_id).is_none());
1102    }
1103
1104    #[test]
1105    fn test_wrong_generation() {
1106        let mut arena = NodeArena::new();
1107        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1108
1109        // Create ID with wrong generation
1110        let wrong_gen_id = NodeId::new(id.index(), id.generation() + 1);
1111        assert!(arena.get(wrong_gen_id).is_none());
1112    }
1113
1114    #[test]
1115    fn test_iter() {
1116        let mut arena = NodeArena::new();
1117        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1118        arena.alloc(test_entry(NodeKind::Class)).unwrap();
1119        arena.alloc(test_entry(NodeKind::Method)).unwrap();
1120
1121        let entries: Vec<_> = arena.iter().collect();
1122        assert_eq!(entries.len(), 3);
1123
1124        let kinds: Vec<_> = entries.iter().map(|(_, e)| e.kind).collect();
1125        assert!(kinds.contains(&NodeKind::Function));
1126        assert!(kinds.contains(&NodeKind::Class));
1127        assert!(kinds.contains(&NodeKind::Method));
1128    }
1129
1130    #[test]
1131    fn test_iter_with_removed() {
1132        let mut arena = NodeArena::new();
1133        let id1 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1134        let id2 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1135        let id3 = arena.alloc(test_entry(NodeKind::Method)).unwrap();
1136
1137        arena.remove(id2);
1138
1139        let entries: Vec<_> = arena.iter().collect();
1140        assert_eq!(entries.len(), 2);
1141
1142        let collected_ids: Vec<_> = entries.iter().map(|(id, _)| *id).collect();
1143        assert!(collected_ids.contains(&id1));
1144        assert!(!collected_ids.contains(&id2));
1145        assert!(collected_ids.contains(&id3));
1146    }
1147
1148    #[test]
1149    fn test_iter_mut() {
1150        let mut arena = NodeArena::new();
1151        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1152        arena.alloc(test_entry(NodeKind::Class)).unwrap();
1153
1154        for (_, entry) in arena.iter_mut() {
1155            entry.start_line = 100;
1156        }
1157
1158        for (_, entry) in arena.iter() {
1159            assert_eq!(entry.start_line, 100);
1160        }
1161    }
1162
1163    #[test]
1164    fn test_clear() {
1165        let mut arena = NodeArena::new();
1166        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1167        arena.alloc(test_entry(NodeKind::Class)).unwrap();
1168
1169        assert_eq!(arena.len(), 2);
1170        arena.clear();
1171        assert_eq!(arena.len(), 0);
1172        assert!(arena.is_empty());
1173    }
1174
1175    #[test]
1176    fn test_reserve() {
1177        let mut arena = NodeArena::new();
1178        arena.reserve(1000);
1179        assert!(arena.capacity() >= 1000);
1180    }
1181
1182    #[test]
1183    fn test_display() {
1184        let mut arena = NodeArena::new();
1185        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1186
1187        let display = format!("{arena}");
1188        assert!(display.contains("NodeArena"));
1189        assert!(display.contains("len=1"));
1190    }
1191
1192    #[test]
1193    fn test_node_entry_builder() {
1194        let entry = NodeEntry::new(NodeKind::Function, test_name(), test_file())
1195            .with_byte_range(10, 100)
1196            .with_location(1, 0, 5, 10)
1197            .with_signature(StringId::new(2))
1198            .with_doc(StringId::new(3))
1199            .with_qualified_name(StringId::new(4));
1200
1201        assert_eq!(entry.kind, NodeKind::Function);
1202        assert_eq!(entry.start_byte, 10);
1203        assert_eq!(entry.end_byte, 100);
1204        assert_eq!(entry.start_line, 1);
1205        assert_eq!(entry.start_column, 0);
1206        assert_eq!(entry.end_line, 5);
1207        assert_eq!(entry.end_column, 10);
1208        assert_eq!(entry.signature, Some(StringId::new(2)));
1209        assert_eq!(entry.doc, Some(StringId::new(3)));
1210        assert_eq!(entry.qualified_name, Some(StringId::new(4)));
1211    }
1212
1213    #[test]
1214    fn test_slot_state() {
1215        let occupied: Slot<i32> = Slot::new_occupied(1, 42);
1216        assert!(occupied.is_occupied());
1217        assert!(!occupied.is_vacant());
1218        assert_eq!(occupied.get(), Some(&42));
1219
1220        let vacant: Slot<i32> = Slot::new_vacant(2, Some(5));
1221        assert!(!vacant.is_occupied());
1222        assert!(vacant.is_vacant());
1223        assert_eq!(vacant.get(), None);
1224    }
1225
1226    #[test]
1227    fn test_slot_generation() {
1228        let slot: Slot<i32> = Slot::new_occupied(42, 100);
1229        assert_eq!(slot.generation(), 42);
1230    }
1231
1232    #[test]
1233    fn test_slot_state_accessor() {
1234        let occupied: Slot<i32> = Slot::new_occupied(1, 42);
1235        assert!(matches!(occupied.state(), SlotState::Occupied(42)));
1236
1237        let vacant: Slot<i32> = Slot::new_vacant(1, Some(3));
1238        assert!(matches!(
1239            vacant.state(),
1240            SlotState::Vacant { next_free: Some(3) }
1241        ));
1242    }
1243
1244    // ---- Bulk API tests ----
1245
1246    #[test]
1247    fn test_alloc_range_basic() {
1248        let mut arena = NodeArena::new();
1249        let placeholder = test_entry(NodeKind::Function);
1250
1251        let start = arena.alloc_range(5, &placeholder).unwrap();
1252        assert_eq!(start, 0);
1253        assert_eq!(arena.len(), 5);
1254        assert_eq!(arena.slot_count(), 5);
1255
1256        // All slots should be occupied with generation 1.
1257        for i in 0..5u32 {
1258            let id = NodeId::new(i, 1);
1259            let entry = arena.get(id).expect("slot should be occupied");
1260            assert_eq!(entry.kind, NodeKind::Function);
1261        }
1262    }
1263
1264    #[test]
1265    fn test_alloc_range_after_existing() {
1266        let mut arena = NodeArena::new();
1267
1268        // Allocate one node via normal path.
1269        let id0 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1270        assert_eq!(id0.index(), 0);
1271        assert_eq!(arena.len(), 1);
1272
1273        // Bulk allocate 3 more.
1274        let placeholder = test_entry(NodeKind::Method);
1275        let start = arena.alloc_range(3, &placeholder).unwrap();
1276        assert_eq!(start, 1);
1277        assert_eq!(arena.len(), 4);
1278        assert_eq!(arena.slot_count(), 4);
1279
1280        // Original node intact.
1281        assert_eq!(arena.get(id0).unwrap().kind, NodeKind::Class);
1282
1283        // Bulk-allocated nodes accessible.
1284        for i in 1..4u32 {
1285            let id = NodeId::new(i, 1);
1286            assert_eq!(arena.get(id).unwrap().kind, NodeKind::Method);
1287        }
1288    }
1289
1290    #[test]
1291    fn test_alloc_range_zero_is_noop() {
1292        let mut arena = NodeArena::new();
1293
1294        // Allocate one normally first.
1295        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1296        let len_before = arena.len();
1297        let slot_count_before = arena.slot_count();
1298
1299        let start = arena.alloc_range(0, &test_entry(NodeKind::Class)).unwrap();
1300        #[allow(clippy::cast_possible_truncation)]
1301        // Arena slot count fits in u32 for graph storage indices
1302        let expected_start = slot_count_before as u32;
1303        assert_eq!(start, expected_start);
1304        assert_eq!(arena.len(), len_before);
1305        assert_eq!(arena.slot_count(), slot_count_before);
1306    }
1307
1308    #[test]
1309    fn test_bulk_slice_mut() {
1310        let mut arena = NodeArena::new();
1311        let placeholder = test_entry(NodeKind::Function);
1312        let start = arena.alloc_range(3, &placeholder).unwrap();
1313
1314        // Overwrite slot 1 via bulk_slice_mut.
1315        {
1316            let slice = arena.bulk_slice_mut(start, 3);
1317            assert_eq!(slice.len(), 3);
1318
1319            // Replace the middle slot with a Class entry.
1320            slice[1] = Slot::new_occupied(1, test_entry(NodeKind::Class));
1321        }
1322
1323        // Verify the overwrite via get().
1324        let id1 = NodeId::new(1, 1);
1325        assert_eq!(arena.get(id1).unwrap().kind, NodeKind::Class);
1326
1327        // Other slots unchanged.
1328        let id0 = NodeId::new(0, 1);
1329        assert_eq!(arena.get(id0).unwrap().kind, NodeKind::Function);
1330        let id2 = NodeId::new(2, 1);
1331        assert_eq!(arena.get(id2).unwrap().kind, NodeKind::Function);
1332    }
1333
1334    #[test]
1335    fn test_truncate_to() {
1336        let mut arena = NodeArena::new();
1337        let placeholder = test_entry(NodeKind::Function);
1338
1339        // Allocate initial range.
1340        arena.alloc_range(3, &placeholder).unwrap();
1341        assert_eq!(arena.len(), 3);
1342        assert_eq!(arena.slot_count(), 3);
1343
1344        // Save state.
1345        let saved = arena.slot_count();
1346
1347        // Allocate more.
1348        arena.alloc_range(4, &placeholder).unwrap();
1349        assert_eq!(arena.len(), 7);
1350        assert_eq!(arena.slot_count(), 7);
1351
1352        // Truncate back.
1353        arena.truncate_to(saved);
1354        assert_eq!(arena.len(), 3);
1355        assert_eq!(arena.slot_count(), 3);
1356
1357        // Original slots still accessible.
1358        for i in 0..3u32 {
1359            let id = NodeId::new(i, 1);
1360            assert!(arena.get(id).is_some());
1361        }
1362    }
1363
1364    #[test]
1365    fn test_alloc_range_with_free_list() {
1366        let mut arena = NodeArena::new();
1367
1368        // Allocate two nodes, then remove the first to create a free list entry.
1369        let id0 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1370        let _id1 = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1371        arena.remove(id0);
1372
1373        // State: len=1, slot_count=2, free_head=Some(0)
1374        assert_eq!(arena.len(), 1);
1375        assert_eq!(arena.slot_count(), 2);
1376
1377        // alloc_range should append AFTER slot_count, not reuse free list.
1378        let start = arena.alloc_range(3, &test_entry(NodeKind::Method)).unwrap();
1379        assert_eq!(start, 2); // Appended after existing 2 slots
1380        assert_eq!(arena.len(), 4); // 1 existing + 3 new
1381        assert_eq!(arena.slot_count(), 5); // 2 existing + 3 new
1382
1383        // Free list should still be intact — slot 0 still vacant.
1384        let slot0 = arena.slot(0).unwrap();
1385        assert!(slot0.is_vacant());
1386
1387        // Bulk-allocated nodes accessible.
1388        for i in 2..5u32 {
1389            let id = NodeId::new(i, 1);
1390            assert_eq!(arena.get(id).unwrap().kind, NodeKind::Method);
1391        }
1392    }
1393
1394    #[test]
1395    fn test_truncate_to_clamps_free_head() {
1396        let mut arena = NodeArena::new();
1397
1398        // Allocate 5 nodes.
1399        let mut ids = Vec::new();
1400        for _ in 0..5 {
1401            ids.push(arena.alloc(test_entry(NodeKind::Function)).unwrap());
1402        }
1403
1404        // Save state at 5 slots.
1405        let saved = arena.slot_count();
1406
1407        // Allocate 3 more, then remove one of them to put it on the free list.
1408        let extra_ids: Vec<_> = (0..3)
1409            .map(|_| arena.alloc(test_entry(NodeKind::Class)).unwrap())
1410            .collect();
1411        arena.remove(extra_ids[1]); // free_head now points to index 6
1412
1413        // Truncate back — free_head should be clamped since index 6 is gone.
1414        arena.truncate_to(saved);
1415        assert_eq!(arena.slot_count(), 5);
1416        assert_eq!(arena.len(), 5);
1417
1418        // Arena should still be usable: new alloc appends (no dangling free list).
1419        let new_id = arena.alloc(test_entry(NodeKind::Variable)).unwrap();
1420        assert_eq!(new_id.index(), 5); // Appended, not reusing dangling slot
1421        assert_eq!(arena.get(new_id).unwrap().kind, NodeKind::Variable);
1422    }
1423
1424    #[test]
1425    fn test_truncate_to_preserves_retained_free_list() {
1426        let mut arena = NodeArena::new();
1427
1428        // Allocate 8 nodes: indices 0..7
1429        let mut ids = Vec::new();
1430        for _ in 0..8 {
1431            ids.push(arena.alloc(test_entry(NodeKind::Function)).unwrap());
1432        }
1433        assert_eq!(arena.len(), 8);
1434
1435        // Remove indices 2 and 6 — both on free list.
1436        // Free list: 6 -> 2 -> None (LIFO)
1437        arena.remove(ids[2]);
1438        arena.remove(ids[6]);
1439        assert_eq!(arena.len(), 6);
1440
1441        // Truncate to 5 slots — removes indices 5,6,7.
1442        // Index 6 (vacant) is in truncated region, index 2 (vacant) is retained.
1443        // Free list chain was 6 -> 2. After truncate, only index 2 should survive.
1444        arena.truncate_to(5);
1445
1446        // Should have 4 occupied (indices 0,1,3,4) + 1 vacant (index 2) = 5 slots.
1447        // len should be 4 (we had 6 occupied, truncated 3 slots: 5 occupied + 1 vacant
1448        // in truncated region = indices 5(occ), 6(vac), 7(occ) => dropped 2 occupied).
1449        assert_eq!(arena.slot_count(), 5);
1450        assert_eq!(arena.len(), 4);
1451
1452        // Free list should contain only index 2.
1453        // Verify by allocating — should reuse index 2.
1454        let reused = arena.alloc(test_entry(NodeKind::Variable)).unwrap();
1455        assert_eq!(reused.index(), 2);
1456        assert_eq!(arena.get(reused).unwrap().kind, NodeKind::Variable);
1457        assert_eq!(arena.len(), 5);
1458
1459        // Next alloc should append (no more free slots).
1460        let appended = arena.alloc(test_entry(NodeKind::Class)).unwrap();
1461        assert_eq!(appended.index(), 5);
1462        assert_eq!(arena.len(), 6);
1463    }
1464
1465    #[test]
1466    fn test_slots_read_access() {
1467        let mut arena = NodeArena::new();
1468        let placeholder = test_entry(NodeKind::Variable);
1469        arena.alloc_range(4, &placeholder).unwrap();
1470
1471        let slots = arena.slots();
1472        assert_eq!(slots.len(), 4);
1473
1474        for slot in slots {
1475            assert!(slot.is_occupied());
1476            assert_eq!(slot.generation(), 1);
1477            assert_eq!(slot.get().unwrap().kind, NodeKind::Variable);
1478        }
1479    }
1480
1481    #[test]
1482    fn test_multiple_remove_reuse_cycle() {
1483        let mut arena = NodeArena::new();
1484
1485        // Allocate and remove multiple times to test free list chaining
1486        let mut ids = Vec::new();
1487        for _ in 0..5 {
1488            ids.push(arena.alloc(test_entry(NodeKind::Function)).unwrap());
1489        }
1490
1491        // Remove in reverse order (builds free list: 4 -> 3 -> 2 -> 1 -> 0)
1492        for &id in ids.iter().rev() {
1493            arena.remove(id);
1494        }
1495
1496        // Reallocate - should use free list in LIFO order
1497        let new_ids: Vec<_> = (0..5)
1498            .map(|_| arena.alloc(test_entry(NodeKind::Class)).unwrap())
1499            .collect();
1500
1501        // All old IDs should be stale
1502        for &old_id in &ids {
1503            assert!(arena.get(old_id).is_none());
1504        }
1505
1506        // All new IDs should work
1507        for &new_id in &new_ids {
1508            assert!(arena.get(new_id).is_some());
1509        }
1510    }
1511
1512    #[test]
1513    fn test_generation_overflow_at_max_generation() {
1514        // Test that allocation fails when generation reaches MAX_GENERATION
1515        let mut arena = NodeArena::new();
1516
1517        // Allocate a node
1518        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1519        assert_eq!(id.index(), 0);
1520
1521        // Remove it to put on free list
1522        arena.remove(id);
1523
1524        // Manually set the slot's generation to MAX_GENERATION
1525        // This simulates what would happen after ~9.2 quintillion allocations
1526        arena.slots[0].generation = NodeId::MAX_GENERATION;
1527
1528        // Now allocation should fail because incrementing MAX_GENERATION would overflow
1529        let result = arena.alloc(test_entry(NodeKind::Class));
1530        assert!(result.is_err());
1531
1532        let err = result.unwrap_err();
1533        match err {
1534            ArenaError::GenerationOverflow(inner) => {
1535                assert_eq!(inner.index, 0);
1536                assert_eq!(inner.generation, NodeId::MAX_GENERATION);
1537            }
1538            _ => panic!("expected GenerationOverflow, got {err:?}"),
1539        }
1540    }
1541
1542    #[test]
1543    fn test_free_list_corruption_returns_error() {
1544        // Test that free list corruption returns an error instead of panicking
1545        let mut arena = NodeArena::new();
1546
1547        // Allocate and remove a node to put it on free list
1548        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1549        arena.remove(id);
1550
1551        // Corrupt the free list by manually marking the vacant slot as occupied
1552        arena.slots[0].state = SlotState::Occupied(test_entry(NodeKind::Class));
1553
1554        // Allocation should fail gracefully with FreeListCorruption error
1555        let result = arena.alloc(test_entry(NodeKind::Method));
1556        assert!(result.is_err());
1557
1558        let err = result.unwrap_err();
1559        match err {
1560            ArenaError::FreeListCorruption { index } => {
1561                assert_eq!(index, 0);
1562            }
1563            _ => panic!("expected FreeListCorruption, got {err:?}"),
1564        }
1565
1566        // Verify the arena is still in a consistent state (len was rolled back)
1567        assert_eq!(arena.len(), 0);
1568    }
1569
1570    #[test]
1571    fn test_arena_error_display() {
1572        let gen_err = ArenaError::GenerationOverflow(GenerationOverflowError {
1573            index: 42,
1574            generation: 100,
1575        });
1576        let display = format!("{gen_err}");
1577        assert!(display.contains("42"));
1578        assert!(display.contains("100"));
1579
1580        let corruption_err = ArenaError::FreeListCorruption { index: 5 };
1581        let display = format!("{corruption_err}");
1582        assert!(display.contains("free list corruption"));
1583        assert!(display.contains('5'));
1584
1585        let capacity_err = ArenaError::CapacityExceeded;
1586        let display = format!("{capacity_err}");
1587        assert!(display.contains("capacity exceeded"));
1588    }
1589
1590    #[test]
1591    fn test_arena_error_source_generation_overflow() {
1592        use std::error::Error;
1593
1594        let inner = GenerationOverflowError {
1595            index: 0,
1596            generation: NodeId::MAX_GENERATION,
1597        };
1598        let err = ArenaError::GenerationOverflow(inner);
1599        // source() returns Some for GenerationOverflow
1600        assert!(err.source().is_some());
1601    }
1602
1603    #[test]
1604    fn test_arena_error_source_none_for_other_variants() {
1605        use std::error::Error;
1606
1607        let corruption = ArenaError::FreeListCorruption { index: 0 };
1608        assert!(corruption.source().is_none());
1609
1610        let capacity = ArenaError::CapacityExceeded;
1611        assert!(capacity.source().is_none());
1612    }
1613
1614    #[test]
1615    fn test_arena_from_generation_overflow_error() {
1616        let inner = GenerationOverflowError {
1617            index: 10,
1618            generation: 99,
1619        };
1620        let err: ArenaError = inner.into();
1621        assert!(matches!(err, ArenaError::GenerationOverflow(_)));
1622    }
1623
1624    #[test]
1625    fn test_arena_error_clone_and_eq() {
1626        let err = ArenaError::CapacityExceeded;
1627        let cloned = err.clone();
1628        assert_eq!(err, cloned);
1629
1630        let err2 = ArenaError::FreeListCorruption { index: 3 };
1631        let cloned2 = err2.clone();
1632        assert_eq!(err2, cloned2);
1633    }
1634
1635    #[test]
1636    fn test_node_entry_with_visibility() {
1637        let entry = NodeEntry::new(NodeKind::Function, test_name(), test_file())
1638            .with_visibility(StringId::new(5));
1639
1640        assert_eq!(entry.visibility, Some(StringId::new(5)));
1641    }
1642
1643    #[test]
1644    fn test_node_entry_with_async_and_static() {
1645        let entry = NodeEntry::new(NodeKind::Method, test_name(), test_file())
1646            .with_async(true)
1647            .with_static(true);
1648
1649        assert!(entry.is_async);
1650        assert!(entry.is_static);
1651    }
1652
1653    #[test]
1654    fn test_node_entry_with_unsafe_flag() {
1655        let entry = NodeEntry::new(NodeKind::Function, test_name(), test_file()).with_unsafe(true);
1656
1657        assert!(entry.is_unsafe);
1658    }
1659
1660    #[test]
1661    fn test_node_entry_with_body_hash() {
1662        use crate::graph::body_hash::BodyHash128;
1663        let hash = BodyHash128::from_u128(0u128);
1664        let entry =
1665            NodeEntry::new(NodeKind::Function, test_name(), test_file()).with_body_hash(hash);
1666
1667        assert!(entry.body_hash.is_some());
1668    }
1669
1670    #[test]
1671    fn test_node_entry_defaults() {
1672        let entry = NodeEntry::new(NodeKind::Function, test_name(), test_file());
1673        assert_eq!(entry.start_byte, 0);
1674        assert_eq!(entry.end_byte, 0);
1675        assert_eq!(entry.start_line, 0);
1676        assert_eq!(entry.start_column, 0);
1677        assert_eq!(entry.end_line, 0);
1678        assert_eq!(entry.end_column, 0);
1679        assert!(entry.signature.is_none());
1680        assert!(entry.doc.is_none());
1681        assert!(entry.qualified_name.is_none());
1682        assert!(entry.visibility.is_none());
1683        assert!(!entry.is_async);
1684        assert!(!entry.is_static);
1685        assert!(!entry.is_unsafe);
1686        assert!(entry.body_hash.is_none());
1687    }
1688
1689    #[test]
1690    fn test_arena_default_impl() {
1691        let arena = NodeArena::default();
1692        assert_eq!(arena.len(), 0);
1693        assert!(arena.is_empty());
1694    }
1695
1696    #[test]
1697    fn test_get_invalid_id() {
1698        let mut arena = NodeArena::new();
1699        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1700        // get with INVALID should return None
1701        assert!(arena.get(NodeId::INVALID).is_none());
1702    }
1703
1704    #[test]
1705    fn test_get_mut_invalid_id() {
1706        let mut arena = NodeArena::new();
1707        arena.alloc(test_entry(NodeKind::Function)).unwrap();
1708        assert!(arena.get_mut(NodeId::INVALID).is_none());
1709    }
1710
1711    #[test]
1712    fn test_get_mut_wrong_generation() {
1713        let mut arena = NodeArena::new();
1714        let id = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1715        let wrong_gen_id = NodeId::new(id.index(), id.generation() + 1);
1716        assert!(arena.get_mut(wrong_gen_id).is_none());
1717    }
1718
1719    #[test]
1720    fn test_remove_invalid_id() {
1721        let mut arena = NodeArena::new();
1722        assert!(arena.remove(NodeId::INVALID).is_none());
1723    }
1724
1725    #[test]
1726    fn test_slot_get_mut_occupied() {
1727        let mut slot: Slot<i32> = Slot::new_occupied(1, 42);
1728        let val = slot.get_mut().unwrap();
1729        *val = 99;
1730        assert_eq!(slot.get(), Some(&99));
1731    }
1732
1733    #[test]
1734    fn test_slot_get_mut_vacant() {
1735        let mut slot: Slot<i32> = Slot::new_vacant(1, None);
1736        assert!(slot.get_mut().is_none());
1737    }
1738
1739    #[test]
1740    fn test_truncate_to_zero_occupied_dropped() {
1741        let mut arena = NodeArena::new();
1742        let placeholder = test_entry(NodeKind::Function);
1743        arena.alloc_range(5, &placeholder).unwrap();
1744        // Truncate to 0 — all 5 occupied slots are dropped
1745        arena.truncate_to(0);
1746        assert_eq!(arena.len(), 0);
1747        assert_eq!(arena.slot_count(), 0);
1748    }
1749
1750    #[test]
1751    fn test_slot_count_vs_len() {
1752        let mut arena = NodeArena::new();
1753        let id0 = arena.alloc(test_entry(NodeKind::Function)).unwrap();
1754        arena.alloc(test_entry(NodeKind::Class)).unwrap();
1755        arena.remove(id0);
1756        // slot_count is total slots (including vacant), len is occupied only
1757        assert_eq!(arena.slot_count(), 2);
1758        assert_eq!(arena.len(), 1);
1759    }
1760}