1use std::{path::PathBuf, process, sync::Mutex};
2
3use clap::{Parser, Subcommand};
4use ignore::WalkBuilder;
5use rayon::prelude::*;
6
7use crate::{Config, LintEngine, output, violation::Violation};
8
9#[derive(Parser)]
10#[command(name = "nu-lint")]
11#[command(about = "A linter for Nushell scripts", long_about = None)]
12#[command(version)]
13pub struct Cli {
14 #[command(subcommand)]
15 pub command: Option<Commands>,
16
17 #[arg(help = "Files or directories to lint")]
18 pub paths: Vec<PathBuf>,
19
20 #[arg(short, long, help = "Configuration file path")]
21 pub config: Option<PathBuf>,
22
23 #[arg(
24 short = 'f',
25 long = "format",
26 alias = "output",
27 short_alias = 'o',
28 help = "Output format",
29 value_enum,
30 default_value = "text"
31 )]
32 pub format: Option<Format>,
33
34 #[arg(long, help = "Apply auto-fixes")]
35 pub fix: bool,
36
37 #[arg(long, help = "Show what would be fixed without applying")]
38 pub dry_run: bool,
39
40 #[arg(
41 long,
42 help = "Process files in parallel (experimental)",
43 default_value = "false"
44 )]
45 pub parallel: bool,
46}
47
48#[derive(Subcommand)]
49pub enum Commands {
50 #[command(about = "List all available rules")]
51 ListRules,
52
53 #[command(about = "Explain a specific rule")]
54 Explain {
55 #[arg(help = "Rule ID to explain")]
56 rule_id: String,
57 },
58}
59
60#[derive(clap::ValueEnum, Clone, Copy)]
61pub enum Format {
62 Text,
63 Json,
64 Github,
65}
66
67pub fn handle_command(command: Commands, config: &Config) {
69 match command {
70 Commands::ListRules => list_rules(config),
71 Commands::Explain { rule_id } => explain_rule(config, &rule_id),
72 }
73}
74
75#[must_use]
78pub fn collect_files_to_lint(paths: &[PathBuf]) -> Vec<PathBuf> {
79 let mut files_to_lint = Vec::new();
80 let mut has_errors = false;
81
82 for path in paths {
83 if !path.exists() {
84 eprintln!("Error: Path not found: {}", path.display());
85 has_errors = true;
86 continue;
87 }
88
89 if path.is_file() {
90 if path.extension().and_then(|s| s.to_str()) == Some("nu") {
93 files_to_lint.push(path.clone());
94 }
95 } else if path.is_dir() {
96 let files = collect_nu_files_with_gitignore(path);
97 if files.is_empty() {
98 eprintln!("Warning: No .nu files found in {}", path.display());
99 }
100 files_to_lint.extend(files);
101 }
102 }
103
104 if files_to_lint.is_empty() {
105 if !has_errors {
106 eprintln!("Error: No files to lint");
107 }
108 process::exit(2);
109 }
110
111 files_to_lint
112}
113
114#[must_use]
116pub fn collect_nu_files_with_gitignore(dir: &PathBuf) -> Vec<PathBuf> {
117 let mut nu_files = Vec::new();
118
119 let walker = WalkBuilder::new(dir)
120 .standard_filters(true) .build();
122
123 for result in walker {
124 match result {
125 Ok(entry) => {
126 let path = entry.path();
127 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("nu") {
128 nu_files.push(path.to_path_buf());
129 }
130 }
131 Err(err) => {
132 eprintln!("Warning: Error walking directory: {err}");
133 }
134 }
135 }
136
137 nu_files
138}
139
140#[must_use]
142pub fn lint_files(
143 engine: &LintEngine,
144 files: &[PathBuf],
145 parallel: bool,
146) -> (Vec<Violation>, bool) {
147 if parallel && files.len() > 1 {
148 lint_files_parallel(engine, files)
149 } else {
150 lint_files_sequential(engine, files)
151 }
152}
153
154fn lint_files_parallel(engine: &LintEngine, files: &[PathBuf]) -> (Vec<Violation>, bool) {
156 let violations_mutex = Mutex::new(Vec::new());
157 let errors_mutex = Mutex::new(false);
158
159 files
160 .par_iter()
161 .for_each(|path| match engine.lint_file(path) {
162 Ok(violations) => {
163 let mut all_viols = violations_mutex.lock().unwrap();
164 all_viols.extend(violations);
165 }
166 Err(e) => {
167 eprintln!("Error linting {}: {}", path.display(), e);
168 let mut has_errs = errors_mutex.lock().unwrap();
169 *has_errs = true;
170 }
171 });
172
173 let violations = violations_mutex.into_inner().unwrap();
174 let has_errors = errors_mutex.into_inner().unwrap();
175 (violations, has_errors)
176}
177
178fn lint_files_sequential(engine: &LintEngine, files: &[PathBuf]) -> (Vec<Violation>, bool) {
180 let mut all_violations = Vec::new();
181 let mut has_errors = false;
182
183 for path in files {
184 match engine.lint_file(path) {
185 Ok(violations) => {
186 all_violations.extend(violations);
187 }
188 Err(e) => {
189 eprintln!("Error linting {}: {}", path.display(), e);
190 has_errors = true;
191 }
192 }
193 }
194
195 (all_violations, has_errors)
196}
197
198pub fn output_results(violations: &[Violation], _files: &[PathBuf], format: Option<Format>) {
200 let output = match format.unwrap_or(Format::Text) {
201 Format::Text | Format::Github => output::format_text(violations),
202 Format::Json => output::format_json(violations),
203 };
204 println!("{output}");
205}
206
207fn list_rules(config: &Config) {
208 let engine = LintEngine::new(config.clone());
209 println!("Available rules:\n");
210
211 for rule in engine.registry().all_rules() {
212 println!(
213 "{:<8} [{:<12}] {} - {}",
214 rule.id, rule.category, rule.severity, rule.description
215 );
216 }
217}
218
219fn explain_rule(config: &Config, rule_id: &str) {
220 let engine = LintEngine::new(config.clone());
221
222 if let Some(rule) = engine.registry().get_rule(rule_id) {
223 println!("Rule: {}", rule.id);
224 println!("Category: {}", rule.category);
225 println!("Severity: {}", rule.severity);
226 println!("Description: {}", rule.description);
227 } else {
228 eprintln!("Error: Rule '{rule_id}' not found");
229 process::exit(2);
230 }
231}