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,
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) -> (Block, StateWorkingSet<'a>, usize) {
32    let mut working_set = StateWorkingSet::new(engine_state);
33    // Get the offset where this file will start in the virtual span space
34    let file_offset = working_set.next_span_start();
35    // Add the source to the working set's file stack so spans work correctly
36    let _file_id = working_set.add_file("source".to_string(), source);
37    // Populate `files` to make `path self` command work
38    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
44/// Check if a file is a Nushell script (by extension or shebang)
45fn 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/// Collect .nu files from a directory, respecting git ignore files
65#[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/// Collect all Nushell files to lint from given paths
84///
85/// For files: includes them if they are `.nu` files or have a nushell shebang
86/// For directories: recursively collects `.nu` files, respecting `.gitignore`
87#[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    /// Get or initialize the default engine state
119    #[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            // Required by command `path self`
127            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            // Add print command (exported by nu-cli but not added by add_cli_context)
134            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            // Commented out because not needed for most lints and may slow down
144            nu_std::load_standard_library(&mut engine_state).unwrap();
145
146            // Set up $nu constant (required for const evaluation at parse time)
147            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    /// Lint a file at the given path.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the file cannot be read.
167    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    /// Lint multiple files, optionally in parallel
189    ///
190    /// Returns a tuple of (violations, `has_errors`) where `has_errors`
191    /// indicates if any files failed to be read/parsed.
192    #[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    /// Lint content from standard input
223    #[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        // Normalize all spans in violations to be file-relative
252        for violation in &mut violations {
253            violation.normalize_spans(file_offset);
254        }
255
256        // Filter out ignored violations (after normalization so spans are
257        // file-relative)
258        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        // Add warnings for unknown rule IDs in ignore comments
268        violations.extend(ignore::validate_ignores(source));
269
270        violations
271    }
272
273    /// Collect violations from all enabled rules
274    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}