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