Skip to content

Commit f6ad5f0

Browse files
committed
✨ Add Python script to elevate Rust linting
Introduce scripts/lint.py to enhance Rust code quality with style and consistency. - Implements Rust linting using cargo tools like clippy and fmt - Provides automatic fixes via cargo fix, supporting both safe and unsafe options - Facilitates code quality focus with Git-aware scanning and elegant outputs This script aims to maintain high standards for Rust code by ensuring style consistency and proactive error detection.
1 parent 6283d48 commit f6ad5f0

File tree

1 file changed

+336
-0
lines changed

1 file changed

+336
-0
lines changed

scripts/lint.py

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
RustLint 🦀 - Elevating Rust Code Quality with Style
5+
6+
A smart lint tool that maintains the highest standards
7+
for your Rust codebase.
8+
9+
Features:
10+
- Code formatting (cargo fmt) - Ensuring style consistency
11+
- Linting (cargo clippy) - Proactive error detection
12+
- Automatic fixes (cargo fix) - Streamlined issue resolution
13+
- Git-aware scanning - Focused quality analysis
14+
- Clear, elegant output - Because elegance matters
15+
16+
Usage:
17+
lint.py # Full lint checks (cargo clippy)
18+
lint.py --format # Format code (cargo fmt)
19+
lint.py path/to/project # Lint specific cargo projects
20+
lint.py -g # Process git-modified projects
21+
lint.py --fix # Attempt automatic fixes (cargo fix)
22+
"""
23+
24+
import argparse
25+
import os
26+
import subprocess
27+
import sys
28+
from abc import ABC
29+
from dataclasses import dataclass
30+
31+
# ANSI color codes
32+
GREEN = "\033[92m"
33+
RED = "\033[91m"
34+
BLUE = "\033[94m"
35+
RESET = "\033[0m"
36+
37+
# Default directory to run the commands on (typically the project root)
38+
DEFAULT_CARGO_DIR = "."
39+
40+
41+
# pylint: disable=too-few-public-methods
42+
@dataclass
43+
class ToolResult:
44+
"""Result of running a tool."""
45+
46+
name: str
47+
success: bool
48+
stdout: str
49+
stderr: str
50+
51+
52+
class BaseRustTool(ABC):
53+
"""Base class for Rust tools like format, linter, and fixer."""
54+
55+
def __init__(self, name: str, command_factory):
56+
"""Initialize the BaseRustTool with a name and a command factory function."""
57+
self.name = name
58+
self.command_factory = command_factory
59+
60+
def run(self, directory: str) -> ToolResult:
61+
"""Run the tool command in the given directory.
62+
63+
If the directory is not a valid Cargo project (i.e., Cargo.toml is missing),
64+
this method skips execution and returns a success result indicating the skip.
65+
"""
66+
if not os.path.exists(os.path.join(directory, "Cargo.toml")):
67+
print(
68+
f"{BLUE}Skipping {directory}: "
69+
f"Cargo.toml not found (not a Rust project).{RESET}"
70+
)
71+
return ToolResult(self.name, True, f"Skipped {directory}", "")
72+
command = self.command_factory(directory)
73+
process = subprocess.run(
74+
command,
75+
cwd=directory,
76+
capture_output=True,
77+
text=True,
78+
check=False,
79+
)
80+
return ToolResult(
81+
name=self.name,
82+
success=process.returncode == 0,
83+
stdout=process.stdout,
84+
stderr=process.stderr,
85+
)
86+
87+
88+
class RustToolRunner:
89+
"""Manages and runs Rust tools (clippy, fmt, fix) across cargo projects."""
90+
91+
def __init__(self) -> None:
92+
"""Initialize with the available tools for linting, formatting, and fixing."""
93+
self.linters = {
94+
"clippy": BaseRustTool(
95+
"clippy", lambda d: ["cargo", "clippy", "--", "-D", "warnings"]
96+
),
97+
}
98+
self.formatters = {
99+
"fmt": BaseRustTool("fmt", lambda d: ["cargo", "fmt"]),
100+
}
101+
self.fixers = {
102+
"fix": BaseRustTool("fix", lambda d: ["cargo", "fix"]),
103+
}
104+
self.unsafe_fixers = {
105+
"fix": BaseRustTool(
106+
"fix", lambda d: ["cargo", "fix", "--allow-dirty", "--allow-staged"]
107+
),
108+
}
109+
110+
def get_tools_to_run(
111+
self,
112+
tools: dict,
113+
selected_tools: list[str] | None = None,
114+
_is_git_modified: bool = False,
115+
) -> dict:
116+
"""Filter and return the rust tools to run based on the selected tools list.
117+
118+
If selected_tools is provided, only return tools whose names are in selected_tools.
119+
Otherwise, return all tools.
120+
"""
121+
if selected_tools:
122+
return {
123+
name: tool for name, tool in tools.items() if name in selected_tools
124+
}
125+
return tools.copy()
126+
127+
def handle_result(self, result: ToolResult, directory: str) -> bool:
128+
"""Handle and display the tool's result for a given directory.
129+
130+
Returns True if the tool failed, otherwise False.
131+
"""
132+
if not result.success:
133+
print(f"{RED}{result.name.capitalize()} issues in {directory}:{RESET}")
134+
print(result.stdout)
135+
if result.stderr:
136+
print(result.stderr)
137+
return True
138+
print(
139+
f"{GREEN}{result.name.capitalize()} checks passed in {directory}.{RESET}"
140+
)
141+
return False
142+
143+
def run_on_dirs(self, tools: dict, dirs: list[str]) -> list[str]:
144+
"""Run each tool on the provided directories and return the names of any that fail."""
145+
failures = []
146+
for tool in tools.values():
147+
tool_failed = False
148+
for d in dirs:
149+
result = tool.run(d)
150+
if self.handle_result(result, d):
151+
tool_failed = True
152+
if tool_failed:
153+
failures.append(tool.name)
154+
return failures
155+
156+
# pylint: disable=too-many-arguments,too-many-positional-arguments
157+
def run(
158+
self,
159+
target_dirs: list[str] | None = None,
160+
selected_tools: list[str] | None = None,
161+
is_formatting: bool = False,
162+
is_fixing: bool = False,
163+
is_unsafe_fixing: bool = False,
164+
is_git_modified: bool = False,
165+
) -> None:
166+
"""Run the selected Rust tool(s) on the target directories.
167+
168+
Depending on the provided flags, runs formatters, fixers, or linters.
169+
Exits with code 1 if any tool fails, otherwise exits with code 0.
170+
"""
171+
if is_formatting:
172+
print(f"{BLUE}🎨 Running formatter (cargo fmt)...{RESET}")
173+
tools_to_run = self.get_tools_to_run(
174+
self.formatters, selected_tools, is_git_modified
175+
)
176+
elif is_fixing:
177+
print(f"{BLUE}🔧 Running fixer (cargo fix)...{RESET}")
178+
if is_unsafe_fixing:
179+
tools_to_run = self.get_tools_to_run(
180+
self.unsafe_fixers, selected_tools, is_git_modified
181+
)
182+
else:
183+
tools_to_run = self.get_tools_to_run(
184+
self.fixers, selected_tools, is_git_modified
185+
)
186+
else:
187+
print(f"{BLUE}🔎 Running linter (cargo clippy)...{RESET}")
188+
tools_to_run = self.get_tools_to_run(
189+
self.linters, selected_tools, is_git_modified
190+
)
191+
192+
paths = target_dirs if target_dirs else [DEFAULT_CARGO_DIR]
193+
failures = self.run_on_dirs(tools_to_run, paths)
194+
195+
if failures:
196+
print(
197+
f"\n{RED}💥 The following tools failed: "
198+
f"{', '.join(failures)}{RESET}"
199+
)
200+
sys.exit(1)
201+
202+
action = (
203+
"formatting"
204+
if is_formatting
205+
else "fixes"
206+
if is_fixing
207+
else "linting checks"
208+
)
209+
print(f"\n{GREEN}🎉 All {action} completed successfully!{RESET}")
210+
sys.exit(0)
211+
212+
213+
def find_cargo_manifest_dir(path: str) -> str | None:
214+
"""
215+
Find the closest directory containing Cargo.toml starting from the file's directory.
216+
"""
217+
curr_dir = os.path.abspath(os.path.dirname(path))
218+
while True:
219+
if os.path.exists(os.path.join(curr_dir, "Cargo.toml")):
220+
return curr_dir
221+
parent = os.path.abspath(os.path.join(curr_dir, os.pardir))
222+
if curr_dir == parent:
223+
break
224+
curr_dir = parent
225+
return None
226+
227+
228+
def get_modified_files() -> list[str]:
229+
"""Get list of modified files (both staged and unstaged) from git."""
230+
staged = subprocess.run(
231+
["git", "diff", "--cached", "--name-only"],
232+
capture_output=True,
233+
text=True,
234+
check=True,
235+
).stdout.splitlines()
236+
237+
unstaged = subprocess.run(
238+
["git", "diff", "--name-only"],
239+
capture_output=True,
240+
text=True,
241+
check=False,
242+
).stdout.splitlines()
243+
244+
# Combine and remove duplicates while preserving order
245+
modified = list(dict.fromkeys(staged + unstaged))
246+
modified = [f for f in modified if os.path.exists(f)]
247+
if not modified:
248+
print(f"{BLUE}No modified files found.{RESET}")
249+
sys.exit(0)
250+
return modified
251+
252+
253+
def get_modified_dirs() -> list[str]:
254+
"""
255+
Determine cargo project directories from git modified files by locating the Cargo.toml.
256+
"""
257+
modified_files = get_modified_files()
258+
dirs = []
259+
for file in modified_files:
260+
manifest_dir = find_cargo_manifest_dir(file)
261+
if manifest_dir and manifest_dir not in dirs:
262+
dirs.append(manifest_dir)
263+
if not dirs:
264+
print(f"{BLUE}No modified cargo projects found.{RESET}")
265+
sys.exit(0)
266+
return dirs
267+
268+
269+
def main() -> None:
270+
"""Main entry point for the Rust lint command."""
271+
runner = RustToolRunner()
272+
273+
parser = argparse.ArgumentParser(
274+
description="Run Rust linting and formatting checks in cargo projects."
275+
)
276+
parser.add_argument(
277+
"paths",
278+
nargs="*",
279+
help="Paths to cargo projects (directories with Cargo.toml).",
280+
)
281+
parser.add_argument(
282+
"--linters",
283+
nargs="+",
284+
choices=list(runner.linters.keys()),
285+
help="Specific linters to run. If not provided, default is 'clippy'.",
286+
)
287+
parser.add_argument(
288+
"--format",
289+
action="store_true",
290+
help="Run formatters (cargo fmt).",
291+
)
292+
parser.add_argument(
293+
"--formatters",
294+
nargs="+",
295+
choices=list(runner.formatters.keys()),
296+
help="Specific formatters to run. If not provided, all formatters will be run.",
297+
)
298+
parser.add_argument(
299+
"--fix",
300+
action="store_true",
301+
help="Run cargo fix to automatically fix issues.",
302+
)
303+
parser.add_argument(
304+
"--unsafe-fixes",
305+
action="store_true",
306+
help="Run cargo fix with --allow-dirty and --allow-staged.",
307+
)
308+
parser.add_argument(
309+
"--git-modified",
310+
"-g",
311+
action="store_true",
312+
help="Run on git modified files (determine cargo project roots from modified files).",
313+
)
314+
args = parser.parse_args()
315+
316+
if args.git_modified:
317+
target_paths = get_modified_dirs()
318+
elif args.paths:
319+
target_paths = args.paths
320+
else:
321+
target_paths = [DEFAULT_CARGO_DIR]
322+
323+
# Extract selected_tools to split the long parameter line
324+
selected_tools = args.formatters if args.format else args.linters
325+
runner.run(
326+
target_dirs=target_paths,
327+
selected_tools=selected_tools,
328+
is_formatting=args.format,
329+
is_fixing=args.fix,
330+
is_unsafe_fixing=args.unsafe_fixes,
331+
is_git_modified=args.git_modified,
332+
)
333+
334+
335+
if __name__ == "__main__":
336+
main()

0 commit comments

Comments
 (0)