Skip to content

Classes declared inside conditional blocks (if/else) are not indexed for type resolution — breaks Doctrine ServiceEntityRepository generic chain #153

@MrSrsen

Description

@MrSrsen

PHPantom version

phpantom_lsp 0.8.0 (also reproduces on main)

Installation method

Pre-built binary from GitHub Releases

Operating system

Linux x86_64

Editor

Neovim

Bug description

A named class/interface/trait/enum declared inside a conditional block (an if/else body, or try, switch, etc.) is discovered by name but is never turned into a full ClassInfo — its extends/implements parent and its @extends/@template-extends generics are dropped. As a result the class behaves as if it had no parent: the inheritance chain is broken, generic type parameters don't propagate, completion/hover on inherited members is missing, and find-references on methods called on instances of such a class falls back to over-broad, name-based matching.

The exact real-world trigger is Doctrine's doctrine/doctrine-bundle, which declares ServiceEntityRepository differently per ORM major version, inside a runtime guard:

// vendor/doctrine/doctrine-bundle/src/Repository/ServiceEntityRepository.php (simplified)
if (! property_exists(EntityRepository::class, '_entityName')) {
    /** @template T of object @template-extends ServiceEntityRepositoryProxy<T> */
    class ServiceEntityRepository extends ServiceEntityRepositoryProxy { /* ... */ }
} else {
    /** @template T of object @template-extends LazyServiceEntityRepository<T> */
    class ServiceEntityRepository extends LazyServiceEntityRepository { /* ... */ }
}

Because ServiceEntityRepository is declared inside the if, phpantom never records its parent or its <T> binding. The whole chain MyRepository → ServiceEntityRepository<T> → EntityRepository<T> collapses, so $repo->find() resolves to object|null / mixed instead of MyEntity|null. Everything downstream (completion, hover, references on the result) is then untyped.

Expected: a class declared inside an if/else block is indexed with its parent and generics, exactly as a top-level declaration would be.
Actual: only the class name is indexed; parent class and @extends generics are silently dropped, breaking inheritance and generic resolution.

Steps to reproduce

Single self-contained file — no vendor/, no composer.json needed.

<?php

/** @template T of object */
class Repo
{
    /** @return T */
    public function get(): object {}
}

class Entity
{
    public function name(): string {}
}

// --- Control: identical class at TOP LEVEL resolves correctly ---
/** @extends Repo<Entity> */
class TopLevelRepo extends Repo {}

// --- Bug: identical class inside an `if` block does NOT resolve ---
if (\PHP_VERSION_ID >= 80000) {
    /** @extends Repo<Entity> */
    class ConditionalRepo extends Repo {}
}

function test(TopLevelRepo $a, ConditionalRepo $b): void
{
    $x = $a->get();   // OK resolves to `Entity`            (top-level class)
    $x->name();       // OK `name()` is offered/resolved

    $y = $b->get();   // ERR resolves to `object` (unresolved) — should be `Entity`
    $y->name();       // ERR `name()` not offered; parent `Repo` is unknown for ConditionalRepo
}
  1. Open the file in your editor.
  2. Hover on $x (from TopLevelRepo) → Entity. Hover on $y (from ConditionalRepo) → object.
  3. Completion on $x-> offers name(); completion on $y-> does not.
  4. Even without generics, go-to-definition / hover does not see ConditionalRepo's parent
    Repo at all — the conditional class has no parent recorded.

The bound (T of object vs T), the kind of guard (PHP_VERSION_ID, function_exists, property_exists, plain if (true)), and inheritance depth do not matter — any named declaration nested in a conditional is affected.

Error output or panic trace

No crash and no error output — purely missing indexing / incorrect type inference.

.phpantom.toml

default / no config file

Additional context

Fix PR: #154

Root cause (traced in source). The class name is found regardless of nesting by the
byte-level scanner (src/classmap_scanner.rs::find_symbols pushes class/interface/
trait/enum names with no brace-depth gate), so the FQN→path classmap is correct. But the
two AST builders that produce the ClassInfo used for type resolution only look at top-level
and namespace-level statements:

  • src/parser/mod.rs::parse_php_versioned_with_namespaces — the top-level match handles
    Statement::Namespace and Statement::Class | Interface | Trait | Enum, but _ => {}
    silently drops Statement::If (and every other container).
  • src/parser/classes.rs::extract_classes_from_statements — its _ arm only calls
    find_anonymous_classes_in_statement, which picks up new class {} expressions but not
    named class X {} declarations nested in an if/try/switch/block body.

So a conditionally-declared class reaches the index with a name but no parent_class and no
extends_generics, which is what breaks the inheritance/generic chain.

Prior art — how the wider PHP-tooling community handles this:

  • PHPStan (phpstan-src) discovers classes with a fully recursive node visitor
    (CachingVisitor driven by a default NodeTraverser) — it descends into if/else
    bodies; there is no top-level-only restriction. On duplicate FQN it keeps the first
    declaration (current($classNodes[$name])).
  • Psalm (vimeo/psalm) scans with a recursive ReflectorVisitor; it descends into
    conditional bodies and keeps the first declaration (emitting DuplicateClass for later
    ones). It can statically prune dead branches for class_exists/PHP_VERSION_ID guards,
    but does not special-case property_exists — it simply indexes the class anyway.
  • Mago (carthage-software/mago, also Rust) uses a generated recursive MutWalker that
    registers class-likes at any nesting depth, including inside if/else.

All three resolve the Doctrine case purely by descending into the conditional and indexing
the class
— none of them evaluate the property_exists guard. The common denominator is:
discovery must be nesting-agnostic.

Proposed fix:

  1. Make both builders descend into the bodies of container statements (If — both
    IfBody::Statement and IfBody::ColonDelimited, plus Try, Block, Switch, loops)
    and recurse via extract_classes_from_statements to extract named classes, not only
    anonymous ones. This is the core fix and matches PHPStan/Psalm/Mago.
  2. For the same-FQN-in-two-branches case, keep the first declaration in source order
    (de-duplicate by FQN at extraction time). This matches PHPStan/Psalm and phpantom's own
    existing first-occurrence-wins convention (classmap_scanner, find_class_by_name's
    .find(), fqn_uri_index's entry().or_insert). For the Doctrine shape the first
    (textual) branch is the ORM3 ServiceEntityRepositoryProxy arm. No member union, no guard
    evaluation, no DuplicateClass-style hard error (which is exactly what breaks users here).

Once the conditional class is a real ClassInfo with its @extends, phpantom's existing
generic-resolution machinery handles the rest.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions