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 its target: the defining
25    /// `DefinitionId` plus the resolved `(container_idx, byte_offset)`.
26    /// Built at link time from named scope containers; lets consumers
27    /// spawn flows at named entry points without needing `DefinitionId`s.
28    pub(crate) address_by_path: HashMap<String, PathTarget>,
29    pub(crate) root_idx: u32,
30    /// List literal values referenced by `PushList(idx)`.
31    pub(crate) list_literals: Vec<ListValue>,
32    /// Per-item metadata keyed by item `DefinitionId`.
33    pub(crate) list_item_map: HashMap<DefinitionId, ListItemEntry>,
34    /// List definitions indexed by position.
35    pub(crate) list_defs: Vec<ListDefEntry>,
36    /// Map from list def `DefinitionId` to index in `list_defs`.
37    pub(crate) list_def_map: HashMap<DefinitionId, usize>,
38    /// External function metadata keyed by the external function's `DefinitionId`.
39    pub(crate) external_fns: HashMap<DefinitionId, ExternalFnEntry>,
40    /// Compiled flow-private scope defaults for knots/stitches: the
41    /// `(path, id)` of every scope container the compiler marked
42    /// `#@local`, sorted by path so knots seed before their stitches.
43    /// The base layer of `WorldPolicy` resolution
44    /// (`docs/directive-annotations-spec.md`).
45    pub(crate) local_scope_defaults: Vec<(String, DefinitionId)>,
46}
47
48pub(crate) struct LinkedContainer {
49    pub id: DefinitionId,
50    pub bytecode: Vec<u8>,
51    pub counting_flags: CountingFlags,
52    pub path_hash: i32,
53    /// Number of declared parameters (for arity-checking host-directed entry).
54    pub param_count: u8,
55    /// Index into `Program.line_tables` for this container's scope line table.
56    pub scope_table_idx: u32,
57}
58
59pub(crate) struct GlobalSlot {
60    #[expect(dead_code, reason = "needed for save/load serialization and debugging")]
61    pub id: DefinitionId,
62    pub name: NameId,
63    pub default: Value,
64    /// Compiled flow-private (`#@local`) scope default for this global.
65    pub local: bool,
66}
67
68/// Runtime metadata for a list item.
69pub(crate) struct ListItemEntry {
70    pub name: NameId,
71    pub ordinal: i32,
72    pub origin: DefinitionId,
73}
74
75/// Runtime metadata for a list definition.
76pub(crate) struct ListDefEntry {
77    pub name: NameId,
78    /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
79    pub items: Vec<DefinitionId>,
80}
81
82/// Runtime metadata for an external function.
83pub(crate) struct ExternalFnEntry {
84    pub name: NameId,
85    pub fallback: Option<DefinitionId>,
86}
87
88/// Resolved target of a qualified path string: the defining `DefinitionId`
89/// (used for visit counting, exactly as a divert to the same target would
90/// use it) plus the linked `(container_idx, byte_offset)` position.
91#[derive(Debug, Clone, Copy)]
92pub(crate) struct PathTarget {
93    pub id: DefinitionId,
94    pub container_idx: u32,
95    pub byte_offset: usize,
96}
97
98impl Program {
99    /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
100    pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
101        self.address_map.get(&id).copied()
102    }
103
104    /// Resolve a definition ID to `(container_idx, byte_offset)`.
105    #[cfg(feature = "testing")]
106    pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
107        self.resolve_target(id)
108    }
109
110    /// Get a container by its index.
111    pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
112        &self.containers[idx as usize]
113    }
114
115    /// Get a container's bytecode by index.
116    #[cfg(feature = "testing")]
117    pub fn container_bytecode(&self, idx: u32) -> &[u8] {
118        &self.containers[idx as usize].bytecode
119    }
120
121    /// Number of containers.
122    #[cfg(feature = "testing")]
123    #[expect(
124        clippy::cast_possible_truncation,
125        reason = "container count fits in u32"
126    )]
127    pub fn container_count(&self) -> u32 {
128        self.containers.len() as u32
129    }
130
131    /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
132    pub fn source_checksum(&self) -> u32 {
133        self.source_checksum
134    }
135
136    /// Get the scope line table index for a container.
137    pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
138        self.containers[container_idx as usize].scope_table_idx
139    }
140
141    /// Look up a name by id.
142    pub(crate) fn name(&self, id: NameId) -> &str {
143        &self.name_table[id.0 as usize]
144    }
145
146    /// Look up a global slot index.
147    pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
148        self.global_map.get(&id).copied()
149    }
150
151    /// Get the root container index.
152    pub(crate) fn root_idx(&self) -> u32 {
153        self.root_idx
154    }
155
156    /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
157    ///
158    /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
159    /// for programs compiled by `brink-compiler`, author labels
160    /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
161    /// `address_paths` table (legacy `.inkb` or converter output) resolve
162    /// knot/stitch scope paths only. Use this to spawn flows at named entry
163    /// points:
164    ///
165    /// ```ignore
166    /// if let Some((idx, _)) = program.find_address("intro_scene") {
167    ///     let (flow, ctx) = FlowInstance::new_at(program, idx);
168    /// }
169    /// ```
170    #[must_use]
171    pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
172        self.address_by_path
173            .get(path)
174            .map(|t| (t.container_idx, t.byte_offset))
175    }
176
177    /// Resolve a qualified ink path to the `DefinitionId` of its target.
178    /// Same path grammar as [`find_address`](Self::find_address). Used by
179    /// `choose_path_string`, which needs the id so the jump goes through the
180    /// same divert machinery (and visit counting) as `-> path` would.
181    pub(crate) fn find_path_target(&self, path: &str) -> Option<DefinitionId> {
182        self.address_by_path.get(path).map(|t| t.id)
183    }
184
185    /// Declared parameter count of the container a `path` targets, for
186    /// arity-checking a host-directed parameterized entry. `None` if the path
187    /// is unknown. (Always `0` for converter-built programs, which don't
188    /// record param counts.)
189    pub(crate) fn path_param_count(&self, path: &str) -> Option<u8> {
190        self.address_by_path
191            .get(path)
192            .map(|t| self.containers[t.container_idx as usize].param_count)
193    }
194
195    /// Build the initial globals vector from slot defaults.
196    pub fn global_defaults(&self) -> Vec<Value> {
197        self.globals.iter().map(|s| s.default.clone()).collect()
198    }
199
200    /// Find the global variable slot index for a variable name, if declared.
201    /// Used by host-facing variable get/set (`Story::variable`/`set_variable`).
202    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
203    pub fn global_index(&self, name: &str) -> Option<u32> {
204        self.globals
205            .iter()
206            .position(|slot| self.name(slot.name) == name)
207            .map(|i| i as u32)
208    }
209
210    /// Get a list literal by index.
211    pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
212        &self.list_literals[idx as usize]
213    }
214
215    /// Look up a list item's metadata.
216    pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
217        self.list_item_map.get(&id)
218    }
219
220    /// Get a list definition by its `DefinitionId`.
221    pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
222        self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
223    }
224
225    /// Find a list definition by its string name.
226    pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
227        self.list_defs
228            .iter()
229            .find(|def| self.name(def.name) == name)
230    }
231
232    /// Look up an external function by its `DefinitionId`.
233    pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
234        self.external_fns.get(&id)
235    }
236
237    // ── Public variable introspection (host-facing) ─────────────────────────
238    // `global_index` (above), `global_name`, and `global_count` form the
239    // host-facing variable-introspection set used by `Story::variable`/
240    // `set_variable` and consumers like the RMMZ var↔switch mapping. They were
241    // previously `testing`-gated; promoted to public per the State View plan.
242
243    /// Resolve a global slot index to its variable name.
244    pub fn global_name(&self, idx: u32) -> Option<&str> {
245        self.globals
246            .get(idx as usize)
247            .map(|slot| self.name(slot.name))
248    }
249
250    // ── Compiled scope defaults (`#@local` — directive-annotations spec) ────
251
252    /// Whether the compiler marked anything flow-private. When `false`
253    /// (all existing unannotated ink), policy resolution keeps its
254    /// all-`World` fast path.
255    pub(crate) fn has_local_defaults(&self) -> bool {
256        !self.local_scope_defaults.is_empty() || self.globals.iter().any(|g| g.local)
257    }
258
259    /// Compiled flow-private default for a global slot.
260    pub(crate) fn global_is_local(&self, idx: u32) -> bool {
261        self.globals.get(idx as usize).is_some_and(|g| g.local)
262    }
263
264    /// Compiled flow-private knot/stitch defaults, sorted by path.
265    pub(crate) fn local_scope_defaults(&self) -> &[(String, DefinitionId)] {
266        &self.local_scope_defaults
267    }
268
269    /// Number of global variable slots.
270    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
271    pub fn global_count(&self) -> u32 {
272        self.globals.len() as u32
273    }
274
275    // ── Debug introspection name lookups (used by `debug_snapshot`) ──────────
276
277    /// Variable name for a global slot index.
278    pub(crate) fn global_slot_name(&self, idx: usize) -> Option<&str> {
279        self.globals.get(idx).map(|slot| self.name(slot.name))
280    }
281
282    /// Variable name for a global's defining `DefinitionId` (e.g. a
283    /// `VariablePointer` target).
284    pub(crate) fn global_var_name(&self, id: DefinitionId) -> Option<&str> {
285        let slot = self.resolve_global(id)?;
286        self.global_slot_name(slot as usize)
287    }
288
289    /// Display name for a list item by its `DefinitionId`.
290    pub(crate) fn list_item_name(&self, id: DefinitionId) -> Option<&str> {
291        self.list_item(id).map(|item| self.name(item.name))
292    }
293
294    // ── Host-facing structured value display (F4.3 web binding) ─────────────
295    // `list_members`/`divert_target_path` give a host (e.g. brink-web's wasm
296    // marshaling) the same name resolution `value_ops::stringify_list` and
297    // `debug::NameResolver` already do internally, but structured rather than
298    // pre-joined into a display string — a host may want to render a list's
299    // members or a divert's destination as distinct fields rather than text.
300    // On-demand only (not on any hot path), like `debug::NameResolver`.
301
302    /// Resolve the active members of a list value for host-facing display:
303    /// each member's origin list name, unqualified item name, and ordinal.
304    /// Sorted the same way in-story list stringification orders them
305    /// (ordinal, then origin name) so the two presentations agree.
306    #[must_use]
307    pub fn list_members(&self, list: &ListValue) -> Vec<ListMember> {
308        let mut entries: Vec<ListMember> = list
309            .items
310            .iter()
311            .filter_map(|&id| {
312                self.list_item(id).map(|entry| {
313                    let origin = self
314                        .list_def(entry.origin)
315                        .map_or_else(String::new, |def| self.name(def.name).to_owned());
316                    let full_name = self.name(entry.name);
317                    let name = full_name
318                        .split_once('.')
319                        .map_or_else(|| full_name.to_owned(), |(_, item)| item.to_owned());
320                    ListMember {
321                        origin,
322                        name,
323                        ordinal: entry.ordinal,
324                    }
325                })
326            })
327            .collect();
328        entries.sort_by(|a, b| {
329            a.ordinal
330                .cmp(&b.ordinal)
331                .then_with(|| a.origin.cmp(&b.origin))
332        });
333        entries
334    }
335
336    /// The qualified knot/stitch path a `DefinitionId` names, if it resolves
337    /// to a named scope entry (offset-0 in `address_by_path`) — the
338    /// destination of a `Value::DivertTarget` for host-facing display.
339    /// Deterministic on collision: shortest path, then lexicographically
340    /// smallest, independent of the map's iteration order (mirrors
341    /// `debug::NameResolver`'s reverse lookup).
342    #[must_use]
343    pub fn divert_target_path(&self, id: DefinitionId) -> Option<String> {
344        let (container_idx, _) = self.resolve_target(id)?;
345        let mut best: Option<&str> = None;
346        for (path, target) in &self.address_by_path {
347            if target.byte_offset != 0 || target.container_idx != container_idx {
348                continue;
349            }
350            best = Some(match best {
351                None => path.as_str(),
352                Some(existing) => {
353                    if path.len() < existing.len()
354                        || (path.len() == existing.len() && path.as_str() < existing)
355                    {
356                        path.as_str()
357                    } else {
358                        existing
359                    }
360                }
361            });
362        }
363        best.map(ToOwned::to_owned)
364    }
365}
366
367/// One active member of a list value, resolved for host-facing display. See
368/// [`Program::list_members`].
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct ListMember {
371    /// The origin list's declared name (e.g. `"Weekday"`).
372    pub origin: String,
373    /// The item's unqualified display name (e.g. `"Monday"`).
374    pub name: String,
375    /// The item's ordinal within its origin list.
376    pub ordinal: i32,
377}
378
379#[cfg(test)]
380mod find_address_tests {
381    use super::*;
382
383    fn make_program_with_named_containers(names: &[&str]) -> Program {
384        // Build a minimal Program where each name maps to a unique
385        // container_idx. Used to exercise find_address without going
386        // through the full link path.
387        let mut address_by_path = HashMap::new();
388        for (i, name) in names.iter().enumerate() {
389            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
390            address_by_path.insert(
391                (*name).to_string(),
392                PathTarget {
393                    id: DefinitionId::new(brink_format::DefinitionTag::Address, i as u64),
394                    container_idx: i as u32,
395                    byte_offset: 0,
396                },
397            );
398        }
399        Program {
400            containers: Vec::new(),
401            address_map: HashMap::new(),
402            scope_ids: Vec::new(),
403            source_checksum: 0,
404            globals: Vec::new(),
405            global_map: HashMap::new(),
406            name_table: Vec::new(),
407            address_by_path,
408            root_idx: 0,
409            list_literals: Vec::new(),
410            list_item_map: HashMap::new(),
411            list_defs: Vec::new(),
412            list_def_map: HashMap::new(),
413            external_fns: HashMap::new(),
414            local_scope_defaults: Vec::new(),
415        }
416    }
417
418    #[test]
419    fn finds_known_knot() {
420        let program = make_program_with_named_containers(&["intro", "outro"]);
421        assert_eq!(program.find_address("intro"), Some((0, 0)));
422        assert_eq!(program.find_address("outro"), Some((1, 0)));
423    }
424
425    #[test]
426    fn returns_none_for_unknown_knot() {
427        let program = make_program_with_named_containers(&["intro"]);
428        assert_eq!(program.find_address("nope"), None);
429    }
430
431    #[test]
432    fn empty_program_returns_none() {
433        let program = make_program_with_named_containers(&[]);
434        assert_eq!(program.find_address("anything"), None);
435    }
436}