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 #[arg(default_value = ".")]
30 paths: Vec<PathBuf>,
31
32 #[arg(long, conflicts_with_all = ["lsp", "list", "groups", "explain"])]
34 fix: bool,
35
36 #[arg(long, conflicts_with_all = ["fix", "list", "groups", "explain"])]
38 lsp: bool,
39
40 #[arg(long, conflicts_with_all = ["fix", "lsp", "groups", "explain"], alias = "rules")]
42 list: bool,
43
44 #[arg(long, conflicts_with_all = ["fix", "lsp", "list", "explain"], alias = "sets")]
46 groups: bool,
47
48 #[arg(long, value_name = "RULE_ID", conflicts_with_all = ["fix", "lsp", "list", "groups"])]
50 explain: Option<String>,
51
52 #[arg(long, value_name = "SOURCE", conflicts_with_all = ["fix", "lsp", "list", "groups", "explain"])]
55 ast: Option<String>,
56
57 #[arg(long, short = 'f', value_enum, default_value_t = Format::Pretty)]
59 format: Format,
60
61 #[arg(long, short)]
63 config: Option<PathBuf>,
64
65 #[arg(long)]
67 stdin: bool,
68
69 #[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()); }
347}