Compare commits

...

4 commits

Author SHA1 Message Date
f35326b5e8 Merge symbols CLI probe
Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-07-21 11:08:12 +04:00
eccdd90b93 refactor: cstat symbols behavioral tune 2026-07-21 11:05:51 +04:00
3517fbd1a0 Merge branch 'loc' 2026-07-21 11:02:32 +04:00
98b7f6a5d9 refactor: cstat loc behavioral tune 2026-07-21 11:01:47 +04:00
9 changed files with 890 additions and 66 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
@ -35,8 +36,13 @@ remain available under `cstat advanced ...`.
Each accepts `--json` for structured output. Each accepts `--json` for structured output.
- `cstat loc --json --path .` — line counts and directory breakdown. - `cstat loc --explain --json` — machine-readable `loc` contract: modes,
- `cstat symbols --json --path .` — symbol totals by kind and per file. `code_lines` rules, project JSON fields, and selected-file JSON fields.
- `cstat loc --json --path .` — project size-shape data.
- `cstat loc --json --path src/lib.rs` — selected-file projected static line
reachability.
- `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.
@ -82,5 +88,13 @@ Use these only when the focused report points to a question they answer:
test/benchmark roots can reach a function or edge. They are not runtime hit-count profiling. test/benchmark roots can reach a function or edge. They are not runtime hit-count profiling.
- Dependency edges come from source-level `use`/`mod` relationships. Generated - Dependency edges come from source-level `use`/`mod` relationships. Generated
code and macro expansion can hide edges. code and macro expansion can hide edges.
- For exact `loc` `code_lines` rules and JSON fields, run
`cstat loc --explain` or `cstat loc --explain --json`; that command is the
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

@ -64,30 +64,31 @@ when learning what the metrics mean.",
fn topic_size() -> TopicContent { fn topic_size() -> TopicContent {
TopicContent { TopicContent {
description: "\ description: "\
Size metrics measure the volume of code at file and function granularity. \ Size metrics measure the volume and distribution of Rust source. `cstat loc` owns the line-count contract; run `cstat loc --explain` for modes, code_lines rules, and JSON fields.",
Large files and functions are not inherently problematic, but they correlate \
with higher defect density, harder navigation, and merge conflicts. The \
distribution shape matters more than any single value.",
metrics: &[ metrics: &[
("file LoC", "Total lines of code per file. High values may indicate a module doing too much."), (
("function LoC", "Lines per function body. Long functions tend to have higher cognitive load."), "file size shape",
("mean / median", "Central tendency of the distribution. A large gap between mean and median indicates skew from outliers."), "Project-level file-size distribution from `cstat loc --path .`.",
("std_dev", "Spread of the distribution. High values mean uneven file sizes."), ),
("max", "The largest single file or function. Often the first place to investigate."), (
"selected-file line reachability",
"Projected static reachable/unreachable production spans from `cstat loc --path <file.rs>`.",
),
(
"distribution statistics",
"Mean, median, std_dev, min, and max summarize project file sizes.",
),
], ],
commands: &[ commands: &[
"cstat loc — per-file LoC with bar charts and directory breakdown", "cstat loc --explain — loc modes, code_lines rules, and JSON field contract",
"cstat loc --json — structured file-level size data", "cstat loc --path . — project size-shape report",
"cstat loc --path src/lib.rs — selected-file projected line reachability",
"cstat dist --metric loc — histogram and outlier analysis of LoC distribution", "cstat dist --metric loc — histogram and outlier analysis of LoC distribution",
], ],
patterns: &[ patterns: &[
"A right-skewed LoC distribution (long tail) usually means a few files have \ "Use `cstat loc --explain` as the canonical reference before consuming loc output.",
grown disproportionately. Check whether those files contain multiple concerns.", "A right-skewed project-mode code_lines distribution means a few files dominate size.",
"Files above 500 lines often contain function clusters that could be separate \ "Selected-file loc is a static projection from function spans and test/benchmark reachability; do not read it as runtime coverage.",
modules. Cross-reference with `cstat deps` to see if the file's functions form \
distinct groups with few cross-calls.",
"Uniform file sizes are not a goal — some modules are naturally larger. The \
signal is when a file is large AND has low cohesion or high internal complexity.",
], ],
} }
} }

View file

@ -377,11 +377,13 @@ pub fn render_loc(files: &[PathBuf], project_path: &Path, top_n: Option<usize>,
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Bar chart: files ranked by code lines (excluding blank lines and comments).", "Bar chart: files ranked by code_lines.",
"Bar length is proportional to LoC relative to the largest file.", "code_lines excludes blank lines, // comment-only lines, and block-comment-only regions.",
"Line classification is static text scanning, not semantic Rust parsing.",
"Bar length is proportional to code_lines relative to the largest file.",
"Color gradient: red = top of the ranking (most lines), green = bottom.", "Color gradient: red = top of the ranking (most lines), green = bottom.",
"Full loc reference: cstat loc --explain.",
]); ]);
render::guide_ref("size");
} }
println!( println!(
@ -477,6 +479,71 @@ pub fn render_loc_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Pa
} }
} }
pub fn render_loc_explain(json: bool) {
if json {
render_loc_explain_json();
} else {
render_loc_explain_human();
}
}
fn render_loc_explain_human() {
render::section_header("loc reference");
println!(" Project mode: cstat loc --path .");
println!(" Project JSON: cstat loc --path . --json");
println!(" Selected-file mode: cstat loc --path src/lib.rs");
println!(" Selected-file JSON: cstat loc --path src/lib.rs --json");
println!(" Machine contract: cstat loc --explain --json");
println!();
println!(" code_lines: static textual classification");
println!(" excludes blank lines, // comment-only lines, and block-comment-only regions");
println!(" not semantic Rust parsing");
println!();
println!(" Project JSON fields: cstat_version, files, aggregate, directory_breakdown");
println!(" Selected-file JSON fields: file, total_lines, code_lines, projected_reachable_lines, projected_unreachable_lines, reachable_spans, unreachable_spans");
}
fn render_loc_explain_json() {
let output = serde_json::json!({
"cstat_version": env!("CARGO_PKG_VERSION"),
"probe": "loc",
"commands": {
"project_human": "cstat loc --path .",
"project_json": "cstat loc --path . --json",
"selected_file_human": "cstat loc --path src/lib.rs",
"selected_file_json": "cstat loc --path src/lib.rs --json",
"explain_human": "cstat loc --explain",
"explain_json": "cstat loc --explain --json",
},
"code_lines": {
"kind": "static textual classification",
"excludes": [
"blank lines",
"// comment-only lines",
"block-comment-only regions via simple /* ... */ state tracking",
],
"not": "semantic Rust parsing",
},
"project_json_fields": {
"cstat_version": "cstat version string",
"files": ["path", "total_lines", "code_lines"],
"aggregate": ["total_files", "total_loc", "mean", "std_dev", "median", "min", "max"],
"directory_breakdown": ["directory", "code_lines"],
},
"selected_file_json_fields": {
"file": "selected file path relative to the crate root",
"total_lines": "physical lines in the selected file",
"code_lines": "production function-span code lines after exclusions",
"projected_reachable_lines": "production code lines in statically reachable function spans",
"projected_unreachable_lines": "production code lines not in statically reachable function spans",
"reachable_spans": ["function", "line_start", "line_end"],
"unreachable_spans": ["function", "line_start", "line_end"],
},
});
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn analyze_file_projected_line_reachability_from_files( pub fn analyze_file_projected_line_reachability_from_files(
rs_files: &[PathBuf], rs_files: &[PathBuf],
project_path: &Path, project_path: &Path,
@ -511,8 +578,10 @@ pub fn render_file_projected_line_reachability_report(
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Physical lines count every source line in the selected file.", "Physical lines count every source line in the selected file.",
"Code lines exclude blank/comment-only lines and test/support/wrapper spans for selected-file production accounting.", "code_lines is production function-span code after excluding tests, benches, test-support helpers, and selected binary wrapper main().",
"Line classification excludes blank lines, // comment-only lines, and block-comment-only regions with simple text scanning.",
"Projected reachable lines are production code lines inside functions statically reached from project tests/benches.", "Projected reachable lines are production code lines inside functions statically reached from project tests/benches.",
"Full loc reference: cstat loc --explain.",
]); ]);
} }

View file

@ -33,7 +33,7 @@ struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
/// Path to the Rust project directory (defaults to current directory) /// Rust project directory or Rust source file to analyze (defaults to current directory)
#[arg(long, default_value = ".", global = true)] #[arg(long, default_value = ".", global = true)]
path: PathBuf, path: PathBuf,
@ -56,13 +56,42 @@ enum Commands {
Summary, Summary,
/// Alias for the focused module metrics report /// Alias for the focused module metrics report
Report, Report,
/// Lines-of-code analysis with bar charts #[command(
about = "Lines-of-code size-shape analysis",
long_about = r#"Lines-of-code size-shape analysis for Rust source.
Project mode:
cstat loc --path .
cstat loc --path . --json
Selected-file mode:
cstat loc --path src/lib.rs
cstat loc --path src/lib.rs --json
Explain mode:
cstat loc --explain
cstat loc --explain --json
Project mode reports total physical lines, code_lines, aggregate stats, per-file ranking, and directory breakdown for discovered Rust files. Selected-file mode reports projected static reachable/unreachable production line spans for one Rust source file.
code_lines is a static textual classification: blank lines are excluded, // comment-only lines are excluded, and block-comment-only regions are excluded using simple /* ... */ state tracking. It is not semantic Rust parsing.
Project JSON fields: cstat_version, files[{path,total_lines,code_lines}], aggregate{total_files,total_loc,mean,std_dev,median,min,max}, directory_breakdown[{directory,code_lines}].
Selected-file JSON fields: file, total_lines, code_lines, projected_reachable_lines, projected_unreachable_lines, reachable_spans[{function,line_start,line_end}], unreachable_spans[{function,line_start,line_end}].
Use --explain to print this usage and JSON field contract without running analysis. --path, --top, and -v are ignored by --explain.
--top only limits the project-mode per-file ranking (ignored for selected-file and --explain modes)."#
)]
Loc { Loc {
/// Show only the top N files /// Show only the top N files in project mode (ignored for selected-file and --explain modes)
#[arg(long)] #[arg(long)]
top: Option<usize>, top: Option<usize>,
/// Print the loc usage and JSON field contract without running analysis
#[arg(long)]
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,
@ -162,6 +191,12 @@ fn main() {
} }
let verbose = cli.verbose; let verbose = cli.verbose;
let command = cli.command.unwrap_or(Commands::Summary);
if let Commands::Loc { explain: true, .. } = &command {
loc::render_loc_explain(json);
return;
}
let target = match discovery::resolve_target(&cli.path) { let target = match discovery::resolve_target(&cli.path) {
Ok(target) => target, Ok(target) => target,
Err(e) => { Err(e) => {
@ -177,8 +212,6 @@ fn main() {
let project_path = &target.project_path; let project_path = &target.project_path;
let rs_files = discovery::files_for_project(&target); let rs_files = discovery::files_for_project(&target);
let command = cli.command.unwrap_or(Commands::Summary);
if let Some(file) = discovery::selected_file(&target) { if let Some(file) = discovery::selected_file(&target) {
let project_rs_files = discovery::files_for_project(&target); let project_rs_files = discovery::files_for_project(&target);
let target_rs_files = discovery::files_for_target(&target); let target_rs_files = discovery::files_for_target(&target);
@ -307,7 +340,7 @@ fn main() {
report::render_report(&rs_files, &project_path, verbose); report::render_report(&rs_files, &project_path, verbose);
} }
} }
Commands::Loc { top } => { Commands::Loc { top, .. } => {
if json { if json {
loc::render_loc_json(&rs_files, &project_path); loc::render_loc_json(&rs_files, &project_path);
} else { } else {

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",
} }

308
tests/loc_cli.rs Normal file
View file

@ -0,0 +1,308 @@
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
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-loc-cli-{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"),
r#"
pub fn live() {
helper();
}
/*
block comment only
*/
fn helper() {}
/* single-line block comment only */
fn orphan() {
// comment-only
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn covers_live() {
live();
}
}
"#,
)
.unwrap();
root
}
fn run_cstat_raw(args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(args);
command.output().expect("invoke cstat binary")
}
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 span_array_contains_function(array: &Value, expected: &str) -> bool {
array
.as_array()
.expect("span array")
.iter()
.any(|item| item.get("function").and_then(Value::as_str) == Some(expected))
}
#[test]
fn loc_help_exposes_project_and_selected_file_modes() {
let output = run_cstat_raw(&["loc", "--help"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"Rust project directory or Rust source file",
"Project mode:",
"Selected-file mode:",
"cstat loc --path .",
"cstat loc --path src/lib.rs",
"cstat loc --explain",
"cstat loc --explain --json",
"code_lines is a static textual classification",
"blank lines are excluded",
"// comment-only lines are excluded",
"block-comment-only regions are excluded",
"Project JSON fields:",
"Selected-file JSON fields:",
"--top only limits the project-mode",
"ignored for selected-file and --explain modes",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
}
#[test]
fn loc_explain_human_is_canonical_reference() {
let output = run_cstat_raw(&[
"--path",
"/definitely/missing/cstat/path",
"loc",
"--explain",
]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"loc reference",
"Project mode: cstat loc --path .",
"Machine contract: cstat loc --explain --json",
"code_lines: static textual classification",
"not semantic Rust parsing",
"Project JSON fields:",
"Selected-file JSON fields:",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Error discovering files"),
"unexpected discovery error: {stderr}",
);
}
#[test]
fn loc_explain_json_exposes_machine_contract() {
let output = run_cstat_raw(&["--json", "loc", "--explain"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse loc explain JSON");
assert_eq!(value["probe"], "loc");
assert_eq!(
value["commands"]["explain_json"],
"cstat loc --explain --json",
);
assert_eq!(value["code_lines"]["kind"], "static textual classification",);
assert!(
value["project_json_fields"]["files"]
.as_array()
.expect("project fields")
.iter()
.any(|field| field.as_str() == Some("code_lines")),
"json={stdout}",
);
assert!(
value["selected_file_json_fields"]["reachable_spans"]
.as_array()
.expect("selected fields")
.iter()
.any(|field| field.as_str() == Some("function")),
"json={stdout}",
);
}
#[test]
fn loc_verbose_points_to_loc_explain_not_guide() {
let root = temp_project("verbose-loc-reference");
let output = run_cstat(&root, &["-v", "loc"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Full loc reference: cstat loc --explain"),
"stdout={stdout}",
);
assert!(
!stdout.contains("cstat guide size"),
"stdout unexpectedly referenced guide: {stdout}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loc_project_json_exposes_shape_contract() {
let root = temp_project("project-json");
let output = run_cstat(&root, &["--json", "loc"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse loc JSON");
assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION"));
let files = value["files"].as_array().expect("files array");
assert!(!files.is_empty(), "json={stdout}");
let lib = files
.iter()
.find(|file| file["path"] == "src/lib.rs")
.expect("src/lib.rs row");
let total_lines = lib["total_lines"].as_u64().expect("total_lines");
let code_lines = lib["code_lines"].as_u64().expect("code_lines");
assert!(
total_lines >= code_lines && code_lines > 0,
"lib row={lib:?}",
);
let aggregate = &value["aggregate"];
for field in [
"total_files",
"total_loc",
"mean",
"std_dev",
"median",
"min",
"max",
] {
assert!(aggregate.get(field).is_some(), "missing {field}: {stdout}");
}
let directories = value["directory_breakdown"]
.as_array()
.expect("directory_breakdown array");
assert!(
directories.iter().any(|directory| {
directory["directory"] == "src"
&& directory["code_lines"]
.as_u64()
.is_some_and(|code_lines| code_lines > 0)
}),
"directory_breakdown={directories:?}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loc_selected_file_human_is_usable() {
let root = temp_project("selected-human");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["loc"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"Projected line reachability",
"file src/lib.rs",
"physical lines",
"production code lines",
"statically reachable lines",
"not statically reachable lines",
"reachable function spans:",
"unreachable function spans:",
"live",
"orphan",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loc_selected_file_json_exposes_projected_reachability_contract() {
let root = temp_project("selected-json");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["--json", "loc"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse selected-file loc JSON");
assert_eq!(value["file"], "src/lib.rs");
let total_lines = value["total_lines"].as_u64().expect("total_lines");
let code_lines = value["code_lines"].as_u64().expect("code_lines");
let projected_reachable_lines = value["projected_reachable_lines"]
.as_u64()
.expect("projected_reachable_lines");
let projected_unreachable_lines = value["projected_unreachable_lines"]
.as_u64()
.expect("projected_unreachable_lines");
assert!(total_lines >= code_lines, "json={stdout}");
assert_eq!(
projected_reachable_lines + projected_unreachable_lines,
code_lines,
"json={stdout}",
);
assert!(
span_array_contains_function(&value["reachable_spans"], "live"),
"json={stdout}",
);
assert!(
span_array_contains_function(&value["reachable_spans"], "helper"),
"json={stdout}",
);
assert!(
span_array_contains_function(&value["unreachable_spans"], "orphan"),
"json={stdout}",
);
fs::remove_dir_all(root).unwrap();
}

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