nu_lint/
engine.rs

1use std::{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, rule::RuleMetadata,
11    rules::RuleRegistry,
12};
13
14/// Parse Nushell source code into an AST and return both the Block and
15/// `StateWorkingSet`.
16///
17/// The `StateWorkingSet` contains the delta with newly defined declarations
18/// (functions, aliases, etc.) which is essential for AST-based linting rules
19/// that need to inspect function signatures, parameter types, and other
20/// semantic information.
21fn parse_source<'a>(engine_state: &'a EngineState, source: &[u8]) -> (Block, StateWorkingSet<'a>) {
22    let mut working_set = StateWorkingSet::new(engine_state);
23    let block = parse(&mut working_set, None, source, false);
24
25    ((*block).clone(), working_set)
26}
27
28pub struct LintEngine {
29    registry: RuleRegistry,
30    config: Config,
31    engine_state: &'static EngineState,
32}
33
34pub struct LintEngineBuilder {
35    registry: Option<RuleRegistry>,
36    config: Option<Config>,
37    engine_state: Option<&'static EngineState>,
38}
39
40impl Default for LintEngineBuilder {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl LintEngineBuilder {
47    #[must_use]
48    pub fn new() -> Self {
49        Self {
50            registry: None,
51            config: None,
52            engine_state: None,
53        }
54    }
55
56    #[must_use]
57    pub fn with_config(mut self, config: Config) -> Self {
58        self.config = Some(config);
59        self
60    }
61
62    #[must_use]
63    pub fn with_registry(mut self, registry: RuleRegistry) -> Self {
64        self.registry = Some(registry);
65        self
66    }
67
68    #[must_use]
69    pub fn with_engine_state(mut self, engine_state: &'static EngineState) -> Self {
70        self.engine_state = Some(engine_state);
71        self
72    }
73
74    #[must_use]
75    pub fn engine_state() -> &'static EngineState {
76        static ENGINE: OnceLock<EngineState> = OnceLock::new();
77        ENGINE.get_or_init(|| {
78            let engine_state = nu_cmd_lang::create_default_context();
79            nu_command::add_shell_command_context(engine_state)
80        })
81    }
82
83    #[must_use]
84    pub fn build(self) -> LintEngine {
85        LintEngine {
86            registry: self
87                .registry
88                .unwrap_or_else(RuleRegistry::with_default_rules),
89            config: self.config.unwrap_or_default(),
90            engine_state: self.engine_state.unwrap_or_else(Self::engine_state),
91        }
92    }
93}
94
95impl LintEngine {
96    #[must_use]
97    pub fn new(config: Config) -> Self {
98        LintEngineBuilder::new().with_config(config).build()
99    }
100
101    #[must_use]
102    pub fn builder() -> LintEngineBuilder {
103        LintEngineBuilder::new()
104    }
105
106    /// Lint a file at the given path.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the file cannot be read.
111    pub fn lint_file(&self, path: &Path) -> Result<Vec<Violation>, LintError> {
112        let source = std::fs::read_to_string(path)?;
113        Ok(self.lint_source(&source, Some(path)))
114    }
115
116    #[must_use]
117    pub fn lint_source(&self, source: &str, path: Option<&Path>) -> Vec<Violation> {
118        let (block, working_set) = parse_source(self.engine_state, source.as_bytes());
119
120        let context = LintContext {
121            source,
122            ast: &block,
123            engine_state: self.engine_state,
124            working_set: &working_set,
125            file_path: path,
126        };
127
128        let mut violations = self.collect_violations(&context);
129        Self::attach_file_path(&mut violations, path);
130        Self::sort_violations(&mut violations);
131        violations
132    }
133
134    /// Collect violations from all enabled rules
135    fn collect_violations(&self, context: &LintContext) -> Vec<Violation> {
136        let enabled_rules = self.get_enabled_rules();
137
138        enabled_rules.flat_map(|rule| rule.check(context)).collect()
139    }
140
141    /// Get all rules that are enabled according to the configuration
142    fn get_enabled_rules(&self) -> impl Iterator<Item = &crate::rule::Rule> {
143        self.registry.all_rules().filter(|rule| {
144            if let Some(configured_severity) = self.config.rule_severity(rule.id()) {
145                configured_severity == rule.severity()
146            } else {
147                !self.config.rules.contains_key(rule.id())
148            }
149        })
150    }
151
152    /// Attach file path to all violations
153    fn attach_file_path(violations: &mut [Violation], path: Option<&Path>) {
154        if let Some(file_path) = path.and_then(|p| p.to_str()).map(String::from) {
155            for violation in violations {
156                violation.file = Some(file_path.clone());
157            }
158        }
159    }
160
161    /// Sort violations by span start position, then by severity
162    fn sort_violations(violations: &mut [Violation]) {
163        violations.sort_by(|a, b| {
164            a.span
165                .start
166                .cmp(&b.span.start)
167                .then(a.severity.cmp(&b.severity))
168        });
169    }
170
171    #[must_use]
172    pub fn registry(&self) -> &RuleRegistry {
173        &self.registry
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_lint_valid_code() {
183        let engine = LintEngine::new(Config::default());
184        let source = "let my_variable = 5";
185        let violations = engine.lint_source(source, None);
186        assert_eq!(violations.len(), 0);
187    }
188
189    #[test]
190    fn test_lint_invalid_snake_case() {
191        let engine = LintEngine::new(Config::default());
192        let source = "let myVariable = 5";
193        let violations = engine.lint_source(source, None);
194        assert!(!violations.is_empty());
195        assert_eq!(violations[0].rule_id, "snake_case_variables");
196    }
197}