crates/nornir-introspect/src/size.rs
//! Per-section + per-symbol + per-crate **size** aggregation over a compiled
//! artifact — the pure-Rust counterpart to Bloaty / cargo-bloat / twiggy, shipped
//! as a warehouse producer (never a shell-out).
//!
//! `object` reads the section table (Bloaty's section view) and the symbol table
//! (cargo-bloat's per-function view); the DWARF symbols already extracted by
//! [`crate::artifact::extract_symbols`] are joined **by mangled name** so every
//! byte that carries debug info lands on the crate + source file/line that
//! produced it. Where a symbol has no DWARF row (a release build, a C dep) the
//! crate is best-effort recovered from the demangled path root, so
//! `bloat --by crate` is useful even without workspace debug info.
//!
//! ROOT-LAW #0: the per-symbol demangle + attribution fan-out rides
//! znippy-zoomies' gatling fork-join pool (index-ordered results), the same
//! primitive [`crate::artifact::map_units_parallel`] uses — never rayon.
use std::collections::HashMap;
use std::fs::File;
use std::path::Path;
use anyhow::{Context, Result};
use memmap2::Mmap;
use object::{Object, ObjectSection, ObjectSymbol, SectionIndex, SymbolSection};
use serde::{Deserialize, Serialize};
use crate::artifact::{self, strip_generics, Symbol};
// ELF `sh_flags` bits (stable ABI constants) — decoded when the section flags
// come back as `SectionFlags::Elf`.
const SHF_WRITE: u64 = 0x1;
const SHF_ALLOC: u64 = 0x2;
const SHF_EXECINSTR: u64 = 0x4;
/// One section's size + placement — the Bloaty section view. `.text`/`.rodata`/
/// `.data`/`.debug_*` split tells debug-info-vs-code at a glance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SectionSize {
/// Section name, e.g. `.text`, `.rodata`, `.debug_info`.
pub name: String,
/// Section size in bytes (`sh_size` / equivalent).
pub size_bytes: u64,
/// Virtual address the section loads at (0 for non-alloc sections).
pub vaddr: u64,
/// Byte offset of the section's data in the file (0 when absent).
pub file_offset: u64,
/// `SHF_ALLOC` — occupies memory at run time.
pub alloc: bool,
/// `SHF_EXECINSTR` — executable code.
pub exec: bool,
/// `SHF_WRITE` — writable at run time.
pub write: bool,
/// Coarse section kind label (`Text`/`Data`/`ReadOnlyData`/`Debug`/…).
pub kind: String,
}
/// One symbol's size + attribution — the cargo-bloat / twiggy per-symbol view.
/// Reuses [`crate::artifact::Symbol`]'s crate + source file/line when the symbol
/// carries DWARF; `is_monomorphization` flags a generic instantiation (twiggy
/// `monos`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SymbolSize {
/// Raw (mangled) symbol name from the symbol table.
pub name_mangled: String,
/// Rustc-demangled name with generics, e.g. `foo::Bar<u32>::new`.
pub name_demangled: String,
/// Demangled name with generics stripped, e.g. `foo::Bar::new`.
pub name_demangled_no_generics: String,
/// Owning crate (DWARF `krate`, else the demangled path root, else empty).
pub crate_name: String,
/// Section the symbol lives in (e.g. `.text`), or empty if unresolved.
pub section: String,
/// Symbol size in bytes.
pub size_bytes: u64,
/// Symbol kind label (`Text`/`Data`/`Unknown`).
pub kind: String,
/// `global` / `weak` / `local`.
pub binding: String,
/// A generic monomorphization (its demangled name carried `<…>`).
pub is_monomorphization: bool,
/// DWARF source file (workspace-relative), if the symbol carried debug info.
pub source_file: Option<String>,
/// DWARF source line, if present.
pub source_line: Option<u32>,
}
/// Per-crate size roll-up (cargo-bloat `--crates`): total bytes + symbol count.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrateSize {
/// Crate name (empty = unattributed symbols, lumped together).
pub crate_name: String,
/// Sum of member symbol sizes.
pub size_bytes: u64,
/// Number of symbols attributed to this crate.
pub symbol_count: u64,
}
/// The full size breakdown of one artifact.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Sizes {
/// Total artifact size on disk, bytes.
pub total_bytes: u64,
/// Per-section sizes (Bloaty view).
pub sections: Vec<SectionSize>,
/// Per-symbol sizes (cargo-bloat / twiggy view).
pub symbols: Vec<SymbolSize>,
/// Per-crate roll-up (descending by size).
pub crates: Vec<CrateSize>,
}
/// Analyse the section + symbol + crate sizes of `path`. When `source_root` is
/// `Some`, DWARF symbols are extracted (scoped to that root) and joined by
/// mangled name so every symbol with debug info is attributed to its crate +
/// source file/line. Gatling-parallel over the symbol table.
pub fn analyze_sizes(path: &Path, source_root: Option<&Path>) -> Result<Sizes> {
let file = File::open(path).with_context(|| format!("open artifact {}", path.display()))?;
let mmap = unsafe { Mmap::map(&file) }.with_context(|| format!("mmap {}", path.display()))?;
let total_bytes = mmap.len() as u64;
let obj =
object::File::parse(&*mmap).with_context(|| format!("parse object {}", path.display()))?;
// ── Sections (Bloaty view) ──
let mut sections = Vec::new();
for sec in obj.sections() {
let name = sec.name().unwrap_or("<unnamed>").to_string();
let (alloc, exec, write) = section_flags(sec.flags(), sec.kind());
sections.push(SectionSize {
name,
size_bytes: sec.size(),
vaddr: sec.address(),
file_offset: sec.file_range().map(|(o, _)| o).unwrap_or(0),
alloc,
exec,
write,
kind: format!("{:?}", sec.kind()),
});
}
// ── DWARF crate/source attribution map (mangled name → Symbol) ──
let dwarf: HashMap<String, Symbol> = match source_root {
Some(root) => artifact::extract_symbols(path, root)
.unwrap_or_default()
.into_iter()
.map(|s| (s.name_mangled.clone(), s))
.collect(),
None => HashMap::new(),
};
// ── Symbols: collect the raw (cheap-borrow) rows, then fan the expensive
// demangle + attribution across cores (ROOT-LAW #0 gatling). ──
let mut raw: Vec<RawSym> = Vec::new();
for sym in obj.symbols() {
if sym.is_undefined() {
continue;
}
let Ok(name) = sym.name() else { continue };
if name.is_empty() {
continue;
}
// Only sized code/data symbols contribute to bloat.
let size = sym.size();
if size == 0 {
continue;
}
let section = match sym.section() {
SymbolSection::Section(idx) => section_name(&obj, idx),
_ => String::new(),
};
let binding = if sym.is_weak() {
"weak"
} else if sym.is_global() {
"global"
} else {
"local"
}
.to_string();
raw.push(RawSym {
name_mangled: name.to_string(),
size_bytes: size,
kind: format!("{:?}", sym.kind()),
section,
binding,
});
}
let symbols = map_syms_parallel(&raw, &dwarf);
// ── Per-crate roll-up ──
let mut by_crate: HashMap<String, (u64, u64)> = HashMap::new();
for s in &symbols {
let e = by_crate.entry(s.crate_name.clone()).or_insert((0, 0));
e.0 += s.size_bytes;
e.1 += 1;
}
let mut crates: Vec<CrateSize> = by_crate
.into_iter()
.map(|(crate_name, (size_bytes, symbol_count))| CrateSize {
crate_name,
size_bytes,
symbol_count,
})
.collect();
crates.sort_by(|a, b| {
b.size_bytes
.cmp(&a.size_bytes)
.then(a.crate_name.cmp(&b.crate_name))
});
Ok(Sizes {
total_bytes,
sections,
symbols,
crates,
})
}
/// A raw symbol row, cheap to collect while the `object::File` borrow is live;
/// the expensive demangle + DWARF join is deferred to the gatling fan-out.
struct RawSym {
name_mangled: String,
size_bytes: u64,
kind: String,
section: String,
binding: String,
}
/// Fan the demangle + crate/source attribution across cores, results in input
/// order (deterministic). ROOT-LAW #0: znippy-zoomies gatling, never rayon.
fn map_syms_parallel(raw: &[RawSym], dwarf: &HashMap<String, Symbol>) -> Vec<SymbolSize> {
if raw.is_empty() {
return Vec::new();
}
let indices: Vec<usize> = (0..raw.len()).collect();
znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
&indices,
0,
1,
|_| 1,
|_, &i| build_symbol_size(&raw[i], dwarf),
)
}
/// Demangle one symbol and attribute it to a crate + source location.
fn build_symbol_size(r: &RawSym, dwarf: &HashMap<String, Symbol>) -> SymbolSize {
let demangled = rustc_demangle::try_demangle(&r.name_mangled)
.map(|d| format!("{:#}", d))
.unwrap_or_else(|_| r.name_mangled.clone());
let no_generics = strip_generics(&demangled);
let is_monomorphization = demangled != no_generics;
let (crate_name, source_file, source_line) = match dwarf.get(&r.name_mangled) {
Some(s) => (s.krate.clone(), Some(s.file.clone()), s.line),
None => {
// Best-effort: a Rust path's leading segment is its crate.
let c = if no_generics.contains("::") {
no_generics.split("::").next().unwrap_or("").to_string()
} else {
String::new()
};
(c, None, None)
}
};
SymbolSize {
name_mangled: r.name_mangled.clone(),
name_demangled: demangled,
name_demangled_no_generics: no_generics,
crate_name,
section: r.section.clone(),
size_bytes: r.size_bytes,
kind: r.kind.clone(),
binding: r.binding.clone(),
is_monomorphization,
source_file,
source_line,
}
}
/// `(alloc, exec, write)` for a section — from ELF `sh_flags` when present, else
/// inferred from the coarse `SectionKind`.
fn section_flags(flags: object::SectionFlags, kind: object::SectionKind) -> (bool, bool, bool) {
match flags {
// object 0.40 (2026-10-02): `sh_flags` is the `elf::SectionFlags` newtype.
object::SectionFlags::Elf { sh_flags, .. } => (
sh_flags.0 & SHF_ALLOC != 0,
sh_flags.0 & SHF_EXECINSTR != 0,
sh_flags.0 & SHF_WRITE != 0,
),
_ => {
use object::SectionKind::*;
match kind {
Text => (true, true, false),
Data | UninitializedData | Tls => (true, false, true),
ReadOnlyData | ReadOnlyString => (true, false, false),
_ => (false, false, false),
}
}
}
}
fn section_name(obj: &object::File, idx: SectionIndex) -> String {
obj.section_by_index(idx)
.ok()
.and_then(|s| s.name().ok().map(str::to_string))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
/// Size analysis of the test executable itself: it always carries a symbol
/// table + DWARF under the `test` profile, so sections, symbols and at least
/// one DWARF-attributed source location must all be non-empty. Red the moment
/// the section/symbol walk or the DWARF join regresses.
#[test]
fn analyze_sizes_selfexe() {
let bin = std::env::current_exe().expect("current test exe");
let sizes = analyze_sizes(&bin, Some(Path::new("/"))).expect("analyze");
assert!(sizes.total_bytes > 0, "total bytes");
assert!(
sizes
.sections
.iter()
.any(|s| s.name == ".text" && s.size_bytes > 0),
"expected a non-empty .text section"
);
assert!(
sizes.symbols.len() > 50,
"expected a substantial symbol set"
);
assert!(
sizes.symbols.iter().any(|s| s.source_file.is_some()),
"expected ≥1 symbol attributed to a DWARF source file"
);
assert!(!sizes.crates.is_empty(), "expected a per-crate roll-up");
}
}