Coverage for vibefpmgui/complexity.py: 94%

412 statements  

« 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). 

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: 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)) 

155 

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) 

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: 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 = [], [], "" 

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(): 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 

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)): 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) 

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: 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("==")) 

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: 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 

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: 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) 

426 

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 

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): 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 

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)