Coverage for vibefpmgui/complexity.py: 94%
412 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"""Code complexity of Fortran: lizard's metrics (https://github.com/terryyin/lizard),
2measured by a reader of Fortran's own (no Qt).
4Per procedure (subroutines, functions, module procedures, a main program):
6- **CCN**, the cyclomatic complexity: 1, plus 1 for each decision: ``if``
7 (a one-line one too), ``else if``, each loop (``do`` with a loop control,
8 ``do while``, ``do concurrent``, ``forall``: not an endless ``do``, whose
9 way out is an ``exit`` under an ``if``), each ``case (…)`` (not ``case default``),
10 each ``type is`` / ``class is`` / ``rank (…)`` guard, each masked ``where``
11 / ``elsewhere``, and each ``.and.`` / ``.or.``. An arithmetic ``if`` counts
12 one less than its different labels (up to 2), a computed ``goto`` as many
13 as its different labels (it can also go on).
14- **NLOC**: its lines with code (not blank, not only a comment).
15- **Tokens**: its code's tokens (names, numbers, strings, operators).
16- **Parameters**: its dummy arguments.
17- **Length**: its lines, first to last (with comments and blank lines).
18- **Depth**: how deeply its block constructs nest (``do``, ``if … then``,
19 ``select``, ``where``, ``associate``, ``block``…).
21An internal procedure (after ``contains``) is measured on its own: its lines,
22tokens and decisions aren't its host's (its lines are in the host's length).
23Interface bodies are declarations, not procedures.
25The reader works a statement at a time: free and fixed form, comments,
26continuation lines, ``;``, labels and construct names, and strings (whose
27text is never read as code). Preprocessor lines (``#ifdef``…) aren't code
28lines, as in lizard: they choose the code, they aren't part of it. Included
29files (``#include "…"``, ``include '…'``) are read where they're included,
30as the compiler reads them (lizard doesn't): their code is the including
31procedure's (a procedure's body can be all in one). Their lines are counted
32for the file that includes them; a procedure's length is its own file's lines.
34Where lizard's Fortran reader (1.24.1, with the fixes in its Fortran issue)
35counts differently: it counts ``select case`` and ``case default`` too, and
36the preprocessor's ``#if`` family; it doesn't count ``else if`` written with
37a space, ``where``, ``forall`` or ``type is``, and counts an endless ``do``. It reports interface bodies
38(declarations) as functions but not a main program, ends a host procedure
39where its internal procedures start, and misses procedures ended by a bare
40``END`` after another one (fixed form).
41"""
43from __future__ import annotations
45import re
46from dataclasses import dataclass, field
47from pathlib import Path
49from .source_text import decode
51FIXED_FORM = {".f", ".for", ".ftn", ".f77"}
52FORTRAN = FIXED_FORM | {".f90", ".f95", ".f03", ".f08", ".f18", ".f23", ".fpp"}
53# lizard's warning thresholds (its -C, -L, -a), and a nesting depth's (its -ND default).
54THRESHOLDS = {"ccn": 15, "length": 1000, "params": 100, "depth": 5}
57def is_fortran(path: Path) -> bool:
58 return path.suffix.lower() in FORTRAN
61@dataclass
62class Procedure:
63 name: str
64 kind: str # "subroutine", "function", "procedure" (a module procedure), "program"
65 container: str # the module (or host procedure) it's in: "calc", "calc > outer"; "" if none
66 path: Path
67 start: int # 1-based lines
68 end: int = 0
69 nloc: int = 0
70 ccn: int = 1
71 tokens: int = 0
72 params: int = 0
73 depth: int = 0
75 @property
76 def length(self) -> int:
77 return self.end - self.start + 1
79 @property
80 def qualified(self) -> str:
81 return f"{self.container}::{self.name}" if self.container else self.name
83 def warnings(self, thresholds: dict[str, int] = THRESHOLDS) -> list[str]:
84 """The metrics over their thresholds ("ccn", "length"…)."""
85 values = {"ccn": self.ccn, "length": self.length, "params": self.params, "depth": self.depth}
86 return [key for key, limit in thresholds.items() if values[key] > limit]
89@dataclass
90class FileMetrics:
91 path: Path
92 nloc: int = 0 # every line with code in the file (also outside procedures; with its included files')
93 tokens: int = 0
94 procedures: list[Procedure] = field(default_factory=list)
95 includes: list[Path] = field(default_factory=list) # the files it includes (theirs too)
97 @property
98 def average_ccn(self) -> float:
99 return sum(p.ccn for p in self.procedures) / len(self.procedures) if self.procedures else 0.0
101 @property
102 def average_nloc(self) -> float:
103 return sum(p.nloc for p in self.procedures) / len(self.procedures) if self.procedures else 0.0
106# ------------------------------------------------------------------ included files
107INCLUDE_RE = re.compile(r"""^\s*(?:#\s*include\s*["<]([^">]+)[">]|include\s*(['"])(.+?)\2\s*(?:!.*)?$)""",
108 re.IGNORECASE)
109MAX_INCLUDE_DEPTH = 10
112@dataclass
113class Source:
114 """Code with its included files read in where they're included."""
115 text: str
116 origins: list[tuple[Path, int]] | None = None # each line's file and line there (None: no includes)
117 includes: list[Path] = field(default_factory=list)
119 def origin(self, line: int, path: Path) -> tuple[Path, int]:
120 """Where ``line`` (1-based, of ``text``) is: its file and line there."""
121 if self.origins is None or not 0 < line <= len(self.origins):
122 return path, line
123 return self.origins[line - 1]
126def expand_includes(text: str, path: Path | str) -> Source:
127 """``text`` (``path``'s) with each file it includes in place of the line
128 that includes it (looked for beside the including file, then beside
129 ``path``); a file that isn't found stays an include line."""
130 path = Path(path)
131 if "include" not in text.lower(): # (most files: nothing to do)
132 return Source(text)
133 lines: list[str] = []
134 origins: list[tuple[Path, int]] = []
135 includes: list[Path] = []
137 def read(code: str, where: Path, depth: int, reading: tuple[Path, ...]):
138 for number, line in enumerate(code.split("\n"), start=1):
139 found = INCLUDE_RE.match(line) if "include" in line.lower() else None
140 name = (found.group(1) or found.group(3)) if found else None
141 target = _find_include(name, where, path) if name else None
142 if target is None or depth >= MAX_INCLUDE_DEPTH or target in reading:
143 lines.append(line)
144 origins.append((where, number))
145 continue
146 try:
147 included = decode(target.read_bytes())[0]
148 except OSError:
149 lines.append(line)
150 origins.append((where, number))
151 continue
152 if target not in includes: 152 ↛ 154line 152 didn't jump to line 154 because the condition on line 152 was always true
153 includes.append(target)
154 read(included.replace("\r\n", "\n"), target, depth + 1, (*reading, target))
156 read(text, path, 0, (path,))
157 if not includes: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 return Source(text)
159 return Source("\n".join(lines), origins, includes)
162def _find_include(name: str, where: Path, top: Path) -> Path | None:
163 for folder in dict.fromkeys((where.parent, top.parent)):
164 candidate = folder / name
165 if candidate.is_file():
166 return candidate
167 return None
170# ------------------------------------------------------------------ statements
171@dataclass
172class Statement:
173 text: str # lowercase code: strings blanked (''), no comments, label or construct name
174 label: str # its statement label ("" if none)
175 lines: list[int] # the lines it's on (with code)
176 tokens: int = 0
179def _strip_code(line: str, quote: str | None) -> tuple[str, str | None]:
180 """(a line's code with strings' text blanked and its comment removed, the
181 quote still open at its end: a string continued on the next line)."""
182 out = []
183 i = 0
184 while i < len(line):
185 ch = line[i]
186 if quote:
187 if ch == quote:
188 if i + 1 < len(line) and line[i + 1] == quote: # ('it''s': a quote in the string)
189 i += 2
190 continue
191 out.append(ch)
192 quote = None
193 elif ch == "&" and line[i + 1:].strip() == "": # (the string goes on, on the next line)
194 out.append("&")
195 i += 1
196 continue
197 if ch in "'\"":
198 quote = ch
199 out.append(ch)
200 elif ch == "!":
201 break
202 else:
203 out.append(ch)
204 i += 1
205 return "".join(out), quote
208TOKEN_RE = re.compile(r"'[^']*'|\"[^\"]*\"|\.[a-z]+\.|\d+(?:\.\d*)?(?:[ed][+-]?\d+)?(?:_\w+)?|\w+"
209 r"|::|=>|\*\*|//|==|/=|<=|>=|\S")
210LABEL_RE = re.compile(r"^(\d{1,5})\s+")
211CONSTRUCT_NAME_RE = re.compile(r"^[a-z_]\w*\s*:(?!:)\s*")
214def statements(text: str, fixed: bool) -> list[Statement]:
215 """The code's statements, in order (preprocessor lines are left out)."""
216 out: list[Statement] = []
217 pending: list[str] = []
218 pending_lines: list[int] = []
219 quote: str | None = None
220 label = ""
222 def finish():
223 nonlocal pending, pending_lines, label
224 if pending_lines:
225 joined = " ".join(pending)
226 first = True
227 for part in _split_semicolons(joined):
228 part = part.strip()
229 if not part: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
230 continue
231 this_label = label if first else ""
232 found = LABEL_RE.match(part)
233 if found:
234 this_label, part = found.group(1), part[found.end():]
235 part = CONSTRUCT_NAME_RE.sub("", part, count=1)
236 out.append(Statement(part.lower(), this_label, list(pending_lines),
237 len(TOKEN_RE.findall(part.lower()))))
238 first = False
239 pending, pending_lines, label = [], [], ""
241 for number, raw in enumerate(text.split("\n"), start=1):
242 line = raw.rstrip("\r")
243 if line.startswith("#"):
244 continue
245 if fixed:
246 if line[:1] in ("c", "C", "*", "!", "d", "D") or not line.strip():
247 continue
248 continued = len(line) > 5 and line[5] not in (" ", "0", "\t") and not line[:5].strip()
249 body = line[6:] if len(line) > 6 else ""
250 if not continued:
251 finish()
252 label = line[:5].strip() if line[:5].strip().isdigit() else ""
253 code, quote = _strip_code(body, quote if continued else None)
254 if code.strip(): 254 ↛ 257line 254 didn't jump to line 257 because the condition on line 254 was always true
255 pending.append(code)
256 pending_lines.append(number)
257 continue
258 if quote: # (a string going on from the line before: up to its "&")
259 stripped = line.lstrip()
260 code, quote = _strip_code(stripped.removeprefix("&"), quote)
261 else:
262 stripped = line.lstrip()
263 if pending and stripped.startswith("&"):
264 stripped = stripped[1:]
265 code, quote = _strip_code(stripped, None)
266 if not code.strip():
267 continue
268 body = code.rstrip()
269 continues = body.endswith("&")
270 pending.append(body[:-1] if continues else body)
271 pending_lines.append(number)
272 if not continues and not quote:
273 finish()
274 finish()
275 return out
278def _split_semicolons(code: str) -> list[str]:
279 parts, start, quote = [], 0, None
280 for i, ch in enumerate(code):
281 if quote:
282 if ch == quote:
283 quote = None
284 elif ch in "'\"":
285 quote = ch
286 elif ch == ";":
287 parts.append(code[start:i])
288 start = i + 1
289 parts.append(code[start:])
290 return parts
293def _close_paren(text: str, start: int) -> int:
294 """The index just past the ")" matching the "(" at ``start`` (len(text) if none)."""
295 depth = 0
296 for i in range(start, len(text)): 296 ↛ 303line 296 didn't jump to line 303 because the loop on line 296 didn't complete
297 if text[i] == "(":
298 depth += 1
299 elif text[i] == ")":
300 depth -= 1
301 if depth == 0:
302 return i + 1
303 return len(text)
306def _is_assignment(text: str) -> bool:
307 """An assignment ("x = …", "a(i)%b = …", "p => …"): a variable (with
308 subscripts and components) before the "=". Not "if (c) x = 1", nor
309 "do i = 1, n" (a keyword, then something other than "(", "%" or "=")."""
310 found = re.match(r"[a-z_]\w*", text)
311 if not found: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 return False
313 i = found.end()
314 while True:
315 while i < len(text) and text[i] == " ":
316 i += 1
317 if i < len(text) and text[i] == "(":
318 i = _close_paren(text, i)
319 elif i < len(text) and text[i] == "%":
320 name = re.match(r"%\s*[a-z_]\w*", text[i:])
321 if not name: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 return False
323 i += name.end()
324 else:
325 break
326 rest = text[i:]
327 return rest.startswith("=>") or (rest.startswith("=") and not rest.startswith("=="))
330# ------------------------------------------------------------------ structure
331END_RE = re.compile(r"^end\s*(subroutine|function|program|module|submodule|interface|type|select|where|forall|"
332 r"associate|block\s*data|block|critical|procedure|enum|team|do|if)?\b\s*\w*\s*$")
333PROCEDURE_RE = re.compile(r"^(?:([\w\s(),=*:]*?)\s)?(subroutine|function)\s+(\w+)\s*(\(.*)?$")
334MODULE_PROCEDURE_RE = re.compile(r"^module\s+procedure\s+(\w+)\s*$")
335PROGRAM_RE = re.compile(r"^program\s+(\w+)")
336MODULE_RE = re.compile(r"^module\s+(\w+)\s*$")
337SUBMODULE_RE = re.compile(r"^submodule\s*\(.*\)\s*(\w+)")
338BLOCK_DATA_RE = re.compile(r"^block\s*data\b")
339INTERFACE_RE = re.compile(r"^(abstract\s+)?interface\b")
340TYPE_DEF_RE = re.compile(r"^type\s*(?:,[^:]*)?::\s*\w+|^type\s+(?!is\b)\w+\s*$")
341ENUM_RE = re.compile(r"^enum\s*,")
342DO_RE = re.compile(r"^do\b\s*(\d+)?")
343SELECT_RE = re.compile(r"^select\s*(case|type|rank)\s*\(")
344GUARD_RE = re.compile(r"^(case\s*\(|type\s+is\s*\(|class\s+is\s*\(|rank\s*\()")
345LOGICAL_RE = re.compile(r"\.(and|or)\.")
346UNIT_KINDS = {"subroutine", "function", "procedure", "program"}
347# Scopes that a bare "end" closes (in a program unit or an interface body).
348BARE_END = UNIT_KINDS | {"module", "submodule", "blockdata", "body"}
351@dataclass
352class _Scope:
353 kind: str # a UNIT_KINDS, "module", "submodule", "blockdata", "interface", "body", "type", "enum"
354 name: str
355 unit: Procedure | None = None
356 constructs: list = field(default_factory=list) # (kind, label) of the open block constructs
359def analyze_text(text: str, path: Path | str = "x.f90") -> FileMetrics:
360 """Fortran source's metrics (see the module's docstring)."""
361 path = Path(path)
362 found = FileMetrics(path)
363 source = expand_includes(text, path)
364 found.includes = source.includes
365 stmts = statements(source.text, path.suffix.lower() in FIXED_FORM)
366 stack: list[_Scope] = []
368 def unit() -> _Scope | None:
369 return next((s for s in reversed(stack) if s.kind in UNIT_KINDS), None)
371 def container() -> str:
372 names = [s.name for s in stack if s.kind in UNIT_KINDS | {"module", "submodule"}]
373 return " > ".join(names)
375 def open_unit(kind: str, name: str, statement: Statement, header: str = ""):
376 proc = Procedure(name, kind, container(), path, statement.lines[0])
377 proc.params = _count_params(header)
378 found.procedures.append(proc)
379 stack.append(_Scope(kind, name, proc))
381 def close(kinds: set[str], statement: Statement):
382 while stack: 382 ↛ exitline 382 didn't return from function 'close' because the condition on line 382 was always true
383 scope = stack.pop()
384 if scope.unit is not None:
385 scope.unit.end = statement.lines[-1]
386 if scope.kind in kinds: 386 ↛ 382line 386 didn't jump to line 382 because the condition on line 386 was always true
387 return
389 def in_interface() -> bool:
390 return any(scope.kind == "interface" for scope in stack)
392 line_owner: dict[int, Procedure | None] = {} # (a line's code is counted once, for its innermost unit)
393 for st in stmts:
394 s = st.text
395 decisions = len(LOGICAL_RE.findall(s))
396 ended = END_RE.match(s)
397 # Closing the do constructs that end at this statement's label ("do 10 …" / "10 continue").
398 if st.label:
399 scope = unit()
400 while scope and scope.constructs and scope.constructs[-1] == ("do", st.label):
401 scope.constructs.pop()
402 if ended:
403 word = (ended.group(1) or "").replace(" ", "")
404 counted = unit()
405 _count(counted, st, decisions, line_owner)
406 if word in ("do", "if", "select", "where", "forall", "associate", "block", "critical", "team"):
407 if counted and counted.constructs: 407 ↛ 417line 407 didn't jump to line 417 because the condition on line 407 was always true
408 counted.constructs.pop()
409 elif word in ("subroutine", "function", "procedure"):
410 close({"subroutine", "function", "procedure", "body"}, st)
411 elif word in ("program", "module", "submodule", "interface", "type", "enum"):
412 close({word}, st)
413 elif word == "blockdata": 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 close({"blockdata"}, st)
415 elif word == "": 415 ↛ 417line 415 didn't jump to line 417 because the condition on line 415 was always true
416 close(BARE_END, st)
417 continue
418 assignment = _is_assignment(s)
419 if not assignment and _open_scope(s, st, stack, open_unit, in_interface()):
420 _count(unit(), st, decisions, line_owner)
421 continue
422 scope = unit()
423 if scope is not None and not assignment:
424 decisions += _decisions(s, st, scope)
425 _count(scope, st, decisions, line_owner)
427 for scope in stack: # (a file that ends without its "end"s) 427 ↛ 428line 427 didn't jump to line 428 because the loop on line 427 never started
428 if scope.unit is not None and not scope.unit.end:
429 scope.unit.end = stmts[-1].lines[-1] if stmts else scope.unit.start
430 for owner in line_owner.values():
431 if owner is not None:
432 owner.nloc += 1
433 found.nloc = len(line_owner)
434 found.tokens = sum(st.tokens for st in stmts)
435 if source.origins is not None: # (the lines of the code as read: back to their own files')
436 for proc in found.procedures:
437 proc.path, start = source.origin(proc.start, path)
438 end_path, end = source.origin(proc.end, path)
439 proc.start, proc.end = start, end if end_path == proc.path else start
440 return found
443def _count(scope: _Scope | None, st: Statement, decisions: int, line_owner: dict):
444 """A statement's tokens, lines and decisions, for the unit it's in."""
445 proc = scope.unit if scope is not None else None
446 for line in st.lines:
447 line_owner[line] = proc
448 if proc is not None:
449 proc.tokens += st.tokens
450 proc.ccn += decisions
453def _open_scope(s: str, st: Statement, stack: list[_Scope], open_unit, in_interface: bool) -> bool:
454 """A statement that opens a program unit, a procedure or another scope
455 (pushed on ``stack``); False for any other statement."""
456 if INTERFACE_RE.match(s):
457 stack.append(_Scope("interface", ""))
458 return True
459 if in_interface:
460 if PROCEDURE_RE.match(s) and not s.startswith("module procedure"):
461 stack.append(_Scope("body", "")) # (an interface body: a declaration, not a procedure)
462 return True
463 return False
464 if TYPE_DEF_RE.match(s):
465 stack.append(_Scope("type", ""))
466 return True
467 if ENUM_RE.match(s): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 stack.append(_Scope("enum", ""))
469 return True
470 if any(scope.kind in ("type", "enum") for scope in stack):
471 return False # (a type's procedure bindings are declarations)
472 found = MODULE_PROCEDURE_RE.match(s)
473 if found: # (a submodule's separate module procedure)
474 open_unit("procedure", found.group(1), st)
475 return True
476 found = PROCEDURE_RE.match(s)
477 if found and not re.search(r"\bend\b", found.group(1) or ""):
478 open_unit(found.group(2), found.group(3), st, found.group(4) or "")
479 return True
480 found = PROGRAM_RE.match(s)
481 if found:
482 open_unit("program", found.group(1), st)
483 return True
484 found = SUBMODULE_RE.match(s)
485 if found:
486 stack.append(_Scope("submodule", found.group(1)))
487 return True
488 found = MODULE_RE.match(s)
489 if found:
490 stack.append(_Scope("module", found.group(1)))
491 return True
492 if BLOCK_DATA_RE.match(s): 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 stack.append(_Scope("blockdata", ""))
494 return True
495 return False
498def _decisions(s: str, st: Statement, scope: _Scope) -> int:
499 """A statement's decisions (beyond .and./.or.), and the block constructs
500 it opens or closes (for the nesting depth)."""
501 proc = scope.unit
503 def opened(kind: str, label: str = ""):
504 scope.constructs.append((kind, label))
505 proc.depth = max(proc.depth, len(scope.constructs))
507 if s.startswith("if") and re.match(r"^if\s*\(", s):
508 after = s[_close_paren(s, s.index("(")):].strip()
509 if after == "then":
510 opened("if")
511 return 1
512 if re.match(r"^\d+\s*,\s*\d+\s*,\s*\d+$", after):
513 return len(set(re.findall(r"\d+", after))) - 1 # (an arithmetic if: up to three ways)
514 return 1 + (_decisions(after, st, scope) if after else 0) # (a one-line if, and its statement)
515 if re.match(r"^else\s*if\s*\(", s):
516 return 1
517 found = re.match(r"^go\s*to\s*\(([\d\s,]+)\)", s)
518 if found: # (a computed goto: to each label, or on: one way more than its labels)
519 return len(set(re.findall(r"\d+", found.group(1))))
520 found = DO_RE.match(s)
521 if found:
522 opened("do", found.group(1) or "")
523 return 0 if s.strip() == "do" else 1 # (an endless "do" decides nothing: its exits' ifs do)
524 if re.match(r"^(where|forall)\s*\(", s):
525 rest = s[_close_paren(s, s.index("(")):].strip()
526 if not rest:
527 opened(s[:s.index("(")].strip()) # (a block: "end where" / "end forall" closes it)
528 return 1
529 if re.match(r"^else\s*where\s*\(", s):
530 return 1
531 if SELECT_RE.match(s):
532 opened("select")
533 return 0
534 if GUARD_RE.match(s):
535 return 1
536 if re.match(r"^(associate\s*\(|block\s*$|critical\b|change\s+team\s*\()", s):
537 opened(s.split("(")[0].strip())
538 return 0
541def _count_params(header: str) -> int:
542 """The dummy arguments in a procedure statement's "(a, b, c) result(r)"."""
543 if not header.startswith("("):
544 return 0
545 inside = header[1:_close_paren(header, 0) - 1].strip()
546 return len([a for a in inside.split(",") if a.strip()]) if inside else 0
549def analyze_file(path: Path) -> FileMetrics:
550 """A file's metrics (an unreadable one: none)."""
551 try:
552 text = decode(path.read_bytes())[0]
553 except OSError:
554 return FileMetrics(path)
555 return analyze_text(text, path)