Skip to main content

mago_codex/scanner/
mod.rs

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