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