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