Skip to main content

nu_lint/
cli.rs

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