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    rules::USED_RULES,
22    violation::{SourceFile, Violation},
23};
24
25/// Parse Nushell source code into an AST and return both the Block and
26/// `StateWorkingSet`, along with the file's starting offset in the span space.
27pub 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    // Get the offset where this file will start in the virtual span space
33    let file_offset = working_set.next_span_start();
34    // Add the source to the working set's file stack so spans work correctly
35    let _file_id = working_set.add_file("source".to_string(), source);
36    // Populate `files` to make `path self` command work
37    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
43/// Check if a file is a Nushell script (by extension or shebang)
44fn 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/// Collect .nu files from a directory, respecting .gitignore files
64#[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/// Collect all Nushell files to lint from given paths
83///
84/// For files: includes them if they are `.nu` files or have a nushell shebang
85/// For directories: recursively collects `.nu` files, respecting `.gitignore`
86#[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    /// Get or initialize the default engine state
118    #[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            // Required by command `path self`
126            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            // Add print command (exported by nu-cli but not added by add_cli_context)
133            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            // Commented out because not needed for most lints and may slow down
143            nu_std::load_standard_library(&mut engine_state).unwrap();
144
145            // Set up $nu constant (required for const evaluation at parse time)
146            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    /// Lint a file at the given path.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the file cannot be read.
166    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    /// Lint multiple files, optionally in parallel
188    ///
189    /// Returns a tuple of (violations, `has_errors`) where `has_errors`
190    /// indicates if any files failed to be read/parsed.
191    #[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    /// Lint content from stdin
222    #[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        // Normalize all spans in violations to be file-relative
251        for violation in &mut violations {
252            violation.normalize_spans(file_offset);
253        }
254
255        violations
256    }
257
258    /// Collect violations from all enabled rules
259    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}