nu_lint/
cli.rs

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
67/// Handle subcommands (list-rules, explain)
68pub 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/// Collect all files to lint from the provided paths, respecting .gitignore
76/// files
77#[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            // For individual files, add them directly (don't check gitignore for explicitly
91            // specified files)
92            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/// Collect .nu files from a directory, respecting .gitignore files
115#[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) // Enable standard filters including .gitignore
121        .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/// Lint files either in parallel or sequentially
141#[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
154/// Lint files in parallel
155fn 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
178/// Lint files sequentially
179fn 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
198/// Format and output linting results
199pub 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}