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

1"""Parser for Clippy cargo diagnostic JSON output.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from typing import Any 

7 

8from loguru import logger 

9 

10from lintro.parsers.base_parser import strip_ansi_codes 

11from lintro.parsers.clippy.clippy_issue import ClippyIssue 

12 

13 

14def _parse_issue(item: dict[str, Any]) -> ClippyIssue | None: 

15 """Convert a Clippy diagnostic JSON object into a ``ClippyIssue``. 

16 

17 Args: 

18 item: A single diagnostic payload returned by cargo clippy in JSON form. 

19 

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 

43 

44 message = item.get("message", {}) 

45 if not isinstance(message, dict): 

46 return None 

47 

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 

55 

56 # Only process Clippy lints (skip regular compiler errors) 

57 if not code or not code.startswith("clippy::"): 

58 return None 

59 

60 # Extract message text 

61 message_text = str(message.get("message", "")).strip() 

62 if not message_text: 

63 return None 

64 

65 # Extract level 

66 level = message.get("level") 

67 if level not in ("warning", "error", "note", "help"): 

68 return None 

69 

70 # Extract spans (location information) 

71 spans = message.get("spans", []) 

72 if not spans or not isinstance(spans, list): 

73 return None 

74 

75 # Use the primary span (first one) 

76 primary_span = spans[0] 

77 if not isinstance(primary_span, dict): 

78 return None 

79 

80 file_name = primary_span.get("file_name") 

81 if not file_name or not isinstance(file_name, str): 

82 return None 

83 

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") 

88 

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 

93 

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 

107 

108 

109def parse_clippy_output(output: str) -> list[ClippyIssue]: 

110 """Parse Clippy JSON Lines output into ``ClippyIssue`` objects. 

111 

112 Args: 

113 output: Raw stdout emitted by cargo clippy using ``--message-format=json``. 

114 

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 [] 

121 

122 # Strip ANSI codes for consistent parsing across environments 

123 output = strip_ansi_codes(output) 

124 

125 issues: list[ClippyIssue] = [] 

126 

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 

141 

142 return issues