Skip to content
Merged
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
48 changes: 23 additions & 25 deletions src/Proxy/Generator/ClassGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ public function getName(): string
return $this->name;
}

/**
* Builds the AST name node for a class-like reference (parent, interface,
* trait, alias) with a single universal rule: explicitly rooted
* ("\Stringable") and multi-segment names are fully qualified; a bare
* short name resolves in the generated class's own namespace (e.g. the
* Foo__AopProxied body trait). Global-namespace names coming from
* ::class constants are rooted upstream (AdviceMatcher, proxy generators)
* before they reach this generator.
*/
private static function classNameNode(string $name): Name
{
$normalized = ltrim($name, '\\');

return str_starts_with($name, '\\') || str_contains($normalized, '\\')
? new Name\FullyQualified($normalized)
: new Name($normalized);
}

/**
* Returns the class AST node only — no namespace or use wrappers.
* Suitable for direct injection into a cloned file AST.
Expand All @@ -176,58 +194,38 @@ public function getNode(): ClassNode
}

if ($this->parentClass !== null && $this->parentClass !== '') {
// Use FullyQualified when the name contains a namespace separator to avoid
// ambiguity in the generated file's namespace context.
$parentName = ltrim($this->parentClass, '\\');
$parentNode = str_contains($parentName, '\\')
? new Name\FullyQualified($parentName)
: $parentName;
$builder->extend($parentNode);
$builder->extend(self::classNameNode($this->parentClass));
}

foreach ($this->interfaces as $interface) {
if ($interface !== '') {
$ifaceName = ltrim($interface, '\\');
$ifaceNode = str_contains($ifaceName, '\\')
? new Name\FullyQualified($ifaceName)
: $ifaceName;
$builder->implement($ifaceNode);
$builder->implement(self::classNameNode($interface));
}
}

// Traits (always use FQN to avoid namespace ambiguity)
if (!empty($this->traits)) {
// Collect unique trait names (preserving order of first occurrence)
$seen = [];
$traitFqcns = [];
$traitNames = [];
foreach ($this->traits as $trait) {
$normalized = ltrim($trait, '\\');
if (!isset($seen[$normalized])) {
$seen[$normalized] = true;
$traitFqcns[] = $normalized;
$traitNames[] = self::classNameNode($trait);
}
}

// Build adaptations for all aliases
$adaptations = [];
foreach ($this->traitAliases as $info) {
$traitNameNode = str_contains($info['trait'], '\\')
? new Name\FullyQualified($info['trait'])
: new Name($info['trait']);
$adaptations[] = new TraitUseAdaptation\Alias(
$traitNameNode,
self::classNameNode($info['trait']),
new Identifier($info['method']),
$this->mapVisibility($info['visibility']),
new Identifier($info['alias'])
);
}

$traitNames = array_map(
static fn(string $t) => str_contains($t, '\\')
? new Name\FullyQualified($t)
: new Name($t),
$traitFqcns
);
$builder->addStmt(new TraitUse($traitNames, $adaptations));
}

Expand Down
27 changes: 27 additions & 0 deletions tests/Proxy/Generator/ClassGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,33 @@ public function testImplementsMultipleInterfaces(): void
$this->assertStringContainsString('Iterator', $output);
}

public function testImplementsGlobalInterfaceIsFullyQualifiedInNamespace(): void
{
// A global-namespace interface (single segment) must not resolve
// relative to the generated class namespace
$gen = new ClassGenerator('MyClass', 'My\Namespace', null, null, ['\Stringable']);
$output = $gen->generate();
$this->assertStringContainsString('implements \Stringable', $output);
}

public function testExtendsExplicitlyRootedGlobalParentStaysFullyQualified(): void
{
$gen = new ClassGenerator('MyClass', 'My\Namespace', null, '\Exception');
$output = $gen->generate();
$this->assertStringContainsString('extends \Exception', $output);
}

public function testUsesExplicitlyRootedGlobalTraitStaysFullyQualified(): void
{
$gen = new ClassGenerator('MyClass', 'My\Namespace', null, null);
$gen->addTraits(['\GlobalHelperTrait', 'MyClass__AopProxied']);
$output = $gen->generate();
$this->assertStringContainsString('\GlobalHelperTrait', $output);
// Deliberate short names keep referring to the class' own namespace
$this->assertStringContainsString('MyClass__AopProxied', $output);
$this->assertStringNotContainsString('\MyClass__AopProxied', $output);
}

public function testWithMethod(): void
{
$method = MethodGenerator::fromReflection(new ReflectionMethod(
Expand Down