From b9ceb8169cc3c3e7ce57ad153d177912fa9a0feb Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 19 Nov 2025 10:58:38 -0600 Subject: [PATCH] feat(loc): extend LOC cmd with file ranking and directory aggregation --- README | 16 +- crates/cli/src/commands/loc.rs | 129 ++++++++++++++ crates/cli/src/commands/mod.rs | 1 + crates/cli/src/main.rs | 95 ++++++---- crates/core/src/cloner/rolling_hash.rs | 2 +- crates/core/src/complexity/loc.rs | 235 +++++++++++++++++++++++++ docs/src/lines-of-code.md | 138 ++++++++++++--- 7 files changed, 554 insertions(+), 62 deletions(-) create mode 100644 crates/cli/src/commands/loc.rs diff --git a/README b/README index 8bdabf4..426b8ef 100644 --- a/README +++ b/README @@ -55,11 +55,16 @@ Purpose: - Quickly identifies copy/paste blocks and boilerplate. ------------------------------------------------------------------------------- 3. Lines of Code (LOC) -- Counts executable or logical lines. +- Counts physical, logical, comment, and blank lines. +- Token-based classification for accuracy. +- Supports ranking by any metric (logical, physical, comments, blank). +- Can aggregate and rank by directory. - Required for future Maintainability Index calculations. Purpose: - Baseline size metric. +- Identify largest files and directories. +- Track codebase growth and comment density. ------------------------------------------------------------------------------- 4. Halstead Complexity Metrics - Based on counts of operators and operands. @@ -137,8 +142,13 @@ core -------------------------------------------------------------------------------- cli - - commands: analyze, clones, complexity, dump-config - - flags: --json, --threshold, --min-tokens, --sort, --path + - commands: analyze, clones, complexity, loc, dump-config + - analyze: full analysis (complexity + clones + LOC) + - clones: detect code clones only + - complexity: analyze cyclomatic complexity and LOC + - loc: focused LOC analysis with ranking (by file or directory) + - dump-config: display or save current configuration + - flags: --json, --threshold, --min-tokens, --rank-by, --rank-dirs, --path =============================================================================== OUTPUT FORMATS diff --git a/crates/cli/src/commands/loc.rs b/crates/cli/src/commands/loc.rs new file mode 100644 index 0000000..f384778 --- /dev/null +++ b/crates/cli/src/commands/loc.rs @@ -0,0 +1,129 @@ +use anyhow::Result; +use mccabre_core::{ + complexity::loc::{FileLocReport, LocMetrics, LocReport, RankBy}, + config::Config, + loader::FileLoader, +}; +use owo_colors::OwoColorize; +use std::path::PathBuf; + +pub fn run( + path: PathBuf, json: bool, rank_by: RankBy, rank_dirs: bool, config_path: Option, respect_gitignore: bool, +) -> Result<()> { + let config = if let Some(config_path) = config_path { + Config::from_file(config_path)? + } else { + Config::load_default()? + }; + + let config = config.merge_with_cli(None, None, Some(respect_gitignore)); + let loader = FileLoader::new().with_gitignore(config.files.respect_gitignore); + let files = loader.load(&path)?; + + if files.is_empty() { + eprintln!("{}", "No supported files found".yellow()); + return Ok(()); + } + + let mut file_reports = Vec::new(); + + for file in &files { + let metrics = LocMetrics::calculate(&file.content, file.language)?; + file_reports.push(FileLocReport { path: file.path.clone(), metrics }); + } + + let report = LocReport::new(file_reports, rank_by, rank_dirs); + + if json { + println!("{}", report.to_json()?); + } else { + print_loc_report(&report, rank_by, rank_dirs); + } + + Ok(()) +} + +fn print_loc_report(report: &LocReport, rank_by: RankBy, rank_dirs: bool) { + println!("{}", "=".repeat(80).cyan()); + println!("{}", "LINES OF CODE ANALYSIS".cyan().bold()); + println!("{}\n", "=".repeat(80).cyan()); + + println!("{}", "SUMMARY".green().bold()); + println!("{}", "-".repeat(80).cyan()); + println!("Total files analyzed: {}", report.summary.total_files.bold()); + println!("Total physical LOC: {}", report.summary.total_physical.bold()); + println!("Total logical LOC: {}", report.summary.total_logical.bold()); + println!("Total comment lines: {}", report.summary.total_comments.bold()); + println!("Total blank lines: {}\n", report.summary.total_blank.bold()); + + let rank_label = match rank_by { + RankBy::Logical => "Logical LOC", + RankBy::Physical => "Physical LOC", + RankBy::Comments => "Comment Lines", + RankBy::Blank => "Blank Lines", + }; + + if rank_dirs { + if let Some(directories) = &report.directories { + println!( + "{} {}", + "DIRECTORIES RANKED BY".green().bold(), + rank_label.green().bold() + ); + println!("{}\n", "-".repeat(80).cyan()); + + for dir in directories { + println!("{} {}", "DIRECTORY:".blue().bold(), dir.path.display().bold()); + println!( + " Total Physical: {} | Logical: {} | Comments: {} | Blank: {}", + dir.total.physical.bold(), + dir.total.logical.bold(), + dir.total.comments.bold(), + dir.total.blank.bold() + ); + println!(); + + if !dir.files.is_empty() { + println!(" {}:", "Files".magenta()); + for file in &dir.files { + let filename = file.path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"); + + let rank_value = rank_by.value_from(&file.metrics); + println!( + " {} ({}: {}) - P: {} | L: {} | C: {} | B: {}", + filename, + rank_label.dimmed(), + rank_value.to_string().yellow(), + file.metrics.physical, + file.metrics.logical, + file.metrics.comments, + file.metrics.blank + ); + } + println!(); + } + } + } + } else { + println!("{} {}", "FILES RANKED BY".green().bold(), rank_label.green().bold()); + println!("{}\n", "-".repeat(80).cyan()); + + for (idx, file) in report.files.iter().enumerate() { + let rank_value = rank_by.value_from(&file.metrics); + println!( + "{}. {} ({}: {})", + (idx + 1).to_string().dimmed(), + file.path.display().bold(), + rank_label.dimmed(), + rank_value.to_string().yellow() + ); + println!( + " Physical: {} | Logical: {} | Comments: {} | Blank: {}", + file.metrics.physical, file.metrics.logical, file.metrics.comments, file.metrics.blank + ); + println!(); + } + } + + println!("{}", "=".repeat(80).cyan()); +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 1d36a08..fefd5d6 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod analyze; pub mod clones; pub mod complexity; pub mod dump_config; +pub mod loc; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index bc4e6e1..2b99f42 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -107,47 +107,72 @@ enum Commands { #[arg(short = 'o', long)] output: Option, }, + + /// Analyze lines of code with ranking + Loc { + /// Path to file or directory to analyze + #[arg(value_name = "PATH", default_value = ".")] + path: PathBuf, + + /// Output in JSON format + #[arg(short, long)] + json: bool, + + /// Rank by criteria: logical, physical, comments, blank + #[arg(long, default_value = "logical")] + rank_by: String, + + /// Rank directories (with files ranked within each) + #[arg(long)] + rank_dirs: bool, + + /// Path to config file + #[arg(short, long)] + config: Option, + + /// Disable gitignore awareness + #[arg(long)] + no_gitignore: bool, + }, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Analyze { - path, - json, - threshold, - min_tokens, - config, - no_gitignore, - no_highlight, - } => commands::analyze::run( - path, - json, - threshold, - Some(min_tokens), - config, - !no_gitignore, - !no_highlight, - ), - - Commands::Complexity { - path, - json, - threshold, - config, - no_gitignore, - } => commands::complexity::run(path, json, threshold, config, !no_gitignore), - - Commands::Clones { - path, - json, - min_tokens, - config, - no_gitignore, - no_highlight, - } => commands::clones::run(path, json, Some(min_tokens), config, !no_gitignore, !no_highlight), - + Commands::Analyze { path, json, threshold, min_tokens, config, no_gitignore, no_highlight } => { + commands::analyze::run( + path, + json, + threshold, + Some(min_tokens), + config, + !no_gitignore, + !no_highlight, + ) + } + Commands::Complexity { path, json, threshold, config, no_gitignore } => { + commands::complexity::run(path, json, threshold, config, !no_gitignore) + } + Commands::Clones { path, json, min_tokens, config, no_gitignore, no_highlight } => { + commands::clones::run(path, json, Some(min_tokens), config, !no_gitignore, !no_highlight) + } Commands::DumpConfig { config, output } => commands::dump_config::run(config, output), + Commands::Loc { path, json, rank_by, rank_dirs, config, no_gitignore } => { + use mccabre_core::complexity::loc::RankBy; + + let rank_by = match rank_by.to_lowercase().as_str() { + "logical" => RankBy::Logical, + "physical" => RankBy::Physical, + "comments" => RankBy::Comments, + "blank" => RankBy::Blank, + _ => { + eprintln!("Invalid rank_by value. Use: logical, physical, comments, or blank"); + std::process::exit(1); + } + }; + + commands::loc::run(path, json, rank_by, rank_dirs, config, !no_gitignore) + } } } diff --git a/crates/core/src/cloner/rolling_hash.rs b/crates/core/src/cloner/rolling_hash.rs index 44058d0..bd2024c 100644 --- a/crates/core/src/cloner/rolling_hash.rs +++ b/crates/core/src/cloner/rolling_hash.rs @@ -140,7 +140,7 @@ mod tests { #[test] fn test_rolling_preserves_pattern() { let mut rh = RollingHash::new(3); - let values = vec![1, 2, 3, 4, 5, 6, 1, 2, 3]; + let values = [1, 2, 3, 4, 5, 6, 1, 2, 3]; rh.init(&values[0..3]); let first_hash = rh.get(); diff --git a/crates/core/src/complexity/loc.rs b/crates/core/src/complexity/loc.rs index a351a88..5302649 100644 --- a/crates/core/src/complexity/loc.rs +++ b/crates/core/src/complexity/loc.rs @@ -1,6 +1,8 @@ use crate::Result; use crate::tokenizer::{Language, TokenType, Tokenizer}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LineKind { @@ -59,6 +61,145 @@ impl LocMetrics { Ok(LocMetrics { physical, logical, comments, blank }) } + + /// Add two LocMetrics together + fn add(&self, other: &Self) -> Self { + Self { + physical: self.physical + other.physical, + logical: self.logical + other.logical, + comments: self.comments + other.comments, + blank: self.blank + other.blank, + } + } +} + +/// Ranking criteria for LOC analysis +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RankBy { + /// Rank by logical lines of code + Logical, + /// Rank by physical lines of code + Physical, + /// Rank by comment lines + Comments, + /// Rank by blank lines + Blank, +} + +impl RankBy { + /// Get the value from LocMetrics based on ranking criteria + pub fn value_from(&self, metrics: &LocMetrics) -> usize { + match self { + Self::Logical => metrics.logical, + Self::Physical => metrics.physical, + Self::Comments => metrics.comments, + Self::Blank => metrics.blank, + } + } +} + +/// LOC metrics for a single file with path information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileLocReport { + /// File path + pub path: PathBuf, + /// LOC metrics + pub metrics: LocMetrics, +} + +/// Aggregated LOC metrics for a directory +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectoryLocMetrics { + /// Directory path + pub path: PathBuf, + /// Total LOC metrics for all files in this directory + pub total: LocMetrics, + /// Files in this directory + pub files: Vec, +} + +/// Complete LOC analysis report with ranking capabilities +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocReport { + /// Per-file reports + pub files: Vec, + /// Per-directory aggregation (if enabled) + pub directories: Option>, + /// Summary statistics + pub summary: LocSummary, +} + +/// Summary statistics for LOC report +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocSummary { + /// Total number of files analyzed + pub total_files: usize, + /// Total physical lines of code + pub total_physical: usize, + /// Total logical lines of code + pub total_logical: usize, + /// Total comment lines + pub total_comments: usize, + /// Total blank lines + pub total_blank: usize, +} + +impl LocReport { + /// Create a new LOC report from file reports + pub fn new(mut files: Vec, rank_by: RankBy, rank_dirs: bool) -> Self { + files.sort_by(|a, b| rank_by.value_from(&b.metrics).cmp(&rank_by.value_from(&a.metrics))); + + let directories = if rank_dirs { Some(Self::aggregate_by_directory(&files, rank_by)) } else { None }; + let summary = LocSummary::from_files(&files); + + Self { files, directories, summary } + } + + /// Aggregate files by directory + fn aggregate_by_directory(files: &[FileLocReport], rank_by: RankBy) -> Vec { + let mut dir_map: HashMap> = HashMap::new(); + + for file in files { + let dir = file.path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(); + dir_map.entry(dir).or_default().push(file.clone()); + } + + let mut directories: Vec = dir_map + .into_iter() + .map(|(path, files)| { + let total = files.iter().fold( + LocMetrics { physical: 0, logical: 0, comments: 0, blank: 0 }, + |acc, f| acc.add(&f.metrics), + ); + + let mut sorted_files = files; + sorted_files.sort_by(|a, b| rank_by.value_from(&b.metrics).cmp(&rank_by.value_from(&a.metrics))); + + DirectoryLocMetrics { path, total, files: sorted_files } + }) + .collect(); + + directories.sort_by(|a, b| rank_by.value_from(&b.total).cmp(&rank_by.value_from(&a.total))); + + directories + } + + /// Serialize to JSON + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string_pretty(self) + } +} + +impl LocSummary { + fn from_files(files: &[FileLocReport]) -> Self { + let total_files = files.len(); + let total_physical = files.iter().map(|f| f.metrics.physical).sum(); + let total_logical = files.iter().map(|f| f.metrics.logical).sum(); + let total_comments = files.iter().map(|f| f.metrics.comments).sum(); + let total_blank = files.iter().map(|f| f.metrics.blank).sum(); + + Self { total_files, total_physical, total_logical, total_comments, total_blank } + } } #[cfg(test)] @@ -136,4 +277,98 @@ function hello() { assert!(metrics.comments >= 3); assert_eq!(metrics.logical, 0); } + + #[test] + fn test_loc_metrics_add() { + let m1 = LocMetrics { physical: 10, logical: 8, comments: 1, blank: 1 }; + let m2 = LocMetrics { physical: 20, logical: 15, comments: 3, blank: 2 }; + let result = m1.add(&m2); + + assert_eq!(result.physical, 30); + assert_eq!(result.logical, 23); + assert_eq!(result.comments, 4); + assert_eq!(result.blank, 3); + } + + #[test] + fn test_rank_by_value_from() { + let metrics = LocMetrics { physical: 100, logical: 80, comments: 10, blank: 10 }; + + assert_eq!(RankBy::Physical.value_from(&metrics), 100); + assert_eq!(RankBy::Logical.value_from(&metrics), 80); + assert_eq!(RankBy::Comments.value_from(&metrics), 10); + assert_eq!(RankBy::Blank.value_from(&metrics), 10); + } + + #[test] + fn test_loc_report_new() { + let files = vec![ + FileLocReport { + path: PathBuf::from("test1.rs"), + metrics: LocMetrics { physical: 100, logical: 80, comments: 10, blank: 10 }, + }, + FileLocReport { + path: PathBuf::from("test2.rs"), + metrics: LocMetrics { physical: 50, logical: 40, comments: 5, blank: 5 }, + }, + ]; + + let report = LocReport::new(files, RankBy::Logical, false); + + assert_eq!(report.summary.total_files, 2); + assert_eq!(report.summary.total_physical, 150); + assert_eq!(report.summary.total_logical, 120); + assert_eq!(report.summary.total_comments, 15); + assert_eq!(report.summary.total_blank, 15); + + assert_eq!(report.files[0].metrics.logical, 80); + assert_eq!(report.files[1].metrics.logical, 40); + } + + #[test] + fn test_loc_report_with_directories() { + let files = vec![ + FileLocReport { + path: PathBuf::from("src/main.rs"), + metrics: LocMetrics { physical: 100, logical: 80, comments: 10, blank: 10 }, + }, + FileLocReport { + path: PathBuf::from("src/lib.rs"), + metrics: LocMetrics { physical: 50, logical: 40, comments: 5, blank: 5 }, + }, + FileLocReport { + path: PathBuf::from("tests/test.rs"), + metrics: LocMetrics { physical: 30, logical: 25, comments: 3, blank: 2 }, + }, + ]; + + let report = LocReport::new(files, RankBy::Logical, true); + + assert!(report.directories.is_some()); + let dirs = report.directories.unwrap(); + assert_eq!(dirs.len(), 2); + + assert_eq!(dirs[0].path, PathBuf::from("src")); + assert_eq!(dirs[0].total.logical, 120); + assert_eq!(dirs[0].files.len(), 2); + + assert_eq!(dirs[1].path, PathBuf::from("tests")); + assert_eq!(dirs[1].total.logical, 25); + assert_eq!(dirs[1].files.len(), 1); + } + + #[test] + fn test_loc_report_to_json() { + let files = vec![FileLocReport { + path: PathBuf::from("test.rs"), + metrics: LocMetrics { physical: 10, logical: 8, comments: 1, blank: 1 }, + }]; + + let report = LocReport::new(files, RankBy::Logical, false); + let json = report.to_json().unwrap(); + + assert!(json.contains("files")); + assert!(json.contains("summary")); + assert!(json.contains("test.rs")); + } } diff --git a/docs/src/lines-of-code.md b/docs/src/lines-of-code.md index fa8cf22..f9441ac 100644 --- a/docs/src/lines-of-code.md +++ b/docs/src/lines-of-code.md @@ -71,31 +71,97 @@ Compare logical vs physical LOC: ## Using LOC with Mccabre -### Basic Usage +### Commands + +Mccabre provides multiple ways to analyze LOC: ```bash -# Analyze LOC for a directory +# Dedicated LOC analysis with ranking +mccabre loc src/ + +# Full analysis including complexity and clones mccabre analyze src/ -# Complexity command also includes LOC +# Complexity analysis includes LOC mccabre complexity src/ ``` +### The `loc` Command + +The `loc` command provides focused LOC analysis with powerful ranking capabilities. + +#### Basic Usage + +```bash +# Rank files by logical LOC (default) +mccabre loc src/ + +# Rank files by physical LOC +mccabre loc src/ --rank-by physical + +# Rank files by comments +mccabre loc src/ --rank-by comments + +# Rank files by blank lines +mccabre loc src/ --rank-by blank +``` + +#### Directory Ranking + +Group and rank files by directory: + +```bash +# Rank directories by total LOC, files ranked within each +mccabre loc src/ --rank-dirs + +# Rank directories by comments +mccabre loc src/ --rank-dirs --rank-by comments +``` + ### Sample Output +#### File Ranking + +```text +LINES OF CODE ANALYSIS + +SUMMARY +Total files analyzed: 12 +Total physical LOC: 2246 +Total logical LOC: 1360 +Total comment lines: 135 +Total blank lines: 751 + +FILES RANKED BY Logical LOC + +1. crates/core/src/complexity/loc.rs (Logical LOC: 297) + Physical: 403 | Logical: 297 | Comments: 37 | Blank: 69 + +2. crates/core/src/reporter.rs (Logical LOC: 212) + Physical: 260 | Logical: 212 | Comments: 16 | Blank: 32 +``` + +#### Directory Ranking + ```text -FILE: src/main.rs - Cyclomatic Complexity: 5 - Physical LOC: 120 - Logical LOC: 85 - Comment lines: 25 - Blank lines: 10 +DIRECTORIES RANKED BY Logical LOC + +DIRECTORY: crates/core/src + Total Physical: 1126 | Logical: 583 | Comments: 42 | Blank: 501 + + Files: + reporter.rs (Logical LOC: 212) - P: 260 | L: 212 | C: 16 | B: 32 + loader.rs (Logical LOC: 150) - P: 204 | L: 150 | C: 8 | B: 46 ``` -### JSON +### JSON Output ```bash -mccabre analyze src/ --json +# File ranking as JSON +mccabre loc src/ --json + +# Directory ranking as JSON +mccabre loc src/ --rank-dirs --json ``` ```json @@ -103,14 +169,22 @@ mccabre analyze src/ --json "files": [ { "path": "src/main.rs", - "loc": { + "metrics": { "physical": 120, "logical": 85, "comments": 25, "blank": 10 } } - ] + ], + "directories": null, + "summary": { + "total_files": 1, + "total_physical": 120, + "total_logical": 85, + "total_comments": 25, + "total_blank": 10 + } } ``` @@ -158,33 +232,51 @@ LOC counts everything, including: Use `.gitignore` to exclude these (Mccabre respects gitignore). -## Tracking LOC Over Time +## Advanced Use Cases -### Baseline +### Finding the Largest Files ```bash -# Create baseline -mccabre analyze src/ --json > baseline.json +# Top 10 largest files by logical LOC +mccabre loc src/ --rank-by logical | head -30 ``` -### Compare +### Identifying Under-Commented Code ```bash +# Files ranked by comment count (ascending) +mccabre loc src/ --rank-by comments +``` + +### Directory Hotspots + +```bash +# Find directories with the most code +mccabre loc . --rank-dirs --rank-by logical +``` + +### Tracking LOC Over Time + +```bash +# Create baseline +mccabre loc src/ --json > baseline.json + # Later... -mccabre analyze src/ --json > current.json +mccabre loc src/ --json > current.json -# Compare (using jq) -jq '.summary.total_logical_loc' baseline.json -jq '.summary.total_logical_loc' current.json +# Compare using jq +jq '.summary.total_logical' baseline.json +jq '.summary.total_logical' current.json ``` -### Visualize Growth +### CI Integration Integrate with your CI to track: - LOC growth per sprint - LOC per feature - Comment ratio trends +- Detect large file additions ## See Also -- 2.51.2