1907 lines
79 KiB
Python
1907 lines
79 KiB
Python
#!/usr/bin/env python3
|
|
import gc
|
|
import os
|
|
import re
|
|
import sys
|
|
import json
|
|
import time
|
|
import argparse
|
|
import statistics
|
|
import subprocess
|
|
import platform
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, wait, FIRST_COMPLETED
|
|
import fnmatch
|
|
|
|
VERSION = "2.1.2"
|
|
|
|
# Windows Terminal Fix for ANSI codes
|
|
if sys.platform == "win32":
|
|
os.system("")
|
|
|
|
# Ensure UTF-8 output on Windows
|
|
if sys.stdout.encoding != 'utf-8':
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
if sys.stderr.encoding != 'utf-8':
|
|
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
|
|
HELP_TEXT = """pjstats - Enhanced project statistics with multi-threaded analysis.
|
|
|
|
Usage examples:
|
|
python3 pjstats.py
|
|
python3 pjstats.py /path/to/project --top 10 --json > pjstats.json
|
|
python3 pjstats.py --ext .py .js --min-code-lines 10
|
|
python3 pjstats.py --exclude language_model .git
|
|
python3 pjstats.py --no-color --no-git
|
|
python3 pjstats.py --workers 4 --no-animation
|
|
|
|
Options:
|
|
path Project root (default: .)
|
|
--json Output JSON summary
|
|
--top N Top N files to display (default: 5)
|
|
--ext Limit to specific extensions (e.g. .py .js)
|
|
--min-code-lines Ignore files with fewer code lines than this
|
|
--exclude Directories to ignore recursively (relative to project root)
|
|
--include-hidden Include hidden directories/files
|
|
--no-color Disable color output
|
|
--no-git Disable Git info detection
|
|
--no-animation Disable startup animation and progress bars
|
|
--bypass-excluded Bypass all ignore rules (.gitignore, built-in excludes, etc.)
|
|
--workers N Number of threads for parallel analysis (0=auto)
|
|
|
|
Notes:
|
|
- Files named Dockerfile.* are treated as Dockerfiles.
|
|
- Languages without a comment token (e.g., Markdown, JSON) are shown as N/A in the Code Quality Snapshot.
|
|
- The installer chooses a sensible default install directory for your OS (Unix: ~/.local/bin, Windows: %LOCALAPPDATA%/Programs/pjstats or %USERPROFILE%/Scripts) if `--install-dir` is not specified.
|
|
|
|
"""
|
|
|
|
# --------------------------------------------------
|
|
# Configuration
|
|
# --------------------------------------------------
|
|
|
|
# files to check for ignore patterns
|
|
IGNORE_FILES_TO_CHECK = ['.gitignore', '.ignore', '.dockerignore', '.npmignore']
|
|
|
|
CODE_LANGUAGES = {
|
|
# JavaScript / TypeScript
|
|
".js": ("JavaScript", "//", "code"),
|
|
".jsx": ("JavaScript (JSX)", "//", "code"),
|
|
".ts": ("TypeScript", "//", "code"),
|
|
".tsx": ("TypeScript (TSX)", "//", "code"),
|
|
".mjs": ("JavaScript (ESM)", "//", "code"),
|
|
".cjs": ("JavaScript (CJS)", "//", "code"),
|
|
# Python
|
|
".py": ("Python", "#", "code"),
|
|
".pyw": ("Python", "#", "code"),
|
|
".pyi": ("Python Stub", "#", "code"),
|
|
# Shell
|
|
".sh": ("Shell", "#", "code"),
|
|
".bash": ("Bash", "#", "code"),
|
|
".zsh": ("Zsh", "#", "code"),
|
|
".fish": ("Fish", "#", "code"),
|
|
".ksh": ("KornShell", "#", "code"),
|
|
# PowerShell
|
|
".ps1": ("PowerShell", "#", "code"),
|
|
".psm1": ("PowerShell Module", "#", "code"),
|
|
".psd1": ("PowerShell Data", "#", "code"),
|
|
# Batch / CMD
|
|
".bat": ("Batch", "REM", "code"),
|
|
".cmd": ("Batch", "REM", "code"),
|
|
# Web
|
|
".html": ("HTML", "<!--", "code"),
|
|
".htm": ("HTML", "<!--", "code"),
|
|
".css": ("CSS", "/*", "code"),
|
|
".scss": ("SCSS", "//", "code"),
|
|
".sass": ("Sass", "//", "code"),
|
|
".less": ("Less", "//", "code"),
|
|
".styl": ("Stylus", "//", "code"),
|
|
# Web Frameworks
|
|
".vue": ("Vue", "//", "code"),
|
|
".svelte": ("Svelte", "//", "code"),
|
|
".astro": ("Astro", "//", "code"),
|
|
# Dart / Flutter
|
|
".dart": ("Dart", "//", "code"),
|
|
# C-Family
|
|
".c": ("C", "//", "code"),
|
|
".h": ("C Header", "//", "code"),
|
|
".cpp": ("C++", "//", "code"),
|
|
".hpp": ("C++ Header", "//", "code"),
|
|
".cc": ("C++", "//", "code"),
|
|
".cxx": ("C++", "//", "code"),
|
|
".hxx": ("C++ Header", "//", "code"),
|
|
".c++": ("C++", "//", "code"),
|
|
".h++": ("C++ Header", "//", "code"),
|
|
".cs": ("C#", "//", "code"),
|
|
".m": ("Objective-C", "//", "code"),
|
|
".mm": ("Objective-C++", "//", "code"),
|
|
# Java / JVM
|
|
".java": ("Java", "//", "code"),
|
|
".kt": ("Kotlin", "//", "code"),
|
|
".kts": ("Kotlin Script", "//", "code"),
|
|
".groovy": ("Groovy", "//", "code"),
|
|
".scala": ("Scala", "//", "code"),
|
|
".clj": ("Clojure", ";;", "code"),
|
|
".cljs": ("ClojureScript", ";;", "code"),
|
|
".cljc": ("Clojure", ";;", "code"),
|
|
# Apple / iOS
|
|
".swift": ("Swift", "//", "code"),
|
|
# Build systems
|
|
".gradle": ("Gradle", "//", "code"),
|
|
".cmake": ("CMake", "#", "code"),
|
|
# Go
|
|
".go": ("Go", "//", "code"),
|
|
# Rust
|
|
".rs": ("Rust", "//", "code"),
|
|
# PHP
|
|
".php": ("PHP", "//", "code"),
|
|
# Ruby
|
|
".rb": ("Ruby", "#", "code"),
|
|
".rake": ("Ruby", "#", "code"),
|
|
".gemspec": ("Ruby", "#", "code"),
|
|
# Lua
|
|
".lua": ("Lua", "--", "code"),
|
|
# SQL
|
|
".sql": ("SQL", "--", "code"),
|
|
# R
|
|
".r": ("R", "#", "code"),
|
|
".R": ("R", "#", "code"),
|
|
".rmd": ("R Markdown", "#", "code"),
|
|
# Perl
|
|
".pl": ("Perl", "#", "code"),
|
|
".pm": ("Perl", "#", "code"),
|
|
".t": ("Perl Test", "#", "code"),
|
|
# Elixir / Erlang
|
|
".ex": ("Elixir", "#", "code"),
|
|
".exs": ("Elixir Script", "#", "code"),
|
|
".erl": ("Erlang", "%", "code"),
|
|
".hrl": ("Erlang Header", "%", "code"),
|
|
# Haskell
|
|
".hs": ("Haskell", "--", "code"),
|
|
".lhs": ("Literate Haskell", "--", "code"),
|
|
# OCaml
|
|
".ml": ("OCaml", "(*", "code"),
|
|
".mli": ("OCaml Interface", "(*", "code"),
|
|
# F#
|
|
".fs": ("F#", "//", "code"),
|
|
".fsx": ("F# Script", "//", "code"),
|
|
".fsi": ("F# Signature", "//", "code"),
|
|
# Nim
|
|
".nim": ("Nim", "#", "code"),
|
|
".nims": ("Nim Script", "#", "code"),
|
|
# Zig
|
|
".zig": ("Zig", "//", "code"),
|
|
# V
|
|
".v": ("V", "//", "code"),
|
|
# Crystal
|
|
".cr": ("Crystal", "#", "code"),
|
|
# Julia
|
|
".jl": ("Julia", "#", "code"),
|
|
# Elm
|
|
".elm": ("Elm", "--", "code"),
|
|
# PureScript
|
|
".purs": ("PureScript", "--", "code"),
|
|
# Fortran
|
|
".f": ("Fortran", "!", "code"),
|
|
".f90": ("Fortran", "!", "code"),
|
|
".f95": ("Fortran", "!", "code"),
|
|
".f03": ("Fortran", "!", "code"),
|
|
".f08": ("Fortran", "!", "code"),
|
|
".for": ("Fortran", "!", "code"),
|
|
# COBOL
|
|
".cob": ("COBOL", "*", "code"),
|
|
".cbl": ("COBOL", "*", "code"),
|
|
# Pascal / Delphi
|
|
".pas": ("Pascal", "//", "code"),
|
|
".dpr": ("Delphi", "//", "code"),
|
|
".dpk": ("Delphi", "//", "code"),
|
|
# D
|
|
".d": ("D", "//", "code"),
|
|
# Ada
|
|
".adb": ("Ada", "--", "code"),
|
|
".ads": ("Ada", "--", "code"),
|
|
# Lisp / Scheme / Racket
|
|
".lisp": ("Lisp", ";;", "code"),
|
|
".lsp": ("Lisp", ";;", "code"),
|
|
".cl": ("Common Lisp", ";;", "code"),
|
|
".scm": ("Scheme", ";;", "code"),
|
|
".ss": ("Scheme", ";;", "code"),
|
|
".rkt": ("Racket", ";;", "code"),
|
|
# Prolog
|
|
".pro": ("Prolog", "%", "code"),
|
|
# Tcl
|
|
".tcl": ("Tcl", "#", "code"),
|
|
# VB.NET / VBScript
|
|
".vb": ("VB.NET", "'", "code"),
|
|
".vbs": ("VBScript", "'", "code"),
|
|
# Assembly
|
|
".asm": ("Assembly", ";", "code"),
|
|
".s": ("Assembly", ";", "code"),
|
|
# VHDL / SystemVerilog
|
|
".vhd": ("VHDL", "--", "code"),
|
|
".vhdl": ("VHDL", "--", "code"),
|
|
".sv": ("SystemVerilog", "//", "code"),
|
|
".svh": ("SystemVerilog Header", "//", "code"),
|
|
# CoffeeScript
|
|
".coffee": ("CoffeeScript", "#", "code"),
|
|
# Handlebars
|
|
".hbs": ("Handlebars", "{{!--", "code"),
|
|
# Solidity
|
|
".sol": ("Solidity", "//", "code"),
|
|
# Terraform / HCL
|
|
".tf": ("Terraform", "#", "code"),
|
|
".tfvars": ("Terraform Vars", "#", "code"),
|
|
".hcl": ("HCL", "#", "code"),
|
|
# Nix
|
|
".nix": ("Nix", "#", "code"),
|
|
# GraphQL
|
|
".graphql": ("GraphQL", "#", "code"),
|
|
".gql": ("GraphQL", "#", "code"),
|
|
# Protocol Buffers / Thrift
|
|
".proto": ("Protobuf", "//", "code"),
|
|
".thrift": ("Thrift", "//", "code"),
|
|
# AWK
|
|
".awk": ("AWK", "#", "code"),
|
|
# Gleam
|
|
".gleam": ("Gleam", "//", "code"),
|
|
# Mojo
|
|
".mojo": ("Mojo", "#", "code"),
|
|
# Odin
|
|
".odin": ("Odin", "//", "code"),
|
|
# Wren
|
|
".wren": ("Wren", "//", "code"),
|
|
# ReScript / Reason
|
|
".res": ("ReScript", "//", "code"),
|
|
".resi": ("ReScript Interface", "//", "code"),
|
|
".re": ("Reason", "//", "code"),
|
|
".rei": ("Reason Interface", "//", "code"),
|
|
# Apex (Salesforce)
|
|
".cls": ("Apex", "//", "code"),
|
|
".trigger": ("Apex Trigger", "//", "code"),
|
|
# CUDA
|
|
".cu": ("CUDA", "//", "code"),
|
|
".cuh": ("CUDA Header", "//", "code"),
|
|
# Hack
|
|
".hack": ("Hack", "//", "code"),
|
|
# Jsonnet
|
|
".jsonnet": ("Jsonnet", "//", "code"),
|
|
".libsonnet": ("Jsonnet", "//", "code"),
|
|
# Dhall
|
|
".dhall": ("Dhall", "--", "code"),
|
|
# Starlark (Bazel)
|
|
".bzl": ("Starlark", "#", "code"),
|
|
".star": ("Starlark", "#", "code"),
|
|
}
|
|
|
|
CONFIG_LANGUAGES = {
|
|
".json": ("JSON", None, "config"),
|
|
".jsonc": ("JSON with Comments", "//", "config"),
|
|
".json5": ("JSON5", "//", "config"),
|
|
".yml": ("YAML", "#", "config"),
|
|
".yaml": ("YAML", "#", "config"),
|
|
# Flutter / Dart config
|
|
".arb": ("ARB (Flutter i18n)", None, "config"),
|
|
".pubspec": ("Pubspec", "#", "config"),
|
|
# Build / Project config
|
|
".xml": ("XML", "<!--", "config"),
|
|
".plist": ("Property List", "<!--", "config"),
|
|
".iml": ("IntelliJ Module", "<!--", "config"),
|
|
".properties": ("Properties", "#", "config"),
|
|
".env": ("Environment", "#", "config"),
|
|
".toml": ("TOML", "#", "config"),
|
|
".ini": ("INI", ";", "config"),
|
|
".cfg": ("Config", "#", "config"),
|
|
".conf": ("Config", "#", "config"),
|
|
# Lock files
|
|
".lock": ("Lock File", "#", "config"),
|
|
# Editor / VCS config
|
|
".editorconfig": ("EditorConfig", "#", "config"),
|
|
".gitignore": ("Git Ignore", "#", "config"),
|
|
".gitattributes": ("Git Attributes", "#", "config"),
|
|
".dockerignore": ("Docker Ignore", "#", "config"),
|
|
# Data formats
|
|
".csv": ("CSV", None, "config"),
|
|
".tsv": ("TSV", None, "config"),
|
|
# EDN (Clojure data)
|
|
".edn": ("EDN", ";;", "config"),
|
|
}
|
|
|
|
DOC_LANGUAGES = {
|
|
".md": ("Markdown", None, "docs"),
|
|
".rst": ("reStructuredText", None, "docs"),
|
|
".txt": ("Text", None, "docs"),
|
|
".adoc": ("AsciiDoc", None, "docs"),
|
|
".tex": ("LaTeX", "%", "docs"),
|
|
".org": ("Org Mode", None, "docs"),
|
|
".wiki": ("Wiki", None, "docs"),
|
|
".rdoc": ("RDoc", None, "docs"),
|
|
}
|
|
|
|
IGNORE_DIRECTORIES = {
|
|
".git", "node_modules", "__pycache__", "dist",
|
|
"build", ".venv", "backup", "backups", ".idea", ".vscode", ".pytest_cache",
|
|
".env", ".eggs", "venv", "env", ".tox", ".mypy_cache", ".ruff_cache",
|
|
".claude", ".openai", "logs", "log", "tmp", "temp", ".githooks",
|
|
# Flutter / Dart specific
|
|
".dart_tool", ".pub-cache", ".pub", "ephemeral", ".symlinks",
|
|
"Pods", "DerivedData", "xcuserdata", ".gradle",
|
|
# Additional
|
|
"vendor", "target", "out", "bin", "obj", "packages", "cache",
|
|
"site-packages", "virtualenv", ".cache", ".next", ".nuxt",
|
|
".svn", ".hg", "bower_components", ".terraform",
|
|
".serverless", "__MACOSX", "coverage", ".coverage",
|
|
".nyc_output", "htmlcov", "_build", ".build", ".sass-cache",
|
|
}
|
|
|
|
IGNORE_FILE_PATTERNS = (
|
|
"package-lock.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
"composer.lock",
|
|
"Gemfile.lock",
|
|
"Cargo.lock",
|
|
"poetry.lock",
|
|
".min.js",
|
|
".min.css",
|
|
".map",
|
|
".bundle.",
|
|
".chunk.",
|
|
)
|
|
|
|
# --------------------------------------------------
|
|
# Terminal Styling
|
|
# --------------------------------------------------
|
|
|
|
RESET = "\033[0m"
|
|
BOLD = "\033[1m"
|
|
DIM = "\033[2m"
|
|
BLUE = "\033[38;5;75m"
|
|
GREEN = "\033[38;5;114m"
|
|
ORANGE = "\033[38;5;215m"
|
|
RED = "\033[38;5;203m"
|
|
CYAN = "\033[38;5;117m"
|
|
MAGENTA = "\033[38;5;183m"
|
|
YELLOW = "\033[38;5;229m"
|
|
WHITE = "\033[38;5;255m"
|
|
PURPLE = "\033[38;5;141m"
|
|
PINK = "\033[38;5;218m"
|
|
TEAL = "\033[38;5;80m"
|
|
|
|
# Animation-specific colors
|
|
HIDE_CURSOR = "\033[?25l"
|
|
SHOW_CURSOR = "\033[?25h"
|
|
GRAD = ["\033[38;5;51m", "\033[38;5;50m", "\033[38;5;49m", "\033[38;5;48m", "\033[38;5;47m", "\033[38;5;46m"]
|
|
GOLD = "\033[38;5;220m"
|
|
DARKGRAY = "\033[38;5;240m"
|
|
GRAY = "\033[38;5;245m"
|
|
|
|
LOGO = r"""
|
|
██████╗ ██╗███████╗████████╗ █████╗ ████████╗███████╗
|
|
██╔══██╗ ██║██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝
|
|
██████╔╝ ██║███████╗ ██║ ███████║ ██║ ███████╗
|
|
██╔═══╝ ██ ██║╚════██║ ██║ ██╔══██║ ██║ ╚════██║
|
|
██║ ╚█████╔╝███████║ ██║ ██║ ██║ ██║ ███████║
|
|
╚═╝ ╚════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
|
|
"""
|
|
|
|
# --------------------------------------------------
|
|
# Animation & Progress
|
|
# --------------------------------------------------
|
|
|
|
def get_term_width():
|
|
try:
|
|
return os.get_terminal_size().columns
|
|
except Exception:
|
|
return 80
|
|
|
|
|
|
def clear_screen():
|
|
sys.stdout.write("\033[2J\033[H")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def print_centered(text, color=""):
|
|
width = get_term_width()
|
|
clean = re.sub(r'\033\[[0-9;]*m', '', text)
|
|
padding = (width - len(clean)) // 2
|
|
print(" " * max(0, padding) + color + text + RESET)
|
|
|
|
|
|
def animate_logo():
|
|
sys.stdout.write(HIDE_CURSOR)
|
|
clear_screen()
|
|
lines = LOGO.strip().split('\n')
|
|
width = get_term_width()
|
|
print()
|
|
for i, line in enumerate(lines):
|
|
color = GRAD[i % len(GRAD)]
|
|
padding = (width - len(line)) // 2
|
|
sys.stdout.write(" " * max(0, padding) + color + line + RESET + "\n")
|
|
sys.stdout.flush()
|
|
time.sleep(0.05)
|
|
print()
|
|
print_centered("━━━ Project Statistics Analyzer ━━━", GRAY)
|
|
time.sleep(0.1)
|
|
print_centered(f"v{VERSION} │ Multi-Threaded Analytics", DARKGRAY)
|
|
print()
|
|
time.sleep(0.2)
|
|
|
|
|
|
def loading_animation(text, duration=0.5, style="dots"):
|
|
frames = {
|
|
"dots": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
"blocks": ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"],
|
|
"arrows": ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
|
|
"pulse": ["○", "◔", "◑", "◕", "●", "◕", "◑", "◔"],
|
|
}
|
|
chars = frames.get(style, frames["dots"])
|
|
start = time.time()
|
|
i = 0
|
|
while time.time() - start < duration:
|
|
frame = chars[i % len(chars)]
|
|
sys.stdout.write(f"\r {CYAN}{frame}{RESET} {GRAY}{text}{RESET} ")
|
|
sys.stdout.flush()
|
|
time.sleep(0.08)
|
|
i += 1
|
|
sys.stdout.write(f"\r {GREEN}✓{RESET} {GRAY}{text}{RESET} \n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def startup_sequence(root_path):
|
|
animate_logo()
|
|
loading_animation("Initializing analyzer", 0.3, "dots")
|
|
loading_animation("Loading language definitions", 0.2, "pulse")
|
|
loading_animation(f"Target: {root_path}", 0.2, "arrows")
|
|
print()
|
|
width = min(60, get_term_width() - 4)
|
|
print_centered("─" * width, DARKGRAY)
|
|
print()
|
|
|
|
|
|
def scan_progress(current, total, filename, width=40):
|
|
pct = (current / total * 100) if total else 0
|
|
filled = int(width * current / total) if total else 0
|
|
spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
spinner = spinners[current % len(spinners)]
|
|
if pct < 25:
|
|
bar_color = CYAN
|
|
elif pct < 50:
|
|
bar_color = BLUE
|
|
elif pct < 75:
|
|
bar_color = TEAL
|
|
else:
|
|
bar_color = GREEN
|
|
bar_str = f"{bar_color}{'━' * filled}{DARKGRAY}{'─' * (width - filled)}{RESET}"
|
|
line1 = f" {CYAN}{spinner}{RESET} {bar_str} {GOLD}{pct:5.1f}%{RESET} {DARKGRAY}│{RESET} {GRAY}{current:,}/{total:,}{RESET}"
|
|
ext = Path(filename).suffix.lower()
|
|
icon = "📄"
|
|
if ext in CODE_LANGUAGES: icon = "💻"
|
|
elif ext in CONFIG_LANGUAGES: icon = "⚙️"
|
|
elif ext in DOC_LANGUAGES: icon = "📝"
|
|
max_fn = get_term_width() - 10
|
|
if len(filename) > max_fn:
|
|
filename = "..." + filename[-(max_fn - 3):]
|
|
line2 = f" {icon} {GRAY}{filename}{RESET}"
|
|
sys.stdout.write(f"\033[2A\033[2K{line1}\n\033[2K{line2}\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def clear_scan_progress():
|
|
sys.stdout.write("\033[2A\033[2K\033[2K")
|
|
sys.stdout.write(f" {GREEN}✓{RESET} {GRAY}Scan complete{RESET}\n\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
# --------------------------------------------------
|
|
# Helpers / Metrics
|
|
# --------------------------------------------------
|
|
|
|
def horizontal_bar(percentage, width=24, color=None, bg_color=None):
|
|
filled = int(width * percentage / 100)
|
|
bar_filled = "█" * filled
|
|
bar_empty = "░" * (width - filled)
|
|
if color:
|
|
return f"{color}{bar_filled}{RESET}{DIM}{bar_empty}{RESET}"
|
|
return bar_filled + bar_empty
|
|
|
|
|
|
def format_number_short(n):
|
|
"""Format large numbers with K/M/B suffixes."""
|
|
if n >= 1_000_000_000:
|
|
return f"{n / 1_000_000_000:.1f}B"
|
|
if n >= 1_000_000:
|
|
return f"{n / 1_000_000:.1f}M"
|
|
if n >= 1_000:
|
|
return f"{n / 1_000:.1f}K"
|
|
return str(n)
|
|
|
|
|
|
def should_ignore_file(path: Path, root: Path, ignore_patterns=IGNORE_FILE_PATTERNS, ignore_globs=None, negation_globs=None):
|
|
"""Return True if file should be ignored based on name patterns or glob patterns.
|
|
|
|
- ignore_patterns: simple substrings (legacy behavior)
|
|
- ignore_globs: list of glob patterns relative to root (supports wildcards and paths)
|
|
- negation_globs: list of patterns that un-ignore files (overrides ignore_globs)
|
|
"""
|
|
name = path.name
|
|
|
|
# Calculate relative path for pattern matching
|
|
try:
|
|
rel = str(path.relative_to(root)).replace('\\', '/')
|
|
except ValueError:
|
|
# If can't get relative path, try resolved
|
|
try:
|
|
rel = str(path.resolve().relative_to(root.resolve())).replace('\\', '/')
|
|
except ValueError:
|
|
return True # Can't determine path, ignore it
|
|
|
|
# Check if file matches a negation pattern (should NOT be ignored)
|
|
if negation_globs:
|
|
for pat in negation_globs:
|
|
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(name, pat):
|
|
return False # Explicitly un-ignored
|
|
|
|
# Check simple substring patterns
|
|
if any(pattern in name for pattern in ignore_patterns):
|
|
return True
|
|
|
|
# Check glob patterns
|
|
if ignore_globs:
|
|
for pat in ignore_globs:
|
|
# try match against relative path and filename
|
|
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(name, pat):
|
|
return True
|
|
return False
|
|
|
|
|
|
_ALL_LANGUAGES = {}
|
|
_ALL_LANGUAGES.update(CODE_LANGUAGES)
|
|
_ALL_LANGUAGES.update(CONFIG_LANGUAGES)
|
|
_ALL_LANGUAGES.update(DOC_LANGUAGES)
|
|
|
|
def detect_language(extension):
|
|
return _ALL_LANGUAGES.get(extension)
|
|
|
|
|
|
# --------------------------------------------------
|
|
# Pre-compiled regex patterns (module-level for performance)
|
|
# --------------------------------------------------
|
|
_RE_PY_FUNC = re.compile(r"^\s*(async\s+def|def)\s+\w+")
|
|
_RE_PY_CLASS = re.compile(r"^\s*class\s+\w+")
|
|
_RE_JS_FUNC = re.compile(r"\bfunction\b|=>")
|
|
_RE_JS_CLASS = re.compile(r"^\s*(export\s+)?(default\s+)?(abstract\s+)?class\s+\w+")
|
|
_RE_C_FAMILY = re.compile(r"^\s*(?:public|private|protected|static|final|abstract|async|override)?\s*(?:void|int|double|float|bool|String|var|dynamic|Future|Stream|List|Map|Set|[A-Z]\w*(?:<[^>]+>)?)?\s+\w+\s*\([^)]*\)\s*(?:async)?\s*[{:]?")
|
|
_RE_DART_FUNC = re.compile(r"^\s*(?:Future|Stream|void|bool|int|double|String|dynamic|var|[A-Z]\w*(?:<[^>]+>)?)\s+\w+\s*\(")
|
|
_RE_JAVA_METHOD = re.compile(r"^\s*(?:@\w+\s*)*(?:public|private|protected)?\s*(?:static)?\s*(?:final)?\s*(?:void|int|long|float|double|boolean|char|byte|short|String|[A-Z]\w*(?:<[^>]+>)?)\s+\w+\s*\(")
|
|
_RE_SWIFT_FUNC = re.compile(r"^\s*(?:@\w+\s*)*(?:public|private|internal|fileprivate|open)?\s*(?:static|class)?\s*func\s+\w+")
|
|
_RE_GO_FUNC = re.compile(r"^\s*func\s+(?:\([^)]+\)\s*)?\w+\s*\(")
|
|
_RE_RUST_FUNC = re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+")
|
|
_RE_RUBY_DEF = re.compile(r"^\s*def\s+\w+")
|
|
_RE_RUBY_CLASS = re.compile(r"^\s*(class|module)\s+\w+")
|
|
_RE_TODO = re.compile(r"\b(TODO|FIXME)\b", re.IGNORECASE)
|
|
_RE_GENERAL_CLASS = re.compile(r"^\s*(?:export\s+)?(?:abstract\s+)?(?:public|private|protected|internal|open|sealed|data|final)?\s*(?:abstract\s+)?(?:class|struct|enum|interface|mixin|extension|typedef|protocol|trait)\s+\w+")
|
|
_RE_RUST_CLASS = re.compile(r"^\s*(?:pub\s+)?(?:struct|enum|trait|impl)\s+\w+")
|
|
_RE_GO_CLASS = re.compile(r"^\s*type\s+\w+\s+(?:struct|interface)\b")
|
|
_RE_ELIXIR_FUNC = re.compile(r"^\s*(def|defp|defmacro|defmacrop)\s+\w+")
|
|
_RE_ELIXIR_CLASS = re.compile(r"^\s*defmodule\s+\w+")
|
|
_RE_ERLANG_FUNC = re.compile(r"^[a-z]\w*\s*\(")
|
|
_RE_HASKELL_FUNC = re.compile(r"^[a-z]\w*\s+::")
|
|
_RE_HASKELL_DATA = re.compile(r"^\s*(data|newtype|class|type)\s+[A-Z]")
|
|
_RE_PERL_FUNC = re.compile(r"^\s*sub\s+\w+")
|
|
_RE_PERL_CLASS = re.compile(r"^\s*package\s+\w+")
|
|
_RE_LUA_FUNC = re.compile(r"(^\s*(local\s+)?function\s+\w+|=\s*function\s*\()")
|
|
_RE_PS_FUNC = re.compile(r"^\s*function\s+\w+", re.IGNORECASE)
|
|
_RE_PS_CLASS = re.compile(r"^\s*class\s+\w+", re.IGNORECASE)
|
|
_RE_SOLIDITY_FUNC = re.compile(r"^\s*(function|modifier|constructor|receive|fallback)\s+\w*")
|
|
_RE_SOLIDITY_CLASS = re.compile(r"^\s*(contract|library|interface)\s+\w+")
|
|
_RE_LISP_FUNC = re.compile(r"^\s*\(def(n|un|macro|multi|method|record|type|protocol)\s+")
|
|
_RE_LISP_IMPORT = re.compile(r"^\s*\((?:require|import|use|ns)\s+")
|
|
|
|
# Import patterns
|
|
_RE_IMPORT_PY = re.compile(r"^\s*(import|from)\s+")
|
|
_RE_IMPORT_JS = re.compile(r"^\s*(import\s+|const\s+.*=\s*require\(|require\()")
|
|
_RE_IMPORT_DART = re.compile(r"^\s*import\s+['\"]")
|
|
_RE_IMPORT_JAVA = re.compile(r"^\s*import\s+")
|
|
_RE_IMPORT_GO = re.compile(r"^\s*import\s+")
|
|
_RE_IMPORT_RUST = re.compile(r"^\s*(use|extern\s+crate)\s+")
|
|
_RE_IMPORT_C = re.compile(r"^\s*#\s*include\s+")
|
|
_RE_IMPORT_RUBY = re.compile(r"^\s*(require|require_relative|load)\s+")
|
|
_RE_IMPORT_ELIXIR = re.compile(r"^\s*(import|use|alias|require)\s+")
|
|
_RE_IMPORT_HASKELL = re.compile(r"^\s*import\s+")
|
|
_RE_IMPORT_PERL = re.compile(r"^\s*(use|require)\s+")
|
|
_RE_IMPORT_LUA = re.compile(r"^\s*(require|dofile|loadfile)\s*[\(\"']")
|
|
_RE_IMPORT_PS = re.compile(r"^\s*(Import-Module|using\s+(module|namespace|assembly))\s+", re.IGNORECASE)
|
|
|
|
# Language family sets
|
|
_LANGS_JS = frozenset({"JavaScript", "JavaScript (JSX)", "JavaScript (ESM)", "JavaScript (CJS)",
|
|
"TypeScript", "TypeScript (TSX)", "Shell", "Bash", "Zsh", "Groovy", "Scala", "PHP",
|
|
"CoffeeScript", "Vue", "Svelte", "Astro"})
|
|
_LANGS_C_FAMILY = frozenset({"C", "C Header", "C++", "C++ Header", "C#", "Objective-C", "Objective-C++",
|
|
"Java", "Kotlin", "Kotlin Script", "Gradle", "CUDA", "CUDA Header", "D",
|
|
"Hack", "Apex", "Apex Trigger"})
|
|
_LANGS_C_INCLUDE = frozenset({"C", "C Header", "C++", "C++ Header", "Objective-C", "Objective-C++", "CUDA", "CUDA Header"})
|
|
_LANGS_DART = frozenset({"Dart"})
|
|
_LANGS_SWIFT = frozenset({"Swift"})
|
|
_LANGS_GO = frozenset({"Go"})
|
|
_LANGS_RUST = frozenset({"Rust"})
|
|
_LANGS_RUBY = frozenset({"Ruby"})
|
|
_LANGS_ELIXIR = frozenset({"Elixir", "Elixir Script"})
|
|
_LANGS_ERLANG = frozenset({"Erlang", "Erlang Header"})
|
|
_LANGS_HASKELL = frozenset({"Haskell", "Literate Haskell", "Elm", "PureScript"})
|
|
_LANGS_PERL = frozenset({"Perl", "Perl Test"})
|
|
_LANGS_LUA = frozenset({"Lua"})
|
|
_LANGS_PS = frozenset({"PowerShell", "PowerShell Module", "PowerShell Data"})
|
|
_LANGS_SOLIDITY = frozenset({"Solidity"})
|
|
_LANGS_LISP = frozenset({"Clojure", "ClojureScript", "Lisp", "Common Lisp", "Scheme", "Racket"})
|
|
_LANGS_PYTHON = frozenset({"Python", "Python Stub"})
|
|
|
|
# --------------------------------------------------
|
|
# Analysis
|
|
# --------------------------------------------------
|
|
|
|
def load_ignore_files(root: Path):
|
|
"""Load ignore patterns from .gitignore/.ignore and return a list of glob patterns, directory names to ignore, and negation patterns."""
|
|
root = Path(root)
|
|
globs = []
|
|
dir_names = set()
|
|
negation_globs = [] # patterns that start with ! (un-ignore)
|
|
negation_dirs = set() # directory negations
|
|
for fname in IGNORE_FILES_TO_CHECK:
|
|
fpath = root / fname
|
|
if not fpath.exists():
|
|
continue
|
|
try:
|
|
with fpath.open('r', encoding='utf-8', errors='ignore') as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
# handle negations (lines starting with !)
|
|
if line.startswith('!'):
|
|
pattern = line[1:] # remove the !
|
|
if pattern.endswith('/'):
|
|
negation_dirs.add(pattern.rstrip('/'))
|
|
else:
|
|
negation_globs.append(pattern)
|
|
continue
|
|
# directory pattern
|
|
if line.endswith('/'):
|
|
dir_names.add(line.rstrip('/'))
|
|
else:
|
|
globs.append(line)
|
|
except Exception:
|
|
continue
|
|
return list(globs), dir_names, list(negation_globs), negation_dirs
|
|
|
|
|
|
def analyze_file(path: Path):
|
|
info = detect_language(path.suffix.lower())
|
|
if not info:
|
|
# fallback for special filenames (Dockerfile, Dockerfile.*, Makefile) or shebang
|
|
name = path.name
|
|
if name in SPECIAL_FILES:
|
|
info = SPECIAL_FILES[name]
|
|
elif name.lower().startswith('dockerfile'):
|
|
# catch Dockerfile, Dockerfile.bot, Dockerfile.native, etc.
|
|
info = ('Dockerfile', '#', 'code')
|
|
else:
|
|
try:
|
|
with path.open('r', encoding='utf-8', errors='ignore') as fh:
|
|
first = fh.readline()
|
|
if first.startswith('#!'):
|
|
info = ('Shell', '#', 'code')
|
|
except Exception:
|
|
pass
|
|
|
|
if not info:
|
|
return None
|
|
|
|
language, comment_token, category = info
|
|
|
|
# Skip extremely large files (minified bundles, generated code, blobs)
|
|
try:
|
|
size = path.stat().st_size
|
|
except OSError:
|
|
return None
|
|
if size > 1_000_000: # 1MB cap — files above this are overwhelmingly minified/generated
|
|
# Still report the file but with minimal metadata (no line-by-line analysis)
|
|
return {
|
|
"path": str(path),
|
|
"relpath": str(path),
|
|
"language": language,
|
|
"category": category,
|
|
"code_lines": 0,
|
|
"code_chars": 0,
|
|
"comment_lines": 0,
|
|
"empty_lines": 0,
|
|
"total_lines": 0,
|
|
"size_bytes": size,
|
|
"function_count": 0,
|
|
"class_count": 0,
|
|
"todo_count": 0,
|
|
"longest_line": 0,
|
|
"avg_line_length": 0,
|
|
"avg_func_length": 0,
|
|
"max_nesting_depth": 0,
|
|
"long_lines": 0,
|
|
"import_count": 0,
|
|
"comment_ratio": 0,
|
|
"has_comment": bool(comment_token),
|
|
}
|
|
|
|
# Single-pass analysis: line counting + function/class/import detection
|
|
code = comments = empty = 0
|
|
code_chars = 0
|
|
func_count = class_count = todo_count = 0
|
|
longest_line = 0
|
|
avg_len_acc = 0
|
|
max_nesting = 0
|
|
current_nesting = 0
|
|
long_lines = 0
|
|
import_count = 0
|
|
total_lines = 0
|
|
|
|
# Select the right import matcher for this language
|
|
import_matcher = None
|
|
if language in _LANGS_PYTHON:
|
|
import_matcher = _RE_IMPORT_PY
|
|
elif language in _LANGS_DART:
|
|
import_matcher = _RE_IMPORT_DART
|
|
elif language in _LANGS_JS:
|
|
import_matcher = _RE_IMPORT_JS
|
|
elif language in _LANGS_C_FAMILY:
|
|
import_matcher = _RE_IMPORT_C if language in _LANGS_C_INCLUDE else _RE_IMPORT_JAVA
|
|
elif language in _LANGS_GO:
|
|
import_matcher = _RE_IMPORT_GO
|
|
elif language in _LANGS_RUST:
|
|
import_matcher = _RE_IMPORT_RUST
|
|
elif language in _LANGS_RUBY:
|
|
import_matcher = _RE_IMPORT_RUBY
|
|
elif language in _LANGS_SWIFT:
|
|
import_matcher = _RE_IMPORT_JAVA
|
|
elif language in _LANGS_ELIXIR:
|
|
import_matcher = _RE_IMPORT_ELIXIR
|
|
elif language in _LANGS_HASKELL:
|
|
import_matcher = _RE_IMPORT_HASKELL
|
|
elif language in _LANGS_PERL:
|
|
import_matcher = _RE_IMPORT_PERL
|
|
elif language in _LANGS_LUA:
|
|
import_matcher = _RE_IMPORT_LUA
|
|
elif language in _LANGS_PS:
|
|
import_matcher = _RE_IMPORT_PS
|
|
elif language in _LANGS_LISP:
|
|
import_matcher = _RE_LISP_IMPORT
|
|
|
|
# Select the right func/class matchers for this language
|
|
func_matcher = class_matcher = None
|
|
func_is_search = False # use .search() instead of .match()
|
|
func_matcher2 = None # secondary func matcher (for dart/c-family)
|
|
if language in _LANGS_PYTHON:
|
|
func_matcher = _RE_PY_FUNC
|
|
class_matcher = _RE_PY_CLASS
|
|
elif language in _LANGS_JS:
|
|
func_matcher = _RE_JS_FUNC
|
|
func_is_search = True
|
|
class_matcher = _RE_JS_CLASS
|
|
elif language in _LANGS_DART:
|
|
func_matcher = _RE_DART_FUNC
|
|
func_matcher2 = _RE_C_FAMILY
|
|
class_matcher = _RE_GENERAL_CLASS
|
|
elif language in _LANGS_C_FAMILY:
|
|
func_matcher = _RE_JAVA_METHOD
|
|
func_matcher2 = _RE_C_FAMILY
|
|
class_matcher = _RE_GENERAL_CLASS
|
|
elif language in _LANGS_SWIFT:
|
|
func_matcher = _RE_SWIFT_FUNC
|
|
class_matcher = _RE_GENERAL_CLASS
|
|
elif language in _LANGS_GO:
|
|
func_matcher = _RE_GO_FUNC
|
|
class_matcher = _RE_GO_CLASS
|
|
elif language in _LANGS_RUST:
|
|
func_matcher = _RE_RUST_FUNC
|
|
class_matcher = _RE_RUST_CLASS
|
|
elif language in _LANGS_RUBY:
|
|
func_matcher = _RE_RUBY_DEF
|
|
class_matcher = _RE_RUBY_CLASS
|
|
elif language in _LANGS_ELIXIR:
|
|
func_matcher = _RE_ELIXIR_FUNC
|
|
class_matcher = _RE_ELIXIR_CLASS
|
|
elif language in _LANGS_ERLANG:
|
|
func_matcher = _RE_ERLANG_FUNC
|
|
elif language in _LANGS_HASKELL:
|
|
func_matcher = _RE_HASKELL_FUNC
|
|
class_matcher = _RE_HASKELL_DATA
|
|
elif language in _LANGS_PERL:
|
|
func_matcher = _RE_PERL_FUNC
|
|
class_matcher = _RE_PERL_CLASS
|
|
elif language in _LANGS_LUA:
|
|
func_matcher = _RE_LUA_FUNC
|
|
func_is_search = True
|
|
elif language in _LANGS_PS:
|
|
func_matcher = _RE_PS_FUNC
|
|
class_matcher = _RE_PS_CLASS
|
|
elif language in _LANGS_SOLIDITY:
|
|
func_matcher = _RE_SOLIDITY_FUNC
|
|
class_matcher = _RE_SOLIDITY_CLASS
|
|
elif language in _LANGS_LISP:
|
|
func_matcher = _RE_LISP_FUNC
|
|
else:
|
|
class_matcher = _RE_GENERAL_CLASS
|
|
|
|
_todo_search = _RE_TODO.search
|
|
_LINE_LEN_CAP = 10_000 # skip regex on very long lines (minified code)
|
|
_MAX_LINES = 100_000 # bail out on files with excessive line counts
|
|
|
|
try:
|
|
with path.open("r", encoding="utf-8", errors="ignore") as fh:
|
|
for line in fh:
|
|
total_lines += 1
|
|
if total_lines > _MAX_LINES:
|
|
break
|
|
stripped = line.strip()
|
|
|
|
# Line counting (code / comment / empty)
|
|
if not stripped:
|
|
empty += 1
|
|
elif comment_token and stripped.startswith(comment_token):
|
|
comments += 1
|
|
else:
|
|
code += 1
|
|
code_chars += len(stripped)
|
|
|
|
# Line length metrics
|
|
l_len = len(line) - 1 if line.endswith('\n') else len(line)
|
|
avg_len_acc += l_len
|
|
if l_len > longest_line:
|
|
longest_line = l_len
|
|
if l_len > 120:
|
|
long_lines += 1
|
|
|
|
# Skip regex analysis on extremely long lines (minified code)
|
|
if l_len > _LINE_LEN_CAP:
|
|
continue
|
|
|
|
# Nesting depth (brace-based)
|
|
if stripped and not stripped.startswith(('//', '#', '/*', '*', '<!--')):
|
|
delta = (stripped.count('{') + stripped.count('(')) - (stripped.count('}') + stripped.count(')'))
|
|
current_nesting += delta
|
|
if current_nesting < 0:
|
|
current_nesting = 0
|
|
if current_nesting > max_nesting:
|
|
max_nesting = current_nesting
|
|
|
|
# TODO/FIXME detection
|
|
if _todo_search(line):
|
|
todo_count += 1
|
|
|
|
# Import detection
|
|
if import_matcher and import_matcher.match(line):
|
|
import_count += 1
|
|
|
|
# Function/class detection
|
|
if class_matcher and class_matcher.match(line):
|
|
class_count += 1
|
|
elif func_matcher:
|
|
if func_is_search:
|
|
if func_matcher.search(line):
|
|
func_count += 1
|
|
else:
|
|
if func_matcher.match(line):
|
|
func_count += 1
|
|
elif func_matcher2 and func_matcher2.match(line):
|
|
func_count += 1
|
|
except Exception:
|
|
return None
|
|
|
|
avg_line_len = (avg_len_acc / total_lines) if total_lines else 0.0
|
|
total = code + comments + empty
|
|
comment_ratio = (comments / code * 100) if code else 0
|
|
avg_func_length = (code / func_count) if func_count else 0
|
|
|
|
return {
|
|
"path": str(path),
|
|
"relpath": str(path),
|
|
"language": language,
|
|
"category": category,
|
|
"code_lines": code,
|
|
"code_chars": code_chars,
|
|
"comment_lines": comments,
|
|
"empty_lines": empty,
|
|
"total_lines": total,
|
|
"size_bytes": size,
|
|
"function_count": func_count,
|
|
"class_count": class_count,
|
|
"todo_count": todo_count,
|
|
"longest_line": longest_line,
|
|
"avg_line_length": avg_line_len,
|
|
"avg_func_length": avg_func_length,
|
|
"max_nesting_depth": max_nesting,
|
|
"long_lines": long_lines,
|
|
"import_count": import_count,
|
|
"comment_ratio": comment_ratio,
|
|
"has_comment": bool(comment_token),
|
|
}
|
|
|
|
|
|
def get_git_info(root: Path, commit_msg_max: int = 120):
|
|
"""Return git info dict if 'root' is inside a git worktree, otherwise None.
|
|
|
|
commit_msg_max: max characters for the single-line commit message shown (truncated).
|
|
"""
|
|
try:
|
|
p = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=str(root), capture_output=True, text=True)
|
|
if p.returncode != 0 or p.stdout.strip() != "true":
|
|
return None
|
|
|
|
branch = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(root), capture_output=True, text=True).stdout.strip()
|
|
|
|
commit_out = subprocess.run(["git", "log", "-1", "--pretty=format:%h;%an;%ad;%B", "--date=short"], cwd=str(root), capture_output=True, text=True)
|
|
latest = None
|
|
if commit_out.returncode == 0 and commit_out.stdout:
|
|
# use %B for full raw body, then split fields manually
|
|
raw = commit_out.stdout.strip()
|
|
# the format we used here is: <hash>;<author>;<date>;<body...>
|
|
parts = raw.split(";", 3)
|
|
if len(parts) == 4:
|
|
short, author, date, msg = parts
|
|
# collapse multi-line messages to single line and trim
|
|
single = " ".join(line.strip() for line in msg.splitlines()).strip()
|
|
if len(single) > commit_msg_max:
|
|
short_msg = single[:commit_msg_max - 1].rstrip() + "…"
|
|
else:
|
|
short_msg = single
|
|
latest = {"hash": short, "author": author, "date": date, "message": msg, "message_short": short_msg}
|
|
|
|
# commit count
|
|
commit_count = 0
|
|
ccount = subprocess.run(["git", "rev-list", "--count", "HEAD"], cwd=str(root), capture_output=True, text=True)
|
|
if ccount.returncode == 0 and ccount.stdout.strip().isdigit():
|
|
commit_count = int(ccount.stdout.strip())
|
|
|
|
# files changed in last commit
|
|
files_changed = []
|
|
files_out = subprocess.run(["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], cwd=str(root), capture_output=True, text=True)
|
|
if files_out.returncode == 0 and files_out.stdout:
|
|
files_changed = [l for l in files_out.stdout.splitlines() if l.strip()]
|
|
|
|
# stats (insertions/deletions) for last commit
|
|
insertions = deletions = 0
|
|
stats_out = subprocess.run(["git", "show", "--numstat", "-1", "HEAD"], cwd=str(root), capture_output=True, text=True)
|
|
if stats_out.returncode == 0 and stats_out.stdout:
|
|
for line in stats_out.stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) >= 3:
|
|
try:
|
|
ins = int(parts[0]) if parts[0].isdigit() else 0
|
|
dels = int(parts[1]) if parts[1].isdigit() else 0
|
|
insertions += ins
|
|
deletions += dels
|
|
except Exception:
|
|
pass
|
|
|
|
# remote URL
|
|
remote = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=str(root), capture_output=True, text=True)
|
|
remote_url = remote.stdout.strip() if remote.returncode == 0 and remote.stdout else None
|
|
|
|
status_out = subprocess.run(["git", "status", "--porcelain"], cwd=str(root), capture_output=True, text=True)
|
|
changed = untracked = 0
|
|
dirty = False
|
|
if status_out.returncode == 0:
|
|
lines = [l for l in status_out.stdout.splitlines() if l.strip()]
|
|
changed = sum(1 for l in lines if not l.startswith("??"))
|
|
untracked = sum(1 for l in lines if l.startswith("??"))
|
|
dirty = bool(lines)
|
|
|
|
ahead = behind = 0
|
|
ub = subprocess.run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd=str(root), capture_output=True, text=True)
|
|
if ub.returncode == 0:
|
|
upstream = ub.stdout.strip()
|
|
ab = subprocess.run(["git", "rev-list", "--left-right", "--count", f"{upstream}...HEAD"], cwd=str(root), capture_output=True, text=True)
|
|
if ab.returncode == 0 and ab.stdout:
|
|
behind_s, ahead_s = ab.stdout.strip().split()
|
|
behind = int(behind_s)
|
|
ahead = int(ahead_s)
|
|
|
|
return {
|
|
"branch": branch,
|
|
"dirty": dirty,
|
|
"changed": changed,
|
|
"untracked": untracked,
|
|
"ahead": ahead,
|
|
"behind": behind,
|
|
"latest_commit": latest,
|
|
"commit_count": commit_count,
|
|
"last_files_changed": files_changed,
|
|
"last_commit_insertions": insertions,
|
|
"last_commit_deletions": deletions,
|
|
"remote_url": remote_url,
|
|
}
|
|
except Exception:
|
|
return None
|
|
|
|
# --------------------------------------------------
|
|
# CLI
|
|
# --------------------------------------------------
|
|
|
|
SPECIAL_FILES = {
|
|
'Dockerfile': ("Dockerfile", "#", "code"),
|
|
'Makefile': ("Makefile", "#", "code"),
|
|
}
|
|
|
|
|
|
def parse_args():
|
|
p = argparse.ArgumentParser(description="pjstats -- enhanced project stats", epilog=HELP_TEXT, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("path", nargs="?", default='.', help="Project root path")
|
|
p.add_argument("--json", action="store_true", help="Output JSON summary")
|
|
p.add_argument("--top", type=int, default=5, help="Top N files to display")
|
|
p.add_argument("--ext", nargs="*", help="Limit to specific extensions (e.g. .py .js)")
|
|
p.add_argument("--min-code-lines", type=int, default=0, help="Ignore files with fewer code lines than this")
|
|
p.add_argument("--exclude", nargs="*", default=[], help="Directories to ignore recursively (relative to project root)")
|
|
p.add_argument("--include-hidden", action="store_true", help="Include hidden directories/files")
|
|
p.add_argument("--no-color", action="store_true", help="Disable color output")
|
|
p.add_argument("--no-git", action="store_true", help="Disable Git info detection")
|
|
p.add_argument("--no-animation", action="store_true", help="Disable startup animation and progress bars")
|
|
p.add_argument("--bypass-excluded", action="store_true", help="Bypass all ignore rules (.gitignore, built-in excludes, hidden dirs, etc.) and scan everything")
|
|
p.add_argument("--workers", type=int, default=0, help="Number of threads for parallel analysis (0=auto)")
|
|
p.add_argument("--commit-msg-max", type=int, default=120, help="Max characters to show for commit message (single-line, truncated)")
|
|
p.add_argument("--install", action="store_true", help="Install pjstats to a directory in your PATH (auto-detected per OS if not provided)")
|
|
p.add_argument("--uninstall", action="store_true", help="Remove installed pjstats from install directory")
|
|
p.add_argument("--install-dir", default=None, help="Installation directory (auto-detected per OS if not set)")
|
|
return p.parse_args()
|
|
|
|
|
|
def default_install_dir():
|
|
"""Return a sensible default install directory depending on the OS."""
|
|
system = platform.system()
|
|
if system == 'Windows':
|
|
# prefer %LOCALAPPDATA%/Programs/pjstats, fallback to %USERPROFILE%/Scripts
|
|
local = os.environ.get('LOCALAPPDATA')
|
|
if local:
|
|
return str(Path(local) / 'Programs' / 'pjstats')
|
|
up = os.environ.get('USERPROFILE')
|
|
if up:
|
|
return str(Path(up) / 'Scripts')
|
|
return str(Path.home() / 'Scripts')
|
|
else:
|
|
# Unix-like default
|
|
return str(Path.home() / '.local' / 'bin')
|
|
|
|
|
|
def install_self(install_dir: str):
|
|
install_dir = Path(os.path.expanduser(install_dir))
|
|
install_dir.mkdir(parents=True, exist_ok=True)
|
|
src = Path(__file__).resolve()
|
|
dest = install_dir / 'pjstats'
|
|
try:
|
|
# normalize line endings while installing to avoid CRLF shebang issues
|
|
with src.open('r', encoding='utf-8', errors='ignore') as fh:
|
|
text = fh.read()
|
|
with dest.open('w', encoding='utf-8', newline='\n') as fh:
|
|
fh.write(text.replace('\r\n', '\n').replace('\r', '\n'))
|
|
dest.chmod(0o755)
|
|
print(f"Installed pjstats -> {dest}")
|
|
# warn if install_dir not in PATH
|
|
path_env = os.environ.get('PATH','')
|
|
if str(install_dir) not in path_env.split(os.pathsep):
|
|
if platform.system() == 'Windows':
|
|
print(f"Note: {install_dir} is not in your PATH. Consider adding it to your User PATH or copy to a directory already on PATH.")
|
|
else:
|
|
print(f"Note: {install_dir} is not in your PATH. Add it to your PATH (e.g. export PATH=\"{install_dir}:$PATH\") to use 'pjstats' directly.")
|
|
return True
|
|
except Exception as exc:
|
|
print(f"Failed to install: {exc}")
|
|
return False
|
|
|
|
|
|
def uninstall_self(install_dir: str):
|
|
install_dir = Path(os.path.expanduser(install_dir))
|
|
dest = install_dir / 'pjstats'
|
|
try:
|
|
if dest.exists():
|
|
dest.unlink()
|
|
print(f"Removed {dest}")
|
|
return True
|
|
else:
|
|
print(f"pjstats is not installed at {dest}")
|
|
return False
|
|
except Exception as exc:
|
|
print(f"Failed to uninstall: {exc}")
|
|
return False
|
|
|
|
|
|
def compute_quality_score(data):
|
|
"""Compute a 0-100 quality score using smooth curves for realistic assessment.
|
|
|
|
Uses continuous penalties proportional to how far each metric deviates
|
|
from ideal values, rather than hard step thresholds.
|
|
"""
|
|
score = 100.0
|
|
|
|
# 1. Comment ratio (max -30 pts) - smooth curve with over-commenting penalty
|
|
if data.get('has_comment', True) and data['code_lines'] > 0:
|
|
cr = data['comment_lines'] / data['code_lines'] * 100
|
|
if cr < 1:
|
|
score -= 30
|
|
elif cr < 5:
|
|
# 30 -> 18 as cr goes 0 -> 5
|
|
score -= 30 - (cr / 5) * 12
|
|
elif cr < 10:
|
|
# 18 -> 8 as cr goes 5 -> 10
|
|
score -= 18 - ((cr - 5) / 5) * 10
|
|
elif cr < 20:
|
|
# 8 -> 0 as cr goes 10 -> 20
|
|
score -= 8 - ((cr - 10) / 10) * 8
|
|
# Penalty for excessive commenting (>60% is unusual)
|
|
if cr > 60:
|
|
score -= min(10, (cr - 60) / 10 * 5)
|
|
|
|
# 2. Lines per function (max -20 pts) - smooth curve
|
|
if data['function_count'] > 0:
|
|
lpf = data['code_lines'] / data['function_count']
|
|
if lpf > 100:
|
|
score -= 20
|
|
elif lpf > 60:
|
|
score -= 10 + (lpf - 60) / 40 * 10
|
|
elif lpf > 30:
|
|
score -= (lpf - 30) / 30 * 10
|
|
# Too many tiny functions penalty (< 3 lines avg with many funcs)
|
|
if lpf < 3 and data['function_count'] > 5:
|
|
score -= 5
|
|
|
|
# 3. TODO/FIXME density per 1000 lines (max -10 pts) - proportional
|
|
td = data.get('todo_density', 0)
|
|
if td > 0:
|
|
score -= min(10, td * 0.5)
|
|
|
|
# 4. Max nesting depth (max -15 pts) - smooth from depth 3+
|
|
mnd = data.get('max_nesting_depth', 0)
|
|
if mnd > 3:
|
|
score -= min(15, (mnd - 3) * 2.5)
|
|
|
|
# 5. Long line ratio (max -10 pts) - proportional
|
|
llr = data.get('long_line_ratio', 0)
|
|
if llr > 0:
|
|
score -= min(10, llr * 0.7)
|
|
|
|
# 6. Large file count (max -10 pts) - proportional to % of files > 300 lines
|
|
if data['files'] > 0:
|
|
lf_pct = data.get('large_file_count', 0) / data['files'] * 100
|
|
if lf_pct > 0:
|
|
score -= min(10, lf_pct * 0.2)
|
|
|
|
# 7. File size consistency (max -5 pts) - coefficient of variation
|
|
med = data.get('median_code_lines', 0)
|
|
sd = data.get('stddev_code_lines', 0)
|
|
if med > 0:
|
|
cv = sd / med
|
|
if cv > 0.5:
|
|
score -= min(5, (cv - 0.5) * 3.3)
|
|
|
|
return max(0, min(100, round(score, 1)))
|
|
|
|
|
|
def score_color(score, no_color=False):
|
|
"""Return an ANSI color for a quality score."""
|
|
if no_color:
|
|
return ""
|
|
if score >= 80:
|
|
return GREEN
|
|
elif score >= 60:
|
|
return YELLOW
|
|
elif score >= 40:
|
|
return ORANGE
|
|
else:
|
|
return RED
|
|
|
|
|
|
def score_grade(score):
|
|
"""Return a letter grade for a quality score."""
|
|
if score >= 90:
|
|
return "A+"
|
|
elif score >= 85:
|
|
return "A"
|
|
elif score >= 80:
|
|
return "A-"
|
|
elif score >= 75:
|
|
return "B+"
|
|
elif score >= 70:
|
|
return "B"
|
|
elif score >= 65:
|
|
return "B-"
|
|
elif score >= 60:
|
|
return "C+"
|
|
elif score >= 55:
|
|
return "C"
|
|
elif score >= 50:
|
|
return "C-"
|
|
elif score >= 45:
|
|
return "D+"
|
|
elif score >= 40:
|
|
return "D"
|
|
else:
|
|
return "F"
|
|
|
|
|
|
def print_summary(result, root: Path, args):
|
|
if args.no_color:
|
|
global BOLD, DIM, BLUE, GREEN, ORANGE, RED, RESET, CYAN, MAGENTA, YELLOW, WHITE, PURPLE, PINK, TEAL
|
|
BOLD = DIM = BLUE = GREEN = ORANGE = RED = RESET = CYAN = MAGENTA = YELLOW = WHITE = PURPLE = PINK = TEAL = ""
|
|
|
|
language_stats = result["language_stats"]
|
|
category_totals = result["category_totals"]
|
|
per_file = result["per_file"]
|
|
|
|
total_files = len(per_file)
|
|
total_lines = sum(f["total_lines"] for f in per_file)
|
|
|
|
print(f"\n{BOLD}{CYAN}PROJECT OVERVIEW{RESET}")
|
|
print(f"{DIM}{root}{RESET}\n")
|
|
if not args.no_git:
|
|
git = get_git_info(root, args.commit_msg_max)
|
|
if git:
|
|
s = f"{PURPLE}Git:{RESET} branch {YELLOW}{git['branch']}{RESET}"
|
|
if git.get('dirty'):
|
|
s += f" | {ORANGE}modified:{git.get('changed',0)}{RESET} {DIM}untracked:{git.get('untracked',0)}{RESET}"
|
|
if git.get('ahead') or git.get('behind'):
|
|
ahead_c = GREEN if git.get('ahead', 0) > 0 else DIM
|
|
behind_c = RED if git.get('behind', 0) > 0 else DIM
|
|
s += f" | {ahead_c}ahead:{git.get('ahead',0)}{RESET} {behind_c}behind:{git.get('behind',0)}{RESET}"
|
|
if git.get('commit_count'):
|
|
s += f" | {CYAN}commits:{git.get('commit_count')}{RESET}"
|
|
cm = git.get('latest_commit') or {}
|
|
if cm:
|
|
s += f" | latest {MAGENTA}{cm.get('hash')}{RESET} by {BLUE}{cm.get('author')}{RESET} on {DIM}{cm.get('date')}{RESET}"
|
|
|
|
# insertion/deletion summary for last commit
|
|
ins = git.get('last_commit_insertions', 0) or 0
|
|
dels = git.get('last_commit_deletions', 0) or 0
|
|
files_changed = git.get('last_files_changed') or []
|
|
if files_changed:
|
|
s += f" | last {GREEN}+{ins}{RESET}/{RED}-{dels}{RESET} in {WHITE}{len(files_changed)}{RESET} files"
|
|
print(s)
|
|
|
|
# show the commit message on its own (single-line, truncated)
|
|
if cm and cm.get('message_short'):
|
|
print(f" {DIM}Message:{RESET} {cm.get('message_short')}")
|
|
|
|
# print a concise list of files changed in the last commit (first 6)
|
|
if files_changed:
|
|
show = files_changed[:6]
|
|
more = len(files_changed) - len(show)
|
|
files_line = f"{DIM}, {RESET}".join(f"{TEAL}{f}{RESET}" for f in show)
|
|
if more > 0:
|
|
files_line += f" {DIM}(+{more} more){RESET}"
|
|
print(f" {DIM}Changed files:{RESET} {files_line}")
|
|
|
|
total_chars = sum(f.get("code_chars", 0) for f in per_file)
|
|
total_funcs = sum(f.get("function_count", 0) for f in per_file)
|
|
total_classes = sum(f.get("class_count", 0) for f in per_file)
|
|
total_todos = sum(f.get("todo_count", 0) for f in per_file)
|
|
print(f"{DIM}Files scanned :{RESET} {CYAN}{total_files}{RESET}")
|
|
print(f"{DIM}Total lines :{RESET} {CYAN}{total_lines:,}{RESET}")
|
|
print(f"{DIM}Total characters (code) :{RESET} {CYAN}{total_chars:,}{RESET}")
|
|
print(f"{DIM}Functions / Methods :{RESET} {MAGENTA}{total_funcs:,}{RESET}")
|
|
print(f"{DIM}Classes / Structs :{RESET} {PURPLE}{total_classes:,}{RESET}")
|
|
print(f"{DIM}TODO / FIXME markers :{RESET} {ORANGE}{total_todos:,}{RESET}")
|
|
print()
|
|
|
|
# Category colors
|
|
cat_colors = {"code": BLUE, "config": ORANGE, "docs": MAGENTA}
|
|
|
|
print(f"{BOLD}{CYAN}CONTENT DISTRIBUTION{RESET}")
|
|
for category, lines in category_totals.items():
|
|
percent = (lines / total_lines * 100) if total_lines else 0
|
|
label = category.capitalize()
|
|
cat_color = cat_colors.get(category, WHITE)
|
|
print(
|
|
f"{cat_color}{label:<15}{RESET} "
|
|
f"{WHITE}{lines:>8,}{RESET} {DIM}lines{RESET} "
|
|
f"{YELLOW}{percent:>5.1f}%{RESET} "
|
|
f"{horizontal_bar(percent, color=cat_color)}"
|
|
)
|
|
|
|
print(f"\n{BOLD}{CYAN}LANGUAGE IMPACT (actual code){RESET}")
|
|
code_total = sum(v["code_lines"] for v in language_stats.values())
|
|
chars_total = sum(v["code_chars"] for v in language_stats.values())
|
|
|
|
# Color mapping for language groups
|
|
def get_lang_color(lang):
|
|
if "Python" in lang or "Mojo" in lang: return YELLOW
|
|
if "Dart" in lang or "Flutter" in lang: return TEAL
|
|
if "Java" in lang or "Kotlin" in lang or "Gradle" in lang: return ORANGE
|
|
if "JavaScript" in lang or "TypeScript" in lang or "CoffeeScript" in lang: return YELLOW
|
|
if "Swift" in lang: return ORANGE
|
|
if "C++" in lang or "C#" in lang or lang == "C" or "Header" in lang or "CUDA" in lang: return BLUE
|
|
if "Objective" in lang: return BLUE
|
|
if "Markdown" in lang or lang in ("reStructuredText", "AsciiDoc", "LaTeX", "Org Mode"): return MAGENTA
|
|
if "SQL" in lang: return CYAN
|
|
if "Shell" in lang or "Bash" in lang or "Zsh" in lang or "Fish" in lang or "KornShell" in lang: return GREEN
|
|
if "PowerShell" in lang or "Batch" in lang: return GREEN
|
|
if "Rust" in lang: return RED
|
|
if "Go" in lang or lang == "V" or "Zig" in lang or "Odin" in lang: return CYAN
|
|
if "Ruby" in lang or "Crystal" in lang: return RED
|
|
if "Elixir" in lang or "Erlang" in lang: return PURPLE
|
|
if "Haskell" in lang or "Elm" in lang or "PureScript" in lang or "OCaml" in lang: return PURPLE
|
|
if "Clojure" in lang or "Lisp" in lang or "Scheme" in lang or "Racket" in lang: return PURPLE
|
|
if "Lua" in lang: return BLUE
|
|
if "PHP" in lang: return PURPLE
|
|
if "Perl" in lang: return PINK
|
|
if "Scala" in lang or "Groovy" in lang: return RED
|
|
if "F#" in lang: return BLUE
|
|
if "Nim" in lang or "Julia" in lang: return PURPLE
|
|
if "Solidity" in lang: return ORANGE
|
|
if "Terraform" in lang or "HCL" in lang: return PURPLE
|
|
if "Vue" in lang or "Svelte" in lang or "Astro" in lang: return GREEN
|
|
if "Fortran" in lang or "COBOL" in lang or "Pascal" in lang or "Ada" in lang: return BLUE
|
|
if "Assembly" in lang or "VHDL" in lang or "SystemVerilog" in lang: return BLUE
|
|
if "Protobuf" in lang or "Thrift" in lang or "GraphQL" in lang: return CYAN
|
|
if lang in ("D", "Hack", "Gleam", "Wren", "Dhall"): return TEAL
|
|
if "ReScript" in lang or "Reason" in lang: return RED
|
|
if "VB" in lang: return BLUE
|
|
if "Nix" in lang or "Starlark" in lang or "Jsonnet" in lang: return CYAN
|
|
return WHITE
|
|
|
|
for language, data in sorted(language_stats.items(), key=lambda x: x[1]["code_lines"], reverse=True):
|
|
if data["code_lines"] == 0:
|
|
continue
|
|
percent = (data["code_lines"] / code_total * 100) if code_total else 0
|
|
lang_color = get_lang_color(language)
|
|
chars_str = format_number_short(data['code_chars'])
|
|
print(
|
|
f"{lang_color}{language:<18}{RESET} "
|
|
f"{WHITE}{data['code_lines']:>8,}{RESET} {DIM}lines{RESET} "
|
|
f"{PURPLE}{chars_str:>7}{RESET} {DIM}chars{RESET} "
|
|
f"{YELLOW}{percent:>5.1f}%{RESET} "
|
|
f"{horizontal_bar(percent, color=lang_color)}"
|
|
)
|
|
|
|
print(f"\n{BOLD}{CYAN}DECLARATIONS{RESET}")
|
|
# Sort by total declarations (functions + classes) descending
|
|
decl_items = [(lang, data) for lang, data in language_stats.items()
|
|
if (data.get('function_count', 0) + data.get('class_count', 0)) > 0]
|
|
decl_items.sort(key=lambda x: x[1].get('function_count', 0) + x[1].get('class_count', 0), reverse=True)
|
|
|
|
if decl_items:
|
|
# Dynamic column widths
|
|
dfunc_w = max(len(str(d.get('function_count', 0))) for _, d in decl_items)
|
|
dclass_w = max(len(str(d.get('class_count', 0))) for _, d in decl_items)
|
|
dtotal_w = max(len(str(d.get('function_count', 0) + d.get('class_count', 0))) for _, d in decl_items)
|
|
|
|
for language, data in decl_items:
|
|
funcs = data.get('function_count', 0)
|
|
classes = data.get('class_count', 0)
|
|
total_decl = funcs + classes
|
|
lang_color = get_lang_color(language)
|
|
print(
|
|
f"{lang_color}{language:<18}{RESET} "
|
|
f"{MAGENTA}{funcs:>{dfunc_w}}{RESET} {DIM}funcs{RESET} "
|
|
f"{PURPLE}{classes:>{dclass_w}}{RESET} {DIM}classes{RESET} "
|
|
f"{DIM}={RESET} "
|
|
f"{WHITE}{total_decl:>{dtotal_w}}{RESET} {DIM}total{RESET}"
|
|
)
|
|
|
|
# Totals row
|
|
sum_funcs = sum(d.get('function_count', 0) for _, d in decl_items)
|
|
sum_classes = sum(d.get('class_count', 0) for _, d in decl_items)
|
|
sum_total = sum_funcs + sum_classes
|
|
print(f"{DIM}{'─' * 18}{RESET} {DIM}{'─' * (dfunc_w + 6)} {'─' * (dclass_w + 8)} {'─' * 2} {'─' * (dtotal_w + 6)}{RESET}")
|
|
print(
|
|
f"{BOLD}{'Total':<18}{RESET} "
|
|
f"{MAGENTA}{sum_funcs:>{dfunc_w}}{RESET} {DIM}funcs{RESET} "
|
|
f"{PURPLE}{sum_classes:>{dclass_w}}{RESET} {DIM}classes{RESET} "
|
|
f"{DIM}={RESET} "
|
|
f"{WHITE}{BOLD}{sum_total:>{dtotal_w}}{RESET} {DIM}total{RESET}"
|
|
)
|
|
|
|
print(f"\n{BOLD}{CYAN}CODE QUALITY SNAPSHOT{RESET}")
|
|
# compute dynamic column widths based on values
|
|
code_items = [(l, d) for l, d in language_stats.items() if d['code_lines'] > 0]
|
|
if code_items:
|
|
files_w = max(len(str(d['files'])) for _, d in code_items)
|
|
code_w = max(len(f"{d['code_lines']:,}") for _, d in code_items)
|
|
med_w = max(len(str(d.get('median_code_lines', 0))) for _, d in code_items)
|
|
sd_w = max(len(f"{d.get('stddev_code_lines', 0):.1f}") for _, d in code_items)
|
|
avg_size_w = max(len(f"{(d.get('avg_size_kb',0)):.1f}") for _, d in code_items)
|
|
lpf_w = max(len(f"{d.get('avg_func_length', 0):.0f}") for _, d in code_items)
|
|
else:
|
|
files_w = code_w = med_w = sd_w = avg_size_w = lpf_w = 4
|
|
|
|
# Sort by quality score (descending)
|
|
def get_quality_score(item):
|
|
_, data = item
|
|
return data.get('quality_score', 0)
|
|
|
|
for language, data in sorted(code_items, key=get_quality_score, reverse=True):
|
|
comment_ratio = (data["comment_lines"] / data["code_lines"] * 100) if data["code_lines"] else 0
|
|
qs = data.get('quality_score', 0)
|
|
grade = score_grade(qs)
|
|
sc = score_color(qs, args.no_color)
|
|
avg_size = data.get("avg_size_kb", 0)
|
|
med = data.get("median_code_lines", 0)
|
|
sd = data.get("stddev_code_lines", 0)
|
|
lpf = data.get("avg_func_length", 0)
|
|
lang_color = get_lang_color(language)
|
|
# Color the comment ratio based on health
|
|
if not data.get('has_comment', True):
|
|
ratio_color = DIM
|
|
elif comment_ratio < 10:
|
|
ratio_color = RED
|
|
elif comment_ratio < 20:
|
|
ratio_color = YELLOW
|
|
else:
|
|
ratio_color = GREEN
|
|
print(
|
|
f"{sc}{grade:>2}{RESET} "
|
|
f"{lang_color}{language:<18}{RESET} "
|
|
f"{DIM}Files:{RESET}{CYAN}{data['files']:>{files_w}}{RESET} {DIM}|{RESET} "
|
|
f"{DIM}Code:{RESET}{WHITE}{data['code_lines']:>{code_w},}{RESET} {DIM}|{RESET} "
|
|
f"{DIM}L/F:{RESET}{WHITE}{lpf:>{lpf_w}.0f}{RESET} {DIM}|{RESET} "
|
|
f"{DIM}Comment:{RESET}{ratio_color}{comment_ratio:>4.1f}%{RESET} {DIM}|{RESET} "
|
|
f"{DIM}Size:{RESET}{WHITE}{avg_size:>{avg_size_w}.1f}{RESET}{DIM}kb{RESET} {DIM}|{RESET} "
|
|
f"{DIM}Score:{RESET} {sc}{qs:.0f}{RESET}"
|
|
)
|
|
|
|
# ---- CODE HEALTH REPORT ----
|
|
print(f"\n{BOLD}{MAGENTA}CODE HEALTH REPORT{RESET}")
|
|
|
|
# Compute overall quality score (weighted average by code lines)
|
|
total_code_lines = sum(d['code_lines'] for _, d in code_items)
|
|
if total_code_lines > 0:
|
|
overall_score = sum(d.get('quality_score', 0) * d['code_lines'] for _, d in code_items) / total_code_lines
|
|
else:
|
|
overall_score = 0
|
|
overall_grade = score_grade(overall_score)
|
|
osc = score_color(overall_score, args.no_color)
|
|
|
|
# Overall totals
|
|
total_funcs = sum(d['function_count'] for _, d in code_items)
|
|
total_classes = sum(d.get('class_count', 0) for _, d in code_items)
|
|
total_comments = sum(d['comment_lines'] for _, d in code_items)
|
|
total_todos = sum(d.get('todo_count', 0) for _, d in code_items)
|
|
total_imports = sum(d.get('import_count', 0) for _, d in code_items)
|
|
total_long_lines = sum(d.get('long_lines', 0) for _, d in code_items)
|
|
total_large_files = sum(d.get('large_file_count', 0) for _, d in code_items)
|
|
overall_cr = (total_comments / total_code_lines * 100) if total_code_lines else 0
|
|
overall_lpf = (total_code_lines / total_funcs) if total_funcs else 0
|
|
overall_td = (total_todos / total_code_lines * 1000) if total_code_lines else 0
|
|
max_nesting_all = max((d.get('max_nesting_depth', 0) for _, d in code_items), default=0)
|
|
all_total_lines = sum(d['code_lines'] + d['comment_lines'] + d['empty_lines'] for _, d in code_items)
|
|
overall_llr = (total_long_lines / all_total_lines * 100) if all_total_lines else 0
|
|
|
|
# Grade display
|
|
print(f"\n {BOLD}Overall Grade:{RESET} {osc}{BOLD}{overall_grade}{RESET} {DIM}({osc}{overall_score:.0f}{RESET}{DIM}/100){RESET}")
|
|
print(f" {BOLD}Quality Bar:{RESET} {horizontal_bar(overall_score, width=30, color=osc)}")
|
|
|
|
# Breakdown metrics
|
|
cr_color = GREEN if overall_cr >= 20 else (YELLOW if overall_cr >= 10 else RED)
|
|
lpf_color = GREEN if overall_lpf <= 30 else (YELLOW if overall_lpf <= 50 else RED)
|
|
td_color = GREEN if overall_td <= 5 else (YELLOW if overall_td <= 10 else RED)
|
|
nesting_color = GREEN if max_nesting_all <= 5 else (YELLOW if max_nesting_all <= 7 else RED)
|
|
llr_color = GREEN if overall_llr <= 3 else (YELLOW if overall_llr <= 8 else RED)
|
|
lf_total_files = sum(d['files'] for _, d in code_items)
|
|
lf_pct = (total_large_files / lf_total_files * 100) if lf_total_files else 0
|
|
lf_color = GREEN if lf_pct <= 15 else (YELLOW if lf_pct <= 30 else RED)
|
|
|
|
print(f"\n {BOLD}Metrics Breakdown:{RESET}")
|
|
print(f" {DIM}Comment Ratio:{RESET} {cr_color}{overall_cr:>5.1f}%{RESET} {DIM}({total_comments:,} comment lines / {total_code_lines:,} code lines){RESET}")
|
|
print(f" {DIM}Lines per Func:{RESET} {lpf_color}{overall_lpf:>5.1f}{RESET} {DIM}({total_code_lines:,} code lines / {total_funcs:,} functions){RESET}")
|
|
print(f" {DIM}TODO Density:{RESET} {td_color}{overall_td:>5.1f}{RESET} {DIM}({total_todos} TODOs per 1K lines){RESET}")
|
|
print(f" {DIM}Max Nesting:{RESET} {nesting_color}{max_nesting_all:>5}{RESET} {DIM}(deepest brace nesting across project){RESET}")
|
|
print(f" {DIM}Long Lines:{RESET} {llr_color}{overall_llr:>5.1f}%{RESET} {DIM}({total_long_lines:,} lines > 120 chars){RESET}")
|
|
print(f" {DIM}Large Files:{RESET} {lf_color}{total_large_files:>5}{RESET} {DIM}({lf_pct:.0f}% files > 300 lines){RESET}")
|
|
print(f" {DIM}Imports:{RESET} {WHITE}{total_imports:>5,}{RESET} {DIM}(total import statements){RESET}")
|
|
|
|
# Per-language quality bar chart (sorted by score)
|
|
print(f"\n {BOLD}Per-Language Scores:{RESET}")
|
|
for language, data in sorted(code_items, key=get_quality_score, reverse=True):
|
|
qs = data.get('quality_score', 0)
|
|
grade = score_grade(qs)
|
|
sc = score_color(qs, args.no_color)
|
|
lang_color = get_lang_color(language)
|
|
bar = horizontal_bar(qs, width=20, color=sc)
|
|
print(f" {lang_color}{language:<18}{RESET} {bar} {sc}{grade:>2}{RESET} {DIM}({qs:.0f}){RESET}")
|
|
|
|
print(f"\n{BOLD}{CYAN}TOP FILES ({args.top}){RESET}")
|
|
# prepare filtered list
|
|
filtered = [f for f in per_file if f['code_lines'] >= args.min_code_lines]
|
|
|
|
# compute widths for predictable columns
|
|
code_w = max((len(f"{f['code_lines']:,}") for f in filtered), default=6)
|
|
lang_w = max((len(f['language']) for f in filtered), default=8)
|
|
size_w = max((len(f"{f['size_bytes']/1024:.1f}" ) for f in filtered), default=6)
|
|
func_w = max((len(str(f['function_count'])) for f in filtered), default=4)
|
|
todo_w = max((len(str(f['todo_count'])) for f in filtered), default=3)
|
|
comment_w = max((len(f"{f['comment_ratio']:.1f}") for f in filtered), default=4)
|
|
|
|
# largest by code
|
|
largest_by_code = sorted(filtered, key=lambda x: x['code_lines'], reverse=True)[:args.top]
|
|
print(f"\n{BOLD}{PURPLE}Largest by code lines{RESET}")
|
|
for f in largest_by_code:
|
|
size_kb = f['size_bytes'] / 1024
|
|
lang_color = get_lang_color(f['language'])
|
|
print(f"{WHITE}{f['code_lines']:{code_w},}{RESET} {DIM}|{RESET} {lang_color}{f['language']:<{lang_w}}{RESET} {DIM}|{RESET} {CYAN}{size_kb:>{size_w}.1f}{RESET}{DIM}kb{RESET} {DIM}|{RESET} {DIM}funcs:{RESET}{MAGENTA}{f['function_count']:{func_w}}{RESET} {DIM}todos:{RESET}{ORANGE}{f['todo_count']:{todo_w}}{RESET} {DIM}|{RESET} {DIM}comment:{RESET}{YELLOW}{f['comment_ratio']:{comment_w}.1f}%{RESET} {TEAL}{Path(f['relpath']).relative_to(root)}{RESET}")
|
|
|
|
# Most TODOs
|
|
most_todos = sorted(filtered, key=lambda x: x['todo_count'], reverse=True)[:args.top]
|
|
print(f"\n{BOLD}{ORANGE}Files with most TODO/FIXME{RESET}")
|
|
for f in most_todos:
|
|
if f['todo_count'] == 0:
|
|
continue
|
|
lang_color = get_lang_color(f['language'])
|
|
print(f"{ORANGE}{f['todo_count']:{todo_w}}{RESET} {DIM}TODOs |{RESET} {lang_color}{f['language']:<{lang_w}}{RESET} {DIM}|{RESET} {DIM}code:{RESET}{WHITE}{f['code_lines']:{code_w},}{RESET} {DIM}|{RESET} {DIM}comment:{RESET}{YELLOW}{f['comment_ratio']:{comment_w}.1f}%{RESET} {TEAL}{Path(f['relpath']).relative_to(root)}{RESET}")
|
|
|
|
# Lowest comment ratio (but with at least some code)
|
|
low_comments = sorted((f for f in filtered if f.get('has_comment', True)), key=lambda x: x['comment_ratio'])[:args.top]
|
|
print(f"\n{BOLD}{RED}Files with lowest comment ratio{RESET} {DIM}(languages that support comments){RESET}")
|
|
for f in low_comments:
|
|
lang_color = get_lang_color(f['language'])
|
|
ratio_color = RED if f['comment_ratio'] < 5 else (YELLOW if f['comment_ratio'] < 10 else GREEN)
|
|
print(f"{ratio_color}{f['comment_ratio']:{comment_w}.1f}%{RESET} {DIM}|{RESET} {lang_color}{f['language']:<{lang_w}}{RESET} {DIM}|{RESET} {DIM}code:{RESET}{WHITE}{f['code_lines']:{code_w},}{RESET} {DIM}|{RESET} {DIM}todos:{RESET}{ORANGE}{f['todo_count']:{todo_w}}{RESET} {TEAL}{Path(f['relpath']).relative_to(root)}{RESET}")
|
|
|
|
elapsed = result.get('elapsed', 0)
|
|
workers = result.get('workers', 1)
|
|
total_files = len(result.get('per_file', []))
|
|
total_lines = sum(f['total_lines'] for f in result.get('per_file', []))
|
|
fps = f"{total_files / elapsed:,.0f} files/s" if elapsed > 0 else "N/A"
|
|
lps_raw = total_lines / elapsed if elapsed > 0 else 0
|
|
if lps_raw >= 1_000_000:
|
|
lps = f"{lps_raw / 1_000_000:.1f}M lines/s"
|
|
elif lps_raw >= 1_000:
|
|
lps = f"{lps_raw / 1_000:.1f}K lines/s"
|
|
else:
|
|
lps = f"{lps_raw:.0f} lines/s"
|
|
print(f"\n{GREEN}Analysis complete ✔{RESET} {DIM}│ {elapsed:.2f}s │ {workers} threads │ {fps} │ {lps} │ v{VERSION}{RESET}\n")
|
|
|
|
# --------------------------------------------------
|
|
# Runner
|
|
# --------------------------------------------------
|
|
|
|
def run(root: Path, args):
|
|
t0 = time.time()
|
|
|
|
language_stats = defaultdict(lambda: {
|
|
"files": 0,
|
|
"code_lines": 0,
|
|
"code_chars": 0,
|
|
"comment_lines": 0,
|
|
"empty_lines": 0,
|
|
"function_count": 0,
|
|
"class_count": 0,
|
|
"todo_count": 0,
|
|
"import_count": 0,
|
|
"long_lines": 0,
|
|
"max_nesting_depth": 0,
|
|
"nesting_depths": [],
|
|
"func_lengths": [],
|
|
"longest_lines": [],
|
|
"sizes": [],
|
|
"per_file_code_lines": [],
|
|
"has_comment": False,
|
|
})
|
|
|
|
category_totals = {
|
|
"code": 0,
|
|
"config": 0,
|
|
"docs": 0
|
|
}
|
|
|
|
per_file = []
|
|
|
|
_bypass = args.bypass_excluded
|
|
|
|
# load dynamic ignore patterns from .gitignore/.ignore files
|
|
if _bypass:
|
|
ignore_dirs = set()
|
|
ignore_globs = []
|
|
negation_globs = []
|
|
negation_dirs = set()
|
|
else:
|
|
ignore_dirs = set(IGNORE_DIRECTORIES)
|
|
ignore_globs, extra_dir_names, negation_globs, negation_dirs = load_ignore_files(root)
|
|
for d in extra_dir_names:
|
|
ignore_dirs.add(Path(d).name)
|
|
|
|
exclude_paths = [] if _bypass else [ (root / e).resolve() for e in (args.exclude or []) ]
|
|
|
|
def is_negated(rel_path: str, name: str) -> bool:
|
|
"""Check if a path matches a negation pattern (should NOT be ignored)."""
|
|
for neg_dir in negation_dirs:
|
|
if fnmatch.fnmatch(rel_path, neg_dir) or fnmatch.fnmatch(rel_path, neg_dir + '/*'):
|
|
return True
|
|
if rel_path == neg_dir or rel_path.startswith(neg_dir + '/'):
|
|
return True
|
|
for pat in negation_globs:
|
|
if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(name, pat):
|
|
return True
|
|
return False
|
|
|
|
show_anim = not args.json and not args.no_color and not args.no_animation
|
|
has_excludes = bool(exclude_paths)
|
|
root_str = str(root.resolve())
|
|
_include_hidden = args.include_hidden or _bypass
|
|
_ext_filter = args.ext
|
|
_spinners = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
|
|
# ── Phase 1: Collect all file paths ──────────────────────────────────
|
|
all_file_paths = []
|
|
_last_anim = time.monotonic()
|
|
|
|
for current_path, directory_names, file_names in os.walk(root, followlinks=False):
|
|
current_path_obj = Path(current_path)
|
|
|
|
# Skip if current path is a symlink pointing outside the project
|
|
if current_path_obj.is_symlink():
|
|
try:
|
|
if not str(current_path_obj.resolve()).startswith(root_str):
|
|
directory_names[:] = []
|
|
continue
|
|
except (OSError, ValueError):
|
|
directory_names[:] = []
|
|
continue
|
|
|
|
# if current path is within an excluded path, skip descending
|
|
if has_excludes:
|
|
try:
|
|
current_res = current_path_obj.resolve()
|
|
if any(ex == current_res or ex in current_res.parents for ex in exclude_paths):
|
|
directory_names[:] = []
|
|
continue
|
|
except (OSError, ValueError):
|
|
directory_names[:] = []
|
|
continue
|
|
|
|
# prune directory_names to avoid descending into ignores/excludes/hidden
|
|
new_dirs = []
|
|
for d in directory_names:
|
|
dpath = current_path_obj / d
|
|
|
|
# Skip symlinks that might point outside the project
|
|
if dpath.is_symlink():
|
|
try:
|
|
if not str(dpath.resolve()).startswith(root_str):
|
|
continue
|
|
except (OSError, ValueError):
|
|
continue
|
|
|
|
# Calculate relative path for pattern matching
|
|
try:
|
|
rel = str(dpath.relative_to(root)).replace('\\', '/')
|
|
except ValueError:
|
|
continue
|
|
|
|
path_is_negated = is_negated(rel, d)
|
|
|
|
if d in ignore_dirs and not path_is_negated:
|
|
continue
|
|
|
|
if has_excludes:
|
|
try:
|
|
dpath_res = dpath.resolve()
|
|
if any(ex == dpath_res or ex in dpath_res.parents for ex in exclude_paths):
|
|
continue
|
|
except (OSError, ValueError):
|
|
continue
|
|
|
|
if not _include_hidden and d.startswith('.'):
|
|
continue
|
|
|
|
if not path_is_negated:
|
|
if any(fnmatch.fnmatch(rel, pat) for pat in ignore_globs):
|
|
continue
|
|
|
|
new_dirs.append(d)
|
|
directory_names[:] = new_dirs
|
|
|
|
for file in file_names:
|
|
file_path = Path(current_path) / file
|
|
if has_excludes:
|
|
try:
|
|
file_res = file_path.resolve()
|
|
except (OSError, ValueError):
|
|
continue
|
|
if any(ex == file_res or ex in file_res.parents for ex in exclude_paths):
|
|
continue
|
|
if not _bypass and should_ignore_file(file_path, root, IGNORE_FILE_PATTERNS, ignore_globs, negation_globs):
|
|
continue
|
|
if _ext_filter and file_path.suffix.lower() not in _ext_filter:
|
|
continue
|
|
all_file_paths.append(file_path)
|
|
if show_anim:
|
|
_now = time.monotonic()
|
|
if _now - _last_anim >= 0.15:
|
|
_last_anim = _now
|
|
sp = _spinners[len(all_file_paths) % len(_spinners)]
|
|
sys.stdout.write(f"\r {CYAN}{sp}{RESET} {GRAY}Discovering files... {GOLD}{len(all_file_paths):,}{RESET} ")
|
|
sys.stdout.flush()
|
|
|
|
total_to_scan = len(all_file_paths)
|
|
|
|
# ── Phase 2: Analyze files in parallel with progress ─────────────────
|
|
if show_anim:
|
|
sys.stdout.write(f"\r {GREEN}✓{RESET} {GRAY}Discovered {GOLD}{total_to_scan:,}{GRAY} files to analyze{RESET} \n")
|
|
sys.stdout.flush()
|
|
sys.stdout.write(HIDE_CURSOR)
|
|
print() # line 1 for progress bar
|
|
print() # line 2 for filename
|
|
|
|
cpu = os.cpu_count() or 1
|
|
workers = args.workers if args.workers > 0 else min(cpu, 16)
|
|
|
|
# Use processes for large workloads (true parallelism), threads for small ones (low overhead)
|
|
use_processes = total_to_scan > 500
|
|
Executor = ProcessPoolExecutor if use_processes else ThreadPoolExecutor
|
|
|
|
# Disable cyclic GC during analysis — the growing results list causes
|
|
# increasingly expensive gen2 collections that stall all threads/processes.
|
|
gc.disable()
|
|
try:
|
|
with Executor(max_workers=workers) as executor:
|
|
# Bounded submit + wait(FIRST_COMPLETED) loop:
|
|
# - Each task is a single file → one slow file only blocks one worker
|
|
# - At most (workers*4) futures in flight → O(1) per wait() call
|
|
# - Workers always have queued work → full utilization
|
|
results = []
|
|
_last_progress = time.monotonic()
|
|
processed = 0
|
|
_MAX_INFLIGHT = workers * 4
|
|
_pending = set()
|
|
_path_map = {} # future -> path
|
|
_path_iter = iter(all_file_paths)
|
|
|
|
def _submit_up_to_limit():
|
|
"""Keep _pending at _MAX_INFLIGHT futures."""
|
|
while len(_pending) < _MAX_INFLIGHT:
|
|
fp = next(_path_iter, None)
|
|
if fp is None:
|
|
return
|
|
fut = executor.submit(analyze_file, fp)
|
|
_pending.add(fut)
|
|
_path_map[fut] = fp
|
|
|
|
_submit_up_to_limit()
|
|
while _pending:
|
|
finished, _pending = wait(_pending, return_when=FIRST_COMPLETED)
|
|
for fut in finished:
|
|
processed += 1
|
|
fp = _path_map.pop(fut)
|
|
if show_anim:
|
|
_now = time.monotonic()
|
|
if _now - _last_progress >= 0.1 or processed == total_to_scan:
|
|
_last_progress = _now
|
|
try:
|
|
rel = str(fp.relative_to(root))
|
|
except ValueError:
|
|
rel = str(fp)
|
|
scan_progress(processed, total_to_scan, rel)
|
|
try:
|
|
info = fut.result()
|
|
except Exception:
|
|
info = None
|
|
if info is not None:
|
|
results.append(info)
|
|
_submit_up_to_limit()
|
|
finally:
|
|
gc.enable()
|
|
gc.collect()
|
|
|
|
if show_anim:
|
|
clear_scan_progress()
|
|
sys.stdout.write(SHOW_CURSOR)
|
|
|
|
# ── Phase 3: Accumulate results ──────────────────────────────────────
|
|
for info in results:
|
|
if info['code_lines'] < args.min_code_lines:
|
|
continue
|
|
|
|
per_file.append(info)
|
|
|
|
lang = info['language']
|
|
language_stats[lang]['files'] += 1
|
|
language_stats[lang]['code_lines'] += info['code_lines']
|
|
language_stats[lang]['code_chars'] += info['code_chars']
|
|
language_stats[lang]['comment_lines'] += info['comment_lines']
|
|
language_stats[lang]['empty_lines'] += info['empty_lines']
|
|
language_stats[lang]['function_count'] += info['function_count']
|
|
language_stats[lang]['class_count'] += info['class_count']
|
|
language_stats[lang]['todo_count'] += info['todo_count']
|
|
language_stats[lang]['import_count'] += info['import_count']
|
|
language_stats[lang]['long_lines'] += info['long_lines']
|
|
if info['max_nesting_depth'] > language_stats[lang]['max_nesting_depth']:
|
|
language_stats[lang]['max_nesting_depth'] = info['max_nesting_depth']
|
|
language_stats[lang]['nesting_depths'].append(info['max_nesting_depth'])
|
|
if info['function_count'] > 0:
|
|
language_stats[lang]['func_lengths'].append(info['avg_func_length'])
|
|
language_stats[lang]['longest_lines'].append(info['longest_line'])
|
|
language_stats[lang]['sizes'].append(info['size_bytes'])
|
|
language_stats[lang]['per_file_code_lines'].append(info['code_lines'])
|
|
language_stats[lang]['has_comment'] = language_stats[lang].get('has_comment', False) or bool(info.get('has_comment'))
|
|
|
|
category_totals[info['category']] += info['total_lines']
|
|
|
|
# ── Phase 4: Post-process language stats ─────────────────────────────
|
|
for lang, data in language_stats.items():
|
|
sizes = data['sizes']
|
|
pl = data['per_file_code_lines']
|
|
data['avg_size_kb'] = (sum(sizes) / len(sizes) / 1024) if sizes else 0
|
|
data['median_code_lines'] = int(statistics.median(pl)) if pl else 0
|
|
data['stddev_code_lines'] = statistics.pstdev(pl) if pl else 0
|
|
data['max_file_lines'] = max(pl) if pl else 0
|
|
data['large_file_count'] = sum(1 for x in pl if x > 300)
|
|
data['avg_func_length'] = (data['code_lines'] / data['function_count']) if data['function_count'] else 0
|
|
nd = data.get('nesting_depths', [])
|
|
data['avg_nesting_depth'] = (sum(nd) / len(nd)) if nd else 0
|
|
ll = data.get('longest_lines', [])
|
|
data['max_longest_line'] = max(ll) if ll else 0
|
|
data['todo_density'] = (data['todo_count'] / data['code_lines'] * 1000) if data['code_lines'] else 0
|
|
total_lines = data['code_lines'] + data['comment_lines'] + data['empty_lines']
|
|
data['long_line_ratio'] = (data['long_lines'] / total_lines * 100) if total_lines else 0
|
|
data['quality_score'] = compute_quality_score(data)
|
|
|
|
elapsed = time.time() - t0
|
|
|
|
result = {
|
|
'language_stats': language_stats,
|
|
'category_totals': category_totals,
|
|
'per_file': per_file,
|
|
'elapsed': elapsed,
|
|
'workers': workers,
|
|
}
|
|
|
|
if args.json:
|
|
out = {
|
|
'root': str(root),
|
|
'summary': {
|
|
'files': len(per_file),
|
|
'total_lines': sum(f['total_lines'] for f in per_file),
|
|
'elapsed': round(elapsed, 3),
|
|
'workers': workers,
|
|
},
|
|
'languages': {k: {"files": v['files'], "code_lines": v['code_lines'], "comment_lines": v['comment_lines']} for k, v in language_stats.items()},
|
|
'top_files': {
|
|
'largest_by_code': [ {k: f[k] for k in ('path','code_lines','size_bytes','function_count','todo_count')} for f in sorted(per_file, key=lambda x: x['code_lines'], reverse=True)[:args.top] ],
|
|
},
|
|
}
|
|
if not args.no_git:
|
|
git = get_git_info(root, args.commit_msg_max)
|
|
if git:
|
|
out['git'] = git
|
|
print(json.dumps(out, indent=2))
|
|
return result
|
|
|
|
print_summary(result, root, args)
|
|
return result
|
|
|
|
|
|
if __name__ == '__main__':
|
|
args = parse_args()
|
|
root = Path(args.path or '.').resolve()
|
|
if args.ext:
|
|
args.ext = [e if e.startswith('.') else '.' + e for e in args.ext]
|
|
|
|
# handle install/uninstall requests
|
|
if args.install or args.uninstall:
|
|
chosen = args.install_dir if args.install_dir else default_install_dir()
|
|
install_dir = os.path.expanduser(chosen)
|
|
if args.install:
|
|
ok = install_self(install_dir)
|
|
sys.exit(0 if ok else 2)
|
|
if args.uninstall:
|
|
ok = uninstall_self(install_dir)
|
|
sys.exit(0 if ok else 2)
|
|
|
|
# Startup animation (skip for JSON output, no-color, or no-animation)
|
|
if not args.json and not args.no_color and not args.no_animation:
|
|
try:
|
|
startup_sequence(root)
|
|
except Exception:
|
|
pass # graceful fallback if terminal doesn't support ANSI
|
|
|
|
try:
|
|
run(root, args)
|
|
except KeyboardInterrupt:
|
|
sys.stdout.write(SHOW_CURSOR)
|
|
print(f"\n{RED}Interrupted.{RESET}")
|
|
sys.exit(1)
|
|
|