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
e21a4ae19c
commit
d370a41cf1
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