nu_lint/
engine.rs

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