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");
}
}