nu_lint/
cli.rs

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    /// VS Code LSP-compatible JSON format
79    VscodeJson,
80    Github,
81}
82
83/// Handle subcommands (list-rules, list-sets, explain)
84pub 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/// Collect all files to lint from the provided paths, respecting .gitignore
112/// files
113#[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/// Collect .nu files from a directory, respecting .gitignore files
157#[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/// Lint files either in parallel or sequentially
176#[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
189/// Lint files in parallel
190fn 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
218/// Lint files sequentially
219fn 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
238/// Format and output linting results
239pub 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}