1use std::{
2 fs,
3 io::{self, BufRead},
4 path::PathBuf,
5 process,
6 sync::Mutex,
7};
8
9use clap::{Parser, Subcommand};
10use ignore::WalkBuilder;
11use rayon::prelude::*;
12
13use crate::{
14 Config, LintEngine, LintLevel, output,
15 rules::ALL_RULES,
16 sets::{BUILTIN_LINT_SETS, DEFAULT_RULE_MAP},
17 violation::Violation,
18};
19
20#[derive(Parser)]
21#[command(name = "nu-lint")]
22#[command(about = "A linter for Nushell scripts", long_about = None)]
23#[command(version)]
24pub struct Cli {
25 #[command(subcommand)]
26 pub command: Option<Commands>,
27
28 #[arg(help = "Files or directories to lint")]
29 pub paths: Vec<PathBuf>,
30
31 #[arg(short, long, help = "Configuration file path")]
32 pub config: Option<PathBuf>,
33
34 #[arg(
35 short = 'f',
36 long = "format",
37 alias = "output",
38 short_alias = 'o',
39 help = "Output format",
40 value_enum,
41 default_value = "text"
42 )]
43 pub format: Option<Format>,
44
45 #[arg(long, help = "Apply auto-fixes")]
46 pub fix: bool,
47
48 #[arg(long, help = "Show what would be fixed without applying")]
49 pub dry_run: bool,
50
51 #[arg(
52 long,
53 help = "Process files in parallel (experimental)",
54 default_value = "false"
55 )]
56 pub parallel: bool,
57}
58
59#[derive(Subcommand)]
60pub enum Commands {
61 #[command(about = "List all available rules")]
62 ListRules,
63
64 #[command(about = "List all available lint sets")]
65 ListSets,
66
67 #[command(about = "Explain a specific rule")]
68 Explain {
69 #[arg(help = "Rule ID to explain")]
70 rule_id: String,
71 },
72}
73
74#[derive(clap::ValueEnum, Clone, Copy)]
75pub enum Format {
76 Text,
77 Json,
78 VscodeJson,
80 Github,
81}
82
83pub fn handle_command(command: Commands, config: &Config) {
85 match command {
86 Commands::ListRules => list_rules(config),
87 Commands::ListSets => list_sets(),
88 Commands::Explain { rule_id } => explain_rule(config, &rule_id),
89 }
90}
91
92fn is_nushell_file(path: &PathBuf) -> bool {
93 path.extension()
94 .and_then(|s| s.to_str())
95 .is_some_and(|ext| ext == "nu")
96 || fs::File::open(path)
97 .ok()
98 .and_then(|file| {
99 let mut reader = io::BufReader::new(file);
100 let mut first_line = String::new();
101 reader.read_line(&mut first_line).ok()?;
102 first_line.starts_with("#!").then(|| {
103 first_line
104 .split_whitespace()
105 .any(|word| word.ends_with("/nu") || word == "nu")
106 })
107 })
108 .unwrap_or(false)
109}
110
111#[must_use]
114pub fn collect_files_to_lint(paths: &[PathBuf]) -> Vec<PathBuf> {
115 let (files, errors): (Vec<_>, Vec<_>) = paths
116 .iter()
117 .map(|path| {
118 if !path.exists() {
119 return Err(format!("Error: Path not found: {}", path.display()));
120 }
121
122 if path.is_file() {
123 Ok(if is_nushell_file(path) {
124 vec![path.clone()]
125 } else {
126 vec![]
127 })
128 } else if path.is_dir() {
129 let files = collect_nu_files_with_gitignore(path);
130 if files.is_empty() {
131 eprintln!("Warning: No .nu files found in {}", path.display());
132 }
133 Ok(files)
134 } else {
135 Ok(vec![])
136 }
137 })
138 .partition(Result::is_ok);
139
140 for err in &errors {
141 if let Err(msg) = err {
142 eprintln!("{msg}");
143 }
144 }
145
146 let files_to_lint: Vec<PathBuf> = files.into_iter().filter_map(Result::ok).flatten().collect();
147
148 if files_to_lint.is_empty() {
149 eprintln!("Error: No files to lint");
150 process::exit(2);
151 }
152
153 files_to_lint
154}
155
156#[must_use]
158pub fn collect_nu_files_with_gitignore(dir: &PathBuf) -> Vec<PathBuf> {
159 WalkBuilder::new(dir)
160 .standard_filters(true)
161 .build()
162 .filter_map(|result| match result {
163 Ok(entry) => {
164 let path = entry.path().to_path_buf();
165 (path.is_file() && is_nushell_file(&path)).then_some(path)
166 }
167 Err(err) => {
168 eprintln!("Warning: Error walking directory: {err}");
169 None
170 }
171 })
172 .collect()
173}
174
175#[must_use]
177pub fn lint_files(
178 engine: &LintEngine,
179 files: &[PathBuf],
180 parallel: bool,
181) -> (Vec<Violation>, bool) {
182 if parallel && files.len() > 1 {
183 lint_files_parallel(engine, files)
184 } else {
185 lint_files_sequential(engine, files)
186 }
187}
188
189fn lint_files_parallel(engine: &LintEngine, files: &[PathBuf]) -> (Vec<Violation>, bool) {
191 let violations_mutex = Mutex::new(Vec::new());
192 let errors_mutex = Mutex::new(false);
193
194 files
195 .par_iter()
196 .for_each(|path| match engine.lint_file(path) {
197 Ok(violations) => {
198 violations_mutex
199 .lock()
200 .expect("Failed to lock violations mutex")
201 .extend(violations);
202 }
203 Err(e) => {
204 eprintln!("Error linting {}: {}", path.display(), e);
205 *errors_mutex.lock().expect("Failed to lock errors mutex") = true;
206 }
207 });
208
209 let violations = violations_mutex
210 .into_inner()
211 .expect("Failed to unwrap violations mutex");
212 let has_errors = errors_mutex
213 .into_inner()
214 .expect("Failed to unwrap errors mutex");
215 (violations, has_errors)
216}
217
218fn lint_files_sequential(engine: &LintEngine, files: &[PathBuf]) -> (Vec<Violation>, bool) {
220 let mut all_violations = Vec::new();
221 let mut has_errors = false;
222
223 for path in files {
224 match engine.lint_file(path) {
225 Ok(violations) => {
226 all_violations.extend(violations);
227 }
228 Err(e) => {
229 eprintln!("Error linting {}: {}", path.display(), e);
230 has_errors = true;
231 }
232 }
233 }
234
235 (all_violations, has_errors)
236}
237
238pub fn output_results(violations: &[Violation], format: Option<Format>) {
240 let output = match format.unwrap_or(Format::Text) {
241 Format::Text | Format::Github => output::format_text(violations),
242 Format::Json => output::format_json(violations),
243 Format::VscodeJson => output::format_vscode_json(violations),
244 };
245 println!("{output}");
246}
247
248fn list_rules(config: &Config) {
249 println!("Available rules:\n");
250
251 for rule in ALL_RULES {
252 let lint_level = config.get_lint_level(rule.id);
253 println!("{:<40} [{:?}] {}", rule.id, lint_level, rule.explanation);
254 }
255}
256
257fn list_sets() {
258 println!("Available lint sets:\n");
259
260 let mut sorted_sets: Vec<_> = BUILTIN_LINT_SETS.iter().collect();
261 sorted_sets.sort_by_key(|(name, _)| *name);
262
263 for (name, set) in sorted_sets {
264 println!(
265 "{:<20} {} ({} rules)",
266 name,
267 set.explanation,
268 set.rules.len()
269 );
270 }
271}
272
273fn explain_rule(config: &Config, rule_id: &str) {
274 if let Some(rule) = ALL_RULES.iter().find(|r| r.id == rule_id) {
275 let lint_level = config.get_lint_level(rule.id);
276 let default_level = DEFAULT_RULE_MAP
277 .rules
278 .get(rule.id)
279 .copied()
280 .unwrap_or(LintLevel::Warn);
281 println!("Rule: {}", rule.id);
282 println!("Lint Level: {lint_level:?}");
283 println!("Default Lint Level: {default_level}");
284 println!("Description: {}", rule.explanation);
285 } else {
286 eprintln!("Error: Rule '{rule_id}' not found");
287 process::exit(2);
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use std::{
294 env::{current_dir, set_current_dir},
295 sync::Mutex,
296 };
297
298 use tempfile::TempDir;
299
300 use super::*;
301 use crate::config::LintLevel;
302
303 static CHDIR_MUTEX: Mutex<()> = Mutex::new(());
304
305 #[test]
306 fn test_no_config_file() {
307 let temp_dir = TempDir::new().unwrap();
308 let nu_file_path = temp_dir.path().join("test.nu");
309
310 fs::write(&nu_file_path, "let myVariable = 5\n").unwrap();
311
312 let config = Config::default();
313 assert_eq!(
314 config.lints.rules.get("snake_case_variables"),
315 Some(&LintLevel::Warn)
316 );
317
318 let engine = LintEngine::new(config);
319 let files = collect_files_to_lint(&[nu_file_path]);
320 let (violations, _) = lint_files(&engine, &files, false);
321
322 assert!(
323 violations
324 .iter()
325 .any(|v| v.rule_id == "snake_case_variables" && v.lint_level == LintLevel::Warn)
326 );
327 }
328
329 #[test]
330 fn test_custom_config_file() {
331 let temp_dir = TempDir::new().unwrap();
332 let config_path = temp_dir.path().join("custom.toml");
333 let nu_file_path = temp_dir.path().join("test.nu");
334
335 fs::write(
336 &config_path,
337 "[lints]\n\n[lints.rules]\nsnake_case_variables = \"deny\"\n",
338 )
339 .unwrap();
340 fs::write(&nu_file_path, "let myVariable = 5\n").unwrap();
341
342 let config = Config::load(Some(&config_path));
343 assert_eq!(
344 config.lints.rules.get("snake_case_variables"),
345 Some(&LintLevel::Deny)
346 );
347
348 let engine = LintEngine::new(config);
349 let files = collect_files_to_lint(&[nu_file_path]);
350 let (violations, _) = lint_files(&engine, &files, false);
351
352 assert!(!violations.is_empty());
353 }
354
355 #[test]
356 fn test_auto_discover_config_file() {
357 let _guard = CHDIR_MUTEX.lock().unwrap();
358
359 let temp_dir = TempDir::new().unwrap();
360 let config_path = temp_dir.path().join(".nu-lint.toml");
361 let nu_file_path = temp_dir.path().join("test.nu");
362
363 fs::write(
364 &config_path,
365 r#"[lints.rules]
366 snake_case_variables = "deny""#,
367 )
368 .unwrap();
369 fs::write(&nu_file_path, "let myVariable = 5\n").unwrap();
370
371 let original_dir = current_dir().unwrap();
372
373 set_current_dir(temp_dir.path()).unwrap();
374
375 let config = Config::load(None);
376 let engine = LintEngine::new(config);
377 let files = collect_files_to_lint(&[PathBuf::from("test.nu")]);
378 let (violations, _) = lint_files(&engine, &files, false);
379
380 set_current_dir(original_dir).unwrap();
381
382 assert!(
383 violations
384 .iter()
385 .any(|v| v.rule_id == "snake_case_variables" && v.lint_level == LintLevel::Deny)
386 );
387 }
388
389 #[test]
390 fn test_auto_discover_config_in_parent_dir() {
391 let _guard = CHDIR_MUTEX.lock().unwrap();
392
393 let temp_dir = TempDir::new().unwrap();
394 let config_path = temp_dir.path().join(".nu-lint.toml");
395 let subdir = temp_dir.path().join("subdir");
396 fs::create_dir(&subdir).unwrap();
397 let nu_file_path = subdir.join("test.nu");
398
399 fs::write(
400 &config_path,
401 r#"[lints.rules]
402 snake_case_variables = "deny""#,
403 )
404 .unwrap();
405 fs::write(&nu_file_path, "let myVariable = 5\n").unwrap();
406
407 let original_dir = current_dir().unwrap();
408
409 set_current_dir(&subdir).unwrap();
410
411 let config = Config::load(None);
412 let engine = LintEngine::new(config);
413 let files = collect_files_to_lint(&[PathBuf::from("test.nu")]);
414 let (violations, _) = lint_files(&engine, &files, false);
415
416 set_current_dir(original_dir).unwrap();
417 assert!(
418 violations
419 .iter()
420 .any(|v| v.rule_id == "snake_case_variables" && v.lint_level == LintLevel::Deny)
421 );
422 }
423
424 #[test]
425 fn test_explicit_config_overrides_auto_discovery() {
426 let _guard = CHDIR_MUTEX.lock().unwrap();
427
428 let temp_dir = TempDir::new().unwrap();
429 let auto_config = temp_dir.path().join(".nu-lint.toml");
430 let explicit_config = temp_dir.path().join("other.toml");
431 let nu_file_path = temp_dir.path().join("test.nu");
432
433 fs::write(
434 &auto_config,
435 "[lints.rules]\nsnake_case_variables = \"allow\"\n",
436 )
437 .unwrap();
438 fs::write(
439 &explicit_config,
440 r#"[lints.rules]
441 snake_case_variables = "deny""#,
442 )
443 .unwrap();
444 fs::write(&nu_file_path, "let myVariable = 5\n").unwrap();
445
446 let original_dir = current_dir().unwrap();
447
448 set_current_dir(temp_dir.path()).unwrap();
449
450 let config = Config::load(Some(&explicit_config));
451 let engine = LintEngine::new(config);
452 let files = collect_files_to_lint(&[PathBuf::from("test.nu")]);
453 let (violations, _) = lint_files(&engine, &files, false);
454
455 set_current_dir(original_dir).unwrap();
456 assert!(
457 violations
458 .iter()
459 .any(|v| v.rule_id == "snake_case_variables" && v.lint_level == LintLevel::Deny)
460 );
461 }
462}