swactor/tools/loc_analysis.py
Claude 7425e484a1 feat: add fn_complexity.py and loc_analysis.py analysis tools
fn_complexity: per-function line count, nesting depth, and unsafe detection.
loc_analysis: per-file breakdown of logic vs comment vs blank vs string lines.
Both support --json, --top N, directory recursion.

Key finding: tick_once is 196 lines (5x runner-up), core is 1,615 logic lines.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:12:15 +07:00

164 lines
5.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""Analyze line-of-code breakdown for Rust source files.
Reports: logic, comments, blank, and string-literal lines per file.
Helps track code reduction progress and identify embedded content.
Usage:
python3 tools/loc_analysis.py src/
python3 tools/loc_analysis.py src/ crates/std/src/ --json
python3 tools/loc_analysis.py src/ --sort-by logic
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
@dataclass
class FileMetrics:
file: str
total: int
logic: int
comment: int
blank: int
string_literal: int
@property
def logic_pct(self) -> float:
return (self.logic / self.total * 100) if self.total else 0.0
def analyze_file(path: str) -> FileMetrics:
"""Count line types in a Rust source file."""
with open(path) as f:
lines = f.readlines()
total = len(lines)
blank = 0
comment = 0
string_lit = 0
logic = 0
in_block_comment = False
in_raw_string = False
for line in lines:
stripped = line.strip()
if not stripped:
blank += 1
continue
# Track block comments
if in_block_comment:
comment += 1
if '*/' in stripped:
in_block_comment = False
continue
if stripped.startswith('/*'):
comment += 1
if '*/' not in stripped:
in_block_comment = True
continue
# Line comments
if stripped.startswith('//'):
comment += 1
continue
# Raw string literals (r#"..."#, r##"..."##, etc.) and regular strings
# Heuristic: line is predominantly a string if it's inside a raw string
# or contains a long string literal (>60 chars of quoted content)
if in_raw_string:
string_lit += 1
if '"#' in stripped or '"##' in stripped:
in_raw_string = False
continue
if 'r#"' in stripped or 'r##"' in stripped:
if '"#' not in stripped.split('r#"', 1)[-1] if 'r#"' in stripped else True:
in_raw_string = True
string_lit += 1
continue
# Heuristic: if the line has a long string literal, count it
string_content = re.findall(r'"([^"]*)"', stripped)
total_string_chars = sum(len(s) for s in string_content)
if total_string_chars > 60:
string_lit += 1
else:
logic += 1
return FileMetrics(
file=path,
total=total,
logic=logic,
comment=comment,
blank=blank,
string_literal=string_lit,
)
def collect_files(path: str) -> list[str]:
"""Collect .rs files from a path (file or directory)."""
p = Path(path)
if p.is_file():
return [str(p)]
elif p.is_dir():
return sorted(str(f) for f in p.rglob('*.rs'))
else:
print(f"Error: {path} is not a file or directory", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description='Rust LOC breakdown analyzer')
parser.add_argument('paths', nargs='+', help='Rust source files or directories')
parser.add_argument('--json', action='store_true', help='Output as JSON')
parser.add_argument('--sort-by', choices=['total', 'logic', 'comment', 'string_literal'],
default='total', help='Sort column')
args = parser.parse_args()
all_metrics: list[FileMetrics] = []
for path in args.paths:
for file in collect_files(path):
all_metrics.extend([analyze_file(file)])
all_metrics.sort(key=lambda m: getattr(m, args.sort_by), reverse=True)
if args.json:
print(json.dumps([asdict(m) for m in all_metrics], indent=2))
else:
# Summary
totals = FileMetrics(
file="TOTAL",
total=sum(m.total for m in all_metrics),
logic=sum(m.logic for m in all_metrics),
comment=sum(m.comment for m in all_metrics),
blank=sum(m.blank for m in all_metrics),
string_literal=sum(m.string_literal for m in all_metrics),
)
print(f"Files: {len(all_metrics)}, Total: {totals.total}, "
f"Logic: {totals.logic} ({totals.logic_pct:.1f}%), "
f"Comment: {totals.comment}, Blank: {totals.blank}, "
f"Strings: {totals.string_literal}")
print()
print(f"{'Total':>6} {'Logic':>6} {'Cmt':>5} {'Blank':>5} {'Str':>5} {'%Logic':>6} {'File'}")
print(f"{'─'*6} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*6} {'─'*45}")
for m in all_metrics:
print(f"{m.total:>6} {m.logic:>6} {m.comment:>5} {m.blank:>5} "
f"{m.string_literal:>5} {m.logic_pct:>5.1f}% {m.file}")
print(f"{'─'*6} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*6} {'─'*45}")
print(f"{totals.total:>6} {totals.logic:>6} {totals.comment:>5} {totals.blank:>5} "
f"{totals.string_literal:>5} {totals.logic_pct:>5.1f}% TOTAL")
if __name__ == '__main__':
main()