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, LintLevel,
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 file_path: Option<&Path>,
32) -> (Block, StateWorkingSet<'a>, usize) {
33 let mut working_set = StateWorkingSet::new(engine_state);
34
35 let (fname, file_buf) = match file_path {
36 Some(p) => (p.display().to_string(), p.to_path_buf()),
37 None => ("source".to_string(), Path::new("source").to_path_buf()),
38 };
39
40 let file_offset = working_set.next_span_start();
42 let _file_id = working_set.add_file(fname.clone(), source);
44 working_set.files = FileStack::with_file(file_buf);
46 let block = parse(&mut working_set, Some(&fname), source, false);
47
48 ((*block).clone(), working_set, file_offset)
49}
50
51fn is_nushell_file(path: &Path) -> bool {
53 path.extension()
54 .and_then(|s| s.to_str())
55 .is_some_and(|ext| ext == "nu")
56 || fs::File::open(path)
57 .ok()
58 .and_then(|file| {
59 let mut reader = io::BufReader::new(file);
60 let mut first_line = String::new();
61 reader.read_line(&mut first_line).ok()?;
62 first_line.starts_with("#!").then(|| {
63 first_line
64 .split_whitespace()
65 .any(|word| word.ends_with("/nu") || word == "nu")
66 })
67 })
68 .unwrap_or(false)
69}
70
71#[must_use]
73pub fn collect_nu_files_from_dir(dir: &Path) -> Vec<PathBuf> {
74 WalkBuilder::new(dir)
75 .standard_filters(true)
76 .build()
77 .filter_map(|result| match result {
78 Ok(entry) => {
79 let path = entry.path().to_path_buf();
80 (path.is_file() && is_nushell_file(&path)).then_some(path)
81 }
82 Err(err) => {
83 log::warn!("Error walking directory: {err}");
84 None
85 }
86 })
87 .collect()
88}
89
90#[must_use]
95pub fn collect_nu_files(paths: &[PathBuf]) -> Vec<PathBuf> {
96 paths
97 .iter()
98 .flat_map(|path| {
99 if !path.exists() {
100 log::warn!("Path not found: {}", path.display());
101 return vec![];
102 }
103
104 if path.is_file() {
105 if is_nushell_file(path) {
106 vec![path.clone()]
107 } else {
108 vec![]
109 }
110 } else if path.is_dir() {
111 collect_nu_files_from_dir(path)
112 } else {
113 vec![]
114 }
115 })
116 .collect()
117}
118
119pub struct LintEngine {
120 pub(crate) config: Config,
121 engine_state: &'static EngineState,
122}
123
124impl LintEngine {
125 #[must_use]
127 pub fn new_state() -> &'static EngineState {
128 static ENGINE: LazyLock<EngineState> = LazyLock::new(|| {
129 let mut engine_state = nu_cmd_lang::create_default_context();
130 engine_state = nu_command::add_shell_command_context(engine_state);
131 engine_state = nu_cmd_extra::add_extra_command_context(engine_state);
132 engine_state = nu_cli::add_cli_context(engine_state);
133 if let Ok(cwd) = env::current_dir()
135 && let Some(cwd) = cwd.to_str()
136 {
137 engine_state.add_env_var("PWD".into(), Value::string(cwd, Span::unknown()));
138 }
139
140 let delta = {
142 let mut working_set = StateWorkingSet::new(&engine_state);
143 working_set.add_decl(Box::new(nu_cli::Print));
144 working_set.render()
145 };
146 engine_state
147 .merge_delta(delta)
148 .expect("Failed to add Print command");
149
150 nu_std::load_standard_library(&mut engine_state).unwrap();
152
153 engine_state.generate_nu_constant();
155
156 engine_state
157 });
158 &ENGINE
159 }
160
161 #[must_use]
162 pub fn new(config: Config) -> Self {
163 Self {
164 config,
165 engine_state: Self::new_state(),
166 }
167 }
168
169 pub(crate) fn lint_file(&self, path: &Path) -> Result<Vec<Violation>, LintError> {
175 log::debug!("Linting file: {}", path.display());
176 let source = fs::read_to_string(path).map_err(|source| LintError::Io {
177 path: path.to_path_buf(),
178 source,
179 })?;
180
181 let file_path = fs::canonicalize(path).ok();
182 let (block, working_set, file_offset) =
183 parse_source(self.engine_state, source.as_bytes(), file_path.as_deref());
184 let mut violations = self.lint_parsed(&source, &block, &working_set, file_offset);
185
186 for violation in &mut violations {
187 violation.file = Some(path.into());
188 }
189
190 violations.sort_by(|a, b| {
191 a.file_span()
192 .start
193 .cmp(&b.file_span().start)
194 .then(a.lint_level.cmp(&b.lint_level))
195 });
196 Ok(violations)
197 }
198
199 #[must_use]
204 pub fn lint_files(&self, files: &[PathBuf]) -> Vec<Violation> {
205 let violations_mutex = Mutex::new(Vec::new());
206
207 let process_file = |path: &PathBuf| match self.lint_file(path) {
208 Ok(violations) => {
209 violations_mutex
210 .lock()
211 .expect("Failed to lock violations mutex")
212 .extend(violations);
213 }
214 Err(e) => {
215 log::error!("Error linting {}: {}", path.display(), e);
216 }
217 };
218
219 if self.config.sequential {
220 for path in files {
221 log::debug!("Processing file: {}", path.display());
222 process_file(path);
223 }
224 } else {
225 files.par_iter().for_each(process_file);
226 }
227
228 violations_mutex
229 .into_inner()
230 .expect("Failed to unwrap violations mutex")
231 }
232
233 #[must_use]
235 pub fn lint_stdin(&self, source: &str) -> Vec<Violation> {
236 let mut violations = self.lint_str(source);
237 let source_owned = source.to_string();
238
239 for violation in &mut violations {
240 violation.file = Some(SourceFile::Stdin);
241 violation.source = Some(source_owned.clone().into());
242 }
243
244 violations
245 }
246
247 #[must_use]
248 pub fn lint_str(&self, source: &str) -> Vec<Violation> {
249 let (block, working_set, file_offset) =
250 parse_source(self.engine_state, source.as_bytes(), None);
251 self.lint_parsed(source, &block, &working_set, file_offset)
252 }
253
254 fn lint_parsed(
255 &self,
256 source: &str,
257 block: &Block,
258 working_set: &StateWorkingSet,
259 file_offset: usize,
260 ) -> Vec<Violation> {
261 let context = LintContext::new(
262 source,
263 block,
264 self.engine_state,
265 working_set,
266 file_offset,
267 &self.config,
268 );
269
270 let mut violations = self.detect_with_fix_data(&context);
271
272 for violation in &mut violations {
273 violation.normalize_spans(file_offset);
274 }
275
276 let ignore_index = ignore::IgnoreIndex::new(source);
277 violations
278 .into_iter()
279 .filter(|v| {
280 let rule_id = v.rule_id.as_deref().unwrap_or("");
281 !ignore_index.should_ignore(v.file_span().start, rule_id)
282 })
283 .collect()
284 }
285
286 fn detect_with_fix_data(&self, context: &LintContext) -> Vec<Violation> {
288 USED_RULES
289 .iter()
290 .filter_map(|rule| {
291 let lint_level = self.config.get_lint_level(*rule);
292 if lint_level == LintLevel::Off {
293 return None;
294 }
295 let mut violations = rule.check(context);
296 for violation in &mut violations {
297 violation.set_rule_id(rule.id());
298 violation.set_lint_level(lint_level.try_into().unwrap());
299 violation.set_doc_url(rule.source_link());
300 violation.set_short_description(rule.short_description());
301 violation.set_diagnostic_tags(rule.diagnostic_tags());
302 }
303
304 (!violations.is_empty()).then_some(violations)
305 })
306 .flatten()
307 .collect()
308 }
309}