Source code for simvx.ide.lint.runner

"""Linting and formatting subprocess runner.

The commands are configurable (``IDESection.lint_command`` /
``format_command``); the shipped defaults are ``ruff check`` and ``black``.
"""

import json
import logging
import shlex
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING

import parso

if TYPE_CHECKING:
    from ..config import Config
    from ..state import State

from ..state import Diagnostic

log = logging.getLogger(__name__)

#: Source of the diagnostics this module raises about the formatter itself, as
#: opposed to the ones it parses out of the linter's report.
_FORMATTER_SOURCE = "format"


def _parse_refusal(data: bytes) -> tuple[int, int, str] | None:
    """``(line, column, reason)`` if *data* is not readable by the scene parser.

    Zero-indexed, matching :class:`~simvx.ide.state.Diagnostic`. Returns None
    when the bytes parse, which is the case a formatter is expected to produce.
    """
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError as exc:
        return (0, 0, f"result is not UTF-8 ({exc.reason} at byte {exc.start})")

    try:
        parso.parse(text, error_recovery=False, version=None)
    except parso.ParserSyntaxError as exc:
        line, column = exc.error_leaf.start_pos
        return (line - 1, column, f"{exc.message} at {exc.error_leaf.value!r}")
    return None


[docs] class LintRunner: """Runs the configured lint and format commands and feeds diagnostics into State.""" def __init__(self, state: State, config: Config): self._state = state self._config = config self._env = config.get_env(state.project_root) if state.project_root else None # -- Public API ------------------------------------------------------------
[docs] def lint_file(self, path: str) -> list[Diagnostic]: """Run ruff check on *path*, parse JSON output, update state diagnostics.""" cmd = self._config.lint_command cmd_list = shlex.split(cmd) + [path] env = self._env or self._config.get_env(self._state.project_root) try: result = subprocess.run( cmd_list, capture_output=True, text=True, timeout=15, cwd=self._state.project_root or None, env=env, ) except FileNotFoundError: log.warning("Lint command not found: %s", cmd) return [] except subprocess.TimeoutExpired: log.warning("Lint timed out for %s", path) return [] diagnostics = self._parse_ruff_json(result.stdout) self._state.set_diagnostics(path, diagnostics) return diagnostics
[docs] def format_file(self, path: str) -> bool: """Run the configured formatter on *path*. Returns True when it stands. The formatter rewrites the file in place, so its result is re-read before the rewrite is allowed to stand. A formatter may emit any syntax the interpreter accepts, and the parser scenes are loaded through accepts less than that: ``ruff format`` at target-version py314 rewrites ``except (A, B):`` as PEP 758 ``except A, B:``, which parso 0.8.7 cannot read, so a scene formatted on save keeps running and stops opening in the editor. When the result no longer parses, the file goes back to what it was and the refusal is reported as an error diagnostic, which is what puts it in the output panel. """ cmd = self._config.format_command cmd_list = shlex.split(cmd) + [path] env = self._env or self._config.get_env(self._state.project_root) source = Path(path) try: before = source.read_bytes() except OSError as exc: log.warning("Cannot read %s before formatting: %s", path, exc) return False try: result = subprocess.run( cmd_list, capture_output=True, text=True, timeout=15, cwd=self._state.project_root or None, env=env, ) except FileNotFoundError: log.warning("Format command not found: %s", cmd) self._report_formatter_fault( path, 0, 0, f"format-on-save is on and {cmd_list[0]!r} is not installed, so nothing was formatted. " "Install it, set a different format_command, or turn format_on_save off.", ) return False except subprocess.TimeoutExpired: log.warning("Format timed out for %s", path) return False if result.returncode not in (0, 1): log.warning("Format failed for %s: %s", path, result.stderr.strip()) return False try: after = source.read_bytes() except OSError as exc: log.warning("Cannot read %s after formatting: %s", path, exc) return False if after == before: return True refusal = _parse_refusal(after) if refusal is None: return True source.write_bytes(before) line, column, reason = refusal log.warning("Format refused for %s: %s", path, reason) self._report_formatter_fault( path, line, column, f"{cmd!r} produced source the scene parser cannot read ({reason}); " "the file was left as it was. Set a different format_command, or turn format_on_save off.", ) return False
def _report_formatter_fault(self, path: str, line: int, column: int, message: str) -> None: """Put a formatter fault where the user will see it, not only in the log. Format-on-save runs without being asked for, so a fault in it has no other surface: the diagnostics channel is what reaches the output panel. """ self._state.set_diagnostics( path, [ Diagnostic( path=path, line=line, col_start=column, col_end=column + 1, severity=1, message=message, source=_FORMATTER_SOURCE, ) ], )
[docs] def lint_on_save(self, path: str): """Called after a file is saved -- runs lint and updates diagnostics.""" self.lint_file(path)
[docs] def format_on_save(self, path: str): """Format *path* on save, but only when the ``format_on_save`` setting is enabled.""" if self._config.format_on_save: self.format_file(path)
# -- Parsing --------------------------------------------------------------- def _parse_ruff_json(self, output: str) -> list[Diagnostic]: """Parse ruff check --output-format=json output into Diagnostic objects.""" if not output or not output.strip(): return [] try: entries = json.loads(output) except json.JSONDecodeError: log.debug("Failed to parse ruff JSON output") return [] diagnostics: list[Diagnostic] = [] for entry in entries: code = entry.get("code", "") message = entry.get("message", "") filename = entry.get("filename", "") loc = entry.get("location", {}) end_loc = entry.get("end_location", {}) line = loc.get("row", 1) - 1 col_start = loc.get("column", 1) - 1 col_end = end_loc.get("column", col_start + 2) - 1 # Ruff codes: E/W = style, F = pyflakes errors, B = bugbear # Map to severity: F-codes and some E-codes are errors, rest warnings severity = 2 # warning by default if code.startswith("F") or code.startswith("E9"): severity = 1 # error diagnostics.append( Diagnostic( path=filename, line=line, col_start=col_start, col_end=col_end, severity=severity, message=message, source="ruff", code=code, ) ) return diagnostics