Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/parser/ast_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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.
Expand Down
170 changes: 170 additions & 0 deletions src/parser/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)>) {
let mut seen: std::collections::HashSet<(Atom, Option<String>)> =
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<Item = &'a Statement<'a>>,
classes: &mut Vec<ClassInfo>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Concrete>` generics — not merely
/// discovered by name.
#[test]
fn conditional_class_inside_if_is_extracted_with_parent_and_generics() {
let src = r#"<?php
/** @template T of object */
class Repo {}
class Entity {}
if (\PHP_VERSION_ID >= 80000) {
/** @extends Repo<Entity> */
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<Entity>` 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#"<?php
if (\defined('SOME_FLAG')) {
class Dup extends First {}
} else {
class Dup extends Second {}
}
"#;
let classes = Backend::parse_php_versioned_with_namespaces(src, None);
let dups: Vec<_> = 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;
Expand Down
19 changes: 18 additions & 1 deletion src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
})
}
Expand Down
55 changes: 55 additions & 0 deletions tests/phpstan_nsrt/conditional-class-generic-extends.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types = 1);

// Regression: a generic class declared inside a conditional `if`/`else` block
// must still be indexed with its parent and `@template-extends` generics, so
// the inheritance chain resolves the class-level template to the concrete type.
//
// This is the Doctrine `ServiceEntityRepository` shape: doctrine-bundle defines
// `ServiceEntityRepository` differently for ORM2 vs ORM3 inside an
// `if (! property_exists(EntityRepository::class, '_entityName')) { ... } else { ... }`
// guard. Before the fix, a class declared inside such a block was discovered by
// name only — its parent and `@extends` generics were dropped — so the chain
// `ConcreteRepo -> ServiceEntityRepository<T> -> EntityRepository<T>` 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<T>`, mirroring Doctrine's ORM3/ORM2 split.
if (\PHP_VERSION_ID >= 80000) {
/**
* @template T of object
* @template-extends EntityRepository<T>
*/
class ServiceEntityRepository extends EntityRepository {}
} else {
/**
* @template T of object
* @template-extends EntityRepository<T>
*/
class ServiceEntityRepository extends EntityRepository {}
}

/** @extends ServiceEntityRepository<Entity> */
class EntityRepo extends ServiceEntityRepository {}

function t(EntityRepo $repo): void
{
// Chain resolves through a class declared inside the `if` branch:
// EntityRepo -> ServiceEntityRepository<Entity> -> EntityRepository<Entity>.
assertType('Entity', $repo->get());
}
Loading