diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 091ae524..c827fe92 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -209,7 +209,19 @@ impl Backend { Statement::Class(_) | Statement::Interface(_) | Statement::Trait(_) - | Statement::Enum(_) => { + | Statement::Enum(_) + // Class-likes declared inside conditional / + // control-flow blocks (e.g. Doctrine's + // `ServiceEntityRepository` version guard) — + // the extractor descends into the bodies. + | Statement::If(_) + | Statement::Block(_) + | Statement::Try(_) + | Statement::Switch(_) + | Statement::While(_) + | Statement::DoWhile(_) + | Statement::For(_) + | Statement::Foreach(_) => { Self::extract_classes_from_statements( std::iter::once(inner), &mut block_classes, @@ -248,7 +260,18 @@ impl Backend { Statement::Class(_) | Statement::Interface(_) | Statement::Trait(_) - | Statement::Enum(_) => { + | Statement::Enum(_) + // Class-likes declared inside top-level conditional / + // control-flow blocks — the extractor descends into the + // bodies (and still collects anonymous classes within). + | Statement::If(_) + | Statement::Block(_) + | Statement::Try(_) + | Statement::Switch(_) + | Statement::While(_) + | Statement::DoWhile(_) + | Statement::For(_) + | Statement::Foreach(_) => { let mut top_classes = Vec::new(); Self::extract_classes_from_statements( std::iter::once(statement), @@ -261,8 +284,7 @@ impl Backend { } _ => { // Walk other top-level statements (expression statements, - // function declarations, control flow, etc.) for anonymous - // classes. + // function declarations, etc.) for anonymous classes. let mut anon_classes = Vec::new(); Self::find_anonymous_classes_in_statement( statement, @@ -276,6 +298,11 @@ impl Backend { } } + // A class-like declared in two branches of a conditional yields + // one entry per branch; keep the first so resolution is + // deterministic (see `dedup_class_likes_first_wins`). + Self::dedup_class_likes_first_wins(&mut classes_with_ns); + // Extract standalone functions (including those inside if-guards // like `if (! function_exists('...'))`) using the shared helper // which recurses into if/block statements. diff --git a/src/parser/classes.rs b/src/parser/classes.rs index 6a3c67b5..4fd7a1ad 100644 --- a/src/parser/classes.rs +++ b/src/parser/classes.rs @@ -884,6 +884,25 @@ impl Backend { /// Recursively walk statements and extract class information. /// This handles classes at the top level as well as classes nested /// inside namespace declarations. + /// De-duplicate parsed class-likes by `(name, namespace)`, keeping the + /// first declaration in source order. + /// + /// A class-like declared in more than one branch of a conditional — + /// e.g. Doctrine's `ServiceEntityRepository`, defined differently for + /// ORM2 vs ORM3 inside an `if`/`else` version guard — yields one + /// [`ClassInfo`] per branch once we descend into conditional bodies. + /// Keeping the first declaration makes resolution deterministic and + /// matches PHPantom's existing first-occurrence-wins convention + /// (`classmap_scanner`, `find_class_by_name`, `fqn_uri_index`) as well + /// as PHPStan/Psalm. This must run before the classes reach + /// `fqn_class_index`, whose insert is last-wins and would otherwise pick + /// the wrong (later) branch. + pub(crate) fn dedup_class_likes_first_wins(items: &mut Vec<(ClassInfo, Option)>) { + let mut seen: std::collections::HashSet<(Atom, Option)> = + std::collections::HashSet::new(); + items.retain(|(cls, ns)| seen.insert((cls.name, ns.clone()))); + } + pub(crate) fn extract_classes_from_statements<'a>( statements: impl Iterator>, classes: &mut Vec, @@ -1340,6 +1359,94 @@ impl Backend { doc_ctx, ); } + // Named class-likes can be declared inside conditional and + // control-flow blocks — most notably Doctrine's + // `ServiceEntityRepository`, defined inside an + // `if (! property_exists(EntityRepository::class, '_entityName'))` + // guard that selects the ORM2 vs ORM3 base class. Descend into + // these container bodies so such declarations are indexed with + // their parent class and `@extends` generics, not merely + // discovered by name. Anonymous classes nested in the + // non-container statements within these bodies are still + // collected via the `_` arm when the recursion reaches them. + Statement::If(if_stmt) => { + Self::extract_classes_from_statements( + if_stmt.body.statements().iter(), + classes, + doc_ctx, + ); + for else_if in if_stmt.body.else_if_statements() { + Self::extract_classes_from_statements(else_if.iter(), classes, doc_ctx); + } + if let Some(else_stmts) = if_stmt.body.else_statements() { + Self::extract_classes_from_statements(else_stmts.iter(), classes, doc_ctx); + } + } + Statement::Block(block) => { + Self::extract_classes_from_statements( + block.statements.iter(), + classes, + doc_ctx, + ); + } + Statement::Try(try_stmt) => { + Self::extract_classes_from_statements( + try_stmt.block.statements.iter(), + classes, + doc_ctx, + ); + for catch in try_stmt.catch_clauses.iter() { + Self::extract_classes_from_statements( + catch.block.statements.iter(), + classes, + doc_ctx, + ); + } + if let Some(finally) = &try_stmt.finally_clause { + Self::extract_classes_from_statements( + finally.block.statements.iter(), + classes, + doc_ctx, + ); + } + } + Statement::Switch(switch_stmt) => { + for case in switch_stmt.body.cases() { + Self::extract_classes_from_statements( + case.statements().iter(), + classes, + doc_ctx, + ); + } + } + Statement::While(while_stmt) => { + Self::extract_classes_from_statements( + while_stmt.body.statements().iter(), + classes, + doc_ctx, + ); + } + Statement::DoWhile(do_while) => { + Self::extract_classes_from_statements( + std::iter::once(do_while.statement), + classes, + doc_ctx, + ); + } + Statement::For(for_stmt) => { + Self::extract_classes_from_statements( + for_stmt.body.statements().iter(), + classes, + doc_ctx, + ); + } + Statement::Foreach(foreach_stmt) => { + Self::extract_classes_from_statements( + foreach_stmt.body.statements().iter(), + classes, + doc_ctx, + ); + } _ => { // Walk into all other statement types to find anonymous // classes nested inside expressions, control flow, method @@ -2700,6 +2807,69 @@ mod tests { ); } + /// A class declared inside an `if` block (e.g. a version guard, like + /// Doctrine's `ServiceEntityRepository`) must be indexed with its native + /// parent and its `@extends Parent` generics — not merely + /// discovered by name. + #[test] + fn conditional_class_inside_if_is_extracted_with_parent_and_generics() { + let src = r#"= 80000) { + /** @extends Repo */ + class ConditionalRepo extends Repo {} +} +"#; + let classes = Backend::parse_php_versioned_with_namespaces(src, None); + let conditional = classes + .iter() + .find(|(c, _)| c.name == atom("ConditionalRepo")) + .map(|(c, _)| c) + .expect("class declared inside `if` should be indexed"); + + assert_eq!( + conditional.parent_class, + Some(atom("Repo")), + "conditional class should carry its native parent", + ); + assert!( + conditional + .extends_generics + .iter() + .any(|(parent, args)| *parent == atom("Repo") && !args.is_empty()), + "conditional class should carry its `@extends Repo` generics, got {:?}", + conditional.extends_generics, + ); + } + + /// When the same class name is declared in both branches of a conditional + /// (the Doctrine ORM2-vs-ORM3 shape), the first declaration in source + /// order wins and exactly one `ClassInfo` is produced. + #[test] + fn conditional_class_in_both_branches_keeps_first() { + let src = r#" = classes + .iter() + .filter(|(c, _)| c.name == atom("Dup")) + .collect(); + + assert_eq!(dups.len(), 1, "duplicate-branch class must be de-duplicated"); + assert_eq!( + dups[0].0.parent_class, + Some(atom("First")), + "first (source-order) branch should win", + ); + } + #[test] fn parse_target_mixed_qualified_and_bare() { let expected = attribute_target::TARGET_FUNCTION | attribute_target::TARGET_PARAMETER; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5b7b7724..a08fb131 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1227,7 +1227,19 @@ impl Backend { Statement::Class(_) | Statement::Interface(_) | Statement::Trait(_) - | Statement::Enum(_) => { + | Statement::Enum(_) + // Class-likes can also be declared inside top-level + // conditional / control-flow blocks (version guards, + // `if (! class_exists(...))` shims, etc.). Route these + // through the extractor, which descends into the bodies. + | Statement::If(_) + | Statement::Block(_) + | Statement::Try(_) + | Statement::Switch(_) + | Statement::While(_) + | Statement::DoWhile(_) + | Statement::For(_) + | Statement::Foreach(_) => { let mut top_classes = Vec::new(); Self::extract_classes_from_statements( std::iter::once(statement), @@ -1242,6 +1254,11 @@ impl Backend { } } + // A class-like declared in two branches of a conditional yields + // one entry per branch; keep the first so resolution is + // deterministic (see `dedup_class_likes_first_wins`). + Self::dedup_class_likes_first_wins(&mut result); + result }) } diff --git a/tests/phpstan_nsrt/conditional-class-generic-extends.php b/tests/phpstan_nsrt/conditional-class-generic-extends.php new file mode 100644 index 00000000..bac39a1f --- /dev/null +++ b/tests/phpstan_nsrt/conditional-class-generic-extends.php @@ -0,0 +1,55 @@ + ServiceEntityRepository -> EntityRepository` collapsed +// and `$repo->get()` resolved to `object`/`mixed` instead of the entity. + +namespace ConditionalClassGenericExtends; + +use function PHPStan\Testing\assertType; + +class Entity {} + +/** + * @template T of object + */ +class EntityRepository +{ + /** @return T */ + public function get(): object {} +} + +// The same FQN is declared in both branches of a runtime guard; the first +// (source-order) declaration wins. Both branches bind +// `@template-extends EntityRepository`, mirroring Doctrine's ORM3/ORM2 split. +if (\PHP_VERSION_ID >= 80000) { + /** + * @template T of object + * @template-extends EntityRepository + */ + class ServiceEntityRepository extends EntityRepository {} +} else { + /** + * @template T of object + * @template-extends EntityRepository + */ + class ServiceEntityRepository extends EntityRepository {} +} + +/** @extends ServiceEntityRepository */ +class EntityRepo extends ServiceEntityRepository {} + +function t(EntityRepo $repo): void +{ + // Chain resolves through a class declared inside the `if` branch: + // EntityRepo -> ServiceEntityRepository -> EntityRepository. + assertType('Entity', $repo->get()); +}