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