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}