Compare commits

..

No commits in common. "f35326b5e8d07b77a797cc0e3733e1ec3e7ec76f" and "d699420eb1bc3fcb5f3f3a1c16d5840090d1cbf5" have entirely different histories.

9 changed files with 66 additions and 890 deletions

View file

@ -20,8 +20,7 @@ report. The JSON contains:
- `line_counts`: total code lines, physical lines, and per-file counts.
- `symbols`: total and per-file counts for functions, structs, enums, traits,
`trait_impls`, consts, statics, and parse-error state. `trait_impls` means
`impl Trait for Type` blocks only, not inherent `impl Type` blocks.
and impl blocks.
- `dependencies`: module list, module interdependency matrix, edge count, and
fan-in/fan-out degrees.
- `dead_code`: functions not statically reachable from main, tests, or
@ -36,13 +35,8 @@ remain available under `cstat advanced ...`.
Each accepts `--json` for structured output.
- `cstat loc --explain --json` — machine-readable `loc` contract: modes,
`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 loc --json --path .` — line counts and directory breakdown.
- `cstat symbols --json --path .` — symbol totals by kind and per file.
- `cstat deps --json --path .` — dependency edges, coupling, fan-in/fan-out,
and cohesion.
- `cstat dead-code --json --path .` — static cold-function candidates.
@ -88,13 +82,5 @@ 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.
- Dependency edges come from source-level `use`/`mod` relationships. Generated
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
across modules, then reduce per-function complexity.

View file

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

View file

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

View file

@ -64,31 +64,30 @@ when learning what the metrics mean.",
fn topic_size() -> TopicContent {
TopicContent {
description: "\
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.",
Size metrics measure the volume of code at file and function granularity. \
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: &[
(
"file size shape",
"Project-level file-size distribution from `cstat loc --path .`.",
),
(
"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.",
),
("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."),
("mean / median", "Central tendency of the distribution. A large gap between mean and median indicates skew from outliers."),
("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."),
],
commands: &[
"cstat loc --explain — loc modes, code_lines rules, and JSON field contract",
"cstat loc --path . — project size-shape report",
"cstat loc --path src/lib.rs — selected-file projected line reachability",
"cstat loc — per-file LoC with bar charts and directory breakdown",
"cstat loc --json — structured file-level size data",
"cstat dist --metric loc — histogram and outlier analysis of LoC distribution",
],
patterns: &[
"Use `cstat loc --explain` as the canonical reference before consuming loc output.",
"A right-skewed project-mode code_lines distribution means a few files dominate size.",
"Selected-file loc is a static projection from function spans and test/benchmark reachability; do not read it as runtime coverage.",
"A right-skewed LoC distribution (long tail) usually means a few files have \
grown disproportionately. Check whether those files contain multiple concerns.",
"Files above 500 lines often contain function clusters that could be separate \
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,13 +377,11 @@ pub fn render_loc(files: &[PathBuf], project_path: &Path, top_n: Option<usize>,
if verbose {
render::verbose_block(&[
"Bar chart: files ranked by code_lines.",
"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.",
"Bar chart: files ranked by code lines (excluding blank lines and comments).",
"Bar length is proportional to LoC relative to the largest file.",
"Color gradient: red = top of the ranking (most lines), green = bottom.",
"Full loc reference: cstat loc --explain.",
]);
render::guide_ref("size");
}
println!(
@ -479,71 +477,6 @@ 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(
rs_files: &[PathBuf],
project_path: &Path,
@ -578,10 +511,8 @@ pub fn render_file_projected_line_reachability_report(
if verbose {
render::verbose_block(&[
"Physical lines count every source line in the selected file.",
"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.",
"Code lines exclude blank/comment-only lines and test/support/wrapper spans for selected-file production accounting.",
"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: Option<Commands>,
/// Rust project directory or Rust source file to analyze (defaults to current directory)
/// Path to the Rust project directory (defaults to current directory)
#[arg(long, default_value = ".", global = true)]
path: PathBuf,
@ -56,42 +56,13 @@ enum Commands {
Summary,
/// Alias for the focused module metrics report
Report,
#[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)."#
)]
/// Lines-of-code analysis with bar charts
Loc {
/// Show only the top N files in project mode (ignored for selected-file and --explain modes)
/// Show only the top N files
#[arg(long)]
top: Option<usize>,
/// Print the loc usage and JSON field contract without running analysis
#[arg(long)]
explain: bool,
},
/// Rust AST symbol counts by kind, including trait impl blocks
/// Symbol counts by kind and file
Symbols,
/// Per-function and per-file complexity rankings
Complexity,
@ -191,12 +162,6 @@ fn main() {
}
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) {
Ok(target) => target,
Err(e) => {
@ -212,6 +177,8 @@ fn main() {
let project_path = &target.project_path;
let rs_files = discovery::files_for_project(&target);
let command = cli.command.unwrap_or(Commands::Summary);
if let Some(file) = discovery::selected_file(&target) {
let project_rs_files = discovery::files_for_project(&target);
let target_rs_files = discovery::files_for_target(&target);
@ -340,7 +307,7 @@ fn main() {
report::render_report(&rs_files, &project_path, verbose);
}
}
Commands::Loc { top, .. } => {
Commands::Loc { top } => {
if json {
loc::render_loc_json(&rs_files, &project_path);
} else {

View file

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

View file

@ -1,308 +0,0 @@
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();
}

View file

@ -1,296 +0,0 @@
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();
}