Skip to main content

valua_lint/
lib.rs

1//! Static analysis for valua. Public crate — API stable from 1.0.
2//!
3//! Core usage:
4//! ```ignore
5//! let block = valua_parser::parse(src)?;
6//! let diags = LintPipeline::default_for(valua_ast::LuaTarget::LuaJIT).run(&block);
7//! ```
8
9use valua_ast::{Block, LuaTarget};
10use valua_diagnostics::Diagnostic;
11
12pub mod passes;
13
14use passes::const_mutation::ConstMutation;
15use passes::unsupported::UnsupportedFeatureGuard;
16
17/// A single static-analysis pass over a parsed AST.
18pub trait Lint {
19    fn check(&self, block: &Block) -> Vec<Diagnostic>;
20}
21
22/// Ordered sequence of lint passes applied to a parsed block.
23pub struct LintPipeline {
24    passes: Vec<Box<dyn Lint>>,
25}
26
27impl LintPipeline {
28    /// Default lint pipeline for `target`.
29    ///
30    /// - `ConstMutation` (E0301) runs for all targets.
31    /// - `UnsupportedFeatureGuard` (E0101, E0102) runs for Lua 5.1 and `LuaJIT`,
32    ///   which both lack `math.type` and differ in integer overflow semantics.
33    #[must_use]
34    pub fn default_for(target: LuaTarget) -> Self {
35        let mut passes: Vec<Box<dyn Lint>> = vec![Box::new(ConstMutation)];
36        match target {
37            LuaTarget::Lua51 | LuaTarget::LuaJIT => {
38                passes.push(Box::new(UnsupportedFeatureGuard));
39            }
40        }
41        Self { passes }
42    }
43
44    /// Run all passes and return all diagnostics in pass order.
45    #[must_use]
46    pub fn run(&self, block: &Block) -> Vec<Diagnostic> {
47        let mut out = Vec::new();
48        for pass in &self.passes {
49            out.extend(pass.check(block));
50        }
51        out
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use valua_ast::{Block, LuaTarget};
58    use valua_diagnostics::{CollectingReporter, Diagnostic, Reporter, Span};
59
60    use super::{Lint, LintPipeline};
61
62    fn parse(src: &str) -> Block {
63        valua_parser::parse(src).expect("parse failed")
64    }
65
66    fn dummy_span() -> Span {
67        Span::new(0, 1, 1, 1)
68    }
69
70    // ── Lint trait: mock implementations ─────────────────────────────────────
71
72    struct AlwaysErrors;
73    impl Lint for AlwaysErrors {
74        fn check(&self, _: &Block) -> Vec<Diagnostic> {
75            vec![Diagnostic::error("always fires", dummy_span()).with_code("E9999")]
76        }
77    }
78
79    struct NeverErrors;
80    impl Lint for NeverErrors {
81        fn check(&self, _: &Block) -> Vec<Diagnostic> {
82            vec![]
83        }
84    }
85
86    #[test]
87    fn lint_trait_mock_fires() {
88        let block = parse("local x = 1");
89        let diags = AlwaysErrors.check(&block);
90        assert_eq!(diags.len(), 1);
91        assert_eq!(diags[0].code, Some("E9999"));
92    }
93
94    #[test]
95    fn lint_trait_mock_silent() {
96        let block = parse("local x = 1");
97        assert!(NeverErrors.check(&block).is_empty());
98    }
99
100    // ── LintPipeline: target routing ─────────────────────────────────────────
101
102    #[test]
103    fn pipeline_lua51_runs_unsupported_guard() {
104        let block = parse("math.type(1)");
105        let diags = LintPipeline::default_for(LuaTarget::Lua51).run(&block);
106        assert!(diags.iter().any(|d| d.code == Some("E0101")));
107    }
108
109    #[test]
110    fn pipeline_luajit_runs_unsupported_guard() {
111        let block = parse("math.type(1)");
112        let diags = LintPipeline::default_for(LuaTarget::LuaJIT).run(&block);
113        assert!(diags.iter().any(|d| d.code == Some("E0101")));
114    }
115
116    #[test]
117    fn pipeline_all_targets_run_const_mutation() {
118        let block = parse("local x <const> = 1\nx = 2");
119        for target in [LuaTarget::Lua51, LuaTarget::LuaJIT] {
120            let diags = LintPipeline::default_for(target).run(&block);
121            assert!(
122                diags.iter().any(|d| d.code == Some("E0301")),
123                "{target:?} should detect E0301"
124            );
125        }
126    }
127
128    #[test]
129    fn pipeline_run_returns_diags_in_pass_order() {
130        let src = format!(
131            "local x <const> = 1\nx = 2\nmath.type(1)\nlocal y = {}",
132            i64::MAX
133        );
134        let diags = LintPipeline::default_for(LuaTarget::Lua51).run(&parse(&src));
135        let codes: Vec<_> = diags.iter().filter_map(|d| d.code).collect();
136        // E0301 from ConstMutation runs before E0101/E0102 from UnsupportedFeatureGuard
137        let e0301_pos = codes.iter().position(|&c| c == "E0301").unwrap();
138        let e0101_pos = codes.iter().position(|&c| c == "E0101").unwrap();
139        assert!(
140            e0301_pos < e0101_pos,
141            "E0301 should appear before E0101 (pass order)"
142        );
143        assert!(codes.contains(&"E0102"));
144    }
145
146    #[test]
147    fn pipeline_clean_code_no_diags() {
148        let block = parse("local x = 1\nlocal y = x + 2\nreturn y");
149        let diags = LintPipeline::default_for(LuaTarget::Lua51).run(&block);
150        assert!(diags.is_empty());
151    }
152
153    // ── CollectingReporter integration ────────────────────────────────────────
154
155    #[test]
156    fn pipeline_with_collecting_reporter() {
157        let src = "local x <const> = 1\nx = 2";
158        let diags = LintPipeline::default_for(LuaTarget::Lua51).run(&parse(src));
159        let mut reporter = CollectingReporter::default();
160        for d in &diags {
161            reporter.report(d, src, "test.lua");
162        }
163        assert!(reporter.has_errors());
164        assert!(reporter.diagnostics.iter().any(|d| d.code == Some("E0301")));
165    }
166
167    #[test]
168    fn pipeline_no_errors_reporter_clean() {
169        let src = "local x = 1";
170        let diags = LintPipeline::default_for(LuaTarget::Lua51).run(&parse(src));
171        let mut reporter = CollectingReporter::default();
172        for d in &diags {
173            reporter.report(d, src, "test.lua");
174        }
175        assert!(!reporter.has_errors());
176    }
177}