Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

crates/nornir-introspect/src/artifact.rs

//! DWARF symbol extraction from compiled artifacts.
//!
//! Reads `DW_TAG_subprogram` entries out of a binary's `.debug_info`
//! section (gimli) and returns one [`Symbol`] per defined function.
//! Both the raw mangled name and the rustc-demangled name (with and
//! without generic args) are kept, so an agent can lookup either form.
//!
//! Symbols whose `DW_AT_decl_file` resolves outside `workspace_root`
//! are filtered out — we only care about *our* code, not libstd or
//! crates.io deps.
//!
//! Performance: `DW_AT_decl_file` resolution (canonicalize + workspace
//! relativize) is done **once per compilation-unit file-table row** and
//! memoized in a process-shared interner ([`CanonCache`]) — the same std
//! / cargo paths recur across all ~10k units, so this collapses what used
//! to be one `canonicalize()` syscall *per subprogram* (millions of
//! failing `readlink`s) down to one per distinct path. Extraction then
//! fans out across units with [`map_units_parallel`], merging the
//! per-unit `Vec<Symbol>` back in unit order so the output is
//! byte-identical to the old single-threaded walk.

use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use anyhow::{anyhow, Context, Result};
use gimli::{AttributeValue, DebuggingInformationEntry, Dwarf, EndianSlice, RunTimeEndian, Unit};
use memmap2::Mmap;
use object::{Object, ObjectSection};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
    /// Rustc-demangled name with generics stripped, e.g. `foo::Bar::new`.
    pub name: String,
    /// Rustc-demangled name with full generics, e.g. `foo::Bar<u32>::new`.
    pub name_demangled: String,
    /// Raw symbol from DWARF (linkage name when present, else `DW_AT_name`).
    pub name_mangled: String,
    /// Absolute source path of `DW_AT_decl_file`, relative to workspace root if inside it.
    pub file: String,
    /// Source line of `DW_AT_decl_line`. `None` if absent.
    pub line: Option<u32>,
    /// `DW_AT_high_pc - DW_AT_low_pc` when both present.
    pub size_bytes: Option<u64>,
    /// Crate name as guessed from the source path's top workspace segment.
    pub krate: String,
}

/// Extract every workspace-local function symbol from `binary_path`.
///
/// `workspace_root` is the dir whose children are repos; any DWARF
/// entry whose source file lives outside this root is dropped. Pass
/// `binary_path` to a build artifact compiled with debug info (the
/// default for `cargo build`; `cargo build --release` keeps it too
/// unless you strip).
pub fn extract_symbols(binary_path: &Path, workspace_root: &Path) -> Result<Vec<Symbol>> {
    let file = File::open(binary_path)
        .with_context(|| format!("open binary {}", binary_path.display()))?;
    let mmap =
        unsafe { Mmap::map(&file) }.with_context(|| format!("mmap {}", binary_path.display()))?;
    let object = object::File::parse(&*mmap)
        .with_context(|| format!("parse object {}", binary_path.display()))?;

    let endian = if object.is_little_endian() {
        RunTimeEndian::Little
    } else {
        RunTimeEndian::Big
    };

    let load_section = |id: gimli::SectionId| -> Result<Cow<[u8]>> {
        Ok(match object.section_by_name(id.name()) {
            Some(s) => s.uncompressed_data().unwrap_or(Cow::Borrowed(&[])),
            None => Cow::Borrowed(&[]),
        })
    };
    let dwarf_sections = gimli::DwarfSections::load(load_section)?;
    let dwarf = dwarf_sections.borrow(|section| EndianSlice::new(section, endian));

    let abs_root = workspace_root
        .canonicalize()
        .unwrap_or_else(|_| workspace_root.to_path_buf());

    // Collect the unit headers up front so extraction can fan out across
    // them (the walk was ~99% of one core, single-threaded).
    let mut headers = Vec::new();
    {
        let mut iter = dwarf.units();
        while let Some(header) = iter.next()? {
            headers.push(header);
        }
    }

    // Process-shared path interner: canonicalize+relativize each distinct
    // file-table path exactly once, not once per subprogram.
    let cache: CanonCache = Mutex::new(HashMap::new());

    let per_unit = map_units_parallel(headers.len(), |i| -> Result<Vec<Symbol>> {
        let unit = dwarf.unit(headers[i].clone())?;
        let comp_dir = unit
            .comp_dir
            .as_ref()
            .map(|s| PathBuf::from(s.to_string_lossy().into_owned()));
        let file_table = build_file_table(&dwarf, &unit, comp_dir.as_deref(), &abs_root, &cache)?;

        let mut out = Vec::new();
        let mut entries = unit.entries();
        while let Some(entry) = entries.next_dfs()? {
            if entry.tag() != gimli::DW_TAG_subprogram {
                continue;
            }
            if let Some(sym) = extract_one(&dwarf, &unit, entry, &file_table)? {
                out.push(sym);
            }
        }
        Ok(out)
    })?;

    // Merge in unit order → identical ordering to the old serial walk.
    Ok(per_unit.into_iter().flatten().collect())
}

/// One resolved file-table row: the workspace-relative source path and its
/// top (crate) segment. `None` in a [`FileTable`] means the row was empty or
/// resolved outside the workspace (i.e. a symbol pointing at it is dropped).
#[derive(Clone)]
pub(crate) struct FileEntry {
    /// Source path relative to the workspace root, e.g. `holger/src/bar.rs`.
    pub file: String,
    /// Crate name = the top workspace segment, e.g. `holger`.
    pub krate: String,
}

type FileTable = Vec<Option<FileEntry>>;

/// Path interner shared across all units of one extraction pass.
/// Maps a raw (pre-canonicalize) file-table path to its resolved
/// [`FileEntry`] (or `None` when it lives outside the workspace). The same
/// std/cargo paths recur in every unit's line-program file table, so this
/// turns millions of `canonicalize()` syscalls into a handful.
pub(crate) type CanonCache = Mutex<HashMap<PathBuf, Option<FileEntry>>>;

/// Canonicalize `raw` and, if it lands under `abs_root`, return its
/// workspace-relative [`FileEntry`]; otherwise `None`. Memoized in `cache`.
///
/// Semantics match the old per-symbol code exactly:
/// `raw.canonicalize().unwrap_or(raw)` then `strip_prefix(abs_root)`.
pub(crate) fn relativize(cache: &CanonCache, raw: &Path, abs_root: &Path) -> Option<FileEntry> {
    if raw.as_os_str().is_empty() {
        return None;
    }
    if let Some(hit) = cache.lock().expect("canon cache poisoned").get(raw) {
        return hit.clone();
    }
    // Canonicalize OUTSIDE the lock (it is the expensive syscall).
    let canon = raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf());
    let entry = match canon.strip_prefix(abs_root) {
        Ok(rel) => {
            let krate = rel
                .components()
                .next()
                .map(|c| c.as_os_str().to_string_lossy().into_owned())
                .unwrap_or_default();
            let file = rel.to_string_lossy().into_owned();
            Some(FileEntry { file, krate })
        }
        Err(_) => None,
    };
    cache
        .lock()
        .expect("canon cache poisoned")
        .entry(raw.to_path_buf())
        .or_insert_with(|| entry.clone());
    entry
}

/// Build the per-unit file table, resolving each row once through the shared
/// interner. Row `idx` is `None` if empty or outside the workspace.
pub(crate) fn build_file_table(
    dwarf: &Dwarf<EndianSlice<RunTimeEndian>>,
    unit: &Unit<EndianSlice<RunTimeEndian>>,
    comp_dir: Option<&Path>,
    abs_root: &Path,
    cache: &CanonCache,
) -> Result<FileTable> {
    let mut out: FileTable = Vec::new();
    let Some(program) = unit.line_program.clone() else {
        return Ok(out);
    };
    let header = program.header();
    // DWARF v5 starts file indices at 0; v2-4 at 1. We just push everything;
    // callers index directly with the DW_AT_decl_file value.
    for (idx, file) in header.file_names().iter().enumerate() {
        let mut path = PathBuf::new();
        if let Some(dir_idx) = file.directory(header) {
            if let Ok(dir) = dwarf.attr_string(unit, dir_idx) {
                let dir_str = dir.to_string_lossy().into_owned();
                if Path::new(&dir_str).is_absolute() {
                    path.push(dir_str);
                } else {
                    if let Some(cd) = comp_dir {
                        path.push(cd);
                    }
                    path.push(dir_str);
                }
            }
        } else if let Some(cd) = comp_dir {
            path.push(cd);
        }
        if let Ok(name) = dwarf.attr_string(unit, file.path_name()) {
            path.push(name.to_string_lossy().into_owned());
        }
        // Pad if needed so DWARF-v2 1-based indexing works.
        while out.len() <= idx {
            out.push(None);
        }
        out[idx] = relativize(cache, &path, abs_root);
    }
    Ok(out)
}

fn extract_one(
    dwarf: &Dwarf<EndianSlice<RunTimeEndian>>,
    unit: &Unit<EndianSlice<RunTimeEndian>>,
    entry: &DebuggingInformationEntry<EndianSlice<RunTimeEndian>>,
    files: &[Option<FileEntry>],
) -> Result<Option<Symbol>> {
    // Resolve (and keep-filter on) the source file FIRST — the workspace
    // membership check is cheap, and doing it before demangling means the
    // millions of rejected out-of-tree DIEs never pay for `rustc_demangle`.
    let file_idx = match entry.attr_value(gimli::DW_AT_decl_file) {
        Some(AttributeValue::FileIndex(n)) => Some(n as usize),
        _ => None,
    };
    let Some(fe) = file_idx.and_then(|i| files.get(i)).and_then(|o| o.as_ref()) else {
        return Ok(None);
    };

    // Kept symbol: now do the expensive name work.
    // Prefer DW_AT_linkage_name (mangled) for the source-of-truth name.
    let linkage = attr_string(dwarf, unit, entry, gimli::DW_AT_linkage_name)?;
    let plain = attr_string(dwarf, unit, entry, gimli::DW_AT_name)?;
    let mangled = match (linkage, plain) {
        (Some(l), _) => l,
        (None, Some(n)) => n,
        (None, None) => return Ok(None),
    };

    let demangled = rustc_demangle::try_demangle(&mangled)
        .map(|d| format!("{:#}", d))
        .unwrap_or_else(|_| mangled.clone());
    let stripped = strip_generics(&demangled);

    let line = match entry.attr_value(gimli::DW_AT_decl_line) {
        Some(AttributeValue::Udata(n)) => Some(n as u32),
        _ => None,
    };

    let low = match entry.attr_value(gimli::DW_AT_low_pc) {
        Some(AttributeValue::Addr(a)) => Some(a),
        _ => None,
    };
    let size_bytes = match (low, entry.attr_value(gimli::DW_AT_high_pc)) {
        (Some(_), Some(AttributeValue::Udata(n))) => Some(n),
        (Some(a), Some(AttributeValue::Addr(b))) => Some(b.saturating_sub(a)),
        _ => None,
    };

    Ok(Some(Symbol {
        name: stripped,
        name_demangled: demangled,
        name_mangled: mangled,
        file: fe.file.clone(),
        line,
        size_bytes,
        krate: fe.krate.clone(),
    }))
}

/// Fan work across compilation units. Calls `f(i)` for every unit index
/// `0..n` (each returning that unit's items in DFS order) and returns the
/// per-unit results **in unit order**, so callers get byte-identical output
/// to a serial `for i in 0..n` walk.
///
/// ROOT-LAW #0: the fan-out rides znippy-zoomies' canonical fork-join pool
/// [`znippy_zoomies::gatling_forkjoin::gatling_map_balanced`] — the same
/// primitive the sibling `knowledge/symbols.rs`, `vector/store.rs` and
/// `warehouse/iceberg.rs` fan-outs use — not a hand-rolled `thread::scope`.
/// Items are the unit indices `0..n`; `batch = 1` with a uniform weight keeps
/// the dynamic one-at-a-time self-dispatch that balances wildly-varying unit
/// sizes across cores, and the pool returns results in **item (unit) order**,
/// so the merge is deterministic and byte-identical to the serial walk. The
/// first `f(i)` error is propagated.
pub(crate) fn map_units_parallel<T, F>(n: usize, f: F) -> Result<Vec<Vec<T>>>
where
    T: Send,
    F: Fn(usize) -> Result<Vec<T>> + Sync,
{
    if n == 0 {
        return Ok(Vec::new());
    }
    let indices: Vec<usize> = (0..n).collect();
    // `0` workers ⇒ pool's all-cores default. Uniform weight (`|_| 1`) + batch 1:
    // heaviest-first is a no-op, but per-item self-dispatch still keeps every core
    // busy regardless of unit size. Results come back in `indices` order.
    let per_unit: Vec<Result<Vec<T>>> =
        znippy_zoomies::gatling_forkjoin::gatling_map_balanced(&indices, 0, 1, |_| 1, |_, &i| f(i));
    per_unit.into_iter().collect()
}

fn attr_string(
    dwarf: &Dwarf<EndianSlice<RunTimeEndian>>,
    unit: &Unit<EndianSlice<RunTimeEndian>>,
    entry: &DebuggingInformationEntry<EndianSlice<RunTimeEndian>>,
    name: gimli::DwAt,
) -> Result<Option<String>> {
    let Some(v) = entry.attr_value(name) else {
        return Ok(None);
    };
    let s = dwarf
        .attr_string(unit, v)
        .map_err(|e| anyhow!("attr_string: {e}"))?;
    Ok(Some(s.to_string_lossy().into_owned()))
}

/// Strip `<…>` generic args at top level, e.g. `Vec<u32>::new` → `Vec::new`.
pub(crate) fn strip_generics(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut depth = 0i32;
    for ch in s.chars() {
        match ch {
            '<' => depth += 1,
            '>' => {
                if depth > 0 {
                    depth -= 1;
                }
            }
            _ if depth == 0 => out.push(ch),
            _ => {}
        }
    }
    out
}

/// Convenience: filter symbols whose [`Symbol::name`] contains `pattern`.
pub fn lookup<'a>(symbols: &'a [Symbol], pattern: &str) -> Vec<&'a Symbol> {
    symbols
        .iter()
        .filter(|s| {
            s.name.contains(pattern)
                || s.name_demangled.contains(pattern)
                || s.name_mangled.contains(pattern)
        })
        .collect()
}

/// Convenience: filter symbols whose source file ends with `suffix`.
pub fn defined_in<'a>(symbols: &'a [Symbol], suffix: &str) -> Vec<&'a Symbol> {
    symbols
        .iter()
        .filter(|s| s.file.ends_with(suffix))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Smoke test against our own debug build. Skips (loudly) when the binary is
    /// absent — see [`crate::prebuilt_nornir_bin`] — so the ALWAYS-runnable
    /// attribution guard is
    /// [`per_crate_attribution_selfexe`], not this.
    ///
    /// **The scan root is the CONSTELLATION root, not the repo root**, and that is
    /// the whole point of the test rather than a detail. `Symbol::krate` is defined
    /// as the FIRST path component of the source file relative to the scan root
    /// (`relativize`, above), so "which crate is this symbol from" is only answered
    /// correctly when the scan root is the directory whose children ARE the crates.
    /// Production always passes such a root — `loaded.workspace_root` from
    /// `nornir.toml`, the parent of every checkout (`src/bin/nornir.rs:16474`,
    /// `src/mcp_stdio.rs:3709`, `src/server_grpc.rs:2170`).
    ///
    /// This test used to pass `crate::workspace_root()`, i.e. the NORNIR REPO root,
    /// under which no path can ever start with `nornir/` — so `krate == "nornir"`
    /// was unsatisfiable and the assertion below could never hold. It never showed,
    /// because the test had been skipping since this crate became a workspace member
    /// (the pre-fix path guard was unsatisfiable too, see `prebuilt_nornir_bin`).
    /// The moment the resolver was fixed and the test actually RAN, it failed:
    /// `expected some symbols from crate nornir (got 33131 total)` — every symbol
    /// had been attributed to `krate = "target"`. Two unsatisfiable preconditions
    /// stacked, and the second was only visible once the first was gone.
    #[test]
    fn extract_own_binary() {
        let Some(bin) = crate::prebuilt_nornir_bin("extract_own_binary") else {
            return;
        };
        let root = crate::constellation_root();
        let syms = extract_symbols(&bin, &root).expect("extract");
        assert!(!syms.is_empty(), "expected symbols in {}", bin.display());
        let nornir_syms = syms.iter().filter(|s| s.krate == "nornir").count();
        // A SHARE, not a non-empty check: one stray symbol attributed correctly
        // while the other 33 000 land in a junk bucket is the failure this test
        // exists to catch, and `!is_empty()` cannot see it.
        assert!(
            nornir_syms * 2 > syms.len(),
            "expected MOST symbols of the nornir binary to attribute to crate `nornir` \
             relative to {}, got {nornir_syms} of {} (buckets: {:?})",
            root.display(),
            syms.len(),
            {
                let mut b: std::collections::BTreeMap<&str, usize> = Default::default();
                for s in &syms {
                    *b.entry(s.krate.as_str()).or_default() += 1;
                }
                b
            }
        );
        // Should find at least our index::Index::build
        let build_hits = lookup(&syms, "Index");
        assert!(!build_hits.is_empty(), "expected `Index` symbols");
    }

    /// **The per-crate ATTRIBUTION property, gated on every `cargo test`.**
    ///
    /// [`extract_own_binary`] above is the only place that ever asserted
    /// `Symbol::krate` — the column `symbol_lookup`/`defined_in` and the whole
    /// `krate` axis of the warehouse are built on — and it SKIPS whenever the
    /// `nornir` bin has not been built, which for `cargo test -p nornir-introspect`
    /// is always. `parallel_matches_serial_selfexe` compares the parallel and serial
    /// walks field-by-field, so it would catch attribution DRIFT between the two
    /// paths, but not attribution that is uniformly wrong in both — which is exactly
    /// what was wrong here (every symbol in one junk bucket, identically, on both
    /// walks). So the property had no unconditional guard at all.
    ///
    /// This one runs against the TEST EXECUTABLE — always present, always carrying
    /// DWARF under the `test` profile — with `<repo>/crates` as the scan root, whose
    /// children are exactly the member crate directories. Every symbol of this crate
    /// must therefore attribute to `nornir-introspect`.
    ///
    /// Not an identity value: the expected bucket is a specific crate NAME that the
    /// code must derive from a path, and the assertions below would fail on the empty
    /// string, on `"crates"` (the repo-root mistake), on `"src"` (one level too deep)
    /// and on `"target"` (the bug this replaced).
    #[test]
    fn per_crate_attribution_selfexe() {
        let bin = std::env::current_exe().expect("current test exe");
        let root = crate::workspace_root().join("crates");
        let syms = extract_symbols(&bin, &root).expect("extract");

        // Non-trivial set, so nothing below can pass vacuously.
        assert!(
            syms.len() > 100,
            "expected a substantial symbol set from the test exe, got {}",
            syms.len()
        );

        let mut buckets: std::collections::BTreeMap<&str, usize> = Default::default();
        for s in &syms {
            *buckets.entry(s.krate.as_str()).or_default() += 1;
        }
        let mine = buckets.get("nornir-introspect").copied().unwrap_or(0);
        assert!(
            mine * 2 > syms.len(),
            "most symbols of this crate's own test binary must attribute to crate \
             `nornir-introspect` relative to {}, got {mine} of {} (buckets: {buckets:?})",
            root.display(),
            syms.len()
        );

        // The file path must be crate-relative under the scan root — the same shape
        // the warehouse stores. A bare file name, or a path still starting with
        // `crates/` or `/home`, means the relativize step silently did nothing.
        for s in syms.iter().filter(|s| s.krate == "nornir-introspect") {
            assert!(
                s.file.starts_with("nornir-introspect/"),
                "file {:?} is not crate-relative under the scan root",
                s.file
            );
        }

        // And the other direction: symbols whose sources live OUTSIDE the scan root
        // (std, registry deps) must be DROPPED, not bucketed under a junk krate. An
        // absolute path or an empty crate name here means the filter is off.
        for (k, n) in &buckets {
            assert!(
                !k.is_empty() && !k.starts_with('/') && *k != "target",
                "junk attribution bucket {k:?} holds {n} symbols — a symbol outside \
                 {} must be dropped, never attributed",
                root.display()
            );
        }
    }

    /// The parallel/interned walk must produce byte-identical output to a
    /// forced single-threaded walk — same symbols, same order, same fields.
    /// This pins FIX 2 (per-unit path interning) + FIX 3 (parallel units)
    /// against a serial reference computed from the same DWARF.
    #[test]
    fn parallel_matches_serial() {
        let Some(bin) = crate::prebuilt_nornir_bin("artifact::parallel_matches_serial") else {
            return;
        };
        let root = crate::workspace_root();

        // Reference: force serial by capping parallelism to 1 unit at a time.
        let serial = extract_symbols_serial(&bin, &root).expect("serial extract");
        let parallel = extract_symbols(&bin, &root).expect("parallel extract");

        assert_eq!(serial.len(), parallel.len(), "symbol count differs");
        for (i, (a, b)) in serial.iter().zip(parallel.iter()).enumerate() {
            assert_eq!(a.name, b.name, "name mismatch at {i}");
            assert_eq!(
                a.name_demangled, b.name_demangled,
                "demangled mismatch at {i}"
            );
            assert_eq!(a.name_mangled, b.name_mangled, "mangled mismatch at {i}");
            assert_eq!(a.file, b.file, "file mismatch at {i}");
            assert_eq!(a.line, b.line, "line mismatch at {i}");
            assert_eq!(a.size_bytes, b.size_bytes, "size mismatch at {i}");
            assert_eq!(a.krate, b.krate, "krate mismatch at {i}");
        }
    }

    /// Self-contained red-when-broken guard for the gatling unit fan-out: unlike
    /// [`parallel_matches_serial`] (which needs a pre-built `nornir` and otherwise
    /// skips), this runs against the **test executable itself** — always present,
    /// always carries DWARF under the `test` profile — so it fires on every
    /// `cargo test`. Root `/` keeps every symbol across every compilation unit, so
    /// any ordering/merge regression in `map_units_parallel` (e.g. results coming
    /// back out of unit order) makes the field-by-field equality below go red.
    #[test]
    fn parallel_matches_serial_selfexe() {
        let bin = std::env::current_exe().expect("current test exe");
        let root = Path::new("/");
        let serial = extract_symbols_serial(&bin, root).expect("serial extract");
        let parallel = extract_symbols(&bin, root).expect("parallel extract");

        assert_eq!(serial.len(), parallel.len(), "symbol count differs");
        // Guard against the trivial all-empty pass: the test binary must yield a
        // non-trivial, multi-unit symbol set for the ordering check to bite.
        assert!(
            serial.len() > 100,
            "expected a substantial symbol set from the test exe, got {}",
            serial.len()
        );
        for (i, (a, b)) in serial.iter().zip(parallel.iter()).enumerate() {
            assert_eq!(a.name, b.name, "name mismatch at {i}");
            assert_eq!(
                a.name_demangled, b.name_demangled,
                "demangled mismatch at {i}"
            );
            assert_eq!(a.name_mangled, b.name_mangled, "mangled mismatch at {i}");
            assert_eq!(a.file, b.file, "file mismatch at {i}");
            assert_eq!(a.line, b.line, "line mismatch at {i}");
            assert_eq!(a.size_bytes, b.size_bytes, "size mismatch at {i}");
            assert_eq!(a.krate, b.krate, "krate mismatch at {i}");
        }
    }

    /// Serial reference extractor: identical logic to [`extract_symbols`]
    /// but walks units one at a time on the calling thread (no fan-out).
    fn extract_symbols_serial(binary_path: &Path, workspace_root: &Path) -> Result<Vec<Symbol>> {
        let file = File::open(binary_path)?;
        let mmap = unsafe { Mmap::map(&file) }?;
        let object = object::File::parse(&*mmap)?;
        let endian = if object.is_little_endian() {
            RunTimeEndian::Little
        } else {
            RunTimeEndian::Big
        };
        let load_section = |id: gimli::SectionId| -> Result<Cow<[u8]>> {
            Ok(match object.section_by_name(id.name()) {
                Some(s) => s.uncompressed_data().unwrap_or(Cow::Borrowed(&[])),
                None => Cow::Borrowed(&[]),
            })
        };
        let dwarf_sections = gimli::DwarfSections::load(load_section)?;
        let dwarf = dwarf_sections.borrow(|section| EndianSlice::new(section, endian));
        let abs_root = workspace_root
            .canonicalize()
            .unwrap_or_else(|_| workspace_root.to_path_buf());
        let cache: CanonCache = Mutex::new(HashMap::new());
        let mut out = Vec::new();
        let mut iter = dwarf.units();
        while let Some(header) = iter.next()? {
            let unit = dwarf.unit(header)?;
            let comp_dir = unit
                .comp_dir
                .as_ref()
                .map(|s| PathBuf::from(s.to_string_lossy().into_owned()));
            let file_table =
                build_file_table(&dwarf, &unit, comp_dir.as_deref(), &abs_root, &cache)?;
            let mut entries = unit.entries();
            while let Some(entry) = entries.next_dfs()? {
                if entry.tag() != gimli::DW_TAG_subprogram {
                    continue;
                }
                if let Some(sym) = extract_one(&dwarf, &unit, entry, &file_table)? {
                    out.push(sym);
                }
            }
        }
        Ok(out)
    }

    #[test]
    fn strip_generics_basic() {
        assert_eq!(strip_generics("Vec<u32>::new"), "Vec::new");
        assert_eq!(strip_generics("foo::Bar<X, Y<Z>>::baz"), "foo::Bar::baz");
        assert_eq!(strip_generics("plain"), "plain");
    }
}