Compare commits
4 commits
d699420eb1
...
f35326b5e8
| Author | SHA1 | Date | |
|---|---|---|---|
| f35326b5e8 | |||
| eccdd90b93 | |||
| 3517fbd1a0 | |||
| 98b7f6a5d9 |
9 changed files with 890 additions and 66 deletions
|
|
@ -20,7 +20,8 @@ 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,
|
||||
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
|
||||
fan-in/fan-out degrees.
|
||||
- `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.
|
||||
|
||||
- `cstat loc --json --path .` — line counts and directory breakdown.
|
||||
- `cstat symbols --json --path .` — symbol totals by kind and per file.
|
||||
- `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 deps --json --path .` — dependency edges, coupling, fan-in/fan-out,
|
||||
and cohesion.
|
||||
- `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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ 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.
|
||||
|
|
@ -44,6 +46,8 @@ 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.
|
||||
|
|
@ -54,6 +58,8 @@ 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.
|
||||
|
|
@ -62,6 +68,8 @@ 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.
|
||||
|
|
@ -70,6 +78,8 @@ 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.
|
||||
|
|
@ -78,6 +88,8 @@ 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>,
|
||||
}
|
||||
|
|
@ -218,6 +230,15 @@ 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 {
|
||||
|
|
@ -226,6 +247,8 @@ impl SymbolExtractor {
|
|||
field_count,
|
||||
derive_count,
|
||||
generic_param_count,
|
||||
line_start,
|
||||
line_end,
|
||||
});
|
||||
}
|
||||
Item::Enum(e) => {
|
||||
|
|
@ -237,6 +260,8 @@ 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) => {
|
||||
|
|
@ -251,18 +276,24 @@ 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) => {
|
||||
|
|
@ -274,6 +305,8 @@ 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 {
|
||||
|
|
@ -294,6 +327,8 @@ impl SymbolExtractor {
|
|||
self.impls.push(ImplInfo {
|
||||
target_type,
|
||||
file: self.file_path.clone(),
|
||||
line_start,
|
||||
line_end,
|
||||
method_count,
|
||||
trait_name,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(&[
|
||||
"Counts are Rust AST items found in the selected file.",
|
||||
"Functions includes free functions and impl methods.",
|
||||
"Selected-file symbols use syn Rust AST item discovery.",
|
||||
"Functions include free functions and impl methods once.",
|
||||
full_section.as_str(),
|
||||
]);
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
"total".cyan(),
|
||||
report.total.to_string().bold(),
|
||||
"fn".cyan(),
|
||||
|
|
@ -298,12 +298,23 @@ 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());
|
||||
|
|
@ -465,6 +476,7 @@ 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",
|
||||
}
|
||||
|
|
|
|||
37
src/guide.rs
37
src/guide.rs
|
|
@ -64,30 +64,31 @@ when learning what the metrics mean.",
|
|||
fn topic_size() -> TopicContent {
|
||||
TopicContent {
|
||||
description: "\
|
||||
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.",
|
||||
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.",
|
||||
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."),
|
||||
("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."),
|
||||
(
|
||||
"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.",
|
||||
),
|
||||
],
|
||||
commands: &[
|
||||
"cstat loc — per-file LoC with bar charts and directory breakdown",
|
||||
"cstat loc --json — structured file-level size data",
|
||||
"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 dist --metric loc — histogram and outlier analysis of LoC distribution",
|
||||
],
|
||||
patterns: &[
|
||||
"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.",
|
||||
"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.",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
src/loc.rs
77
src/loc.rs
|
|
@ -377,11 +377,13 @@ 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 (excluding blank lines and comments).",
|
||||
"Bar length is proportional to LoC relative to the largest file.",
|
||||
"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.",
|
||||
"Color gradient: red = top of the ranking (most lines), green = bottom.",
|
||||
"Full loc reference: cstat loc --explain.",
|
||||
]);
|
||||
render::guide_ref("size");
|
||||
}
|
||||
|
||||
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(
|
||||
rs_files: &[PathBuf],
|
||||
project_path: &Path,
|
||||
|
|
@ -511,8 +578,10 @@ 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 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.",
|
||||
"Full loc reference: cstat loc --explain.",
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
47
src/main.rs
47
src/main.rs
|
|
@ -33,7 +33,7 @@ struct Cli {
|
|||
#[command(subcommand)]
|
||||
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)]
|
||||
path: PathBuf,
|
||||
|
||||
|
|
@ -56,13 +56,42 @@ enum Commands {
|
|||
Summary,
|
||||
/// Alias for the focused module metrics 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 {
|
||||
/// Show only the top N files
|
||||
/// Show only the top N files in project mode (ignored for selected-file and --explain modes)
|
||||
#[arg(long)]
|
||||
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,
|
||||
/// Per-function and per-file complexity rankings
|
||||
Complexity,
|
||||
|
|
@ -162,6 +191,12 @@ 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) => {
|
||||
|
|
@ -177,8 +212,6 @@ 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);
|
||||
|
|
@ -307,7 +340,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 {
|
||||
|
|
|
|||
118
src/symbols.rs
118
src/symbols.rs
|
|
@ -12,7 +12,9 @@ pub struct SymbolTotals {
|
|||
pub structs: usize,
|
||||
pub enums: usize,
|
||||
pub traits: usize,
|
||||
pub impls: usize,
|
||||
pub trait_impls: usize,
|
||||
pub consts: usize,
|
||||
pub statics: usize,
|
||||
pub parse_error_files: usize,
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +26,9 @@ pub struct FileSymbolCounts {
|
|||
pub structs: usize,
|
||||
pub enums: usize,
|
||||
pub traits: usize,
|
||||
pub impls: usize,
|
||||
pub trait_impls: usize,
|
||||
pub consts: usize,
|
||||
pub statics: usize,
|
||||
pub parse_error: bool,
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +45,7 @@ pub enum FileSymbolKind {
|
|||
Struct,
|
||||
Enum,
|
||||
Trait,
|
||||
TraitImpl,
|
||||
Const,
|
||||
Static,
|
||||
}
|
||||
|
|
@ -61,8 +66,10 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -75,8 +82,10 @@ 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 impls = file.impls.len();
|
||||
let total = functions + structs + enums + traits + impls;
|
||||
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;
|
||||
|
||||
FileSymbolCounts {
|
||||
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,
|
||||
enums,
|
||||
traits,
|
||||
impls,
|
||||
trait_impls,
|
||||
consts,
|
||||
statics,
|
||||
parse_error: file.parse_error,
|
||||
}
|
||||
})
|
||||
|
|
@ -99,7 +110,9 @@ pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolR
|
|||
acc.structs += file.structs;
|
||||
acc.enums += file.enums;
|
||||
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 {
|
||||
acc.parse_error_files += 1;
|
||||
}
|
||||
|
|
@ -128,8 +141,10 @@ pub fn analyze_symbols_file(
|
|||
structs: 0,
|
||||
enums: 0,
|
||||
traits: 0,
|
||||
trait_impls: 0,
|
||||
consts: 0,
|
||||
statics: 0,
|
||||
parse_error: false,
|
||||
symbols: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
|
@ -147,40 +162,50 @@ pub fn analyze_symbols_file(
|
|||
rows.push(FileSymbolRow {
|
||||
kind: FileSymbolKind::Struct,
|
||||
symbol: item.name.clone(),
|
||||
line_start: None,
|
||||
line_end: None,
|
||||
line_start: Some(item.line_start),
|
||||
line_end: Some(item.line_end),
|
||||
});
|
||||
}
|
||||
for item in &file_symbols.enums {
|
||||
rows.push(FileSymbolRow {
|
||||
kind: FileSymbolKind::Enum,
|
||||
symbol: item.name.clone(),
|
||||
line_start: None,
|
||||
line_end: None,
|
||||
line_start: Some(item.line_start),
|
||||
line_end: Some(item.line_end),
|
||||
});
|
||||
}
|
||||
for item in &file_symbols.traits {
|
||||
rows.push(FileSymbolRow {
|
||||
kind: FileSymbolKind::Trait,
|
||||
symbol: item.name.clone(),
|
||||
line_start: None,
|
||||
line_end: None,
|
||||
line_start: Some(item.line_start),
|
||||
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 {
|
||||
rows.push(FileSymbolRow {
|
||||
kind: FileSymbolKind::Const,
|
||||
symbol: item.name.clone(),
|
||||
line_start: None,
|
||||
line_end: None,
|
||||
line_start: Some(item.line_start),
|
||||
line_end: Some(item.line_end),
|
||||
});
|
||||
}
|
||||
for item in &file_symbols.statics {
|
||||
rows.push(FileSymbolRow {
|
||||
kind: FileSymbolKind::Static,
|
||||
symbol: item.name.clone(),
|
||||
line_start: None,
|
||||
line_end: None,
|
||||
line_start: Some(item.line_start),
|
||||
line_end: Some(item.line_end),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -188,9 +213,14 @@ 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 + consts + statics;
|
||||
let total = functions + structs + enums + traits + trait_impls + consts + statics;
|
||||
|
||||
FileSymbolReport {
|
||||
file: file_display,
|
||||
|
|
@ -199,8 +229,10 @@ pub fn analyze_symbols_file(
|
|||
structs,
|
||||
enums,
|
||||
traits,
|
||||
trait_impls,
|
||||
consts,
|
||||
statics,
|
||||
parse_error: file_symbols.parse_error,
|
||||
symbols: rows,
|
||||
}
|
||||
}
|
||||
|
|
@ -210,14 +242,16 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
|
|||
|
||||
if verbose {
|
||||
render::verbose_block(&[
|
||||
"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.",
|
||||
"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.",
|
||||
]);
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
"total".cyan(),
|
||||
report.totals.total.to_string().bold(),
|
||||
"fn".cyan(),
|
||||
|
|
@ -228,8 +262,12 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
|
|||
report.totals.enums.to_string().bold(),
|
||||
"trait".cyan(),
|
||||
report.totals.traits.to_string().bold(),
|
||||
"impl".cyan(),
|
||||
report.totals.impls.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(),
|
||||
);
|
||||
|
||||
if report.totals.parse_error_files > 0 {
|
||||
|
|
@ -242,26 +280,30 @@ pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
|
|||
|
||||
println!();
|
||||
println!(
|
||||
" {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>5}",
|
||||
" {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>11} {:>6} {:>6}",
|
||||
"file".bold(),
|
||||
"total".bold(),
|
||||
"fn".bold(),
|
||||
"struct".bold(),
|
||||
"enum".bold(),
|
||||
"trait".bold(),
|
||||
"impl".bold()
|
||||
"trait_impl".bold(),
|
||||
"const".bold(),
|
||||
"static".bold()
|
||||
);
|
||||
for file in &report.files {
|
||||
let marker = if file.parse_error { " !" } else { "" };
|
||||
println!(
|
||||
" {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>5}",
|
||||
" {:<48} {:>5} {:>5} {:>6} {:>5} {:>6} {:>11} {:>6} {:>6}",
|
||||
truncate(&format!("{}{}", file.path, marker), 48),
|
||||
file.total,
|
||||
file.functions,
|
||||
file.structs,
|
||||
file.enums,
|
||||
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 {
|
||||
render::verbose_block(&[
|
||||
"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 -.",
|
||||
"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.",
|
||||
]);
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
"total".cyan(),
|
||||
report.total.to_string().bold(),
|
||||
"fn".cyan(),
|
||||
|
|
@ -311,12 +355,23 @@ 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} {}",
|
||||
|
|
@ -344,6 +399,7 @@ 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",
|
||||
}
|
||||
|
|
|
|||
308
tests/loc_cli.rs
Normal file
308
tests/loc_cli.rs
Normal 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
296
tests/symbols_cli.rs
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue