Skip to main content

mlua_check/
engine.rs

1//! Top-level lint engine that coordinates parsing, rule execution, and result
2//! aggregation.
3
4use crate::config::LintConfig;
5use crate::symbols::SymbolTable;
6use crate::types::LintResult;
7use crate::walker;
8
9/// Main entry point for linting Lua source code.
10///
11/// # Example
12///
13/// ```
14/// use mlua_check::{LintEngine, LintConfig, LintPolicy};
15///
16/// let mut engine = LintEngine::new();
17/// engine.symbols_mut().add_global("alc");
18/// engine.symbols_mut().add_global_field("alc", "llm");
19///
20/// let result = engine.lint("alc.llm_call('hello')", "@main.lua");
21/// assert!(result.has_errors() || result.warning_count > 0);
22/// ```
23#[derive(Debug, Clone)]
24pub struct LintEngine {
25    symbols: SymbolTable,
26    config: LintConfig,
27}
28
29impl LintEngine {
30    /// Create a new engine with Lua 5.4 stdlib pre-populated and default
31    /// config (policy: Warn).
32    pub fn new() -> Self {
33        Self {
34            symbols: SymbolTable::new().with_lua54_stdlib(),
35            config: LintConfig::default(),
36        }
37    }
38
39    /// Create with a specific config.
40    pub fn with_config(config: LintConfig) -> Self {
41        Self {
42            symbols: SymbolTable::new().with_lua54_stdlib(),
43            config,
44        }
45    }
46
47    /// Mutable access to the symbol table for registration.
48    pub fn symbols_mut(&mut self) -> &mut SymbolTable {
49        &mut self.symbols
50    }
51
52    /// Immutable access to the symbol table.
53    pub fn symbols(&self) -> &SymbolTable {
54        &self.symbols
55    }
56
57    /// Mutable access to config.
58    pub fn config_mut(&mut self) -> &mut LintConfig {
59        &mut self.config
60    }
61
62    /// Run all enabled lint rules on the given source code.
63    ///
64    /// `_chunk_name` is reserved for future use in multi-file analysis.
65    pub fn lint(&self, source: &str, _chunk_name: &str) -> LintResult {
66        // Single-pass scope-aware walk: collects UndefinedGlobal and
67        // UnusedVariable diagnostics with proper lexical scoping.
68        let mut all_diagnostics = walker::walk(source, &self.symbols, &self.config);
69
70        // Sort by line, then column for stable output
71        all_diagnostics.sort_by(|a, b| a.line.cmp(&b.line).then(a.column.cmp(&b.column)));
72
73        LintResult::new(all_diagnostics)
74    }
75}
76
77impl Default for LintEngine {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn engine_detects_undefined_global() {
89        let engine = LintEngine::new();
90        let result = engine.lint("unknown_func()", "@test.lua");
91        assert_eq!(result.warning_count, 1);
92        assert!(result.diagnostics[0].message.contains("unknown_func"));
93    }
94
95    #[test]
96    fn engine_with_custom_globals() {
97        let mut engine = LintEngine::new();
98        engine.symbols_mut().add_global("alc");
99        engine.symbols_mut().add_global_field("alc", "llm");
100
101        let result = engine.lint("alc.llm('hello')", "@test.lua");
102        assert_eq!(result.diagnostics.len(), 0);
103
104        let result = engine.lint("alc.llm_call('hello')", "@test.lua");
105        assert_eq!(result.diagnostics.len(), 1);
106        assert!(result.diagnostics[0].message.contains("llm_call"));
107    }
108
109    #[test]
110    fn engine_empty_code_no_errors() {
111        let engine = LintEngine::new();
112        let result = engine.lint("", "@test.lua");
113        assert_eq!(result.diagnostics.len(), 0);
114    }
115
116    #[test]
117    fn engine_detects_unused_variable() {
118        let engine = LintEngine::new();
119        let result = engine.lint("local unused = 42\nprint('hi')", "@test.lua");
120        let unused: Vec<_> = result
121            .diagnostics
122            .iter()
123            .filter(|d| d.rule == crate::types::RuleId::UnusedVariable)
124            .collect();
125        assert_eq!(unused.len(), 1);
126        assert!(unused[0].message.contains("unused"));
127    }
128
129    #[test]
130    fn engine_scoped_local_out_of_scope() {
131        let engine = LintEngine::new();
132        let result = engine.lint("do\n  local x = 1\nend\nprint(x)", "@test.lua");
133        let globals: Vec<_> = result
134            .diagnostics
135            .iter()
136            .filter(|d| d.rule == crate::types::RuleId::UndefinedGlobal)
137            .collect();
138        // x is out of scope after do...end
139        assert_eq!(globals.len(), 1, "diagnostics: {globals:?}");
140    }
141}