Skip to main content

brink_runtime/
program.rs

1//! Immutable linked program.
2
3use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6
7use brink_format::{AliasEntry, CountingFlags, DefinitionId, ListValue, NameId, ShapeId, Value};
8
9use crate::collections::Map as HashMap;
10
11/// A linked, ready-to-execute program.
12///
13/// Created from [`StoryData`](brink_format::StoryData) via [`link()`](crate::link).
14/// Immutable after creation — mutable per-instance state lives in [`Story`](crate::Story).
15pub struct Program {
16    pub(crate) containers: Vec<LinkedContainer>,
17    /// Unified address map: `id → (container_idx, byte_offset)`.
18    /// Contains both container IDs (offset 0) and intra-container addresses.
19    pub(crate) address_map: HashMap<DefinitionId, (u32, usize)>,
20    /// Scope `DefinitionId` for each entry in the line tables (parallel vec).
21    /// Structural metadata — does not change with locale.
22    pub(crate) scope_ids: Vec<DefinitionId>,
23    /// CRC-32 checksum from the source `.inkb`, used for locale validation.
24    pub(crate) source_checksum: u32,
25    pub(crate) globals: Vec<GlobalSlot>,
26    pub(crate) global_map: HashMap<DefinitionId, u32>,
27    pub(crate) name_table: Vec<String>,
28    /// Map from a knot/stitch path string to its target: the defining
29    /// `DefinitionId` plus the resolved `(container_idx, byte_offset)`.
30    /// Built at link time from named scope containers; lets consumers
31    /// spawn flows at named entry points without needing `DefinitionId`s.
32    pub(crate) address_by_path: HashMap<String, PathTarget>,
33    pub(crate) root_idx: u32,
34    /// List literal values referenced by `PushList(idx)`.
35    pub(crate) list_literals: Vec<ListValue>,
36    /// The T1b literal pool: constant collection values referenced by
37    /// `PushLiteral(idx)` (`docs/format-v4-rfc.md` §2).
38    pub(crate) literal_pool: Vec<Value>,
39    /// Per-item metadata keyed by item `DefinitionId`.
40    pub(crate) list_item_map: HashMap<DefinitionId, ListItemEntry>,
41    /// List definitions indexed by position.
42    pub(crate) list_defs: Vec<ListDefEntry>,
43    /// Map from list def `DefinitionId` to index in `list_defs`.
44    pub(crate) list_def_map: HashMap<DefinitionId, usize>,
45    /// External function metadata keyed by the external function's `DefinitionId`.
46    pub(crate) external_fns: HashMap<DefinitionId, ExternalFnEntry>,
47    /// Compiled flow-private scope defaults for knots/stitches: the
48    /// `(path, id)` of every scope container the compiler marked
49    /// `#@local`, sorted by path so knots seed before their stitches.
50    /// The base layer of `WorldPolicy` resolution
51    /// (`docs/directive-annotations-spec.md`).
52    pub(crate) local_scope_defaults: Vec<(String, DefinitionId)>,
53    /// TM-4 `StructShapes` table (`docs/typed-mode-spec.md` §6), indexed by
54    /// `ShapeId` — every `RecordNew`/`RecordGetDyn`/`RecordSetDyn` opcode
55    /// looks a shape up here for its field count and field-name → offset
56    /// mapping. Empty until a compiler milestone emits `STRUCT`
57    /// declarations.
58    pub(crate) struct_shapes: Vec<StructShapeEntry>,
59    /// M-2b (`docs/modules-spec.md` §4): the set of `#@private` definition
60    /// ids. Used only to refuse host **semantic** access (variable get/set,
61    /// entry lookup, function eval) — the VM and host **persistence** never
62    /// consult it, so private state still executes and still saves/loads.
63    /// Empty (and never consulted) for the all-public pre-modules world.
64    /// Sorted ascending by raw id (the linker sorts it), so membership is a
65    /// `binary_search` — no set type needed for a list that is typically empty
66    /// or tiny, and `no_std`-clean.
67    pub(crate) private_defs: Vec<DefinitionId>,
68    /// M-3 (`docs/modules-spec.md` §5): the compiled `#@was` alias table,
69    /// sorted by `old` — [`Program::resolve_alias`] binary-searches it.
70    /// Empty for every story that uses no `#@was`.
71    pub(crate) alias_table: Vec<AliasEntry>,
72}
73
74/// Runtime metadata for one declared struct shape.
75pub(crate) struct StructShapeEntry {
76    /// The declared `STRUCT` name — the head of the structural display
77    /// default (`Point { x: 1, y: 2 }`, NS-A3 / stdlib-spec §9.6).
78    pub name: NameId,
79    /// Declared field names, in shape order — the same order
80    /// [`brink_format::Value::Record`]'s flat field vector follows.
81    pub fields: Vec<NameId>,
82}
83
84pub(crate) struct LinkedContainer {
85    pub id: DefinitionId,
86    pub bytecode: Vec<u8>,
87    pub counting_flags: CountingFlags,
88    pub path_hash: i32,
89    /// Number of declared parameters (for arity-checking host-directed entry).
90    pub param_count: u8,
91    /// Per-parameter name/mode metadata, in declared order (T1c, #700).
92    /// Empty for containers the converter produced or that declare no params;
93    /// used by function-value dispatch to validate a rehydrated closure's
94    /// bound env against the current signature.
95    pub params: Vec<brink_format::ParamMeta>,
96    /// Index into `Program.line_tables` for this container's scope line table.
97    pub scope_table_idx: u32,
98}
99
100pub(crate) struct GlobalSlot {
101    /// The global's `DefinitionId` — used by save/load and by M-2b
102    /// visibility enforcement ([`Program::global_is_private`]).
103    pub id: DefinitionId,
104    pub name: NameId,
105    pub default: Value,
106    /// Compiled flow-private (`#@local`) scope default for this global.
107    pub local: bool,
108}
109
110/// Runtime metadata for a list item.
111pub(crate) struct ListItemEntry {
112    pub name: NameId,
113    pub ordinal: i32,
114    pub origin: DefinitionId,
115}
116
117/// Runtime metadata for a list definition.
118pub(crate) struct ListDefEntry {
119    pub name: NameId,
120    /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
121    pub items: Vec<DefinitionId>,
122}
123
124/// Runtime metadata for an external function.
125pub(crate) struct ExternalFnEntry {
126    pub name: NameId,
127    pub fallback: Option<DefinitionId>,
128}
129
130/// Resolved target of a qualified path string: the defining `DefinitionId`
131/// (used for visit counting, exactly as a divert to the same target would
132/// use it) plus the linked `(container_idx, byte_offset)` position.
133#[derive(Debug, Clone, Copy)]
134pub(crate) struct PathTarget {
135    pub id: DefinitionId,
136    pub container_idx: u32,
137    pub byte_offset: usize,
138}
139
140impl Program {
141    /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
142    pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
143        self.address_map.get(&id).copied()
144    }
145
146    /// Whether `id` names a live container/address in the current program
147    /// (a knot/stitch/label or a synthetic child address) — the "does this
148    /// still resolve directly" half of the M-3 rehydration miss-path check
149    /// (`docs/modules-spec.md` §5).
150    pub(crate) fn knows_address(&self, id: DefinitionId) -> bool {
151        self.address_map.contains_key(&id)
152    }
153
154    /// Whether `id` names a live global slot in the current program.
155    pub(crate) fn knows_global(&self, id: DefinitionId) -> bool {
156        self.global_map.contains_key(&id)
157    }
158
159    /// Whether `id` names a live list item in the current program — the
160    /// list-item half of the M-3 rehydration miss-path check (`docs/modules-spec.md`
161    /// §5), mirroring [`knows_address`](Self::knows_address)/[`knows_global`](Self::knows_global)
162    /// for the ids embedded in a saved `Value::List` (active items).
163    pub(crate) fn knows_list_item(&self, id: DefinitionId) -> bool {
164        self.list_item_map.contains_key(&id)
165    }
166
167    /// Whether `id` names a live list definition in the current program —
168    /// the list-origin half of the M-3 rehydration miss-path check, for the
169    /// ids embedded in a saved `Value::List`'s `origins`.
170    pub(crate) fn knows_list_def(&self, id: DefinitionId) -> bool {
171        self.list_def_map.contains_key(&id)
172    }
173
174    /// M-3 rehydration miss-path lookup (`docs/modules-spec.md` §5): given
175    /// an `id` the current program doesn't recognize, consult the compiled
176    /// `#@was` alias table for its current identity. Callers still need to
177    /// check whether the returned id itself resolves — an alias chain is
178    /// never followed (the compiler always emits `old -> new` against the
179    /// definition's *current* id, never `old -> old2`).
180    pub(crate) fn resolve_alias(&self, old: DefinitionId) -> Option<DefinitionId> {
181        self.alias_table
182            .binary_search_by_key(&old, |e| e.old)
183            .ok()
184            .map(|idx| self.alias_table[idx].new)
185    }
186
187    /// Whether this program carries any `#@was`-derived alias-table
188    /// entries at all. Gates `load_state`'s miss-path reporting: an
189    /// ordinary content edit with no rename directive stays exactly as
190    /// silent as it was before M-3.
191    pub(crate) fn has_aliases(&self) -> bool {
192        !self.alias_table.is_empty()
193    }
194
195    /// Resolve a definition ID to `(container_idx, byte_offset)`.
196    #[cfg(feature = "testing")]
197    pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
198        self.resolve_target(id)
199    }
200
201    /// Get a container by its index.
202    pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
203        &self.containers[idx as usize]
204    }
205
206    /// Get a container's bytecode by index.
207    #[cfg(feature = "testing")]
208    pub fn container_bytecode(&self, idx: u32) -> &[u8] {
209        &self.containers[idx as usize].bytecode
210    }
211
212    /// Number of containers.
213    #[cfg(feature = "testing")]
214    #[expect(
215        clippy::cast_possible_truncation,
216        reason = "container count fits in u32"
217    )]
218    pub fn container_count(&self) -> u32 {
219        self.containers.len() as u32
220    }
221
222    /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
223    pub fn source_checksum(&self) -> u32 {
224        self.source_checksum
225    }
226
227    /// Get the scope line table index for a container.
228    pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
229        self.containers[container_idx as usize].scope_table_idx
230    }
231
232    /// Look up a name by id.
233    pub(crate) fn name(&self, id: NameId) -> &str {
234        &self.name_table[id.0 as usize]
235    }
236
237    /// Look up a name by id, returning `None` if the id is out of range. Used
238    /// by function-value rehydration (T1c, #700): a closure loaded from a save
239    /// produced against a *different* compile can carry a `NameId` that no
240    /// longer indexes this program's table — treated as a mismatch (fault),
241    /// never a panic.
242    ///
243    /// Public (T1d, `docs/t1d-spec.md` §6): the same "index by id, `None` if
244    /// out of range" contract a host needs to resolve a [`brink_format::Value::Handle`]'s
245    /// `kind` to its manifest-declared name — e.g. for dev-tooling display or
246    /// a host-side capability check. `bevy-brink` re-exports `Program`
247    /// (decision 2026-07-10), so this is reachable from engine code without a
248    /// direct `brink-runtime` dependency.
249    pub fn name_checked(&self, id: NameId) -> Option<&str> {
250        self.name_table.get(id.0 as usize).map(String::as_str)
251    }
252
253    /// Reverse of [`name_checked`](Self::name_checked): look up the
254    /// [`NameId`] a string interns to in this program's name table, if any.
255    ///
256    /// Public (T1d-3, `docs/t1d-spec.md` §4): a host minting a
257    /// [`brink_format::Value::Handle`] from a binding (e.g. `spawn_timer()`
258    /// returning a fresh `Handle<Timer>`) needs the compiled program's
259    /// `NameId` for the manifest-declared kind name (`"Timer"`) to build the
260    /// token — the wire form carries only the interned id, never the string.
261    /// `None` means this compile never interned that name (e.g. no
262    /// `Handle<Timer>`-typed signature or annotation anywhere in the source
263    /// graph), so no token of that kind can be minted against this program.
264    /// Linear scan, same cost class as [`global_index`](Self::global_index).
265    #[must_use]
266    pub fn name_id(&self, name: &str) -> Option<NameId> {
267        self.name_table
268            .iter()
269            .position(|n| n == name)
270            .and_then(|i| u16::try_from(i).ok())
271            .map(NameId)
272    }
273
274    /// Access a container's per-parameter name/mode metadata (T1c, #700).
275    pub(crate) fn container_params(&self, idx: u32) -> &[brink_format::ParamMeta] {
276        &self.containers[idx as usize].params
277    }
278
279    /// Look up a global slot index.
280    pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
281        self.global_map.get(&id).copied()
282    }
283
284    /// Get the root container index.
285    pub(crate) fn root_idx(&self) -> u32 {
286        self.root_idx
287    }
288
289    /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
290    ///
291    /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
292    /// for programs compiled by `brink-compiler`, author labels
293    /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
294    /// `address_paths` table (legacy `.inkb` or converter output) resolve
295    /// knot/stitch scope paths only. Use this to spawn flows at named entry
296    /// points:
297    ///
298    /// ```no_run
299    /// # fn example(program: &brink_runtime::Program) {
300    /// use brink_runtime::FlowInstance;
301    ///
302    /// if let Some((idx, _)) = program.find_address("intro_scene") {
303    ///     let (flow, ctx) = FlowInstance::new_at(program, idx);
304    /// }
305    /// # }
306    /// ```
307    #[must_use]
308    pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
309        self.address_by_path
310            .get(path)
311            .map(|t| (t.container_idx, t.byte_offset))
312    }
313
314    /// Resolve a qualified ink path to the `DefinitionId` of its target.
315    /// Same path grammar as [`find_address`](Self::find_address). Used by
316    /// `choose_path_string`, which needs the id so the jump goes through the
317    /// same divert machinery (and visit counting) as `-> path` would.
318    pub(crate) fn find_path_target(&self, path: &str) -> Option<DefinitionId> {
319        self.address_by_path.get(path).map(|t| t.id)
320    }
321
322    /// Public wrapper on [`find_path_target`](Self::find_path_target): resolve
323    /// a qualified ink path (same grammar as [`find_address`](Self::find_address))
324    /// to the `DefinitionId` of its target. Used by hosts that need the id
325    /// itself — e.g. `bevy-brink`'s wake-condition purity check (issue #995),
326    /// which looks the id up in the story's `EffectRows` table to inspect a
327    /// `FlowSleep` condition's effect row before admitting it into the wake
328    /// contract.
329    #[must_use]
330    pub fn definition_id_for_path(&self, path: &str) -> Option<DefinitionId> {
331        self.find_path_target(path)
332    }
333
334    /// Declared parameter count of the container a `path` targets, for
335    /// arity-checking a host-directed parameterized entry. `None` if the path
336    /// is unknown. (Always `0` for converter-built programs, which don't
337    /// record param counts.)
338    pub(crate) fn path_param_count(&self, path: &str) -> Option<u8> {
339        self.address_by_path
340            .get(path)
341            .map(|t| self.containers[t.container_idx as usize].param_count)
342    }
343
344    // ── Visibility (`#@private` — M-2b, docs/modules-spec.md §4) ────────────
345
346    /// Whether the compiler marked any definition `#@private`. `false` for the
347    /// entire pre-modules / all-public world — the fast path where visibility
348    /// enforcement is a single boolean check that skips every lookup below.
349    pub(crate) fn has_private_defs(&self) -> bool {
350        !self.private_defs.is_empty()
351    }
352
353    /// Whether the definition `id` was declared `#@private`.
354    pub(crate) fn is_private(&self, id: DefinitionId) -> bool {
355        self.private_defs
356            .binary_search_by_key(&id.to_raw(), |d| d.to_raw())
357            .is_ok()
358    }
359
360    /// Whether the global at slot `idx` is `#@private`.
361    pub(crate) fn global_is_private(&self, idx: u32) -> bool {
362        self.globals
363            .get(idx as usize)
364            .is_some_and(|slot| self.is_private(slot.id))
365    }
366
367    /// Whether the named entry point (knot/stitch/function path) is
368    /// `#@private`. Unknown paths are treated as not-private — resolution
369    /// failure is reported by the caller's own "not found" path, not here.
370    pub(crate) fn path_is_private(&self, path: &str) -> bool {
371        self.find_path_target(path)
372            .is_some_and(|id| self.is_private(id))
373    }
374
375    /// Whether the container at `idx` is `#@private`. Used by
376    /// [`FlowInstance::begin_function_eval`](crate::FlowInstance::begin_function_eval)/
377    /// [`begin_function_value_eval`](crate::FlowInstance::begin_function_value_eval),
378    /// which receive an already-resolved `container_idx` rather than a name
379    /// (the caller resolves it, typically via [`find_address`](Self::find_address),
380    /// before entering the VM boundary). Out-of-range indices are not
381    /// private — an invalid index is the caller's bug, reported elsewhere.
382    pub(crate) fn container_is_private(&self, idx: u32) -> bool {
383        self.containers
384            .get(idx as usize)
385            .is_some_and(|c| self.is_private(c.id))
386    }
387
388    /// Build the initial globals vector from slot defaults.
389    pub fn global_defaults(&self) -> Vec<Value> {
390        self.globals.iter().map(|s| s.default.clone()).collect()
391    }
392
393    /// Find the global variable slot index for a variable name, if declared.
394    /// Used by host-facing variable get/set (`Story::variable`/`set_variable`).
395    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
396    pub fn global_index(&self, name: &str) -> Option<u32> {
397        self.globals
398            .iter()
399            .position(|slot| self.name(slot.name) == name)
400            .map(|i| i as u32)
401    }
402
403    /// Get a list literal by index.
404    pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
405        &self.list_literals[idx as usize]
406    }
407
408    /// Get a T1b literal pool entry by index. `None` on an out-of-range
409    /// index (malformed bytecode) rather than panicking — the VM turns
410    /// this into a `RuntimeError`, never a crash.
411    pub(crate) fn literal_pool_entry(&self, idx: u32) -> Option<&Value> {
412        self.literal_pool.get(idx as usize)
413    }
414
415    /// Look up a `STRUCT` shape's runtime metadata by `ShapeId`. `None` on an
416    /// out-of-range id (malformed bytecode) rather than panicking — mirrors
417    /// [`literal_pool_entry`](Self::literal_pool_entry).
418    pub(crate) fn struct_shape(&self, shape: ShapeId) -> Option<&StructShapeEntry> {
419        self.struct_shapes.get(shape.0 as usize)
420    }
421
422    /// Look up a list item's metadata.
423    pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
424        self.list_item_map.get(&id)
425    }
426
427    /// Get a list definition by its `DefinitionId`.
428    pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
429        self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
430    }
431
432    /// Find a list definition by its string name.
433    pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
434        self.list_defs
435            .iter()
436            .find(|def| self.name(def.name) == name)
437    }
438
439    /// Look up an external function by its `DefinitionId`.
440    pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
441        self.external_fns.get(&id)
442    }
443
444    // ── Public variable introspection (host-facing) ─────────────────────────
445    // `global_index` (above), `global_name`, and `global_count` form the
446    // host-facing variable-introspection set used by `Story::variable`/
447    // `set_variable` and consumers like the RMMZ var↔switch mapping. They were
448    // previously `testing`-gated; promoted to public per the State View plan.
449
450    /// Resolve a global cell's `DefinitionId` to its slot index — the
451    /// numbering [`ContextAccess::set_global`](crate::ContextAccess) and
452    /// [`Self::global_index`] use.
453    ///
454    /// Public because effect rows (`brink_format::DirectEffects::reads` /
455    /// `writes`) name global cells by `DefinitionId` while the runtime's
456    /// world writes are keyed by slot: a host consuming rows for scheduling
457    /// (bevy-brink's row-directed wake dirtying, issue #1146) needs exactly
458    /// this bridge. `None` for an id this program declares no global for
459    /// (a stale row, a `VAR` removed by a story patch).
460    pub fn global_slot(&self, id: DefinitionId) -> Option<u32> {
461        self.resolve_global(id)
462    }
463
464    /// Resolve a global slot index to its variable name.
465    pub fn global_name(&self, idx: u32) -> Option<&str> {
466        self.globals
467            .get(idx as usize)
468            .map(|slot| self.name(slot.name))
469    }
470
471    // ── Compiled scope defaults (`#@local` — directive-annotations spec) ────
472
473    /// Whether the compiler marked anything flow-private. When `false`
474    /// (all existing unannotated ink), policy resolution keeps its
475    /// all-`World` fast path.
476    ///
477    /// Public so the bevy host (`bevy-brink`'s batch driver) can guard
478    /// against batching a `#@local`-annotated story: batch mode routes only
479    /// the shared `World`, never a flow's private `FlowLocal`, so a story
480    /// carrying compiled flow-private defaults must stay on the serial API
481    /// (`docs/effects-spec.md` §12; bevy-brink #925).
482    pub fn has_local_defaults(&self) -> bool {
483        !self.local_scope_defaults.is_empty() || self.globals.iter().any(|g| g.local)
484    }
485
486    /// Compiled flow-private default for a global slot.
487    pub(crate) fn global_is_local(&self, idx: u32) -> bool {
488        self.globals.get(idx as usize).is_some_and(|g| g.local)
489    }
490
491    /// Compiled flow-private knot/stitch defaults, sorted by path.
492    pub(crate) fn local_scope_defaults(&self) -> &[(String, DefinitionId)] {
493        &self.local_scope_defaults
494    }
495
496    /// Number of global variable slots.
497    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
498    pub fn global_count(&self) -> u32 {
499        self.globals.len() as u32
500    }
501
502    // ── Debug introspection name lookups (used by `debug_snapshot`) ──────────
503
504    /// Variable name for a global slot index.
505    pub(crate) fn global_slot_name(&self, idx: usize) -> Option<&str> {
506        self.globals.get(idx).map(|slot| self.name(slot.name))
507    }
508
509    /// Compiled `DefinitionId` for a global slot index — the identity
510    /// `save_state` round-trips into `SaveState::global_ids` so the M-3
511    /// rehydration miss path (`docs/modules-spec.md` §5) can recover a
512    /// renamed VAR/CONST/LIST global's *save-time* id (declared-module
513    /// identity is `(module, name)`-hashed, so the bare name alone can't
514    /// reconstruct it) and look it up in the compiled alias table.
515    pub(crate) fn global_id(&self, idx: usize) -> Option<DefinitionId> {
516        self.globals.get(idx).map(|slot| slot.id)
517    }
518
519    /// Variable name for a global's defining `DefinitionId` (e.g. a
520    /// `VariablePointer` target, or a T1e projection's root cell). `pub`
521    /// (not `pub(crate)`) since `brink-web`'s program-model/speculation
522    /// disassembly needs it to render a projection's root name at the wasm
523    /// boundary, the same way `divert_target_path` already resolves a
524    /// divert's `DefinitionId` for that consumer.
525    pub fn global_var_name(&self, id: DefinitionId) -> Option<&str> {
526        let slot = self.resolve_global(id)?;
527        self.global_slot_name(slot as usize)
528    }
529
530    /// Display name for a list item by its `DefinitionId`.
531    pub(crate) fn list_item_name(&self, id: DefinitionId) -> Option<&str> {
532        self.list_item(id).map(|item| self.name(item.name))
533    }
534
535    // ── Host-facing structured value display (F4.3 web binding) ─────────────
536    // `list_members`/`divert_target_path` give a host (e.g. brink-web's wasm
537    // marshaling) the same name resolution `value_ops::stringify_list` and
538    // `debug::NameResolver` already do internally, but structured rather than
539    // pre-joined into a display string — a host may want to render a list's
540    // members or a divert's destination as distinct fields rather than text.
541    // On-demand only (not on any hot path), like `debug::NameResolver`.
542
543    /// Resolve the active members of a list value for host-facing display:
544    /// each member's origin list name, unqualified item name, and ordinal.
545    /// Sorted the same way in-story list stringification orders them
546    /// (ordinal, then origin name) so the two presentations agree.
547    #[must_use]
548    pub fn list_members(&self, list: &ListValue) -> Vec<ListMember> {
549        let mut entries: Vec<ListMember> = list
550            .items
551            .iter()
552            .filter_map(|&id| {
553                self.list_item(id).map(|entry| {
554                    let origin = self
555                        .list_def(entry.origin)
556                        .map_or_else(String::new, |def| self.name(def.name).to_owned());
557                    let full_name = self.name(entry.name);
558                    let name = full_name
559                        .split_once('.')
560                        .map_or_else(|| full_name.to_owned(), |(_, item)| item.to_owned());
561                    ListMember {
562                        origin,
563                        name,
564                        ordinal: entry.ordinal,
565                    }
566                })
567            })
568            .collect();
569        entries.sort_by(|a, b| {
570            a.ordinal
571                .cmp(&b.ordinal)
572                .then_with(|| a.origin.cmp(&b.origin))
573        });
574        entries
575    }
576
577    /// The qualified knot/stitch path a `DefinitionId` names, if it resolves
578    /// to a named scope entry (offset-0 in `address_by_path`) — the
579    /// destination of a `Value::DivertTarget` for host-facing display.
580    /// Deterministic on collision: shortest path, then lexicographically
581    /// smallest, independent of the map's iteration order (mirrors
582    /// `debug::NameResolver`'s reverse lookup).
583    #[must_use]
584    pub fn divert_target_path(&self, id: DefinitionId) -> Option<String> {
585        let (container_idx, _) = self.resolve_target(id)?;
586        let mut best: Option<&str> = None;
587        for (path, target) in &self.address_by_path {
588            if target.byte_offset != 0 || target.container_idx != container_idx {
589                continue;
590            }
591            best = Some(match best {
592                None => path.as_str(),
593                Some(existing) => {
594                    if path.len() < existing.len()
595                        || (path.len() == existing.len() && path.as_str() < existing)
596                    {
597                        path.as_str()
598                    } else {
599                        existing
600                    }
601                }
602            });
603        }
604        best.map(ToOwned::to_owned)
605    }
606}
607
608/// One active member of a list value, resolved for host-facing display. See
609/// [`Program::list_members`].
610#[derive(Debug, Clone, PartialEq, Eq)]
611pub struct ListMember {
612    /// The origin list's declared name (e.g. `"Weekday"`).
613    pub origin: String,
614    /// The item's unqualified display name (e.g. `"Monday"`).
615    pub name: String,
616    /// The item's ordinal within its origin list.
617    pub ordinal: i32,
618}
619
620#[cfg(test)]
621mod find_address_tests {
622    use super::*;
623
624    fn make_program_with_named_containers(names: &[&str]) -> Program {
625        // Build a minimal Program where each name maps to a unique
626        // container_idx. Used to exercise find_address without going
627        // through the full link path.
628        let mut address_by_path = HashMap::new();
629        for (i, name) in names.iter().enumerate() {
630            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
631            address_by_path.insert(
632                (*name).to_string(),
633                PathTarget {
634                    id: DefinitionId::new(brink_format::DefinitionTag::Address, i as u64),
635                    container_idx: i as u32,
636                    byte_offset: 0,
637                },
638            );
639        }
640        Program {
641            containers: Vec::new(),
642            address_map: HashMap::new(),
643            scope_ids: Vec::new(),
644            source_checksum: 0,
645            globals: Vec::new(),
646            global_map: HashMap::new(),
647            name_table: Vec::new(),
648            address_by_path,
649            root_idx: 0,
650            list_literals: Vec::new(),
651            literal_pool: Vec::new(),
652            list_item_map: HashMap::new(),
653            list_defs: Vec::new(),
654            list_def_map: HashMap::new(),
655            external_fns: HashMap::new(),
656            local_scope_defaults: Vec::new(),
657            struct_shapes: Vec::new(),
658            private_defs: Vec::new(),
659            alias_table: Vec::new(),
660        }
661    }
662
663    #[test]
664    fn finds_known_knot() {
665        let program = make_program_with_named_containers(&["intro", "outro"]);
666        assert_eq!(program.find_address("intro"), Some((0, 0)));
667        assert_eq!(program.find_address("outro"), Some((1, 0)));
668    }
669
670    #[test]
671    fn returns_none_for_unknown_knot() {
672        let program = make_program_with_named_containers(&["intro"]);
673        assert_eq!(program.find_address("nope"), None);
674    }
675
676    #[test]
677    fn empty_program_returns_none() {
678        let program = make_program_with_named_containers(&[]);
679        assert_eq!(program.find_address("anything"), None);
680    }
681}