Coverage for vibefpmgui/lint.py: 86%
56 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Linting Fortran files in the editor with fortitude.
3`fortitude <https://fortitude.readthedocs.io>`_ reads the editor's text on
4stdin (so unsaved changes are linted too) and reports its findings as JSON.
5It runs in the folder of the file, so it finds the project's settings
6(``fpm.toml``'s ``[extra.fortitude]`` table or a ``fortitude.toml``).
7fortitude only understands free-form Fortran, so other files are skipped.
8"""
10import json
11import shutil
12import subprocess
13from pathlib import Path
14from typing import NamedTuple
16from .console import NO_WINDOW
18LINT_EXTS = {".f90", ".f95", ".f03", ".f08", ".f18", ".f23"} # any case, e.g. .F90
21class FixEdit(NamedTuple):
22 """One change a fix makes (0-based, end exclusive: the same fields as
23 `vibefpmgui.lsp.TextEdit`, so the editor applies both the same way)."""
24 line: int
25 column: int
26 end_line: int
27 end_column: int
28 new_text: str
31class LintIssue(NamedTuple):
32 line: int # 1-based
33 column: int # 1-based
34 end_line: int
35 end_column: int
36 code: str # e.g. "C121"; E… are syntax errors
37 message: str
38 fix: str # what fortitude --fix would do ("" if nothing)
39 fix_safe: bool = False # fortitude applies it with --fix (others need --unsafe-fixes)
40 edits: tuple[FixEdit, ...] = () # the fix's changes (empty: no automatic fix)
42 @property
43 def fixable(self) -> bool:
44 return bool(self.edits)
46 @property
47 def severity(self) -> str:
48 return "lint error" if self.code.startswith("E") else "lint"
50 def describe(self) -> str:
51 fix = f" (fix: {self.fix})" if self.fix else ""
52 return f"fortitude {self.code}: {self.message}{fix}"
55def executable() -> str | None:
56 return shutil.which("fortitude")
59def can_lint(path: Path) -> bool:
60 return path.suffix.lower() in LINT_EXTS
63def command(path: Path) -> list[str]:
64 """Arguments for fortitude (after the executable); the text goes to stdin."""
65 return ["check", "--output-format", "json", "--stdin-filename", str(path), "-"]
68def parse(output: str) -> list[LintIssue]:
69 """fortitude's JSON output -> issues (empty if it can't be read)."""
70 try:
71 data = json.loads(output)
72 except ValueError:
73 return []
74 issues = []
75 for item in data if isinstance(data, list) else []:
76 try:
77 start, end = item["location"], item.get("end_location") or item["location"]
78 fix = item.get("fix") or {}
79 edits = tuple(FixEdit(int(e["location"]["line"]) - 1, int(e["location"]["column"]) - 1,
80 int(e["end_location"]["line"]) - 1, int(e["end_location"]["column"]) - 1,
81 str(e.get("content", "")))
82 for e in fix.get("edits") or [])
83 issues.append(LintIssue(int(start["line"]), int(start["column"]),
84 int(end["line"]), int(end["column"]),
85 str(item.get("code", "")), str(item.get("message", "")),
86 str(fix.get("message") or ""), fix.get("applicability") == "safe", edits))
87 except (KeyError, TypeError, ValueError):
88 continue
89 return issues
92def fix_text(text: str, path: Path, unsafe: bool = False, timeout: float = 60) -> tuple[str | None, str]:
93 """(the text with fortitude's fixes applied, "") or (None, why not).
94 Safe fixes only, unless ``unsafe``. Runs in the file's folder (for its
95 settings), on the given text rather than the file on disk."""
96 program = executable()
97 if program is None: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 return None, "fortitude was not found on the PATH."
99 args = [program, "check", "--fix", "--stdin-filename", str(path), "-"]
100 if unsafe:
101 args.insert(3, "--unsafe-fixes")
102 try:
103 out = subprocess.run(args, input=text, capture_output=True, text=True, encoding="utf-8",
104 errors="replace", cwd=path.parent, timeout=timeout, check=False, **NO_WINDOW)
105 except (OSError, subprocess.SubprocessError) as exc:
106 return None, str(exc)
107 # (The exit status says whether issues remain, so it isn't an error.)
108 if text.strip() and not out.stdout.strip(): 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 lines = [line for line in out.stderr.splitlines() if line.strip()]
110 return None, lines[-1] if lines else "fortitude returned no text."
111 return out.stdout, ""