Skip to main content

brink_codegen_inkb/
chunk.rs

1//! Symbolic name-reference + relocation representation for codegen chunks
2//! (FG-4b, #808 — `docs/fine-grained-salsa-proposal.md` §5 + the
3//! three-resolution-moments appendix).
4//!
5//! A per-container **chunk** ([`ContainerChunk`]) is the unit
6//! [`crate::emit`] produces for one LIR container: its emitted bytecode plus
7//! a table of *relocations*. Every name reference codegen must bake into
8//! bytecode (today only `PushString`'s `NameId` operand) is written as an
9//! unresolved placeholder ([`UNRESOLVED_NAME_ID`]) and recorded as a
10//! [`Relocation`] carrying the *symbolic* name ([`NameRef`]) rather than a
11//! resolved `NameId`. The assembly/link phase ([`ContainerChunk::link`])
12//! resolves each symbol against the assembled story name table and patches
13//! the two operand bytes in place.
14//!
15//! This is compile-link — the first of the three resolution moments in the
16//! proposal's appendix — mirroring the runtime's existing symbolic-ref /
17//! linker model (decision-log 2026-03-01) one layer earlier. Note the
18//! division of labour: the chunk owns only *bytecode* name references
19//! (patch sites). A container's own identity strings (its name, its
20//! author-path) are assembly-table fields, resolved by the assembler as it
21//! builds the story's name table — not chunk relocations.
22//!
23//! The types are deliberately **serializable-in-principle** — a [`NameRef`]
24//! owns its string and a [`Relocation`] is self-contained (a byte offset +
25//! a symbol, no transient pointers) — so a future dynamic-linking slice
26//! (#717) can ship relocatable chunks without redesigning them. No
27//! serialization is implemented here.
28//!
29//! History-independence (the FG-4d gate this representation exists to
30//! satisfy): a chunk's relocation offsets are byte positions in its own
31//! bytecode and its `NameRef`s are the source strings — both derived from
32//! the container's content, never from allocation history. Resolving them
33//! against a name table built in the deterministic container-walk order
34//! yields byte-identical output whether the chunk was freshly emitted or
35//! (in a future incremental world) re-linked from cache.
36
37use brink_format::{ContainerDef, NameId};
38
39use crate::CodegenError;
40
41/// The placeholder written into a chunk's bytecode at every name-reference
42/// operand before linking. `u16::MAX` is never a legitimate resolved
43/// `NameId` in the stories this backend emits (name tables stay far below
44/// 65 535 entries at game-corpus sizes), so an un-patched site is
45/// detectable rather than silently valid.
46pub const UNRESOLVED_NAME_ID: u16 = u16::MAX;
47
48/// A symbolic reference to a name, resolved to a final [`NameId`] by the
49/// link phase. Owns its string so the record is self-contained (the FG-4
50/// appendix's "no transient pointers, serializable in principle").
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum NameRef {
53    /// The referenced name, identified by its interned string. The content
54    /// *is* the address, so this doubles as the content-addressed id the
55    /// appendix calls for.
56    Symbol(String),
57}
58
59impl NameRef {
60    /// The referenced symbol's string.
61    #[must_use]
62    pub fn as_str(&self) -> &str {
63        match self {
64            NameRef::Symbol(s) => s,
65        }
66    }
67}
68
69/// A patch site in a chunk's bytecode: the little-endian `u16` `NameId`
70/// operand at byte `offset` (currently always a `PushString` operand) must
71/// be overwritten with the link-resolved id of [`name`](Self::name).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Relocation {
74    /// Byte offset of the 2-byte operand within the chunk's own bytecode.
75    pub offset: u32,
76    /// The symbolic name to resolve and patch in.
77    pub name: NameRef,
78}
79
80/// One container's codegen output: a [`ContainerDef`] whose bytecode still
81/// carries [`UNRESOLVED_NAME_ID`] placeholders at every [`Relocation`] site,
82/// plus the relocation table itself. [`link`](Self::link) resolves the
83/// symbols and yields a fully-patched `ContainerDef`.
84#[derive(Debug, Clone)]
85pub struct ContainerChunk {
86    /// The container definition. `def.bytecode` holds placeholders at every
87    /// relocation offset until [`link`](Self::link) runs.
88    pub def: ContainerDef,
89    /// Symbolic name-reference patch sites into `def.bytecode`, in emission
90    /// order.
91    pub relocations: Vec<Relocation>,
92}
93
94impl ContainerChunk {
95    /// Resolve every relocation against `resolve` and patch the operand
96    /// bytes in place, consuming the chunk and returning the linked
97    /// [`ContainerDef`].
98    ///
99    /// `resolve` maps a symbol to its assembled [`NameId`] (a lookup into
100    /// the story name table). A miss is an internal invariant violation —
101    /// the writer interns every referenced symbol into the name table as it
102    /// records the relocation — so it surfaces as a distinct
103    /// [`CodegenError`] rather than being silently skipped.
104    pub fn link(
105        mut self,
106        resolve: impl Fn(&str) -> Option<NameId>,
107    ) -> Result<ContainerDef, CodegenError> {
108        for reloc in &self.relocations {
109            let id = resolve(reloc.name.as_str()).ok_or_else(|| {
110                CodegenError::new(format!(
111                    "codegen link: unresolved name reference {:?} at byte offset {}",
112                    reloc.name.as_str(),
113                    reloc.offset
114                ))
115            })?;
116            let start = reloc.offset as usize;
117            let len = self.def.bytecode.len();
118            let slot = self.def.bytecode.get_mut(start..start + 2).ok_or_else(|| {
119                CodegenError::new(format!(
120                    "codegen link: relocation offset {} out of bounds for chunk bytecode (len {len})",
121                    reloc.offset,
122                ))
123            })?;
124            slot.copy_from_slice(&id.0.to_le_bytes());
125        }
126        Ok(self.def)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use brink_format::{CountingFlags, DefinitionId, DefinitionTag, Opcode};
133
134    use super::{ContainerChunk, NameRef, Relocation, UNRESOLVED_NAME_ID};
135
136    fn def_id() -> DefinitionId {
137        DefinitionId::new(DefinitionTag::Address, 1)
138    }
139
140    /// A minimal `ContainerDef` whose bytecode is exactly `bytecode`.
141    fn chunk_with(bytecode: Vec<u8>, relocations: Vec<Relocation>) -> ContainerChunk {
142        ContainerChunk {
143            def: brink_format::ContainerDef {
144                id: def_id(),
145                scope_id: def_id(),
146                name: None,
147                bytecode,
148                counting_flags: CountingFlags::empty(),
149                path_hash: 0,
150                param_count: 0,
151                params: Vec::new(),
152                local: false,
153            },
154            relocations,
155        }
156    }
157
158    /// The writer's placeholder → the reader's resolved id: a single
159    /// `PushString` site round-trips through `link` to the byte-exact
160    /// operand a resolved emit would have produced.
161    #[test]
162    fn push_string_relocation_round_trips() {
163        // Writer: emit `PushString(UNRESOLVED)`; record a relocation at the
164        // 2-byte operand.
165        let mut bytecode = Vec::new();
166        Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
167        let offset = u32::try_from(bytecode.len() - 2).unwrap();
168        let chunk = chunk_with(
169            bytecode,
170            vec![Relocation {
171                offset,
172                name: NameRef::Symbol("greeting".to_string()),
173            }],
174        );
175
176        // The chunk is genuinely symbolic before linking: the operand is the
177        // placeholder, not a resolved id.
178        assert_eq!(
179            &chunk.def.bytecode[offset as usize..offset as usize + 2],
180            &[0xFF, 0xFF]
181        );
182
183        // Reader: resolve "greeting" -> NameId(7) and patch.
184        let linked = chunk
185            .link(|s| (s == "greeting").then_some(brink_format::NameId(7)))
186            .expect("resolvable");
187
188        // Byte-identical to a directly-resolved emit.
189        let mut expected = Vec::new();
190        Opcode::PushString(7).encode(&mut expected);
191        assert_eq!(linked.bytecode, expected);
192    }
193
194    /// Multiple relocations in one chunk each patch their own site; an empty
195    /// relocation table leaves bytecode untouched.
196    #[test]
197    fn multiple_and_empty_relocations() {
198        let mut bytecode = Vec::new();
199        Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
200        let first = u32::try_from(bytecode.len() - 2).unwrap();
201        Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
202        let second = u32::try_from(bytecode.len() - 2).unwrap();
203
204        let chunk = chunk_with(
205            bytecode,
206            vec![
207                Relocation {
208                    offset: first,
209                    name: NameRef::Symbol("a".to_string()),
210                },
211                Relocation {
212                    offset: second,
213                    name: NameRef::Symbol("b".to_string()),
214                },
215            ],
216        );
217        let linked = chunk
218            .link(|s| match s {
219                "a" => Some(brink_format::NameId(3)),
220                "b" => Some(brink_format::NameId(9)),
221                _ => None,
222            })
223            .expect("resolvable");
224
225        let mut expected = Vec::new();
226        Opcode::PushString(3).encode(&mut expected);
227        Opcode::PushString(9).encode(&mut expected);
228        assert_eq!(linked.bytecode, expected);
229
230        // No relocations => untouched.
231        let untouched = chunk_with(vec![0xAB, 0xCD], Vec::new());
232        let out = untouched.link(|_| None).expect("no work");
233        assert_eq!(out.bytecode, vec![0xAB, 0xCD]);
234    }
235
236    /// A symbol the name table cannot resolve is a distinct error, never a
237    /// silently-skipped patch.
238    #[test]
239    fn unresolved_symbol_is_an_error() {
240        let mut bytecode = Vec::new();
241        Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
242        let offset = u32::try_from(bytecode.len() - 2).unwrap();
243        let chunk = chunk_with(
244            bytecode,
245            vec![Relocation {
246                offset,
247                name: NameRef::Symbol("missing".to_string()),
248            }],
249        );
250        let err = chunk.link(|_| None).expect_err("should fail");
251        assert!(err.to_string().contains("missing"));
252    }
253}