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