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, ""
LINT_EXTS = {'.f23', '.f08', '.f95', '.f90', '.f03', '.f18'}
class FixEdit(typing.NamedTuple):
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).

FixEdit( line: int, column: int, end_line: int, end_column: int, new_text: str)

Create new instance of FixEdit(line, column, end_line, end_column, new_text)

line: int

Alias for field number 0

column: int

Alias for field number 1

end_line: int

Alias for field number 2

end_column: int

Alias for field number 3

new_text: str

Alias for field number 4

class LintIssue(typing.NamedTuple):
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)

LintIssue( line: int, column: int, end_line: int, end_column: int, code: str, message: str, fix: str, fix_safe: bool = False, edits: tuple[FixEdit, ...] = ())

Create new instance of LintIssue(line, column, end_line, end_column, code, message, fix, fix_safe, edits)

line: int

Alias for field number 0

column: int

Alias for field number 1

end_line: int

Alias for field number 2

end_column: int

Alias for field number 3

code: str

Alias for field number 4

message: str

Alias for field number 5

fix: str

Alias for field number 6

fix_safe: bool

Alias for field number 7

edits: tuple[FixEdit, ...]

Alias for field number 8

fixable: bool
43    @property
44    def fixable(self) -> bool:
45        return bool(self.edits)
severity: str
47    @property
48    def severity(self) -> str:
49        return "lint error" if self.code.startswith("E") else "lint"
def describe(self) -> str:
51    def describe(self) -> str:
52        fix = f" (fix: {self.fix})" if self.fix else ""
53        return f"fortitude {self.code}: {self.message}{fix}"
def executable() -> str | None:
56def executable() -> str | None:
57    return shutil.which("fortitude")
def can_lint(path: pathlib.Path) -> bool:
60def can_lint(path: Path) -> bool:
61    return path.suffix.lower() in LINT_EXTS
def command(path: pathlib.Path) -> list[str]:
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.

def parse(output: str) -> list[LintIssue]:
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).

def fix_text( text: str, path: pathlib.Path, unsafe: bool = False, timeout: float = 60) -> tuple[str | None, str]:
 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.