mago_analyzer/settings.rs
1use mago_algebra::AlgebraThresholds;
2use mago_codex::metadata::class_like::ClassLikeMetadata;
3use mago_codex::ttype::combiner::CombinerOptions;
4use mago_php_version::PHPVersion;
5use mago_word::Word;
6use mago_word::WordSet;
7use mago_word::ascii_lowercase_word;
8
9/// Default maximum logical formula size during conditional analysis.
10pub const DEFAULT_FORMULA_SIZE_THRESHOLD: u16 = 512;
11
12/// Default cap on the loop assignment-graph depth that the analyzer will
13/// explore when running fixed-point iteration over loop bodies.
14///
15/// The default of `1` means each loop body is re-analyzed at most once after
16/// the initial pass, which is sufficient to stabilise the vast majority of
17/// real-world code and keeps per-file cost bounded. Projects that care about
18/// maximally precise narrowing of long loop-carried dependency chains can
19/// raise this in their config at the cost of analysis time.
20pub const DEFAULT_LOOP_ASSIGNMENT_DEPTH_THRESHOLD: u8 = 1;
21
22/// Configuration settings that control the behavior of the Mago analyzer.
23///
24/// This struct allows you to enable/disable specific checks, suppress categories of issues,
25/// and tune the analyzer's performance and strictness.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Settings {
28 /// The target PHP version for the analysis.
29 pub version: PHPVersion,
30
31 /// Find and report expressions whose results are not used (e.g., `$a + $b;`). Defaults to `false`.
32 pub find_unused_expressions: bool,
33
34 /// Find and report unused definitions (e.g., private methods that are never called). Defaults to `false`.
35 pub find_unused_definitions: bool,
36
37 /// Warn when a function's declared return type contains a branch the body never actually returns
38 /// (e.g. `: string|false` on a function that always returns a string). Defaults to `false`.
39 pub find_overly_wide_return_types: bool,
40
41 /// Analyze code that appears to be unreachable. Defaults to `false`.
42 pub analyze_dead_code: bool,
43
44 /// Track the literal values of class properties when they are assigned.
45 /// This improves type inference but may increase memory usage. Defaults to `true`.
46 pub memoize_properties: bool,
47
48 /// Allow accessing array keys that may not be defined without reporting an issue. Defaults to `true`.
49 pub allow_possibly_undefined_array_keys: bool,
50
51 /// Enable checking for unhandled thrown exceptions.
52 ///
53 /// When `true`, the analyzer will report any exception that is thrown but not caught
54 /// in a `try-catch` block or documented in a `@throws` tag.
55 ///
56 /// This check is disabled by default (`false`) as it can be computationally expensive.
57 pub check_throws: bool,
58
59 /// Exceptions to ignore including all subclasses (hierarchy-aware).
60 ///
61 /// When an exception class is in this set, any exception of that class or any of its
62 /// subclasses will be ignored during `check_throws` analysis.
63 ///
64 /// For example, adding `LogicException` will ignore `LogicException`, `InvalidArgumentException`,
65 /// `OutOfBoundsException`, and all other subclasses.
66 pub unchecked_exceptions: WordSet,
67
68 /// Exceptions to ignore (exact class match only, not subclasses).
69 ///
70 /// When an exception class is in this set, only that exact class will be ignored
71 /// during `check_throws` analysis. Parent classes and subclasses are not affected.
72 pub unchecked_exception_classes: WordSet,
73
74 /// Check for missing `#[Override]` attributes on overriding methods.
75 ///
76 /// When enabled, the analyzer reports methods that override a parent method without
77 /// the `#[Override]` attribute (PHP 8.3+).
78 ///
79 /// Defaults to `true`.
80 pub check_missing_override: bool,
81
82 /// Find and report unused function/method parameters.
83 ///
84 /// When enabled, the analyzer reports parameters that are declared but never used
85 /// within the function body.
86 ///
87 /// Defaults to `true`.
88 pub find_unused_parameters: bool,
89
90 /// Enforce strict checks when accessing list elements by index.
91 ///
92 /// When `true`, the analyzer requires that any integer used to access a `list`
93 /// element is provably non-negative (e.g., of type `int<0, max>`). This helps
94 /// prevent potential runtime errors from using a negative index.
95 ///
96 /// When `false` (the default), any `int` is permitted as an index, offering
97 /// more flexibility at the cost of type safety.
98 pub strict_list_index_checks: bool,
99
100 /// Treat array/list indices that are not provably present as `T|null` and warn on access.
101 ///
102 /// When `true`, reading a key from any array-like type whose presence is not
103 /// guaranteed emits `possibly-undefined-int-array-index` /
104 /// `possibly-undefined-string-array-index` and the resulting type is widened to
105 /// `T|null`. This applies to `list<T>` (non-zero indices), non-required entries of
106 /// `array{...}` shapes, and `array<K, V>` lookups with arbitrary keys. It lets
107 /// `=== null`, `??`, and `??=` checks behave correctly against PHP's runtime
108 /// semantics — PHP turns missing reads into `null` with an `Undefined array key`
109 /// warning.
110 ///
111 /// When `false` (the default), the analyzer keeps the looser behavior: the value is
112 /// flagged as possibly-undefined internally but is not unioned with `null` and no
113 /// warning is emitted. This is friendlier for typical PHP code that destructures or
114 /// reads from arrays/lists by index without first asserting existence.
115 pub strict_array_index_existence: bool,
116
117 /// Allow arrays as operands of logical operators (`&&`, `||`, `xor`).
118 ///
119 /// When `true`, the analyzer accepts an array on either side of a logical operator
120 /// without emitting `invalid-operand`. PHP coerces empty arrays to `false` and
121 /// non-empty arrays to `true`, mirroring the truthiness used by `if ($array)`.
122 ///
123 /// When `false` (the default), the analyzer flags array operands of `&&`/`||`/`xor`
124 /// to call out the implicit `bool` coercion. This matches the long-standing default
125 /// behavior; standalone `if ($array)` is still accepted and never produces this warning.
126 pub allow_array_truthy_operand: bool,
127
128 /// Disable comparisons to boolean literals (`true`/`false`).
129 ///
130 /// When enabled, comparisons to boolean literals will not be reported as issues.
131 ///
132 /// Defaults to `false`.
133 pub no_boolean_literal_comparison: bool,
134
135 /// Enforce that concrete classes are declared `final`.
136 ///
137 /// When enabled, the analyzer reports a warning for any class that is not
138 /// `final`, `abstract`, or annotated with `@api`, provided the class has no children.
139 ///
140 /// Defaults to `false`.
141 pub enforce_class_finality: bool,
142
143 /// Require `@api` or `@internal` annotations on abstract classes, interfaces, and traits.
144 ///
145 /// When enabled, the analyzer reports a warning for any abstract class, interface,
146 /// or trait that is not annotated with either `@api` or `@internal`.
147 ///
148 /// Defaults to `false`.
149 pub require_api_or_internal: bool,
150
151 /// Check for missing type hints on parameters, properties, and return types.
152 ///
153 /// When enabled, the analyzer will report warnings for function parameters, class properties,
154 /// and function return types that lack explicit type declarations. The analyzer uses its
155 /// type system knowledge to avoid false positives - for instance, it won't require a type hint
156 /// on a property if adding one would conflict with a parent class or trait that has no type hint.
157 ///
158 /// Defaults to `false`.
159 pub check_missing_type_hints: bool,
160
161 /// Check for missing type hints (both parameters and return types) in closures when `check_missing_type_hints` is enabled.
162 ///
163 /// When `true`, closures (anonymous functions declared with `function() {}`) will be
164 /// checked for missing type hints. When `false`, closures are ignored, which is useful
165 /// because closures often rely on type inference.
166 ///
167 /// Defaults to `false`.
168 pub check_closure_missing_type_hints: bool,
169
170 /// Check for missing type hints (both parameters and return types) in arrow functions when `check_missing_type_hints` is enabled.
171 ///
172 /// When `true`, arrow functions (declared with `fn() => ...`) will be checked for missing
173 /// type hints. When `false`, arrow functions are ignored, which is useful because arrow
174 /// functions often rely on type inference and are typically short, making types obvious.
175 ///
176 /// Defaults to `false`.
177 pub check_arrow_function_missing_type_hints: bool,
178
179 /// Skip the missing-type-hint checks for closures and arrow functions used
180 /// directly as the right-hand side of the pipe operator (`|>`).
181 ///
182 /// When `true`, an inline pipe callable like
183 /// `$x |> fn($p) => strtoupper($p)` will not warn about its parameter or
184 /// return type being missing, even when `check-closure-missing-type-hints`
185 /// or `check-arrow-function-missing-type-hints` is on. The pipe operand's
186 /// type is enough to derive the parameter type, so requiring a hint here
187 /// is mostly noise.
188 ///
189 /// Defaults to `false`.
190 pub allow_implicit_pipe_callable_types: bool,
191
192 /// Register superglobals (e.g., `$_GET`, `$_POST`, `$_SERVER`) in the analysis context.
193 ///
194 /// If disabled, super globals won't be available unless explicitly imported using
195 /// the `global` keyword.
196 ///
197 /// Defaults to `true`.
198 pub register_super_globals: bool,
199
200 /// Enable colored output in terminal environments that support it. Defaults to `true`.
201 ///
202 /// This setting is primarily used for enabling/disabling colored diffs in
203 /// issue reports.
204 pub use_colors: bool,
205
206 /// **Internal use only.**
207 ///
208 /// Enables a diffing mode for incremental analysis, used by integrations like LSPs.
209 /// This avoids re-analyzing unchanged code in the same session. Defaults to `false`.
210 pub diff: bool,
211
212 /// Trust symbol existence checks to narrow types.
213 ///
214 /// When enabled, conditional checks like `method_exists()`, `property_exists()`,
215 /// `function_exists()`, and `defined()` will narrow the type within the conditional block,
216 /// suppressing errors for symbols that are verified to exist at runtime.
217 ///
218 /// When disabled, these checks are ignored and the analyzer requires explicit type hints,
219 /// which is stricter but may produce more false positives for dynamic code.
220 ///
221 /// Defaults to `true`.
222 pub trust_existence_checks: bool,
223
224 /// Method names treated as class initializers (like `__construct`).
225 ///
226 /// Properties initialized in these methods count as "definitely initialized"
227 /// just like in the constructor. This is useful for frameworks that use
228 /// lifecycle methods like `PHPUnit`'s `setUp()` or framework `boot()` methods.
229 ///
230 /// Entries can be either bare method names (applying to any class that has
231 /// that method) or qualified as `Fully\\Qualified\\Class::method` to scope
232 /// the rule to a specific class hierarchy.
233 ///
234 /// Example: `["setUp", "boot", "PHPUnit\\Framework\\TestCase::setUpBeforeClass"]`
235 ///
236 /// Defaults to empty (no additional initializers).
237 pub class_initializers: Vec<ClassInitializer>,
238
239 /// Enable property initialization checking (`missing-constructor`, `uninitialized-property`).
240 ///
241 /// When `false`, disables both `missing-constructor` and `uninitialized-property` issues
242 /// entirely. This is useful for projects that prefer to rely on runtime errors for
243 /// property initialization.
244 ///
245 /// Defaults to `false`.
246 pub check_property_initialization: bool,
247
248 /// Check for non-existent symbols in use statements.
249 ///
250 /// When enabled, the analyzer will report use statements that import symbols
251 /// (classes, interfaces, traits, enums, functions, or constants) that do not exist
252 /// in the codebase.
253 ///
254 /// Defaults to `false`.
255 pub check_use_statements: bool,
256
257 /// Check for usage of `@experimental` symbols from non-experimental contexts.
258 ///
259 /// When enabled, the analyzer reports warnings when a symbol marked `@experimental`
260 /// is used from a context that is not itself marked `@experimental`.
261 ///
262 /// Defaults to `false`.
263 pub check_experimental: bool,
264
265 /// Check for incorrect casing when referencing classes, interfaces, traits, enums,
266 /// and functions.
267 ///
268 /// When enabled, the analyzer reports warnings when a symbol is referenced with
269 /// different casing than its definition (e.g., `new fooBar()` when defined as `FooBar`).
270 /// This helps prevent autoloading failures on case-sensitive file systems.
271 ///
272 /// Defaults to `false`.
273 pub check_name_casing: bool,
274
275 /// Whether to allow calls to impure functions inside conditions.
276 ///
277 /// When set to `false`, any call to a function not marked `@pure` or
278 /// `@mutation-free` inside an `if`, `while`, `for`, ternary, or `match`
279 /// condition is reported. This helps catch surprising evaluation-order
280 /// bugs where a side effect in one part of a condition silently alters
281 /// a variable used in another part.
282 ///
283 /// Defaults to `true` (impure calls in conditions are allowed).
284 pub allow_side_effects_in_conditions: bool,
285
286 // Performance tuning thresholds
287 // Higher values allow deeper analysis at the cost of performance.
288 // Lower values improve speed but may reduce precision on complex code.
289 /// Maximum number of clauses to process during CNF saturation.
290 ///
291 /// Controls how many clauses the simplification algorithm will work with.
292 /// If exceeded, saturation returns an empty result to avoid performance issues.
293 ///
294 /// Defaults to `8192`.
295 pub saturation_complexity_threshold: u16,
296
297 /// Maximum number of clauses per side in disjunction operations.
298 ///
299 /// Controls the complexity limit for OR operations between clause sets.
300 /// If either side exceeds this, the disjunction returns an empty result.
301 ///
302 /// Defaults to `4096`.
303 pub disjunction_complexity_threshold: u16,
304
305 /// Maximum cumulative complexity during formula negation.
306 ///
307 /// Controls how complex the negation of a formula can become.
308 /// If exceeded, negation gives up to avoid exponential blowup.
309 ///
310 /// Defaults to `4096`.
311 pub negation_complexity_threshold: u16,
312
313 /// Upper limit for consensus optimization during saturation.
314 ///
315 /// Controls when the consensus rule is applied during saturation.
316 /// Only applies when clause count is between 3 and this limit.
317 ///
318 /// Defaults to `256`.
319 pub consensus_limit_threshold: u16,
320
321 /// Maximum logical formula size during conditional analysis.
322 ///
323 /// Limits the size of generated formulas to prevent exponential blowup
324 /// in deeply nested conditionals.
325 ///
326 /// Defaults to `512`.
327 pub formula_size_threshold: u16,
328
329 /// Maximum number of literal strings to track before generalizing.
330 ///
331 /// When combining types with many different literal string values, tracking each
332 /// literal individually causes O(n) memory and O(n²) comparison time.
333 /// Once the threshold is exceeded, we generalize to the base string type.
334 ///
335 /// Defaults to `128`.
336 pub string_combination_threshold: u16,
337
338 /// Maximum number of literal integers to track before generalizing.
339 ///
340 /// When combining types with many different literal integer values, tracking each
341 /// literal individually causes O(n) memory and O(n²) comparison time.
342 /// Once the threshold is exceeded, we generalize to the base int type.
343 ///
344 /// Defaults to `128`.
345 pub integer_combination_threshold: u16,
346
347 /// Maximum number of array elements to track individually.
348 ///
349 /// When building array types through repeated push operations (`$arr[] = ...`),
350 /// this limits how many individual elements are tracked before generalizing
351 /// to a simpler array type. This prevents memory explosion on files with
352 /// thousands of array pushes.
353 ///
354 /// Defaults to `128`.
355 pub array_combination_threshold: u16,
356
357 /// Maximum depth of the loop assignment dependency graph that the fixed-point
358 /// analyzer will explore when re-analysing loop bodies.
359 ///
360 /// The analyzer uses fixed-point iteration to propagate widened types along
361 /// loop-carried dependency chains. A chain of length `N` can require up to
362 /// `N` extra passes for the type at the end of the chain to fully stabilise,
363 /// and each pass re-analyses the entire loop body. On large, complex loops
364 /// (think thousand-line procedural functions with deeply nested conditionals)
365 /// the per-pass cost dominates file analysis time.
366 ///
367 /// The default of `1` means each loop body is re-analysed at most once after
368 /// the initial pass; enough to stabilise virtually all real-world code while
369 /// keeping analysis cost bounded. Projects that require maximally precise
370 /// narrowing of long loop-carried chains can raise this value (typically to
371 /// `2` or `3`) at the cost of significantly slower analysis on complex files.
372 ///
373 /// Setting this to `0` disables fixed-point iteration entirely and analyses
374 /// each loop body exactly once. This is the fastest option but may produce
375 /// less precise types for variables that depend on themselves across
376 /// iterations.
377 ///
378 /// Defaults to `1`.
379 pub loop_assignment_depth_threshold: u8,
380}
381
382impl Default for Settings {
383 fn default() -> Self {
384 Self::new(PHPVersion::LATEST)
385 }
386}
387
388impl Settings {
389 #[must_use]
390 pub fn new(version: PHPVersion) -> Self {
391 let default_thresholds = AlgebraThresholds::default();
392 let default_combiner_options = CombinerOptions::default();
393
394 Self {
395 version,
396 find_unused_expressions: true,
397 find_unused_definitions: true,
398 find_overly_wide_return_types: false,
399 analyze_dead_code: false,
400 memoize_properties: true,
401 allow_possibly_undefined_array_keys: true,
402 check_throws: false,
403 unchecked_exceptions: WordSet::default(),
404 unchecked_exception_classes: WordSet::default(),
405 use_colors: true,
406 check_missing_override: false,
407 find_unused_parameters: false,
408 strict_list_index_checks: false,
409 strict_array_index_existence: false,
410 allow_array_truthy_operand: false,
411 no_boolean_literal_comparison: false,
412 enforce_class_finality: false,
413 require_api_or_internal: false,
414 check_missing_type_hints: false,
415 check_closure_missing_type_hints: false,
416 check_arrow_function_missing_type_hints: false,
417 allow_implicit_pipe_callable_types: false,
418 register_super_globals: true,
419 diff: false,
420 trust_existence_checks: true,
421 class_initializers: Vec::new(),
422 check_property_initialization: false,
423 check_use_statements: false,
424 check_experimental: false,
425 check_name_casing: false,
426 allow_side_effects_in_conditions: true,
427 saturation_complexity_threshold: default_thresholds.saturation_complexity,
428 disjunction_complexity_threshold: default_thresholds.disjunction_complexity,
429 negation_complexity_threshold: default_thresholds.negation_complexity,
430 consensus_limit_threshold: default_thresholds.consensus_limit,
431 formula_size_threshold: DEFAULT_FORMULA_SIZE_THRESHOLD,
432 string_combination_threshold: default_combiner_options.string_combination_threshold,
433 integer_combination_threshold: default_combiner_options.integer_combination_threshold,
434 array_combination_threshold: default_combiner_options.array_combination_threshold,
435 loop_assignment_depth_threshold: DEFAULT_LOOP_ASSIGNMENT_DEPTH_THRESHOLD,
436 }
437 }
438
439 /// Returns the algebra thresholds derived from the settings.
440 #[must_use]
441 pub fn algebra_thresholds(&self) -> AlgebraThresholds {
442 AlgebraThresholds {
443 saturation_complexity: self.saturation_complexity_threshold,
444 disjunction_complexity: self.disjunction_complexity_threshold,
445 negation_complexity: self.negation_complexity_threshold,
446 consensus_limit: self.consensus_limit_threshold,
447 }
448 }
449
450 /// Returns the combiner options derived from the settings.
451 #[must_use]
452 pub fn combiner_options(&self) -> CombinerOptions {
453 CombinerOptions {
454 overwrite_empty_array: false,
455 array_combination_threshold: self.array_combination_threshold,
456 string_combination_threshold: self.string_combination_threshold,
457 integer_combination_threshold: self.integer_combination_threshold,
458 }
459 }
460
461 /// Returns `true` when `method_name` is a configured class initializer
462 /// applicable to `meta` (either an unrestricted entry, or one whose class
463 /// qualifier `meta` is a subclass/implementer of).
464 #[must_use]
465 pub fn is_class_initializer_for(&self, meta: &ClassLikeMetadata, method_name: Word) -> bool {
466 self.class_initializers.iter().any(|init| init.method == method_name && init.applies_to(meta))
467 }
468
469 /// Iterator over initializer method names applicable to `meta`.
470 pub fn applicable_class_initializers<'cfg>(
471 &'cfg self,
472 meta: &'cfg ClassLikeMetadata,
473 ) -> impl Iterator<Item = Word> + 'cfg {
474 self.class_initializers.iter().filter(move |init| init.applies_to(meta)).map(|init| init.method)
475 }
476}
477
478/// A class-initializer entry: a method that, when present on a class, marks
479/// any properties it assigns as definitely initialized.
480///
481/// The optional `class` qualifier restricts the rule to a specific class hierarchy.
482#[derive(Debug, Clone, PartialEq, Eq, Hash)]
483pub struct ClassInitializer {
484 /// Lowercased FQN of the class/interface this entry is scoped to. `None`
485 /// means the entry applies to any class that has the named method.
486 pub class: Option<Word>,
487 /// Lowercased method name.
488 pub method: Word,
489}
490
491impl ClassInitializer {
492 /// Parse `"Class::method"` or `"method"`. Returns `None` for empty input
493 /// or empty halves.
494 #[must_use]
495 pub fn parse(raw: &str) -> Option<Self> {
496 match raw.split_once("::") {
497 Some((class, method)) => {
498 let class = class.trim_start_matches('\\').trim();
499 let method = method.trim();
500 if class.is_empty() || method.is_empty() {
501 return None;
502 }
503
504 Some(Self {
505 class: Some(ascii_lowercase_word(class.as_bytes())),
506 method: ascii_lowercase_word(method.as_bytes()),
507 })
508 }
509 None => {
510 let method = raw.trim();
511 if method.is_empty() {
512 return None;
513 }
514 Some(Self { class: None, method: ascii_lowercase_word(method.as_bytes()) })
515 }
516 }
517 }
518
519 /// `true` when this entry's class qualifier (if any) covers `meta`.
520 #[must_use]
521 pub fn applies_to(&self, meta: &ClassLikeMetadata) -> bool {
522 let Some(class) = self.class else { return true };
523 meta.name == class || meta.all_parent_classes.contains(&class) || meta.all_parent_interfaces.contains(&class)
524 }
525}