Skip to main content

mago_codex/scanner/
mod.rs

1use mago_allocator::Arena;
2
3use mago_database::file::File;
4use mago_names::ResolvedNames;
5use mago_names::scope::NamespaceScope;
6use mago_php_version::PHPVersion;
7use mago_span::HasSpan;
8use mago_syntax::comments::docblock::get_docblock_for_node;
9use mago_syntax::cst::AnonymousClass;
10use mago_syntax::cst::ArrowFunction;
11use mago_syntax::cst::Call;
12use mago_syntax::cst::Class;
13use mago_syntax::cst::Closure;
14use mago_syntax::cst::Constant;
15use mago_syntax::cst::Enum;
16use mago_syntax::cst::Expression;
17use mago_syntax::cst::Function;
18use mago_syntax::cst::FunctionCall;
19use mago_syntax::cst::If;
20use mago_syntax::cst::IfBody;
21use mago_syntax::cst::Interface;
22use mago_syntax::cst::Method;
23use mago_syntax::cst::Namespace;
24use mago_syntax::cst::Program;
25use mago_syntax::cst::Trait;
26use mago_syntax::cst::Trivia;
27use mago_syntax::cst::UnaryPrefix;
28use mago_syntax::cst::UnaryPrefixOperator;
29use mago_syntax::cst::Use;
30use mago_syntax::walker::MutWalker;
31use mago_syntax::walker::walk_anonymous_class_mut;
32use mago_syntax::walker::walk_class_mut;
33use mago_syntax::walker::walk_enum_mut;
34use mago_syntax::walker::walk_interface_mut;
35use mago_syntax::walker::walk_trait_mut;
36use mago_word::Word;
37use mago_word::WordMap;
38use mago_word::WordSet;
39use mago_word::ascii_lowercase_word;
40use mago_word::empty_word;
41use mago_word::word;
42
43use crate::identifier::method::MethodIdentifier;
44use crate::metadata::CodebaseMetadata;
45use crate::metadata::flags::MetadataFlags;
46use crate::metadata::function_like::FunctionLikeKind;
47use crate::metadata::function_like::FunctionLikeMetadata;
48use crate::scanner::class_like::register_anonymous_class;
49use crate::scanner::class_like::register_class;
50use crate::scanner::class_like::register_enum;
51use crate::scanner::class_like::register_interface;
52use crate::scanner::class_like::register_trait;
53use crate::scanner::constant::scan_constant;
54use crate::scanner::constant::scan_defined_constant;
55use crate::scanner::function_like::scan_arrow_function;
56use crate::scanner::function_like::scan_closure;
57use crate::scanner::function_like::scan_function;
58use crate::scanner::function_like::scan_method;
59use crate::scanner::property::scan_promoted_property;
60use crate::ttype::resolution::TypeResolutionContext;
61use crate::ttype::template::GenericTemplate;
62
63mod assertion_inference;
64mod attribute;
65mod class_like;
66mod class_like_constant;
67mod constant;
68mod docblock;
69mod enum_case;
70mod function_like;
71
72pub mod inference;
73
74mod parameter;
75mod property;
76mod ttype;
77mod version_claim;
78
79/// Scans a parsed PHP program into a [`CodebaseMetadata`] snapshot, gating
80/// each `Mago\AvailableSince` / `Mago\AvailableUntil` symbol against the
81/// configured PHP version on the way in.
82///
83/// Items whose claims exclude `version` are simply not inserted,
84/// so the resulting metadata is already version-correct and downstream
85/// consumers don't need a separate filter pass.
86#[inline]
87pub fn scan_program<'arena, 'ctx, A>(
88    arena: &'arena A,
89    file: &'ctx File,
90    program: &'arena Program<'arena>,
91    resolved_names: &'ctx ResolvedNames<'arena>,
92    php_version: PHPVersion,
93) -> CodebaseMetadata
94where
95    A: Arena,
96{
97    let mut context = Context::new(arena, file, program, resolved_names, php_version);
98    let mut scanner = Scanner::new();
99
100    scanner.walk_program(program, &mut context);
101
102    scanner.codebase
103}
104
105#[derive(Clone, Debug)]
106struct Context<'ctx, 'arena, A> {
107    pub arena: &'arena A,
108    pub file: &'ctx File,
109    pub program: &'arena Program<'arena>,
110    pub resolved_names: &'arena ResolvedNames<'arena>,
111    /// PHP version configured for this scan, used to evaluate `Mago\*`
112    /// version-gating attributes inline.
113    pub php_version: PHPVersion,
114}
115
116impl<'ctx, 'arena, A> Context<'ctx, 'arena, A>
117where
118    A: Arena,
119{
120    pub fn new(
121        arena: &'arena A,
122        file: &'ctx File,
123        program: &'arena Program<'arena>,
124        resolved_names: &'arena ResolvedNames<'arena>,
125        php_version: PHPVersion,
126    ) -> Self {
127        Self { arena, file, program, resolved_names, php_version }
128    }
129
130    pub fn get_docblock(&self, node: impl HasSpan) -> Option<&'arena Trivia<'arena>> {
131        get_docblock_for_node(self.program, node)
132    }
133}
134
135type TemplateConstraint = (Word, GenericTemplate);
136type TemplateConstraintList = Vec<TemplateConstraint>;
137
138#[derive(Debug, Default)]
139struct Scanner {
140    codebase: CodebaseMetadata,
141    stack: Vec<Word>,
142    template_constraints: Vec<TemplateConstraintList>,
143    scope: NamespaceScope,
144    has_constructor: bool,
145    file_type_aliases: WordSet,
146    file_imported_aliases: WordMap<(Word, Word)>,
147    polyfill_depth: u32,
148}
149
150#[derive(Debug, Clone, Copy, Eq, PartialEq)]
151enum PolyfillGuardBranch {
152    Then,
153    Else,
154    None,
155}
156
157const POLYFILL_GUARD_FUNCTIONS: &[&[u8]] =
158    &[b"class_exists", b"interface_exists", b"trait_exists", b"enum_exists", b"function_exists", b"defined"];
159
160fn classify_polyfill_guard(cond: &Expression<'_>) -> PolyfillGuardBranch {
161    let cond = cond.unparenthesized();
162
163    if is_polyfill_existence_check(cond) {
164        return PolyfillGuardBranch::Else;
165    }
166
167    if let Expression::UnaryPrefix(UnaryPrefix { operator: UnaryPrefixOperator::Not(_), operand }) = cond
168        && is_polyfill_existence_check(operand.unparenthesized())
169    {
170        return PolyfillGuardBranch::Then;
171    }
172
173    PolyfillGuardBranch::None
174}
175
176/// Returns true if `expr` is a call to one of the recognized existence-check
177/// functions (regardless of whether it's written as `class_exists` or
178/// `\class_exists` — we just look at the trailing segment).
179fn is_polyfill_existence_check(expr: &Expression<'_>) -> bool {
180    let Expression::Call(Call::Function(FunctionCall { function, .. })) = expr.unparenthesized() else {
181        return false;
182    };
183    let Expression::Identifier(identifier) = function.unparenthesized() else {
184        return false;
185    };
186    let last = identifier.last_segment();
187    POLYFILL_GUARD_FUNCTIONS.iter().any(|name| last.eq_ignore_ascii_case(name))
188}
189
190impl Scanner {
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    fn get_current_type_resolution_context(&self) -> TypeResolutionContext {
196        let mut context = TypeResolutionContext::new();
197        context = context.with_type_aliases(self.file_type_aliases.clone());
198
199        for (local_name, (source_class, original_name)) in &self.file_imported_aliases {
200            context = context.with_imported_type_alias(*local_name, *source_class, *original_name);
201        }
202
203        for template_constraint_list in self.template_constraints.iter().rev() {
204            for (name, constraint) in template_constraint_list {
205                if !context.has_template_definition(*name) {
206                    context = context.with_template_definition(*name, vec![constraint.clone()]);
207                }
208            }
209        }
210
211        context
212    }
213
214    fn apply_polyfill_flag_to_class_like(&mut self, id: Word) {
215        if self.polyfill_depth == 0 {
216            return;
217        }
218
219        if let Some(metadata) = self.codebase.class_likes.get_mut(&id) {
220            metadata.flags |= MetadataFlags::POLYFILL;
221        }
222    }
223}
224
225#[allow(clippy::expect_used)]
226impl<'ctx, 'arena, A> MutWalker<'arena, 'arena, Context<'ctx, 'arena, A>> for Scanner
227where
228    A: Arena,
229{
230    #[inline]
231    fn walk_in_namespace(&mut self, namespace: &'arena Namespace<'arena>, _context: &mut Context<'ctx, 'arena, A>) {
232        self.scope = match &namespace.name {
233            Some(name) => NamespaceScope::for_namespace(name.value()),
234            None => NamespaceScope::global(),
235        };
236    }
237
238    #[inline]
239    fn walk_out_namespace(&mut self, _namespace: &'arena Namespace<'arena>, _context: &mut Context<'ctx, 'arena, A>) {
240        self.scope = NamespaceScope::global();
241    }
242
243    #[inline]
244    fn walk_in_use(&mut self, r#use: &'arena Use<'arena>, _context: &mut Context<'ctx, 'arena, A>) {
245        self.scope.populate_from_use(r#use);
246    }
247
248    fn walk_if(&mut self, r#if: &'arena If<'arena>, context: &mut Context<'ctx, 'arena, A>) {
249        self.walk_keyword(&r#if.r#if, context);
250        self.walk_expression(r#if.condition, context);
251
252        let guard = classify_polyfill_guard(r#if.condition);
253
254        match &r#if.body {
255            IfBody::Statement(body) => {
256                let then_polyfill = matches!(guard, PolyfillGuardBranch::Then);
257                if then_polyfill {
258                    self.polyfill_depth = self.polyfill_depth.saturating_add(1);
259                }
260                self.walk_statement(body.statement, context);
261                if then_polyfill {
262                    self.polyfill_depth = self.polyfill_depth.saturating_sub(1);
263                }
264
265                for else_if_clause in &body.else_if_clauses {
266                    self.walk_if_statement_body_else_if_clause(else_if_clause, context);
267                }
268
269                if let Some(else_clause) = &body.else_clause {
270                    let else_polyfill = matches!(guard, PolyfillGuardBranch::Else);
271                    if else_polyfill {
272                        self.polyfill_depth = self.polyfill_depth.saturating_add(1);
273                    }
274                    self.walk_if_statement_body_else_clause(else_clause, context);
275                    if else_polyfill {
276                        self.polyfill_depth = self.polyfill_depth.saturating_sub(1);
277                    }
278                }
279            }
280            IfBody::ColonDelimited(body) => {
281                let then_polyfill = matches!(guard, PolyfillGuardBranch::Then);
282                if then_polyfill {
283                    self.polyfill_depth = self.polyfill_depth.saturating_add(1);
284                }
285                for statement in &body.statements {
286                    self.walk_statement(statement, context);
287                }
288                if then_polyfill {
289                    self.polyfill_depth = self.polyfill_depth.saturating_sub(1);
290                }
291
292                for else_if_clause in &body.else_if_clauses {
293                    self.walk_if_colon_delimited_body_else_if_clause(else_if_clause, context);
294                }
295
296                if let Some(else_clause) = &body.else_clause {
297                    let else_polyfill = matches!(guard, PolyfillGuardBranch::Else);
298                    if else_polyfill {
299                        self.polyfill_depth = self.polyfill_depth.saturating_add(1);
300                    }
301                    self.walk_if_colon_delimited_body_else_clause(else_clause, context);
302                    if else_polyfill {
303                        self.polyfill_depth = self.polyfill_depth.saturating_sub(1);
304                    }
305                }
306
307                self.walk_keyword(&body.endif, context);
308                self.walk_terminator(&body.terminator, context);
309            }
310        }
311    }
312
313    #[inline]
314    fn walk_in_function(&mut self, function: &'arena Function<'arena>, context: &mut Context<'ctx, 'arena, A>) {
315        let type_context = self.get_current_type_resolution_context();
316
317        let name = ascii_lowercase_word(context.resolved_names.get(&function.name));
318        let identifier = (empty_word(), name);
319        let Some(mut metadata) = scan_function(
320            identifier,
321            function,
322            self.stack.last().copied(),
323            context,
324            &mut self.scope,
325            type_context,
326            Some(&self.codebase.constants),
327        ) else {
328            // Push an empty frame so the matching `walk_out_function` pop balances.
329            self.template_constraints.push(vec![]);
330            return;
331        };
332
333        self.template_constraints.push({
334            let mut constraints: TemplateConstraintList = vec![];
335            for (template_name, template_constraints) in &metadata.template_types {
336                constraints.push((*template_name, template_constraints.clone()));
337            }
338
339            constraints
340        });
341
342        if self.polyfill_depth > 0 {
343            metadata.flags |= MetadataFlags::POLYFILL;
344        }
345
346        self.codebase.function_likes.entry(identifier).or_insert(metadata);
347    }
348
349    #[inline]
350    fn walk_out_function(&mut self, _function: &'arena Function<'arena>, _context: &mut Context<'ctx, 'arena, A>) {
351        self.template_constraints.pop().expect("Expected template stack to be non-empty");
352    }
353
354    #[inline]
355    fn walk_in_closure(&mut self, closure: &'arena Closure<'arena>, context: &mut Context<'ctx, 'arena, A>) {
356        let span = closure.span();
357
358        let synthetic = crate::build_synthetic_name("closure", context.file, span);
359        let identifier = (empty_word(), synthetic);
360
361        let type_resolution_context = self.get_current_type_resolution_context();
362        let metadata = scan_closure(
363            identifier,
364            closure,
365            self.stack.last().copied(),
366            context,
367            &mut self.scope,
368            type_resolution_context,
369        );
370
371        self.template_constraints.push({
372            let mut constraints: TemplateConstraintList = vec![];
373            for (template_name, template_constraints) in &metadata.template_types {
374                constraints.push((*template_name, template_constraints.clone()));
375            }
376
377            constraints
378        });
379
380        self.codebase.function_likes.entry(identifier).or_insert(metadata);
381    }
382
383    #[inline]
384    fn walk_out_closure(&mut self, _closure: &'arena Closure<'arena>, _context: &mut Context<'ctx, 'arena, A>) {
385        self.template_constraints.pop().expect("Expected template stack to be non-empty");
386    }
387
388    #[inline]
389    fn walk_in_arrow_function(
390        &mut self,
391        arrow_function: &'arena ArrowFunction<'arena>,
392        context: &mut Context<'ctx, 'arena, A>,
393    ) {
394        let span = arrow_function.span();
395
396        let synthetic = crate::build_synthetic_name("closure", context.file, span);
397        let identifier = (empty_word(), synthetic);
398
399        let type_resolution_context = self.get_current_type_resolution_context();
400
401        let metadata = scan_arrow_function(
402            identifier,
403            arrow_function,
404            self.stack.last().copied(),
405            context,
406            &mut self.scope,
407            type_resolution_context,
408        );
409
410        self.template_constraints.push({
411            let mut constraints: TemplateConstraintList = vec![];
412            for (template_name, template_constraints) in &metadata.template_types {
413                constraints.push((*template_name, template_constraints.clone()));
414            }
415
416            constraints
417        });
418        self.codebase.function_likes.entry(identifier).or_insert(metadata);
419    }
420
421    #[inline]
422    fn walk_out_arrow_function(
423        &mut self,
424        _arrow_function: &'arena ArrowFunction<'arena>,
425        _context: &mut Context<'ctx, 'arena, A>,
426    ) {
427        self.template_constraints.pop().expect("Expected template stack to be non-empty");
428    }
429
430    #[inline]
431    fn walk_in_constant(&mut self, constant: &'arena Constant<'arena>, context: &mut Context<'ctx, 'arena, A>) {
432        let constants = scan_constant(constant, context, &self.get_current_type_resolution_context(), &self.scope);
433
434        for mut constant_metadata in constants {
435            if self.polyfill_depth > 0 {
436                constant_metadata.flags |= MetadataFlags::POLYFILL;
437            }
438            let constant_name = constant_metadata.name;
439            self.codebase.constants.entry(constant_name).or_insert(constant_metadata);
440        }
441    }
442
443    #[inline]
444    fn walk_in_function_call(
445        &mut self,
446        function_call: &'arena FunctionCall<'arena>,
447        context: &mut Context<'ctx, 'arena, A>,
448    ) {
449        let Some(mut constant_metadata) =
450            scan_defined_constant(function_call, context, &self.get_current_type_resolution_context(), &self.scope)
451        else {
452            return;
453        };
454
455        if self.polyfill_depth > 0 {
456            constant_metadata.flags |= MetadataFlags::POLYFILL;
457        }
458
459        self.codebase.constants.entry(constant_metadata.name).or_insert(constant_metadata);
460    }
461
462    #[inline]
463    fn walk_anonymous_class(
464        &mut self,
465        anonymous_class: &'arena AnonymousClass<'arena>,
466        context: &mut Context<'ctx, 'arena, A>,
467    ) {
468        if let Some((id, template_definition, type_aliases, imported_aliases)) =
469            register_anonymous_class(&mut self.codebase, anonymous_class, context, &mut self.scope)
470        {
471            self.apply_polyfill_flag_to_class_like(id);
472            self.file_type_aliases.extend(type_aliases);
473            self.file_imported_aliases.extend(imported_aliases);
474            self.stack.push(id);
475            self.template_constraints.push(template_definition);
476
477            walk_anonymous_class_mut(self, anonymous_class, context);
478        }
479    }
480
481    #[inline]
482    fn walk_class(&mut self, class: &'arena Class<'arena>, context: &mut Context<'ctx, 'arena, A>) {
483        if let Some((id, templates, type_aliases, imported_aliases)) =
484            register_class(&mut self.codebase, class, context, &mut self.scope)
485        {
486            self.apply_polyfill_flag_to_class_like(id);
487            self.file_type_aliases.extend(type_aliases);
488            self.file_imported_aliases.extend(imported_aliases);
489            self.stack.push(id);
490            self.template_constraints.push(templates);
491
492            walk_class_mut(self, class, context);
493        }
494    }
495
496    #[inline]
497    fn walk_trait(&mut self, r#trait: &'arena Trait<'arena>, context: &mut Context<'ctx, 'arena, A>) {
498        if let Some((id, templates, type_aliases, imported_aliases)) =
499            register_trait(&mut self.codebase, r#trait, context, &mut self.scope)
500        {
501            self.apply_polyfill_flag_to_class_like(id);
502            self.file_type_aliases.extend(type_aliases);
503            self.file_imported_aliases.extend(imported_aliases);
504            self.stack.push(id);
505            self.template_constraints.push(templates);
506
507            walk_trait_mut(self, r#trait, context);
508        }
509    }
510
511    #[inline]
512    fn walk_enum(&mut self, r#enum: &'arena Enum<'arena>, context: &mut Context<'ctx, 'arena, A>) {
513        if let Some((id, templates, type_aliases, imported_aliases)) =
514            register_enum(&mut self.codebase, r#enum, context, &mut self.scope)
515        {
516            self.apply_polyfill_flag_to_class_like(id);
517            self.file_type_aliases.extend(type_aliases);
518            self.file_imported_aliases.extend(imported_aliases);
519            self.stack.push(id);
520            self.template_constraints.push(templates);
521
522            walk_enum_mut(self, r#enum, context);
523        }
524    }
525
526    #[inline]
527    fn walk_interface(&mut self, interface: &'arena Interface<'arena>, context: &mut Context<'ctx, 'arena, A>) {
528        if let Some((id, templates, type_aliases, imported_aliases)) =
529            register_interface(&mut self.codebase, interface, context, &mut self.scope)
530        {
531            self.apply_polyfill_flag_to_class_like(id);
532            self.file_type_aliases.extend(type_aliases);
533            self.file_imported_aliases.extend(imported_aliases);
534            self.stack.push(id);
535            self.template_constraints.push(templates);
536
537            walk_interface_mut(self, interface, context);
538        }
539    }
540
541    #[inline]
542    fn walk_in_method(&mut self, method: &'arena Method<'arena>, context: &mut Context<'ctx, 'arena, A>) {
543        let current_class = self.stack.last().copied().expect("Expected class-like stack to be non-empty");
544        let mut class_like_metadata =
545            self.codebase.class_likes.remove(&current_class).expect("Expected class-like metadata to be present");
546
547        let name = ascii_lowercase_word(method.name.value);
548
549        if class_like_metadata.methods.contains(&name) {
550            if class_like_metadata.pseudo_methods.contains(&name)
551                && let Some(existing_method) = self.codebase.function_likes.get_mut(&(class_like_metadata.name, name))
552            {
553                class_like_metadata.pseudo_methods.remove(&name);
554                existing_method.flags.remove(MetadataFlags::MAGIC_METHOD);
555            }
556
557            self.codebase.class_likes.insert(current_class, class_like_metadata);
558            self.template_constraints.push(vec![]);
559
560            return;
561        }
562
563        let method_id = (class_like_metadata.name, name);
564        let type_resolution_context = {
565            let mut context = self.get_current_type_resolution_context();
566
567            for alias_name in class_like_metadata.type_aliases.keys() {
568                context = context.with_type_alias(*alias_name);
569            }
570
571            for (alias_name, (source_class, original_name, _span)) in &class_like_metadata.imported_type_aliases {
572                context = context.with_imported_type_alias(*alias_name, *source_class, *original_name);
573            }
574
575            context
576        };
577
578        let Some(mut function_like_metadata) = scan_method(
579            method_id,
580            method,
581            &class_like_metadata,
582            context,
583            &mut self.scope,
584            Some(type_resolution_context),
585        ) else {
586            // Restore the class-like metadata we removed above so the next method on
587            // this class can still find it, and push an empty template-constraints
588            // frame so the matching `walk_out_method` pop balances.
589            self.codebase.class_likes.insert(current_class, class_like_metadata);
590            self.template_constraints.push(vec![]);
591            return;
592        };
593
594        #[allow(clippy::unreachable)]
595        let Some(method_metadata) = &function_like_metadata.method_metadata else {
596            unreachable!("Method info should be present for method.",);
597        };
598
599        let mut is_constructor = false;
600        let mut is_clone = false;
601        if method_metadata.is_constructor {
602            is_constructor = true;
603            self.has_constructor = true;
604
605            let type_context = self.get_current_type_resolution_context();
606            for (index, param) in method.parameter_list.parameters.iter().enumerate() {
607                if !param.is_promoted_property() {
608                    continue;
609                }
610
611                let Some(parameter_metadata) = function_like_metadata.parameters.get_mut(index) else {
612                    continue;
613                };
614
615                let property_metadata = scan_promoted_property(
616                    param,
617                    parameter_metadata,
618                    &mut class_like_metadata,
619                    current_class,
620                    &type_context,
621                    context,
622                    &self.scope,
623                );
624
625                class_like_metadata.add_property_metadata(property_metadata);
626            }
627        } else {
628            is_clone = name == word("__clone");
629        }
630
631        class_like_metadata.methods.insert(name);
632        let method_identifier = MethodIdentifier::new(class_like_metadata.name, name);
633        class_like_metadata.add_declaring_method_id(name, method_identifier);
634        if !method_metadata.visibility.is_private() || is_constructor || is_clone || class_like_metadata.kind.is_trait()
635        {
636            class_like_metadata.inheritable_method_ids.insert(name, method_identifier);
637        }
638
639        if method_metadata.is_final && is_constructor {
640            class_like_metadata.flags |= MetadataFlags::CONSISTENT_CONSTRUCTOR;
641        }
642
643        self.template_constraints.push({
644            let mut constraints: TemplateConstraintList = vec![];
645            for (template_name, template_constraints) in &function_like_metadata.template_types {
646                constraints.push((*template_name, template_constraints.clone()));
647            }
648
649            constraints
650        });
651
652        self.codebase.class_likes.entry(current_class).or_insert(class_like_metadata);
653        self.codebase.function_likes.entry(method_id).or_insert(function_like_metadata);
654    }
655
656    #[inline]
657    fn walk_out_method(&mut self, _method: &'arena Method<'arena>, _context: &mut Context<'ctx, 'arena, A>) {
658        self.template_constraints.pop().expect("Expected template stack to be non-empty");
659    }
660
661    #[inline]
662    fn walk_out_anonymous_class(
663        &mut self,
664        _anonymous_class: &'arena AnonymousClass<'arena>,
665        _context: &mut Context<'ctx, 'arena, A>,
666    ) {
667        self.stack.pop().expect("Expected class stack to be non-empty");
668        self.template_constraints.pop().expect("Expected template stack to be non-empty");
669    }
670
671    #[inline]
672    fn walk_out_class(&mut self, _class: &'arena Class<'arena>, context: &mut Context<'ctx, 'arena, A>) {
673        finalize_class_like(self, context);
674    }
675
676    #[inline]
677    fn walk_out_trait(&mut self, _trait: &'arena Trait<'arena>, context: &mut Context<'ctx, 'arena, A>) {
678        finalize_class_like(self, context);
679    }
680
681    #[inline]
682    fn walk_out_enum(&mut self, _enum: &'arena Enum<'arena>, context: &mut Context<'ctx, 'arena, A>) {
683        finalize_class_like(self, context);
684    }
685
686    #[inline]
687    fn walk_out_interface(&mut self, _interface: &'arena Interface<'arena>, context: &mut Context<'ctx, 'arena, A>) {
688        finalize_class_like(self, context);
689    }
690}
691
692#[allow(clippy::expect_used)]
693fn finalize_class_like<A>(scanner: &mut Scanner, context: &Context<'_, '_, A>)
694where
695    A: Arena,
696{
697    let has_constructor = scanner.has_constructor;
698    scanner.has_constructor = false;
699
700    let class_like_id = scanner.stack.pop().expect("Expected class stack to be non-empty");
701    scanner.template_constraints.pop().expect("Expected template stack to be non-empty");
702
703    if has_constructor {
704        return;
705    }
706
707    let Some(mut class_like_metadata) = scanner.codebase.class_likes.remove(&class_like_id) else {
708        return;
709    };
710
711    if class_like_metadata.flags.has_consistent_constructor() {
712        let constructor_name = word("__construct");
713
714        class_like_metadata.methods.insert(constructor_name);
715        let constructor_method_id = MethodIdentifier::new(class_like_metadata.name, constructor_name);
716        class_like_metadata.add_declaring_method_id(constructor_name, constructor_method_id);
717        class_like_metadata.inheritable_method_ids.insert(constructor_name, constructor_method_id);
718
719        let mut flags = MetadataFlags::PURE;
720        flags |= MetadataFlags::origin_flags(context.file.file_type);
721
722        scanner.codebase.function_likes.insert(
723            (class_like_metadata.name, constructor_name),
724            FunctionLikeMetadata::new(
725                FunctionLikeKind::Method,
726                constructor_name,
727                constructor_name,
728                class_like_metadata.span,
729                flags,
730            ),
731        );
732    }
733
734    scanner.codebase.class_likes.insert(class_like_id, class_like_metadata);
735}
736
737#[cfg(test)]
738#[allow(clippy::unwrap_used, clippy::expect_used)]
739mod polyfill_tests {
740    use mago_allocator::LocalArena;
741    use std::borrow::Cow;
742
743    use mago_database::Database;
744    use mago_database::DatabaseConfiguration;
745    use mago_database::DatabaseReader;
746    use mago_database::file::File;
747    use mago_names::resolver::NameResolver;
748    use mago_php_version::PHPVersion;
749    use mago_syntax::parser::parse_file;
750    use mago_word::ascii_lowercase_word;
751    use mago_word::empty_word;
752    use mago_word::word;
753
754    use crate::metadata::CodebaseMetadata;
755    use crate::metadata::flags::MetadataFlags;
756    use crate::scanner::scan_program;
757
758    fn scan(code: &'static str) -> CodebaseMetadata {
759        let file = File::ephemeral(Cow::Borrowed(b"code.php"), Cow::Borrowed(code.as_bytes()));
760        let config =
761            DatabaseConfiguration::new(std::path::Path::new("/"), vec![], vec![], vec![], vec![]).into_static();
762        let database = Database::single(file, config);
763
764        let mut codebase = CodebaseMetadata::new();
765        let arena = LocalArena::new();
766        for file in database.files() {
767            let program = parse_file(&arena, &file);
768            assert!(!program.has_errors(), "parse failed: {:?}", program.errors);
769            let resolved_names = NameResolver::new(&arena).resolve(program);
770            codebase.extend(scan_program(&arena, &file, program, &resolved_names, PHPVersion::LATEST));
771        }
772        codebase
773    }
774
775    fn class_flags(codebase: &CodebaseMetadata, name: &str) -> MetadataFlags {
776        codebase
777            .class_likes
778            .get(&ascii_lowercase_word(name.as_bytes()))
779            .unwrap_or_else(|| panic!("class-like `{name}` not found; have {:?}", codebase.class_likes.keys()))
780            .flags
781    }
782
783    fn function_flags(codebase: &CodebaseMetadata, name: &str) -> MetadataFlags {
784        codebase
785            .function_likes
786            .get(&(empty_word(), ascii_lowercase_word(name.as_bytes())))
787            .unwrap_or_else(|| panic!("function `{name}` not found"))
788            .flags
789    }
790
791    fn constant_flags(codebase: &CodebaseMetadata, name: &str) -> MetadataFlags {
792        codebase.constants.get(&word(name)).unwrap_or_else(|| panic!("constant `{name}` not found")).flags
793    }
794
795    #[test]
796    fn class_in_not_class_exists_is_polyfill() {
797        let code = "<?php
798            if (!class_exists('Foo')) {
799                class Foo {}
800            }
801        ";
802        assert!(class_flags(&scan(code), "Foo").is_polyfill());
803    }
804
805    #[test]
806    fn interface_in_not_interface_exists_is_polyfill() {
807        let code = "<?php
808            if (!interface_exists('Bar')) {
809                interface Bar {}
810            }
811        ";
812        assert!(class_flags(&scan(code), "Bar").is_polyfill());
813    }
814
815    #[test]
816    fn trait_in_not_trait_exists_is_polyfill() {
817        let code = "<?php
818            if (!trait_exists('Mix')) {
819                trait Mix {}
820            }
821        ";
822        assert!(class_flags(&scan(code), "Mix").is_polyfill());
823    }
824
825    #[test]
826    fn enum_in_not_enum_exists_is_polyfill() {
827        let code = "<?php
828            if (!enum_exists('Kind')) {
829                enum Kind { case A; }
830            }
831        ";
832        assert!(class_flags(&scan(code), "Kind").is_polyfill());
833    }
834
835    #[test]
836    fn function_in_not_function_exists_is_polyfill() {
837        let code = "<?php
838            if (!function_exists('foo')) {
839                function foo(): void {}
840            }
841        ";
842        assert!(function_flags(&scan(code), "foo").is_polyfill());
843    }
844
845    #[test]
846    fn const_in_not_defined_is_polyfill() {
847        let code = "<?php
848            if (!defined('FOO')) {
849                const FOO = 1;
850            }
851        ";
852        assert!(constant_flags(&scan(code), "FOO").is_polyfill());
853    }
854
855    #[test]
856    fn define_call_in_not_defined_is_polyfill() {
857        let code = "<?php
858            if (!defined('BAR')) {
859                define('BAR', 1);
860            }
861        ";
862        assert!(constant_flags(&scan(code), "BAR").is_polyfill());
863    }
864
865    #[test]
866    fn class_in_else_branch_of_positive_check_is_polyfill() {
867        let code = "<?php
868            if (class_exists('Foo')) {
869            } else {
870                class Foo {}
871            }
872        ";
873        assert!(class_flags(&scan(code), "Foo").is_polyfill());
874    }
875
876    #[test]
877    fn class_in_then_branch_of_positive_check_is_not_polyfill() {
878        let code = "<?php
879            if (class_exists('Foo')) {
880                class Bar {}
881            }
882        ";
883        assert!(!class_flags(&scan(code), "Bar").is_polyfill());
884    }
885
886    #[test]
887    fn top_level_class_is_not_polyfill() {
888        let code = "<?php class Plain {}";
889        assert!(!class_flags(&scan(code), "Plain").is_polyfill());
890    }
891
892    #[test]
893    fn class_inside_unrelated_if_is_not_polyfill() {
894        let code = "<?php
895            if (PHP_VERSION_ID > 80000) {
896                class Modern {}
897            }
898        ";
899        assert!(!class_flags(&scan(code), "Modern").is_polyfill());
900    }
901
902    #[test]
903    fn class_in_then_branch_when_condition_is_not_exists_check_is_not_polyfill() {
904        let code = "<?php
905            if (!some_other_check()) {
906                class Other {}
907            }
908        ";
909        assert!(!class_flags(&scan(code), "Other").is_polyfill());
910    }
911
912    #[test]
913    fn polyfill_flag_does_not_leak_to_siblings() {
914        let code = "<?php
915            if (!class_exists('Polyfilled')) {
916                class Polyfilled {}
917            }
918
919            class Real {}
920        ";
921        let codebase = scan(code);
922        assert!(class_flags(&codebase, "Polyfilled").is_polyfill());
923        assert!(!class_flags(&codebase, "Real").is_polyfill());
924    }
925
926    #[test]
927    fn class_inside_else_does_not_leak_to_preceding_sibling() {
928        let code = "<?php
929            if (class_exists('Gate')) {
930                class Sibling {}
931            } else {
932                class Gate {}
933            }
934        ";
935        let codebase = scan(code);
936        assert!(!class_flags(&codebase, "Sibling").is_polyfill());
937        assert!(class_flags(&codebase, "Gate").is_polyfill());
938    }
939
940    #[test]
941    fn class_nested_inside_polyfill_guard_is_still_polyfill() {
942        let code = "<?php
943            if (!class_exists('Wrapper')) {
944                if (PHP_VERSION_ID >= 80000) {
945                    class Wrapper {}
946                }
947            }
948        ";
949        assert!(class_flags(&scan(code), "Wrapper").is_polyfill());
950    }
951
952    #[test]
953    fn nested_polyfill_guards_unwind_correctly() {
954        let code = "<?php
955            if (!class_exists('A')) {
956                class A {}
957            }
958            class B {}
959            if (!class_exists('C')) {
960                class C {}
961            }
962            class D {}
963        ";
964        let codebase = scan(code);
965        assert!(class_flags(&codebase, "A").is_polyfill());
966        assert!(!class_flags(&codebase, "B").is_polyfill());
967        assert!(class_flags(&codebase, "C").is_polyfill());
968        assert!(!class_flags(&codebase, "D").is_polyfill());
969    }
970
971    #[test]
972    fn polyfill_within_namespace_gets_full_fqn_flagged() {
973        let code = r#"<?php
974            namespace Pkg;
975            if (!class_exists('Pkg\\Stub')) {
976                class Stub {}
977            }
978        "#;
979        assert!(class_flags(&scan(code), "Pkg\\Stub").is_polyfill());
980    }
981
982    #[test]
983    fn class_in_alternative_syntax_then_branch_is_polyfill() {
984        let code = "<?php
985            if (!class_exists('Alt')):
986                class Alt {}
987            endif;
988        ";
989        assert!(class_flags(&scan(code), "Alt").is_polyfill());
990    }
991
992    #[test]
993    fn class_in_alternative_syntax_else_branch_is_polyfill() {
994        let code = "<?php
995            if (class_exists('AltElse')):
996            else:
997                class AltElse {}
998            endif;
999        ";
1000        assert!(class_flags(&scan(code), "AltElse").is_polyfill());
1001    }
1002
1003    #[test]
1004    fn leading_backslash_on_guard_function_is_recognized() {
1005        let code = r#"<?php
1006            if (!\class_exists('Qualified')) {
1007                class Qualified {}
1008            }
1009        "#;
1010        assert!(class_flags(&scan(code), "Qualified").is_polyfill());
1011    }
1012
1013    #[test]
1014    fn guard_function_case_insensitive() {
1015        let code = "<?php
1016            if (!CLASS_EXISTS('Uppercase')) {
1017                class Uppercase {}
1018            }
1019        ";
1020        assert!(class_flags(&scan(code), "Uppercase").is_polyfill());
1021    }
1022
1023    #[test]
1024    fn parenthesized_guard_expression_is_recognized() {
1025        let code = "<?php
1026            if (!(class_exists('Parenned'))) {
1027                class Parenned {}
1028            }
1029        ";
1030        assert!(class_flags(&scan(code), "Parenned").is_polyfill());
1031    }
1032
1033    #[test]
1034    fn doubly_parenthesized_guard_is_recognized() {
1035        let code = "<?php
1036            if ((!((class_exists('DoubleParen'))))) {
1037                class DoubleParen {}
1038            }
1039        ";
1040        assert!(class_flags(&scan(code), "DoubleParen").is_polyfill());
1041    }
1042
1043    #[test]
1044    fn class_in_elseif_branch_is_not_polyfill() {
1045        let code = "<?php
1046            if (false) {
1047            } elseif (!class_exists('Never')) {
1048                class Never {}
1049            }
1050        ";
1051        assert!(!class_flags(&scan(code), "Never").is_polyfill());
1052    }
1053
1054    #[test]
1055    fn merge_non_polyfill_overrides_polyfill() {
1056        let mut stub = scan(
1057            "<?php
1058            if (!class_exists('Shared')) {
1059                class Shared {}
1060            }
1061        ",
1062        );
1063        let real = scan("<?php class Shared { public int $x = 1; }");
1064        stub.extend(real);
1065        let flags = class_flags(&stub, "Shared");
1066        assert!(!flags.is_polyfill(), "polyfill should have been replaced by real: flags = {flags:?}");
1067    }
1068
1069    #[test]
1070    fn merge_polyfill_does_not_override_non_polyfill() {
1071        let mut real = scan("<?php class Shared { public int $x = 1; }");
1072        let stub = scan(
1073            "<?php
1074            if (!class_exists('Shared')) {
1075                class Shared {}
1076            }
1077        ",
1078        );
1079        real.extend(stub);
1080        assert!(!class_flags(&real, "Shared").is_polyfill());
1081    }
1082
1083    #[test]
1084    fn merge_only_polyfill_is_kept() {
1085        let codebase = scan(
1086            "<?php
1087            if (!class_exists('OnlyStub')) {
1088                class OnlyStub {}
1089            }
1090        ",
1091        );
1092        assert!(class_flags(&codebase, "OnlyStub").is_polyfill());
1093    }
1094
1095    #[test]
1096    fn merge_function_non_polyfill_overrides_polyfill() {
1097        let mut stub = scan(
1098            "<?php
1099            if (!function_exists('array_is_list')) {
1100                function array_is_list(array $arr): bool { return true; }
1101            }
1102        ",
1103        );
1104        let real = scan("<?php function array_is_list(array $arr): bool { return false; }");
1105        stub.extend(real);
1106        assert!(!function_flags(&stub, "array_is_list").is_polyfill());
1107    }
1108
1109    #[test]
1110    fn merge_constant_non_polyfill_overrides_polyfill() {
1111        let mut stub = scan(
1112            "<?php
1113            if (!defined('MY_CONST')) {
1114                const MY_CONST = 1;
1115            }
1116        ",
1117        );
1118        let real = scan("<?php const MY_CONST = 2;");
1119        stub.extend(real);
1120        assert!(!constant_flags(&stub, "MY_CONST").is_polyfill());
1121    }
1122
1123    #[test]
1124    fn phpunit_test_case_stub_scenario_prefers_real() {
1125        let mut codebase = scan(
1126            r#"<?php
1127            namespace PHPUnit\Framework;
1128
1129            if (!class_exists('PHPUnit\\Framework\\TestCase')) {
1130                abstract class TestCase {}
1131            }
1132        "#,
1133        );
1134        let real = scan(
1135            r#"<?php
1136            namespace PHPUnit\Framework {
1137                abstract class Assert {}
1138                abstract class TestCase extends Assert {}
1139            }
1140        "#,
1141        );
1142        codebase.extend(real);
1143
1144        let tc = codebase
1145            .class_likes
1146            .get(&ascii_lowercase_word(b"PHPUnit\\Framework\\TestCase"))
1147            .expect("TestCase should be present in merged codebase");
1148
1149        assert!(!tc.flags.is_polyfill(), "merged TestCase should be the real definition");
1150        assert_eq!(
1151            tc.direct_parent_class.map(|p| p.to_string()),
1152            Some("PHPUnit\\Framework\\Assert".to_ascii_lowercase()),
1153        );
1154    }
1155}