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}