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