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
33 changes: 15 additions & 18 deletions src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public static function parse(string $function): array
if (str_contains($function, '::')) {
[$className, $methodName] = explode('::', $function, 2);

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

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

if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) {
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) {
return self::$propertyCache[$cacheKey] = null;
}

Expand Down Expand Up @@ -227,10 +221,6 @@ public static function parseProperty(string $className, string $propertyName): ?
/**
* Parses and resolves a class-level @method docblock for __call / __callStatic.
*
* Resolution Steps:
* 1. Search class, parent, interface, and trait hierarchy for @method tags (excluding vendor files).
* 2. Substitute type aliases and resolve FQCNs for parameters and return types.
*
* @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
*/
public static function parseMagicMethod(string $className, string $methodName): ?array
Expand All @@ -240,7 +230,7 @@ public static function parseMagicMethod(string $className, string $methodName):
return self::$magicMethodCache[$cacheKey];
}

if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) {
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) {
return self::$magicMethodCache[$cacheKey] = null;
}

Expand Down Expand Up @@ -349,7 +339,7 @@ public static function parseMagicMethod(string $className, string $methodName):
*/
public static function parseClassAliases(string $className): array
{
if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className)) {
if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) {
return [];
}

Expand Down Expand Up @@ -547,9 +537,16 @@ private static function parseMethodHierarchyDocs(
$targetParamName = $paramName;
} else {
$paramIndex = $hierNameToIndex[$paramName] ?? null;
$targetParamName = ($paramIndex !== null && isset($baseParamNames[$paramIndex]))
? $baseParamNames[$paramIndex]
: null;
if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) {
$candidateName = $baseParamNames[$paramIndex];
if (! isset($hierNameToIndex[$candidateName])) {
$targetParamName = $candidateName;
} else {
$targetParamName = null;
}
} else {
$targetParamName = null;
}
}

if ($targetParamName !== null && ! isset($types[$targetParamName])) {
Expand Down Expand Up @@ -609,7 +606,7 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref
*
* @param array<string, TypeNode> $aliases
*/
private static function substituteAliases(TypeNode $node, array $aliases): TypeNode
public static function substituteAliases(TypeNode $node, array $aliases): TypeNode
{
if ($node instanceof IdentifierTypeNode) {
if (isset($aliases[$node->name])) {
Expand Down
18 changes: 12 additions & 6 deletions src/Contract/DocblockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,19 @@ public static function extractAliases(
}
}
}

// Expand nested/imported alias references inside extracted local aliases
foreach ($aliases as $name => $type) {
$aliases[$name] = ContractParser::substituteAliases($type, $aliases);
}
}

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

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

foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) {
if ($aliasTag->alias === $importedAlias) {
return $aliasTag->type;
}
$targetAliases = [];
self::extractAliases($phpDocNode, $targetAliases, $ref);

if (isset($targetAliases[$importedAlias])) {
return $targetAliases[$importedAlias];
}

foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) {
Expand Down
2 changes: 2 additions & 0 deletions src/Internal/DocblockNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ final class DocblockNormalizer
*/
public static function normalize(string $doc): string
{
$doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc;

$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;

if (! str_contains($doc, '{')) {
Expand Down
7 changes: 6 additions & 1 deletion src/Internal/Visitor/FunctionContractInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node):
return; // Skip injecting contract checks for this specific function/method!
}

$methodName = $isClassMethod ? strtolower($node->name->toString()) : '';
$isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true);

$hasParam = $isClassMethod || str_contains($docText, '@param');
$hasReturn = $isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return');

// Never inject return checks into constructors, destructors, or clone methods
$hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return'));

if (! $hasParam && ! $hasReturn) {
return;
Expand Down
27 changes: 22 additions & 5 deletions src/Resolver/SpecialTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
*/
public static function resolveForFile(TypeNode $node, string $file): TypeNode
{
$file = str_replace('\\', '/', $file);

if ($node instanceof ThisTypeNode) {
return clone $node;
}
Expand Down Expand Up @@ -256,7 +258,7 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio
if (str_contains($context, '::')) {
[$className, $methodName] = explode('::', $context, 2);

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

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

private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null
{
if (class_exists($fqcn) || interface_exists($fqcn)) {
if (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn)) {
try {
$refClass = new \ReflectionClass($fqcn);
if ($refClass->hasConstant($constName)) {
Expand All @@ -671,6 +673,7 @@ private static function resolveConstantKeyValue(string $fqcn, string $constName)
public static function seedFileMetadata(string $fileName, string $namespace, array $imports): void
{
if ($fileName !== '') {
$fileName = str_replace('\\', '/', $fileName);
self::$fileNamespaces[$fileName] = $namespace;
self::$fileUseImports[$fileName] = $imports;
}
Expand Down Expand Up @@ -700,14 +703,20 @@ public static function getUseImports(\ReflectionClass|\ReflectionFunction|\Refle
*/
public static function getUseImportsFromFile(string $fileName): array
{
if ($fileName === '' || ! file_exists($fileName)) {
if ($fileName === '') {
return [];
}

$fileName = str_replace('\\', '/', $fileName);

if (isset(self::$fileUseImports[$fileName])) {
return self::$fileUseImports[$fileName];
}

if (! file_exists($fileName)) {
return [];
}

$source = file_get_contents($fileName);
if ($source === false) {
return self::$fileUseImports[$fileName] = [];
Expand All @@ -723,14 +732,20 @@ public static function getUseImportsFromFile(string $fileName): array
*/
public static function getNamespaceFromFile(string $fileName): string
{
if ($fileName === '' || ! file_exists($fileName)) {
if ($fileName === '') {
return '';
}

$fileName = str_replace('\\', '/', $fileName);

if (isset(self::$fileNamespaces[$fileName])) {
return self::$fileNamespaces[$fileName];
}

if (! file_exists($fileName)) {
return '';
}

$source = file_get_contents($fileName);
if ($source === false) {
return self::$fileNamespaces[$fileName] = '';
Expand Down Expand Up @@ -790,6 +805,8 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun
*/
public static function resolveFqcnForFile(string $name, string $file): string
{
$file = str_replace('\\', '/', $file);

if (self::isBuiltInTypeKeyword($name)) {
return $name;
}
Expand Down
36 changes: 36 additions & 0 deletions tests/Contract/DocblockExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use TypePHP\Contract\DocblockExtractor;
use TypePHP\Tests\Fixtures\Services\UserService;
use TypePHP\Tests\Fixtures\Shopware\Metric\Type as MetricTypeEnum;
use TypePHP\Tests\Fixtures\Types\NestedAliasChainedB;
use TypePHP\Tests\Fixtures\Types\NestedAliasService;
use TypePHP\Tests\Fixtures\Types\UserApi;

describe('DocblockExtractor Unit Tests', function () {
Expand Down Expand Up @@ -57,6 +60,39 @@
expect($aliases)->toHaveKey('LocalUserShape');
});

test('resolves imported type aliases from Enums', function () {
$resolvedNode = DocblockExtractor::resolveImportedTypeAlias(MetricTypeEnum::class, 'MetricTypeValues');

expect($resolvedNode)->not()->toBeNull()
->and((string) $resolvedNode)->toContain('histogram')
;
});

test('resolves multi-tier chained imported type aliases (A -> B -> C)', function () {
$resolvedNode = DocblockExtractor::resolveImportedTypeAlias(NestedAliasChainedB::class, 'MidShape');

expect($resolvedNode)->not()->toBeNull()
->and((string) $resolvedNode)->toContain('positive-int')
->and((string) $resolvedNode)->toContain('non-empty-string')
;
});

test('fully expands nested alias dependencies when extracting aliases from a class', function () {
$ref = new ReflectionClass(NestedAliasService::class);
$doc = $ref->getDocComment();
expect($doc)->not()->toBeFalse();

$phpDocNode = DocblockExtractor::parseDocString($doc);
$aliases = [];

DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref);

expect($aliases)->toHaveKey('LocalRecordList')
->and((string) $aliases['LocalRecordList'])->toContain('positive-int')
->and((string) $aliases['LocalRecordList'])->toContain('active')
;
});

test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () {
$doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list<string> \$tags\n */";

Expand Down
13 changes: 13 additions & 0 deletions tests/Fixtures/Services/BaseShiftedAbstractService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Services;

abstract class BaseShiftedAbstractService implements ShiftedInterfaceContract
{
/**
* @param list<positive-int> $items
*/
abstract public function processItems(array $items): bool;
}
31 changes: 31 additions & 0 deletions tests/Fixtures/Services/BaseShiftedMethodService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Services;

class BaseShiftedMethodService
{
/**
* Parent defines positional order: $id, $name, $options
*
* @param positive-int $id
* @param non-empty-string $name
* @param array{active: bool} $options
*/
public function updateUser(int $id, string $name, array $options = []): bool
{
return true;
}

/**
* Static method with positional order: $batch, $format
*
* @param list<positive-int> $batch
* @param non-empty-string $format
*/
public static function processBatch(array $batch, string $format): bool
{
return true;
}
}
24 changes: 24 additions & 0 deletions tests/Fixtures/Services/BaseShiftedParentService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Services;

class BaseShiftedParentService
{
/**
* Parent constructor has 3 params:
* Index 0: $helper
* Index 1: $definitions
* Index 2: $repositoryMap
*
* @param array<string, string> $definitions
* @param array<string, string> $repositoryMap
*/
public function __construct(
HelperService $helper,
array $definitions,
array $repositoryMap
) {
}
}
Loading
Loading