Coverage for vibefpmgui/complexity_panel.py: 91%
346 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The *Complexity* tab: each procedure's complexity, as lizard measures it
2(see `vibefpmgui.complexity`): CCN, lines of code, tokens, parameters,
3length and nesting depth, the values over lizard's thresholds in red; each
4file's totals; and the selected procedure's control-flow graph (see
5`vibefpmgui.control_flow`, `vibefpmgui.cfg_view`). Click a procedure to open
6it; click a file to list only its procedures (its first one selected and
7drawn); click a node of the graph to open its lines.
8"""
10from __future__ import annotations
12import html
13from pathlib import Path
14from typing import NamedTuple
16from qtpy.QtCore import Qt, Signal
17from qtpy.QtGui import QColor, QFont
18from qtpy.QtWidgets import (
19 QAbstractItemView,
20 QApplication,
21 QCheckBox,
22 QHBoxLayout,
23 QHeaderView,
24 QLabel,
25 QLineEdit,
26 QSplitter,
27 QVBoxLayout,
28 QWidget,
29)
31from . import complexity as cx
32from . import control_flow as cf
33from .build_monitor import PALETTES, TitleIcon, app_is_light, panel_stylesheet
34from .cfg_view import CfgView
35from .icons import symbol_icon
36from .metrics_table import Column, MetricsTable, RowStyle
37from .source_text import read_source
38from .widgets import StatusLabel
40OVER = "#e03131" # a value over its threshold
41PROCEDURE_COLUMNS = ("FILE", "PROCEDURE", "CCN", "NLOC", "TOKENS", "PARAMS", "LENGTH", "DEPTH")
42FILE_COLUMN, NAME_COLUMN = 0, 1 # (in the procedures' table)
43MAX_TEXT_WIDTH = 320 # a file's or procedure's column at most (wider: drag it; a long path elided at its start)
44FILE_COLUMNS = ("FILE", "NLOC", "PROCEDURES", "AVG CCN", "MAX CCN", "OVER")
45COLUMN_TIPS = {
46 "CCN": "Cyclomatic complexity: 1 + its decisions (if, else if, loops, case, type is, where, .and., .or.)",
47 "NLOC": "Lines of code (not blank, not only a comment)",
48 "TOKENS": "Tokens in its code",
49 "PARAMS": "Dummy arguments",
50 "LENGTH": "Lines, first to last (with comments and its internal procedures)",
51 "DEPTH": "How deeply its block constructs (do, if…then, select…) nest",
52 "OVER": "Procedures with a value over its threshold",
53}
54METRIC_OF_COLUMN = {"CCN": "ccn", "PARAMS": "params", "LENGTH": "length", "DEPTH": "depth"}
55PROCEDURE_ROLE = Qt.UserRole + 1 # a procedure's row: its complexity.Procedure
58class ProcedureRow(NamedTuple):
59 file: cx.FileMetrics
60 proc: cx.Procedure
61 label: str # its file's label
62 over: frozenset # its metrics over their thresholds ("ccn"…)
65class FileRow(NamedTuple):
66 file: cx.FileMetrics
67 label: str
68 over: int # its procedures over a threshold
69 highest: int # its highest CCN
70FILE_ROLE = Qt.UserRole + 2 # a row's file (its FileMetrics' path: a procedure in an included file's is the includer's)
73class ComplexityPanel(QWidget):
74 """The files (top), their procedures (below), their totals, and the
75 selected procedure's control-flow graph (beside them)."""
77 open_requested = Signal(str, int) # file, line
78 refresh_requested = Signal()
80 def __init__(self, parent=None):
81 super().__init__(parent)
82 self.files: list[cx.FileMetrics] = []
83 self._labels: dict[Path, str] = {} # (a file's label, worked out once: see label)
84 self._is_test: dict[Path, bool] = {}
85 self.project_dir: Path | None = None
86 self.test_dirs: list[Path] = []
87 self.dependency_dirs: list[Path] = []
88 self.thresholds = dict(cx.THRESHOLDS)
89 self.description = "" # (when it was measured)
90 self.icon_for = None # a file's QIcon, as in Project files (set by the window)
91 self.is_light = True
92 self.palette_colors = PALETTES["light"]
93 self.setObjectName("ComplexityPanel")
94 self.setAttribute(Qt.WA_StyledBackground, True)
96 self.title_icon = TitleIcon("complexity")
97 self.title = QLabel("Complexity")
98 self.subtitle = StatusLabel()
99 self.tests_check = QCheckBox("Include tests")
100 self.tests_check.setToolTip("Also measure the test programs")
101 self.tests_check.toggled.connect(self._fill)
102 self.dependencies_check = QCheckBox("Include dependencies")
103 self.dependencies_check.setToolTip("Also measure the dependencies' code")
104 self.dependencies_check.toggled.connect(self.refresh_requested)
105 header = QHBoxLayout()
106 header.setSpacing(6)
107 header.addWidget(self.title_icon)
108 header.addWidget(self.title)
109 header.addSpacing(4)
110 header.addWidget(self.subtitle, 1)
111 header.addWidget(self.tests_check)
112 header.addSpacing(12)
113 header.addWidget(self.dependencies_check)
115 self.filter_edit = QLineEdit()
116 self.filter_edit.setPlaceholderText("Filter by procedure or file")
117 self.filter_edit.setClearButtonEnabled(True)
118 self.filter_edit.setMinimumWidth(160) # (narrow: the totals beside it are cut off, not the filter)
119 self.filter_edit.textChanged.connect(self._fill_procedures)
120 self.totals = StatusLabel()
121 totals_row = QHBoxLayout()
122 totals_row.setSpacing(10)
123 totals_row.addWidget(self.filter_edit, 1)
124 totals_row.addWidget(self.totals)
126 metric = {"CCN": "ccn", "NLOC": "nloc", "TOKENS": "tokens", "PARAMS": "params", "LENGTH": "length",
127 "DEPTH": "depth"}
128 procedure_columns = [Column("FILE", lambda r: r.label), Column("PROCEDURE", lambda r: r.proc.name)] + [
129 Column(name, lambda r, k=key: f"{getattr(r.proc, k):,}", lambda r, k=key: getattr(r.proc, k),
130 COLUMN_TIPS.get(name, "")) for name, key in metric.items()]
131 self.procedures = self._table(procedure_columns, RowStyle(
132 color=self._procedure_color, bold=self._procedure_bold, icon=lambda r: symbol_icon(r.proc.kind),
133 tip=lambda r: self._procedure_tip(r.proc, r.label, r.over), data=self._procedure_data,
134 icon_column=NAME_COLUMN))
135 self.procedures.horizontalHeader().setSortIndicator(2, Qt.DescendingOrder) # (the most complex first)
136 self.procedures.cellClicked.connect(self._procedure_clicked)
137 file_columns = [
138 Column("FILE", lambda r: r.label),
139 Column("NLOC", lambda r: f"{r.file.nloc:,}", lambda r: r.file.nloc, COLUMN_TIPS["NLOC"]),
140 Column("PROCEDURES", lambda r: str(len(r.file.procedures)), lambda r: len(r.file.procedures)),
141 Column("AVG CCN", lambda r: f"{r.file.average_ccn:.1f}", lambda r: r.file.average_ccn),
142 Column("MAX CCN", lambda r: str(r.highest), lambda r: r.highest),
143 Column("OVER", lambda r: str(r.over), lambda r: r.over, COLUMN_TIPS["OVER"])]
144 self.file_table = self._table(file_columns, RowStyle(
145 color=lambda r, column: QColor(OVER) if column == len(FILE_COLUMNS) - 1 and r.over else None,
146 icon=lambda r: self.icon_for(r.file.path) if self.icon_for is not None else None,
147 tip=lambda r: f"{html.escape(str(r.file.path))}<br>Click to list only its procedures",
148 data=lambda r, role: r.label if role == Qt.UserRole else r.file.path if role == FILE_ROLE else None))
149 self.file_table.horizontalHeader().setSortIndicator(3, Qt.DescendingOrder)
150 # A file chosen (clicked, or with the arrow keys): only its procedures;
151 # the chosen file clicked again: all of them.
152 self.file_table.currentCellChanged.connect(self._file_chosen)
153 self.file_table.cellClicked.connect(self._file_clicked)
154 self._file_just_chosen = False
155 self.splitter = QSplitter(Qt.Vertical) # (the files above their procedures)
156 self.splitter.addWidget(self.file_table)
157 self.splitter.addWidget(self.procedures)
158 self.splitter.setStretchFactor(0, 1)
159 self.splitter.setStretchFactor(1, 3)
160 # The selected procedure's control flow, beside the tables.
161 self.flow_title = StatusLabel()
162 self.flow = CfgView()
163 self.flow.line_clicked.connect(self._flow_line_clicked)
164 self.flow_shown: cx.Procedure | None = None
165 flow_box = QWidget()
166 flow_layout = QVBoxLayout(flow_box)
167 flow_layout.setContentsMargins(0, 0, 0, 0)
168 flow_layout.setSpacing(4)
169 flow_layout.addWidget(self.flow_title)
170 flow_layout.addWidget(self.flow, 1)
171 self.body = QSplitter(Qt.Horizontal)
172 self.body.addWidget(self.splitter)
173 self.body.addWidget(flow_box)
174 self.body.setStretchFactor(0, 3)
175 self.body.setStretchFactor(1, 2)
176 self.procedures.currentCellChanged.connect(lambda row, *_: self._show_flow(row))
178 self.empty = QLabel()
179 self.empty.setAlignment(Qt.AlignCenter)
180 self.empty.setWordWrap(True)
182 layout = QVBoxLayout(self)
183 layout.setContentsMargins(14, 10, 14, 6)
184 layout.setSpacing(8)
185 layout.addLayout(header)
186 layout.addLayout(totals_row)
187 layout.addWidget(self.body, 1)
188 layout.addWidget(self.empty, 1)
189 self.refresh_fonts()
190 self.show_message("Open a project to see the complexity of its Fortran code here.")
192 def _table(self, columns: list[Column], style: RowStyle) -> MetricsTable:
193 """A table whose columns are as wide as their contents, scrolling
194 sideways if the panel is narrower (not squeezing the first column);
195 its cells drawn on demand (see `vibefpmgui.metrics_table`)."""
196 table = MetricsTable(columns, style)
197 table.verticalHeader().setVisible(False)
198 table.setShowGrid(False)
199 table.setEditTriggers(QAbstractItemView.NoEditTriggers)
200 table.setSelectionBehavior(QAbstractItemView.SelectRows)
201 table.setSelectionMode(QAbstractItemView.SingleSelection)
202 table.setSortingEnabled(True)
203 table.setWordWrap(False)
204 table.setTextElideMode(Qt.ElideLeft) # (a long path: the end of its file name still shows)
205 table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
206 hh = table.horizontalHeader()
207 hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
208 hh.setSectionResizeMode(QHeaderView.Interactive)
209 hh.setStretchLastSection(False)
210 hh.sortIndicatorChanged.connect(lambda *_: self._fit_columns(table))
211 return table
213 @staticmethod
214 def _fit_columns(table: MetricsTable):
215 """Each column as wide as its values and its title, room for the sort
216 arrow only in the sorted one (Qt leaves room for it in every title)."""
217 header = table.horizontalHeader()
218 header.setSortIndicatorShown(False)
219 table.resizeColumnsToContents()
220 header.setSortIndicatorShown(True)
221 column = header.sortIndicatorSection()
222 if 0 <= column < header.count(): 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was always true
223 header.resizeSection(column, max(header.sectionSize(column), header.sectionSizeHint(column)))
224 for column in range(header.count()):
225 if header.sectionSize(column) > MAX_TEXT_WIDTH: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 header.resizeSection(column, MAX_TEXT_WIDTH)
228 # ------------------------------------------------------------ styling
229 def refresh_palette(self):
230 self.is_light = app_is_light()
231 self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
232 self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
233 + f"#{self.objectName()} QHeaderView::section {{ padding: 6px 8px; }}")
234 self.title_icon.draw(pal["text"], self.title)
235 for label in (self.subtitle, self.empty, self.totals, self.flow_title):
236 label.setStyleSheet(f"color: {pal['muted']};")
237 self.flow.set_dark(not self.is_light)
238 self._fill()
240 def refresh_fonts(self):
241 font = QFont(QApplication.font())
242 font.setPointSizeF(font.pointSizeF() + 2)
243 font.setWeight(QFont.Bold)
244 self.title.setFont(font)
245 self.refresh_palette()
247 # -------------------------------------------------------------- state
248 def show_message(self, message: str):
249 """Nothing to list: why, or what's happening."""
250 self.empty.setText(message)
251 self.empty.show()
252 self.body.hide()
253 self.filter_edit.setEnabled(False)
254 self.totals.setText("")
256 def show_metrics(self, files: list[cx.FileMetrics], project_dir: Path, test_dirs: list[Path] | None = None,
257 dependency_dirs: list[Path] | None = None, description: str = ""):
258 """Every measured file's metrics (the project's tests and the
259 dependencies' are listed only with their boxes ticked)."""
260 place = (project_dir.resolve(), [Path(d).resolve() for d in test_dirs or []],
261 [Path(d).resolve() for d in dependency_dirs or []])
262 if place != (self.project_dir, self.test_dirs, self.dependency_dirs):
263 self._labels.clear()
264 self._is_test.clear()
265 self.files = files
266 self.project_dir, self.test_dirs, self.dependency_dirs = place
267 self.description = description
268 self._fill()
270 def shown_files(self) -> list[cx.FileMetrics]:
271 """The files listed: the project's own, with its tests if ticked
272 (the dependencies' are only measured when their box is ticked)."""
273 if self.tests_check.isChecked():
274 return list(self.files)
275 return [f for f in self.files if not self._test_file(f.path)]
277 def _test_file(self, path: Path) -> bool:
278 found = self._is_test.get(path)
279 if found is None:
280 resolved = path.resolve()
281 found = self._is_test[path] = any(resolved.is_relative_to(d) for d in self.test_dirs)
282 return found
284 def label(self, path: Path) -> str:
285 """A file's name as listed: its path in the project (or in its dependency)."""
286 label = self._labels.get(path)
287 if label is None:
288 label = self._labels[path] = self._label(path)
289 return label
291 def _label(self, path: Path) -> str:
292 path = path.resolve()
293 for top in (*self.dependency_dirs, self.project_dir): 293 ↛ 297line 293 didn't jump to line 297 because the loop on line 293 didn't complete
294 if top is not None and path.is_relative_to(top):
295 relative = path.relative_to(top).as_posix()
296 return relative if top == self.project_dir else f"{top.name}/{relative}"
297 return str(path)
299 def _fill(self):
300 files = self.shown_files()
301 if not self.files:
302 if self.project_dir is not None:
303 self.show_message("No Fortran files in this project.")
304 return
305 self.empty.hide()
306 self.body.show()
307 self.filter_edit.setEnabled(True)
308 self._update_totals(files)
309 self._fill_files(files)
310 self._fill_procedures()
312 def _update_totals(self, files: list[cx.FileMetrics]):
313 self.subtitle.setText(" · ".join(part for part in (self.description, f"{len(files)} files") if part))
314 procedures = [p for f in files for p in f.procedures]
315 over = [p for p in procedures if p.warnings(self.thresholds)]
316 nloc = sum(f.nloc for f in files)
317 average_ccn = sum(p.ccn for p in procedures) / len(procedures) if procedures else 0.0
318 average_nloc = sum(p.nloc for p in procedures) / len(procedures) if procedures else 0.0
319 self.totals.setText(f"{nloc:,} lines of code · {len(procedures):,} procedures · average CCN "
320 f"{average_ccn:.1f}, NLOC {average_nloc:.0f} · "
321 + (f"<span style='color:{OVER}'>{len(over)} over a threshold</span>" if over
322 else "none over a threshold"))
323 self.totals.setToolTip("Thresholds (lizard's): " + ", ".join(
324 f"{key.upper() if key == 'ccn' else key} {value}" for key, value in self.thresholds.items()))
326 def chosen_file(self) -> str | None:
327 """The file chosen in the files' table (its label), if the filter is
328 still that file's."""
329 row = self.file_table.currentRow()
330 item = self.file_table.item(row, 0) if row >= 0 else None
331 label = item.data(Qt.UserRole) if item is not None else None
332 return label if label and label == self.filter_edit.text() else None
334 def _listed(self, files: list[cx.FileMetrics]) -> list[tuple[cx.FileMetrics, cx.Procedure, str]]:
335 """(file, procedure, file's label) of the files' procedures the filter
336 lets through (a file chosen: only that file's)."""
337 needle = self.filter_edit.text().strip().lower()
338 chosen = self.chosen_file()
339 rows = []
340 for f in files:
341 label = self.label(f.path)
342 if chosen is not None and label != chosen: # (only that file's: not another path it's a part of)
343 continue
344 for proc in f.procedures:
345 if chosen is None and needle and needle not in proc.qualified.lower() and needle not in label.lower():
346 continue
347 rows.append((f, proc, label))
348 return rows
350 def _fill_procedures(self):
351 rows = [ProcedureRow(f, proc, label, frozenset(proc.warnings(self.thresholds)))
352 for f, proc, label in self._listed(self.shown_files())]
353 self.procedures.setColumnHidden(FILE_COLUMN, self.chosen_file() is not None) # (one file: no need to say which)
354 table = self.procedures
355 table.blockSignals(True) # (refilling isn't choosing a procedure: see the end)
356 table.set_rows(rows)
357 self._fit_columns(table)
358 table.blockSignals(False)
359 self._keep_flow()
361 def _procedure_color(self, row: ProcedureRow, column: int) -> QColor | None:
362 if column == FILE_COLUMN:
363 return QColor(self.palette_colors["muted"])
364 return QColor(OVER) if METRIC_OF_COLUMN.get(PROCEDURE_COLUMNS[column]) in row.over else None
366 def _procedure_bold(self, row: ProcedureRow, column: int) -> bool:
367 return METRIC_OF_COLUMN.get(PROCEDURE_COLUMNS[column]) in row.over
369 @staticmethod
370 def _procedure_data(row: ProcedureRow, role: int):
371 if role == Qt.UserRole:
372 return str(row.proc.path), row.proc.start
373 if role == PROCEDURE_ROLE: 373 ↛ 375line 373 didn't jump to line 375 because the condition on line 373 was always true
374 return row.proc
375 if role == FILE_ROLE:
376 return row.file.path
377 return None
379 # ------------------------------------------------------------ control flow
380 def set_code_font(self, font: QFont):
381 """The graph's font (the editor's)."""
382 self.flow.set_code_font(font)
384 def _row_of(self, path: Path, qualified: str) -> int | None:
385 return next((n for n, row in enumerate(self.procedures.rows())
386 if row.proc.path == path and row.proc.qualified == qualified), None)
388 def _keep_flow(self):
389 """After the tables are filled again (a save, a filter, a file chosen):
390 the procedure shown stays selected, its graph drawn again from its file
391 (it may have changed); filtered out, or a file just chosen, the first
392 procedure listed is selected and shown instead; gone from the project,
393 the graph goes too."""
394 shown = self.flow_shown
395 row = self._row_of(shown.path, shown.qualified) if shown is not None else None
396 if row is None and self.procedures.rowCount() and (shown is not None or self.chosen_file()):
397 row = 0 # (filtered out, or a file chosen: its most complex procedure)
398 if row is None and shown is None:
399 return
400 if row is None: 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true
401 if not any(p.path == shown.path and p.qualified == shown.qualified
402 for f in self.files for p in f.procedures):
403 self.flow_shown = None
404 self.flow_title.setText("")
405 self.flow.show_message("Select a procedure to see its control flow.")
406 return
407 self.procedures.blockSignals(True)
408 self.procedures.setCurrentCell(row, NAME_COLUMN)
409 self.procedures.blockSignals(False)
410 self._show_flow(row, again=True)
412 def _show_flow(self, row: int, again: bool = False):
413 """The control-flow graph of the procedure in ``row``."""
414 item = self.procedures.item(row, NAME_COLUMN) if row >= 0 else None
415 proc = item.data(PROCEDURE_ROLE) if item is not None else None
416 if proc is None or (not again and proc is self.flow_shown): 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 return
418 self.flow_shown = proc
419 try:
420 text = read_source(proc.path).text
421 except OSError as error:
422 self.flow.show_message(f"Couldn't read {proc.path.name}: {error}")
423 return
424 graph = cf.build(text, proc.path, proc)
425 decisions = graph.complexity - 1
426 self.flow_title.setText(f"<b>{html.escape(proc.name)}</b> · lines {proc.start}–{proc.end} · "
427 f"{decisions} decision{'s' * (decisions != 1)} · click a node to open it, "
428 "right-click for more")
429 self.flow.show_graph(graph, text)
431 def _flow_line_clicked(self, path: str, line: int):
432 if self.flow_shown is not None: 432 ↛ exitline 432 didn't return from function '_flow_line_clicked' because the condition on line 432 was always true
433 self.open_requested.emit(path or str(self.flow_shown.path), line)
435 def _procedure_tip(self, proc: cx.Procedure, label: str, over: set[str]) -> str:
436 lines = [f"<b>{html.escape(proc.qualified)}</b> ({proc.kind})",
437 f"{html.escape(label)}, lines {proc.start}–{proc.end}"]
438 if over:
439 names = {"ccn": "CCN", "length": "length", "params": "parameters", "depth": "depth"}
440 over_text = ", ".join(f"{names[key]} (over {self.thresholds[key]})" for key in sorted(over))
441 lines.append(f"<span style='color:{OVER}'>Over: {over_text}</span>")
442 return "<br>".join(lines)
444 def _fill_files(self, files: list[cx.FileMetrics]):
445 table = self.file_table
446 chosen = self.chosen_file()
447 table.blockSignals(True) # (refilling isn't choosing a file)
448 table.set_rows([FileRow(f, self.label(f.path), sum(1 for p in f.procedures if p.warnings(self.thresholds)),
449 max((p.ccn for p in f.procedures), default=0)) for f in files])
450 self._fit_columns(table)
451 table.setCurrentCell(-1, -1)
452 row = next((n for n, r in enumerate(table.rows()) if r.label == chosen), None)
453 if row is not None: # (still chosen: a save refills the tables)
454 table.setCurrentCell(row, 0)
455 table.blockSignals(False)
457 def _procedure_clicked(self, row: int, _column: int):
458 item = self.procedures.item(row, NAME_COLUMN)
459 if item is not None: 459 ↛ exitline 459 didn't return from function '_procedure_clicked' because the condition on line 459 was always true
460 path, line = item.data(Qt.UserRole)
461 self.open_requested.emit(path, line)
463 def _file_chosen(self, row: int, _column: int, previous_row: int, _previous_column: int):
464 """The current file changed (a click, or an arrow key): only its procedures."""
465 item = self.file_table.item(row, 0) if row >= 0 else None
466 if item is None or row == previous_row: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true
467 return
468 # (The click that chose it doesn't also unchoose it; an arrow key isn't a click.)
469 self._file_just_chosen = bool(QApplication.mouseButtons() & Qt.LeftButton)
470 self.filter_edit.setText(item.data(Qt.UserRole))
472 def _file_clicked(self, row: int, _column: int):
473 """A click on the file already chosen: all the procedures again."""
474 if self._file_just_chosen:
475 self._file_just_chosen = False
476 return
477 item = self.file_table.item(row, 0)
478 if item is not None and self.filter_edit.text() == item.data(Qt.UserRole): 478 ↛ exitline 478 didn't return from function '_file_clicked' because the condition on line 478 was always true
479 self.filter_edit.clear()
480 self.file_table.blockSignals(True)
481 self.file_table.setCurrentCell(-1, -1)
482 self.file_table.blockSignals(False)