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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Clippy tool definition.
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"""
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.
12from __future__ import annotations
14import os
15import subprocess # nosec B404 - used safely with shell disabled
16from dataclasses import dataclass
17from pathlib import Path
18from typing import Any
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)
38# Constants for Clippy configuration
39CLIPPY_DEFAULT_TIMEOUT: int = 120
40CLIPPY_DEFAULT_PRIORITY: int = 85
41CLIPPY_FILE_PATTERNS: list[str] = ["*.rs", "Cargo.toml"]
44def _find_cargo_root(paths: list[str]) -> Path | None:
45 """Return the nearest directory containing Cargo.toml for given paths.
47 Args:
48 paths: List of file paths to search from.
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
66 if not roots:
67 return None
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]
74 try:
75 common = Path(os.path.commonpath([str(r) for r in unique_roots]))
76 except ValueError:
77 return None
79 manifest = common / "Cargo.toml"
80 return common if manifest.exists() else None
83def _build_clippy_command(fix: bool = False) -> list[str]:
84 """Build the cargo clippy command.
86 Args:
87 fix: Whether to include --fix flag.
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
104@register_tool
105@dataclass
106class ClippyPlugin(BaseToolPlugin):
107 """Clippy Rust linter plugin.
109 This plugin integrates Rust's Clippy linter with Lintro for checking
110 Rust code for correctness, style, and performance issues.
111 """
113 @property
114 def definition(self) -> ToolDefinition:
115 """Return the tool definition.
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 )
137 def _verify_tool_version(self) -> ToolResult | None:
138 """Verify that Rust toolchain meets minimum version requirements.
140 Clippy version is tied to Rust version, so we check rustc version instead.
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
148 # Check Rust version instead of clippy version
149 version_info = check_tool_version("clippy", ["rustc"])
151 if version_info.version_check_passed:
152 return None # Version check passed
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 )
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 )
168 def set_options(
169 self,
170 timeout: int | None = None,
171 **kwargs: Any,
172 ) -> None:
173 """Set Clippy-specific options.
175 Args:
176 timeout: Timeout in seconds (default: 120).
177 **kwargs: Additional options.
178 """
179 validate_positive_int(timeout, "timeout")
181 options = filter_none_options(timeout=timeout)
182 super().set_options(**options, **kwargs)
184 def doc_url(self, code: str) -> str | None:
185 """Return Clippy documentation URL for the given lint name.
187 Args:
188 code: Clippy lint name (e.g., "needless_return").
190 Returns:
191 URL to the Clippy lint documentation.
192 """
193 if code:
194 return DocUrlTemplate.CLIPPY.format(code=code)
195 return None
197 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
198 """Run `cargo clippy` and parse linting issues.
200 Args:
201 paths: List of file or directory paths to check.
202 options: Runtime options that override defaults.
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]
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 )
225 cmd = _build_clippy_command(fix=False)
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 )
250 issues = parse_clippy_output(output=output)
251 issues_count = len(issues)
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
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 )
265 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
266 """Run `cargo clippy --fix` then re-check for remaining issues.
268 Args:
269 paths: List of file or directory paths to fix.
270 options: Runtime options that override defaults.
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]
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 )
296 check_cmd = _build_clippy_command(fix=False)
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 )
325 initial_issues = parse_clippy_output(output=output_check)
326 initial_count = len(initial_issues)
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 )
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 )
383 remaining_issues = parse_clippy_output(output=output_after)
384 remaining_count = len(remaining_issues)
385 fixed_count = max(0, initial_count - remaining_count)
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
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 )