Skip to main content

mago_analyzer/
lib.rs

1#![allow(clippy::too_many_arguments)]
2
3use bumpalo::Bump;
4
5use mago_codex::context::ScopeContext;
6use mago_codex::metadata::CodebaseMetadata;
7use mago_collector::Collector;
8use mago_database::file::File;
9use mago_names::ResolvedNames;
10use mago_span::HasSpan;
11use mago_syntax::ast::Program;
12
13use crate::analysis_result::AnalysisResult;
14use crate::artifacts::AnalysisArtifacts;
15use crate::context::Context;
16use crate::context::block::BlockContext;
17use crate::error::AnalysisError;
18use crate::plugin::PluginRegistry;
19use crate::plugin::context::HookContext;
20use crate::plugin::hook::HookAction;
21use crate::settings::Settings;
22use crate::statement::analyze_statements;
23
24pub mod analysis_result;
25pub mod code;
26pub mod error;
27pub mod plugin;
28pub mod settings;
29
30mod analyzable;
31mod artifacts;
32mod assertion;
33mod common;
34mod context;
35mod expression;
36mod formula;
37mod invocation;
38mod reconciler;
39mod resolver;
40mod statement;
41mod utils;
42mod visibility;
43
44const COLLECTOR_CATEGORIES: &[&str] = &["analysis", "analyzer", "analyser"];
45
46#[derive(Debug)]
47pub struct Analyzer<'ctx, 'ast, 'arena> {
48    pub arena: &'arena Bump,
49    pub source_file: &'ctx File,
50    pub resolved_names: &'ast ResolvedNames<'arena>,
51    pub codebase: &'ctx CodebaseMetadata,
52    pub settings: Settings,
53    pub plugin_registry: &'ctx PluginRegistry,
54}
55
56impl<'ctx, 'ast, 'arena> Analyzer<'ctx, 'ast, 'arena> {
57    pub fn new(
58        arena: &'arena Bump,
59        source_file: &'ctx File,
60        resolved_names: &'ast ResolvedNames<'arena>,
61        codebase: &'ctx CodebaseMetadata,
62        plugin_registry: &'ctx PluginRegistry,
63        settings: Settings,
64    ) -> Self {
65        Self { arena, source_file, resolved_names, codebase, plugin_registry, settings }
66    }
67
68    pub fn analyze(
69        &self,
70        program: &'ast Program<'arena>,
71        analysis_result: &mut AnalysisResult,
72    ) -> Result<(), AnalysisError> {
73        #[cfg(not(target_arch = "wasm32"))]
74        let start_time = std::time::Instant::now();
75
76        if !program.has_script() {
77            #[cfg(not(target_arch = "wasm32"))]
78            {
79                analysis_result.time_in_analysis = start_time.elapsed();
80            }
81
82            return Ok(());
83        }
84
85        let statements = program.statements.as_slice();
86
87        let mut collector = Collector::new(self.arena, self.source_file, program, COLLECTOR_CATEGORIES);
88        if self.settings.diff {
89            collector.set_skip_unfulfilled_expect(true);
90        }
91
92        let mut context = Context::new(
93            self.arena,
94            self.codebase,
95            self.source_file,
96            self.resolved_names,
97            &self.settings,
98            statements[0].span(),
99            program.trivia.as_slice(),
100            collector,
101            self.plugin_registry,
102        );
103
104        let mut block_context = BlockContext::new(ScopeContext::new(), context.settings.register_super_globals);
105        let mut artifacts = AnalysisArtifacts::new();
106
107        if self.plugin_registry.has_program_hooks() {
108            let mut hook_context = HookContext::new(context.codebase, &mut block_context, &mut artifacts);
109
110            if let HookAction::Skip =
111                self.plugin_registry.before_program(self.source_file, program, &mut hook_context)?
112            {
113                for reported in hook_context.take_issues() {
114                    context.collector.report_with_code(reported.code, reported.issue);
115                }
116
117                context.finish(artifacts, analysis_result);
118
119                #[cfg(not(target_arch = "wasm32"))]
120                {
121                    analysis_result.time_in_analysis = start_time.elapsed();
122                }
123
124                return Ok(());
125            }
126
127            for reported in hook_context.take_issues() {
128                context.collector.report_with_code(reported.code, reported.issue);
129            }
130        }
131
132        analyze_statements(statements, &mut context, &mut block_context, &mut artifacts)?;
133
134        // Call after_program hooks
135        if self.plugin_registry.has_program_hooks() {
136            let mut hook_context = HookContext::new(context.codebase, &mut block_context, &mut artifacts);
137            self.plugin_registry.after_program(self.source_file, program, &mut hook_context)?;
138            for reported in hook_context.take_issues() {
139                context.collector.report_with_code(reported.code, reported.issue);
140            }
141        }
142
143        context.finish(artifacts, analysis_result);
144
145        // Filter issues through registered issue filter hooks
146        if self.plugin_registry.has_issue_filter_hooks() {
147            analysis_result.issues =
148                self.plugin_registry.filter_issues(self.source_file, std::mem::take(&mut analysis_result.issues));
149        }
150
151        #[cfg(not(target_arch = "wasm32"))]
152        {
153            analysis_result.time_in_analysis = start_time.elapsed();
154        }
155
156        Ok(())
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use std::borrow::Cow;
163    use std::collections::BTreeMap;
164
165    use ahash::HashSet;
166
167    use mago_atom::AtomSet;
168    use mago_codex::metadata::CodebaseMetadata;
169    use mago_codex::populator::populate_codebase;
170    use mago_codex::reference::SymbolReferences;
171    use mago_codex::scanner::scan_program;
172    use mago_database::file::File;
173    use mago_names::resolver::NameResolver;
174    use mago_syntax::parser::parse_file;
175
176    use crate::Analyzer;
177    use crate::analysis_result::AnalysisResult;
178    use crate::code::IssueCode;
179    use crate::plugin::PluginRegistry;
180    use crate::settings::Settings;
181
182    #[derive(Debug, Clone)]
183    pub struct TestCase {
184        name: &'static str,
185        content: &'static str,
186        settings: Settings,
187        expected_issues: Vec<IssueCode>,
188    }
189
190    impl TestCase {
191        pub fn new(name: &'static str, content: &'static str) -> Self {
192            Self {
193                name,
194                content,
195                settings: Settings {
196                    find_unused_expressions: true,
197                    find_unused_definitions: true,
198                    ..Default::default()
199                },
200                expected_issues: vec![],
201            }
202        }
203
204        pub fn settings(mut self, settings: Settings) -> Self {
205            self.settings = settings;
206            self
207        }
208
209        pub fn expect_success(mut self) -> Self {
210            self.expected_issues = vec![];
211            self
212        }
213
214        pub fn expect_issues(mut self, codes: Vec<IssueCode>) -> Self {
215            self.expected_issues = codes;
216            self
217        }
218
219        pub fn run(self) {
220            run_test_case_inner(self);
221        }
222    }
223
224    fn run_test_case_inner(config: TestCase) {
225        let arena = bumpalo::Bump::new();
226        let source_file = File::ephemeral(Cow::Borrowed(config.name), Cow::Borrowed(config.content));
227
228        let (program, parse_issues) = parse_file(&arena, &source_file);
229        assert!(parse_issues.is_none(), "Test '{}' failed during parsing:\n{:#?}", config.name, parse_issues);
230
231        let resolver = NameResolver::new(&arena);
232        let resolved_names = resolver.resolve(program);
233        let mut codebase = scan_program(&arena, &source_file, program, &resolved_names);
234        let mut symbol_references = SymbolReferences::new();
235
236        populate_codebase(&mut codebase, &mut symbol_references, AtomSet::default(), HashSet::default());
237
238        let plugin_registry = PluginRegistry::with_library_providers();
239
240        let mut analysis_result = AnalysisResult::new(symbol_references);
241        let analyzer =
242            Analyzer::new(&arena, &source_file, &resolved_names, &codebase, &plugin_registry, config.settings);
243
244        let analysis_run_result = analyzer.analyze(program, &mut analysis_result);
245
246        if let Err(err) = analysis_run_result {
247            panic!("Test '{}': Expected analysis to succeed, but it failed with an error: {}", config.name, err);
248        }
249
250        verify_reported_issues(config.name, analysis_result, codebase, &config.expected_issues);
251    }
252
253    fn verify_reported_issues(
254        test_name: &str,
255        mut analysis_result: AnalysisResult,
256        mut codebase: CodebaseMetadata,
257        expected_issue_codes: &[IssueCode],
258    ) {
259        let mut actual_issues_collected = std::mem::take(&mut analysis_result.issues);
260
261        actual_issues_collected.extend(codebase.take_issues(true));
262
263        let actual_issues_count = actual_issues_collected.len();
264        let mut expected_issue_counts: BTreeMap<&str, usize> = BTreeMap::new();
265        for kind in expected_issue_codes {
266            *expected_issue_counts.entry(kind.as_str()).or_insert(0) += 1;
267        }
268
269        let mut actual_issue_counts: BTreeMap<String, usize> = BTreeMap::new();
270        for actual_issue in &actual_issues_collected {
271            let Some(issue_code) = actual_issue.code.clone() else {
272                panic!("Analyzer returned an issue with no code: {actual_issue:?}");
273            };
274
275            *actual_issue_counts.entry(issue_code).or_insert(0) += 1;
276        }
277
278        let mut discrepancies = Vec::new();
279
280        for (actual_kind, &actual_count) in &actual_issue_counts {
281            let expected_count = expected_issue_counts.get(actual_kind.as_str()).copied().unwrap_or(0);
282            if actual_count > expected_count {
283                discrepancies.push(format!(
284                    "- Unexpected issue(s) of kind `{}`: found {}, expected {}.",
285                    actual_kind.as_str(),
286                    actual_count,
287                    expected_count
288                ));
289            }
290        }
291
292        for (expected_kind, expected_count) in expected_issue_counts {
293            let actual_count = actual_issue_counts.get(expected_kind).copied().unwrap_or(0);
294            if actual_count < expected_count {
295                discrepancies.push(format!(
296                    "- Missing expected issue(s) of kind `{expected_kind}`: expected {expected_count}, found {actual_count}.",
297                ));
298            }
299        }
300
301        if !discrepancies.is_empty() {
302            let mut panic_message = format!("Test '{test_name}' failed with issue discrepancies:\n");
303            for d in discrepancies {
304                panic_message.push_str(&format!("  {d}\n"));
305            }
306
307            panic!("{}", panic_message);
308        }
309
310        if expected_issue_codes.is_empty() && actual_issues_count != 0 {
311            let mut panic_message = format!("Test '{test_name}': Expected no issues, but found:\n");
312            for issue in actual_issues_collected {
313                panic_message.push_str(&format!(
314                    "  - Code: `{}`, Message: \"{}\"\n",
315                    issue.code.unwrap_or_default(),
316                    issue.message
317                ));
318            }
319
320            panic!("{}", panic_message);
321        }
322    }
323
324    #[macro_export]
325    macro_rules! test_analysis {
326        (name = $test_name:ident, code = $code_str:expr $(,)?) => {
327            #[test]
328            pub fn $test_name() {
329                $crate::tests::TestCase::new(stringify!($test_name), $code_str).expect_success().run();
330            }
331        };
332        (name = $test_name:ident, settings = $settings:expr, code = $code_str:expr $(,)?) => {
333            #[test]
334            pub fn $test_name() {
335                $crate::tests::TestCase::new(stringify!($test_name), $code_str).settings($settings).expect_success().run();
336            }
337        };
338        (name = $test_name:ident, code = $code_str:expr, issues = [$($issue_kind:expr),* $(,)?] $(,)?) => {
339            #[test]
340            pub fn $test_name() {
341                $crate::tests::TestCase::new(stringify!($test_name), $code_str)
342                    .expect_issues(vec![$($issue_kind),*])
343                    .run();
344            }
345        };
346        (name = $test_name:ident, settings = $settings:expr, code = $code_str:expr, issues = [$($issue_kind:expr),* $(,)?] $(,)?) => {
347            #[test]
348            pub fn $test_name() {
349                $crate::tests::TestCase::new(stringify!($test_name), $code_str)
350                    .settings($settings)
351                    .expect_issues(vec![$($issue_kind),*])
352                    .run();
353            }
354        };
355    }
356}