nu_lint/
engine.rs

1use std::{collections::HashSet, path::Path, sync::OnceLock};
2
3use nu_parser::parse;
4use nu_protocol::{
5    ast::Block,
6    engine::{EngineState, StateWorkingSet},
7};
8
9use crate::{
10    LintError, config::Config, context::LintContext, rules::RuleRegistry, violation::Violation,
11};
12
13/// Parse Nushell source code into an AST and return both the Block and
14/// `StateWorkingSet`.
15fn parse_source<'a>(engine_state: &'a EngineState, source: &[u8]) -> (Block, StateWorkingSet<'a>) {
16    let mut working_set = StateWorkingSet::new(engine_state);
17    let block = parse(&mut working_set, None, source, false);
18
19    ((*block).clone(), working_set)
20}
21
22pub struct LintEngine {
23    registry: RuleRegistry,
24    config: Config,
25    engine_state: &'static EngineState,
26}
27
28impl LintEngine {
29    /// Get or initialize the default engine state
30    fn default_engine_state() -> &'static EngineState {
31        static ENGINE: OnceLock<EngineState> = OnceLock::new();
32        ENGINE.get_or_init(|| {
33            let engine_state = nu_cmd_lang::create_default_context();
34            let engine_state = nu_command::add_shell_command_context(engine_state);
35            let mut engine_state = nu_cli::add_cli_context(engine_state);
36
37            // Add print command (it's in nu-cli but not added by add_cli_context)
38            let delta = {
39                let mut working_set = StateWorkingSet::new(&engine_state);
40                working_set.add_decl(Box::new(nu_cli::Print));
41                working_set.render()
42            };
43
44            if let Err(err) = engine_state.merge_delta(delta) {
45                eprintln!("Error adding Print command: {err:?}");
46            }
47
48            engine_state
49        })
50    }
51
52    #[must_use]
53    pub fn new(config: Config) -> Self {
54        Self {
55            registry: RuleRegistry::with_default_rules(),
56            config,
57            engine_state: Self::default_engine_state(),
58        }
59    }
60
61    /// Lint a file at the given path.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the file cannot be read.
66    pub fn lint_file(&self, path: &Path) -> Result<Vec<Violation>, LintError> {
67        let source = std::fs::read_to_string(path)?;
68        Ok(self.lint_source(&source, Some(path)))
69    }
70
71    #[must_use]
72    pub fn lint_source(&self, source: &str, path: Option<&Path>) -> Vec<Violation> {
73        let (block, working_set) = parse_source(self.engine_state, source.as_bytes());
74
75        let context = LintContext {
76            source,
77            ast: &block,
78            engine_state: self.engine_state,
79            working_set: &working_set,
80        };
81
82        let mut violations = self.collect_violations(&context);
83
84        // Extract parse errors from the working set and convert to violations
85        violations.extend(self.convert_parse_errors_to_violations(&working_set));
86
87        Self::attach_file_path(&mut violations, path);
88        Self::sort_violations(&mut violations);
89        violations
90    }
91
92    /// Collect violations from all enabled rules
93    fn collect_violations(&self, context: &LintContext) -> Vec<Violation> {
94        let eligible_rules = self.get_eligible_rules();
95
96        eligible_rules
97            .flat_map(|rule| {
98                let rule_violations = (rule.check)(context);
99                let rule_severity = self.get_effective_rule_severity(rule);
100
101                // Convert RuleViolations to Violations with the rule's effective severity
102                rule_violations
103                    .into_iter()
104                    .map(|rule_violation| rule_violation.into_violation(rule_severity))
105                    .collect::<Vec<_>>()
106            })
107            .collect()
108    }
109
110    /// Convert parse errors from the `StateWorkingSet` into violations
111    fn convert_parse_errors_to_violations(&self, working_set: &StateWorkingSet) -> Vec<Violation> {
112        // Get the nu_parse_error rule to use its metadata
113        let parse_error_rule = self.registry.get_rule("nu_parse_error");
114
115        if parse_error_rule.is_none() {
116            return vec![];
117        }
118
119        let rule = parse_error_rule.unwrap();
120        let rule_severity = self.get_effective_rule_severity(rule);
121
122        // Check if this rule meets the minimum severity threshold
123        if let Some(min_threshold) = self.get_minimum_severity_threshold()
124            && rule_severity < min_threshold
125        {
126            return vec![];
127        }
128
129        let mut seen = HashSet::new();
130
131        // Convert each parse error to a violation, deduplicating by span and message
132        working_set
133            .parse_errors
134            .iter()
135            .filter_map(|parse_error| {
136                let key = (
137                    parse_error.span().start,
138                    parse_error.span().end,
139                    parse_error.to_string(),
140                );
141                if seen.insert(key.clone()) {
142                    use crate::violation::RuleViolation;
143
144                    Some(
145                        RuleViolation::new_dynamic(
146                            "nu_parse_error",
147                            parse_error.to_string(),
148                            parse_error.span(),
149                        )
150                        .into_violation(rule_severity),
151                    )
152                } else {
153                    None
154                }
155            })
156            .collect()
157    }
158
159    /// Get all rules that are enabled according to the configuration
160    fn get_enabled_rules(&self) -> impl Iterator<Item = &crate::rule::Rule> {
161        self.registry.all_rules().filter(|rule| {
162            // If not in config, use default (enabled). If in config, check if it's not
163            // turned off.
164            !matches!(
165                self.config.rules.get(rule.id),
166                Some(&crate::config::RuleSeverity::Off)
167            )
168        })
169    }
170
171    /// Get all rules that are enabled and meet the `min_severity` threshold
172    /// This is more efficient as it avoids running rules that would be filtered
173    /// out anyway
174    fn get_eligible_rules(&self) -> impl Iterator<Item = &crate::rule::Rule> {
175        let min_severity_threshold = self.get_minimum_severity_threshold();
176
177        self.get_enabled_rules().filter(move |rule| {
178            let rule_severity = self.get_effective_rule_severity(rule);
179
180            // Handle special case: min_severity = "off" means no rules are eligible
181            if matches!(
182                self.config.general.min_severity,
183                crate::config::RuleSeverity::Off
184            ) {
185                return false;
186            }
187
188            // Check if rule severity meets minimum threshold
189            match min_severity_threshold {
190                Some(min_threshold) => rule_severity >= min_threshold,
191                None => true, // min_severity = "info" means all rules are eligible
192            }
193        })
194    }
195
196    /// Get the effective severity for a rule (config override or rule default)
197    fn get_effective_rule_severity(&self, rule: &crate::rule::Rule) -> crate::violation::Severity {
198        if let Some(config_severity) = self.config.rule_severity(rule.id) {
199            config_severity
200        } else {
201            rule.severity
202        }
203    }
204
205    /// Get the minimum severity threshold from `min_severity` config
206    /// `min_severity` sets the minimum threshold for showing violations:
207    /// - "error": Show only errors (minimum threshold = Error)
208    /// - "warning": Show warnings and errors (minimum threshold = Warning)
209    /// - "info": Show info, warnings, and errors (minimum threshold = Info,
210    ///   i.e., all)
211    /// - "off": Show nothing
212    fn get_minimum_severity_threshold(&self) -> Option<crate::violation::Severity> {
213        use crate::config::RuleSeverity;
214        match self.config.general.min_severity {
215            RuleSeverity::Error => Some(crate::violation::Severity::Error), // Show only errors
216            RuleSeverity::Warning => Some(crate::violation::Severity::Warning), // Show warnings and
217            // above
218            RuleSeverity::Info | RuleSeverity::Off => None, // Show all (no filtering)
219        }
220    }
221
222    /// Attach file path to all violations
223    fn attach_file_path(violations: &mut [Violation], path: Option<&Path>) {
224        if let Some(file_path_str) = path.and_then(|p| p.to_str()) {
225            use std::borrow::Cow;
226            let file_path: Cow<'static, str> = file_path_str.to_owned().into();
227            for violation in violations {
228                violation.file = Some(file_path.clone());
229            }
230        }
231    }
232
233    /// Sort violations by span start position, then by severity
234    fn sort_violations(violations: &mut [Violation]) {
235        violations.sort_by(|a, b| {
236            a.span
237                .start
238                .cmp(&b.span.start)
239                .then(a.severity.cmp(&b.severity))
240        });
241    }
242
243    #[must_use]
244    pub fn registry(&self) -> &RuleRegistry {
245        &self.registry
246    }
247}