Coverage for vibefpmgui/lint.py: 86%

56 statements  

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

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