Coverage for lintro / parsers / clippy / clippy_parser.py: 79%
67 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Parser for Clippy cargo diagnostic JSON output."""
3from __future__ import annotations
5import json
6from typing import Any
8from loguru import logger
10from lintro.parsers.base_parser import strip_ansi_codes
11from lintro.parsers.clippy.clippy_issue import ClippyIssue
14def _parse_issue(item: dict[str, Any]) -> ClippyIssue | None:
15 """Convert a Clippy diagnostic JSON object into a ``ClippyIssue``.
17 Args:
18 item: A single diagnostic payload returned by cargo clippy in JSON form.
20 Returns:
21 A populated ``ClippyIssue`` instance or ``None`` when the payload cannot
22 be parsed.
23 """
24 try:
25 # Clippy outputs cargo diagnostic format:
26 # {
27 # "reason": "compiler-message",
28 # "message": {
29 # "code": {"code": "clippy::needless_return"},
30 # "level": "warning",
31 # "message": "unneeded `return` statement",
32 # "spans": [{
33 # "file_name": "src/lib.rs",
34 # "line_start": 42,
35 # "line_end": 42,
36 # "column_start": 5,
37 # "column_end": 15
38 # }]
39 # }
40 # }
41 if item.get("reason") != "compiler-message":
42 return None
44 message = item.get("message", {})
45 if not isinstance(message, dict):
46 return None
48 # Extract code
49 code_obj = message.get("code")
50 code: str | None = None
51 if isinstance(code_obj, dict):
52 code = code_obj.get("code")
53 elif isinstance(code_obj, str):
54 code = code_obj
56 # Only process Clippy lints (skip regular compiler errors)
57 if not code or not code.startswith("clippy::"):
58 return None
60 # Extract message text
61 message_text = str(message.get("message", "")).strip()
62 if not message_text:
63 return None
65 # Extract level
66 level = message.get("level")
67 if level not in ("warning", "error", "note", "help"):
68 return None
70 # Extract spans (location information)
71 spans = message.get("spans", [])
72 if not spans or not isinstance(spans, list):
73 return None
75 # Use the primary span (first one)
76 primary_span = spans[0]
77 if not isinstance(primary_span, dict):
78 return None
80 file_name = primary_span.get("file_name")
81 if not file_name or not isinstance(file_name, str):
82 return None
84 line_start = primary_span.get("line_start")
85 line_end = primary_span.get("line_end")
86 column_start = primary_span.get("column_start")
87 column_end = primary_span.get("column_end")
89 line = int(line_start) if line_start is not None else 0
90 column = int(column_start) if column_start is not None else 0
91 end_line = int(line_end) if line_end is not None else line
92 end_column = int(column_end) if column_end is not None else column
94 return ClippyIssue(
95 file=file_name,
96 line=line,
97 column=column,
98 code=code,
99 message=message_text,
100 level=str(level) if level else None,
101 end_line=end_line if end_line != line else None,
102 end_column=end_column if end_column != column else None,
103 )
104 except (KeyError, TypeError, ValueError) as e:
105 logger.debug(f"Failed to parse clippy diagnostic: {e}")
106 return None
109def parse_clippy_output(output: str) -> list[ClippyIssue]:
110 """Parse Clippy JSON Lines output into ``ClippyIssue`` objects.
112 Args:
113 output: Raw stdout emitted by cargo clippy using ``--message-format=json``.
115 Returns:
116 A list of ``ClippyIssue`` instances parsed from the output. Returns an
117 empty list when no issues are present or the output cannot be decoded.
118 """
119 if not output or not output.strip():
120 return []
122 # Strip ANSI codes for consistent parsing across environments
123 output = strip_ansi_codes(output)
125 issues: list[ClippyIssue] = []
127 # Clippy outputs JSON Lines (one object per line)
128 for line in output.splitlines():
129 line = line.strip()
130 if not line or not line.startswith("{"):
131 continue
132 try:
133 data = json.loads(line)
134 if not isinstance(data, dict):
135 continue
136 parsed = _parse_issue(data)
137 if parsed is not None:
138 issues.append(parsed)
139 except json.JSONDecodeError:
140 continue
142 return issues