vibefpmgui.complexity
Code complexity of Fortran: lizard's metrics (https://github.com/terryyin/lizard), measured by a reader of Fortran's own (no Qt).
Per procedure (subroutines, functions, module procedures, a main program):
- CCN, the cyclomatic complexity: 1, plus 1 for each decision:
if(a one-line one too),else if, each loop (dowith a loop control,do while,do concurrent,forall: not an endlessdo, whose way out is anexitunder anif), eachcase (…)(notcase default), eachtype is/class is/rank (…)guard, each maskedwhere/elsewhere, and each.and./.or.. An arithmeticifcounts one less than its different labels (up to 2), a computedgotoas many as its different labels (it can also go on). - NLOC: its lines with code (not blank, not only a comment).
- Tokens: its code's tokens (names, numbers, strings, operators).
- Parameters: its dummy arguments.
- Length: its lines, first to last (with comments and blank lines).
- Depth: how deeply its block constructs nest (
do,if … then,select,where,associate,block…).
An internal procedure (after contains) is measured on its own: its lines,
tokens and decisions aren't its host's (its lines are in the host's length).
Interface bodies are declarations, not procedures.
The reader works a statement at a time: free and fixed form, comments,
continuation lines, ;, labels and construct names, and strings (whose
text is never read as code). Preprocessor lines (#ifdef…) aren't code
lines, as in lizard: they choose the code, they aren't part of it. Included
files (#include "…", include '…') are read where they're included,
as the compiler reads them (lizard doesn't): their code is the including
procedure's (a procedure's body can be all in one). Their lines are counted
for the file that includes them; a procedure's length is its own file's lines.
Where lizard's Fortran reader (1.24.1, with the fixes in its Fortran issue)
counts differently: it counts select case and case default too, and
the preprocessor's #if family; it doesn't count else if written with
a space, where, forall or type is, and counts an endless do. It reports interface bodies
(declarations) as functions but not a main program, ends a host procedure
where its internal procedures start, and misses procedures ended by a bare
END after another one (fixed form).
1"""Code complexity of Fortran: lizard's metrics (https://github.com/terryyin/lizard), 2measured by a reader of Fortran's own (no Qt). 3 4Per procedure (subroutines, functions, module procedures, a main program): 5 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``…). 20 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. 24 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. 33 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""" 42 43from __future__ import annotations 44 45import re 46from dataclasses import dataclass, field 47from pathlib import Path 48 49from .source_text import decode 50 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} 55 56 57def is_fortran(path: Path) -> bool: 58 return path.suffix.lower() in FORTRAN 59 60 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 74 75 @property 76 def length(self) -> int: 77 return self.end - self.start + 1 78 79 @property 80 def qualified(self) -> str: 81 return f"{self.container}::{self.name}" if self.container else self.name 82 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] 87 88 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) 96 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 100 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 104 105 106# ------------------------------------------------------------------ included files 107INCLUDE_RE = re.compile(r"""^\s*(?:#\s*include\s*["<]([^">]+)[">]|include\s*(['"])(.+?)\2\s*(?:!.*)?$)""", 108 re.IGNORECASE) 109MAX_INCLUDE_DEPTH = 10 110 111 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) 118 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] 124 125 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] = [] 136 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: 153 includes.append(target) 154 read(included.replace("\r\n", "\n"), target, depth + 1, (*reading, target)) 155 156 read(text, path, 0, (path,)) 157 if not includes: 158 return Source(text) 159 return Source("\n".join(lines), origins, includes) 160 161 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 168 169 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 177 178 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 206 207 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*") 212 213 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 = "" 221 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: 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 = [], [], "" 240 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(): 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 276 277 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 291 292 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)): 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) 304 305 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: 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: 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("==")) 328 329 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"} 349 350 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 357 358 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] = [] 367 368 def unit() -> _Scope | None: 369 return next((s for s in reversed(stack) if s.kind in UNIT_KINDS), None) 370 371 def container() -> str: 372 names = [s.name for s in stack if s.kind in UNIT_KINDS | {"module", "submodule"}] 373 return " > ".join(names) 374 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)) 380 381 def close(kinds: set[str], statement: Statement): 382 while stack: 383 scope = stack.pop() 384 if scope.unit is not None: 385 scope.unit.end = statement.lines[-1] 386 if scope.kind in kinds: 387 return 388 389 def in_interface() -> bool: 390 return any(scope.kind == "interface" for scope in stack) 391 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: 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": 414 close({"blockdata"}, st) 415 elif word == "": 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) 426 427 for scope in stack: # (a file that ends without its "end"s) 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 441 442 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 451 452 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): 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): 493 stack.append(_Scope("blockdata", "")) 494 return True 495 return False 496 497 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 502 503 def opened(kind: str, label: str = ""): 504 scope.constructs.append((kind, label)) 505 proc.depth = max(proc.depth, len(scope.constructs)) 506 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 539 540 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 547 548 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)
62@dataclass 63class Procedure: 64 name: str 65 kind: str # "subroutine", "function", "procedure" (a module procedure), "program" 66 container: str # the module (or host procedure) it's in: "calc", "calc > outer"; "" if none 67 path: Path 68 start: int # 1-based lines 69 end: int = 0 70 nloc: int = 0 71 ccn: int = 1 72 tokens: int = 0 73 params: int = 0 74 depth: int = 0 75 76 @property 77 def length(self) -> int: 78 return self.end - self.start + 1 79 80 @property 81 def qualified(self) -> str: 82 return f"{self.container}::{self.name}" if self.container else self.name 83 84 def warnings(self, thresholds: dict[str, int] = THRESHOLDS) -> list[str]: 85 """The metrics over their thresholds ("ccn", "length"…).""" 86 values = {"ccn": self.ccn, "length": self.length, "params": self.params, "depth": self.depth} 87 return [key for key, limit in thresholds.items() if values[key] > limit]
84 def warnings(self, thresholds: dict[str, int] = THRESHOLDS) -> list[str]: 85 """The metrics over their thresholds ("ccn", "length"…).""" 86 values = {"ccn": self.ccn, "length": self.length, "params": self.params, "depth": self.depth} 87 return [key for key, limit in thresholds.items() if values[key] > limit]
The metrics over their thresholds ("ccn", "length"…).
90@dataclass 91class FileMetrics: 92 path: Path 93 nloc: int = 0 # every line with code in the file (also outside procedures; with its included files') 94 tokens: int = 0 95 procedures: list[Procedure] = field(default_factory=list) 96 includes: list[Path] = field(default_factory=list) # the files it includes (theirs too) 97 98 @property 99 def average_ccn(self) -> float: 100 return sum(p.ccn for p in self.procedures) / len(self.procedures) if self.procedures else 0.0 101 102 @property 103 def average_nloc(self) -> float: 104 return sum(p.nloc for p in self.procedures) / len(self.procedures) if self.procedures else 0.0
113@dataclass 114class Source: 115 """Code with its included files read in where they're included.""" 116 text: str 117 origins: list[tuple[Path, int]] | None = None # each line's file and line there (None: no includes) 118 includes: list[Path] = field(default_factory=list) 119 120 def origin(self, line: int, path: Path) -> tuple[Path, int]: 121 """Where ``line`` (1-based, of ``text``) is: its file and line there.""" 122 if self.origins is None or not 0 < line <= len(self.origins): 123 return path, line 124 return self.origins[line - 1]
Code with its included files read in where they're included.
120 def origin(self, line: int, path: Path) -> tuple[Path, int]: 121 """Where ``line`` (1-based, of ``text``) is: its file and line there.""" 122 if self.origins is None or not 0 < line <= len(self.origins): 123 return path, line 124 return self.origins[line - 1]
Where line (1-based, of text) is: its file and line there.
127def expand_includes(text: str, path: Path | str) -> Source: 128 """``text`` (``path``'s) with each file it includes in place of the line 129 that includes it (looked for beside the including file, then beside 130 ``path``); a file that isn't found stays an include line.""" 131 path = Path(path) 132 if "include" not in text.lower(): # (most files: nothing to do) 133 return Source(text) 134 lines: list[str] = [] 135 origins: list[tuple[Path, int]] = [] 136 includes: list[Path] = [] 137 138 def read(code: str, where: Path, depth: int, reading: tuple[Path, ...]): 139 for number, line in enumerate(code.split("\n"), start=1): 140 found = INCLUDE_RE.match(line) if "include" in line.lower() else None 141 name = (found.group(1) or found.group(3)) if found else None 142 target = _find_include(name, where, path) if name else None 143 if target is None or depth >= MAX_INCLUDE_DEPTH or target in reading: 144 lines.append(line) 145 origins.append((where, number)) 146 continue 147 try: 148 included = decode(target.read_bytes())[0] 149 except OSError: 150 lines.append(line) 151 origins.append((where, number)) 152 continue 153 if target not in includes: 154 includes.append(target) 155 read(included.replace("\r\n", "\n"), target, depth + 1, (*reading, target)) 156 157 read(text, path, 0, (path,)) 158 if not includes: 159 return Source(text) 160 return Source("\n".join(lines), origins, includes)
text (path's) with each file it includes in place of the line
that includes it (looked for beside the including file, then beside
path); a file that isn't found stays an include line.
215def statements(text: str, fixed: bool) -> list[Statement]: 216 """The code's statements, in order (preprocessor lines are left out).""" 217 out: list[Statement] = [] 218 pending: list[str] = [] 219 pending_lines: list[int] = [] 220 quote: str | None = None 221 label = "" 222 223 def finish(): 224 nonlocal pending, pending_lines, label 225 if pending_lines: 226 joined = " ".join(pending) 227 first = True 228 for part in _split_semicolons(joined): 229 part = part.strip() 230 if not part: 231 continue 232 this_label = label if first else "" 233 found = LABEL_RE.match(part) 234 if found: 235 this_label, part = found.group(1), part[found.end():] 236 part = CONSTRUCT_NAME_RE.sub("", part, count=1) 237 out.append(Statement(part.lower(), this_label, list(pending_lines), 238 len(TOKEN_RE.findall(part.lower())))) 239 first = False 240 pending, pending_lines, label = [], [], "" 241 242 for number, raw in enumerate(text.split("\n"), start=1): 243 line = raw.rstrip("\r") 244 if line.startswith("#"): 245 continue 246 if fixed: 247 if line[:1] in ("c", "C", "*", "!", "d", "D") or not line.strip(): 248 continue 249 continued = len(line) > 5 and line[5] not in (" ", "0", "\t") and not line[:5].strip() 250 body = line[6:] if len(line) > 6 else "" 251 if not continued: 252 finish() 253 label = line[:5].strip() if line[:5].strip().isdigit() else "" 254 code, quote = _strip_code(body, quote if continued else None) 255 if code.strip(): 256 pending.append(code) 257 pending_lines.append(number) 258 continue 259 if quote: # (a string going on from the line before: up to its "&") 260 stripped = line.lstrip() 261 code, quote = _strip_code(stripped.removeprefix("&"), quote) 262 else: 263 stripped = line.lstrip() 264 if pending and stripped.startswith("&"): 265 stripped = stripped[1:] 266 code, quote = _strip_code(stripped, None) 267 if not code.strip(): 268 continue 269 body = code.rstrip() 270 continues = body.endswith("&") 271 pending.append(body[:-1] if continues else body) 272 pending_lines.append(number) 273 if not continues and not quote: 274 finish() 275 finish() 276 return out
The code's statements, in order (preprocessor lines are left out).
360def analyze_text(text: str, path: Path | str = "x.f90") -> FileMetrics: 361 """Fortran source's metrics (see the module's docstring).""" 362 path = Path(path) 363 found = FileMetrics(path) 364 source = expand_includes(text, path) 365 found.includes = source.includes 366 stmts = statements(source.text, path.suffix.lower() in FIXED_FORM) 367 stack: list[_Scope] = [] 368 369 def unit() -> _Scope | None: 370 return next((s for s in reversed(stack) if s.kind in UNIT_KINDS), None) 371 372 def container() -> str: 373 names = [s.name for s in stack if s.kind in UNIT_KINDS | {"module", "submodule"}] 374 return " > ".join(names) 375 376 def open_unit(kind: str, name: str, statement: Statement, header: str = ""): 377 proc = Procedure(name, kind, container(), path, statement.lines[0]) 378 proc.params = _count_params(header) 379 found.procedures.append(proc) 380 stack.append(_Scope(kind, name, proc)) 381 382 def close(kinds: set[str], statement: Statement): 383 while stack: 384 scope = stack.pop() 385 if scope.unit is not None: 386 scope.unit.end = statement.lines[-1] 387 if scope.kind in kinds: 388 return 389 390 def in_interface() -> bool: 391 return any(scope.kind == "interface" for scope in stack) 392 393 line_owner: dict[int, Procedure | None] = {} # (a line's code is counted once, for its innermost unit) 394 for st in stmts: 395 s = st.text 396 decisions = len(LOGICAL_RE.findall(s)) 397 ended = END_RE.match(s) 398 # Closing the do constructs that end at this statement's label ("do 10 …" / "10 continue"). 399 if st.label: 400 scope = unit() 401 while scope and scope.constructs and scope.constructs[-1] == ("do", st.label): 402 scope.constructs.pop() 403 if ended: 404 word = (ended.group(1) or "").replace(" ", "") 405 counted = unit() 406 _count(counted, st, decisions, line_owner) 407 if word in ("do", "if", "select", "where", "forall", "associate", "block", "critical", "team"): 408 if counted and counted.constructs: 409 counted.constructs.pop() 410 elif word in ("subroutine", "function", "procedure"): 411 close({"subroutine", "function", "procedure", "body"}, st) 412 elif word in ("program", "module", "submodule", "interface", "type", "enum"): 413 close({word}, st) 414 elif word == "blockdata": 415 close({"blockdata"}, st) 416 elif word == "": 417 close(BARE_END, st) 418 continue 419 assignment = _is_assignment(s) 420 if not assignment and _open_scope(s, st, stack, open_unit, in_interface()): 421 _count(unit(), st, decisions, line_owner) 422 continue 423 scope = unit() 424 if scope is not None and not assignment: 425 decisions += _decisions(s, st, scope) 426 _count(scope, st, decisions, line_owner) 427 428 for scope in stack: # (a file that ends without its "end"s) 429 if scope.unit is not None and not scope.unit.end: 430 scope.unit.end = stmts[-1].lines[-1] if stmts else scope.unit.start 431 for owner in line_owner.values(): 432 if owner is not None: 433 owner.nloc += 1 434 found.nloc = len(line_owner) 435 found.tokens = sum(st.tokens for st in stmts) 436 if source.origins is not None: # (the lines of the code as read: back to their own files') 437 for proc in found.procedures: 438 proc.path, start = source.origin(proc.start, path) 439 end_path, end = source.origin(proc.end, path) 440 proc.start, proc.end = start, end if end_path == proc.path else start 441 return found
Fortran source's metrics (see the module's docstring).
550def analyze_file(path: Path) -> FileMetrics: 551 """A file's metrics (an unreadable one: none).""" 552 try: 553 text = decode(path.read_bytes())[0] 554 except OSError: 555 return FileMetrics(path) 556 return analyze_text(text, path)
A file's metrics (an unreadable one: none).