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