Merge symbols CLI probe

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-21 11:08:12 +04:00
commit f35326b5e8
6 changed files with 443 additions and 37 deletions

View file

@ -20,7 +20,8 @@ report. The JSON contains:
- `line_counts`: total code lines, physical lines, and per-file counts. - `line_counts`: total code lines, physical lines, and per-file counts.
- `symbols`: total and per-file counts for functions, structs, enums, traits, - `symbols`: total and per-file counts for functions, structs, enums, traits,
and impl blocks. `trait_impls`, consts, statics, and parse-error state. `trait_impls` means
`impl Trait for Type` blocks only, not inherent `impl Type` blocks.
- `dependencies`: module list, module interdependency matrix, edge count, and - `dependencies`: module list, module interdependency matrix, edge count, and
fan-in/fan-out degrees. fan-in/fan-out degrees.
- `dead_code`: functions not statically reachable from main, tests, or - `dead_code`: functions not statically reachable from main, tests, or
@ -40,7 +41,8 @@ Each accepts `--json` for structured output.
- `cstat loc --json --path .` — project size-shape data. - `cstat loc --json --path .` — project size-shape data.
- `cstat loc --json --path src/lib.rs` — selected-file projected static line - `cstat loc --json --path src/lib.rs` — selected-file projected static line
reachability. reachability.
- `cstat symbols --json --path .` — symbol totals by kind and per file. - `cstat symbols --json --path .` — symbol totals by kind and per file; pass a
Rust source file to `--path` for selected-file rows and line spans.
- `cstat deps --json --path .` — dependency edges, coupling, fan-in/fan-out, - `cstat deps --json --path .` — dependency edges, coupling, fan-in/fan-out,
and cohesion. and cohesion.
- `cstat dead-code --json --path .` — static cold-function candidates. - `cstat dead-code --json --path .` — static cold-function candidates.
@ -89,5 +91,10 @@ Use these only when the focused report points to a question they answer:
- For exact `loc` `code_lines` rules and JSON fields, run - For exact `loc` `code_lines` rules and JSON fields, run
`cstat loc --explain` or `cstat loc --explain --json`; that command is the `cstat loc --explain` or `cstat loc --explain --json`; that command is the
canonical contract. canonical contract.
- Symbol counts come from `syn` Rust AST parsing. They are not semantic name
resolution, rustc integration, macro expansion, or proof of public API usage.
Parse errors are reported instead of ignored; selected-file mode is used when
`--path` points at a Rust source file under a crate's source, test, or bench
root.
- Prefer targeted reductions: remove dead code, split large files, move symbols - Prefer targeted reductions: remove dead code, split large files, move symbols
across modules, then reduce per-function complexity. across modules, then reduce per-function complexity.

View file

@ -33,6 +33,8 @@ pub struct StructInfo {
pub field_count: usize, pub field_count: usize,
pub derive_count: usize, pub derive_count: usize,
pub generic_param_count: usize, pub generic_param_count: usize,
pub line_start: usize,
pub line_end: usize,
} }
/// Extracted information about an enum. /// Extracted information about an enum.
@ -44,6 +46,8 @@ pub struct EnumInfo {
pub variant_count: usize, pub variant_count: usize,
pub derive_count: usize, pub derive_count: usize,
pub generic_param_count: usize, pub generic_param_count: usize,
pub line_start: usize,
pub line_end: usize,
} }
/// Extracted information about a trait. /// Extracted information about a trait.
@ -54,6 +58,8 @@ pub struct TraitInfo {
pub file: PathBuf, pub file: PathBuf,
pub method_count: usize, pub method_count: usize,
pub generic_param_count: usize, pub generic_param_count: usize,
pub line_start: usize,
pub line_end: usize,
} }
/// Extracted information about a const item. /// Extracted information about a const item.
@ -62,6 +68,8 @@ pub struct TraitInfo {
pub struct ConstInfo { pub struct ConstInfo {
pub name: String, pub name: String,
pub file: PathBuf, pub file: PathBuf,
pub line_start: usize,
pub line_end: usize,
} }
/// Extracted information about a static item. /// Extracted information about a static item.
@ -70,6 +78,8 @@ pub struct ConstInfo {
pub struct StaticInfo { pub struct StaticInfo {
pub name: String, pub name: String,
pub file: PathBuf, pub file: PathBuf,
pub line_start: usize,
pub line_end: usize,
} }
/// Extracted information about an impl block. /// Extracted information about an impl block.
@ -78,6 +88,8 @@ pub struct StaticInfo {
pub struct ImplInfo { pub struct ImplInfo {
pub target_type: String, pub target_type: String,
pub file: PathBuf, pub file: PathBuf,
pub line_start: usize,
pub line_end: usize,
pub method_count: usize, pub method_count: usize,
pub trait_name: Option<String>, pub trait_name: Option<String>,
} }
@ -218,6 +230,15 @@ impl SymbolExtractor {
syn::Fields::Unnamed(f) => f.unnamed.len(), syn::Fields::Unnamed(f) => f.unnamed.len(),
syn::Fields::Unit => 0, syn::Fields::Unit => 0,
}; };
let line_start = s.struct_token.span.start().line;
let line_end = match &s.fields {
syn::Fields::Named(fields) => fields.brace_token.span.close().end().line,
syn::Fields::Unnamed(fields) => fields.paren_token.span.close().end().line,
syn::Fields::Unit => s
.semi_token
.as_ref()
.map_or(line_start, |semi| semi.span.end().line),
};
let derive_count = count_derives(&s.attrs); let derive_count = count_derives(&s.attrs);
let generic_param_count = s.generics.params.len(); let generic_param_count = s.generics.params.len();
self.structs.push(StructInfo { self.structs.push(StructInfo {
@ -226,6 +247,8 @@ impl SymbolExtractor {
field_count, field_count,
derive_count, derive_count,
generic_param_count, generic_param_count,
line_start,
line_end,
}); });
} }
Item::Enum(e) => { Item::Enum(e) => {
@ -237,6 +260,8 @@ impl SymbolExtractor {
variant_count: e.variants.len(), variant_count: e.variants.len(),
derive_count, derive_count,
generic_param_count, generic_param_count,
line_start: e.enum_token.span.start().line,
line_end: e.brace_token.span.close().end().line,
}); });
} }
Item::Trait(t) => { Item::Trait(t) => {
@ -251,18 +276,24 @@ impl SymbolExtractor {
file: self.file_path.clone(), file: self.file_path.clone(),
method_count, method_count,
generic_param_count, generic_param_count,
line_start: t.trait_token.span.start().line,
line_end: t.brace_token.span.close().end().line,
}); });
} }
Item::Const(c) => { Item::Const(c) => {
self.consts.push(ConstInfo { self.consts.push(ConstInfo {
name: self.qualify_name(&c.ident.to_string()), name: self.qualify_name(&c.ident.to_string()),
file: self.file_path.clone(), file: self.file_path.clone(),
line_start: c.const_token.span.start().line,
line_end: c.semi_token.span.end().line,
}); });
} }
Item::Static(s) => { Item::Static(s) => {
self.statics.push(StaticInfo { self.statics.push(StaticInfo {
name: self.qualify_name(&s.ident.to_string()), name: self.qualify_name(&s.ident.to_string()),
file: self.file_path.clone(), file: self.file_path.clone(),
line_start: s.static_token.span.start().line,
line_end: s.semi_token.span.end().line,
}); });
} }
Item::Impl(imp) => { Item::Impl(imp) => {
@ -274,6 +305,8 @@ impl SymbolExtractor {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("::") .join("::")
}); });
let line_start = imp.impl_token.span.start().line;
let line_end = imp.brace_token.span.close().end().line;
let mut method_count = 0; let mut method_count = 0;
for impl_item in &imp.items { for impl_item in &imp.items {
if let ImplItem::Fn(method) = impl_item { if let ImplItem::Fn(method) = impl_item {
@ -294,6 +327,8 @@ impl SymbolExtractor {
self.impls.push(ImplInfo { self.impls.push(ImplInfo {
target_type, target_type,
file: self.file_path.clone(), file: self.file_path.clone(),
line_start,
line_end,
method_count, method_count,
trait_name, trait_name,
}); });

View file

@ -280,14 +280,14 @@ fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool)
if verbose { if verbose {
let full_section = format!("Full section: cstat symbols --path {file} -v"); let full_section = format!("Full section: cstat symbols --path {file} -v");
render::verbose_block(&[ render::verbose_block(&[
"Counts are Rust AST items found in the selected file.", "Selected-file symbols use syn Rust AST item discovery.",
"Functions includes free functions and impl methods.", "Functions include free functions and impl methods once.",
full_section.as_str(), full_section.as_str(),
]); ]);
} }
println!( println!(
" {} {} {} {} {} {} {} {} {} {} {} {} {} {}", " {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
"total".cyan(), "total".cyan(),
report.total.to_string().bold(), report.total.to_string().bold(),
"fn".cyan(), "fn".cyan(),
@ -298,12 +298,23 @@ fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool)
report.enums.to_string().bold(), report.enums.to_string().bold(),
"trait".cyan(), "trait".cyan(),
report.traits.to_string().bold(), report.traits.to_string().bold(),
"trait_impl".cyan(),
report.trait_impls.to_string().bold(),
"const".cyan(), "const".cyan(),
report.consts.to_string().bold(), report.consts.to_string().bold(),
"static".cyan(), "static".cyan(),
report.statics.to_string().bold(), report.statics.to_string().bold(),
); );
if report.parse_error {
println!(
" {}",
"parse error: selected file could not be parsed"
.yellow()
.bold()
);
}
if !report.symbols.is_empty() { if !report.symbols.is_empty() {
println!(); println!();
println!(" {:<10} {}", "kind".bold(), "symbol".bold()); println!(" {:<10} {}", "kind".bold(), "symbol".bold());
@ -465,6 +476,7 @@ fn file_symbol_kind_label(kind: &symbols::FileSymbolKind) -> &'static str {
symbols::FileSymbolKind::Struct => "struct", symbols::FileSymbolKind::Struct => "struct",
symbols::FileSymbolKind::Enum => "enum", symbols::FileSymbolKind::Enum => "enum",
symbols::FileSymbolKind::Trait => "trait", symbols::FileSymbolKind::Trait => "trait",
symbols::FileSymbolKind::TraitImpl => "trait_impl",
symbols::FileSymbolKind::Const => "const", symbols::FileSymbolKind::Const => "const",
symbols::FileSymbolKind::Static => "static", symbols::FileSymbolKind::Static => "static",
} }

View file

@ -91,7 +91,7 @@ Use --explain to print this usage and JSON field contract without running analys
#[arg(long)] #[arg(long)]
explain: bool, explain: bool,
}, },
/// Symbol counts by kind and file /// Rust AST symbol counts by kind, including trait impl blocks
Symbols, Symbols,
/// Per-function and per-file complexity rankings /// Per-function and per-file complexity rankings
Complexity, Complexity,

View file

@ -12,7 +12,9 @@ pub struct SymbolTotals {
pub structs: usize, pub structs: usize,
pub enums: usize, pub enums: usize,
pub traits: usize, pub traits: usize,
pub impls: usize, pub trait_impls: usize,
pub consts: usize,
pub statics: usize,
pub parse_error_files: usize, pub parse_error_files: usize,
} }
@ -24,7 +26,9 @@ pub struct FileSymbolCounts {
pub structs: usize, pub structs: usize,
pub enums: usize, pub enums: usize,
pub traits: usize, pub traits: usize,
pub impls: usize, pub trait_impls: usize,
pub consts: usize,
pub statics: usize,
pub parse_error: bool, pub parse_error: bool,
} }
@ -41,6 +45,7 @@ pub enum FileSymbolKind {
Struct, Struct,
Enum, Enum,
Trait, Trait,
TraitImpl,
Const, Const,
Static, Static,
} }
@ -61,8 +66,10 @@ pub struct FileSymbolReport {
pub structs: usize, pub structs: usize,
pub enums: usize, pub enums: usize,
pub traits: usize, pub traits: usize,
pub trait_impls: usize,
pub consts: usize, pub consts: usize,
pub statics: usize, pub statics: usize,
pub parse_error: bool,
pub symbols: Vec<FileSymbolRow>, pub symbols: Vec<FileSymbolRow>,
} }
@ -75,8 +82,10 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR
let structs = file.structs.len(); let structs = file.structs.len();
let enums = file.enums.len(); let enums = file.enums.len();
let traits = file.traits.len(); let traits = file.traits.len();
let impls = file.impls.len(); let trait_impls = file.impls.iter().filter(|i| i.trait_name.is_some()).count();
let total = functions + structs + enums + traits + impls; let consts = file.consts.len();
let statics = file.statics.len();
let total = functions + structs + enums + traits + trait_impls + consts + statics;
FileSymbolCounts { FileSymbolCounts {
path: strip_prefix(&file.path, project_path).display().to_string(), path: strip_prefix(&file.path, project_path).display().to_string(),
@ -85,7 +94,9 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR
structs, structs,
enums, enums,
traits, traits,
impls, trait_impls,
consts,
statics,
parse_error: file.parse_error, parse_error: file.parse_error,
} }
}) })
@ -99,7 +110,9 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR
acc.structs += file.structs; acc.structs += file.structs;
acc.enums += file.enums; acc.enums += file.enums;
acc.traits += file.traits; acc.traits += file.traits;
acc.impls += file.impls; acc.trait_impls += file.trait_impls;
acc.consts += file.consts;
acc.statics += file.statics;
if file.parse_error { if file.parse_error {
acc.parse_error_files += 1; acc.parse_error_files += 1;
} }
@ -128,8 +141,10 @@ pub fn analyze_symbols_file(
structs: 0, structs: 0,
enums: 0, enums: 0,
traits: 0, traits: 0,
trait_impls: 0,
consts: 0, consts: 0,
statics: 0, statics: 0,
parse_error: false,
symbols: Vec::new(), symbols: Vec::new(),
}; };
}; };
@ -147,40 +162,50 @@ pub fn analyze_symbols_file(
rows.push(FileSymbolRow { rows.push(FileSymbolRow {
kind: FileSymbolKind::Struct, kind: FileSymbolKind::Struct,
symbol: item.name.clone(), symbol: item.name.clone(),
line_start: None, line_start: Some(item.line_start),
line_end: None, line_end: Some(item.line_end),
}); });
} }
for item in &file_symbols.enums { for item in &file_symbols.enums {
rows.push(FileSymbolRow { rows.push(FileSymbolRow {
kind: FileSymbolKind::Enum, kind: FileSymbolKind::Enum,
symbol: item.name.clone(), symbol: item.name.clone(),
line_start: None, line_start: Some(item.line_start),
line_end: None, line_end: Some(item.line_end),
}); });
} }
for item in &file_symbols.traits { for item in &file_symbols.traits {
rows.push(FileSymbolRow { rows.push(FileSymbolRow {
kind: FileSymbolKind::Trait, kind: FileSymbolKind::Trait,
symbol: item.name.clone(), symbol: item.name.clone(),
line_start: None, line_start: Some(item.line_start),
line_end: None, line_end: Some(item.line_end),
}); });
} }
for item in &file_symbols.impls {
if let Some(trait_name) = &item.trait_name {
rows.push(FileSymbolRow {
kind: FileSymbolKind::TraitImpl,
symbol: format!("<{} as {}>", item.target_type, trait_name),
line_start: Some(item.line_start),
line_end: Some(item.line_end),
});
}
}
for item in &file_symbols.consts { for item in &file_symbols.consts {
rows.push(FileSymbolRow { rows.push(FileSymbolRow {
kind: FileSymbolKind::Const, kind: FileSymbolKind::Const,
symbol: item.name.clone(), symbol: item.name.clone(),
line_start: None, line_start: Some(item.line_start),
line_end: None, line_end: Some(item.line_end),
}); });
} }
for item in &file_symbols.statics { for item in &file_symbols.statics {
rows.push(FileSymbolRow { rows.push(FileSymbolRow {
kind: FileSymbolKind::Static, kind: FileSymbolKind::Static,
symbol: item.name.clone(), symbol: item.name.clone(),
line_start: None, line_start: Some(item.line_start),
line_end: None, line_end: Some(item.line_end),
}); });
} }
@ -188,9 +213,14 @@ pub fn analyze_symbols_file(
let structs = file_symbols.structs.len(); let structs = file_symbols.structs.len();
let enums = file_symbols.enums.len(); let enums = file_symbols.enums.len();
let traits = file_symbols.traits.len(); let traits = file_symbols.traits.len();
let trait_impls = file_symbols
.impls
.iter()
.filter(|item| item.trait_name.is_some())
.count();
let consts = file_symbols.consts.len(); let consts = file_symbols.consts.len();
let statics = file_symbols.statics.len(); let statics = file_symbols.statics.len();
let total = functions + structs + enums + traits + consts + statics; let total = functions + structs + enums + traits + trait_impls + consts + statics;
FileSymbolReport { FileSymbolReport {
file: file_display, file: file_display,
@ -199,8 +229,10 @@ pub fn analyze_symbols_file(
structs, structs,
enums, enums,
traits, traits,
trait_impls,
consts, consts,
statics, statics,
parse_error: file_symbols.parse_error,
symbols: rows, symbols: rows,
} }
} }
@ -210,14 +242,16 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Counts are Rust AST items found in parseable files.", "symbols uses syn Rust AST item discovery.",
"Functions includes free functions and impl methods.", "It does not perform semantic name resolution, rustc analysis, macro expansion, or public API usage proof.",
"Parse-error files are listed with zero symbols so the report stays deterministic.", "Functions include free functions and methods in any impl block; methods are counted once as functions.",
"trait_impls counts only impl Trait for Type blocks; inherent impl Type containers are not counted separately.",
"Parse-error files are surfaced with zero counts and parse_error=true in JSON.",
]); ]);
} }
println!( println!(
" {} {} {} {} {} {} {} {} {} {} {} {}", " {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
"total".cyan(), "total".cyan(),
report.totals.total.to_string().bold(), report.totals.total.to_string().bold(),
"fn".cyan(), "fn".cyan(),
@ -228,8 +262,12 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
report.totals.enums.to_string().bold(), report.totals.enums.to_string().bold(),
"trait".cyan(), "trait".cyan(),
report.totals.traits.to_string().bold(), report.totals.traits.to_string().bold(),
"impl".cyan(), "trait_impl".cyan(),
report.totals.impls.to_string().bold(), report.totals.trait_impls.to_string().bold(),
"const".cyan(),
report.totals.consts.to_string().bold(),
"static".cyan(),
report.totals.statics.to_string().bold(),
); );
if report.totals.parse_error_files > 0 { if report.totals.parse_error_files > 0 {
@ -242,26 +280,30 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
println!(); println!();
println!( println!(
" {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>5}", " {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>11} {:>6} {:>6}",
"file".bold(), "file".bold(),
"total".bold(), "total".bold(),
"fn".bold(), "fn".bold(),
"struct".bold(), "struct".bold(),
"enum".bold(), "enum".bold(),
"trait".bold(), "trait".bold(),
"impl".bold() "trait_impl".bold(),
"const".bold(),
"static".bold()
); );
for file in &report.files { for file in &report.files {
let marker = if file.parse_error { " !" } else { "" }; let marker = if file.parse_error { " !" } else { "" };
println!( println!(
" {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>5}", " {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>11} {:>6} {:>6}",
truncate(&format!("{}{}", file.path, marker), 48), truncate(&format!("{}{}", file.path, marker), 48),
file.total, file.total,
file.functions, file.functions,
file.structs, file.structs,
file.enums, file.enums,
file.traits, file.traits,
file.impls, file.trait_impls,
file.consts,
file.statics,
); );
} }
} }
@ -293,14 +335,16 @@ pub fn render_file_symbol_report(report: &FileSymbolReport, verbose: bool) {
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Counts are Rust AST items found in the selected file.", "Selected-file mode is used when --path points at a Rust source file.",
"Functions includes free functions and impl methods.", "symbols uses syn Rust AST item discovery; it does not perform semantic name resolution, rustc analysis, macro expansion, or public API usage proof.",
"Line spans are available for functions; other symbol kinds show lines -.", "Functions include free functions and methods in any impl block; methods are counted once as functions.",
"trait_impls counts only impl Trait for Type blocks; inherent impl Type containers are not counted separately.",
"Line spans come from syn/proc-macro2 token spans for Rust item rows.",
]); ]);
} }
println!( println!(
" {} {} {} {} {} {} {} {} {} {} {} {} {} {}", " {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
"total".cyan(), "total".cyan(),
report.total.to_string().bold(), report.total.to_string().bold(),
"fn".cyan(), "fn".cyan(),
@ -311,12 +355,23 @@ pub fn render_file_symbol_report(report: &FileSymbolReport, verbose: bool) {
report.enums.to_string().bold(), report.enums.to_string().bold(),
"trait".cyan(), "trait".cyan(),
report.traits.to_string().bold(), report.traits.to_string().bold(),
"trait_impl".cyan(),
report.trait_impls.to_string().bold(),
"const".cyan(), "const".cyan(),
report.consts.to_string().bold(), report.consts.to_string().bold(),
"static".cyan(), "static".cyan(),
report.statics.to_string().bold(), report.statics.to_string().bold(),
); );
if report.parse_error {
println!(
" {}",
"parse error: selected file could not be parsed"
.yellow()
.bold()
);
}
println!(); println!();
println!( println!(
" {:<10} {:<48} {}", " {:<10} {:<48} {}",
@ -344,6 +399,7 @@ fn file_symbol_kind_label(kind: &FileSymbolKind) -> &'static str {
FileSymbolKind::Struct => "struct", FileSymbolKind::Struct => "struct",
FileSymbolKind::Enum => "enum", FileSymbolKind::Enum => "enum",
FileSymbolKind::Trait => "trait", FileSymbolKind::Trait => "trait",
FileSymbolKind::TraitImpl => "trait_impl",
FileSymbolKind::Const => "const", FileSymbolKind::Const => "const",
FileSymbolKind::Static => "static", FileSymbolKind::Static => "static",
} }

296
tests/symbols_cli.rs Normal file
View file

@ -0,0 +1,296 @@
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
const LIB_RS: &str = r#"const LIMIT: usize = 10;
static NAME: &str = "fixture";
struct Parser;
enum Mode {
Fast,
}
trait Parse {
fn parse(&self);
}
impl Parser {
fn new() -> Self {
Self
}
}
impl Parse for Parser {
fn parse(&self) {}
}
fn free() {
let _ = LIMIT;
}
mod inline {
pub struct Inner;
pub const FLAG: bool = true;
pub fn nested() {}
}
"#;
fn temp_project(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("cstat-symbols-{name}-{unique}"));
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
fs::write(root.join("src/lib.rs"), LIB_RS).unwrap();
fs::write(root.join("src/broken.rs"), "fn broken(\n").unwrap();
root
}
fn run_cstat(path: &Path, args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(["--no-color", "--path"]);
command.arg(path);
command.args(args);
command.output().expect("invoke cstat binary")
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"cstat failed: status={:?}\nstderr={}\nstdout={}",
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
}
fn parse_json(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).expect("parse symbols JSON")
}
fn find_file<'a>(value: &'a Value, path: &str) -> &'a Value {
value["files"]
.as_array()
.expect("files array")
.iter()
.find(|entry| entry["path"] == path)
.unwrap_or_else(|| panic!("missing file row {path}: {value}"))
}
fn assert_zero_symbol_counts(value: &Value) {
for field in [
"total",
"functions",
"structs",
"enums",
"traits",
"trait_impls",
"consts",
"statics",
] {
assert_eq!(value[field], 0, "expected zero {field}: {value}");
}
}
fn find_symbol<'a>(rows: &'a [Value], kind: &str, symbol: &str) -> &'a Value {
rows.iter()
.find(|row| row["kind"] == kind && row["symbol"] == symbol)
.unwrap_or_else(|| panic!("missing {kind} symbol {symbol}: {rows:?}"))
}
#[test]
fn project_json_reports_trait_impl_contract_and_parse_errors() {
let root = temp_project("project-json");
let output = run_cstat(&root, &["--json", "symbols"]);
assert_success(&output);
let value = parse_json(&output);
assert!(value.get("totals").is_some(), "json={value}");
assert!(value.get("files").is_some(), "json={value}");
let totals = &value["totals"];
assert_eq!(totals["total"], 12);
assert_eq!(totals["functions"], 4);
assert_eq!(totals["structs"], 2);
assert_eq!(totals["enums"], 1);
assert_eq!(totals["traits"], 1);
assert_eq!(totals["trait_impls"], 1);
assert_eq!(totals["consts"], 2);
assert_eq!(totals["statics"], 1);
assert_eq!(totals["parse_error_files"], 1);
assert!(
totals.get("impls").is_none(),
"old impls key present: {totals}"
);
let lib = find_file(&value, "src/lib.rs");
assert_eq!(lib["total"], 12);
assert_eq!(lib["functions"], 4);
assert_eq!(lib["structs"], 2);
assert_eq!(lib["enums"], 1);
assert_eq!(lib["traits"], 1);
assert_eq!(lib["trait_impls"], 1);
assert_eq!(lib["consts"], 2);
assert_eq!(lib["statics"], 1);
assert_eq!(lib["parse_error"], false);
assert!(lib.get("impls").is_none(), "old impls key present: {lib}");
let broken = find_file(&value, "src/broken.rs");
assert_eq!(broken["parse_error"], true);
assert_zero_symbol_counts(broken);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_file_json_reports_rows_spans_and_schema() {
let root = temp_project("selected-json");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["--json", "symbols"]);
assert_success(&output);
let value = parse_json(&output);
for field in [
"file",
"total",
"functions",
"structs",
"enums",
"traits",
"trait_impls",
"consts",
"statics",
"parse_error",
"symbols",
] {
assert!(value.get(field).is_some(), "missing {field}: {value}");
}
assert_eq!(value["file"], "src/lib.rs");
assert_eq!(value["parse_error"], false);
assert_eq!(value["total"], 12);
assert_eq!(value["functions"], 4);
assert_eq!(value["structs"], 2);
assert_eq!(value["enums"], 1);
assert_eq!(value["traits"], 1);
assert_eq!(value["trait_impls"], 1);
assert_eq!(value["consts"], 2);
assert_eq!(value["statics"], 1);
assert!(
value.get("impls").is_none(),
"old impls key present: {value}"
);
let rows = value["symbols"].as_array().expect("symbols array");
let fn_rows = rows.iter().filter(|row| row["kind"] == "fn").count();
assert_eq!(fn_rows, 4, "rows={rows:?}");
let free = find_symbol(rows, "fn", "free");
assert_eq!(free["line_start"], 24);
assert_eq!(free["line_end"], 26);
let inherent_method = find_symbol(rows, "fn", "Parser::new");
assert_eq!(inherent_method["line_start"], 15);
assert_eq!(inherent_method["line_end"], 17);
find_symbol(rows, "fn", "inline::nested");
let trait_impl_rows: Vec<&Value> = rows
.iter()
.filter(|row| row["kind"] == "trait_impl")
.collect();
assert_eq!(trait_impl_rows.len(), 1, "rows={rows:?}");
let trait_impl = trait_impl_rows[0];
assert_eq!(trait_impl["symbol"], "<Parser as Parse>");
assert_eq!(trait_impl["line_start"], 20);
assert_eq!(trait_impl["line_end"], 22);
find_symbol(rows, "const", "inline::FLAG");
find_symbol(rows, "static", "NAME");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_file_json_surfaces_parse_error() {
let root = temp_project("parse-error");
let file = root.join("src/broken.rs");
let output = run_cstat(&file, &["--json", "symbols"]);
assert_success(&output);
let value = parse_json(&output);
assert_eq!(value["file"], "src/broken.rs");
assert_eq!(value["parse_error"], true);
assert_zero_symbol_counts(&value);
assert!(
value["symbols"]
.as_array()
.expect("symbols array")
.is_empty(),
"json={value}"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn symbols_human_verbose_explains_contract() {
let root = temp_project("human-verbose");
let output = run_cstat(&root, &["-v", "symbols"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"Rust AST",
"syn",
"semantic name resolution",
"public API usage",
"trait_impl",
"inherent",
"Parse-error",
"const",
"static",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn symbols_help_mentions_file_path_and_trait_impls() {
let root = temp_project("help");
let root_help = run_cstat(&root, &["--help"]);
assert_success(&root_help);
let root_stdout = String::from_utf8_lossy(&root_help.stdout);
assert!(
root_stdout.contains("Rust project directory or Rust source file"),
"stdout={root_stdout}"
);
let symbols_help = run_cstat(&root, &["symbols", "--help"]);
assert_success(&symbols_help);
let symbols_stdout = String::from_utf8_lossy(&symbols_help.stdout);
assert!(
symbols_stdout.contains("Rust AST symbol counts"),
"stdout={symbols_stdout}"
);
assert!(
symbols_stdout.contains("trait impl"),
"stdout={symbols_stdout}"
);
fs::remove_dir_all(root).unwrap();
}