diff --git a/desloppify/languages/_framework/generic_parts/parsers.py b/desloppify/languages/_framework/generic_parts/parsers.py index 397113402..77609f310 100644 --- a/desloppify/languages/_framework/generic_parts/parsers.py +++ b/desloppify/languages/_framework/generic_parts/parsers.py @@ -98,6 +98,35 @@ def parse_json(output: str, scan_path: Path) -> list[dict]: return entries +def parse_ktlint(output: str, scan_path: Path) -> list[dict]: + """Parse ktlint's per-file JSON objects containing nested errors.""" + del scan_path + data = _load_json_output(output, parser_name="ktlint") + if not isinstance(data, list): + raise ToolParserError("ktlint output must be a JSON array") + entries: list[dict] = [] + for item in data: + if not isinstance(item, dict) or not isinstance(item.get("errors"), list): + raise ToolParserError("ktlint file entry must contain an errors array") + filename = item.get("file") + if not isinstance(filename, str) or not filename: + raise ToolParserError("ktlint file entry is missing its filename") + for error in item["errors"]: + if not isinstance(error, dict): + raise ToolParserError("ktlint error must be an object") + line = _coerce_line(error.get("line")) + column = _coerce_line(error.get("column")) + message, rule = error.get("message"), error.get("rule") + if line is None or column is None or not isinstance(message, str) or not isinstance(rule, str): + raise ToolParserError("ktlint error is missing location, message or rule") + entries.append({ + "file": filename, "line": line, "message": message, + "id": f"ktlint_violation::{rule}::{line}:{column}", + "detail": {"rule": rule, "column": column}, + }) + return entries + + def parse_rubocop(output: str, scan_path: Path) -> list[dict]: """Parse RuboCop JSON: `{"files": [{"path": ..., "offenses": [...]}]}`.""" del scan_path @@ -310,6 +339,7 @@ def parse_air(output: str, scan_path: Path) -> list[dict]: "gnu": parse_gnu, "golangci": parse_golangci, "json": parse_json, + "ktlint": parse_ktlint, "credo": parse_credo, "phpstan": parse_phpstan, "rubocop": parse_rubocop, @@ -332,6 +362,7 @@ def parse_air(output: str, scan_path: Path) -> list[dict]: "parse_gnu", "parse_golangci", "parse_json", + "parse_ktlint", "parse_phpstan", "parse_next_lint", "parse_rubocop", diff --git a/desloppify/languages/kotlin/__init__.py b/desloppify/languages/kotlin/__init__.py index cd9e40cd5..230807927 100644 --- a/desloppify/languages/kotlin/__init__.py +++ b/desloppify/languages/kotlin/__init__.py @@ -2,18 +2,19 @@ from desloppify.languages._framework.generic_support.core import generic_lang from desloppify.languages._framework.treesitter import KOTLIN_SPEC +from desloppify.languages.kotlin.fixers import KTLINT_COMMAND, make_ktlint_fixer -generic_lang( +_config = generic_lang( name="kotlin", extensions=[".kt", ".kts"], tools=[ { "label": "ktlint", - "cmd": "ktlint --reporter=json", - "fmt": "json", + "cmd": KTLINT_COMMAND, + "fmt": "ktlint", "id": "ktlint_violation", "tier": 2, - "fix_cmd": "ktlint --format", + "fix_cmd": "ktlint --format --log-level=none", }, ], exclude=["build"], @@ -22,6 +23,9 @@ treesitter_spec=KOTLIN_SPEC, ) +_config.fixers["ktlint-violation"] = make_ktlint_fixer(_config.file_finder) +_config.detect_commands["ktlint_violation"] = _config.fixers["ktlint-violation"].detect + __all__ = [ "generic_lang", "KOTLIN_SPEC", diff --git a/desloppify/languages/kotlin/fixers.py b/desloppify/languages/kotlin/fixers.py new file mode 100644 index 000000000..178b98e54 --- /dev/null +++ b/desloppify/languages/kotlin/fixers.py @@ -0,0 +1,112 @@ +"""Ktlint formatting restricted to eligible files with selected findings.""" + +from __future__ import annotations + +import stat +import subprocess # nosec B404 +from collections import Counter +from collections.abc import Callable +from pathlib import Path + +from desloppify.base.discovery.file_paths import resolve_scan_file, safe_write_text +from desloppify.base.discovery.paths import get_project_root +from desloppify.languages._framework.base.types import FixerConfig, FixResult +from desloppify.languages._framework.generic_parts.parsers import ( + ToolParserError, + parse_ktlint, +) +from desloppify.languages._framework.generic_parts.tool_runner import run_tool_result + +KTLINT_COMMAND = "ktlint --reporter=json --log-level=none" + + +def _format_source(filepath: Path, source: str, root: Path) -> tuple[str, bool] | None: + """Use stdin so file names are never interpreted as formatting globs.""" + common = ["ktlint", "--stdin", "--stdin-path", str(filepath), "--log-level=none"] + options = {"cwd": str(root), "capture_output": True, "text": True, "timeout": 120} + formatted = subprocess.run([*common, "--format"], input=source, **options) + # ktlint can return exit 0 with empty stdout for a syntax error. + if formatted.returncode not in (0, 1) or not formatted.stdout: + return None + checked = subprocess.run([*common, "--reporter=json"], input=formatted.stdout, **options) + report = checked.stdout if checked.stdout.strip() else checked.stderr + remaining = parse_ktlint(report, root) + if ( + checked.returncode not in (0, 1) + or (checked.returncode == 1 and not remaining) + or any(not entry["detail"]["rule"] for entry in remaining) + ): + return None + return formatted.stdout, not remaining + + +def make_ktlint_fixer(file_finder: Callable[[Path], list[str]]) -> FixerConfig: + """Build a Kotlin-only fixer; generic tool behavior remains unchanged.""" + def eligible_files(path: Path) -> set[Path]: + root = get_project_root() + candidates = (root / filename for filename in file_finder(path)) + return { + candidate.resolve() for candidate in candidates + if not candidate.is_symlink() and candidate.resolve().is_relative_to(root) + } + + def detect(path: Path) -> list[dict]: + scan_path = Path(getattr(path, "path", path)) + eligible = eligible_files(scan_path) + result = run_tool_result(KTLINT_COMMAND, scan_path, parse_ktlint) + entries = [] + for entry in result.entries: + filepath = resolve_scan_file(entry["file"], scan_root=scan_path) + if filepath in eligible: + entries.append({**entry, "file": str(filepath)}) + return entries + + def fix(entries: list[dict], *, dry_run: bool = False) -> FixResult: + root = get_project_root() + eligible = eligible_files(root) + grouped: dict[Path, list[dict]] = {} + skipped: Counter[str] = Counter() + for entry in entries: + filepath = resolve_scan_file(entry["file"]) + if filepath not in eligible: + skipped["excluded_file"] += 1 + continue + grouped.setdefault(filepath, []).append(entry) + + results = [] + for filepath, file_entries in grouped.items(): + try: + with filepath.open(encoding="utf-8", newline="") as handle: + source = handle.read() + formatted = _format_source(filepath, source, root) + if formatted is None: + skipped["tool_failed"] += len(file_entries) + continue + updated, clean = formatted + if updated == source: + skipped["no_change"] += len(file_entries) + continue + if not dry_run: + if filepath.read_bytes().decode("utf-8") != source: + skipped["file_changed"] += len(file_entries) + continue + mode = stat.S_IMODE(filepath.stat().st_mode) + safe_write_text(filepath, updated) + filepath.chmod(mode) + except (OSError, UnicodeError, subprocess.TimeoutExpired, ToolParserError): + skipped["tool_failed"] += len(file_entries) + continue + result = {"file": str(filepath), "summary": "Formatted with ktlint"} + if clean: + result["removed"] = [ + entry.get("id", f"ktlint_violation::{entry['line']}") + for entry in file_entries + ] + else: + result["summary"] += "; remaining violations require a rescan" + results.append(result) + return FixResult(entries=results, skip_reasons=dict(skipped)) + + return FixerConfig( + label="ktlint issues", detect=detect, fix=fix, detector="ktlint_violation", + ) diff --git a/desloppify/tests/lang/common/test_kotlin_ktlint.py b/desloppify/tests/lang/common/test_kotlin_ktlint.py new file mode 100644 index 000000000..d52142169 --- /dev/null +++ b/desloppify/tests/lang/common/test_kotlin_ktlint.py @@ -0,0 +1,224 @@ +"""Ktlint JSON, fixer selection and bounded formatting regressions.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import stat +import subprocess + +import pytest + +from desloppify.base.exception_sets import CommandError +from desloppify.base.runtime_state import runtime_scope +from desloppify.languages import get_lang +from desloppify.languages._framework.generic_parts.parsers import ( + PARSERS, + ToolParserError, +) + + +def _report(filename="Example.kt"): + return json.dumps([{"file": filename, "errors": [ + {"line": 1, "column": 12, "message": "Unexpected whitespace", "rule": "standard:parameter-list-spacing"}, + {"line": 1, "column": 12, "message": "Unexpected spacing after (", "rule": "standard:paren-spacing"}, + ]}]) + + +def test_nested_ktlint_errors_have_distinct_identities(tmp_path): + assert "ktlint" in PARSERS + entries = PARSERS["ktlint"](_report(), tmp_path) + assert len(entries) == 2 + assert [entry["file"] for entry in entries] == ["Example.kt", "Example.kt"] + assert [entry["line"] for entry in entries] == [1, 1] + assert entries[0]["id"] != entries[1]["id"] + assert entries[0]["detail"]["column"] == 12 + assert entries[0]["detail"]["rule"] == "standard:parameter-list-spacing" + + +@pytest.mark.parametrize("output", ["not JSON", "WARN message\n[]", "{}", '[{"file":"Example.kt","errors":{}}]']) +def test_malformed_ktlint_output_is_not_treated_as_clean(tmp_path, output): + assert "ktlint" in PARSERS + with pytest.raises(ToolParserError): + PARSERS["ktlint"](output, tmp_path) + + +def test_empty_ktlint_output_is_clean(tmp_path): + assert "ktlint" in PARSERS + assert PARSERS["ktlint"]("[]", tmp_path) == [] + + +def test_kotlin_only_selects_registered_fixer(monkeypatch): + from desloppify.app.commands.autofix import fixer_selection + from desloppify.engine._work_queue.helpers import primary_command_for_issue + + lang = get_lang("kotlin") + monkeypatch.setattr(fixer_selection, "resolve_lang", lambda args: lang) + _, fixer = fixer_selection.resolve_fixer_config(argparse.Namespace(), "ktlint-violation") + assert fixer is lang.fixers["ktlint-violation"] + assert primary_command_for_issue( + {"id": "k1", "detector": "ktlint_violation"}, supported_fixers=set(lang.fixers), + ) == "desloppify autofix ktlint-violation --dry-run" + with pytest.raises(CommandError, match="Unknown fixer: unused-imports"): + fixer_selection.resolve_fixer_config(argparse.Namespace(), "unused-imports") + + +@pytest.fixture +def kotlin_project(tmp_path): + with runtime_scope() as runtime: + runtime.project_root = tmp_path + runtime.exclusions = ("generated",) + files = {} + for name in ("Example.kt", "Other.kt", "generated/Bindings.kt", "build/Generated.kt", "odd[1].kt"): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('fun sample( ){println("hello")}\n') + files[name] = path + yield tmp_path, files + + +def test_formatter_never_receives_excluded_or_unselected_files(kotlin_project, monkeypatch): + from desloppify.languages.kotlin import fixers + + root, files = kotlin_project + calls = [] + original = {name: path.read_bytes() for name, path in files.items()} + original_mode = stat.S_IMODE(files["Example.kt"].stat().st_mode) + formatted = 'fun sample() {\n println("hello")\n}\n' + + def run(argv, **kwargs): + calls.append(argv) + assert "--stdin" in argv + assert "--log-level=none" in argv + assert argv[argv.index("--stdin-path") + 1] == str(files["Example.kt"]) + return subprocess.CompletedProcess(argv, 0, formatted if "--format" in argv else "", "[]") + + monkeypatch.setattr(fixers.subprocess, "run", run) + entries = [{"file": str(files[name]), "line": 1} for name in ( + "Example.kt", "generated/Bindings.kt", "build/Generated.kt", + )] + result = get_lang("kotlin").fixers["ktlint-violation"].fix(entries) + assert len(calls) == 2 + assert len(result.entries) == 1 + assert result.skip_reasons == {"excluded_file": 2} + assert files["Example.kt"].read_text() == formatted + assert stat.S_IMODE(files["Example.kt"].stat().st_mode) == original_mode + for name in ("Other.kt", "generated/Bindings.kt", "build/Generated.kt", "odd[1].kt"): + assert files[name].read_bytes() == original[name] + + +@pytest.mark.parametrize("failure", ["empty", "bad-json", "timeout", "missing-tool"]) +def test_formatter_failures_preserve_source(kotlin_project, monkeypatch, failure): + from desloppify.languages.kotlin import fixers + + root, files = kotlin_project + source = files["Example.kt"] + original = source.read_bytes() + + def run(argv, **kwargs): + if failure == "timeout": + raise subprocess.TimeoutExpired(argv, 120) + if failure == "missing-tool": + raise FileNotFoundError("ktlint") + stdout = "" if failure == "empty" else "fun sample() = Unit\n" + return subprocess.CompletedProcess(argv, 0, stdout, "not JSON") + + monkeypatch.setattr(fixers.subprocess, "run", run) + result = get_lang("kotlin").fixers["ktlint-violation"].fix( + [{"file": str(source), "line": 1}], + ) + assert result.entries == [] + assert result.skip_reasons == {"tool_failed": 1} + assert source.read_bytes() == original + + +def test_concurrent_source_change_is_preserved(kotlin_project, monkeypatch): + from desloppify.languages.kotlin import fixers + + root, files = kotlin_project + source = files["Example.kt"] + edited = "fun changed() = Unit\n" + + def run(argv, **kwargs): + source.write_text(edited) + return subprocess.CompletedProcess(argv, 0, "fun sample() = Unit\n" if "--format" in argv else "", "[]") + + monkeypatch.setattr(fixers.subprocess, "run", run) + result = get_lang("kotlin").fixers["ktlint-violation"].fix( + [{"file": str(source), "line": 1}], + ) + assert result.entries == [] + assert result.skip_reasons == {"file_changed": 1} + assert source.read_text() == edited + + +requires_ktlint = pytest.mark.skipif(shutil.which("ktlint") is None, reason="ktlint is not installed") + + +@requires_ktlint +def test_actual_ktlint_output_is_detected_and_exclusions_apply(kotlin_project): + root, files = kotlin_project + fixer = get_lang("kotlin").fixers["ktlint-violation"] + entries = fixer.detect(root) + assert entries + assert {entry["file"] for entry in entries} == { + str(files[name]) for name in ("Example.kt", "Other.kt", "odd[1].kt") + } + + +@requires_ktlint +def test_format_only_selected_eligible_files_without_path_argument(kotlin_project): + root, files = kotlin_project + fixer = get_lang("kotlin").fixers["ktlint-violation"] + original = {name: path.read_bytes() for name, path in files.items()} + entries = [{"file": str(files[name]), "line": 1} for name in ( + "Example.kt", "odd[1].kt", "generated/Bindings.kt", "build/Generated.kt", + )] + result = fixer.fix(entries) + assert {entry["file"] for entry in result.entries} == {str(files["Example.kt"]), str(files["odd[1].kt"])} + for name in ("Example.kt", "odd[1].kt"): + assert files[name].read_text() == 'fun sample() {\n println("hello")\n}\n' + for name in ("Other.kt", "generated/Bindings.kt", "build/Generated.kt"): + assert files[name].read_bytes() == original[name] + + +@requires_ktlint +def test_dry_run_does_not_write(kotlin_project): + root, files = kotlin_project + source = files["Example.kt"] + original = source.read_bytes() + result = get_lang("kotlin").fixers["ktlint-violation"].fix( + [{"file": str(source), "line": 1}], dry_run=True, + ) + assert result.entries + assert source.read_bytes() == original + + +@requires_ktlint +def test_invalid_kotlin_is_never_truncated(kotlin_project): + root, files = kotlin_project + source = files["Example.kt"] + source.write_text("fun broken( {\n") + original = source.read_bytes() + result = get_lang("kotlin").fixers["ktlint-violation"].fix( + [{"file": str(source), "line": 1}], + ) + assert result.entries == [] + assert result.skip_reasons + assert source.read_bytes() == original + + +@requires_ktlint +def test_uncorrectable_violations_are_not_auto_resolved(kotlin_project): + root, files = kotlin_project + source = files["Example.kt"] + source.write_text('fun BAD_NAME() { println("hello") }\n') + fixer = get_lang("kotlin").fixers["ktlint-violation"] + entries = [entry for entry in fixer.detect(root) if entry["file"] == str(source)] + assert entries + result = fixer.fix(entries) + assert len(result.entries) == 1 + assert "removed" not in result.entries[0] + assert "remaining violations" in result.entries[0]["summary"] + assert source.read_text() == 'fun BAD_NAME() {\n println("hello")\n}\n'