Coverage for lintro / tools / definitions / clippy.py: 31%

116 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Clippy tool definition. 

2 

3Clippy is Rust's official linter with hundreds of lint rules for correctness, 

4style, complexity, and performance. It runs via `cargo clippy` and requires 

5a Cargo.toml file in the project. 

6""" 

7 

8# mypy: ignore-errors 

9# Note: mypy errors are suppressed because lintro runs mypy from file's directory, 

10# breaking package resolution. When run properly (mypy lintro/...), this file passes. 

11 

12from __future__ import annotations 

13 

14import os 

15import subprocess # nosec B404 - used safely with shell disabled 

16from dataclasses import dataclass 

17from pathlib import Path 

18from typing import Any 

19 

20from lintro._tool_versions import get_min_version 

21from lintro.enums.doc_url_template import DocUrlTemplate 

22from lintro.enums.tool_name import ToolName 

23from lintro.enums.tool_type import ToolType 

24from lintro.models.core.tool_result import ToolResult 

25from lintro.parsers.clippy.clippy_parser import parse_clippy_output 

26from lintro.plugins.base import BaseToolPlugin 

27from lintro.plugins.protocol import ToolDefinition 

28from lintro.plugins.registry import register_tool 

29from lintro.tools.core.option_validators import ( 

30 filter_none_options, 

31 validate_positive_int, 

32) 

33from lintro.tools.core.timeout_utils import ( 

34 create_timeout_result, 

35 run_subprocess_with_timeout, 

36) 

37 

38# Constants for Clippy configuration 

39CLIPPY_DEFAULT_TIMEOUT: int = 120 

40CLIPPY_DEFAULT_PRIORITY: int = 85 

41CLIPPY_FILE_PATTERNS: list[str] = ["*.rs", "Cargo.toml"] 

42 

43 

44def _find_cargo_root(paths: list[str]) -> Path | None: 

45 """Return the nearest directory containing Cargo.toml for given paths. 

46 

47 Args: 

48 paths: List of file paths to search from. 

49 

50 Returns: 

51 Path to Cargo.toml directory, or None if not found. 

52 """ 

53 roots: list[Path] = [] 

54 for raw_path in paths: 

55 current = Path(raw_path).resolve() 

56 # If it's a file, start from its parent 

57 if current.is_file(): 

58 current = current.parent 

59 # Search upward for Cargo.toml 

60 for candidate in [current] + list(current.parents): 

61 manifest = candidate / "Cargo.toml" 

62 if manifest.exists(): 

63 roots.append(candidate) 

64 break 

65 

66 if not roots: 

67 return None 

68 

69 # Prefer a single root; if multiple, use common path when valid 

70 unique_roots = set(roots) 

71 if len(unique_roots) == 1: 

72 return roots[0] 

73 

74 try: 

75 common = Path(os.path.commonpath([str(r) for r in unique_roots])) 

76 except ValueError: 

77 return None 

78 

79 manifest = common / "Cargo.toml" 

80 return common if manifest.exists() else None 

81 

82 

83def _build_clippy_command(fix: bool = False) -> list[str]: 

84 """Build the cargo clippy command. 

85 

86 Args: 

87 fix: Whether to include --fix flag. 

88 

89 Returns: 

90 List of command arguments. 

91 """ 

92 cmd = [ 

93 "cargo", 

94 "clippy", 

95 "--all-targets", 

96 "--all-features", 

97 "--message-format=json", 

98 ] 

99 if fix: 

100 cmd.extend(["--fix", "--allow-dirty", "--allow-staged"]) 

101 return cmd 

102 

103 

104@register_tool 

105@dataclass 

106class ClippyPlugin(BaseToolPlugin): 

107 """Clippy Rust linter plugin. 

108 

109 This plugin integrates Rust's Clippy linter with Lintro for checking 

110 Rust code for correctness, style, and performance issues. 

111 """ 

112 

113 @property 

114 def definition(self) -> ToolDefinition: 

115 """Return the tool definition. 

116 

117 Returns: 

118 ToolDefinition containing tool metadata. 

119 """ 

120 return ToolDefinition( 

121 name="clippy", 

122 description=("Rust linter for correctness, style, and performance"), 

123 can_fix=True, 

124 tool_type=ToolType.LINTER, 

125 file_patterns=CLIPPY_FILE_PATTERNS, 

126 priority=CLIPPY_DEFAULT_PRIORITY, 

127 conflicts_with=[], 

128 native_configs=["clippy.toml", ".clippy.toml"], 

129 version_command=["rustc", "--version"], 

130 min_version=get_min_version(ToolName.CLIPPY), 

131 default_options={ 

132 "timeout": CLIPPY_DEFAULT_TIMEOUT, 

133 }, 

134 default_timeout=CLIPPY_DEFAULT_TIMEOUT, 

135 ) 

136 

137 def _verify_tool_version(self) -> ToolResult | None: 

138 """Verify that Rust toolchain meets minimum version requirements. 

139 

140 Clippy version is tied to Rust version, so we check rustc version instead. 

141 

142 Returns: 

143 Optional[ToolResult]: None if version check passes, or a skip result 

144 if it fails. 

145 """ 

146 from lintro.tools.core.version_requirements import check_tool_version 

147 

148 # Check Rust version instead of clippy version 

149 version_info = check_tool_version("clippy", ["rustc"]) 

150 

151 if version_info.version_check_passed: 

152 return None # Version check passed 

153 

154 # Version check failed - return skip result with warning 

155 skip_message = ( 

156 f"Skipping {self.definition.name}: {version_info.error_message}. " 

157 f"Minimum required: {version_info.min_version}. " 

158 f"{version_info.install_hint}" 

159 ) 

160 

161 return ToolResult( 

162 name=self.definition.name, 

163 success=True, # Not an error, just skipping 

164 output=skip_message, 

165 issues_count=0, 

166 ) 

167 

168 def set_options( 

169 self, 

170 timeout: int | None = None, 

171 **kwargs: Any, 

172 ) -> None: 

173 """Set Clippy-specific options. 

174 

175 Args: 

176 timeout: Timeout in seconds (default: 120). 

177 **kwargs: Additional options. 

178 """ 

179 validate_positive_int(timeout, "timeout") 

180 

181 options = filter_none_options(timeout=timeout) 

182 super().set_options(**options, **kwargs) 

183 

184 def doc_url(self, code: str) -> str | None: 

185 """Return Clippy documentation URL for the given lint name. 

186 

187 Args: 

188 code: Clippy lint name (e.g., "needless_return"). 

189 

190 Returns: 

191 URL to the Clippy lint documentation. 

192 """ 

193 if code: 

194 return DocUrlTemplate.CLIPPY.format(code=code) 

195 return None 

196 

197 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

198 """Run `cargo clippy` and parse linting issues. 

199 

200 Args: 

201 paths: List of file or directory paths to check. 

202 options: Runtime options that override defaults. 

203 

204 Returns: 

205 ToolResult with check results. 

206 """ 

207 # Use shared preparation for version check, path validation, file discovery 

208 ctx = self._prepare_execution( 

209 paths, 

210 options, 

211 no_files_message="No Rust files found to check.", 

212 ) 

213 if ctx.should_skip: 

214 return ctx.early_result # type: ignore[return-value] 

215 

216 cargo_root = _find_cargo_root(ctx.files) 

217 if cargo_root is None: 

218 return ToolResult( 

219 name=self.definition.name, 

220 success=True, 

221 output="No Cargo.toml found; skipping clippy.", 

222 issues_count=0, 

223 ) 

224 

225 cmd = _build_clippy_command(fix=False) 

226 

227 try: 

228 success_cmd, output = run_subprocess_with_timeout( 

229 tool=self, 

230 cmd=cmd, 

231 timeout=ctx.timeout, 

232 cwd=str(cargo_root), 

233 tool_name="clippy", 

234 ) 

235 except subprocess.TimeoutExpired: 

236 timeout_result = create_timeout_result( 

237 tool=self, 

238 timeout=ctx.timeout, 

239 cmd=cmd, 

240 tool_name="clippy", 

241 ) 

242 return ToolResult( 

243 name=self.definition.name, 

244 success=timeout_result.success, 

245 output=timeout_result.output, 

246 issues_count=timeout_result.issues_count, 

247 issues=timeout_result.issues, 

248 ) 

249 

250 issues = parse_clippy_output(output=output) 

251 issues_count = len(issues) 

252 

253 # Preserve output when command fails with no parsed issues for debugging 

254 # When issues exist, they'll be displayed instead 

255 should_show_output = not success_cmd and issues_count == 0 

256 

257 return ToolResult( 

258 name=self.definition.name, 

259 success=bool(success_cmd), 

260 output=output if should_show_output else None, 

261 issues_count=issues_count, 

262 issues=issues, 

263 ) 

264 

265 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

266 """Run `cargo clippy --fix` then re-check for remaining issues. 

267 

268 Args: 

269 paths: List of file or directory paths to fix. 

270 options: Runtime options that override defaults. 

271 

272 Returns: 

273 ToolResult with fix results. 

274 """ 

275 # Use shared preparation for version check, path validation, file discovery 

276 ctx = self._prepare_execution( 

277 paths, 

278 options, 

279 no_files_message="No Rust files found to fix.", 

280 ) 

281 if ctx.should_skip: 

282 return ctx.early_result # type: ignore[return-value] 

283 

284 cargo_root = _find_cargo_root(ctx.files) 

285 if cargo_root is None: 

286 return ToolResult( 

287 name=self.definition.name, 

288 success=True, 

289 output="No Cargo.toml found; skipping clippy.", 

290 issues_count=0, 

291 initial_issues_count=0, 

292 fixed_issues_count=0, 

293 remaining_issues_count=0, 

294 ) 

295 

296 check_cmd = _build_clippy_command(fix=False) 

297 

298 # First, count issues before fixing 

299 try: 

300 success_check, output_check = run_subprocess_with_timeout( 

301 tool=self, 

302 cmd=check_cmd, 

303 timeout=ctx.timeout, 

304 cwd=str(cargo_root), 

305 tool_name="clippy", 

306 ) 

307 except subprocess.TimeoutExpired: 

308 timeout_result = create_timeout_result( 

309 tool=self, 

310 timeout=ctx.timeout, 

311 cmd=check_cmd, 

312 tool_name="clippy", 

313 ) 

314 return ToolResult( 

315 name=self.definition.name, 

316 success=timeout_result.success, 

317 output=timeout_result.output, 

318 issues_count=timeout_result.issues_count, 

319 issues=timeout_result.issues, 

320 initial_issues_count=0, 

321 fixed_issues_count=0, 

322 remaining_issues_count=1, 

323 ) 

324 

325 initial_issues = parse_clippy_output(output=output_check) 

326 initial_count = len(initial_issues) 

327 

328 # Run fix 

329 fix_cmd = _build_clippy_command(fix=True) 

330 try: 

331 success_fix, output_fix = run_subprocess_with_timeout( 

332 tool=self, 

333 cmd=fix_cmd, 

334 timeout=ctx.timeout, 

335 cwd=str(cargo_root), 

336 tool_name="clippy", 

337 ) 

338 except subprocess.TimeoutExpired: 

339 timeout_result = create_timeout_result( 

340 tool=self, 

341 timeout=ctx.timeout, 

342 cmd=fix_cmd, 

343 tool_name="clippy", 

344 ) 

345 return ToolResult( 

346 name=self.definition.name, 

347 success=timeout_result.success, 

348 output=timeout_result.output, 

349 issues_count=timeout_result.issues_count, 

350 issues=initial_issues, 

351 initial_issues_count=initial_count, 

352 fixed_issues_count=0, 

353 remaining_issues_count=1, 

354 ) 

355 

356 # Re-check after fix to count remaining issues 

357 try: 

358 success_after, output_after = run_subprocess_with_timeout( 

359 tool=self, 

360 cmd=check_cmd, 

361 timeout=ctx.timeout, 

362 cwd=str(cargo_root), 

363 tool_name="clippy", 

364 ) 

365 except subprocess.TimeoutExpired: 

366 timeout_result = create_timeout_result( 

367 tool=self, 

368 timeout=ctx.timeout, 

369 cmd=check_cmd, 

370 tool_name="clippy", 

371 ) 

372 return ToolResult( 

373 name=self.definition.name, 

374 success=timeout_result.success, 

375 output=timeout_result.output, 

376 issues_count=timeout_result.issues_count, 

377 issues=initial_issues, 

378 initial_issues_count=initial_count, 

379 fixed_issues_count=0, 

380 remaining_issues_count=1, 

381 ) 

382 

383 remaining_issues = parse_clippy_output(output=output_after) 

384 remaining_count = len(remaining_issues) 

385 fixed_count = max(0, initial_count - remaining_count) 

386 

387 # Preserve output when command fails but no issues were parsed 

388 # This allows users to see error messages like compilation failures 

389 should_show_output = not success_after and remaining_count == 0 

390 

391 return ToolResult( 

392 name=self.definition.name, 

393 success=remaining_count == 0, 

394 output=output_after if should_show_output else None, 

395 issues_count=remaining_count, 

396 issues=remaining_issues, 

397 initial_issues_count=initial_count, 

398 fixed_issues_count=fixed_count, 

399 remaining_issues_count=remaining_count, 

400 )