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 (do with a loop control, do while, do concurrent, forall: not an endless do, whose way out is an exit under an if), each case (…) (not case default), each type is / class is / rank (…) guard, each masked where / elsewhere, and each .and. / .or.. An arithmetic if counts one less than its different labels (up to 2), a computed goto as 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)
FIXED_FORM = {'.f77', '.f', '.for', '.ftn'}
FORTRAN = {'.for', '.f95', '.f90', '.f77', '.f', '.f18', '.f23', '.f08', '.fpp', '.ftn', '.f03'}
THRESHOLDS = {'ccn': 15, 'length': 1000, 'params': 100, 'depth': 5}
def is_fortran(path: pathlib.Path) -> bool:
58def is_fortran(path: Path) -> bool:
59    return path.suffix.lower() in FORTRAN
@dataclass
class Procedure:
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]
Procedure( name: str, kind: str, container: str, path: pathlib.Path, start: int, end: int = 0, nloc: int = 0, ccn: int = 1, tokens: int = 0, params: int = 0, depth: int = 0)
name: str
kind: str
container: str
path: pathlib.Path
start: int
end: int = 0
nloc: int = 0
ccn: int = 1
tokens: int = 0
params: int = 0
depth: int = 0
length: int
76    @property
77    def length(self) -> int:
78        return self.end - self.start + 1
qualified: str
80    @property
81    def qualified(self) -> str:
82        return f"{self.container}::{self.name}" if self.container else self.name
def warnings( self, thresholds: dict[str, int] = {'ccn': 15, 'length': 1000, 'params': 100, 'depth': 5}) -> list[str]:
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"…).

@dataclass
class FileMetrics:
 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
FileMetrics( path: pathlib.Path, nloc: int = 0, tokens: int = 0, procedures: list[Procedure] = <factory>, includes: list[pathlib.Path] = <factory>)
path: pathlib.Path
nloc: int = 0
tokens: int = 0
procedures: list[Procedure]
includes: list[pathlib.Path]
average_ccn: float
 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
average_nloc: float
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
INCLUDE_RE = re.compile('^\\s*(?:#\\s*include\\s*["<]([^">]+)[">]|include\\s*([\'"])(.+?)\\2\\s*(?:!.*)?$)', re.IGNORECASE)
MAX_INCLUDE_DEPTH = 10
@dataclass
class Source:
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.

Source( text: str, origins: list[tuple[pathlib.Path, int]] | None = None, includes: list[pathlib.Path] = <factory>)
text: str
origins: list[tuple[pathlib.Path, int]] | None = None
includes: list[pathlib.Path]
def origin(self, line: int, path: pathlib.Path) -> tuple[pathlib.Path, int]:
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.

def expand_includes(text: str, path: pathlib.Path | str) -> Source:
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.

@dataclass
class Statement:
172@dataclass
173class Statement:
174    text: str                 # lowercase code: strings blanked (''), no comments, label or construct name
175    label: str                # its statement label ("" if none)
176    lines: list[int]          # the lines it's on (with code)
177    tokens: int = 0
Statement(text: str, label: str, lines: list[int], tokens: int = 0)
text: str
label: str
lines: list[int]
tokens: int = 0
TOKEN_RE = re.compile('\'[^\']*\'|\\"[^\\"]*\\"|\\.[a-z]+\\.|\\d+(?:\\.\\d*)?(?:[ed][+-]?\\d+)?(?:_\\w+)?|\\w+|::|=>|\\*\\*|//|==|/=|<=|>=|\\S')
LABEL_RE = re.compile('^(\\d{1,5})\\s+')
CONSTRUCT_NAME_RE = re.compile('^[a-z_]\\w*\\s*:(?!:)\\s*')
def statements(text: str, fixed: bool) -> list[Statement]:
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).

END_RE = re.compile('^end\\s*(subroutine|function|program|module|submodule|interface|type|select|where|forall|associate|block\\s*data|block|critical|procedure|enum|team|do|if)?\\b\\s*\\w*\\s*$')
PROCEDURE_RE = re.compile('^(?:([\\w\\s(),=*:]*?)\\s)?(subroutine|function)\\s+(\\w+)\\s*(\\(.*)?$')
MODULE_PROCEDURE_RE = re.compile('^module\\s+procedure\\s+(\\w+)\\s*$')
PROGRAM_RE = re.compile('^program\\s+(\\w+)')
MODULE_RE = re.compile('^module\\s+(\\w+)\\s*$')
SUBMODULE_RE = re.compile('^submodule\\s*\\(.*\\)\\s*(\\w+)')
BLOCK_DATA_RE = re.compile('^block\\s*data\\b')
INTERFACE_RE = re.compile('^(abstract\\s+)?interface\\b')
TYPE_DEF_RE = re.compile('^type\\s*(?:,[^:]*)?::\\s*\\w+|^type\\s+(?!is\\b)\\w+\\s*$')
ENUM_RE = re.compile('^enum\\s*,')
DO_RE = re.compile('^do\\b\\s*(\\d+)?')
SELECT_RE = re.compile('^select\\s*(case|type|rank)\\s*\\(')
GUARD_RE = re.compile('^(case\\s*\\(|type\\s+is\\s*\\(|class\\s+is\\s*\\(|rank\\s*\\()')
LOGICAL_RE = re.compile('\\.(and|or)\\.')
UNIT_KINDS = {'subroutine', 'function', 'program', 'procedure'}
BARE_END = {'function', 'body', 'submodule', 'module', 'subroutine', 'blockdata', 'program', 'procedure'}
def analyze_text( text: str, path: pathlib.Path | str = 'x.f90') -> FileMetrics:
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).

def analyze_file(path: pathlib.Path) -> FileMetrics:
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).