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 #[arg(default_value = ".")]
29 paths: Vec<PathBuf>,
30
31 #[arg(long, conflicts_with_all = ["lsp", "list", "groups", "explain"])]
33 fix: bool,
34
35 #[arg(long, conflicts_with_all = ["fix", "list", "groups", "explain"])]
37 lsp: bool,
38
39 #[arg(long, conflicts_with_all = ["fix", "lsp", "groups", "explain"], alias = "rules")]
41 list: bool,
42
43 #[arg(long, conflicts_with_all = ["fix", "lsp", "list", "explain"], alias = "sets")]
45 groups: bool,
46
47 #[arg(long, value_name = "RULE_ID", conflicts_with_all = ["fix", "lsp", "list", "groups"])]
49 explain: Option<String>,
50
51 #[arg(long, value_name = "SOURCE", conflicts_with_all = ["fix", "lsp", "list", "groups", "explain"])]
54 ast: Option<String>,
55
56 #[arg(long, short = 'f', value_enum, default_value_t = Format::Text)]
58 format: Format,
59
60 #[arg(long, short)]
62 config: Option<PathBuf>,
63
64 #[arg(long)]
66 stdin: bool,
67
68 #[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; 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()); }
345}