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 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 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}