nu_lint/
cli.rs

1use std::{
2    io::{self, Read},
3    path::PathBuf,
4    process,
5};
6
7use clap::Parser;
8
9use crate::{
10    LintLevel,
11    ast::tree,
12    config::Config,
13    engine::{LintEngine, collect_nu_files},
14    fix::{apply_fixes, apply_fixes_to_stdin, format_fix_results},
15    log::init_log,
16    lsp,
17    output::{Format, Summary, format_output},
18    rule::Rule,
19    rules::{USED_RULES, groups::ALL_GROUPS},
20};
21
22#[derive(Parser)]
23#[command(name = "nu-lint")]
24#[command(about = "A linter for Nushell scripts")]
25#[command(version)]
26pub struct Cli {
27    /// Files or directories to lint/fix
28    #[arg(default_value = ".")]
29    paths: Vec<PathBuf>,
30
31    /// Auto-fix lint violations
32    #[arg(long, conflicts_with_all = ["lsp", "list", "groups", "explain"])]
33    fix: bool,
34
35    /// Start the LSP server
36    #[arg(long, conflicts_with_all = ["fix", "list", "groups", "explain"])]
37    lsp: bool,
38
39    /// List all available lint rules
40    #[arg(long, conflicts_with_all = ["fix", "lsp", "groups", "explain"], alias = "rules")]
41    list: bool,
42
43    /// List all available rule groups
44    #[arg(long, conflicts_with_all = ["fix", "lsp", "list", "explain"], alias = "sets")]
45    groups: bool,
46
47    /// Explain a specific lint rule
48    #[arg(long, value_name = "RULE_ID", conflicts_with_all = ["fix", "lsp", "list", "groups"])]
49    explain: Option<String>,
50
51    /// Print AST (Abstract Syntax Tree) with expanded blocks for the given
52    /// source code
53    #[arg(long, value_name = "SOURCE", conflicts_with_all = ["fix", "lsp", "list", "groups", "explain"])]
54    ast: Option<String>,
55
56    /// Output format
57    #[arg(long, short = 'f', value_enum, default_value_t = Format::Text)]
58    format: Format,
59
60    /// Path to config file
61    #[arg(long, short)]
62    config: Option<PathBuf>,
63
64    /// Read from stdin
65    #[arg(long)]
66    stdin: bool,
67
68    /// Verbose output (requires a level set by environment variable
69    /// `RUST_LOG=debug`)
70    #[arg(long, short = 'v')]
71    verbose: bool,
72}
73
74impl Cli {
75    fn load_config(path: Option<PathBuf>) -> Config {
76        path.map(|p| {
77            Config::load_from_file(&p).unwrap_or_else(|e| {
78                log::error!("Error loading config from {}: {e}", p.display());
79                Config::default()
80            })
81        })
82        .unwrap_or_default()
83    }
84
85    fn read_stdin() -> String {
86        let mut source = String::new();
87        io::stdin()
88            .read_to_string(&mut source)
89            .expect("Failed to read from stdin");
90        source
91    }
92
93    fn lint(&self) {
94        let config = Self::load_config(self.config.clone());
95        let engine = LintEngine::new(config);
96
97        let violations = if self.stdin {
98            let source = Self::read_stdin();
99            engine.lint_stdin(&source)
100        } else {
101            let files = collect_nu_files(&self.paths);
102            if files.is_empty() {
103                eprintln!("Warning: No Nushell files found in specified paths");
104                return;
105            }
106            engine.lint_files(&files)
107        };
108
109        let output = format_output(&violations, self.format);
110        if !output.is_empty() {
111            println!("{output}");
112        }
113
114        let summary = Summary::from_violations(&violations);
115        eprintln!("{}", summary.format_compact());
116
117        if violations.iter().any(|v| v.lint_level > LintLevel::Hint) {
118            process::exit(1);
119        } else {
120            process::exit(0);
121        }
122    }
123
124    fn fix(&self) {
125        let config = Self::load_config(self.config.clone());
126        let engine = LintEngine::new(config);
127
128        if self.stdin {
129            Self::fix_stdin(&engine);
130        } else {
131            Self::fix_files(&self.paths, &engine);
132        }
133    }
134
135    fn fix_stdin(engine: &LintEngine) {
136        let source = Self::read_stdin();
137        let violations = engine.lint_stdin(&source);
138
139        if let Some(fixed) = apply_fixes_to_stdin(&violations) {
140            print!("{fixed}");
141        } else {
142            print!("{source}");
143        }
144    }
145
146    fn fix_files(paths: &[PathBuf], engine: &LintEngine) {
147        let files = collect_nu_files(paths);
148        if files.is_empty() {
149            eprintln!("Warning: No Nushell files found in specified paths");
150            return;
151        }
152
153        let violations = engine.lint_files(&files);
154
155        let results = apply_fixes(&violations, false, engine);
156        let output = format_fix_results(&results, false);
157        print!("{output}");
158    }
159
160    fn list_rules() {
161        println!("## Available Lint Rules\n");
162        let mut sorted_rules = USED_RULES.to_vec();
163        sorted_rules.sort_by_key(|r| r.id());
164
165        let max_id_len = sorted_rules.iter().map(|r| r.id().len()).max().unwrap_or(0) + 2; // +2 for backticks
166        let max_desc_len = sorted_rules
167            .iter()
168            .map(|r| r.short_description().len())
169            .max()
170            .unwrap_or(0);
171
172        println!(
173            "| {:<width_id$} | {:<width_desc$} | {:<7} | {:<8} |",
174            "Rule",
175            "Description",
176            "Level",
177            "Auto-fix",
178            width_id = max_id_len,
179            width_desc = max_desc_len
180        );
181        println!(
182            "| {:-<width_id$} | {:-<width_desc$} | {:-<7} | {:-<8} |",
183            "",
184            "",
185            "",
186            "",
187            width_id = max_id_len,
188            width_desc = max_desc_len
189        );
190        for rule in &sorted_rules {
191            let level = match rule.level() {
192                LintLevel::Hint => "hint",
193                LintLevel::Warning => "warning",
194                LintLevel::Error => "error",
195            };
196            let auto_fix = if rule.has_auto_fix() { "Yes" } else { "" };
197            let id_formatted = format!("`{}`", rule.id());
198            println!(
199                "| {:<width_id$} | {:<width_desc$} | {:<7} | {:<8} |",
200                id_formatted,
201                rule.short_description(),
202                level,
203                auto_fix,
204                width_id = max_id_len,
205                width_desc = max_desc_len
206            );
207        }
208        let fixable_count = sorted_rules.iter().filter(|r| r.has_auto_fix()).count();
209        println!(
210            "\n*{n} rules available, {f} with auto-fix.*",
211            n = sorted_rules.len(),
212            f = fixable_count
213        );
214    }
215
216    fn list_groups() {
217        fn auto_fix_suffix(rule: &dyn Rule) -> &'static str {
218            if rule.has_auto_fix() {
219                " (auto-fix)"
220            } else {
221                ""
222            }
223        }
224        for set in ALL_GROUPS {
225            println!("`{}` - {}\n", set.name, set.description);
226            for rule in set.rules {
227                println!("- `{}`{}", rule.id(), auto_fix_suffix(*rule));
228            }
229            println!();
230        }
231    }
232
233    fn explain_rule(rule_id: &str) {
234        let rule = USED_RULES.iter().find(|r| r.id() == rule_id);
235
236        if let Some(rule) = rule {
237            println!("Rule: {}", rule.id());
238            println!("Explanation: {}", rule.short_description());
239            if let Some(url) = rule.source_link() {
240                println!("Documentation: {url}");
241            }
242        } else {
243            eprintln!("Unknown rule ID: {rule_id}");
244            process::exit(1);
245        }
246    }
247}
248
249pub fn run() {
250    let cli = Cli::parse();
251
252    if cli.verbose {
253        init_log();
254    }
255
256    if cli.list {
257        Cli::list_rules();
258    } else if cli.groups {
259        Cli::list_groups();
260    } else if let Some(ref rule_id) = cli.explain {
261        Cli::explain_rule(rule_id);
262    } else if let Some(ref source) = cli.ast {
263        tree::print_ast(source);
264    } else if cli.lsp {
265        lsp::run_lsp_server();
266    } else if cli.fix {
267        cli.fix();
268    } else {
269        cli.lint();
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use std::{fs, path::PathBuf};
276
277    use clap::Parser;
278
279    use crate::{Config, LintEngine, cli::Cli, engine::collect_nu_files};
280
281    #[test]
282    fn test_cli_parsing() {
283        let cli = Cli::try_parse_from(["nu-lint", "file.nu"]).unwrap();
284        assert_eq!(cli.paths, vec![PathBuf::from("file.nu")]);
285        assert!(!cli.stdin);
286    }
287
288    #[test]
289    fn test_cli_stdin_flag() {
290        let cli = Cli::try_parse_from(["nu-lint", "--stdin"]).unwrap();
291        assert!(cli.stdin);
292    }
293
294    #[test]
295    fn test_cli_list_rules_flag() {
296        let cli = Cli::try_parse_from(["nu-lint", "--list"]).unwrap();
297        assert!(cli.list);
298    }
299
300    #[test]
301    fn test_cli_list_groups_flag() {
302        let cli = Cli::try_parse_from(["nu-lint", "--groups"]).unwrap();
303        assert!(cli.groups);
304    }
305
306    #[test]
307    fn test_cli_explain_flag() {
308        let cli = Cli::try_parse_from(["nu-lint", "--explain", "some-rule"]).unwrap();
309        assert_eq!(cli.explain, Some("some-rule".to_string()));
310    }
311
312    #[test]
313    fn test_cli_lsp_flag() {
314        let cli = Cli::try_parse_from(["nu-lint", "--lsp"]).unwrap();
315        assert!(cli.lsp);
316    }
317
318    #[test]
319    fn test_cli_fix_flag() {
320        let cli = Cli::try_parse_from(["nu-lint", "--fix", "file.nu"]).unwrap();
321        assert!(cli.fix);
322        assert_eq!(cli.paths, vec![PathBuf::from("file.nu")]);
323    }
324
325    #[test]
326    fn test_cli_mutually_exclusive_flags() {
327        assert!(Cli::try_parse_from(["nu-lint", "--fix", "--lsp"]).is_err());
328        assert!(Cli::try_parse_from(["nu-lint", "--list-rules", "--list-groups"]).is_err());
329        assert!(Cli::try_parse_from(["nu-lint", "--fix", "--explain", "rule"]).is_err());
330    }
331
332    #[test]
333    fn test_lint_integration() {
334        let temp_dir = tempfile::tempdir().unwrap();
335        let test_file = temp_dir.path().join("test.nu");
336        fs::write(&test_file, "def foo [] { echo 'hello' }").unwrap();
337
338        let engine = LintEngine::new(Config::default());
339        let files = collect_nu_files(&[test_file]);
340
341        assert_eq!(files.len(), 1);
342        let violations = engine.lint_files(&files);
343        assert!(violations.is_empty() || !violations.is_empty()); // Just ensure it runs
344    }
345}