Skip to main content

synth_core/
dwarf_line.rs

1//! VCR-DBG-001 step 3 — compose the source-line table (the join half).
2//!
3//! The DWARF Tier-1 bridge maps an ARM text offset back to a source `file:line`
4//! through three established facts:
5//!   1. each ARM instruction carries `source_line` = the wasm OP INDEX
6//!      (`ArmInstruction.source_line`);
7//!   2. step 1 (`FunctionOps.op_offsets`) maps op-index → the wasm code BYTE
8//!      OFFSET (module-relative);
9//!   3. step 2 parses the input wasm's `.debug_line` → (code-section-relative
10//!      address → `file:line`) rows.
11//!
12//! This module is the join for the wasm half — **op-index → source line** —
13//! which step 4 (emit) composes with the ARM layout (ARM-text-offset → op-index
14//! is just `source_line`). It is pure plain-data (no gimli, no backend): the
15//! caller parses the rows and supplies them, so the module is Bazel-clean and
16//! unwired (frozen-safe) until the emitter consumes it.
17//!
18//! The crux it encodes (validated on `scripts/repro/dwarf_coherent.wasm`,
19//! VCR-DBG-001 step-3 fixture): `op_offsets` are MODULE-relative while DWARF
20//! addresses are CODE-section-relative, and they differ by a single constant —
21//! the code section's payload start. So normalization is one subtraction:
22//! `dwarf_addr = op_offset - code_base`.
23
24/// One `.debug_line` row: a code-section-relative address and its source line.
25/// `file` is an opaque caller-supplied id (e.g. an index into the line
26/// program's file table) so this stays gimli-free.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct LineRow {
29    /// Code-section-relative address (the DWARF-for-wasm address space).
30    pub addr: u32,
31    pub line: u32,
32    pub file: u32,
33}
34
35/// A resolved source location for a wasm op.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct SourceLoc {
38    pub line: u32,
39    pub file: u32,
40}
41
42/// Map each wasm op (by its module-relative `op_offsets` byte offset) to a
43/// source location, by normalizing into the code-section-relative DWARF address
44/// space (`op_offset - code_base`) and taking the covering line-table row (the
45/// last row whose address is ≤ the op's address — standard line-table lookup).
46///
47/// Returns one entry per `op_offsets` element (parallel to a function's ops).
48/// `None` where the op precedes `code_base` (shouldn't happen for real code) or
49/// no row covers it (an op before the first line-table address).
50pub fn op_offsets_to_source(
51    op_offsets: &[u32],
52    code_base: u32,
53    rows: &[LineRow],
54) -> Vec<Option<SourceLoc>> {
55    let mut sorted: Vec<LineRow> = rows.to_vec();
56    sorted.sort_by_key(|r| r.addr);
57    op_offsets
58        .iter()
59        .map(|&off| {
60            let a = off.checked_sub(code_base)?;
61            // Largest row addr ≤ a (the line in effect at address a).
62            sorted
63                .iter()
64                .rev()
65                .find(|r| r.addr <= a)
66                .map(|r| SourceLoc {
67                    line: r.line,
68                    file: r.file,
69                })
70        })
71        .collect()
72}
73
74// ---------------------------------------------------------------------------
75// VCR-DBG-001 step 4 — PRODUCTION read + emit (the `--debug-line` feature).
76//
77// `read_input_dwarf_line` ports the read-side spike
78// (`tests/dwarf_line_read_spike.rs` + `dwarf_compose_step3.rs::code_base`):
79// pull the `.debug_*` custom sections out of the input wasm, parse `.debug_line`
80// with gimli, and also report the code-section payload start (`code_base`) the
81// compose normalizes against. `emit_debug_sections` ports the emit-side spike
82// (`tests/dwarf_emit_roundtrip_step4.rs::emit_dwarf`): take an address-ordered
83// (arm_addr → source line) table and produce a FULL debugger-readable DWARF unit
84// (`.debug_info`/`.debug_abbrev`/`.debug_str`/`.debug_line`) via gimli::write,
85// the CU's DW_AT_stmt_list pointing at the line table. Both are gated behind
86// `--debug-line`; when the input carries no DWARF, `read_input_dwarf_line`
87// returns empty rows (graceful no-op) and the emit is skipped, so the default
88// object stays bit-identical.
89
90use std::collections::HashMap;
91
92use gimli::{Dwarf, EndianSlice, LittleEndian, SectionId};
93use wasmparser::{Parser, Payload};
94
95/// Result of reading the input wasm's DWARF line table: the parsed rows plus the
96/// code-section payload start (`code_base`) the op-offset compose subtracts.
97#[derive(Debug, Default, Clone)]
98pub struct InputDwarfLine {
99    /// Code-section-relative `.debug_line` rows (`addr` is a wasm code byte
100    /// offset; for the synth bridge that equals the DWARF address space).
101    pub rows: Vec<LineRow>,
102    /// Module-relative byte offset of the code section payload start. Empty wasm
103    /// or a wasm with no code section reports 0.
104    pub code_base: u32,
105    /// GLOBAL source-file table `(directory, file name)`. `LineRow.file` indexes
106    /// into this — NOT the input's per-unit DWARF file index. The input wasm has
107    /// several compilation units each with their own file table (colliding
108    /// per-unit indices), so the reader resolves every row's file to a real
109    /// `(dir, name)` string and interns it here, giving one flat table the emit
110    /// reproduces 1:1. Empty when no row resolves to a file (emit then falls back
111    /// to its single-file default).
112    pub files: Vec<(String, String)>,
113}
114
115/// Read the input wasm's `.debug_line` into code-section-relative
116/// `(addr → line)` rows and report `code_base`. Returns an empty table (rows
117/// empty, the feature a no-op) when the input carries no `.debug_*` sections or
118/// no parseable line program — never an error for a DWARF-free module.
119pub fn read_input_dwarf_line(wasm: &[u8]) -> InputDwarfLine {
120    // (a) extract every `.debug_*` custom section + find the code payload start.
121    let mut sections: HashMap<String, Vec<u8>> = HashMap::new();
122    let mut code_base = 0u32;
123    for payload in Parser::new(0).parse_all(wasm) {
124        match payload {
125            Ok(Payload::CustomSection(c)) if c.name().starts_with(".debug_") => {
126                sections.insert(c.name().to_string(), c.data().to_vec());
127            }
128            Ok(Payload::CodeSectionStart { range, .. }) => {
129                code_base = range.start as u32;
130            }
131            _ => {}
132        }
133    }
134    if !sections.contains_key(".debug_line") {
135        return InputDwarfLine {
136            rows: Vec::new(),
137            code_base,
138            files: Vec::new(),
139        };
140    }
141
142    // (b) parse `.debug_line` with gimli. A malformed line program degrades to
143    // an empty table (the feature no-ops) rather than failing the compile.
144    let (rows, files) = parse_debug_line_rows(&sections).unwrap_or_default();
145    InputDwarfLine {
146        rows,
147        code_base,
148        files,
149    }
150}
151
152/// gimli read of `.debug_line` → `(rows, global file table)`. The input carries
153/// several compilation units, each with its own file table, so a row's per-unit
154/// `file_index` is ambiguous once rows are flattened. To keep `LineRow.file`
155/// meaningful across units, each row's file index is resolved (via that unit's
156/// own header + `dwarf.attr_string`) to a real `(directory, name)` string and
157/// interned into one flat GLOBAL table; `LineRow.file` becomes that global index.
158/// `header.file(idx)` / `header.directory(idx)` handle the DWARF version's
159/// indexing (v4 is 1-based; v5 is 0-based) so both are read correctly.
160type LineTable = (Vec<LineRow>, Vec<(String, String)>);
161
162fn parse_debug_line_rows(sections: &HashMap<String, Vec<u8>>) -> Result<LineTable, gimli::Error> {
163    let empty: &[u8] = &[];
164    let load = |id: SectionId| -> Result<EndianSlice<'_, LittleEndian>, gimli::Error> {
165        let data = sections.get(id.name()).map_or(empty, |v| v.as_slice());
166        Ok(EndianSlice::new(data, LittleEndian))
167    };
168    let dwarf = Dwarf::load(load)?;
169
170    let mut rows = Vec::new();
171    let mut files: Vec<(String, String)> = Vec::new();
172    let mut units = dwarf.units();
173    while let Some(header) = units.next()? {
174        let unit = dwarf.unit(header)?;
175        let Some(program) = unit.line_program.clone() else {
176            continue;
177        };
178        // The header borrows the program; clone it so we can consume `program`
179        // in `rows()` while still resolving file indices afterwards.
180        let line_header = program.header().clone();
181        let mut state = program.rows();
182        while let Some((_, row)) = state.next_row()? {
183            if row.end_sequence() {
184                continue;
185            }
186            // Resolve this row's per-unit file index to a real (dir, name) and
187            // intern it into the flat global table (dedup); store the global idx.
188            let file = resolve_file(&dwarf, &unit, &line_header, row.file_index())
189                .map(|entry| intern_file(&mut files, entry))
190                .unwrap_or(0);
191            rows.push(LineRow {
192                addr: row.address() as u32,
193                line: row.line().map(|l| l.get() as u32).unwrap_or(0),
194                file,
195            });
196        }
197    }
198    Ok((rows, files))
199}
200
201/// Resolve a line-program `file_index` to `(directory, file name)` strings using
202/// the owning unit's header. `header.file` / `header.directory` apply the correct
203/// per-version indexing; `attr_string` resolves both inline (`.debug_line`) and
204/// `.debug_line_str`/`.debug_str` forms. `None` when the index has no file entry
205/// (e.g. DWARF v4 index 0).
206fn resolve_file(
207    dwarf: &Dwarf<EndianSlice<'_, LittleEndian>>,
208    unit: &gimli::Unit<EndianSlice<'_, LittleEndian>>,
209    header: &gimli::LineProgramHeader<EndianSlice<'_, LittleEndian>>,
210    file_index: u64,
211) -> Option<(String, String)> {
212    let file = header.file(file_index)?;
213    let name = dwarf
214        .attr_string(unit, file.path_name())
215        .ok()?
216        .to_string_lossy()
217        .into_owned();
218    let dir = match header.directory(file.directory_index()) {
219        Some(av) => dwarf
220            .attr_string(unit, av)
221            .ok()
222            .map(|s| s.to_string_lossy().into_owned())
223            .unwrap_or_default(),
224        None => String::new(),
225    };
226    Some((dir, name))
227}
228
229/// Intern a `(dir, name)` into the global file table, returning its index
230/// (existing if already present). Keeps the table small (the fixture interns a
231/// single `panicking.rs`).
232fn intern_file(files: &mut Vec<(String, String)>, entry: (String, String)) -> u32 {
233    if let Some(i) = files.iter().position(|f| *f == entry) {
234        return i as u32;
235    }
236    files.push(entry);
237    (files.len() - 1) as u32
238}
239
240/// One function to describe with a `DW_TAG_subprogram` child DIE (#394, Tier-1)
241/// so a debugger backtrace shows the FUNCTION NAME, not a bare address. Carries
242/// the function's name and its object-relative `[low_pc, high_pc)` `.text` range
243/// (byte offsets against the `.text` base). The emitter relocates `low_pc`
244/// against the SAME `.text` symbol as the CU (addend = `low_pc`) and writes
245/// `high_pc - low_pc` as the offset form of `DW_AT_high_pc` (no relocation).
246#[derive(Debug, Clone)]
247pub struct SubprogramInfo {
248    /// The export/function name shown in a debugger frame.
249    pub name: String,
250    /// Object-relative `.text` byte offset of the function's first instruction.
251    pub low_pc: u64,
252    /// Object-relative `.text` byte offset one past the function's last byte.
253    pub high_pc: u64,
254}
255
256/// Emit an address-ordered `(arm_addr, line)` table as a FULL minimal DWARF unit
257/// (gimli::write) and return EVERY non-empty `.debug_*` section it produces —
258/// `.debug_info`, `.debug_abbrev`, `.debug_str`, `.debug_line` (and
259/// `.debug_line_str`/`.debug_ranges` etc. when non-empty). The caller composes
260/// the table (one address-sorted, de-duped sequence covering every function);
261/// this produces the section bytes for non-ALLOC ELF `PROGBITS` sections.
262/// Returns an empty `Vec` for an empty table (nothing to map ⇒ no sections ⇒
263/// output stays byte-identical).
264///
265/// Crucially this emits a real root `DW_TAG_compile_unit` DIE with `DW_AT_name`,
266/// `DW_AT_low_pc`/`DW_AT_high_pc` spanning the emitted text, and the line program
267/// attached — so the CU's `DW_AT_stmt_list` points at `.debug_line`. That makes
268/// the line table reachable via the NORMAL debugger walk (`.debug_info` → CU →
269/// `DW_AT_stmt_list` → line program), not just a standalone `.debug_line` parse.
270///
271/// Ports `tests/dwarf_emit_roundtrip_step4.rs::emit_dwarf` (which emits the same
272/// full unit and round-trips through `Dwarf::units()`).
273pub fn emit_debug_sections(
274    table: &[(u64, u32, u32)],
275    text_sym: usize,
276    files: &[(String, String)],
277    subprograms: &[SubprogramInfo],
278) -> Vec<EmittedDwarfSection> {
279    use gimli::write::{Address, AttributeValue, DwarfUnit, LineProgram, LineString, Sections};
280
281    if table.is_empty() {
282        return Vec::new();
283    }
284
285    let encoding = gimli::Encoding {
286        format: gimli::Format::Dwarf32,
287        version: 4,
288        address_size: 4,
289    };
290    let mut dwarf = DwarfUnit::new(encoding);
291
292    // The span of emitted text the unit describes: low_pc=`.text`+0 (text base),
293    // high_pc one past the last mapped address.
294    let high_pc = table.iter().map(|&(a, _, _)| a).max().unwrap_or(0) + 1;
295
296    // Reproduce the input's source-file table so a debugger resolves each stop to
297    // the REAL file (e.g. `panicking.rs`), not the fabricated `synth.wasm`. The
298    // comp-dir/file passed to `LineProgram::new` become the unit's primary file;
299    // use the FIRST real interned file for it (never `synth.wasm`) so even the
300    // primary is a genuine name. Then `add_file` each interned file and map its
301    // global index → the returned `FileId`. When the input carried no resolvable
302    // file table (`files` empty) keep the historical single-file default so a
303    // DWARF-less-but-lined input still emits something rather than panicking.
304    //
305    // gimli 0.33's `LineProgram::new` args are
306    // `(working_dir, working_dir_info, source_file, source_file_info)`.
307    let (primary_dir, primary_name) = files
308        .first()
309        .map(|(d, n)| (d.clone().into_bytes(), n.clone().into_bytes()))
310        .unwrap_or_else(|| (b"/synth".to_vec(), b"synth.wasm".to_vec()));
311    let mut program = LineProgram::new(
312        encoding,
313        gimli::LineEncoding::default(),
314        LineString::String(primary_dir),
315        None,
316        LineString::String(primary_name.clone()),
317        None,
318    );
319    // Global file index → emitted `FileId`. `file_ids[0]` always exists (either
320    // the first real file or the single fallback), so an out-of-range row index
321    // clamps to it rather than panicking.
322    let mut file_ids = Vec::with_capacity(files.len().max(1));
323    if files.is_empty() {
324        let dir = program.default_directory();
325        file_ids.push(program.add_file(LineString::String(b"synth.wasm".to_vec()), dir, None));
326    } else {
327        for (dir, name) in files {
328            let dir_id = program.add_directory(LineString::String(dir.clone().into_bytes()));
329            file_ids.push(program.add_file(
330                LineString::String(name.clone().into_bytes()),
331                dir_id,
332                None,
333            ));
334        }
335    }
336
337    // The sequence base is `.text + 0` as a RELOCATABLE address (one
338    // `DW_LNE_set_address` against the `.text` symbol, addend 0); each row's
339    // `address_offset` stays a text-relative DELTA, so only this single site
340    // needs a relocation per section. Addend 0 ⇒ the in-place bytes are
341    // byte-identical to the previous `Address::Constant(0)` form.
342    let text_base = Address::Symbol {
343        symbol: text_sym,
344        addend: 0,
345    };
346    program.begin_sequence(Some(text_base));
347    for &(addr, line, file) in table {
348        let row = program.row();
349        row.address_offset = addr;
350        row.file = *file_ids.get(file as usize).unwrap_or(&file_ids[0]);
351        row.line = line as u64;
352        program.generate_row();
353    }
354    program.end_sequence(high_pc);
355    dwarf.unit.line_program = program;
356
357    // Populate the root DW_TAG_compile_unit DIE: a name, the text span, and (via
358    // gimli auto-wiring the attached line_program) DW_AT_stmt_list → .debug_line.
359    // Name the CU after the primary real source file (fallback `synth.wasm`).
360    {
361        let cu_name = files
362            .first()
363            .map(|(_, n)| n.clone())
364            .unwrap_or_else(|| "synth.wasm".to_string());
365        let name_id = dwarf.strings.add(cu_name);
366        let root = dwarf.unit.root();
367        let root_die = dwarf.unit.get_mut(root);
368        root_die.set(gimli::DW_AT_name, AttributeValue::StringRef(name_id));
369        root_die.set(gimli::DW_AT_low_pc, AttributeValue::Address(text_base));
370        root_die.set(gimli::DW_AT_high_pc, AttributeValue::Udata(high_pc));
371    }
372
373    // #394 Tier-1: attach one DW_TAG_subprogram child DIE per function so a
374    // debugger backtrace shows the FUNCTION NAME, not a bare address.
375    //   - DW_AT_name    = the export/function name.
376    //   - DW_AT_low_pc  = the function's `.text` address, relocated against the
377    //     SAME `__synth_text_base` symbol as the CU low_pc but with addend =
378    //     the function's object-relative offset, so it shifts correctly when a
379    //     linker places `.text` (each is an extra `.rel.debug_info` record).
380    //   - DW_AT_high_pc = the offset (size) form `high_pc - low_pc` — a plain
381    //     constant, so it needs no relocation (matches the CU's high_pc form).
382    // No parameters/locals/frame-base yet — that is Tier-2, gated on VCR-RA.
383    {
384        let root = dwarf.unit.root();
385        for sp in subprograms {
386            let name_id = dwarf.strings.add(sp.name.clone());
387            let die_id = dwarf.unit.add(root, gimli::DW_TAG_subprogram);
388            let die = dwarf.unit.get_mut(die_id);
389            die.set(gimli::DW_AT_name, AttributeValue::StringRef(name_id));
390            die.set(
391                gimli::DW_AT_low_pc,
392                AttributeValue::Address(Address::Symbol {
393                    symbol: text_sym,
394                    addend: sp.low_pc as i64,
395                }),
396            );
397            die.set(
398                gimli::DW_AT_high_pc,
399                AttributeValue::Udata(sp.high_pc.saturating_sub(sp.low_pc)),
400            );
401        }
402    }
403
404    let seed = RelocWriter {
405        inner: gimli::write::EndianVec::new(LittleEndian),
406        relocs: Vec::new(),
407    };
408    let mut sections = Sections::new(seed);
409    if dwarf.write(&mut sections).is_err() {
410        return Vec::new();
411    }
412
413    let mut out: Vec<EmittedDwarfSection> = Vec::new();
414    let _ = sections.for_each(|id, w: &RelocWriter| -> Result<(), ()> {
415        let bytes = w.inner.slice();
416        if !bytes.is_empty()
417            && let Some(name) = section_name(id)
418        {
419            let text_relocs = w
420                .relocs
421                .iter()
422                .map(|&(offset, _addend, size)| DwarfTextReloc {
423                    offset: offset as u32,
424                    size,
425                })
426                .collect();
427            out.push(EmittedDwarfSection {
428                name,
429                bytes: bytes.to_vec(),
430                text_relocs,
431            });
432        }
433        Ok(())
434    });
435    out
436}
437
438/// A relocation a `.debug_*` section needs against the `.text` symbol so a host
439/// linker fixes up the embedded `.text` address when `.text` is placed. REL
440/// form: the in-place bytes already hold the addend (always `0` for our
441/// text-base references), so only the site (`offset`) and `size` travel here.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub struct DwarfTextReloc {
444    /// Byte offset within the section where the relocated address word sits.
445    pub offset: u32,
446    /// Size of the relocated value (always 4 for DWARF32 addresses).
447    pub size: u8,
448}
449
450/// One emitted `.debug_*` section: its ELF name, bytes, and the `.text`-symbol
451/// relocations it needs (empty for address-free sections like `.debug_str`).
452#[derive(Debug, Clone)]
453pub struct EmittedDwarfSection {
454    /// `'static` ELF section name (e.g. `.debug_line`).
455    pub name: &'static str,
456    /// Section payload bytes.
457    pub bytes: Vec<u8>,
458    /// `.text`-symbol relocations within this section (REL, in-place addend 0).
459    pub text_relocs: Vec<DwarfTextReloc>,
460}
461
462/// A gimli `write::Writer` that delegates to an inner `EndianVec` but records
463/// every `Address::Symbol` write as a relocation. ONLY `write_address` is
464/// overridden — `write_offset` (gimli's internal section-to-section references,
465/// e.g. `.debug_info` → `.debug_str`/`.debug_abbrev` and `DW_AT_stmt_list` →
466/// `.debug_line`) keeps the default, so those stay CONCRETE intra-file offsets
467/// and need no section symbols. The relocations captured are the `.text`
468/// references: the line program's `DW_LNE_set_address`, the CU's `DW_AT_low_pc`,
469/// and one per `DW_TAG_subprogram` `DW_AT_low_pc` (#394). `Clone` so
470/// `Sections::new` can seed each section writer.
471#[derive(Clone)]
472struct RelocWriter {
473    inner: gimli::write::EndianVec<LittleEndian>,
474    /// (offset within section, addend, size) for each `Address::Symbol` write.
475    relocs: Vec<(usize, i64, u8)>,
476}
477
478impl gimli::write::Writer for RelocWriter {
479    type Endian = LittleEndian;
480
481    fn endian(&self) -> Self::Endian {
482        self.inner.endian()
483    }
484
485    fn len(&self) -> usize {
486        self.inner.len()
487    }
488
489    fn write(&mut self, bytes: &[u8]) -> gimli::write::Result<()> {
490        self.inner.write(bytes)
491    }
492
493    fn write_at(&mut self, offset: usize, bytes: &[u8]) -> gimli::write::Result<()> {
494        self.inner.write_at(offset, bytes)
495    }
496
497    fn write_address(
498        &mut self,
499        address: gimli::write::Address,
500        size: u8,
501    ) -> gimli::write::Result<()> {
502        use gimli::write::Address;
503        match address {
504            Address::Constant(val) => self.inner.write_udata(val, size),
505            Address::Symbol { symbol: _, addend } => {
506                // REL: record the site and write the addend in place (0 ⇒ the
507                // bytes match the old `Address::Constant(0)` exactly).
508                let offset = self.inner.len();
509                self.relocs.push((offset, addend, size));
510                self.inner.write_udata(addend as u64, size)
511            }
512        }
513    }
514}
515
516/// `'static` ELF section name for the `.debug_*` sections the emitter can
517/// produce. Returns `None` for any section id we do not wire (none are expected
518/// for this minimal unit, but the match keeps the names `'static`).
519fn section_name(id: SectionId) -> Option<&'static str> {
520    Some(match id {
521        SectionId::DebugInfo => ".debug_info",
522        SectionId::DebugAbbrev => ".debug_abbrev",
523        SectionId::DebugStr => ".debug_str",
524        SectionId::DebugLine => ".debug_line",
525        SectionId::DebugLineStr => ".debug_line_str",
526        SectionId::DebugRanges => ".debug_ranges",
527        SectionId::DebugRngLists => ".debug_rnglists",
528        SectionId::DebugStrOffsets => ".debug_str_offsets",
529        SectionId::DebugAddr => ".debug_addr",
530        _ => return None,
531    })
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn covering_row_lookup() {
540        // code_base 100; rows at code-rel 0→line10, 8→line11, 20→line12.
541        let rows = [
542            LineRow {
543                addr: 0,
544                line: 10,
545                file: 1,
546            },
547            LineRow {
548                addr: 8,
549                line: 11,
550                file: 1,
551            },
552            LineRow {
553                addr: 20,
554                line: 12,
555                file: 1,
556            },
557        ];
558        // ops at module 100 (→0), 104 (→4), 108 (→8), 130 (→30).
559        let got = op_offsets_to_source(&[100, 104, 108, 130], 100, &rows);
560        assert_eq!(got[0].map(|s| s.line), Some(10)); // addr 0  → row 0
561        assert_eq!(got[1].map(|s| s.line), Some(10)); // addr 4  → still row 0
562        assert_eq!(got[2].map(|s| s.line), Some(11)); // addr 8  → row 8
563        assert_eq!(got[3].map(|s| s.line), Some(12)); // addr 30 → row 20 (last ≤)
564    }
565
566    #[test]
567    fn op_before_first_row_is_none() {
568        let rows = [LineRow {
569            addr: 8,
570            line: 11,
571            file: 1,
572        }];
573        // op at module 100 → code-rel 0, before the first row (addr 8).
574        let got = op_offsets_to_source(&[100], 100, &rows);
575        assert_eq!(got[0], None);
576    }
577
578    #[test]
579    fn op_before_code_base_is_none() {
580        let rows = [LineRow {
581            addr: 0,
582            line: 1,
583            file: 1,
584        }];
585        let got = op_offsets_to_source(&[50], 100, &rows);
586        assert_eq!(got[0], None);
587    }
588}