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}