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 function name shown in a debugger frame. The caller composes it
249    /// with priority `name`-section name > export name > synthetic `func_N`
250    /// (#394 Tier-1.x), so internal functions carry their real developer-facing
251    /// name (e.g. `core::panicking::panic_fmt::h...`) whenever the input wasm
252    /// has a `name` custom section.
253    pub name: String,
254    /// Object-relative `.text` byte offset of the function's first instruction.
255    pub low_pc: u64,
256    /// Object-relative `.text` byte offset one past the function's last byte.
257    pub high_pc: u64,
258}
259
260/// Emit an address-ordered `(arm_addr, line)` table as a FULL minimal DWARF unit
261/// (gimli::write) and return EVERY non-empty `.debug_*` section it produces —
262/// `.debug_info`, `.debug_abbrev`, `.debug_str`, `.debug_line` (and
263/// `.debug_line_str`/`.debug_ranges` etc. when non-empty). The caller composes
264/// the table (one address-sorted, de-duped sequence covering every function);
265/// this produces the section bytes for non-ALLOC ELF `PROGBITS` sections.
266/// Returns an empty `Vec` for an empty table (nothing to map ⇒ no sections ⇒
267/// output stays byte-identical).
268///
269/// Crucially this emits a real root `DW_TAG_compile_unit` DIE with `DW_AT_name`,
270/// `DW_AT_low_pc`/`DW_AT_high_pc` spanning the emitted text, and the line program
271/// attached — so the CU's `DW_AT_stmt_list` points at `.debug_line`. That makes
272/// the line table reachable via the NORMAL debugger walk (`.debug_info` → CU →
273/// `DW_AT_stmt_list` → line program), not just a standalone `.debug_line` parse.
274///
275/// Ports `tests/dwarf_emit_roundtrip_step4.rs::emit_dwarf` (which emits the same
276/// full unit and round-trips through `Dwarf::units()`).
277pub fn emit_debug_sections(
278    table: &[(u64, u32, u32)],
279    text_sym: usize,
280    files: &[(String, String)],
281    subprograms: &[SubprogramInfo],
282) -> Vec<EmittedDwarfSection> {
283    use gimli::write::{Address, AttributeValue, DwarfUnit, LineProgram, LineString, Sections};
284
285    if table.is_empty() {
286        return Vec::new();
287    }
288
289    let encoding = gimli::Encoding {
290        format: gimli::Format::Dwarf32,
291        version: 4,
292        address_size: 4,
293    };
294    let mut dwarf = DwarfUnit::new(encoding);
295
296    // The line program's extent: one past the last mapped address (the
297    // `end_sequence` terminator below).
298    let high_pc = table.iter().map(|&(a, _, _)| a).max().unwrap_or(0) + 1;
299
300    // #564: the CU DIE's `DW_AT_high_pc` must cover the CODE extent, not merely
301    // the line-table extent. The child `DW_TAG_subprogram` DIEs (#557) carry
302    // true code extents, and a function's code routinely extends past its last
303    // line-mapped address (the mapped op itself is ≥2 bytes, plus any unmapped
304    // epilogue/literal-pool tail) — a CU high_pc derived from the line table
305    // then fails to CONTAIN its own children (`llvm-dwarfdump --verify`
306    // post-link: "DIE address ranges are not contained in its parent's
307    // ranges"), and a debugger walking CUs by PC range misses the tail
308    // function. Cover max(subprogram high_pc), i.e. the `.text` extent the
309    // caller composed; fall back to the line-table extent when no subprograms
310    // were passed (nothing to contain).
311    let cu_high_pc = subprograms
312        .iter()
313        .map(|sp| sp.high_pc)
314        .fold(high_pc, u64::max);
315
316    // Reproduce the input's source-file table so a debugger resolves each stop to
317    // the REAL file (e.g. `panicking.rs`), not the fabricated `synth.wasm`. The
318    // comp-dir/file passed to `LineProgram::new` become the unit's primary file;
319    // use the FIRST real interned file for it (never `synth.wasm`) so even the
320    // primary is a genuine name. Then `add_file` each interned file and map its
321    // global index → the returned `FileId`. When the input carried no resolvable
322    // file table (`files` empty) keep the historical single-file default so a
323    // DWARF-less-but-lined input still emits something rather than panicking.
324    //
325    // gimli 0.33's `LineProgram::new` args are
326    // `(working_dir, working_dir_info, source_file, source_file_info)`.
327    let (primary_dir, primary_name) = files
328        .first()
329        .map(|(d, n)| (d.clone().into_bytes(), n.clone().into_bytes()))
330        .unwrap_or_else(|| (b"/synth".to_vec(), b"synth.wasm".to_vec()));
331    let mut program = LineProgram::new(
332        encoding,
333        gimli::LineEncoding::default(),
334        LineString::String(primary_dir),
335        None,
336        LineString::String(primary_name.clone()),
337        None,
338    );
339    // Global file index → emitted `FileId`. `file_ids[0]` always exists (either
340    // the first real file or the single fallback), so an out-of-range row index
341    // clamps to it rather than panicking.
342    let mut file_ids = Vec::with_capacity(files.len().max(1));
343    if files.is_empty() {
344        let dir = program.default_directory();
345        file_ids.push(program.add_file(LineString::String(b"synth.wasm".to_vec()), dir, None));
346    } else {
347        for (dir, name) in files {
348            let dir_id = program.add_directory(LineString::String(dir.clone().into_bytes()));
349            file_ids.push(program.add_file(
350                LineString::String(name.clone().into_bytes()),
351                dir_id,
352                None,
353            ));
354        }
355    }
356
357    // The sequence base is `.text + 0` as a RELOCATABLE address (one
358    // `DW_LNE_set_address` against the `.text` symbol, addend 0); each row's
359    // `address_offset` stays a text-relative DELTA, so only this single site
360    // needs a relocation per section. Addend 0 ⇒ the in-place bytes are
361    // byte-identical to the previous `Address::Constant(0)` form.
362    let text_base = Address::Symbol {
363        symbol: text_sym,
364        addend: 0,
365    };
366    program.begin_sequence(Some(text_base));
367    for &(addr, line, file) in table {
368        let row = program.row();
369        row.address_offset = addr;
370        row.file = *file_ids.get(file as usize).unwrap_or(&file_ids[0]);
371        row.line = line as u64;
372        program.generate_row();
373    }
374    program.end_sequence(high_pc);
375    dwarf.unit.line_program = program;
376
377    // Populate the root DW_TAG_compile_unit DIE: a name, the text span, and (via
378    // gimli auto-wiring the attached line_program) DW_AT_stmt_list → .debug_line.
379    // Name the CU after the primary real source file (fallback `synth.wasm`).
380    {
381        let cu_name = files
382            .first()
383            .map(|(_, n)| n.clone())
384            .unwrap_or_else(|| "synth.wasm".to_string());
385        let name_id = dwarf.strings.add(cu_name);
386        let root = dwarf.unit.root();
387        let root_die = dwarf.unit.get_mut(root);
388        root_die.set(gimli::DW_AT_name, AttributeValue::StringRef(name_id));
389        root_die.set(gimli::DW_AT_low_pc, AttributeValue::Address(text_base));
390        root_die.set(gimli::DW_AT_high_pc, AttributeValue::Udata(cu_high_pc));
391    }
392
393    // #394 Tier-1: attach one DW_TAG_subprogram child DIE per function so a
394    // debugger backtrace shows the FUNCTION NAME, not a bare address.
395    //   - DW_AT_name    = the export/function name.
396    //   - DW_AT_low_pc  = the function's `.text` address, relocated against the
397    //     SAME `__synth_text_base` symbol as the CU low_pc but with addend =
398    //     the function's object-relative offset, so it shifts correctly when a
399    //     linker places `.text` (each is an extra `.rel.debug_info` record).
400    //   - DW_AT_high_pc = the offset (size) form `high_pc - low_pc` — a plain
401    //     constant, so it needs no relocation (matches the CU's high_pc form).
402    // No parameters/locals/frame-base yet — that is Tier-2, gated on VCR-RA.
403    {
404        let root = dwarf.unit.root();
405        for sp in subprograms {
406            let name_id = dwarf.strings.add(sp.name.clone());
407            let die_id = dwarf.unit.add(root, gimli::DW_TAG_subprogram);
408            let die = dwarf.unit.get_mut(die_id);
409            die.set(gimli::DW_AT_name, AttributeValue::StringRef(name_id));
410            die.set(
411                gimli::DW_AT_low_pc,
412                AttributeValue::Address(Address::Symbol {
413                    symbol: text_sym,
414                    addend: sp.low_pc as i64,
415                }),
416            );
417            die.set(
418                gimli::DW_AT_high_pc,
419                AttributeValue::Udata(sp.high_pc.saturating_sub(sp.low_pc)),
420            );
421        }
422    }
423
424    let seed = RelocWriter {
425        inner: gimli::write::EndianVec::new(LittleEndian),
426        relocs: Vec::new(),
427    };
428    let mut sections = Sections::new(seed);
429    if dwarf.write(&mut sections).is_err() {
430        return Vec::new();
431    }
432
433    let mut out: Vec<EmittedDwarfSection> = Vec::new();
434    let _ = sections.for_each(|id, w: &RelocWriter| -> Result<(), ()> {
435        let bytes = w.inner.slice();
436        if !bytes.is_empty()
437            && let Some(name) = section_name(id)
438        {
439            let text_relocs = w
440                .relocs
441                .iter()
442                .map(|&(offset, _addend, size)| DwarfTextReloc {
443                    offset: offset as u32,
444                    size,
445                })
446                .collect();
447            out.push(EmittedDwarfSection {
448                name,
449                bytes: bytes.to_vec(),
450                text_relocs,
451            });
452        }
453        Ok(())
454    });
455    out
456}
457
458/// A relocation a `.debug_*` section needs against the `.text` symbol so a host
459/// linker fixes up the embedded `.text` address when `.text` is placed. REL
460/// form: the in-place bytes already hold the addend (always `0` for our
461/// text-base references), so only the site (`offset`) and `size` travel here.
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub struct DwarfTextReloc {
464    /// Byte offset within the section where the relocated address word sits.
465    pub offset: u32,
466    /// Size of the relocated value (always 4 for DWARF32 addresses).
467    pub size: u8,
468}
469
470/// One emitted `.debug_*` section: its ELF name, bytes, and the `.text`-symbol
471/// relocations it needs (empty for address-free sections like `.debug_str`).
472#[derive(Debug, Clone)]
473pub struct EmittedDwarfSection {
474    /// `'static` ELF section name (e.g. `.debug_line`).
475    pub name: &'static str,
476    /// Section payload bytes.
477    pub bytes: Vec<u8>,
478    /// `.text`-symbol relocations within this section (REL, in-place addend 0).
479    pub text_relocs: Vec<DwarfTextReloc>,
480}
481
482/// A gimli `write::Writer` that delegates to an inner `EndianVec` but records
483/// every `Address::Symbol` write as a relocation. ONLY `write_address` is
484/// overridden — `write_offset` (gimli's internal section-to-section references,
485/// e.g. `.debug_info` → `.debug_str`/`.debug_abbrev` and `DW_AT_stmt_list` →
486/// `.debug_line`) keeps the default, so those stay CONCRETE intra-file offsets
487/// and need no section symbols. The relocations captured are the `.text`
488/// references: the line program's `DW_LNE_set_address`, the CU's `DW_AT_low_pc`,
489/// and one per `DW_TAG_subprogram` `DW_AT_low_pc` (#394). `Clone` so
490/// `Sections::new` can seed each section writer.
491#[derive(Clone)]
492struct RelocWriter {
493    inner: gimli::write::EndianVec<LittleEndian>,
494    /// (offset within section, addend, size) for each `Address::Symbol` write.
495    relocs: Vec<(usize, i64, u8)>,
496}
497
498impl gimli::write::Writer for RelocWriter {
499    type Endian = LittleEndian;
500
501    fn endian(&self) -> Self::Endian {
502        self.inner.endian()
503    }
504
505    fn len(&self) -> usize {
506        self.inner.len()
507    }
508
509    fn write(&mut self, bytes: &[u8]) -> gimli::write::Result<()> {
510        self.inner.write(bytes)
511    }
512
513    fn write_at(&mut self, offset: usize, bytes: &[u8]) -> gimli::write::Result<()> {
514        self.inner.write_at(offset, bytes)
515    }
516
517    fn write_address(
518        &mut self,
519        address: gimli::write::Address,
520        size: u8,
521    ) -> gimli::write::Result<()> {
522        use gimli::write::Address;
523        match address {
524            Address::Constant(val) => self.inner.write_udata(val, size),
525            Address::Symbol { symbol: _, addend } => {
526                // REL: record the site and write the addend in place (0 ⇒ the
527                // bytes match the old `Address::Constant(0)` exactly).
528                let offset = self.inner.len();
529                self.relocs.push((offset, addend, size));
530                self.inner.write_udata(addend as u64, size)
531            }
532        }
533    }
534}
535
536/// `'static` ELF section name for the `.debug_*` sections the emitter can
537/// produce. Returns `None` for any section id we do not wire (none are expected
538/// for this minimal unit, but the match keeps the names `'static`).
539fn section_name(id: SectionId) -> Option<&'static str> {
540    Some(match id {
541        SectionId::DebugInfo => ".debug_info",
542        SectionId::DebugAbbrev => ".debug_abbrev",
543        SectionId::DebugStr => ".debug_str",
544        SectionId::DebugLine => ".debug_line",
545        SectionId::DebugLineStr => ".debug_line_str",
546        SectionId::DebugRanges => ".debug_ranges",
547        SectionId::DebugRngLists => ".debug_rnglists",
548        SectionId::DebugStrOffsets => ".debug_str_offsets",
549        SectionId::DebugAddr => ".debug_addr",
550        _ => return None,
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn covering_row_lookup() {
560        // code_base 100; rows at code-rel 0→line10, 8→line11, 20→line12.
561        let rows = [
562            LineRow {
563                addr: 0,
564                line: 10,
565                file: 1,
566            },
567            LineRow {
568                addr: 8,
569                line: 11,
570                file: 1,
571            },
572            LineRow {
573                addr: 20,
574                line: 12,
575                file: 1,
576            },
577        ];
578        // ops at module 100 (→0), 104 (→4), 108 (→8), 130 (→30).
579        let got = op_offsets_to_source(&[100, 104, 108, 130], 100, &rows);
580        assert_eq!(got[0].map(|s| s.line), Some(10)); // addr 0  → row 0
581        assert_eq!(got[1].map(|s| s.line), Some(10)); // addr 4  → still row 0
582        assert_eq!(got[2].map(|s| s.line), Some(11)); // addr 8  → row 8
583        assert_eq!(got[3].map(|s| s.line), Some(12)); // addr 30 → row 20 (last ≤)
584    }
585
586    #[test]
587    fn op_before_first_row_is_none() {
588        let rows = [LineRow {
589            addr: 8,
590            line: 11,
591            file: 1,
592        }];
593        // op at module 100 → code-rel 0, before the first row (addr 8).
594        let got = op_offsets_to_source(&[100], 100, &rows);
595        assert_eq!(got[0], None);
596    }
597
598    #[test]
599    fn op_before_code_base_is_none() {
600        let rows = [LineRow {
601            addr: 0,
602            line: 1,
603            file: 1,
604        }];
605        let got = op_offsets_to_source(&[50], 100, &rows);
606        assert_eq!(got[0], None);
607    }
608}