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