brink_format/definition.rs
1use alloc::string::String;
2use alloc::vec::Vec;
3
4use crate::counting::CountingFlags;
5use crate::id::{DefinitionId, NameId};
6use crate::line::LineContent;
7use crate::value::{ShapeId, Value, ValueType};
8
9/// A compiled container (knot, stitch, gather, or anonymous flow block).
10#[derive(Debug, Clone, PartialEq)]
11pub struct ContainerDef {
12 pub id: DefinitionId,
13 /// The lexical scope this container belongs to.
14 /// For scope containers (root, knot, stitch): `scope_id == id`.
15 /// For child containers (gather, choice target, sequence, etc.): `scope_id` is
16 /// the enclosing scope's `DefinitionId`.
17 pub scope_id: DefinitionId,
18 /// Human-readable name for scope-owning containers (root, knot, stitch).
19 /// `None` for child containers.
20 pub name: Option<NameId>,
21 pub bytecode: Vec<u8>,
22 pub counting_flags: CountingFlags,
23 /// Sum of char values from the container's ink path string.
24 /// Used to seed the RNG for shuffle sequences.
25 pub path_hash: i32,
26 /// Number of parameters this container declares (a parameterized knot,
27 /// stitch, or function — e.g. `=== call(action, present) ===` has 2). The
28 /// container's prologue binds them with that many leading `DeclareTemp`s.
29 /// `0` for the vast majority of containers. Lets the runtime arity-check a
30 /// host-directed entry (`choose_path_string_with_args`) or `call_function`.
31 /// The converter reference pipeline leaves this `0` (inklecate's JSON does
32 /// not expose it); only the brink compiler populates the true count.
33 pub param_count: u8,
34 /// Per-parameter name and mode metadata, in declared order (T1c,
35 /// `docs/t1c-spec.md` §6). Empty for the vast majority of containers.
36 ///
37 /// Carried so the runtime can validate a **rehydrated function value**
38 /// against the *current* signature: a `#fn`/closure saved before a
39 /// recompile stores its bound params' names and modes, and on load/invoke
40 /// they are checked against this table — a renamed or re-moded param is a
41 /// defined fault, never a silent misbinding (spec §6). `len()` always
42 /// equals [`param_count`](Self::param_count); both are kept (the count is
43 /// the pre-T1c arity-check field, the metadata is additive). The converter
44 /// reference pipeline leaves this empty.
45 pub params: Vec<ParamMeta>,
46 /// Compiled scope default: `true` for a flow-private (`#@local`) knot or
47 /// stitch. Only ever set on scope-owning containers; subtree coverage of
48 /// interior containers is resolved by the runtime at policy resolution
49 /// (`docs/directive-annotations-spec.md`). The converter always emits
50 /// `false` (inklecate has no flow-private concept).
51 pub local: bool,
52}
53
54/// Name + mode of one declared parameter of a container (T1c,
55/// `docs/t1c-spec.md` §6). See [`ContainerDef::params`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ParamMeta {
58 /// The parameter's interned name.
59 pub name: NameId,
60 /// `true` if declared `ref`, `false` for a by-value param.
61 pub is_ref: bool,
62 /// The call-frame temp slot this parameter occupies (`.inkb` v10).
63 ///
64 /// **Not** simply the parameter's position: a knot and its stitches
65 /// share one frame and one temp map, so a knot's parameters take slots
66 /// `0 …` and a *stitch*'s parameters continue after them
67 /// (`brink-ir`'s `alloc_temps`). `= opt(n)` inside `=== outer(m)` has
68 /// `opt`'s single parameter at slot 1. The VM binds arguments at
69 /// container entry (`docs/compiler-spec.md` §"Parameter binding"), so
70 /// it needs the real slot, not an assumed one.
71 pub slot: u16,
72}
73
74/// Metadata for a single interpolation slot in a template line.
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76pub struct SlotInfo {
77 pub index: u8,
78 pub name: String,
79}
80
81/// Source location of a line in the original `.ink` file.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SourceLocation {
84 pub file: String,
85 pub range_start: u32,
86 pub range_end: u32,
87}
88
89/// One entry in a container's line table.
90#[derive(Debug, Clone, PartialEq)]
91pub struct LineEntry {
92 pub content: LineContent,
93 pub flags: crate::LineFlags,
94 pub source_hash: u64,
95 pub audio_ref: Option<String>,
96 pub slot_info: Vec<SlotInfo>,
97 pub source_location: Option<SourceLocation>,
98}
99
100/// A locale line entry — content + optional audio, no source metadata.
101#[derive(Debug, Clone, PartialEq)]
102pub struct LocaleLineEntry {
103 pub content: LineContent,
104 pub audio_ref: Option<String>,
105}
106
107/// A per-scope locale line table.
108#[derive(Debug, Clone, PartialEq)]
109pub struct LocaleScopeTable {
110 pub scope_id: DefinitionId,
111 pub lines: Vec<LocaleLineEntry>,
112}
113
114/// Complete locale overlay data from a `.inkl` file.
115#[derive(Debug, Clone, PartialEq)]
116pub struct LocaleData {
117 pub locale_tag: String,
118 pub base_checksum: u32,
119 pub line_tables: Vec<LocaleScopeTable>,
120}
121
122/// Per-scope line table, stored separately from [`ContainerDef`] for
123/// locale overlay swapping (`.inkl`).
124///
125/// All containers within a lexical scope (knot, stitch, or root) share one
126/// `ScopeLineTable`. `EmitLine(idx)` indices are scope-relative.
127#[derive(Debug, Clone, PartialEq)]
128pub struct ScopeLineTable {
129 pub scope_id: DefinitionId,
130 pub lines: Vec<LineEntry>,
131}
132
133/// One line-variant group (stage 1 of the shared-alternatives track,
134/// issue #3273): the record that ties `dims.iter().product()` consecutive
135/// [`LineEntry`]s in a scope's line table back to ONE authored source line
136/// whose inline alternatives were enumerated at recognition time.
137///
138/// The entries themselves are ordinary whole-line entries — each with its
139/// own `source_hash` and its own `audio_ref`, which is the point: VO is
140/// associated per *rendered* line, and a translator sees whole lines. This
141/// record exists so intl export and audio tooling can group them, and so
142/// codegen's combo switch and the table agree on the layout.
143///
144/// Layout contract: variant `(i, j, …)` — one branch index per authored
145/// alternative, in source order — lives at
146/// `base + i * dims[1..].product() + j * dims[2..].product() + …`
147/// (row-major, first alternative varies slowest). `dims` is never empty
148/// and every dim is ≥ 1.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct LineVariantGroup {
151 /// The scope whose [`ScopeLineTable`] holds this group's entries.
152 pub scope_id: DefinitionId,
153 /// Index of the group's first entry in that scope's `lines`.
154 pub base: u32,
155 /// Branch count per authored alternative, in source order.
156 pub dims: Vec<u16>,
157}
158
159/// A global variable definition.
160#[derive(Debug, Clone, PartialEq)]
161pub struct GlobalVarDef {
162 pub id: DefinitionId,
163 pub name: NameId,
164 pub value_type: ValueType,
165 pub default_value: Value,
166 pub mutable: bool,
167 /// Compiled scope default: `true` for a flow-private (`#@local`)
168 /// variable, `false` for ordinary shared state. Consumed by the runtime
169 /// as the base layer of `WorldPolicy` resolution
170 /// (`docs/directive-annotations-spec.md`).
171 pub local: bool,
172}
173
174/// A list (enum-like set) definition.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ListDef {
177 pub id: DefinitionId,
178 pub name: NameId,
179 /// `(item_name, ordinal)` pairs in declaration order.
180 pub items: Vec<(NameId, i32)>,
181}
182
183/// A `STRUCT` shape definition (TM-4, `docs/typed-mode-spec.md` §6;
184/// `StructShapes` section, `docs/format-spec.md` tag `0x0C`).
185///
186/// Closed shape: `fields` is the ordered set of declared field names — the
187/// same order [`crate::value::Value::Record`]'s flat field vector follows,
188/// and the order `RecordNew`/static `RecordGet`/`RecordSet` offsets index
189/// into.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct StructShapeDef {
192 pub id: ShapeId,
193 pub name: NameId,
194 pub fields: Vec<NameId>,
195}
196
197/// One old→new `DefinitionId` rename record (M-3, `docs/modules-spec.md`
198/// §5): the compiler emits one of these per `#@was(old_name)` directive —
199/// on a module, one entry per definition the renamed module currently
200/// owns; on a single definition, one entry for it. Rehydration
201/// (`brink-runtime`'s `load_state`) consults the table **only on the miss
202/// path**: a saved fn token, divert value, or visit-count key that the
203/// current program doesn't recognize is looked up here before being
204/// treated as genuinely gone.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
206pub struct AliasEntry {
207 /// The identity a save from before the rename may still carry.
208 pub old: DefinitionId,
209 /// The definition's current identity.
210 pub new: DefinitionId,
211}
212
213/// The capability-parameter slot carried by every call atom in a factored
214/// effect row (T2-3, `docs/effects-spec.md` §11; ruled 2026-07-14,
215/// `docs/t1d-spec.md` §7).
216///
217/// v1 populates every atom as [`CapabilityParam::Any`] — component-granular,
218/// the whole capability unrefined. Path-granular refinement (#826) and the
219/// instance-resolving handle parameter are later narrowing rungs; their
220/// discriminants are reserved (the strict reader rejects them until a section
221/// version graduates them), the same reservation discipline the projection
222/// range segment follows.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
224pub enum CapabilityParam {
225 /// The whole capability, unrefined — the only value v1 ever emits.
226 #[default]
227 Any,
228}
229
230/// A single call atom in an effect row's direct part (`docs/effects-spec.md`
231/// §2/§11): the name of an `EXTERNAL` binding (a call-kind), plus its
232/// [capability-parameter slot](CapabilityParam) and a **reserved
233/// handle-parameter slot**.
234///
235/// The handle-parameter slot (`docs/t1d-spec.md` §7) is where a
236/// handle-parameterized atom (`Transform(@argN)`) will record which minted
237/// handle bounds the capability; v1 leaves it `None` (the reserved wire byte is
238/// `0`). Possession-bounded capabilities are the tier-2 security model — out of
239/// scope for this slice, but the slot ships now so the row encoding need not
240/// change to carry it later.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct CallAtom {
243 /// The `EXTERNAL` binding name (interned into the story's `name_table`).
244 pub name: NameId,
245 /// Capability-parameter slot — v1 always [`CapabilityParam::Any`].
246 pub capability: CapabilityParam,
247 /// Reserved handle-parameter slot — v1 always `None`. A non-`None` value
248 /// is never emitted in this section version.
249 pub handle_param: Option<u8>,
250}
251
252/// The direct part of a factored effect row (`docs/effects-spec.md` §7/§11):
253/// the atoms a definition (and everything it statically calls) may perform,
254/// independent of any dispatch-cell narrowing. Mirrors the analyzer's flat
255/// `EffectRow`, lowered to wire vocabulary (cells as [`DefinitionId`]s, call
256/// kinds as [`CallAtom`]s).
257///
258/// Sets are stored as vectors already sorted/deduplicated by the producer so
259/// the encoding is deterministic (the analyzer sources them from `BTreeSet`s).
260#[derive(Debug, Clone, Default, PartialEq, Eq)]
261#[expect(
262 clippy::struct_excessive_bools,
263 reason = "the four bools are independent wire dimensions of one factored \
264 effect row (opaque + the NS-A2 emits/tags/faults flags), not a \
265 state machine in disguise — mirrors the analyzer's EffectRow"
266)]
267pub struct DirectEffects {
268 /// Global cells this row may read.
269 pub reads: Vec<DefinitionId>,
270 /// Global cells this row may write.
271 pub writes: Vec<DefinitionId>,
272 /// Call-kind atoms this row may transitively perform.
273 pub calls: Vec<CallAtom>,
274 /// The pessimal top element (`docs/effects-spec.md` §3): this row performs
275 /// a call whose effects inference cannot summarize.
276 pub opaque: bool,
277 /// NS-A2 (issue #1108, from #1087): the definition may produce content —
278 /// narration/dialogue fragments a host renders (glue-only output counts;
279 /// tag-only lines do NOT — those set [`Self::tags`]). Bool v1.
280 pub emits: bool,
281 /// NS-A2 (issue #1108, from #1087's second ruling): the definition may
282 /// touch the tag channel. Independent of [`Self::emits`]. Bool v1.
283 pub tags: bool,
284 /// NS-A2 (issue #1108, from #1097): the definition may raise a
285 /// turn-terminating fault. Bool v1 — per-fault-kind granularity is the
286 /// reserved refinement and graduates via a section-version bump, the
287 /// same reservation discipline the capability/handle slots follow.
288 pub faults: bool,
289}
290
291/// A per-dispatch entry in a factored effect row (`docs/effects-spec.md` §7):
292/// the row a call through a dispatch `cell` contributes, whether that dispatch
293/// is runtime-**narrowable** (its cell is not in the entry's own write set),
294/// and the **static fallback** row used when narrowing does not apply.
295///
296/// v1 emits none of these (call-through-value is inferred as opaque, folded
297/// into the direct part) — but the encoding ships the structure now, because a
298/// flat row structurally forecloses the §7 narrowing the host will do at
299/// schedule-commit. The reader round-trips a populated dispatch list so writer
300/// and reader stay paired (the #742 lesson).
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct DispatchEntry {
303 /// The dispatch cell whose live fn tokens the host may narrow against.
304 pub cell: DefinitionId,
305 /// Whether this dispatch is statically narrowable (`docs/effects-spec.md`
306 /// §7 soundness gate: the cell is not in the entry's own write set).
307 pub narrowable: bool,
308 /// The static fallback row — the conservative join used when the host does
309 /// not (or cannot) narrow.
310 pub fallback: DirectEffects,
311}
312
313/// One entry in the `EffectRows` `DefinitionId → row` table (T2-3,
314/// `docs/effects-spec.md` §11, `docs/format-v4-rfc.md` §2 `EffectRows`
315/// reservation): a factored effect row for one definition.
316///
317/// Every knot/stitch ships one — the per-container row is the host's
318/// resume-scheduling estimate (`docs/effects-spec.md` §12.1: a flow resumes
319/// from wherever it parked). The row is additive metadata; the runtime does
320/// not consume it yet (`sleep`/narrowing are the future clients), so a story
321/// carrying rows is byte-identical in behavior to one without.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct EffectRowEntry {
324 /// The definition (knot/stitch) this row summarizes.
325 pub def: DefinitionId,
326 /// The freeze bit (#882, `docs/effects-spec.md` §10 sitting-2 ruling;
327 /// corroborated on `main` by `docs/modules-spec.md` §4 boundary rule 1/2
328 /// and the 2026-07-14 "Modules & visibility rulings" decision-log entry —
329 /// `docs/effects-spec.md` itself never merged past `docs/effects-skeleton`).
330 ///
331 /// `true` for every def by default: the row is a legitimate host
332 /// **entry point** (host-callable by path/name — `begin_function_eval`,
333 /// entry lookup, play-from-here). `false` for a `#@private` definition:
334 /// **not an entry** — host-facing name lookup on it is refused (the
335 /// load-error class effects-spec §10 rules) — but the row itself is not
336 /// dropped from the table. `#@private` hides the *name*, not the *cell*:
337 /// a private knot/stitch can still be captured as a first-class fn-value
338 /// token that a *public* path holds and later calls through, and the
339 /// dispatch-narrowing machinery (§7) resolves such a token by
340 /// `DefinitionId`, not by name — so the row must stay resolvable in this
341 /// table regardless of `is_entry`. This is unconditional (never a
342 /// reachability computation over whether some public path actually holds
343 /// such a token today): proving that would need whole-program fn-value
344 /// capture analysis, which conservative-total rows do not attempt.
345 /// Dev-tooling (play-from-here, `brink ide` effects-diff) is the
346 /// documented visibility override (modules-spec §4 rule 3) and may read
347 /// a non-entry row directly from this table; only *host* semantic lookup
348 /// respects `is_entry`.
349 pub is_entry: bool,
350 /// The direct part — atoms independent of dispatch narrowing.
351 pub direct: DirectEffects,
352 /// Per-dispatch entries (empty in v1).
353 pub dispatches: Vec<DispatchEntry>,
354}
355
356/// The name-keyed **frame shape** for one `await` site
357/// (`docs/flow-suspension-spec.md` §4/§11): the static description of which
358/// locals cross the park at that site, so the runtime knows what to
359/// spill on park and restore on wake.
360///
361/// Emitted into the `FrameShapes` [`StoryData`](crate::StoryData) section
362/// (`.inkb` tag `0x10`,
363/// `.inkt` `(frame_shapes …)`). The shape is **name-keyed** — the runtime
364/// spills/restores crossing locals by name, riding the same rehydration
365/// machinery as `#@was`/saves (spec §7), so a frame survives recompiles
366/// without instruction offsets (spec §2/§3).
367///
368/// **Reserved-through-fence**: the FS-3c compiler slice lands this section's
369/// encoding (writer + reader + round-trips) but does not yet *emit* a
370/// non-empty table — the E052 `await` lowering fence keeps `await` from
371/// producing any `StoryData`. First emission rides the continuation-splitting
372/// codegen when the fence drops (FS-3r), the same reserved-then-materialized
373/// discipline `StructShapes` followed. `frame_shapes` is therefore empty for
374/// every story compiled today (and for all converter output).
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct FrameShapeDef {
377 /// The `await` site's stable identity — the [`DefinitionId`] of the
378 /// synthesized resume/continuation container (spec §11.1: stable identity
379 /// = module + enclosing def + site index). This is both the wake-policy
380 /// site id and the container the runtime enters from its top on resume.
381 pub site: DefinitionId,
382 /// The name-keyed crossing locals, in stable declared order (spec §4).
383 /// Each entry is the local's interned [`NameId`] (into the story's
384 /// `name_table`); the runtime's frame record is keyed by these names.
385 /// Values are not stored here — the shape is static; the live values live
386 /// in the save-time `SuspendedFlow.frame` (`docs/flow-suspension-spec.md`
387 /// §2, FS-1).
388 pub slots: Vec<NameId>,
389}
390
391/// A single list item definition.
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub struct ListItemDef {
394 pub id: DefinitionId,
395 pub origin: DefinitionId,
396 pub ordinal: i32,
397 pub name: NameId,
398}
399
400/// An address pointing to a specific byte offset within a container.
401///
402/// Addresses are used for divert targets, visit tracking, and any definition
403/// that maps to a position within a container. A "primary" address has
404/// `byte_offset == 0` and the same `id` as its `container_id`, functioning
405/// like the old `Container` tag. Intra-container addresses have non-zero
406/// offsets and distinct IDs.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub struct AddressDef {
409 pub id: DefinitionId,
410 pub container_id: DefinitionId,
411 pub byte_offset: u32,
412}
413
414/// Maps a qualified author path (e.g. `knot`, `knot.stitch`, `knot.label`,
415/// `knot.stitch.label`) to the [`DefinitionId`] it addresses.
416///
417/// This is the source of truth for path → address lookup
418/// ([`Program::find_address`](../../brink_runtime/struct.Program.html#method.find_address)):
419/// the linker resolves each `target` through its address map. The compiler
420/// emits one entry per scope container (knot/stitch) and per author-labeled
421/// gather/choice. `path` indexes the name table; `target` is the addressed
422/// container/label id.
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub struct AddressPath {
425 pub path: NameId,
426 pub target: DefinitionId,
427}
428
429/// Compute a stable hash of source text — FNV-1a, 64-bit, over the UTF-8
430/// bytes.
431///
432/// **Part of the wire contract** (`docs/format-spec.md`), not an
433/// implementation detail. Hashes produced here are written into artifacts
434/// ([`LineEntry::source_hash`] for the intl regeneration workflow;
435/// [`DebugFileEntry::source_hash`] for the debugger's staleness check,
436/// issue #3261) and compared later — potentially by a different binary, a
437/// different toolchain, or a `no_std` build. So the algorithm is specified
438/// and identical on every path, and must not change without treating it as
439/// a format change.
440///
441/// This is why it is no longer `std`'s `DefaultHasher`: Rust documents that
442/// hasher's algorithm as unspecified and subject to change between
443/// releases, and the `no_std` fallback it used to sit beside was explicitly
444/// not bit-identical to it. Both were fine while nothing compared hashes
445/// across builds. Recording a hash in an artifact makes that exactly what
446/// happens, and a silent algorithm change would make every comparison
447/// report "changed" forever, with no obvious cause.
448///
449/// FNV-1a is a **change detector, not a proof**: it is not collision
450/// resistant and is not a security primitive. A collision means changed
451/// text is reported as unchanged — for the intl workflow, a line missed for
452/// retranslation; for the debugger, a stale source accepted as fresh, which
453/// is no worse than the silent wrong answer the check exists to replace.
454#[must_use]
455pub fn content_hash(text: &str) -> u64 {
456 // FNV-1a 64-bit: offset basis, then per-byte xor-and-multiply by the
457 // FNV prime. Written out rather than pulled from a crate so the wire
458 // contract has no dependency that could revise it underneath us.
459 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
460 for byte in text.as_bytes() {
461 hash ^= u64::from(*byte);
462 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
463 }
464 hash
465}
466
467/// An externally-bound function definition.
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub struct ExternalFnDef {
470 pub id: DefinitionId,
471 pub name: NameId,
472 pub arg_count: u8,
473 pub fallback: Option<DefinitionId>,
474}
475
476// ── DebugInfo (D6, `docs/debugger-spec.md` §2) ──────────────────────────────
477
478/// `flags` bit 0: this entry marks a recommended stop location / the start
479/// of a statement (`docs/debugger-spec.md` §2.1's DWARF-`is_stmt` design).
480/// v1 sets this on every entry (statement-level rows only); a later
481/// expression-level entry arrives with this bit unset, additively — no
482/// version bump, no reader change.
483pub const DEBUG_FLAG_IS_STMT: u8 = 0b0000_0001;
484
485/// `flags` bit 1: this entry's own `bytecode_offset` is the prologue-end
486/// landing point for a breakpoint set on the enclosing container
487/// (`docs/debugger-spec.md` §2.4) — past any leading parameter-binding
488/// `DeclareTemp`s / choice-output prologue bytes. At most one entry per
489/// container carries this bit.
490pub const DEBUG_FLAG_PROLOGUE_END: u8 = 0b0000_0010;
491
492/// Bits 2–7 of `flags` are reserved. Per `docs/debugger-spec.md` §2.2's
493/// explicit, ruled departure from this format's default strict-rejection
494/// posture, a `DebugInfo` reader **must ignore** any reserved bit it does
495/// not recognize rather than reject the entry — this constant exists so
496/// callers can mask deliberately (e.g. a round-trip test asserting v1 never
497/// sets a reserved bit) without hand-writing the mask twice.
498pub const DEBUG_FLAG_RESERVED_MASK: u8 = !(DEBUG_FLAG_IS_STMT | DEBUG_FLAG_PROLOGUE_END);
499
500/// Which frontend parsed a `DebugInfo` file-table entry's file
501/// (`docs/debugger-spec.md` §2.3) — `KindToken::raw` is frontend-private
502/// (two independent `ProvenanceResolver` numberings), so a reader must know
503/// which resolver applies before interpreting an entry's `kind_token`.
504/// Recorded once per file (not per entry) since surface is a property of
505/// where the code came from, constant for every entry pointing at that
506/// file.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508#[repr(u8)]
509pub enum FileSurface {
510 /// The reserved sentinel file (index 0) — synthetic provenance
511 /// (`Provenance::synthetic`'s `FileId(u32::MAX)`, §2.5). Never a real
512 /// file; `path` is always empty for this surface.
513 Synthetic = 0,
514 /// Parsed by the `.ink` compatibility surface (`brink-syntax`).
515 Ink = 1,
516 /// Parsed by the `.brink` native surface (`brink-syntax-native`).
517 Native = 2,
518}
519
520impl FileSurface {
521 pub(crate) fn from_u8(tag: u8) -> Result<Self, crate::opcode::DecodeError> {
522 match tag {
523 0 => Ok(Self::Synthetic),
524 1 => Ok(Self::Ink),
525 2 => Ok(Self::Native),
526 _ => Err(crate::opcode::DecodeError::InvalidFileSurface(tag)),
527 }
528 }
529}
530
531/// One entry in the `DebugInfo` section's section-local file table
532/// (`docs/debugger-spec.md` §2.3). Index 0 is always the reserved synthetic
533/// sentinel (§2.5) — `surface = Synthetic`, `path = ""` — real files start
534/// at index 1. Paths are project-root-relative (`root_relative_key`), not
535/// process-cwd-relative or absolute.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct DebugFileEntry {
538 pub surface: FileSurface,
539 pub path: String,
540 /// [`content_hash`] of this file's text **exactly as the compiler
541 /// consumed it** (#3261) — no normalisation of line endings,
542 /// whitespace or encoding on either side. A reader that hashes
543 /// differently-normalised text will see a spurious mismatch, so the
544 /// contract is the raw bytes that were compiled.
545 ///
546 /// Lets a consumer detect that the source it is measuring against is
547 /// not the source this program was built from, and answer
548 /// `StaleSource` instead of a confidently wrong address. That failure
549 /// applies to byte ranges every bit as much as to line numbers —
550 /// offsets shift on every inserted character.
551 ///
552 /// Per-file, deliberately: one dirty file then degrades debugging in
553 /// that file alone, where a whole-program checksum degrades
554 /// everything.
555 ///
556 /// `0` for the reserved synthetic sentinel at index 0, which names no
557 /// real file.
558 pub source_hash: u64,
559 /// Byte offset of the start of each line in this file, ascending, with
560 /// `line_starts[0] == 0` (#3261). Length is the file's line count.
561 ///
562 /// Carrying this means the engine can answer `file:line` **without
563 /// being handed source text at all** — which is what a remote debugger
564 /// frontend (DAP's `setBreakpoints` is file + line) needs, and what
565 /// keeps line↔byte conversion to one implementation instead of one per
566 /// consumer. Line indexing is 0-based here; a UI showing 1-based line
567 /// numbers converts at its own edge.
568 ///
569 /// Empty for the synthetic sentinel, and legitimately empty for an
570 /// empty file.
571 pub line_starts: Vec<u32>,
572}
573
574/// One row in a container's `DebugInfo` entry table
575/// (`docs/debugger-spec.md` §2.2): maps a bytecode offset (within that
576/// container's own bytecode) to the source range it was lowered from.
577/// Entries for one container are sorted ascending by `bytecode_offset` so a
578/// reader can floor-lookup via binary search.
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub struct DebugEntry {
581 /// Byte offset within the owning container's own bytecode.
582 pub bytecode_offset: u32,
583 /// Index into the section's file table (§2.3) — not the compiler's
584 /// project-wide `FileId`.
585 pub file_idx: u32,
586 /// Absolute source byte offset within the file at `file_idx`.
587 pub range_start: u32,
588 /// Length in bytes of the source range (`range_end = range_start +
589 /// range_len`).
590 pub range_len: u32,
591 /// `KindToken::as_u32()` verbatim (class in the high 16 bits, raw in
592 /// the low 16) — `brink-format` carries this opaquely; interpreting it
593 /// needs `file_table[file_idx].surface` to pick the right
594 /// `ProvenanceResolver` (§2.3), which is `brink-ir`'s job, not this
595 /// crate's (no dependency edge from `brink-format` to `brink-ir`).
596 pub kind_token: u32,
597 /// `DEBUG_FLAG_IS_STMT` / `DEBUG_FLAG_PROLOGUE_END`, plus reserved bits
598 /// a reader must tolerate (never reject on) — see those constants' docs.
599 pub flags: u8,
600}
601
602/// One row in a container's `DebugInfo` locals table
603/// (`docs/debugger-spec.md` §3): a VM temp slot's declared name, and
604/// optionally the source range it was declared at (for slot-reuse
605/// disambiguation). **D7's payload** (`docs/debugger-spec.md` §3, issue
606/// #3185) — D6 emits the structural framing (an empty `locals` per
607/// container) but does not populate real entries; the wire shape ships now
608/// so D7 adds data without a layout change.
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct DebugLocalEntry {
611 /// Matches the `u16` operand `DeclareTemp`/`GetTemp`/`SetTemp` use.
612 pub slot: u16,
613 pub name: String,
614 /// The declaring range, if known: `(file_idx, range_start, range_len)`,
615 /// the same triple shape entries use (§2.2).
616 pub declaring_range: Option<(u32, u32, u32)>,
617 /// A temp the compiler minted rather than the author (issue #3395: the
618 /// lift-order hoist's `$liftN` temps — `docs/debugger-spec.md` §3).
619 /// The studio's locals view hides these rows; the value is real and
620 /// still resolvable by slot for tooling that wants it. Wire: bit 1 of
621 /// the row's flags byte (section version 2), `false` for every row a
622 /// version-1 writer produced.
623 pub synthetic: bool,
624}
625
626/// One container's `DebugInfo` table (`docs/debugger-spec.md` §2.2): the
627/// `DebugInfo` section's Nth `DebugContainerTable` describes the container
628/// at `StoryData::containers[N]` — addressed by the same `container_idx`
629/// the runtime's `ContainerPosition` uses, lockstep with the `Containers`
630/// section, no `DefinitionId` lookup needed on the read path.
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub struct DebugContainerTable {
633 /// Sorted ascending by `bytecode_offset`; covers the container's full
634 /// address range with no gaps (§2.2's coverage guarantee).
635 pub entries: Vec<DebugEntry>,
636 /// D7's payload (see [`DebugLocalEntry`]) — empty until D7 lands.
637 pub locals: Vec<DebugLocalEntry>,
638}
639
640/// The `DebugInfo` section (`docs/debugger-spec.md` §2, `.inkb` tag
641/// `0x11`): bytecode-offset → source-range map, plus the section-local file
642/// table it's keyed against. Carried on [`crate::StoryData::debug_info`] as
643/// `Option` — `None` when not requested (dev/studio compiles and an
644/// explicit CLI debug flag opt in; release export never does, §1.2 ship
645/// policy) — distinct from the other "always present, possibly empty"
646/// section types, because presence here tracks *whether debug info was
647/// requested*, not merely whether any entry was produced.
648#[derive(Debug, Clone, PartialEq, Eq)]
649pub struct DebugInfoSection {
650 /// Index 0 is always the reserved synthetic sentinel (§2.5).
651 pub files: Vec<DebugFileEntry>,
652 /// One table per container, in the same order and count as
653 /// [`crate::StoryData::containers`].
654 pub containers: Vec<DebugContainerTable>,
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 #[test]
662 fn content_hash_deterministic() {
663 let a = content_hash("Hello, world!");
664 let b = content_hash("Hello, world!");
665 assert_eq!(a, b);
666 }
667
668 // ── #3261: the hash is a wire contract, so pin the DIGESTS ──────────
669 //
670 // `content_hash_deterministic` above hashes the same string twice in
671 // one process. That proves same-run stability — which is NOT the
672 // property anything depends on. These hashes are written into
673 // artifacts and compared later, possibly by a different binary or
674 // toolchain, so what matters is that the algorithm itself never moves.
675 // A same-process round trip cannot see an algorithm change; hard-coded
676 // digests can, and are the reason `std`'s `DefaultHasher` (documented
677 // as unspecified between Rust releases) is no longer used here.
678
679 #[test]
680 fn content_hash_matches_the_canonical_fnv_1a_64_vectors() {
681 // Published FNV-1a 64-bit test vectors — independent of this
682 // implementation, so they prove the ALGORITHM is right rather than
683 // merely self-consistent. If these fail, the function is no longer
684 // FNV-1a and `docs/format-spec.md` is lying.
685 assert_eq!(content_hash("a"), 0xaf63_dc4c_8601_ec8c);
686 assert_eq!(content_hash("foobar"), 0x8594_4171_f739_67e8);
687 // The empty string is the bare offset basis.
688 assert_eq!(content_hash(""), 0xcbf2_9ce4_8422_2325);
689 }
690
691 #[test]
692 fn content_hash_digests_are_pinned_for_representative_source_text() {
693 // Including non-ASCII, because brink is a narrative language: em
694 // dashes and curly quotes are the normal case, and a change that
695 // hashed chars instead of UTF-8 bytes would pass ASCII-only tests.
696 assert_eq!(content_hash("Hello, world!"), 0x38d1_3341_4498_7bf4);
697 assert_eq!(content_hash("some text"), 0x15b9_e594_d5d3_b704);
698 assert_eq!(
699 content_hash("The vendor — she of the curly quotes — said \u{201c}no\u{201d}."),
700 0x9de0_0913_8091_d4e5
701 );
702 }
703
704 #[test]
705 fn content_hash_distinguishes_texts_that_differ_only_late() {
706 // A rolling hash that failed to mix would collide on these; the
707 // detector would then miss exactly the small edits it exists for.
708 assert_ne!(content_hash("chapter one"), content_hash("chapter onf"));
709 assert_ne!(
710 content_hash("a long line of prose"),
711 content_hash("a long line of prosf")
712 );
713 }
714
715 #[test]
716 fn content_hash_non_zero_for_non_empty() {
717 assert_ne!(content_hash("some text"), 0);
718 assert_ne!(content_hash("x"), 0);
719 }
720
721 #[test]
722 fn content_hash_differs_for_different_input() {
723 assert_ne!(content_hash("hello"), content_hash("world"));
724 }
725}