@@ -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 }
0 commit comments