Skip to main content

brink_runtime/
program.rs

1//! Immutable linked program.
2
3use std::collections::HashMap;
4
5use brink_format::{CountingFlags, DefinitionId, ListValue, NameId, Value};
6
7/// A linked, ready-to-execute program.
8///
9/// Created from [`StoryData`](brink_format::StoryData) via [`link()`](crate::link).
10/// Immutable after creation — mutable per-instance state lives in [`Story`](crate::Story).
11pub struct Program {
12    pub(crate) containers: Vec<LinkedContainer>,
13    /// Unified address map: `id → (container_idx, byte_offset)`.
14    /// Contains both container IDs (offset 0) and intra-container addresses.
15    pub(crate) address_map: HashMap<DefinitionId, (u32, usize)>,
16    /// Scope `DefinitionId` for each entry in the line tables (parallel vec).
17    /// Structural metadata — does not change with locale.
18    pub(crate) scope_ids: Vec<DefinitionId>,
19    /// CRC-32 checksum from the source `.inkb`, used for locale validation.
20    pub(crate) source_checksum: u32,
21    pub(crate) globals: Vec<GlobalSlot>,
22    pub(crate) global_map: HashMap<DefinitionId, u32>,
23    pub(crate) name_table: Vec<String>,
24    /// Map from a knot/stitch path string to `(container_idx, byte_offset)`.
25    /// Built at link time from named scope containers; lets consumers
26    /// spawn flows at named entry points without needing `DefinitionId`s.
27    pub(crate) address_by_path: HashMap<String, (u32, usize)>,
28    pub(crate) root_idx: u32,
29    /// List literal values referenced by `PushList(idx)`.
30    pub(crate) list_literals: Vec<ListValue>,
31    /// Per-item metadata keyed by item `DefinitionId`.
32    pub(crate) list_item_map: HashMap<DefinitionId, ListItemEntry>,
33    /// List definitions indexed by position.
34    pub(crate) list_defs: Vec<ListDefEntry>,
35    /// Map from list def `DefinitionId` to index in `list_defs`.
36    pub(crate) list_def_map: HashMap<DefinitionId, usize>,
37    /// External function metadata keyed by the external function's `DefinitionId`.
38    pub(crate) external_fns: HashMap<DefinitionId, ExternalFnEntry>,
39}
40
41pub(crate) struct LinkedContainer {
42    pub id: DefinitionId,
43    pub bytecode: Vec<u8>,
44    pub counting_flags: CountingFlags,
45    pub path_hash: i32,
46    /// Index into `Program.line_tables` for this container's scope line table.
47    pub scope_table_idx: u32,
48}
49
50pub(crate) struct GlobalSlot {
51    #[expect(dead_code, reason = "needed for save/load serialization and debugging")]
52    pub id: DefinitionId,
53    #[cfg_attr(
54        not(feature = "testing"),
55        expect(dead_code, reason = "needed for save/load serialization and debugging")
56    )]
57    pub name: NameId,
58    pub default: Value,
59}
60
61/// Runtime metadata for a list item.
62pub(crate) struct ListItemEntry {
63    pub name: NameId,
64    pub ordinal: i32,
65    pub origin: DefinitionId,
66}
67
68/// Runtime metadata for a list definition.
69pub(crate) struct ListDefEntry {
70    pub name: NameId,
71    /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
72    pub items: Vec<DefinitionId>,
73}
74
75/// Runtime metadata for an external function.
76pub(crate) struct ExternalFnEntry {
77    pub name: NameId,
78    pub fallback: Option<DefinitionId>,
79}
80
81impl Program {
82    /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
83    pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
84        self.address_map.get(&id).copied()
85    }
86
87    /// Resolve a definition ID to `(container_idx, byte_offset)`.
88    #[cfg(feature = "testing")]
89    pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
90        self.resolve_target(id)
91    }
92
93    /// Get a container by its index.
94    pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
95        &self.containers[idx as usize]
96    }
97
98    /// Get a container's bytecode by index.
99    #[cfg(feature = "testing")]
100    pub fn container_bytecode(&self, idx: u32) -> &[u8] {
101        &self.containers[idx as usize].bytecode
102    }
103
104    /// Number of containers.
105    #[cfg(feature = "testing")]
106    #[expect(
107        clippy::cast_possible_truncation,
108        reason = "container count fits in u32"
109    )]
110    pub fn container_count(&self) -> u32 {
111        self.containers.len() as u32
112    }
113
114    /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
115    pub fn source_checksum(&self) -> u32 {
116        self.source_checksum
117    }
118
119    /// Get the scope line table index for a container.
120    pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
121        self.containers[container_idx as usize].scope_table_idx
122    }
123
124    /// Look up a name by id.
125    pub(crate) fn name(&self, id: NameId) -> &str {
126        &self.name_table[id.0 as usize]
127    }
128
129    /// Look up a global slot index.
130    pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
131        self.global_map.get(&id).copied()
132    }
133
134    /// Get the root container index.
135    pub(crate) fn root_idx(&self) -> u32 {
136        self.root_idx
137    }
138
139    /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
140    ///
141    /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
142    /// for programs compiled by `brink-compiler`, author labels
143    /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
144    /// `address_paths` table (legacy `.inkb` or converter output) resolve
145    /// knot/stitch scope paths only. Use this to spawn flows at named entry
146    /// points:
147    ///
148    /// ```ignore
149    /// if let Some((idx, _)) = program.find_address("intro_scene") {
150    ///     let (flow, ctx) = FlowInstance::new_at(program, idx);
151    /// }
152    /// ```
153    #[must_use]
154    pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
155        self.address_by_path.get(path).copied()
156    }
157
158    /// Build the initial globals vector from slot defaults.
159    pub fn global_defaults(&self) -> Vec<Value> {
160        self.globals.iter().map(|s| s.default.clone()).collect()
161    }
162
163    /// Get a list literal by index.
164    pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
165        &self.list_literals[idx as usize]
166    }
167
168    /// Look up a list item's metadata.
169    pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
170        self.list_item_map.get(&id)
171    }
172
173    /// Get a list definition by its `DefinitionId`.
174    pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
175        self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
176    }
177
178    /// Find a list definition by its string name.
179    pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
180        self.list_defs
181            .iter()
182            .find(|def| self.name(def.name) == name)
183    }
184
185    /// Look up an external function by its `DefinitionId`.
186    pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
187        self.external_fns.get(&id)
188    }
189
190    /// Resolve a global slot index to its variable name.
191    #[cfg(feature = "testing")]
192    pub fn global_name(&self, idx: u32) -> Option<&str> {
193        self.globals
194            .get(idx as usize)
195            .map(|slot| self.name(slot.name))
196    }
197
198    /// Number of global variable slots.
199    #[cfg(feature = "testing")]
200    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
201    pub fn global_count(&self) -> u32 {
202        self.globals.len() as u32
203    }
204}
205
206#[cfg(test)]
207mod find_address_tests {
208    use super::*;
209
210    fn make_program_with_named_containers(names: &[&str]) -> Program {
211        // Build a minimal Program where each name maps to a unique
212        // container_idx. Used to exercise find_address without going
213        // through the full link path.
214        let mut address_by_path = HashMap::new();
215        for (i, name) in names.iter().enumerate() {
216            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
217            address_by_path.insert((*name).to_string(), (i as u32, 0));
218        }
219        Program {
220            containers: Vec::new(),
221            address_map: HashMap::new(),
222            scope_ids: Vec::new(),
223            source_checksum: 0,
224            globals: Vec::new(),
225            global_map: HashMap::new(),
226            name_table: Vec::new(),
227            address_by_path,
228            root_idx: 0,
229            list_literals: Vec::new(),
230            list_item_map: HashMap::new(),
231            list_defs: Vec::new(),
232            list_def_map: HashMap::new(),
233            external_fns: HashMap::new(),
234        }
235    }
236
237    #[test]
238    fn finds_known_knot() {
239        let program = make_program_with_named_containers(&["intro", "outro"]);
240        assert_eq!(program.find_address("intro"), Some((0, 0)));
241        assert_eq!(program.find_address("outro"), Some((1, 0)));
242    }
243
244    #[test]
245    fn returns_none_for_unknown_knot() {
246        let program = make_program_with_named_containers(&["intro"]);
247        assert_eq!(program.find_address("nope"), None);
248    }
249
250    #[test]
251    fn empty_program_returns_none() {
252        let program = make_program_with_named_containers(&[]);
253        assert_eq!(program.find_address("anything"), None);
254    }
255}