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::{
8 AliasEntry, CountingFlags, DebugInfoSection, DefinitionId, ListValue, NameId, ShapeId, Value,
9};
10
11use crate::collections::Map as HashMap;
12use crate::error::RuntimeError;
13
14/// A linked, ready-to-execute program.
15///
16/// Created from [`StoryData`](brink_format::StoryData) via [`link()`](crate::link).
17/// Immutable after creation — mutable per-instance state lives in [`Story`](crate::Story).
18pub struct Program {
19 pub(crate) containers: Vec<LinkedContainer>,
20 /// What the linker derived from the symbolic bytecode: see [`LinkTables`].
21 pub(crate) link: LinkTables,
22 /// Unified address map: `id → (container_idx, byte_offset)`.
23 /// Contains both container IDs (offset 0) and intra-container addresses.
24 pub(crate) address_map: HashMap<DefinitionId, (u32, usize)>,
25 /// Scope `DefinitionId` for each entry in the line tables (parallel vec).
26 /// Structural metadata — does not change with locale.
27 pub(crate) scope_ids: Vec<DefinitionId>,
28 /// CRC-32 checksum from the source `.inkb`, used for locale validation.
29 pub(crate) source_checksum: u32,
30 pub(crate) globals: Vec<GlobalSlot>,
31 pub(crate) global_map: HashMap<DefinitionId, u32>,
32 pub(crate) name_table: Vec<String>,
33 /// Map from a knot/stitch path string to its target: the defining
34 /// `DefinitionId` plus the resolved `(container_idx, byte_offset)`.
35 /// Built at link time from named scope containers; lets consumers
36 /// spawn flows at named entry points without needing `DefinitionId`s.
37 pub(crate) address_by_path: HashMap<String, PathTarget>,
38 /// `container_idx → shortest knot/stitch path` for every container that
39 /// is the offset-0 target of an author-facing path — the reverse of
40 /// `address_by_path`, built once at link time. Backs
41 /// [`Program::container_path`] and, through it, the runtime's
42 /// [`current_path`](crate::Story::current_path) query and the
43 /// debugger's location names. Deterministic on collision: the shortest
44 /// path, then the lexicographically smallest.
45 pub(crate) container_paths: HashMap<u32, String>,
46 pub(crate) root_idx: u32,
47 /// List literal values referenced by `PushList(idx)`.
48 pub(crate) list_literals: Vec<ListValue>,
49 /// The T1b literal pool: constant collection values referenced by
50 /// `PushLiteral(idx)` (`docs/format-v4-rfc.md` §2).
51 pub(crate) literal_pool: Vec<Value>,
52 /// Per-item metadata keyed by item `DefinitionId`.
53 pub(crate) list_item_map: HashMap<DefinitionId, ListItemEntry>,
54 /// List definitions indexed by position.
55 pub(crate) list_defs: Vec<ListDefEntry>,
56 /// Map from list def `DefinitionId` to index in `list_defs`.
57 pub(crate) list_def_map: HashMap<DefinitionId, usize>,
58 /// External function metadata keyed by the external function's `DefinitionId`.
59 pub(crate) external_fns: HashMap<DefinitionId, ExternalFnEntry>,
60 /// Compiled flow-private scope defaults for knots/stitches: the
61 /// `(path, id)` of every scope container the compiler marked
62 /// `#@local`, sorted by path so knots seed before their stitches.
63 /// The base layer of `WorldPolicy` resolution
64 /// (`docs/directive-annotations-spec.md`).
65 pub(crate) local_scope_defaults: Vec<(String, DefinitionId)>,
66 /// TM-4 `StructShapes` table (`docs/typed-mode-spec.md` §6), indexed by
67 /// `ShapeId` — every `RecordNew`/`RecordGetDyn`/`RecordSetDyn` opcode
68 /// looks a shape up here for its field count and field-name → offset
69 /// mapping. Empty until a compiler milestone emits `STRUCT`
70 /// declarations.
71 pub(crate) struct_shapes: Vec<StructShapeEntry>,
72 /// M-2b (`docs/modules-spec.md` §4): the set of `#@private` definition
73 /// ids. Used only to refuse host **semantic** access (variable get/set,
74 /// entry lookup, function eval) — the VM and host **persistence** never
75 /// consult it, so private state still executes and still saves/loads.
76 /// Empty (and never consulted) for the all-public pre-modules world.
77 /// Sorted ascending by raw id (the linker sorts it), so membership is a
78 /// `binary_search` — no set type needed for a list that is typically empty
79 /// or tiny, and `no_std`-clean.
80 pub(crate) private_defs: Vec<DefinitionId>,
81 /// M-3 (`docs/modules-spec.md` §5): the compiled `#@was` alias table,
82 /// sorted by `old` — [`Program::resolve_alias`] binary-searches it.
83 /// Empty for every story that uses no `#@was`.
84 pub(crate) alias_table: Vec<AliasEntry>,
85 /// D6's `DebugInfo` section (`docs/debugger-spec.md` §2, `.inkb` tag
86 /// `0x11`), carried through unchanged from `StoryData::debug_info` —
87 /// `None` for a release-exported / non-debug compile (§1.2's ship
88 /// policy, never requested) or any story compiled before D6. Two
89 /// consumers, both reading it lockstep with the `Containers` table
90 /// this `Program` already links (no `DefinitionId` lookup on either
91 /// read path, per the section's own design):
92 ///
93 /// - [`Program::resolve_debug_position`] (D9, #3187) — a running
94 /// `(container_idx, offset)` position resolves to a source range by
95 /// direct index into `containers[container_idx]`.
96 /// - [`Program::scope_debug_locals`] (D7, #3185), behind
97 /// [`crate::debug::DebugFrame::locals`] — §3's per-container
98 /// `LocalsTable` names a call frame's live temp slots.
99 pub(crate) debug_info: Option<DebugInfoSection>,
100}
101
102/// Runtime metadata for one declared struct shape.
103pub(crate) struct StructShapeEntry {
104 /// The declared `STRUCT` name — the head of the structural display
105 /// default (`Point { x: 1, y: 2 }`, NS-A3 / stdlib-spec §9.6).
106 pub name: NameId,
107 /// Declared field names, in shape order — the same order
108 /// [`brink_format::Value::Record`]'s flat field vector follows.
109 pub fields: Vec<NameId>,
110}
111
112/// A static jump/call target, resolved once by the linker.
113///
114/// `id` is kept alongside the position because the VM still needs the
115/// address identity at run time — visit and turn counts are keyed by it —
116/// and the two rulings behind hot-reload (decision log 2026-03-01) make the
117/// id the stable identity across relinks while `container_idx` is not.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub(crate) struct LinkedTarget {
120 pub container_idx: u32,
121 pub offset: usize,
122 pub id: DefinitionId,
123}
124
125/// The linker's derived layer over the symbolic bytecode.
126///
127/// `.inkb` stores `DefinitionId`s in every jump and call, with no
128/// compile-time indices (decision log 2026-03-01, "`.inkb` stores
129/// `ContainerId`s … resolved to fast internal indices at load time"). Until
130/// this table existed, "at load time" meant a hash lookup on every
131/// `Goto`/`Call`/`BeginChoice` the VM executed — 1.7% of `TheIntercept`'s
132/// instructions in `Program::resolve_target`. Now the linker walks each
133/// container once, interns every resolvable static target into `targets`,
134/// and writes the target's ordinal over the operand bytes in its own copy
135/// of the code (`code[i]` is `containers[i].bytecode` with those operands
136/// rewritten — same length, same offsets). The VM indexes `targets` by
137/// that ordinal; nothing hashes. A global's operand (`GetGlobal`,
138/// `SetGlobal`, `TakeGlobal`) is rewritten the same way to its slot index in
139/// `globals`, which is already dense, so it needs no table of its own.
140///
141/// The symbolic `bytecode` stays untouched on each `LinkedContainer` for
142/// every other decoder (the debugger, `container_bytecode`, tests):
143/// `Opcode::decode` is not defined over the rewritten copy. An operand the
144/// linker cannot resolve is left symbolic in `code` too, so an unresolved
145/// divert still fails exactly where and how it did before — when reached.
146///
147/// Rebuilt on every link, so a hot patch that renumbers containers simply
148/// produces a new table; nothing here is persisted.
149#[derive(Debug, Clone, Default)]
150pub(crate) struct LinkTables {
151 pub code: Vec<Vec<u8>>,
152 pub targets: Vec<LinkedTarget>,
153}
154
155/// The operand at a static site of *linked* code: `Some(n)` — a target's
156/// ordinal into [`LinkTables::targets`], or a global's slot index — when the
157/// linker resolved it, `None` when the bytes still hold a symbolic
158/// `DefinitionId`.
159///
160/// The two are distinguishable by the last byte: an id's top byte is its
161/// `DefinitionTag`, never zero (`brink_format::DefinitionTag` starts at
162/// `0x01`), while an ordinal is written as a little-endian `u64` whose top
163/// four bytes are zero.
164pub(crate) fn linked_ordinal(operand: &[u8]) -> Option<u32> {
165 let raw = u64::from_le_bytes(operand.try_into().ok()?);
166 #[expect(clippy::cast_possible_truncation, reason = "top 32 bits checked zero")]
167 (raw >> 32 == 0).then_some(raw as u32)
168}
169
170/// The linked form of a target ordinal — the inverse of [`linked_ordinal`].
171pub(crate) fn linked_operand(ordinal: u32) -> [u8; 8] {
172 u64::from(ordinal).to_le_bytes()
173}
174
175pub(crate) struct LinkedContainer {
176 pub id: DefinitionId,
177 pub bytecode: Vec<u8>,
178 pub counting_flags: CountingFlags,
179 pub path_hash: i32,
180 /// Number of declared parameters (for arity-checking host-directed entry).
181 pub param_count: u8,
182 /// Per-parameter name/mode metadata, in declared order (T1c, #700).
183 /// Empty for containers the converter produced or that declare no params;
184 /// used by function-value dispatch to validate a rehydrated closure's
185 /// bound env against the current signature.
186 pub params: Vec<brink_format::ParamMeta>,
187 /// Index into `Program.line_tables` for this container's scope line table.
188 pub scope_table_idx: u32,
189 /// The lexical scope this container belongs to (`ContainerDef::
190 /// scope_id`'s own doc: `scope_id == id` for a scope container itself —
191 /// root/knot/stitch — and the enclosing scope's `id` for a child
192 /// container — gather, choice target, sequence branch, etc.). D7
193 /// (`docs/debugger-spec.md` §3, #3185): the key
194 /// [`Program::scope_debug_locals`] groups by, since a call frame's
195 /// `container_stack` can legitimately drop back to a single entry
196 /// mid-frame (`vm::goto_target`'s "target not already on the stack"
197 /// branch clears and replaces it — verified on `origin/main`) while the
198 /// frame's declared locals stay live in its `temps` regardless of which
199 /// child container the current leaf position sits in.
200 pub scope_id: DefinitionId,
201}
202
203pub(crate) struct GlobalSlot {
204 /// The global's `DefinitionId` — used by save/load and by M-2b
205 /// visibility enforcement ([`Program::global_is_private`]).
206 pub id: DefinitionId,
207 pub name: NameId,
208 pub default: Value,
209 /// Compiled flow-private (`#@local`) scope default for this global.
210 pub local: bool,
211}
212
213/// Runtime metadata for a list item.
214pub(crate) struct ListItemEntry {
215 pub name: NameId,
216 pub ordinal: i32,
217 pub origin: DefinitionId,
218}
219
220/// Runtime metadata for a list definition.
221pub(crate) struct ListDefEntry {
222 pub name: NameId,
223 /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
224 pub items: Vec<DefinitionId>,
225}
226
227/// Runtime metadata for an external function.
228pub(crate) struct ExternalFnEntry {
229 pub name: NameId,
230 pub fallback: Option<DefinitionId>,
231}
232
233/// Resolved target of a qualified path string: the defining `DefinitionId`
234/// (used for visit counting, exactly as a divert to the same target would
235/// use it) plus the linked `(container_idx, byte_offset)` position.
236#[derive(Debug, Clone, Copy)]
237pub(crate) struct PathTarget {
238 pub id: DefinitionId,
239 pub container_idx: u32,
240 pub byte_offset: usize,
241}
242
243/// Build the `container_idx → path` index [`Program::container_paths`]
244/// holds: offset-0 targets only, shortest path (then lexicographically
245/// smallest) per container — independent of map iteration order.
246pub(crate) fn container_paths_from(
247 address_by_path: &HashMap<String, PathTarget>,
248) -> HashMap<u32, String> {
249 let mut rev: HashMap<u32, String> = HashMap::new();
250 for (path, target) in address_by_path {
251 if target.byte_offset != 0 {
252 continue;
253 }
254 let better = match rev.get(&target.container_idx) {
255 None => true,
256 Some(existing) => {
257 path.len() < existing.len()
258 || (path.len() == existing.len() && path.as_str() < existing.as_str())
259 }
260 };
261 if better {
262 rev.insert(target.container_idx, path.clone());
263 }
264 }
265 rev
266}
267
268impl Program {
269 /// The knot or `knot.stitch` path a container names, if it names one.
270 /// Exact: an anonymous container (a choice body, gather, sequence
271 /// branch) is `None` — the save format relies on that to write such a
272 /// container's visit entry without a path. For "where is this
273 /// container" see [`Program::scope_path`].
274 #[must_use]
275 pub fn container_path(&self, idx: u32) -> Option<&str> {
276 self.container_paths.get(&idx).map(String::as_str)
277 }
278
279 /// The knot or `knot.stitch` a container sits in: its own path when it
280 /// names one, otherwise its lexical scope's — a choice body, gather, or
281 /// sequence branch reports the knot/stitch that holds it (after
282 /// `choose`, the frame holds only the chosen branch, and the story is
283 /// still in that knot). `None` for the root scope and anything directly
284 /// under it. The runtime's own vocabulary for "where am I" — see
285 /// [`Story::current_path`](crate::Story::current_path).
286 #[must_use]
287 pub fn scope_path(&self, idx: u32) -> Option<&str> {
288 if let Some(path) = self.container_path(idx) {
289 return Some(path);
290 }
291 let scope_id = self.containers.get(usize::try_from(idx).ok()?)?.scope_id;
292 let (scope_idx, _) = self.resolve_target(scope_id)?;
293 if scope_idx == idx {
294 return None;
295 }
296 self.container_path(scope_idx)
297 }
298
299 /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
300 pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
301 self.address_map.get(&id).copied()
302 }
303
304 /// Whether `id` names a live container/address in the current program
305 /// (a knot/stitch/label or a synthetic child address) — the "does this
306 /// still resolve directly" half of the M-3 rehydration miss-path check
307 /// (`docs/modules-spec.md` §5).
308 pub(crate) fn knows_address(&self, id: DefinitionId) -> bool {
309 self.address_map.contains_key(&id)
310 }
311
312 /// Whether `id` names a live global slot in the current program.
313 pub(crate) fn knows_global(&self, id: DefinitionId) -> bool {
314 self.global_map.contains_key(&id)
315 }
316
317 /// Whether `id` names a live list item in the current program — the
318 /// list-item half of the M-3 rehydration miss-path check (`docs/modules-spec.md`
319 /// §5), mirroring [`knows_address`](Self::knows_address)/[`knows_global`](Self::knows_global)
320 /// for the ids embedded in a saved `Value::List` (active items).
321 pub(crate) fn knows_list_item(&self, id: DefinitionId) -> bool {
322 self.list_item_map.contains_key(&id)
323 }
324
325 /// Whether `id` names a live list definition in the current program —
326 /// the list-origin half of the M-3 rehydration miss-path check, for the
327 /// ids embedded in a saved `Value::List`'s `origins`.
328 pub(crate) fn knows_list_def(&self, id: DefinitionId) -> bool {
329 self.list_def_map.contains_key(&id)
330 }
331
332 /// M-3 rehydration miss-path lookup (`docs/modules-spec.md` §5): given
333 /// an `id` the current program doesn't recognize, consult the compiled
334 /// `#@was` alias table for its current identity. Callers still need to
335 /// check whether the returned id itself resolves — an alias chain is
336 /// never followed (the compiler always emits `old -> new` against the
337 /// definition's *current* id, never `old -> old2`).
338 pub(crate) fn resolve_alias(&self, old: DefinitionId) -> Option<DefinitionId> {
339 self.alias_table
340 .binary_search_by_key(&old, |e| e.old)
341 .ok()
342 .map(|idx| self.alias_table[idx].new)
343 }
344
345 /// Whether this program carries any `#@was`-derived alias-table
346 /// entries at all. Gates `load_state`'s miss-path reporting: an
347 /// ordinary content edit with no rename directive stays exactly as
348 /// silent as it was before M-3.
349 pub(crate) fn has_aliases(&self) -> bool {
350 !self.alias_table.is_empty()
351 }
352
353 /// Resolve a definition ID to `(container_idx, byte_offset)`.
354 ///
355 /// Promoted from `#[cfg(feature = "testing")]` to real public API by
356 /// W2 (#3295): with [`Self::definition_id_for_path`] it is the
357 /// name-based half of source→program addressing ("break on
358 /// `tavern.order`"), which the wasm bridge composes as
359 /// `resolve_path_address`. A pure lookup over the container table —
360 /// nothing here touches the VM hot path, so the `step_once` promotion
361 /// warning (`docs/debugger-spec.md` §1.4) does not apply.
362 #[must_use]
363 pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
364 self.resolve_target(id)
365 }
366
367 /// The program→source resolver (D9, issue #3187; wire encoding: D6,
368 /// `docs/debugger-spec.md` §2.2). Resolves a runtime execution position
369 /// — [`crate::DebugPosition`], as reported by
370 /// [`crate::DebugSnapshot::position`]/[`crate::DebugFrame::position`]
371 /// (D4, #3182) — to the source range it was compiled from, via this
372 /// program's `DebugInfo` section.
373 ///
374 /// `None` when:
375 /// - no `DebugInfo` section is present (a release-exported or
376 /// `--debug-info`-less compile — §1.2 ship policy: this is the
377 /// expected, non-error case for most builds, not a fault);
378 /// - `container_idx` is out of range for the section's container table
379 /// (defensive — should not happen for a position this same `Program`
380 /// produced);
381 /// - `offset` is before the container's first recorded entry (the
382 /// section's coverage guarantee, §2.2, means this should not happen
383 /// for a real instruction boundary either, but a reader must not
384 /// panic on an adversarial/malformed position).
385 ///
386 /// The returned range's `file` is `None` for the reserved synthetic
387 /// sentinel file (index 0, §2.5) — a compiler-synthesized construct
388 /// with no author source to point at — and `Some(path)` (project-root-
389 /// relative) otherwise. This is exactly the `path`/`span` pair the
390 /// studio's `source` Location space needs (`docs/studio-shell-spec.md`
391 /// §6.1) — a caller's `program` resolver wraps this method and returns
392 /// `{ kind: "source", file, span: { start: range_start, end:
393 /// range_start + range_len } }`.
394 ///
395 /// Entries within a container are sorted ascending by
396 /// `bytecode_offset` and cover the container's full address range with
397 /// no gaps (§2.2), so a floor lookup — the last entry whose
398 /// `bytecode_offset` is `<= offset` — always names the instruction's
399 /// own statement, matching how a running VM's `offset` (the *next*
400 /// instruction to execute, always itself a decoded instruction
401 /// boundary) lines up against entries recorded at instruction
402 /// boundaries during codegen's own walk.
403 #[must_use]
404 pub fn resolve_debug_position(
405 &self,
406 position: crate::debug::DebugPosition,
407 ) -> Option<crate::debug::DebugSourceLocation> {
408 let entry = self.debug_entry_at(position)?;
409 let debug_info = self.debug_info.as_ref()?;
410 let file = debug_info.files.get(entry.file_idx as usize)?;
411 let path = match file.surface {
412 brink_format::FileSurface::Synthetic => None,
413 brink_format::FileSurface::Ink | brink_format::FileSurface::Native => {
414 Some(file.path.clone())
415 }
416 };
417 Some(crate::debug::DebugSourceLocation {
418 file: path,
419 range_start: entry.range_start,
420 range_len: entry.range_len,
421 })
422 }
423
424 /// The `DebugInfo` entry covering `position` — the floor lookup both
425 /// [`Self::resolve_debug_position`] and [`Self::debug_line_key`] share.
426 fn debug_entry_at(
427 &self,
428 position: crate::debug::DebugPosition,
429 ) -> Option<&brink_format::DebugEntry> {
430 let debug_info = self.debug_info.as_ref()?;
431 let table = debug_info.containers.get(position.container_idx as usize)?;
432 let target = u32::try_from(position.offset).ok()?;
433 let idx = match table
434 .entries
435 .binary_search_by_key(&target, |e| e.bytecode_offset)
436 {
437 Ok(i) => i,
438 Err(0) => return None,
439 Err(i) => i - 1,
440 };
441 table.entries.get(idx)
442 }
443
444 /// A cheap identity for "which source line is this position on":
445 /// `(file_idx, line_idx)`, both section-local and 0-based (#3264).
446 ///
447 /// Deliberately not `(String, u32)`: line stepping compares this once
448 /// per VM instruction, and cloning a path per instruction to answer
449 /// "same line?" would make the verb's cost scale with path length for
450 /// no benefit. The indices are only ever compared to each other, never
451 /// shown, so they never need resolving to text.
452 ///
453 /// `None` when the artifact carries no `DebugInfo`, the position has no
454 /// covering entry, or that file carries no line index (compiled without
455 /// source text — see `DebugFileEntry::line_starts`).
456 #[cfg(feature = "debug-hooks")]
457 pub(crate) fn debug_line_key(
458 &self,
459 position: crate::debug::DebugPosition,
460 ) -> Option<(u32, u32)> {
461 let entry = self.debug_entry_at(position)?;
462 let file = self
463 .debug_info
464 .as_ref()?
465 .files
466 .get(entry.file_idx as usize)?;
467 let line = Self::line_index_in(file, entry.range_start)?;
468 Some((entry.file_idx, line))
469 }
470}
471
472/// [`Program::resolve_debug_line`]'s answer: where a bytecode position
473/// sits in author-facing source, at both granularities the debugger
474/// serves — the line (the author tier's band/chip) and the covering
475/// entry's byte range (the finer tiers: expression rows, instruction
476/// stepping, step-out's mid-line call-site landing).
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub struct ResolvedDebugLine<'a> {
479 pub file: &'a str,
480 /// 0-based.
481 pub line: u32,
482 /// Byte offset in `file`, as the compiler consumed it.
483 pub range_start: u32,
484 pub range_len: u32,
485}
486
487impl Program {
488 /// The `file:line` — plus the covering entry's exact byte range — of a
489 /// bytecode position (W6/#3299). The line places the execution
490 /// highlight's band and the paused chip; the RANGE rides along so
491 /// finer-than-line consumers need no new seam: expression-level
492 /// entries (D1's unflagged rows, once #3183's `Expr` provenance
493 /// lands), instruction stepping in the editor, and the one
494 /// mid-line case that exists TODAY — a step-out lands at the call
495 /// site, not a line start (`docs/debugger-spec.md` §4's `finish`
496 /// semantics).
497 ///
498 /// 0-based line (UIs showing 1-based convert at their edge). `None`
499 /// when the position doesn't resolve, resolves to the synthetic
500 /// sentinel, or that file carries no line index.
501 #[must_use]
502 pub fn resolve_debug_line(
503 &self,
504 position: crate::debug::DebugPosition,
505 ) -> Option<ResolvedDebugLine<'_>> {
506 let entry = self.debug_entry_at(position)?;
507 let file = self
508 .debug_info
509 .as_ref()?
510 .files
511 .get(entry.file_idx as usize)?;
512 if !matches!(
513 file.surface,
514 brink_format::FileSurface::Ink | brink_format::FileSurface::Native
515 ) {
516 return None;
517 }
518 let line = Self::line_index_in(file, entry.range_start)?;
519 Some(ResolvedDebugLine {
520 file: file.path.as_str(),
521 line,
522 range_start: entry.range_start,
523 range_len: entry.range_len,
524 })
525 }
526
527 /// 0-based line containing `byte` within `file`'s line index, or `None`
528 /// when that file carries no index or the offset precedes its first
529 /// line start (which a well-formed index makes impossible, since it
530 /// always begins at 0).
531 fn line_index_in(file: &brink_format::DebugFileEntry, byte: u32) -> Option<u32> {
532 if file.line_starts.is_empty() {
533 return None;
534 }
535 // `partition_point` gives the count of starts at or before `byte`;
536 // the line is one less. Never underflows for a well-formed index,
537 // whose first start is 0 — but a malformed artifact must degrade to
538 // `None` rather than wrap.
539 let count = file.line_starts.partition_point(|&s| s <= byte);
540 u32::try_from(count.checked_sub(1)?).ok()
541 }
542
543 /// 0-based line containing `byte` in `file` (#3264) — the public form
544 /// of the lookup [`Self::debug_line_key`] uses internally. `None` when
545 /// the file is unknown or carries no line index.
546 #[must_use]
547 pub fn line_at(&self, file: &str, byte: u32) -> Option<u32> {
548 let debug_info = self.debug_info.as_ref()?;
549 let entry = debug_info.files.iter().find(|f| {
550 matches!(
551 f.surface,
552 brink_format::FileSurface::Ink | brink_format::FileSurface::Native
553 ) && f.path == file
554 })?;
555 Self::line_index_in(entry, byte)
556 }
557
558 /// The inverse of [`Self::resolve_debug_position`] (D9/#3187): the
559 /// program address to break on for a span of **source** text — issue
560 /// #3246, the half a breakpoint gutter needs. `BreakpointSet` is keyed
561 /// by `(container_idx, offset)`; an editor speaks in source. This maps
562 /// the latter to the former.
563 ///
564 /// # Why a byte range and not a line number
565 ///
566 /// The `DebugInfo` section records **byte ranges**, and a `Program`
567 /// holds neither source text nor a line table — so it physically
568 /// cannot turn "line 7" into bytes. That conversion belongs where the
569 /// source already lives (the editor, the CLI's own file read), which
570 /// also keeps the UTF-8/UTF-16 question out of the runtime entirely.
571 /// The caller passes the half-open byte range `[start, end)` it
572 /// considers "the line" (or a selection, or any span), and this answers
573 /// where to break within it.
574 ///
575 /// # Which candidate wins
576 ///
577 /// Every entry in every container whose file is `file` and whose
578 /// `range_start` lies in `[start, end)` is a candidate. The winner is
579 /// the minimum by `(range_start, container_idx, bytecode_offset)`:
580 ///
581 /// - **`range_start` first** — the textually earliest construct in the
582 /// span, which is what "break on this line" means to a person. Note
583 /// this is deliberately *not* "lowest `container_idx`": containers
584 /// are independent bytecode streams with no execution order between
585 /// them, so ordering by container index would be arbitrary dressed
586 /// up as a rule.
587 /// - **then `container_idx`, then `bytecode_offset`** — pure
588 /// tie-breaking, so a given span always yields the same address
589 /// rather than whichever entry iteration happened to reach first
590 /// (`CLAUDE.md`: determinism matters).
591 ///
592 /// # `None` is a real answer, not a failure
593 ///
594 /// Returns `None` when the span contains no executable code at all — a
595 /// comment, a blank line, a line whose code folded away — and when the
596 /// artifact carries no `DebugInfo` or names no such file. Callers
597 /// **must** surface that: a gutter has to refuse to arm visibly,
598 /// because a breakpoint that silently never hits is worse than no
599 /// breakpoint.
600 ///
601 /// Entries whose file is the reserved synthetic sentinel (§2.5) never
602 /// match, since no author-facing path names it.
603 #[must_use]
604 pub fn resolve_source_range(
605 &self,
606 file: &str,
607 start: u32,
608 end: u32,
609 ) -> Option<crate::debug::DebugPosition> {
610 let debug_info = self.debug_info.as_ref()?;
611
612 // Path -> file table index. Synthetic entries carry no
613 // author-facing path and must never match one.
614 let file_idx = u32::try_from(debug_info.files.iter().position(|f| {
615 matches!(
616 f.surface,
617 brink_format::FileSurface::Ink | brink_format::FileSurface::Native
618 ) && f.path == file
619 })?)
620 .ok()?;
621
622 let mut best: Option<(u32, u32, u32)> = None;
623 for (container_idx, table) in debug_info.containers.iter().enumerate() {
624 let Ok(container_idx) = u32::try_from(container_idx) else {
625 continue;
626 };
627 for entry in &table.entries {
628 if entry.file_idx != file_idx
629 || entry.range_start < start
630 || entry.range_start >= end
631 {
632 continue;
633 }
634 let candidate = (entry.range_start, container_idx, entry.bytecode_offset);
635 if best.is_none_or(|current| candidate < current) {
636 best = Some(candidate);
637 }
638 }
639 }
640
641 let (_, container_idx, bytecode_offset) = best?;
642 Some(crate::debug::DebugPosition {
643 container_idx,
644 offset: bytecode_offset as usize,
645 })
646 }
647
648 /// Whether this program carries a `DebugInfo` section at all (#3248).
649 ///
650 /// Every other debug accessor returns `None` for two very different
651 /// reasons — "this artifact was compiled without `--debug-info`" and
652 /// "that particular position/line has nothing on it" — and a debugger
653 /// front-end must tell a user which. Reporting "that line has no
654 /// executable code" for a story compiled without the flag sends the
655 /// author hunting a bug in their source when the fix is a compiler
656 /// flag. This is the cheap discriminator that keeps that message
657 /// honest; it says nothing about whether any *particular* lookup will
658 /// succeed.
659 #[must_use]
660 pub fn has_debug_info(&self) -> bool {
661 self.debug_info.is_some()
662 }
663
664 /// The program address to break on for a **line** of source, with no
665 /// source text required (#3261) — the `DebugInfo` file table carries a
666 /// per-file line index, so the engine can answer `file:line` directly.
667 ///
668 /// `line` is **0-based**. Every UI that shows 1-based line numbers
669 /// converts at its own edge; keeping the engine 0-based means the
670 /// fencepost lives in exactly one place per consumer instead of being
671 /// re-decided here.
672 ///
673 /// This is the shape a remote debugger frontend needs — DAP's
674 /// `setBreakpoints` is file + line, and an adapter may hold no source
675 /// at all. It is a thin wrapper over
676 /// [`Self::resolve_source_range`]: the line index turns the line into
677 /// its half-open byte span, and the same "textually earliest construct
678 /// wins" rule picks the address.
679 ///
680 /// `None` when the file is unknown, carries no line index (compiled
681 /// before this data existed, or with no source text supplied), the line
682 /// is past the end of the file, or the line holds no executable code —
683 /// a comment, a blank, a line whose code folded away. Callers must
684 /// surface that: a gutter has to refuse to arm visibly, because a
685 /// breakpoint that silently never hits is worse than no breakpoint.
686 #[must_use]
687 pub fn resolve_source_line(
688 &self,
689 file: &str,
690 line: u32,
691 ) -> Option<crate::debug::DebugPosition> {
692 let (start, end) = self.line_span(file, line)?;
693 self.resolve_source_range(file, start, end)
694 }
695
696 /// The half-open byte span `[start, end)` of a 0-based `line` in `file`,
697 /// from the `DebugInfo` file table's line index (#3261). `None` when the
698 /// file is unknown, has no line index, or the line is past its end.
699 ///
700 /// The last line runs to the end of the file, which the index does not
701 /// record — so it is represented as `u32::MAX`, an end bound no real
702 /// `range_start` can reach. That is deliberate rather than clamping to
703 /// a length the section does not carry.
704 #[must_use]
705 pub fn line_span(&self, file: &str, line: u32) -> Option<(u32, u32)> {
706 let debug_info = self.debug_info.as_ref()?;
707 let entry = debug_info.files.iter().find(|f| {
708 matches!(
709 f.surface,
710 brink_format::FileSurface::Ink | brink_format::FileSurface::Native
711 ) && f.path == file
712 })?;
713 let idx = line as usize;
714 let start = *entry.line_starts.get(idx)?;
715 let end = entry.line_starts.get(idx + 1).copied().unwrap_or(u32::MAX);
716 Some((start, end))
717 }
718
719 /// Whether `text` is byte-identical to the source `file` was compiled
720 /// from, by the `DebugInfo` file table's `source_hash` (#3261).
721 ///
722 /// The problem this exists for: both debug resolvers happily answer
723 /// questions about source they were never built from. Author types, the
724 /// recompile is still debounced, the gutter asks about the *current*
725 /// buffer against the *previous* program — and gets a confidently wrong
726 /// address rather than an error. That applies to byte ranges every bit
727 /// as much as to line numbers; offsets shift on every inserted
728 /// character.
729 ///
730 /// Per-file on purpose: one dirty file degrades debugging in that file
731 /// alone, where a whole-program checksum degrades everything.
732 ///
733 /// `None` — "cannot tell" — when the artifact carries no `DebugInfo`,
734 /// names no such file, or recorded no hash (compiled without source
735 /// text). Deliberately tri-state rather than defaulting to `false`:
736 /// "unknown" and "stale" call for different handling, and collapsing
737 /// them would make every hash-less artifact look permanently stale.
738 ///
739 /// A change **detector**, not a proof — see [`brink_format::content_hash`].
740 #[must_use]
741 pub fn source_matches(&self, file: &str, text: &str) -> Option<bool> {
742 let debug_info = self.debug_info.as_ref()?;
743 let entry = debug_info.files.iter().find(|f| {
744 matches!(
745 f.surface,
746 brink_format::FileSurface::Ink | brink_format::FileSurface::Native
747 ) && f.path == file
748 })?;
749 if entry.source_hash == 0 {
750 return None;
751 }
752 Some(entry.source_hash == brink_format::content_hash(text))
753 }
754
755 /// Get a container by its index.
756 pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
757 &self.containers[idx as usize]
758 }
759
760 /// The call-frame temp slots container `idx`'s parameters occupy, in
761 /// declared order — what the VM binds arguments into at entry since
762 /// `.inkb` v10 (`docs/compiler-spec.md` §"Parameter binding"). Not
763 /// necessarily `0 … n-1`: a stitch's parameters continue after its
764 /// knot's.
765 pub(crate) fn container_param_slots(&self, idx: u32) -> Vec<u16> {
766 self.containers[idx as usize]
767 .params
768 .iter()
769 .map(|p| p.slot)
770 .collect()
771 }
772
773 /// The code the VM executes for container `idx`: the linker's rewritten
774 /// copy when it produced one (`LinkTables::code`), else the symbolic
775 /// bytecode — same bytes at every offset except static-target operands.
776 #[inline]
777 pub(crate) fn code(&self, idx: u32) -> &[u8] {
778 self.link.code.get(idx as usize).map_or_else(
779 || self.containers[idx as usize].bytecode.as_slice(),
780 Vec::as_slice,
781 )
782 }
783
784 /// Static target `ordinal` of the linked table.
785 #[inline]
786 pub(crate) fn target(&self, ordinal: u32) -> Option<&LinkedTarget> {
787 self.link.targets.get(ordinal as usize)
788 }
789
790 /// Resolve a symbolic address id to a [`LinkedTarget`] — the slow path
791 /// the VM takes for operands the linker left symbolic and for targets
792 /// that arrive as values (`Value::DivertTarget`).
793 pub(crate) fn resolve(&self, id: DefinitionId) -> Result<LinkedTarget, RuntimeError> {
794 let (container_idx, offset) = self
795 .resolve_target(id)
796 .ok_or(RuntimeError::UnresolvedDefinition(id))?;
797 Ok(LinkedTarget {
798 container_idx,
799 offset,
800 id,
801 })
802 }
803
804 /// Get a container's bytecode by index.
805 #[cfg(feature = "testing")]
806 pub fn container_bytecode(&self, idx: u32) -> &[u8] {
807 &self.containers[idx as usize].bytecode
808 }
809
810 /// Number of containers. Promoted from `#[cfg(feature = "testing")]`
811 /// to real public API for the structural-transcript re-render road
812 /// (RULED 2026-08-30): a transcript saved against an older compile can
813 /// carry container indices this program no longer has, and the caller
814 /// must be able to bounds-filter them before `scope_table_idx` would
815 /// panic.
816 #[expect(
817 clippy::cast_possible_truncation,
818 reason = "container count fits in u32"
819 )]
820 pub fn container_count(&self) -> u32 {
821 self.containers.len() as u32
822 }
823
824 /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
825 pub fn source_checksum(&self) -> u32 {
826 self.source_checksum
827 }
828
829 /// Get the scope line table index for a container.
830 pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
831 self.containers[container_idx as usize].scope_table_idx
832 }
833
834 /// Look up a name by id.
835 pub(crate) fn name(&self, id: NameId) -> &str {
836 &self.name_table[id.0 as usize]
837 }
838
839 /// Look up a name by id, returning `None` if the id is out of range. Used
840 /// by function-value rehydration (T1c, #700): a closure loaded from a save
841 /// produced against a *different* compile can carry a `NameId` that no
842 /// longer indexes this program's table — treated as a mismatch (fault),
843 /// never a panic.
844 ///
845 /// Public (T1d, `docs/t1d-spec.md` §6): the same "index by id, `None` if
846 /// out of range" contract a host needs to resolve a [`brink_format::Value::Handle`]'s
847 /// `kind` to its manifest-declared name — e.g. for dev-tooling display or
848 /// a host-side capability check. `bevy-brink` re-exports `Program`
849 /// (decision 2026-07-10), so this is reachable from engine code without a
850 /// direct `brink-runtime` dependency.
851 pub fn name_checked(&self, id: NameId) -> Option<&str> {
852 self.name_table.get(id.0 as usize).map(String::as_str)
853 }
854
855 /// Reverse of [`name_checked`](Self::name_checked): look up the
856 /// [`NameId`] a string interns to in this program's name table, if any.
857 ///
858 /// Public (T1d-3, `docs/t1d-spec.md` §4): a host minting a
859 /// [`brink_format::Value::Handle`] from a binding (e.g. `spawn_timer()`
860 /// returning a fresh `Handle<Timer>`) needs the compiled program's
861 /// `NameId` for the manifest-declared kind name (`"Timer"`) to build the
862 /// token — the wire form carries only the interned id, never the string.
863 /// `None` means this compile never interned that name (e.g. no
864 /// `Handle<Timer>`-typed signature or annotation anywhere in the source
865 /// graph), so no token of that kind can be minted against this program.
866 /// Linear scan, same cost class as [`global_index`](Self::global_index).
867 #[must_use]
868 pub fn name_id(&self, name: &str) -> Option<NameId> {
869 self.name_table
870 .iter()
871 .position(|n| n == name)
872 .and_then(|i| u16::try_from(i).ok())
873 .map(NameId)
874 }
875
876 /// Access a container's per-parameter name/mode metadata (T1c, #700).
877 pub(crate) fn container_params(&self, idx: u32) -> &[brink_format::ParamMeta] {
878 &self.containers[idx as usize].params
879 }
880
881 /// Look up a global slot index.
882 pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
883 self.global_map.get(&id).copied()
884 }
885
886 /// Get the root container index.
887 pub(crate) fn root_idx(&self) -> u32 {
888 self.root_idx
889 }
890
891 /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
892 ///
893 /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
894 /// for programs compiled by `brink-compiler`, author labels
895 /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
896 /// `address_paths` table (legacy `.inkb` or converter output) resolve
897 /// knot/stitch scope paths only. Use this to spawn flows at named entry
898 /// points:
899 ///
900 /// ```no_run
901 /// # fn example(program: &brink_runtime::Program) {
902 /// use brink_runtime::FlowInstance;
903 ///
904 /// if let Some((idx, _)) = program.find_address("intro_scene") {
905 /// let (flow, ctx) = FlowInstance::new_at(program, idx);
906 /// }
907 /// # }
908 /// ```
909 #[must_use]
910 pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
911 self.address_by_path
912 .get(path)
913 .map(|t| (t.container_idx, t.byte_offset))
914 }
915
916 /// Resolve a qualified ink path to the `DefinitionId` of its target.
917 /// Same path grammar as [`find_address`](Self::find_address). Used by
918 /// `choose_path_string`, which needs the id so the jump goes through the
919 /// same divert machinery (and visit counting) as `-> path` would.
920 pub(crate) fn find_path_target(&self, path: &str) -> Option<DefinitionId> {
921 self.address_by_path.get(path).map(|t| t.id)
922 }
923
924 /// Public wrapper on [`find_path_target`](Self::find_path_target): resolve
925 /// a qualified ink path (same grammar as [`find_address`](Self::find_address))
926 /// to the `DefinitionId` of its target. Used by hosts that need the id
927 /// itself — e.g. `bevy-brink`'s wake-condition purity check (issue #995),
928 /// which looks the id up in the story's `EffectRows` table to inspect a
929 /// `FlowSleep` condition's effect row before admitting it into the wake
930 /// contract.
931 #[must_use]
932 pub fn definition_id_for_path(&self, path: &str) -> Option<DefinitionId> {
933 self.find_path_target(path)
934 }
935
936 /// Declared parameter count of the container a `path` targets, for
937 /// arity-checking a host-directed parameterized entry. `None` if the path
938 /// is unknown. (Always `0` for converter-built programs, which don't
939 /// record param counts.)
940 pub(crate) fn path_param_count(&self, path: &str) -> Option<u8> {
941 self.address_by_path
942 .get(path)
943 .map(|t| self.containers[t.container_idx as usize].param_count)
944 }
945
946 // ── Visibility (`#@private` — M-2b, docs/modules-spec.md §4) ────────────
947
948 /// Whether the compiler marked any definition `#@private`. `false` for the
949 /// entire pre-modules / all-public world — the fast path where visibility
950 /// enforcement is a single boolean check that skips every lookup below.
951 pub(crate) fn has_private_defs(&self) -> bool {
952 !self.private_defs.is_empty()
953 }
954
955 /// Whether the definition `id` was declared `#@private`.
956 pub(crate) fn is_private(&self, id: DefinitionId) -> bool {
957 self.private_defs
958 .binary_search_by_key(&id.to_raw(), |d| d.to_raw())
959 .is_ok()
960 }
961
962 /// Whether the global at slot `idx` is `#@private`.
963 pub(crate) fn global_is_private(&self, idx: u32) -> bool {
964 self.globals
965 .get(idx as usize)
966 .is_some_and(|slot| self.is_private(slot.id))
967 }
968
969 /// Whether the named entry point (knot/stitch/function path) is
970 /// `#@private`. Unknown paths are treated as not-private — resolution
971 /// failure is reported by the caller's own "not found" path, not here.
972 pub(crate) fn path_is_private(&self, path: &str) -> bool {
973 self.find_path_target(path)
974 .is_some_and(|id| self.is_private(id))
975 }
976
977 /// Whether the container at `idx` is `#@private`. Used by
978 /// [`FlowInstance::begin_function_eval`](crate::FlowInstance::begin_function_eval)/
979 /// [`begin_function_value_eval`](crate::FlowInstance::begin_function_value_eval),
980 /// which receive an already-resolved `container_idx` rather than a name
981 /// (the caller resolves it, typically via [`find_address`](Self::find_address),
982 /// before entering the VM boundary). Out-of-range indices are not
983 /// private — an invalid index is the caller's bug, reported elsewhere.
984 pub(crate) fn container_is_private(&self, idx: u32) -> bool {
985 self.containers
986 .get(idx as usize)
987 .is_some_and(|c| self.is_private(c.id))
988 }
989
990 /// Build the initial globals vector from slot defaults.
991 pub fn global_defaults(&self) -> Vec<Value> {
992 self.globals.iter().map(|s| s.default.clone()).collect()
993 }
994
995 /// Find the global variable slot index for a variable name, if declared.
996 /// Used by host-facing variable get/set (`Story::variable`/`set_variable`).
997 #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
998 pub fn global_index(&self, name: &str) -> Option<u32> {
999 self.globals
1000 .iter()
1001 .position(|slot| self.name(slot.name) == name)
1002 .map(|i| i as u32)
1003 }
1004
1005 /// Get a list literal by index.
1006 pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
1007 &self.list_literals[idx as usize]
1008 }
1009
1010 /// Get a T1b literal pool entry by index. `None` on an out-of-range
1011 /// index (malformed bytecode) rather than panicking — the VM turns
1012 /// this into a `RuntimeError`, never a crash.
1013 pub(crate) fn literal_pool_entry(&self, idx: u32) -> Option<&Value> {
1014 self.literal_pool.get(idx as usize)
1015 }
1016
1017 /// Look up a `STRUCT` shape's runtime metadata by `ShapeId`. `None` on an
1018 /// out-of-range id (malformed bytecode) rather than panicking — mirrors
1019 /// [`literal_pool_entry`](Self::literal_pool_entry).
1020 pub(crate) fn struct_shape(&self, shape: ShapeId) -> Option<&StructShapeEntry> {
1021 self.struct_shapes.get(shape.0 as usize)
1022 }
1023
1024 /// Look up a list item's metadata.
1025 pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
1026 self.list_item_map.get(&id)
1027 }
1028
1029 /// Get a list definition by its `DefinitionId`.
1030 pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
1031 self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
1032 }
1033
1034 /// Find a list definition by its string name.
1035 pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
1036 self.list_defs
1037 .iter()
1038 .find(|def| self.name(def.name) == name)
1039 }
1040
1041 /// Look up an external function by its `DefinitionId`.
1042 pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
1043 self.external_fns.get(&id)
1044 }
1045
1046 // ── Public variable introspection (host-facing) ─────────────────────────
1047 // `global_index` (above), `global_name`, and `global_count` form the
1048 // host-facing variable-introspection set used by `Story::variable`/
1049 // `set_variable` and consumers like the RMMZ var↔switch mapping. They were
1050 // previously `testing`-gated; promoted to public per the State View plan.
1051
1052 /// Resolve a global cell's `DefinitionId` to its slot index — the
1053 /// numbering [`ContextAccess::set_global`](crate::ContextAccess) and
1054 /// [`Self::global_index`] use.
1055 ///
1056 /// Public because effect rows (`brink_format::DirectEffects::reads` /
1057 /// `writes`) name global cells by `DefinitionId` while the runtime's
1058 /// world writes are keyed by slot: a host consuming rows for scheduling
1059 /// (bevy-brink's row-directed wake dirtying, issue #1146) needs exactly
1060 /// this bridge. `None` for an id this program declares no global for
1061 /// (a stale row, a `VAR` removed by a story patch).
1062 pub fn global_slot(&self, id: DefinitionId) -> Option<u32> {
1063 self.resolve_global(id)
1064 }
1065
1066 /// Resolve a global slot index to its variable name.
1067 pub fn global_name(&self, idx: u32) -> Option<&str> {
1068 self.globals
1069 .get(idx as usize)
1070 .map(|slot| self.name(slot.name))
1071 }
1072
1073 // ── Compiled scope defaults (`#@local` — directive-annotations spec) ────
1074
1075 /// Whether the compiler marked anything flow-private. When `false`
1076 /// (all existing unannotated ink), policy resolution keeps its
1077 /// all-`World` fast path.
1078 ///
1079 /// Public so the bevy host (`bevy-brink`'s batch driver) can guard
1080 /// against batching a `#@local`-annotated story: batch mode routes only
1081 /// the shared `World`, never a flow's private `FlowLocal`, so a story
1082 /// carrying compiled flow-private defaults must stay on the serial API
1083 /// (`docs/effects-spec.md` §12; bevy-brink #925).
1084 pub fn has_local_defaults(&self) -> bool {
1085 !self.local_scope_defaults.is_empty() || self.globals.iter().any(|g| g.local)
1086 }
1087
1088 /// Compiled flow-private default for a global slot.
1089 pub(crate) fn global_is_local(&self, idx: u32) -> bool {
1090 self.globals.get(idx as usize).is_some_and(|g| g.local)
1091 }
1092
1093 /// Compiled flow-private knot/stitch defaults, sorted by path.
1094 pub(crate) fn local_scope_defaults(&self) -> &[(String, DefinitionId)] {
1095 &self.local_scope_defaults
1096 }
1097
1098 /// Number of global variable slots.
1099 #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
1100 pub fn global_count(&self) -> u32 {
1101 self.globals.len() as u32
1102 }
1103
1104 // ── Debug introspection name lookups (used by `debug_snapshot`) ──────────
1105
1106 /// Variable name for a global slot index.
1107 pub(crate) fn global_slot_name(&self, idx: usize) -> Option<&str> {
1108 self.globals.get(idx).map(|slot| self.name(slot.name))
1109 }
1110
1111 /// D7 (`docs/debugger-spec.md` §3, #3185): every `LocalsTable` row
1112 /// declared anywhere in the same lexical **scope** (`ContainerDef::
1113 /// scope_id`) as the container at `leaf_container_idx` — not just that
1114 /// one container's own table.
1115 ///
1116 /// This is deliberately scope-wide, not container-local: a call frame's
1117 /// `container_stack` can legitimately shrink back to a single entry
1118 /// mid-frame (`vm::goto_target`'s "target not already on the stack"
1119 /// branch `clear()`s and replaces it wholesale — e.g. entering a
1120 /// `{? … }` choice-target body from its enclosing knot is exactly this
1121 /// case), which would silently drop an enclosing container's locals
1122 /// (its own parameters/`~ temp`s) from view the moment the leaf
1123 /// position moves into a *sibling* child container — even though the
1124 /// call frame's `temps` are completely unaffected (they are declared
1125 /// once per **scope root**, `docs/debugger-spec.md` §3: "VM temp slots
1126 /// ... are allocated per active call frame, not lexically nested").
1127 /// Grouping by `scope_id` instead of by whatever happens to be on
1128 /// `container_stack` right now is what keeps a parameter/`~ temp`
1129 /// visible for the frame's entire lifetime, matching the runtime's own
1130 /// slot-allocation model rather than the transient shape of one
1131 /// in-frame navigation stack.
1132 ///
1133 /// Empty when this artifact carries no `DebugInfo` at all (a
1134 /// release-exported story, or one compiled before D6), when
1135 /// `leaf_container_idx` is out of range (malformed/adversarial
1136 /// `.inkb`, not a panic case), or when the scope genuinely declares no
1137 /// locals.
1138 pub(crate) fn scope_debug_locals(
1139 &self,
1140 leaf_container_idx: u32,
1141 ) -> Vec<&brink_format::DebugLocalEntry> {
1142 let Some(debug_info) = self.debug_info.as_ref() else {
1143 return Vec::new();
1144 };
1145 let Some(scope_id) = self
1146 .containers
1147 .get(leaf_container_idx as usize)
1148 .map(|c| c.scope_id)
1149 else {
1150 return Vec::new();
1151 };
1152 self.containers
1153 .iter()
1154 .zip(debug_info.containers.iter())
1155 .filter(|(c, _)| c.scope_id == scope_id)
1156 .flat_map(|(_, table)| table.locals.iter())
1157 .collect()
1158 }
1159
1160 /// Compiled `DefinitionId` for a global slot index — the identity
1161 /// `save_state` round-trips into `SaveState::global_ids` so the M-3
1162 /// rehydration miss path (`docs/modules-spec.md` §5) can recover a
1163 /// renamed VAR/CONST/LIST global's *save-time* id (declared-module
1164 /// identity is `(module, name)`-hashed, so the bare name alone can't
1165 /// reconstruct it) and look it up in the compiled alias table.
1166 pub(crate) fn global_id(&self, idx: usize) -> Option<DefinitionId> {
1167 self.globals.get(idx).map(|slot| slot.id)
1168 }
1169
1170 /// Variable name for a global's defining `DefinitionId` (e.g. a
1171 /// `VariablePointer` target, or a T1e projection's root cell). `pub`
1172 /// (not `pub(crate)`) since `brink-web`'s program-model/speculation
1173 /// disassembly needs it to render a projection's root name at the wasm
1174 /// boundary, the same way `divert_target_path` already resolves a
1175 /// divert's `DefinitionId` for that consumer.
1176 pub fn global_var_name(&self, id: DefinitionId) -> Option<&str> {
1177 let slot = self.resolve_global(id)?;
1178 self.global_slot_name(slot as usize)
1179 }
1180
1181 /// Display name for a list item by its `DefinitionId`.
1182 pub(crate) fn list_item_name(&self, id: DefinitionId) -> Option<&str> {
1183 self.list_item(id).map(|item| self.name(item.name))
1184 }
1185
1186 // ── Host-facing structured value display (F4.3 web binding) ─────────────
1187 // `list_members`/`divert_target_path` give a host (e.g. brink-web's wasm
1188 // marshaling) the same name resolution `value_ops::stringify_list` and
1189 // `debug::NameResolver` already do internally, but structured rather than
1190 // pre-joined into a display string — a host may want to render a list's
1191 // members or a divert's destination as distinct fields rather than text.
1192 // On-demand only (not on any hot path), like `debug::NameResolver`.
1193
1194 /// Resolve the active members of a list value for host-facing display:
1195 /// each member's origin list name, unqualified item name, and ordinal.
1196 /// Sorted the same way in-story list stringification orders them
1197 /// (ordinal, then origin name) so the two presentations agree.
1198 #[must_use]
1199 pub fn list_members(&self, list: &ListValue) -> Vec<ListMember> {
1200 let mut entries: Vec<ListMember> = list
1201 .items
1202 .iter()
1203 .filter_map(|&id| {
1204 self.list_item(id).map(|entry| {
1205 let origin = self
1206 .list_def(entry.origin)
1207 .map_or_else(String::new, |def| self.name(def.name).to_owned());
1208 let full_name = self.name(entry.name);
1209 let name = full_name
1210 .split_once('.')
1211 .map_or_else(|| full_name.to_owned(), |(_, item)| item.to_owned());
1212 ListMember {
1213 origin,
1214 name,
1215 ordinal: entry.ordinal,
1216 }
1217 })
1218 })
1219 .collect();
1220 entries.sort_by(|a, b| {
1221 a.ordinal
1222 .cmp(&b.ordinal)
1223 .then_with(|| a.origin.cmp(&b.origin))
1224 });
1225 entries
1226 }
1227
1228 /// The qualified knot/stitch path a `DefinitionId` names, if it resolves
1229 /// to a named scope entry (offset-0 in `address_by_path`) — the
1230 /// destination of a `Value::DivertTarget` for host-facing display.
1231 /// Deterministic on collision: shortest path, then lexicographically
1232 /// smallest, independent of the map's iteration order (mirrors
1233 /// `debug::NameResolver`'s reverse lookup).
1234 #[must_use]
1235 pub fn divert_target_path(&self, id: DefinitionId) -> Option<String> {
1236 let (container_idx, _) = self.resolve_target(id)?;
1237 let mut best: Option<&str> = None;
1238 for (path, target) in &self.address_by_path {
1239 if target.byte_offset != 0 || target.container_idx != container_idx {
1240 continue;
1241 }
1242 best = Some(match best {
1243 None => path.as_str(),
1244 Some(existing) => {
1245 if path.len() < existing.len()
1246 || (path.len() == existing.len() && path.as_str() < existing)
1247 {
1248 path.as_str()
1249 } else {
1250 existing
1251 }
1252 }
1253 });
1254 }
1255 best.map(ToOwned::to_owned)
1256 }
1257}
1258
1259/// One active member of a list value, resolved for host-facing display. See
1260/// [`Program::list_members`].
1261#[derive(Debug, Clone, PartialEq, Eq)]
1262pub struct ListMember {
1263 /// The origin list's declared name (e.g. `"Weekday"`).
1264 pub origin: String,
1265 /// The item's unqualified display name (e.g. `"Monday"`).
1266 pub name: String,
1267 /// The item's ordinal within its origin list.
1268 pub ordinal: i32,
1269}
1270
1271#[cfg(test)]
1272mod find_address_tests {
1273 use super::*;
1274
1275 fn make_program_with_named_containers(names: &[&str]) -> Program {
1276 // Build a minimal Program where each name maps to a unique
1277 // container_idx. Used to exercise find_address without going
1278 // through the full link path.
1279 let mut address_by_path = HashMap::new();
1280 for (i, name) in names.iter().enumerate() {
1281 #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
1282 address_by_path.insert(
1283 (*name).to_string(),
1284 PathTarget {
1285 id: DefinitionId::new(brink_format::DefinitionTag::Address, i as u64),
1286 container_idx: i as u32,
1287 byte_offset: 0,
1288 },
1289 );
1290 }
1291 Program {
1292 link: crate::program::LinkTables::default(),
1293 containers: Vec::new(),
1294 address_map: HashMap::new(),
1295 scope_ids: Vec::new(),
1296 source_checksum: 0,
1297 globals: Vec::new(),
1298 global_map: HashMap::new(),
1299 name_table: Vec::new(),
1300 container_paths: container_paths_from(&address_by_path),
1301 address_by_path,
1302 root_idx: 0,
1303 list_literals: Vec::new(),
1304 literal_pool: Vec::new(),
1305 list_item_map: HashMap::new(),
1306 list_defs: Vec::new(),
1307 list_def_map: HashMap::new(),
1308 external_fns: HashMap::new(),
1309 local_scope_defaults: Vec::new(),
1310 struct_shapes: Vec::new(),
1311 private_defs: Vec::new(),
1312 alias_table: Vec::new(),
1313 debug_info: None,
1314 }
1315 }
1316
1317 #[test]
1318 fn finds_known_knot() {
1319 let program = make_program_with_named_containers(&["intro", "outro"]);
1320 assert_eq!(program.find_address("intro"), Some((0, 0)));
1321 assert_eq!(program.find_address("outro"), Some((1, 0)));
1322 }
1323
1324 #[test]
1325 fn returns_none_for_unknown_knot() {
1326 let program = make_program_with_named_containers(&["intro"]);
1327 assert_eq!(program.find_address("nope"), None);
1328 }
1329
1330 #[test]
1331 fn empty_program_returns_none() {
1332 let program = make_program_with_named_containers(&[]);
1333 assert_eq!(program.find_address("anything"), None);
1334 }
1335}