Skip to main content

mago_analyzer/
lib.rs

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