1use std::{
2 env, fs,
3 io::{self, BufRead},
4 path::{Path, PathBuf},
5 sync::{LazyLock, Mutex},
6};
7
8use ::ignore::WalkBuilder;
9use nu_parser::parse;
10use nu_protocol::{
11 Span, Value,
12 ast::Block,
13 engine::{EngineState, FileStack, StateWorkingSet},
14};
15use rayon::prelude::*;
16
17use crate::{
18 LintError,
19 config::Config,
20 context::LintContext,
21 ignore,
22 rules::USED_RULES,
23 violation::{SourceFile, Violation},
24};
25
26pub fn parse_source<'a>(
29 engine_state: &'a EngineState,
30 source: &[u8],
31) -> (Block, StateWorkingSet<'a>, usize) {
32 let mut working_set = StateWorkingSet::new(engine_state);
33 let file_offset = working_set.next_span_start();
35 let _file_id = working_set.add_file("source".to_string(), source);
37 working_set.files = FileStack::with_file(Path::new("source").to_path_buf());
39 let block = parse(&mut working_set, Some("source"), source, false);
40
41 ((*block).clone(), working_set, file_offset)
42}
43
44fn is_nushell_file(path: &Path) -> bool {
46 path.extension()
47 .and_then(|s| s.to_str())
48 .is_some_and(|ext| ext == "nu")
49 || fs::File::open(path)
50 .ok()
51 .and_then(|file| {
52 let mut reader = io::BufReader::new(file);
53 let mut first_line = String::new();
54 reader.read_line(&mut first_line).ok()?;
55 first_line.starts_with("#!").then(|| {
56 first_line
57 .split_whitespace()
58 .any(|word| word.ends_with("/nu") || word == "nu")
59 })
60 })
61 .unwrap_or(false)
62}
63
64#[must_use]
66pub fn collect_nu_files_from_dir(dir: &Path) -> Vec<PathBuf> {
67 WalkBuilder::new(dir)
68 .standard_filters(true)
69 .build()
70 .filter_map(|result| match result {
71 Ok(entry) => {
72 let path = entry.path().to_path_buf();
73 (path.is_file() && is_nushell_file(&path)).then_some(path)
74 }
75 Err(err) => {
76 log::warn!("Error walking directory: {err}");
77 None
78 }
79 })
80 .collect()
81}
82
83#[must_use]
88pub fn collect_nu_files(paths: &[PathBuf]) -> Vec<PathBuf> {
89 paths
90 .iter()
91 .flat_map(|path| {
92 if !path.exists() {
93 log::warn!("Path not found: {}", path.display());
94 return vec![];
95 }
96
97 if path.is_file() {
98 if is_nushell_file(path) {
99 vec![path.clone()]
100 } else {
101 vec![]
102 }
103 } else if path.is_dir() {
104 collect_nu_files_from_dir(path)
105 } else {
106 vec![]
107 }
108 })
109 .collect()
110}
111
112pub struct LintEngine {
113 pub(crate) config: Config,
114 engine_state: &'static EngineState,
115}
116
117impl LintEngine {
118 #[must_use]
120 pub fn new_state() -> &'static EngineState {
121 static ENGINE: LazyLock<EngineState> = LazyLock::new(|| {
122 let mut engine_state = nu_cmd_lang::create_default_context();
123 engine_state = nu_command::add_shell_command_context(engine_state);
124 engine_state = nu_cmd_extra::add_extra_command_context(engine_state);
125 engine_state = nu_cli::add_cli_context(engine_state);
126 if let Ok(cwd) = env::current_dir()
128 && let Some(cwd) = cwd.to_str()
129 {
130 engine_state.add_env_var("PWD".into(), Value::string(cwd, Span::unknown()));
131 }
132
133 let delta = {
135 let mut working_set = StateWorkingSet::new(&engine_state);
136 working_set.add_decl(Box::new(nu_cli::Print));
137 working_set.render()
138 };
139 engine_state
140 .merge_delta(delta)
141 .expect("Failed to add Print command");
142
143 nu_std::load_standard_library(&mut engine_state).unwrap();
145
146 engine_state.generate_nu_constant();
148
149 engine_state
150 });
151 &ENGINE
152 }
153
154 #[must_use]
155 pub fn new(config: Config) -> Self {
156 Self {
157 config,
158 engine_state: Self::new_state(),
159 }
160 }
161
162 pub(crate) fn lint_file(&self, path: &Path) -> Result<Vec<Violation>, LintError> {
168 log::debug!("Linting file: {}", path.display());
169 let source = fs::read_to_string(path).map_err(|source| LintError::Io {
170 path: path.to_path_buf(),
171 source,
172 })?;
173 let mut violations = self.lint_str(&source);
174
175 for violation in &mut violations {
176 violation.file = Some(path.into());
177 }
178
179 violations.sort_by(|a, b| {
180 a.file_span()
181 .start
182 .cmp(&b.file_span().start)
183 .then(a.lint_level.cmp(&b.lint_level))
184 });
185 Ok(violations)
186 }
187
188 #[must_use]
193 pub fn lint_files(&self, files: &[PathBuf]) -> Vec<Violation> {
194 let violations_mutex = Mutex::new(Vec::new());
195
196 let process_file = |path: &PathBuf| match self.lint_file(path) {
197 Ok(violations) => {
198 violations_mutex
199 .lock()
200 .expect("Failed to lock violations mutex")
201 .extend(violations);
202 }
203 Err(e) => {
204 log::error!("Error linting {}: {}", path.display(), e);
205 }
206 };
207
208 if self.config.sequential {
209 for path in files {
210 log::debug!("Processing file: {}", path.display());
211 process_file(path);
212 }
213 } else {
214 files.par_iter().for_each(process_file);
215 }
216
217 violations_mutex
218 .into_inner()
219 .expect("Failed to unwrap violations mutex")
220 }
221
222 #[must_use]
224 pub fn lint_stdin(&self, source: &str) -> Vec<Violation> {
225 let mut violations = self.lint_str(source);
226 let source_owned = source.to_string();
227
228 for violation in &mut violations {
229 violation.file = Some(SourceFile::Stdin);
230 violation.source = Some(source_owned.clone().into());
231 }
232
233 violations
234 }
235
236 #[must_use]
237 pub fn lint_str(&self, source: &str) -> Vec<Violation> {
238 let (block, working_set, file_offset) = parse_source(self.engine_state, source.as_bytes());
239
240 let context = LintContext::new(
241 source,
242 &block,
243 self.engine_state,
244 &working_set,
245 file_offset,
246 &self.config,
247 );
248
249 let mut violations = self.detect_with_fix_data(&context);
250
251 for violation in &mut violations {
253 violation.normalize_spans(file_offset);
254 }
255
256 let ignore_index = ignore::IgnoreIndex::new(source);
259 let mut violations: Vec<Violation> = violations
260 .into_iter()
261 .filter(|v| {
262 let rule_id = v.rule_id.as_deref().unwrap_or("");
263 !ignore_index.should_ignore(v.file_span().start, rule_id)
264 })
265 .collect();
266
267 violations.extend(ignore::validate_ignores(source));
269
270 violations
271 }
272
273 fn detect_with_fix_data(&self, context: &LintContext) -> Vec<Violation> {
275 USED_RULES
276 .iter()
277 .filter_map(|rule| {
278 let lint_level = self.config.get_lint_level(*rule)?;
279
280 let mut violations = rule.check(context);
281 for violation in &mut violations {
282 violation.set_rule_id(rule.id());
283 violation.set_lint_level(lint_level);
284 violation.set_doc_url(rule.source_link());
285 violation.set_short_description(rule.short_description());
286 violation.set_diagnostic_tags(rule.diagnostic_tags());
287 }
288
289 (!violations.is_empty()).then_some(violations)
290 })
291 .flatten()
292 .collect()
293 }
294}