diff --git a/tools/fn_complexity.py b/tools/fn_complexity.py new file mode 100755 index 0000000..84212cb --- /dev/null +++ b/tools/fn_complexity.py @@ -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() diff --git a/tools/loc_analysis.py b/tools/loc_analysis.py new file mode 100755 index 0000000..c7e6de2 --- /dev/null +++ b/tools/loc_analysis.py @@ -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()