Skip to main content

brink_codegen_inkb/
debug_info.rs

1//! D6 (`docs/debugger-spec.md` §2, issue #3184): recording
2//! `(bytecode_offset, source_range)` pairs as `emit` walks the container
3//! tree, and assembling them into a `brink_format::DebugInfoSection`.
4//!
5//! Gated on [`EmitOptions::emit_debug_info`], default `false` — the
6//! ship-policy default (§1.2): a release compile never pays for this, and
7//! never changes a single emitted byte (the byte-identical guarantee this
8//! module exists to preserve).
9
10use std::collections::HashMap;
11
12use brink_format::{
13    DEBUG_FLAG_IS_STMT, DEBUG_FLAG_PROLOGUE_END, DebugContainerTable, DebugEntry, DebugFileEntry,
14    DebugInfoSection, DebugLocalEntry, FileSurface, NameId,
15};
16use brink_ir::{FileId, Provenance, lir};
17
18/// Codegen-facing knobs for one `emit` call. `emit_debug_info` is the only
19/// field today — a `struct` (not a bare bool parameter) so a future knob
20/// (e.g. a D7 "populate locals" toggle) doesn't need another `emit_with_*`
21/// overload.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct EmitOptions<'a> {
24    /// Emit the `SectionKind::DebugInfo` section (`docs/debugger-spec.md`
25    /// §2). `false` (the `Default`) reproduces today's `emit()` behavior
26    /// byte-for-byte — this is what the ship-policy ruling (§1.2) and the
27    /// oracle-safety guarantee both depend on.
28    pub emit_debug_info: bool,
29    /// Source text per file, for the `DebugInfo` file table's `source_hash`
30    /// and `line_starts` (#3261). Only read when `emit_debug_info` is set,
31    /// so a release compile never pays for gathering it.
32    ///
33    /// The text must be **exactly what the compiler consumed** — the hash
34    /// is a staleness detector and any normalisation applied here but not
35    /// by a later reader (or vice versa) turns it into a permanent false
36    /// alarm.
37    ///
38    /// `None` (or a file missing from the map) means that file's entry gets
39    /// `source_hash: 0` and no line index: the section is still valid and
40    /// positions still resolve, but staleness cannot be detected and
41    /// `file:line` lookups for that file are unavailable. Degrading rather
42    /// than failing is deliberate — a debug artifact without the extras
43    /// beats no debug artifact.
44    pub debug_sources: Option<&'a std::collections::BTreeMap<brink_ir::FileId, String>>,
45}
46
47/// One recorded `(bytecode_offset, provenance)` pair for a single
48/// container, before file-table interning. Statement-level only in v1
49/// (`docs/debugger-spec.md` §2.1) — every entry this module produces sets
50/// `DEBUG_FLAG_IS_STMT`.
51pub(crate) struct RawDebugEntry {
52    pub offset: u32,
53    pub provenance: Provenance,
54    /// This entry's `bytecode_offset` is the prologue-end landing point
55    /// (§2.4) — at most one `true` per container.
56    pub prologue_end: bool,
57}
58
59/// One recorded temp-slot declaration for a single container's `LocalsTable`
60/// (`docs/debugger-spec.md` §3, D7/#3185). Produced from
61/// [`brink_ir::lir::Param`] (function/knot/stitch parameters — bound by a
62/// bare `DeclareTemp` opcode the caller emits directly, with no `lir::Stmt`
63/// of its own, so no source-level declaring range exists at this layer) and
64/// from [`brink_ir::lir::StmtKind::DeclareTemp`] (`~ temp` declarations,
65/// which do carry a real declaring [`Provenance`] via `Stmt::provenance`).
66pub(crate) struct RawLocal {
67    pub slot: u16,
68    pub name: NameId,
69    /// `None` for parameters (no per-param source range in LIR — see
70    /// above); `Some(stmt.provenance)` for a `~ temp` declaration.
71    pub declaring_range: Option<Provenance>,
72    /// [`brink_ir::lir::StmtKind::DeclareTemp`]'s `synthetic` — a
73    /// compiler-minted temp (#3395) the debugger hides; always `false` for
74    /// a parameter.
75    pub synthetic: bool,
76}
77
78/// Per-`emit()`-call debug-info recording state, held alongside
79/// [`crate::EmitState`] and threaded the same way. A dedicated struct
80/// (rather than loose `Option` fields on `EmitState`) keeps the container
81/// walk's debug bookkeeping out of the production emission path except at
82/// its two call sites, both gated on `EmitState::debug` being `Some`
83/// (`CLAUDE.md` "Instrumentation doesn't belong in the production path").
84pub(crate) struct DebugCollector {
85    /// One `Vec<RawDebugEntry>` per container, pushed in the same order as
86    /// `EmitState::chunks` — i.e. lockstep with the eventual
87    /// `StoryData::containers`, matching §2.2's `container_idx` contract.
88    containers: Vec<Vec<RawDebugEntry>>,
89    /// One `Vec<RawLocal>` per container, parallel to `containers` above
90    /// (same push order, same lockstep contract).
91    locals: Vec<Vec<RawLocal>>,
92    files: FileTableBuilder,
93}
94
95impl DebugCollector {
96    pub(crate) fn new() -> Self {
97        Self {
98            containers: Vec::new(),
99            locals: Vec::new(),
100            files: FileTableBuilder::new(),
101        }
102    }
103
104    /// Push one container's raw entries (already offset-ordered by
105    /// construction — the container walk records them in emission order,
106    /// which is offset order) plus its raw locals, and intern every
107    /// referenced file (from entries and from any local's declaring range)
108    /// into the section-local file table, first-reference order (§2.3).
109    pub(crate) fn push_container(&mut self, raw: Vec<RawDebugEntry>, locals: Vec<RawLocal>) {
110        for entry in &raw {
111            self.files.intern(entry.provenance.file);
112        }
113        for local in &locals {
114            if let Some(range) = local.declaring_range {
115                self.files.intern(range.file);
116            }
117        }
118        self.containers.push(raw);
119        self.locals.push(locals);
120    }
121
122    /// Finish collecting and produce the wire-shaped section. `program`
123    /// resolves each interned `FileId` to its project-root-relative path
124    /// and lets each file be classified by surface (§2.3). `errors` is
125    /// `EmitState::errors` (#3219 review): an interned `FileId` missing from
126    /// `program.file_paths` is a defect in the LIR fed to codegen — the same
127    /// class of thing `CodegenError` exists for — and must be surfaced
128    /// there, not silently defaulted to an empty path (which
129    /// `FileTableBuilder::to_entries` used to do, misclassifying the entry
130    /// as `FileSurface::Ink` in the process — worse than a crash, since it
131    /// routes a resolver lookup to the wrong `ProvenanceResolver` instead of
132    /// failing loudly).
133    pub(crate) fn finish(
134        self,
135        program: &lir::Program,
136        sources: Option<&std::collections::BTreeMap<FileId, String>>,
137        errors: &mut Vec<crate::CodegenError>,
138    ) -> DebugInfoSection {
139        let files = self.files.to_entries(program, sources, errors);
140        let index_of = |file: FileId| -> u32 { self.files.index_of(file) };
141        // Resolve a `NameId` against the program's name table — falls back
142        // to an empty name (never panics, per `CLAUDE.md`'s deny-`unwrap`/
143        // `expect`/`panic` posture) on an out-of-range id, which should not
144        // happen: every `NameId` on a `Param`/`DeclareTemp` is interned into
145        // this same table during LIR lowering.
146        let name_of = |id: NameId| -> String {
147            program
148                .name_table
149                .get(id.0 as usize)
150                .cloned()
151                .unwrap_or_default()
152        };
153        let containers = self
154            .containers
155            .into_iter()
156            .zip(self.locals)
157            .map(|(raw, raw_locals)| {
158                let entries = raw
159                    .into_iter()
160                    .map(|e| {
161                        let mut flags = DEBUG_FLAG_IS_STMT;
162                        if e.prologue_end {
163                            flags |= DEBUG_FLAG_PROLOGUE_END;
164                        }
165                        let range = e.provenance.range;
166                        DebugEntry {
167                            bytecode_offset: e.offset,
168                            file_idx: index_of(e.provenance.file),
169                            range_start: u32::from(range.start()),
170                            range_len: u32::from(range.len()),
171                            kind_token: e.provenance.kind.as_u32(),
172                            flags,
173                        }
174                    })
175                    .collect();
176                // D7's payload (docs/debugger-spec.md §3, issue #3185):
177                // slot -> name (+ optional declaring range) for every
178                // parameter and `~ temp` declared directly in this
179                // container's own body (nested child containers — branch
180                // bodies, gathers, choice targets — get their own table
181                // when they're walked in turn, per §2.2's per-container
182                // lockstep framing).
183                let locals = raw_locals
184                    .into_iter()
185                    .map(|l| DebugLocalEntry {
186                        slot: l.slot,
187                        name: name_of(l.name),
188                        declaring_range: l.declaring_range.map(|p| {
189                            (
190                                index_of(p.file),
191                                u32::from(p.range.start()),
192                                u32::from(p.range.len()),
193                            )
194                        }),
195                        synthetic: l.synthetic,
196                    })
197                    .collect();
198                DebugContainerTable { entries, locals }
199            })
200            .collect();
201        DebugInfoSection { files, containers }
202    }
203}
204
205/// Interns `FileId`s into the section-local file table (§2.3) in
206/// first-reference order, seeded with the reserved synthetic sentinel at
207/// index 0 (§2.5) regardless of whether anything ends up referencing it.
208struct FileTableBuilder {
209    order: Vec<FileId>,
210    index: HashMap<FileId, u32>,
211}
212
213impl FileTableBuilder {
214    fn new() -> Self {
215        let mut b = Self {
216            order: Vec::new(),
217            index: HashMap::new(),
218        };
219        // Index 0 is always the synthetic sentinel — `Provenance::synthetic`
220        // stamps `FileId(u32::MAX)` (`brink-ir/src/provenance.rs`).
221        b.order.push(FileId(u32::MAX));
222        b.index.insert(FileId(u32::MAX), 0);
223        b
224    }
225
226    fn intern(&mut self, file: FileId) {
227        if self.index.contains_key(&file) {
228            return;
229        }
230        #[expect(clippy::cast_possible_truncation)]
231        let idx = self.order.len() as u32;
232        self.order.push(file);
233        self.index.insert(file, idx);
234    }
235
236    /// Every raw entry's file passes through [`Self::intern`] (via
237    /// `DebugCollector::push_container`) before this is ever called, so a
238    /// miss here would mean a caller forgot that step — falling back to the
239    /// sentinel index (never panicking, per `CLAUDE.md`'s deny-`unwrap`/
240    /// `expect`/`panic` posture) rather than misattributing to another
241    /// file.
242    fn index_of(&self, file: FileId) -> u32 {
243        self.index.get(&file).copied().unwrap_or(0)
244    }
245
246    /// `errors` receives a [`crate::CodegenError`] for every interned
247    /// `FileId` that `program.file_paths` cannot resolve (#3219 review): a
248    /// silent `unwrap_or_default()` used to land such a file at its
249    /// already-assigned real index as `{surface: Ink, path: ""}` — a wrong
250    /// answer stamped with unwarranted confidence, worse than failing,
251    /// since a reader would route that file's entries through the ink
252    /// `ProvenanceResolver` for a file that was never ink at all. The
253    /// fallback shape here (`Synthetic`, empty path) is only ever reached
254    /// alongside a pushed error, never silently.
255    fn to_entries(
256        &self,
257        program: &lir::Program,
258        sources: Option<&std::collections::BTreeMap<FileId, String>>,
259        errors: &mut Vec<crate::CodegenError>,
260    ) -> Vec<DebugFileEntry> {
261        self.order
262            .iter()
263            .map(|file| {
264                if *file == FileId(u32::MAX) {
265                    return DebugFileEntry {
266                        surface: FileSurface::Synthetic,
267                        path: String::new(),
268                        source_hash: 0,
269                        line_starts: Vec::new(),
270                    };
271                }
272                if let Some(path) = program.file_paths.get(file) {
273                    // #3261: hash and line index, when the caller supplied
274                    // this file's text. Absent text degrades to
275                    // `source_hash: 0` + no index rather than failing —
276                    // positions still resolve, only staleness detection and
277                    // `file:line` lookup are unavailable.
278                    let (source_hash, line_starts) = sources.and_then(|m| m.get(file)).map_or_else(
279                        || (0, Vec::new()),
280                        |text| (brink_format::content_hash(text), line_starts_of(text)),
281                    );
282                    DebugFileEntry {
283                        surface: surface_from_path(path),
284                        path: path.clone(),
285                        source_hash,
286                        line_starts,
287                    }
288                } else {
289                    errors.push(crate::CodegenError::new(format!(
290                        "codegen: DebugInfo file table references {file:?}, which has no \
291                         entry in Program.file_paths — cannot resolve its path or surface \
292                         for the debug-info section (#3219)"
293                    )));
294                    DebugFileEntry {
295                        surface: FileSurface::Synthetic,
296                        path: String::new(),
297                        source_hash: 0,
298                        line_starts: Vec::new(),
299                    }
300                }
301            })
302            .collect()
303    }
304}
305
306/// Byte offset of the start of every line in `text` (#3261), ascending,
307/// always beginning with 0.
308///
309/// Lines are split on `\n`; a `\r\n` file simply carries the `\r` as the
310/// last byte of the preceding line, which is correct for offset purposes
311/// and is why nothing here normalises. Normalising would silently break the
312/// `source_hash` contract next to it, which is the raw bytes the compiler
313/// consumed.
314///
315/// A trailing newline does NOT produce a final empty line entry: `"a\n"` is
316/// one line, matching how every editor numbers it.
317fn line_starts_of(text: &str) -> Vec<u32> {
318    let mut starts = vec![0_u32];
319    for (i, byte) in text.bytes().enumerate() {
320        if byte == b'\n' {
321            let next = i + 1;
322            // A trailing newline does not open a new line: `"a\n"` is one
323            // line, matching how every editor numbers it.
324            if next < text.len()
325                && let Ok(next) = u32::try_from(next)
326            {
327                starts.push(next);
328            }
329        }
330    }
331    starts
332}
333
334/// Classify a source file's frontend from its path — the same pure,
335/// deterministic extension test `brink-db::queries::file_language` uses
336/// (`.brink` case-insensitive → native, everything else → ink).
337/// Duplicated here rather than shared: `brink-codegen-inkb` cannot depend
338/// on `brink-db` (wrong dependency direction — `brink-db` depends on the
339/// compiler crates, not the reverse), and this is a one-line pure
340/// path-string predicate with nothing else worth extracting a shared crate
341/// for.
342fn surface_from_path(path: &str) -> FileSurface {
343    let is_native = std::path::Path::new(path)
344        .extension()
345        .is_some_and(|ext| ext.eq_ignore_ascii_case("brink"));
346    if is_native {
347        FileSurface::Native
348    } else {
349        FileSurface::Ink
350    }
351}