Skip to content

Commit 17a751a

Browse files
committed
Enhance magic method and property handling
- Implemented validation for magic methods and properties in ParamChecker and ReturnChecker. - Added support for dynamic @method and @Property annotations in DocblockExtractor. - Introduced new tests for inherited magic methods and properties. - Improved error handling for non-existent methods and properties. - Updated configuration to manage magic methods and properties validation. - Refactored SpecialTypeResolver to handle reflection context more gracefully. - Added new fixtures for testing dynamic method and property behavior.
1 parent 5e1cbb2 commit 17a751a

27 files changed

Lines changed: 611 additions & 127 deletions

src/Command/ConfigInitCommand.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ private static function getTemplate(): string
6464
'params' => true,
6565
'returns' => true,
6666
67+
/*
68+
|--------------------------------------------------------------------------
69+
| Magic Annotations (@property & @method)
70+
|--------------------------------------------------------------------------
71+
| Enforces class-level annotations for dynamic properties and magic methods
72+
| routed through __get, __set, __call, and __callStatic.
73+
*/
74+
'magic_properties' => true,
75+
'magic_methods' => true,
76+
6777
/*
6878
|--------------------------------------------------------------------------
6979
| Respect Ignore Docblock Tags

src/Contract/ContractParser.php

Lines changed: 173 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ final class ContractParser
3737
*/
3838
private static array $propertyCache = [];
3939

40+
/**
41+
* Cache for resolved magic method contracts.
42+
*
43+
* @var array<string, ?array{return: ?TypeNode, parameters: array<int, array{name: string, type: ?TypeNode, isVariadic: bool, isOptional: bool}>, aliases: array<string, TypeNode>, templates: array<string, TemplateTagValueNode>}>
44+
*/
45+
private static array $magicMethodCache = [];
46+
47+
/**
48+
* Resets the contract and property caches. Useful for test isolation or config changes.
49+
*/
50+
public static function reset(): void
51+
{
52+
self::$cache = [];
53+
self::$propertyCache = [];
54+
self::$magicMethodCache = [];
55+
}
56+
4057
/**
4158
* Parses PHPDoc contracts for a function or class method.
4259
*
@@ -86,6 +103,9 @@ public static function parse(string $function): array
86103
/**
87104
* Parses and resolves the @var docblock for a given class property (including PHP 8.4 interface properties).
88105
*/
106+
/**
107+
* Parses and resolves the @var or @property docblock for a given class property.
108+
*/
89109
public static function parseProperty(string $className, string $propertyName): ?TypeNode
90110
{
91111
$cacheKey = $className . '::$' . $propertyName;
@@ -103,8 +123,10 @@ public static function parseProperty(string $className, string $propertyName): ?
103123

104124
$doc = false;
105125
$declaringClass = null;
126+
$typeNode = null;
127+
$isMagicProperty = false;
106128

107-
// Search Class and Parent Class Hierarchy
129+
// 1. Search Class and Parent Class Hierarchy for physical properties
108130
$current = $refClass;
109131
while ($current !== false) {
110132
if ($current->hasProperty($propertyName)) {
@@ -120,7 +142,7 @@ public static function parseProperty(string $className, string $propertyName): ?
120142
$current = $current->getParentClass();
121143
}
122144

123-
// Search Implemented Interfaces (PHP 8.4 Interface Properties)
145+
// 2. Search Implemented Interfaces (PHP 8.4 Interface Properties)
124146
if ($doc === false) {
125147
foreach ($refClass->getInterfaces() as $interface) {
126148
if ($interface->hasProperty($propertyName)) {
@@ -136,6 +158,25 @@ public static function parseProperty(string $className, string $propertyName): ?
136158
}
137159
}
138160

161+
// 3. NEW: Fallback to class-level magic @property tags if enabled and physical property not found
162+
if ($doc === false && (bool) (Config::get()['magic_properties'] ?? true)) {
163+
$classHierarchy = HierarchyResolver::getClassHierarchy($refClass);
164+
foreach ($classHierarchy as $hierClass) {
165+
$classDoc = $hierClass->getDocComment();
166+
if ($classDoc !== false) {
167+
$extractedType = DocblockExtractor::extractTypeFromClassPropertyDoc($classDoc, $propertyName);
168+
if ($extractedType !== null) {
169+
$doc = $classDoc;
170+
$declaringClass = $hierClass;
171+
$typeNode = $extractedType;
172+
$isMagicProperty = true;
173+
174+
break;
175+
}
176+
}
177+
}
178+
}
179+
139180
if ($doc === false || $declaringClass === null) {
140181
return self::$propertyCache[$cacheKey] = null;
141182
}
@@ -146,20 +187,30 @@ public static function parseProperty(string $className, string $propertyName): ?
146187
return self::$propertyCache[$cacheKey] = null;
147188
}
148189

149-
$phpDocNode = DocblockExtractor::parseDocString($doc);
150-
$varTags = $phpDocNode->getVarTagValues();
190+
// 4. Parse physical @var tags if not already resolved as a magic property
191+
if (! $isMagicProperty) {
192+
$phpDocNode = DocblockExtractor::parseDocString($doc);
193+
$varTags = $phpDocNode->getVarTagValues();
151194

152-
if (\count($varTags) === 0) {
153-
return self::$propertyCache[$cacheKey] = null;
195+
if (\count($varTags) === 0) {
196+
return self::$propertyCache[$cacheKey] = null;
197+
}
198+
199+
$typeNode = $varTags[0]->type;
154200
}
155201

156-
$typeNode = $varTags[0]->type;
202+
if ($typeNode === null) {
203+
return self::$propertyCache[$cacheKey] = null;
204+
}
157205

158206
$aliases = [];
159207
$templates = [];
160208
self::parseClassLevelDocs($declaringClass, $templates, $aliases);
161209

162-
DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass);
210+
if (! $isMagicProperty) {
211+
$phpDocNode = DocblockExtractor::parseDocString($doc);
212+
DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass);
213+
}
163214

164215
$typeNode = self::substituteAliases($typeNode, $aliases);
165216
$resolvedNode = SpecialTypeResolver::resolve($typeNode, $declaringClass);
@@ -170,6 +221,117 @@ public static function parseProperty(string $className, string $propertyName): ?
170221
}
171222
}
172223

224+
/**
225+
* Parses and resolves a class-level @method docblock for __call / __callStatic.
226+
*/
227+
public static function parseMagicMethod(string $className, string $methodName): ?array
228+
{
229+
$cacheKey = $className . '::' . $methodName;
230+
if (\array_key_exists($cacheKey, self::$magicMethodCache)) {
231+
return self::$magicMethodCache[$cacheKey];
232+
}
233+
234+
if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) {
235+
return self::$magicMethodCache[$cacheKey] = null;
236+
}
237+
238+
try {
239+
/** @var class-string<object> $className */
240+
$refClass = new \ReflectionClass($className);
241+
$doc = false;
242+
$declaringClass = null;
243+
$methodTag = null;
244+
245+
$classHierarchy = HierarchyResolver::getClassHierarchy($refClass);
246+
foreach ($classHierarchy as $hierClass) {
247+
$fileName = $hierClass->getFileName();
248+
if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
249+
continue;
250+
}
251+
252+
$classDoc = $hierClass->getDocComment();
253+
if ($classDoc !== false) {
254+
$tag = DocblockExtractor::extractMagicMethodContract($classDoc, $methodName);
255+
if ($tag !== null) {
256+
$doc = $classDoc;
257+
$declaringClass = $hierClass;
258+
$methodTag = $tag;
259+
260+
break;
261+
}
262+
}
263+
}
264+
265+
if ($methodTag === null || $declaringClass === null) {
266+
return self::$magicMethodCache[$cacheKey] = null;
267+
}
268+
269+
$shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true);
270+
if ($shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'))) {
271+
return self::$magicMethodCache[$cacheKey] = null;
272+
}
273+
274+
$aliases = [];
275+
$templates = [];
276+
self::parseClassLevelDocs($declaringClass, $templates, $aliases);
277+
278+
$phpDocNode = DocblockExtractor::parseDocString($doc);
279+
DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass);
280+
281+
$resolvedReturn = null;
282+
if ($methodTag->returnType !== null) {
283+
$subReturn = self::substituteAliases($methodTag->returnType, $aliases);
284+
$resolvedReturn = SpecialTypeResolver::resolve($subReturn, $declaringClass);
285+
}
286+
287+
$resolvedParams = [];
288+
foreach ($methodTag->parameters as $p) {
289+
$pType = $p->type ?? null;
290+
if ($pType !== null) {
291+
$subType = self::substituteAliases($pType, $aliases);
292+
$pType = SpecialTypeResolver::resolve($subType, $declaringClass);
293+
}
294+
295+
$rawParamName = '';
296+
$pVars = get_object_vars($p);
297+
foreach ($pVars as $key => $val) {
298+
if (\is_string($val) && str_starts_with($val, '$')) {
299+
$rawParamName = $val;
300+
301+
break;
302+
}
303+
}
304+
if ($rawParamName === '') {
305+
foreach (['parameterName', 'name', 'paramName', 'varName'] as $key) {
306+
if (isset($pVars[$key]) && \is_string($pVars[$key])) {
307+
$rawParamName = $pVars[$key];
308+
309+
break;
310+
}
311+
}
312+
}
313+
314+
$pName = ltrim($rawParamName, '$');
315+
316+
$resolvedParams[] = [
317+
'name' => $pName,
318+
'type' => $pType,
319+
'isVariadic' => $p->isVariadic ?? false,
320+
'isOptional' => (isset($p->isOptional) ? (bool) $p->isOptional : false) || (($p->defaultValue ?? null) !== null),
321+
];
322+
}
323+
324+
return self::$magicMethodCache[$cacheKey] = [
325+
'return' => $resolvedReturn,
326+
'parameters' => $resolvedParams,
327+
'aliases' => $aliases,
328+
'templates' => $templates,
329+
];
330+
} catch (\Throwable $e) {
331+
return self::$magicMethodCache[$cacheKey] = null;
332+
}
333+
}
334+
173335
/**
174336
* Extracts and returns all class-level type aliases for a given class.
175337
*
@@ -461,7 +623,7 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN
461623
if ($node instanceof GenericTypeNode) {
462624
$genericType = self::substituteAliases($node->type, $aliases);
463625
$genericTypes = array_map(
464-
fn($t) => self::substituteAliases($t, $aliases),
626+
fn ($t) => self::substituteAliases($t, $aliases),
465627
$node->genericTypes
466628
);
467629

@@ -478,14 +640,14 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN
478640

479641
if ($node instanceof UnionTypeNode) {
480642
return new UnionTypeNode(array_map(
481-
fn($t) => self::substituteAliases($t, $aliases),
643+
fn ($t) => self::substituteAliases($t, $aliases),
482644
$node->types
483645
));
484646
}
485647

486648
if ($node instanceof IntersectionTypeNode) {
487649
return new IntersectionTypeNode(array_map(
488-
fn($t) => self::substituteAliases($t, $aliases),
650+
fn ($t) => self::substituteAliases($t, $aliases),
489651
$node->types
490652
));
491653
}

src/Contract/DocblockExtractor.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,14 @@ public static function extractAliases(
115115
\ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref
116116
): void {
117117
foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) {
118-
if (!isset($aliases[$aliasTag->alias])) {
118+
if (! isset($aliases[$aliasTag->alias])) {
119119
$aliases[$aliasTag->alias] = $aliasTag->type;
120120
}
121121
}
122122

123123
foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) {
124124
$localName = $importTag->importedAs ?? $importTag->importedAlias;
125-
if (!isset($aliases[$localName])) {
125+
if (! isset($aliases[$localName])) {
126126
$fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref);
127127
$resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias);
128128
if ($resolvedType !== null) {

0 commit comments

Comments
 (0)