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