Skip to content

Commit 032a2e9

Browse files
authored
Quick patch 5 (#25)
* Enhance type resolution by adding support for enums and improving docblock handling and nested type aliases * Add support for multi-tier nested type aliases and enhance tests for alias resolution * Implement parameter shifting and renaming in child classes, add tests for inheritance disambiguation * Refactor code to ensure proper newline at end of file in multiple service and type files; update cache directory configuration to null * Refactor NestedAliasService and NestedTypeAliasesTest to remove unused chainedProperty and clean up type error assertions
1 parent 96e4804 commit 032a2e9

33 files changed

Lines changed: 883 additions & 54 deletions

src/Contract/ContractParser.php

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public static function parse(string $function): array
6969
if (str_contains($function, '::')) {
7070
[$className, $methodName] = explode('::', $function, 2);
7171

72-
if (class_exists($className) || interface_exists($className) || trait_exists($className)) {
72+
if (class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) {
7373
/** @var class-string<object> $className */
7474
$refClass = new \ReflectionClass($className);
7575
if ($refClass->hasMethod($methodName)) {
@@ -102,12 +102,6 @@ public static function parse(string $function): array
102102

103103
/**
104104
* Parses and resolves the @var or @property docblock for a given class property.
105-
*
106-
* Resolution Steps:
107-
* 1. Search class and parent class hierarchy for physical properties.
108-
* 2. Search implemented interfaces (PHP 8.4 interface properties).
109-
* 3. Fall back to class-level magic @property tags if enabled and physical property is not found.
110-
* 4. Parse physical @var tags if not already resolved as a magic property.
111105
*/
112106
public static function parseProperty(string $className, string $propertyName): ?TypeNode
113107
{
@@ -116,7 +110,7 @@ public static function parseProperty(string $className, string $propertyName): ?
116110
return self::$propertyCache[$cacheKey];
117111
}
118112

119-
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) {
113+
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) {
120114
return self::$propertyCache[$cacheKey] = null;
121115
}
122116

@@ -227,10 +221,6 @@ public static function parseProperty(string $className, string $propertyName): ?
227221
/**
228222
* Parses and resolves a class-level @method docblock for __call / __callStatic.
229223
*
230-
* Resolution Steps:
231-
* 1. Search class, parent, interface, and trait hierarchy for @method tags (excluding vendor files).
232-
* 2. Substitute type aliases and resolve FQCNs for parameters and return types.
233-
*
234224
* @return array{return: ?TypeNode, parameters: array<int, array{name: string, type: ?TypeNode, isVariadic: bool, isOptional: bool}>, aliases: array<string, TypeNode>, templates: array<string, TemplateTagValueNode>}|null
235225
*/
236226
public static function parseMagicMethod(string $className, string $methodName): ?array
@@ -240,7 +230,7 @@ public static function parseMagicMethod(string $className, string $methodName):
240230
return self::$magicMethodCache[$cacheKey];
241231
}
242232

243-
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) {
233+
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) {
244234
return self::$magicMethodCache[$cacheKey] = null;
245235
}
246236

@@ -349,7 +339,7 @@ public static function parseMagicMethod(string $className, string $methodName):
349339
*/
350340
public static function parseClassAliases(string $className): array
351341
{
352-
if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className)) {
342+
if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) {
353343
return [];
354344
}
355345

@@ -547,9 +537,16 @@ private static function parseMethodHierarchyDocs(
547537
$targetParamName = $paramName;
548538
} else {
549539
$paramIndex = $hierNameToIndex[$paramName] ?? null;
550-
$targetParamName = ($paramIndex !== null && isset($baseParamNames[$paramIndex]))
551-
? $baseParamNames[$paramIndex]
552-
: null;
540+
if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) {
541+
$candidateName = $baseParamNames[$paramIndex];
542+
if (! isset($hierNameToIndex[$candidateName])) {
543+
$targetParamName = $candidateName;
544+
} else {
545+
$targetParamName = null;
546+
}
547+
} else {
548+
$targetParamName = null;
549+
}
553550
}
554551

555552
if ($targetParamName !== null && ! isset($types[$targetParamName])) {
@@ -609,7 +606,7 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref
609606
*
610607
* @param array<string, TypeNode> $aliases
611608
*/
612-
private static function substituteAliases(TypeNode $node, array $aliases): TypeNode
609+
public static function substituteAliases(TypeNode $node, array $aliases): TypeNode
613610
{
614611
if ($node instanceof IdentifierTypeNode) {
615612
if (isset($aliases[$node->name])) {

src/Contract/DocblockExtractor.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,19 @@ public static function extractAliases(
130130
}
131131
}
132132
}
133+
134+
// Expand nested/imported alias references inside extracted local aliases
135+
foreach ($aliases as $name => $type) {
136+
$aliases[$name] = ContractParser::substituteAliases($type, $aliases);
137+
}
133138
}
134139

135140
/**
136-
* Resolves an imported type alias (@phpstan-import-type) from a target class or interface.
141+
* Resolves an imported type alias (@phpstan-import-type) from a target class, interface, trait, or enum.
137142
*/
138143
public static function resolveImportedTypeAlias(string $fqcn, string $importedAlias): ?TypeNode
139144
{
140-
if (! ClassNameValidator::isValid($fqcn) || (! class_exists($fqcn) && ! interface_exists($fqcn) && ! trait_exists($fqcn))) {
145+
if (! ClassNameValidator::isValid($fqcn) || (! class_exists($fqcn) && ! interface_exists($fqcn) && ! trait_exists($fqcn) && ! enum_exists($fqcn))) {
141146
return null;
142147
}
143148

@@ -148,10 +153,11 @@ public static function resolveImportedTypeAlias(string $fqcn, string $importedAl
148153
if ($doc !== false) {
149154
$phpDocNode = self::parseDocString($doc);
150155

151-
foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) {
152-
if ($aliasTag->alias === $importedAlias) {
153-
return $aliasTag->type;
154-
}
156+
$targetAliases = [];
157+
self::extractAliases($phpDocNode, $targetAliases, $ref);
158+
159+
if (isset($targetAliases[$importedAlias])) {
160+
return $targetAliases[$importedAlias];
155161
}
156162

157163
foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) {

src/Internal/DocblockNormalizer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ final class DocblockNormalizer
2828
*/
2929
public static function normalize(string $doc): string
3030
{
31+
$doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc;
32+
3133
$doc = preg_replace('/(\\\\?[a-zA-Z_\x80-\xff][\\\\a-zA-Z0-9_\x80-\xff]*::[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*(\??:)/', '"$1"$2', $doc) ?? $doc;
3234

3335
if (! str_contains($doc, '{')) {

src/Internal/Visitor/FunctionContractInjector.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node):
3434
return; // Skip injecting contract checks for this specific function/method!
3535
}
3636

37+
$methodName = $isClassMethod ? strtolower($node->name->toString()) : '';
38+
$isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true);
39+
3740
$hasParam = $isClassMethod || str_contains($docText, '@param');
38-
$hasReturn = $isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return');
41+
42+
// Never inject return checks into constructors, destructors, or clone methods
43+
$hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return'));
3944

4045
if (! $hasParam && ! $hasReturn) {
4146
return;

src/Resolver/SpecialTypeResolver.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
160160
*/
161161
public static function resolveForFile(TypeNode $node, string $file): TypeNode
162162
{
163+
$file = str_replace('\\', '/', $file);
164+
163165
if ($node instanceof ThisTypeNode) {
164166
return clone $node;
165167
}
@@ -256,7 +258,7 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio
256258
if (str_contains($context, '::')) {
257259
[$className, $methodName] = explode('::', $context, 2);
258260

259-
if (class_exists($className) || interface_exists($className) || trait_exists($className)) {
261+
if (class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) {
260262
/** @var class-string<object> $className */
261263
try {
262264
return new \ReflectionMethod($className, $methodName);
@@ -620,7 +622,7 @@ private static function extractItemKey(mixed $keyName): string|int|null
620622

621623
private static function resolveConstantOffsetValue(string $fqcn, string $constName, string|int $offsetKey): ?TypeNode
622624
{
623-
if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) {
625+
if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn))) {
624626
try {
625627
$refClass = new \ReflectionClass($fqcn);
626628
if ($refClass->hasConstant($constName)) {
@@ -644,7 +646,7 @@ private static function resolveConstantOffsetValue(string $fqcn, string $constNa
644646

645647
private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null
646648
{
647-
if (class_exists($fqcn) || interface_exists($fqcn)) {
649+
if (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn)) {
648650
try {
649651
$refClass = new \ReflectionClass($fqcn);
650652
if ($refClass->hasConstant($constName)) {
@@ -671,6 +673,7 @@ private static function resolveConstantKeyValue(string $fqcn, string $constName)
671673
public static function seedFileMetadata(string $fileName, string $namespace, array $imports): void
672674
{
673675
if ($fileName !== '') {
676+
$fileName = str_replace('\\', '/', $fileName);
674677
self::$fileNamespaces[$fileName] = $namespace;
675678
self::$fileUseImports[$fileName] = $imports;
676679
}
@@ -700,14 +703,20 @@ public static function getUseImports(\ReflectionClass|\ReflectionFunction|\Refle
700703
*/
701704
public static function getUseImportsFromFile(string $fileName): array
702705
{
703-
if ($fileName === '' || ! file_exists($fileName)) {
706+
if ($fileName === '') {
704707
return [];
705708
}
706709

710+
$fileName = str_replace('\\', '/', $fileName);
711+
707712
if (isset(self::$fileUseImports[$fileName])) {
708713
return self::$fileUseImports[$fileName];
709714
}
710715

716+
if (! file_exists($fileName)) {
717+
return [];
718+
}
719+
711720
$source = file_get_contents($fileName);
712721
if ($source === false) {
713722
return self::$fileUseImports[$fileName] = [];
@@ -723,14 +732,20 @@ public static function getUseImportsFromFile(string $fileName): array
723732
*/
724733
public static function getNamespaceFromFile(string $fileName): string
725734
{
726-
if ($fileName === '' || ! file_exists($fileName)) {
735+
if ($fileName === '') {
727736
return '';
728737
}
729738

739+
$fileName = str_replace('\\', '/', $fileName);
740+
730741
if (isset(self::$fileNamespaces[$fileName])) {
731742
return self::$fileNamespaces[$fileName];
732743
}
733744

745+
if (! file_exists($fileName)) {
746+
return '';
747+
}
748+
734749
$source = file_get_contents($fileName);
735750
if ($source === false) {
736751
return self::$fileNamespaces[$fileName] = '';
@@ -790,6 +805,8 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun
790805
*/
791806
public static function resolveFqcnForFile(string $name, string $file): string
792807
{
808+
$file = str_replace('\\', '/', $file);
809+
793810
if (self::isBuiltInTypeKeyword($name)) {
794811
return $name;
795812
}

tests/Contract/DocblockExtractorTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
66
use TypePHP\Contract\DocblockExtractor;
77
use TypePHP\Tests\Fixtures\Services\UserService;
8+
use TypePHP\Tests\Fixtures\Shopware\Metric\Type as MetricTypeEnum;
9+
use TypePHP\Tests\Fixtures\Types\NestedAliasChainedB;
10+
use TypePHP\Tests\Fixtures\Types\NestedAliasService;
811
use TypePHP\Tests\Fixtures\Types\UserApi;
912

1013
describe('DocblockExtractor Unit Tests', function () {
@@ -57,6 +60,39 @@
5760
expect($aliases)->toHaveKey('LocalUserShape');
5861
});
5962

63+
test('resolves imported type aliases from Enums', function () {
64+
$resolvedNode = DocblockExtractor::resolveImportedTypeAlias(MetricTypeEnum::class, 'MetricTypeValues');
65+
66+
expect($resolvedNode)->not()->toBeNull()
67+
->and((string) $resolvedNode)->toContain('histogram')
68+
;
69+
});
70+
71+
test('resolves multi-tier chained imported type aliases (A -> B -> C)', function () {
72+
$resolvedNode = DocblockExtractor::resolveImportedTypeAlias(NestedAliasChainedB::class, 'MidShape');
73+
74+
expect($resolvedNode)->not()->toBeNull()
75+
->and((string) $resolvedNode)->toContain('positive-int')
76+
->and((string) $resolvedNode)->toContain('non-empty-string')
77+
;
78+
});
79+
80+
test('fully expands nested alias dependencies when extracting aliases from a class', function () {
81+
$ref = new ReflectionClass(NestedAliasService::class);
82+
$doc = $ref->getDocComment();
83+
expect($doc)->not()->toBeFalse();
84+
85+
$phpDocNode = DocblockExtractor::parseDocString($doc);
86+
$aliases = [];
87+
88+
DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref);
89+
90+
expect($aliases)->toHaveKey('LocalRecordList')
91+
->and((string) $aliases['LocalRecordList'])->toContain('positive-int')
92+
->and((string) $aliases['LocalRecordList'])->toContain('active')
93+
;
94+
});
95+
6096
test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () {
6197
$doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list<string> \$tags\n */";
6298

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Services;
6+
7+
abstract class BaseShiftedAbstractService implements ShiftedInterfaceContract
8+
{
9+
/**
10+
* @param list<positive-int> $items
11+
*/
12+
abstract public function processItems(array $items): bool;
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Services;
6+
7+
class BaseShiftedMethodService
8+
{
9+
/**
10+
* Parent defines positional order: $id, $name, $options
11+
*
12+
* @param positive-int $id
13+
* @param non-empty-string $name
14+
* @param array{active: bool} $options
15+
*/
16+
public function updateUser(int $id, string $name, array $options = []): bool
17+
{
18+
return true;
19+
}
20+
21+
/**
22+
* Static method with positional order: $batch, $format
23+
*
24+
* @param list<positive-int> $batch
25+
* @param non-empty-string $format
26+
*/
27+
public static function processBatch(array $batch, string $format): bool
28+
{
29+
return true;
30+
}
31+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Services;
6+
7+
class BaseShiftedParentService
8+
{
9+
/**
10+
* Parent constructor has 3 params:
11+
* Index 0: $helper
12+
* Index 1: $definitions
13+
* Index 2: $repositoryMap
14+
*
15+
* @param array<string, string> $definitions
16+
* @param array<string, string> $repositoryMap
17+
*/
18+
public function __construct(
19+
HelperService $helper,
20+
array $definitions,
21+
array $repositoryMap
22+
) {
23+
}
24+
}

0 commit comments

Comments
 (0)