Skip to main content

tfparser_core/ir/
ids.rs

1//! Stable-within-a-run identifiers for [`Component`] and [`Module`].
2//!
3//! IDs are [`NonZeroU32`] per CLAUDE.md § Type Design — zero is *not* a valid
4//! ID; encoding "missing" as `Option<ComponentId>` makes the missing case
5//! cost-free (niche-optimisation collapses `Option<NonZeroU32>` to 4 bytes).
6//!
7//! IDs are not persisted across parse runs. Do not store them in Parquet.
8//!
9//! [`Component`]: crate::ir::Component
10//! [`Module`]: crate::ir::Module
11
12use std::num::NonZeroU32;
13
14use serde::{Deserialize, Serialize};
15
16macro_rules! define_id {
17    ($(#[$meta:meta])* $name:ident) => {
18        $(#[$meta])*
19        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
20        #[serde(transparent)]
21        pub struct $name(NonZeroU32);
22
23        impl $name {
24            /// Construct from a raw 1-based index. Returns `None` for zero.
25            #[must_use]
26            pub const fn new(raw: u32) -> Option<Self> {
27                match NonZeroU32::new(raw) {
28                    Some(v) => Some(Self(v)),
29                    None => None,
30                }
31            }
32
33            /// Construct from a 0-based index (typical interner output).
34            ///
35            /// Saturates at `u32::MAX` if `raw` would not fit in a `u32`
36            /// (i.e. on 64-bit targets at `raw >= u32::MAX as usize`). One
37            /// slot of capacity is sacrificed at the very top; cheap.
38            ///
39            /// Phase 5 module expansion may push id space toward this
40            /// limit. If that becomes likely, replace this constructor
41            /// with a fallible one that surfaces `IdSpaceExhausted`.
42            #[must_use]
43            pub fn from_index(raw: usize) -> Self {
44                // `try_from` returns the original on success or `Err` on
45                // overflow. On overflow we use `u32::MAX - 1` so the
46                // subsequent `+1` lands at `u32::MAX` without wrapping.
47                let clamped: u32 = u32::try_from(raw).unwrap_or(u32::MAX - 1);
48                let one_based = clamped.saturating_add(1);
49                NonZeroU32::new(one_based).map_or(Self(NonZeroU32::MIN), Self)
50            }
51
52            /// Raw 1-based id.
53            #[must_use]
54            pub const fn get(self) -> u32 {
55                self.0.get()
56            }
57
58            /// 0-based index suitable for `Vec` lookup.
59            #[must_use]
60            pub const fn index(self) -> usize {
61                (self.0.get() - 1) as usize
62            }
63        }
64    };
65}
66
67define_id! {
68    /// Stable-within-a-run identifier for a [`Component`].
69    ///
70    /// [`Component`]: crate::ir::Component
71    ComponentId
72}
73
74define_id! {
75    /// Stable-within-a-run identifier for a referenced [`Module`].
76    ///
77    /// [`Module`]: crate::ir::Module
78    ModuleId
79}
80
81#[cfg(test)]
82#[allow(
83    clippy::unwrap_used,
84    clippy::expect_used,
85    clippy::panic,
86    clippy::indexing_slicing
87)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_should_round_trip_component_id_from_index() {
93        let id = ComponentId::from_index(0);
94        assert_eq!(id.get(), 1, "1-based id");
95        assert_eq!(id.index(), 0, "0-based index");
96    }
97
98    #[test]
99    fn test_should_reject_zero_in_component_id_new() {
100        assert!(ComponentId::new(0).is_none(), "zero is not a valid id");
101        assert_eq!(ComponentId::new(1).map(ComponentId::get), Some(1));
102    }
103
104    #[test]
105    fn test_should_keep_option_niche_optimisation() {
106        // CLAUDE.md § Type Design — using `NonZeroU32` over `u32` keeps
107        // `Option<ComponentId>` the same size as `ComponentId` (4 bytes).
108        assert_eq!(
109            std::mem::size_of::<Option<ComponentId>>(),
110            std::mem::size_of::<ComponentId>()
111        );
112    }
113
114    #[test]
115    fn test_should_saturate_component_id_from_giant_index() {
116        let id = ComponentId::from_index(usize::MAX);
117        // Saturation lands at u32::MAX (capacity − 1 + 1) and the NonZeroU32
118        // invariant holds.
119        assert_eq!(id.get(), u32::MAX);
120    }
121
122    #[test]
123    fn test_should_serde_round_trip_component_id() {
124        let id = ComponentId::from_index(41);
125        let s = serde_json::to_string(&id).unwrap();
126        assert_eq!(s, "42");
127        let back: ComponentId = serde_json::from_str(&s).unwrap();
128        assert_eq!(back, id);
129    }
130}