diff --git a/desloppify/languages/_framework/generic_parts/parsers.py b/desloppify/languages/_framework/generic_parts/parsers.py index 397113402..761373712 100644 --- a/desloppify/languages/_framework/generic_parts/parsers.py +++ b/desloppify/languages/_framework/generic_parts/parsers.py @@ -181,6 +181,35 @@ def parse_eslint(output: str, scan_path: Path) -> list[dict]: return entries +def parse_ktlint(output: str, scan_path: Path) -> list[dict]: + """Parse ktlint `--reporter=json` output. + + Shape: ``[{"file": ..., "errors": [{"line", "column", "message", "rule"}]}]``. + This differs from the generic flat ``json`` format (which expects + top-level ``file``/``line``/``message`` keys per entry) and from the + ESLint format (which nests under ``filePath``/``messages``), so it needs + its own parser rather than reusing either. + """ + del scan_path + entries: list[dict] = [] + data = _load_json_output(output, parser_name="ktlint") + for fobj in data if isinstance(data, list) else []: + if not isinstance(fobj, dict): + continue + filepath = fobj.get("file", "") + for err in fobj.get("errors") or []: + if not isinstance(err, dict): + continue + line = _coerce_line(err.get("line", 0)) + message = err.get("message", "") + rule = err.get("rule", "") + if rule: + message = f"{message} ({rule})" + if filepath and message and line is not None: + entries.append({"file": str(filepath), "line": line, "message": str(message)}) + return entries + + def parse_phpstan(output: str, scan_path: Path) -> list[dict]: """Parse PHPStan JSON: ``{"files": {"": {"messages": [{"message": "...", "line": 42}]}}}``.""" del scan_path @@ -314,6 +343,7 @@ def parse_air(output: str, scan_path: Path) -> list[dict]: "phpstan": parse_phpstan, "rubocop": parse_rubocop, "cargo": parse_cargo, + "ktlint": parse_ktlint, "eslint": parse_eslint, "next_lint": parse_next_lint, "air": parse_air, @@ -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/_framework/generic_support/core.py b/desloppify/languages/_framework/generic_support/core.py index 90eb46409..4435929e2 100644 --- a/desloppify/languages/_framework/generic_support/core.py +++ b/desloppify/languages/_framework/generic_support/core.py @@ -26,6 +26,7 @@ parse_gnu, parse_golangci, parse_json, + parse_ktlint, parse_rubocop, ) from desloppify.languages._framework.generic_parts.tool_factories import ( @@ -178,5 +179,6 @@ def generic_lang( "parse_gnu", "parse_golangci", "parse_json", + "parse_ktlint", "parse_rubocop", ] diff --git a/desloppify/languages/kotlin/__init__.py b/desloppify/languages/kotlin/__init__.py index cd9e40cd5..af97aa865 100644 --- a/desloppify/languages/kotlin/__init__.py +++ b/desloppify/languages/kotlin/__init__.py @@ -10,7 +10,7 @@ { "label": "ktlint", "cmd": "ktlint --reporter=json", - "fmt": "json", + "fmt": "ktlint", "id": "ktlint_violation", "tier": 2, "fix_cmd": "ktlint --format", diff --git a/desloppify/tests/lang/common/test_generic_plugin.py b/desloppify/tests/lang/common/test_generic_plugin.py index 03626dad5..066856dfe 100644 --- a/desloppify/tests/lang/common/test_generic_plugin.py +++ b/desloppify/tests/lang/common/test_generic_plugin.py @@ -20,6 +20,7 @@ parse_gnu, parse_golangci, parse_json, + parse_ktlint, parse_rubocop, ) from desloppify.languages._framework.generic_parts.tool_factories import make_detect_fn @@ -234,6 +235,59 @@ def test_invalid_json(self): parse_eslint("not json", Path(".")) +class TestParseKtlint: + def test_extracts_errors_with_rule_suffix(self): + payload = [ + { + "file": "src/main/Foo.kt", + "errors": [ + { + "line": 10, + "column": 5, + "message": "Newline expected", + "rule": "standard:class-signature", + } + ], + } + ] + entries = parse_ktlint(json.dumps(payload), Path(".")) + assert entries == [ + { + "file": "src/main/Foo.kt", + "line": 10, + "message": "Newline expected (standard:class-signature)", + } + ] + + def test_handles_multiple_files_and_errors(self): + payload = [ + {"file": "a.kt", "errors": [{"line": 1, "message": "m1", "rule": "r1"}]}, + { + "file": "b.kt", + "errors": [ + {"line": 2, "message": "m2", "rule": "r2"}, + {"line": 3, "message": "m3", "rule": "r3"}, + ], + }, + ] + entries = parse_ktlint(json.dumps(payload), Path(".")) + assert len(entries) == 3 + assert entries[0]["file"] == "a.kt" + assert entries[1]["file"] == "b.kt" and entries[1]["line"] == 2 + assert entries[2]["line"] == 3 + + def test_file_with_no_errors_yields_nothing(self): + payload = [{"file": "clean.kt", "errors": []}] + assert parse_ktlint(json.dumps(payload), Path(".")) == [] + + def test_empty_array(self): + assert parse_ktlint("[]", Path(".")) == [] + + def test_invalid_json(self): + with pytest.raises(ToolParserError): + parse_ktlint("not json", Path(".")) + + class TestParsePhpstan: def test_extracts_messages(self): data = {