Skip to main content

brink_runtime/
linker.rs

1//! Links [`StoryData`] into an executable [`Program`].
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use brink_format::{DefinitionId, NameId, StoryData};
7
8use crate::collections::{Map as HashMap, map_with_capacity};
9use crate::error::RuntimeError;
10use crate::program::{
11    ExternalFnEntry, GlobalSlot, LinkTables, LinkedContainer, LinkedTarget, ListDefEntry,
12    ListItemEntry, PathTarget, Program, StructShapeEntry, linked_operand,
13};
14
15/// Look up a `NameId` in `StoryData::name_table`, failing cleanly on an
16/// out-of-range index instead of panicking. `NameId`s embedded in
17/// malformed/adversarial bytecode are not guaranteed to be in range — this
18/// is the linker's own validation, the sanctioned way for such a program to
19/// stop (never an unchecked index panic).
20fn resolve_name(data: &StoryData, name_id: NameId) -> Result<String, RuntimeError> {
21    data.name_table
22        .get(name_id.0 as usize)
23        .cloned()
24        .ok_or_else(|| RuntimeError::InvalidNameId(name_id.0))
25}
26
27/// Link a [`StoryData`] into an executable [`Program`].
28///
29/// Builds lookup tables mapping [`DefinitionId`]s to flat array indices.
30/// The root container is `containers[0]` by convention — the brink compiler
31/// emits the root first.
32#[expect(clippy::cast_possible_truncation, clippy::too_many_lines)]
33pub fn link(
34    data: &StoryData,
35) -> Result<(Program, Vec<Vec<brink_format::LineEntry>>), RuntimeError> {
36    let mut container_map = map_with_capacity(data.containers.len());
37
38    for (i, cdef) in data.containers.iter().enumerate() {
39        let idx = i as u32;
40        container_map.insert(cdef.id, idx);
41    }
42
43    // Build scope line tables and a map from scope_id → table index.
44    let mut scope_table_map: HashMap<DefinitionId, u32> = map_with_capacity(data.line_tables.len());
45    let mut line_tables: Vec<Vec<brink_format::LineEntry>> =
46        Vec::with_capacity(data.line_tables.len());
47    let mut scope_ids: Vec<DefinitionId> = Vec::with_capacity(data.line_tables.len());
48    for lt in &data.line_tables {
49        let idx = line_tables.len() as u32;
50        scope_table_map.insert(lt.scope_id, idx);
51        scope_ids.push(lt.scope_id);
52        line_tables.push(lt.lines.clone());
53    }
54
55    // Build containers with scope_table_idx.
56    let mut containers = Vec::with_capacity(data.containers.len());
57    for cdef in &data.containers {
58        let scope_table_idx = scope_table_map.get(&cdef.scope_id).copied().unwrap_or(0);
59        containers.push(LinkedContainer {
60            id: cdef.id,
61            bytecode: cdef.bytecode.clone(),
62            counting_flags: cdef.counting_flags,
63            path_hash: cdef.path_hash,
64            param_count: cdef.param_count,
65            params: cdef.params.clone(),
66            scope_table_idx,
67            scope_id: cdef.scope_id,
68        });
69    }
70
71    // Build globals.
72    let mut globals = Vec::with_capacity(data.variables.len());
73    let mut global_map = map_with_capacity(data.variables.len());
74    for (i, gvar) in data.variables.iter().enumerate() {
75        let idx = i as u32;
76        global_map.insert(gvar.id, idx);
77        globals.push(GlobalSlot {
78            id: gvar.id,
79            name: gvar.name,
80            default: gvar.default_value.clone(),
81            local: gvar.local,
82        });
83    }
84
85    // Build unified address map from containers and address defs.
86    // Containers get offset 0 (primary addresses).
87    let mut address_map = map_with_capacity(data.containers.len() + data.addresses.len());
88    for (i, cdef) in data.containers.iter().enumerate() {
89        address_map.insert(cdef.id, (i as u32, 0usize));
90    }
91    // Address defs add intra-container targets (and primary addresses from converter).
92    for addr in &data.addresses {
93        let container_idx = container_map
94            .get(&addr.container_id)
95            .copied()
96            .ok_or_else(|| RuntimeError::UnresolvedDefinition(addr.container_id))?;
97        address_map.insert(addr.id, (container_idx, addr.byte_offset as usize));
98    }
99
100    // Root container is always the first entry by convention.
101    if data.containers.is_empty() {
102        return Err(RuntimeError::NoRootContainer);
103    }
104    let link = link_static_operands(&containers, &address_map, &global_map);
105
106    let root_idx = 0;
107
108    let name_table = data.name_table.clone();
109
110    // Build list item map.
111    let mut list_item_map = map_with_capacity(data.list_items.len());
112    for li in &data.list_items {
113        list_item_map.insert(
114            li.id,
115            ListItemEntry {
116                name: li.name,
117                ordinal: li.ordinal,
118                origin: li.origin,
119            },
120        );
121    }
122
123    // Build list defs and list def map.
124    let mut list_defs = Vec::with_capacity(data.list_defs.len());
125    let mut list_def_map = map_with_capacity(data.list_defs.len());
126    for ldef in &data.list_defs {
127        let idx = list_defs.len();
128        // Collect all items belonging to this list, sorted by ordinal.
129        let mut items: Vec<_> = data
130            .list_items
131            .iter()
132            .filter(|li| li.origin == ldef.id)
133            .collect();
134        items.sort_by_key(|li| li.ordinal);
135        let item_ids: Vec<_> = items.iter().map(|li| li.id).collect();
136
137        list_def_map.insert(ldef.id, idx);
138        list_defs.push(ListDefEntry {
139            name: ldef.name,
140            items: item_ids,
141        });
142    }
143
144    // Clone list literals.
145    let list_literals = data.list_literals.clone();
146
147    // Clone the T1b literal pool (`PushLiteral(idx)` targets).
148    let literal_pool = data.literal_pool.clone();
149
150    // Build the TM-4 struct shape table, indexed by `ShapeId` (contiguous
151    // small-integer ids assigned at codegen time — a plain `Vec` indexed by
152    // `shape.0` mirrors `literal_pool`'s `u32`-indexed layout, no `HashMap`
153    // involved).
154    let mut struct_shapes: Vec<StructShapeEntry> = Vec::with_capacity(data.struct_shapes.len());
155    for shape in &data.struct_shapes {
156        let idx = shape.id.0 as usize;
157        if struct_shapes.len() <= idx {
158            struct_shapes.resize_with(idx + 1, || StructShapeEntry {
159                name: NameId(0),
160                fields: Vec::new(),
161            });
162        }
163        struct_shapes[idx] = StructShapeEntry {
164            name: shape.name,
165            fields: shape.fields.clone(),
166        };
167    }
168
169    // Build external function map.
170    let mut external_fns = map_with_capacity(data.externals.len());
171    for ext in &data.externals {
172        external_fns.insert(
173            ext.id,
174            ExternalFnEntry {
175                name: ext.name,
176                fallback: ext.fallback,
177            },
178        );
179    }
180
181    // Build the path → address lookup used by `Program::find_address`.
182    //
183    // When the program carries an explicit `address_paths` table (compiler
184    // output), it is the source of truth: each entry's qualified path maps to
185    // its target, resolved through `address_map`. This is what enables
186    // qualified addressing of scopes (`knot`, `knot.stitch`) and author labels
187    // (`knot.label`, `knot.stitch.label`).
188    //
189    // When the table is empty (legacy `.inkb` or converter output, which does
190    // not emit it), fall back to deriving scope paths from container names —
191    // the previous behavior, which already qualifies knot/stitch scope names.
192    let mut address_by_path: HashMap<String, PathTarget> = HashMap::new();
193    if data.address_paths.is_empty() {
194        // `BTreeMap` has no `reserve` — no-op under `no_std`.
195        #[cfg(feature = "std")]
196        address_by_path.reserve(data.containers.len());
197        for (i, cdef) in data.containers.iter().enumerate() {
198            if let Some(name_id) = cdef.name {
199                let name = resolve_name(data, name_id)?;
200                address_by_path.insert(
201                    name,
202                    PathTarget {
203                        id: cdef.id,
204                        container_idx: i as u32,
205                        byte_offset: 0,
206                    },
207                );
208            }
209        }
210    } else {
211        // `BTreeMap` has no `reserve` — no-op under `no_std`.
212        #[cfg(feature = "std")]
213        address_by_path.reserve(data.address_paths.len());
214        for ap in &data.address_paths {
215            // Resolve the target through the address map; skip anything
216            // unresolvable (defensive — should not happen for valid output).
217            if let Some(&(idx, offset)) = address_map.get(&ap.target) {
218                let name = resolve_name(data, ap.path)?;
219                address_by_path.insert(
220                    name,
221                    PathTarget {
222                        id: ap.target,
223                        container_idx: idx,
224                        byte_offset: offset,
225                    },
226                );
227            }
228        }
229    }
230
231    // Compiled `#@local` knot/stitch defaults — the base layer of policy
232    // resolution. Sorted by path so a knot expands before its stitches.
233    let mut local_scope_defaults: Vec<(String, DefinitionId)> = Vec::new();
234    for cdef in data.containers.iter().filter(|c| c.local) {
235        if let Some(n) = cdef.name {
236            local_scope_defaults.push((resolve_name(data, n)?, cdef.id));
237        }
238    }
239    local_scope_defaults.sort();
240
241    // M-2b (`docs/modules-spec.md` §4): the `#@private` definition set, used
242    // only to refuse host semantic access. Empty for the all-public world.
243    // Sorted so `Program::is_private` can binary-search (the compiler already
244    // emits it sorted; re-sort defensively for hand-built/legacy `StoryData`).
245    let mut private_defs: Vec<DefinitionId> = data.private_defs.clone();
246    private_defs.sort_by_key(|d| d.to_raw());
247
248    // M-3 (`docs/modules-spec.md` §5): the compiled alias table, sorted by
249    // `old` for `Program::resolve_alias`'s binary search. Sorted again here
250    // rather than trusted as-is — malformed/adversarial `.inkb` bytes are
251    // not guaranteed to preserve the compiler's ordering invariant.
252    let mut alias_table = data.alias_table.clone();
253    alias_table.sort_unstable();
254
255    let program = Program {
256        containers,
257        link,
258        address_map,
259        scope_ids,
260        source_checksum: data.source_checksum,
261        globals,
262        global_map,
263        name_table,
264        container_paths: crate::program::container_paths_from(&address_by_path),
265        address_by_path,
266        root_idx,
267        list_literals,
268        literal_pool,
269        list_item_map,
270        list_defs,
271        list_def_map,
272        external_fns,
273        local_scope_defaults,
274        struct_shapes,
275        private_defs,
276        alias_table,
277        debug_info: data.debug_info.clone(),
278    };
279    Ok((program, line_tables))
280}
281
282/// Resolve every static operand once and write its resolved form into a
283/// linked copy of each container's code — a target's ordinal in
284/// `LinkTables::targets`, a global's slot index — see `LinkTables` for the
285/// layout and the rulings it serves.
286///
287/// Ordinals are assigned in walk order (container by container, instruction
288/// by instruction), so two links of the same data produce the same table.
289/// A target `address_map` cannot resolve stays symbolic, and a container
290/// whose bytecode stops decoding is left symbolic from that point on: in
291/// both cases the VM meets exactly the error it would have met before.
292#[expect(
293    clippy::cast_possible_truncation,
294    reason = "a target ordinal indexes a Vec built here; it cannot exceed u32"
295)]
296fn link_static_operands(
297    containers: &[LinkedContainer],
298    address_map: &HashMap<DefinitionId, (u32, usize)>,
299    global_map: &HashMap<DefinitionId, u32>,
300) -> LinkTables {
301    use brink_format::{Opcode, StaticKind};
302
303    let mut targets: Vec<LinkedTarget> = Vec::new();
304    let mut ordinals: HashMap<DefinitionId, u32> = HashMap::new();
305    let mut code = Vec::with_capacity(containers.len());
306    for container in containers {
307        let symbolic = &container.bytecode;
308        let mut linked = symbolic.clone();
309        let mut offset = 0;
310        while offset < symbolic.len() {
311            let site = Opcode::peek_static(symbolic, offset);
312            let Ok(op) = Opcode::decode(symbolic, &mut offset) else {
313                break;
314            };
315            let Some(site) = site else {
316                continue;
317            };
318            let resolved = match (site.kind, op) {
319                (
320                    StaticKind::Target(_),
321                    Opcode::Goto(id)
322                    | Opcode::GotoIf(id)
323                    | Opcode::EnterContainer(id)
324                    | Opcode::Call(id)
325                    | Opcode::TunnelCall(id)
326                    | Opcode::ThreadCall(id)
327                    | Opcode::BeginChoice(_, id),
328                ) => address_map.get(&id).map(|&(container_idx, target_offset)| {
329                    *ordinals.entry(id).or_insert_with(|| {
330                        targets.push(LinkedTarget {
331                            container_idx,
332                            offset: target_offset,
333                            id,
334                        });
335                        (targets.len() - 1) as u32
336                    })
337                }),
338                // A global's linked operand is its slot index — `globals`
339                // is already dense, so no table is needed.
340                (
341                    StaticKind::Global(_),
342                    Opcode::GetGlobal(id) | Opcode::SetGlobal(id) | Opcode::TakeGlobal(id),
343                ) => global_map.get(&id).copied(),
344                _ => None,
345            };
346            if let Some(operand) = resolved {
347                linked[site.operand..site.end].copy_from_slice(&linked_operand(operand));
348            }
349        }
350        code.push(linked);
351    }
352    LinkTables { code, targets }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    use brink_format::Opcode;
360
361    use crate::program::linked_ordinal;
362
363    /// Every kind of static target in one story: a divert to a knot, a
364    /// gather label inside a weave, a function call, a tunnel, a thread and
365    /// a choice.
366    const STORY: &str = r"
367VAR x = 0
368-> top
369=== top ===
370~ x = f(1)
371-> tunnel ->
372<- side
373* [A] -> gather_here
374* [B]
375- (gather_here) Gathered.
376{ x > 0: -> top | -> END }
377=== function f(n) ===
378~ return n + 1
379=== tunnel ===
380In the tunnel.
381->->
382=== side ===
383Side thread.
384-> DONE
385";
386
387    fn compiled() -> StoryData {
388        brink_compiler::compile("main.ink", |_p| Ok(STORY.to_owned()))
389            .unwrap()
390            .data
391    }
392
393    /// Walk a container's symbolic bytecode, yielding each static-global
394    /// site with the id its operand carries.
395    fn global_sites(bytecode: &[u8]) -> Vec<(brink_format::StaticSite, DefinitionId)> {
396        let mut out = Vec::new();
397        let mut off = 0;
398        while off < bytecode.len() {
399            let site = Opcode::peek_static(bytecode, off);
400            let op = Opcode::decode(bytecode, &mut off).expect("symbolic bytecode decodes");
401            let Some(site) = site else { continue };
402            if !matches!(site.kind, brink_format::StaticKind::Global(_)) {
403                continue;
404            }
405            let (Opcode::GetGlobal(id) | Opcode::SetGlobal(id) | Opcode::TakeGlobal(id)) = op
406            else {
407                continue;
408            };
409            assert_eq!(site.end, off);
410            out.push((site, id));
411        }
412        out
413    }
414
415    /// Walk a container's symbolic bytecode, yielding each static-target
416    /// site with the id its operand carries.
417    fn target_sites(bytecode: &[u8]) -> Vec<(brink_format::TargetSite, DefinitionId)> {
418        let mut out = Vec::new();
419        let mut off = 0;
420        while off < bytecode.len() {
421            let site = Opcode::peek_target(bytecode, off);
422            let op = Opcode::decode(bytecode, &mut off).expect("symbolic bytecode decodes");
423            let Some(site) = site else { continue };
424            // `peek_target`'s classification is pinned by brink-format's own
425            // test; here only the extent agreement matters.
426            let (Opcode::Goto(id)
427            | Opcode::GotoIf(id)
428            | Opcode::EnterContainer(id)
429            | Opcode::Call(id)
430            | Opcode::TunnelCall(id)
431            | Opcode::ThreadCall(id)
432            | Opcode::BeginChoice(_, id)) = op
433            else {
434                continue;
435            };
436            assert_eq!(
437                site.end, off,
438                "peek and decode agree on the instruction's extent"
439            );
440            out.push((site, id));
441        }
442        out
443    }
444
445    /// Each resolvable static target's operand is rewritten to an ordinal
446    /// whose table entry is exactly what `address_map` says for the id the
447    /// symbolic bytecode still carries; nothing else in the code changes,
448    /// and the symbolic bytecode is untouched.
449    #[test]
450    fn linked_code_holds_ordinals_for_every_resolvable_static_target() {
451        let data = compiled();
452        let (program, _) = link(&data).expect("links");
453        assert_eq!(program.link.code.len(), program.containers.len());
454
455        let mut sites_seen = 0;
456        let mut globals_seen = 0;
457        let mut kinds = alloc::collections::BTreeSet::new();
458        for (i, container) in program.containers.iter().enumerate() {
459            let symbolic = &container.bytecode;
460            let linked = &program.link.code[i];
461            assert_eq!(
462                symbolic, &data.containers[i].bytecode,
463                "symbolic copy untouched"
464            );
465            assert_eq!(symbolic.len(), linked.len(), "same length, same offsets");
466
467            let mut rewritten = alloc::vec![false; symbolic.len()];
468            for (site, id) in global_sites(symbolic) {
469                globals_seen += 1;
470                let slot = program.global_map.get(&id).copied();
471                let linked_slot = linked_ordinal(&linked[site.operand..site.end]);
472                assert_eq!(slot, linked_slot, "global site {site:?} for {id}");
473                if linked_slot.is_some() {
474                    rewritten[site.operand..site.end].fill(true);
475                }
476            }
477            for (site, id) in target_sites(symbolic) {
478                sites_seen += 1;
479                kinds.insert(
480                    format!("{:?}", site.kind)
481                        .split('(')
482                        .next()
483                        .unwrap()
484                        .to_owned(),
485                );
486                let expected = program.address_map.get(&id).copied();
487                let ordinal = linked_ordinal(&linked[site.operand..site.end]);
488                assert_eq!(
489                    expected.is_some(),
490                    ordinal.is_some(),
491                    "site {site:?} for {id}: address_map {expected:?}, linked {ordinal:?}"
492                );
493                if let (Some((cidx, coff)), Some(ord)) = (expected, ordinal) {
494                    let t = program.target(ord).expect("ordinal in table");
495                    assert_eq!((t.container_idx, t.offset, t.id), (cidx, coff, id));
496                    rewritten[site.operand..site.end].fill(true);
497                }
498            }
499            for (k, (a, b)) in symbolic.iter().zip(linked).enumerate() {
500                if !rewritten[k] {
501                    assert_eq!(
502                        a, b,
503                        "byte {k} of container {i} outside any operand changed"
504                    );
505                }
506            }
507        }
508        assert!(
509            sites_seen >= 6,
510            "the story exercises several targets: {sites_seen}"
511        );
512        assert!(
513            globals_seen >= 2,
514            "the story reads and writes a global: {globals_seen}"
515        );
516        for kind in ["Goto", "Call", "TunnelCall", "ThreadCall", "BeginChoice"] {
517            assert!(kinds.contains(kind), "story exercises {kind}: {kinds:?}");
518        }
519        // Ordinals are dense and deterministic: linking twice gives the same table.
520        let (again, _) = link(&data).expect("links");
521        assert_eq!(program.link.targets, again.link.targets);
522        assert_eq!(program.link.code, again.link.code);
523    }
524
525    /// An operand naming an address the program does not have stays
526    /// symbolic in the linked code, so the VM meets the same
527    /// `UnresolvedDefinition` it did before — and everything after it in the
528    /// container is still rewritten.
529    #[test]
530    fn unresolvable_target_stays_symbolic() {
531        let mut data = compiled();
532        // Find a Goto site and point it at an id nothing defines.
533        let bogus = DefinitionId::new(brink_format::DefinitionTag::Address, 0x00DE_AD00_BEEF);
534        let mut patched: Option<(usize, brink_format::TargetSite)> = None;
535        'outer: for (i, c) in data.containers.iter().enumerate() {
536            for (site, _) in target_sites(&c.bytecode) {
537                if site.kind == brink_format::TargetKind::Goto {
538                    patched = Some((i, site));
539                    break 'outer;
540                }
541            }
542        }
543        let (ci, site) = patched.expect("the story has a Goto");
544        data.containers[ci].bytecode[site.operand..site.end]
545            .copy_from_slice(&bogus.to_raw().to_le_bytes());
546
547        let (program, _) =
548            link(&data).expect("an unresolvable divert is a run-time error, not a link error");
549        let linked = &program.link.code[ci];
550        assert_eq!(linked_ordinal(&linked[site.operand..site.end]), None);
551        assert_eq!(
552            &linked[site.operand..site.end],
553            &bogus.to_raw().to_le_bytes()
554        );
555        assert!(
556            !program.link.targets.iter().any(|t| t.id == bogus),
557            "nothing interned for the bogus id"
558        );
559        assert!(program.resolve(bogus).is_err());
560    }
561
562    /// Regression for a fuzzer-discovered panic (`vm_no_panic`, PR #672
563    /// workstream C): a `NameId` outside `StoryData::name_table`'s range —
564    /// reachable from arbitrary/malformed `.inkb` bytes, not just
565    /// well-formed compiler output — indexed the table directly and
566    /// panicked (`index out of bounds`). Linking such a program must fail
567    /// cleanly instead.
568    fn story_with_out_of_range_address_path_name() -> StoryData {
569        let mut data = brink_compiler::compile("main.ink", |_p| {
570            Ok("=== knot ===\nHello.\n-> END\n".to_owned())
571        })
572        .unwrap()
573        .data;
574        assert!(
575            !data.address_paths.is_empty(),
576            "compiler output should carry an address_paths table"
577        );
578        data.address_paths[0].path = NameId(u16::MAX);
579        data
580    }
581
582    #[test]
583    fn link_rejects_out_of_range_address_path_name_id() {
584        let data = story_with_out_of_range_address_path_name();
585        let result = link(&data);
586        assert!(
587            matches!(result, Err(RuntimeError::InvalidNameId(id)) if id == u16::MAX),
588            "out-of-range NameId must not link"
589        );
590    }
591}