Skip to main content

nu_lint/
engine.rs

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
26/// Parse Nushell source code into an AST and return both the Block and
27/// `StateWorkingSet`, along with the file's starting offset in the span space.
28pub 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    // Get the offset where this file will start in the virtual span space
41    let file_offset = working_set.next_span_start();
42    // Add the source to the working set's file stack so spans work correctly
43    let _file_id = working_set.add_file(fname.clone(), source);
44    // Populate `files` to make `path self` command work
45    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
51/// Check if a file is a Nushell script (by extension or shebang)
52fn 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/// Collect .nu files from a directory, respecting git ignore files
72#[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/// Collect all Nushell files to lint from given paths
91///
92/// For files: includes them if they are `.nu` files or have a nushell shebang
93/// For directories: recursively collects `.nu` files, respecting `.gitignore`
94#[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    /// Get or initialize the default engine state
126    #[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            // Required by command `path self`
134            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            // Add print command (exported by nu-cli but not added by add_cli_context)
141            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            // Commented out because not needed for most lints and may slow down
151            nu_std::load_standard_library(&mut engine_state).unwrap();
152
153            // Set up $nu constant (required for const evaluation at parse time)
154            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    /// Lint a file at the given path.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the file cannot be read.
174    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    /// Lint multiple files, optionally in parallel
200    ///
201    /// Returns a tuple of (violations, `has_errors`) where `has_errors`
202    /// indicates if any files failed to be read/parsed.
203    #[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    /// Lint content from standard input
234    #[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    /// Collect violations from all enabled rules
287    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}