288 lines
12 KiB
Python
288 lines
12 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
from pathlib import Path
|
|
import math
|
|
|
|
from ..core.scanner import find_source_files
|
|
from ..config.languages import LANGUAGE_EXTENSIONS
|
|
from .tooltip import ToolTip
|
|
|
|
|
|
|
|
class MetricsTab(ttk.Frame):
|
|
"""Tab that computes Cyclomatic Complexity and Maintainability Index.
|
|
|
|
Uses `lizard` to extract per-function CC and aggregates per-file values.
|
|
"""
|
|
|
|
def __init__(self, parent, topbar, app=None, *args, **kwargs):
|
|
super().__init__(parent, *args, **kwargs)
|
|
self.topbar = topbar
|
|
self.app = app
|
|
|
|
controls = ttk.Frame(self)
|
|
controls.grid(row=0, column=0, sticky="ew", padx=8, pady=6)
|
|
self.run_btn = ttk.Button(controls, text="Run Metrics", command=self.start_metrics)
|
|
self.run_btn.grid(row=0, column=0, sticky="w")
|
|
self.cancel_btn = ttk.Button(controls, text="Cancel", command=self.cancel, state="disabled")
|
|
self.cancel_btn.grid(row=0, column=1, padx=(8, 0))
|
|
self.export_btn = ttk.Button(controls, text="Export CSV", command=self.export_csv, state="disabled")
|
|
self.export_btn.grid(row=0, column=2, padx=(8, 0))
|
|
|
|
# Treeview for metrics
|
|
columns = ("name", "path", "avg_cc", "max_cc", "func_count", "mi", "lang")
|
|
self.tree = ttk.Treeview(self, columns=columns, show="headings")
|
|
self.tree.heading("name", text="File")
|
|
self.tree.heading("path", text="Path")
|
|
self.tree.heading("avg_cc", text="Avg CC")
|
|
self.tree.heading("max_cc", text="Max CC")
|
|
self.tree.heading("func_count", text="#Functions")
|
|
self.tree.heading("mi", text="Maintainability Index")
|
|
self.tree.heading("lang", text="Language")
|
|
|
|
self.tree.column("name", width=300, anchor="w", stretch=True)
|
|
self.tree.column("path", width=400, anchor="w", stretch=True)
|
|
self.tree.column("avg_cc", width=80, anchor="e", stretch=False)
|
|
self.tree.column("max_cc", width=80, anchor="e", stretch=False)
|
|
self.tree.column("func_count", width=80, anchor="e", stretch=False)
|
|
self.tree.column("mi", width=120, anchor="e", stretch=False)
|
|
self.tree.column("lang", width=120, anchor="w", stretch=False)
|
|
|
|
self.tree.grid(row=1, column=0, sticky="nsew", padx=8, pady=8)
|
|
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
|
|
hsb = ttk.Scrollbar(self, orient="horizontal", command=self.tree.xview)
|
|
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
|
vsb.grid(row=1, column=1, sticky="ns")
|
|
hsb.grid(row=2, column=0, columnspan=2, sticky="ew", padx=8)
|
|
|
|
# Small help icon with tooltip explaining columns
|
|
help_txt = (
|
|
"Columns:\n"
|
|
"File: filename\n"
|
|
"Path: full file path\n"
|
|
"Avg CC: average cyclomatic complexity across functions\n"
|
|
"Max CC: maximum cyclomatic complexity in the file\n"
|
|
"#Functions: number of functions detected\n"
|
|
"Maintainability Index: approximate MI (0-100)\n"
|
|
"Language: detected language from extension"
|
|
)
|
|
help_frame = ttk.Frame(self)
|
|
help_frame.grid(row=0, column=1, sticky="e", padx=(0,12))
|
|
help_lbl = tk.Label(help_frame, text="❓", foreground='#2b6fb2', cursor='question_arrow')
|
|
help_lbl.pack()
|
|
# attach tooltip with 1s delay
|
|
ToolTip(help_lbl, help_txt, delay=1000)
|
|
|
|
self.rowconfigure(1, weight=1)
|
|
self.columnconfigure(0, weight=1)
|
|
|
|
def start_metrics(self):
|
|
# determine list of paths to analyze: prefer profile.paths if available
|
|
paths = []
|
|
pr = getattr(self.topbar, "current_profile", None)
|
|
if not pr:
|
|
pname = getattr(self.topbar, "profile_var", None)
|
|
if pname:
|
|
from ..config import profiles as profiles_cfg
|
|
pr = profiles_cfg.find_profile(pname.get()) if hasattr(pname, "get") else profiles_cfg.find_profile(str(pname))
|
|
|
|
if pr:
|
|
paths = pr.get("paths") or []
|
|
else:
|
|
p = self.topbar.path_var.get().strip()
|
|
if p:
|
|
paths = [p]
|
|
|
|
if not paths:
|
|
messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.")
|
|
return
|
|
|
|
# compute allowed extensions and ignore patterns from profile
|
|
allowed_exts = None
|
|
if pr:
|
|
langs = pr.get("languages", []) or []
|
|
exts = []
|
|
for ln in langs:
|
|
if ln in LANGUAGE_EXTENSIONS:
|
|
exts.extend(LANGUAGE_EXTENSIONS[ln])
|
|
else:
|
|
val = ln.strip()
|
|
if val.startswith('.'):
|
|
exts.append(val.lower())
|
|
elif len(val) <= 5 and not val.isalpha():
|
|
exts.append(f".{val.lower()}")
|
|
if exts:
|
|
allowed_exts = set(exts)
|
|
ignore_patterns = pr.get("ignore", []) if pr else None
|
|
|
|
# aggregate targets from all configured paths
|
|
targets = []
|
|
for pstr in paths:
|
|
pth = Path(pstr)
|
|
try:
|
|
if pth.is_dir():
|
|
targets.extend(find_source_files(pth, allowed_extensions=allowed_exts, ignore_patterns=ignore_patterns))
|
|
elif pth.is_file():
|
|
targets.append(pth)
|
|
except Exception:
|
|
continue
|
|
|
|
# prepare UI
|
|
self.run_btn.config(state="disabled")
|
|
self.cancel_btn.config(state="normal")
|
|
self.export_btn.config(state="disabled")
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
|
|
# use worker manager if available
|
|
# lazy import of lizard: if not installed, inform the user
|
|
try:
|
|
import lizard as _lizard
|
|
self._lizard = _lizard
|
|
except Exception:
|
|
messagebox.showerror("Missing dependency", "The 'lizard' package is required to compute metrics.\nPlease install requirements.txt (pip install -r requirements.txt).")
|
|
self._finish()
|
|
return
|
|
try:
|
|
if getattr(self, 'app', None) and getattr(self.app, 'worker', None):
|
|
self._task_id = self.app.worker.map_iterable(
|
|
func=self._analyze_file_metrics,
|
|
items=targets,
|
|
kind='thread',
|
|
on_progress=self._on_file_result,
|
|
on_done=self._on_all_done,
|
|
)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
# fallback: run sequentially
|
|
results = []
|
|
for t in targets:
|
|
try:
|
|
r = self._analyze_file_metrics(t)
|
|
results.append(r)
|
|
self._on_file_result(r)
|
|
except Exception as e:
|
|
self._on_file_result({"file": str(t), "error": str(e)})
|
|
self._on_all_done(results)
|
|
|
|
def cancel(self):
|
|
messagebox.showinfo("Cancel", "Cancellation requested but not supported yet.")
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log("Metrics cancelled by user", "WARNING")
|
|
except Exception:
|
|
pass
|
|
self._finish()
|
|
|
|
def _analyze_file_metrics(self, path):
|
|
"""Analyze a single file with lizard and compute per-file aggregates.
|
|
|
|
Returns a dict suitable for `on_progress` handling.
|
|
"""
|
|
p = Path(path)
|
|
# prefer cached module if available
|
|
lizard_mod = getattr(self, '_lizard', None)
|
|
if lizard_mod is None:
|
|
import lizard as lizard_mod
|
|
res = lizard_mod.analyze_file(str(p))
|
|
funcs = res.function_list if hasattr(res, 'function_list') else []
|
|
cc_values = [getattr(f, 'cyclomatic_complexity', 0) for f in funcs]
|
|
func_count = len(cc_values)
|
|
avg_cc = float(sum(cc_values) / func_count) if func_count else 0.0
|
|
max_cc = int(max(cc_values)) if cc_values else 0
|
|
|
|
# Maintainability Index (approximate): use Coleman-Oman formula when possible.
|
|
# MI = 171 - 5.2 * ln(HV) - 0.23 * CC - 16.2 * ln(LOC)
|
|
# lizard may not provide Halstead volume (HV); fall back to CC and LOC only.
|
|
loc = getattr(res, 'nloc', None) or (sum(1 for _ in p.open('r', encoding='utf-8', errors='ignore')))
|
|
hv = None
|
|
try:
|
|
# some lizard versions expose `__dict__` extensions; try to read halstead volume if present
|
|
hv = getattr(res, 'halstead_volume', None)
|
|
except Exception:
|
|
hv = None
|
|
|
|
mi_raw = 171.0
|
|
try:
|
|
if hv and hv > 0 and loc and loc > 0:
|
|
mi_raw = 171 - 5.2 * math.log(max(1.0, hv)) - 0.23 * avg_cc - 16.2 * math.log(max(1.0, loc))
|
|
elif loc and loc > 0:
|
|
mi_raw = 171 - 0.23 * avg_cc - 16.2 * math.log(max(1.0, loc))
|
|
else:
|
|
mi_raw = 0.0
|
|
mi = max(0.0, min(100.0, (mi_raw * 100.0 / 171.0)))
|
|
except Exception:
|
|
mi = 0.0
|
|
|
|
# attempt to map extension to language
|
|
lang = self._ext_to_language(p.suffix)
|
|
|
|
return {
|
|
"file": str(p),
|
|
"name": p.name,
|
|
"avg_cc": round(avg_cc, 2),
|
|
"max_cc": int(max_cc),
|
|
"func_count": int(func_count),
|
|
"mi": round(mi, 2),
|
|
"language": lang,
|
|
}
|
|
|
|
def _ext_to_language(self, ext: str):
|
|
if not ext:
|
|
return "unknown"
|
|
for lang, exts in LANGUAGE_EXTENSIONS.items():
|
|
if ext.lower() in exts:
|
|
return lang
|
|
return ext.lower()
|
|
|
|
def _on_file_result(self, r):
|
|
try:
|
|
if not isinstance(r, dict):
|
|
return
|
|
if "error" in r:
|
|
self.tree.insert("", "end", values=(r.get('name') or '', r.get('file') or '', 0, 0, 0, r.get('error'), ''))
|
|
else:
|
|
self.tree.insert("", "end", values=(r.get('name'), r.get('file'), r.get('avg_cc'), r.get('max_cc'), r.get('func_count'), r.get('mi'), r.get('language')))
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_all_done(self, results):
|
|
try:
|
|
self.export_btn.config(state="normal")
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Metrics finished: {len(self.tree.get_children())} items", "INFO")
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._finish()
|
|
|
|
def _finish(self):
|
|
self.run_btn.config(state="normal")
|
|
self.cancel_btn.config(state="disabled")
|
|
|
|
def export_csv(self):
|
|
path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*")])
|
|
if not path:
|
|
return
|
|
headers = ["name", "path", "avg_cc", "max_cc", "func_count", "mi", "language"]
|
|
from ..utils.csv_exporter import export_rows_to_csv
|
|
try:
|
|
rows = (self.tree.item(child, "values") for child in self.tree.get_children())
|
|
written = export_rows_to_csv(path, headers, rows)
|
|
messagebox.showinfo("Export", f"Exported {written} rows to {path}")
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Exported {written} rows to {path}", "INFO")
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
messagebox.showerror("Export Error", str(e))
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Export error: {e}", "ERROR")
|
|
except Exception:
|
|
pass
|