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
This commit is contained in:
parent
25e791ae9b
commit
7425e484a1
2 changed files with 345 additions and 0 deletions
181
tools/fn_complexity.py
Executable file
181
tools/fn_complexity.py
Executable file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Analyze per-function complexity metrics for Rust source files.
|
||||
|
||||
Reports: function name, line count, max nesting depth, and file location.
|
||||
Sorted by line count (descending) to surface the largest functions first.
|
||||
|
||||
Usage:
|
||||
python3 tools/fn_complexity.py src/worker.rs
|
||||
python3 tools/fn_complexity.py src/ # recurse into directory
|
||||
python3 tools/fn_complexity.py src/ --json # JSON output
|
||||
python3 tools/fn_complexity.py src/ --min-lines 20 # filter small fns
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FnMetric:
|
||||
file: str
|
||||
name: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
lines: int
|
||||
max_depth: int
|
||||
has_unsafe: bool
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
return f"{self.file}:{self.start_line}"
|
||||
|
||||
|
||||
# Matches fn declarations (free functions, methods, trait impls)
|
||||
FN_PATTERN = re.compile(
|
||||
r'^\s*(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+(\w+)'
|
||||
)
|
||||
|
||||
# Matches impl blocks to qualify method names
|
||||
IMPL_PATTERN = re.compile(
|
||||
r'^\s*impl(?:<[^>]*>)?\s+(?:(\w+(?:<[^>]*>)?)\s+for\s+)?(\w+)'
|
||||
)
|
||||
|
||||
|
||||
def analyze_file(path: str) -> list[FnMetric]:
|
||||
"""Parse a single Rust file and extract function metrics."""
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
metrics = []
|
||||
current_impl = None
|
||||
brace_depth = 0
|
||||
fn_stack: list[tuple[str, int, int, bool]] = [] # (name, start_line, start_depth, has_unsafe)
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.rstrip()
|
||||
|
||||
# Track impl blocks for method qualification
|
||||
impl_match = IMPL_PATTERN.match(stripped)
|
||||
if impl_match and '{' in stripped:
|
||||
trait_name = impl_match.group(1)
|
||||
type_name = impl_match.group(2)
|
||||
if trait_name:
|
||||
current_impl = f"{trait_name} for {type_name}"
|
||||
else:
|
||||
current_impl = type_name
|
||||
|
||||
# Detect function start
|
||||
fn_match = FN_PATTERN.match(stripped)
|
||||
if fn_match and '{' in stripped:
|
||||
fn_name = fn_match.group(1)
|
||||
if current_impl:
|
||||
fn_name = f"{current_impl}::{fn_name}"
|
||||
has_unsafe = 'unsafe' in stripped
|
||||
fn_stack.append((fn_name, i, brace_depth, has_unsafe))
|
||||
|
||||
# Track brace depth
|
||||
# Simple brace counting (ignores braces in strings/comments, good enough)
|
||||
opens = stripped.count('{')
|
||||
closes = stripped.count('}')
|
||||
brace_depth += opens - closes
|
||||
|
||||
# Check for unsafe blocks within functions
|
||||
if fn_stack and 'unsafe' in stripped and fn_match is None:
|
||||
name, start, depth, _ = fn_stack[-1]
|
||||
fn_stack[-1] = (name, start, depth, True)
|
||||
|
||||
# When a function's brace depth returns to entry level, it's done
|
||||
while fn_stack and brace_depth <= fn_stack[-1][2]:
|
||||
fn_name, start_line, _, has_unsafe = fn_stack.pop()
|
||||
end_line = i
|
||||
fn_lines = end_line - start_line + 1
|
||||
|
||||
# Calculate max nesting depth within this function
|
||||
max_depth = 0
|
||||
local_depth = 0
|
||||
for j in range(start_line - 1, end_line):
|
||||
if j < len(lines):
|
||||
local_depth += lines[j].count('{') - lines[j].count('}')
|
||||
max_depth = max(max_depth, local_depth)
|
||||
|
||||
# Reset impl context if we've left the impl block
|
||||
if brace_depth == 0:
|
||||
current_impl = None
|
||||
|
||||
metrics.append(FnMetric(
|
||||
file=path,
|
||||
name=fn_name,
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
lines=fn_lines,
|
||||
max_depth=max_depth,
|
||||
has_unsafe=has_unsafe,
|
||||
))
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
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 function complexity 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('--min-lines', type=int, default=0,
|
||||
help='Only show functions with at least N lines')
|
||||
parser.add_argument('--top', type=int, default=0,
|
||||
help='Show only the top N largest functions')
|
||||
args = parser.parse_args()
|
||||
|
||||
all_metrics: list[FnMetric] = []
|
||||
for path in args.paths:
|
||||
for file in collect_files(path):
|
||||
all_metrics.extend(analyze_file(file))
|
||||
|
||||
# Filter and sort
|
||||
if args.min_lines:
|
||||
all_metrics = [m for m in all_metrics if m.lines >= args.min_lines]
|
||||
all_metrics.sort(key=lambda m: m.lines, reverse=True)
|
||||
if args.top:
|
||||
all_metrics = all_metrics[:args.top]
|
||||
|
||||
if args.json:
|
||||
output = [asdict(m) for m in all_metrics]
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
# Summary stats
|
||||
if all_metrics:
|
||||
total_fns = len(all_metrics)
|
||||
avg_lines = sum(m.lines for m in all_metrics) / total_fns
|
||||
max_fn = all_metrics[0]
|
||||
|
||||
print(f"Functions: {total_fns}, avg lines: {avg_lines:.1f}, "
|
||||
f"largest: {max_fn.name} ({max_fn.lines} lines)")
|
||||
print()
|
||||
|
||||
# Table output
|
||||
print(f"{'Lines':>5} {'Depth':>5} {'Location':<45} {'Function'}")
|
||||
print(f"{'─'*5} {'─'*5} {'─'*45} {'─'*40}")
|
||||
for m in all_metrics:
|
||||
loc = f"{m.file}:{m.start_line}"
|
||||
unsafe_marker = " [unsafe]" if m.has_unsafe else ""
|
||||
print(f"{m.lines:>5} {m.max_depth:>5} {loc:<45} {m.name}{unsafe_marker}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
164
tools/loc_analysis.py
Executable file
164
tools/loc_analysis.py
Executable file
|
|
@ -0,0 +1,164 @@
|
|||
#!/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()
|
||||
Loading…
Reference in a new issue