Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.29% covered (warning)
79.29%
111 / 140
30.77% covered (danger)
30.77%
4 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddMissingMethodPhpDocRector
79.29% covered (warning)
79.29%
111 / 140
30.77% covered (danger)
30.77%
4 / 13
92.00
0.00% covered (danger)
0.00%
0 / 1
 getRuleDefinition
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getNodeTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 refactor
91.18% covered (success)
91.18%
31 / 34
0.00% covered (danger)
0.00%
0 / 1
10.07
 normalizeDocblockSpacing
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 resolveTagGroup
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 shouldInsertBlankLineBetweenTagGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExistingParamVariables
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 shouldAddReturnTag
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getExistingThrowsTypes
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 resolveThrows
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 resolveNameToString
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 createDocblockFromReflection
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 resolveTypeToString
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of fast-forward/dev-tools.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/dev-tools
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\DevTools\Rector;
20
21use PHPStan\Reflection\ClassReflection;
22use phpowermove\docblock\Docblock;
23use phpowermove\docblock\tags\ParamTag;
24use phpowermove\docblock\tags\ReturnTag;
25use phpowermove\docblock\tags\ThrowsTag;
26use PhpParser\Comment\Doc;
27use PhpParser\Node;
28use PhpParser\Node\ComplexType;
29use PhpParser\Node\Expr\New_;
30use PhpParser\Node\Identifier;
31use PhpParser\Node\IntersectionType;
32use PhpParser\Node\Name;
33use PhpParser\Node\NullableType;
34use PhpParser\Node\Stmt\ClassMethod;
35use PhpParser\Node\Expr\Throw_;
36use PhpParser\Node\UnionType;
37use PhpParser\NodeFinder;
38use Rector\PHPStan\ScopeFetcher;
39use Rector\Rector\AbstractRector;
40use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
41
42use function Safe\preg_split;
43
44/**
45 * Executes AST inspections parsing missing documentation on methods automatically.
46 * It MUST append `@param`, `@return`, and `@throws` tags where deduced accurately.
47 * The logic SHALL NOT override existing documentation.
48 */
49final class AddMissingMethodPhpDocRector extends AbstractRector
50{
51    /**
52     * Delivers the formal rule description configured within the Rector ecosystem.
53     *
54     * The method MUST accurately describe its functional changes logically.
55     *
56     * @return RuleDefinition explains the rule's active behavior context
57     */
58    public function getRuleDefinition(): RuleDefinition
59    {
60        return new RuleDefinition('Add basic PHPDoc to methods without docblock', [
61            new \Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample(
62                'public function foo() {}',
63                "/**\n * \n */\npublic function foo() {}"
64            )
65        ]);
66    }
67
68    /**
69     * Designates the primary Abstract Syntax Tree (AST) node structures intercepted.
70     *
71     * The method MUST register solely `ClassMethod` class references to guarantee precision.
72     *
73     * @return array<int, class-string<Node>> the structural bindings applicable for this modification
74     */
75    public function getNodeTypes(): array
76    {
77        return [ClassMethod::class];
78    }
79
80    /**
81     * Computes necessary PHPDoc metadata for a given class method selectively.
82     *
83     * The method MUST identify the missing `@param`, `@return`, and `@throws` tags algorithmically.
84     * It SHALL preserve pre-existing valid tags cleanly. If no augmentation is achieved, it returns the node unaltered.
85     *
86     * @param Node $node the target method representation parsed synchronously
87     *
88     * @return Node the refined active syntax instance inclusive of generated documentation
89     */
90    public function refactor(Node $node): Node
91    {
92        if (! $node instanceof ClassMethod) {
93            return $node;
94        }
95
96        $docComment = $node->getDocComment();
97        $docblock = $docComment instanceof Doc
98            ? new Docblock($docComment->getText())
99            : $this->createDocblockFromReflection($node);
100
101        $existingParamVariables = $this->getExistingParamVariables($docblock);
102
103        foreach ($node->params as $param) {
104            $paramName = $this->getName($param->var);
105            if (null === $paramName) {
106                continue;
107            }
108
109            if (\in_array($paramName, $existingParamVariables, true)) {
110                continue;
111            }
112
113            $paramTag = new ParamTag();
114            $paramTag->setType($this->resolveTypeToString($param->type));
115            $paramTag->setVariable($paramName);
116
117            $docblock->appendTag($paramTag);
118        }
119
120        if ($this->shouldAddReturnTag($node, $docblock)) {
121            $returnTag = new ReturnTag();
122            $returnTag->setType($this->resolveTypeToString($node->returnType));
123
124            $docblock->appendTag($returnTag);
125        }
126
127        $existingThrowsTypes = $this->getExistingThrowsTypes($docblock);
128
129        foreach ($this->resolveThrows($node) as $exception) {
130            $normalizedException = ltrim($exception, '\\');
131            if (\in_array($normalizedException, $existingThrowsTypes, true)) {
132                continue;
133            }
134
135            $throwsTag = new ThrowsTag();
136            $throwsTag->setType($exception);
137
138            $docblock->appendTag($throwsTag);
139            $existingThrowsTypes[] = $normalizedException;
140        }
141
142        if ($docblock->isEmpty()) {
143            return $node;
144        }
145
146        $node->setDocComment(new Doc($this->normalizeDocblockSpacing($docblock->toString())));
147
148        return $node;
149    }
150
151    /**
152     * Formats the newly synthesized document block optimally, balancing whitespaces and gaps.
153     *
154     * This method MUST ensure visual spacing between separate tag families (e.g., between param and return).
155     * It SHALL preserve the structural integrity of the PHPDoc format effectively.
156     *
157     * @param string $docblock the unsanitized raw string equivalent of the document block
158     *
159     * @return string the formatted textual content accurately respecting conventions
160     */
161    private function normalizeDocblockSpacing(string $docblock): string
162    {
163        $lines = preg_split('/\R/', $docblock);
164
165        if ([] === $lines) {
166            return $docblock;
167        }
168
169        $normalizedLines = [];
170        $previousTagGroup = null;
171
172        foreach ($lines as $line) {
173            $currentTagGroup = $this->resolveTagGroup($line);
174
175            if (
176                null !== $currentTagGroup
177                && null !== $previousTagGroup
178                && $currentTagGroup !== $previousTagGroup
179                && $this->shouldInsertBlankLineBetweenTagGroups($previousTagGroup, $currentTagGroup)
180                && [] !== $normalizedLines
181                && ' *' !== end($normalizedLines)
182                && '/**' !== end($normalizedLines)
183            ) {
184                $normalizedLines[] = ' *';
185            }
186
187            $normalizedLines[] = $line;
188
189            if (null !== $currentTagGroup) {
190                $previousTagGroup = $currentTagGroup;
191            }
192        }
193
194        return implode("\n", $normalizedLines);
195    }
196
197    /**
198     * Attempts to resolve the functional category inherent to a documentation tag.
199     *
200     * The method MUST parse the string descriptor reliably, extracting the tag intention logically.
201     *
202     * @param string $line the single document property statement being reviewed
203     *
204     * @return string|null the functional label or null if unbound correctly
205     */
206    private function resolveTagGroup(string $line): ?string
207    {
208        $trimmedLine = trim($line);
209
210        if (str_starts_with($trimmedLine, '* @param ')) {
211            return 'param';
212        }
213
214        if (str_starts_with($trimmedLine, '* @return ')) {
215            return 'return';
216        }
217
218        if (str_starts_with($trimmedLine, '* @throws ')) {
219            return 'throws';
220        }
221
222        return null;
223    }
224
225    /**
226     * Concludes if architectural clarity requires an explicit blank interval.
227     *
228     * The method MUST mandate proper line spacing between `@param`, `@return`, and `@throws` groups.
229     *
230     * @param string $previousTagGroup the prior tag context encountered
231     * @param string $currentTagGroup the newly active tag context currently processing
232     *
233     * @return bool true if an empty marker requires insertion natively
234     */
235    private function shouldInsertBlankLineBetweenTagGroups(string $previousTagGroup, string $currentTagGroup): bool
236    {
237        return $previousTagGroup !== $currentTagGroup;
238    }
239
240    /**
241     * Collates variables already declared adequately within the existing documentation base.
242     *
243     * This method MUST retrieve predefined `@param` configurations logically, avoiding collisions.
244     *
245     * @param Docblock $docblock the active parsed commentary structure instance
246     *
247     * @return string[] uniquely filtered established parameters
248     */
249    private function getExistingParamVariables(Docblock $docblock): array
250    {
251        $variables = [];
252
253        foreach ($docblock->getTags('param')->toArray() as $tag) {
254            if (! $tag instanceof ParamTag) {
255                continue;
256            }
257
258            $variable = $tag->getVariable();
259
260            if ('' === $variable) {
261                continue;
262            }
263
264            $variables[] = $variable;
265        }
266
267        return array_values(array_unique($variables));
268    }
269
270    /**
271     * Calculates whether a `@return` tag is fundamentally valid for the given context.
272     *
273     * The method SHALL exclude magic implementations such as `__construct` deliberately.
274     *
275     * @param ClassMethod $node the specific operation structure verified securely
276     * @param Docblock $docblock the connected documentation references
277     *
278     * @return bool indicates validation explicitly approving return blocks selectively
279     */
280    private function shouldAddReturnTag(ClassMethod $node, Docblock $docblock): bool
281    {
282        if ('__construct' === $node->name->toString()) {
283            return false;
284        }
285
286        return ! $docblock->hasTag('return');
287    }
288
289    /**
290     * Assembles all established exceptions logged intentionally within the existing tag array.
291     *
292     * The method MUST enumerate declared `@throws` statements efficiently.
293     *
294     * @param Docblock $docblock the functional parser tree model internally loaded
295     *
296     * @return string[] discovered types of configured operational errors generically
297     */
298    private function getExistingThrowsTypes(Docblock $docblock): array
299    {
300        $types = [];
301
302        foreach ($docblock->getTags('throws')->toArray() as $tag) {
303            if (! $tag instanceof ThrowsTag) {
304                continue;
305            }
306
307            $type = $tag->getType();
308
309            if ('' === $type) {
310                continue;
311            }
312
313            $types[] = ltrim($type, '\\');
314        }
315
316        return array_values(array_unique($types));
317    }
318
319    /**
320     * Parses the architectural scope of an intercepted method to infer exceptional operations natively.
321     *
322     * This method MUST accurately deduce exception creations traversing internal components recursively.
323     * It SHALL strictly return precise, unique internal naming identifiers safely.
324     *
325     * @param ClassMethod $node the active evaluated root target element dynamically instantiated
326     *
327     * @return string[] expected failure objects effectively defined within its contextual boundary
328     */
329    private function resolveThrows(ClassMethod $node): array
330    {
331        if (null === $node->stmts) {
332            return [];
333        }
334
335        $nodeFinder = new NodeFinder();
336
337        /** @var Throw_[] $throwNodes */
338        $throwNodes = $nodeFinder->findInstanceOf($node->stmts, Throw_::class);
339
340        $exceptions = [];
341
342        foreach ($throwNodes as $throwNode) {
343            $throwExpr = $throwNode->expr;
344            
345            if (! $throwExpr instanceof New_) {
346                continue;
347            }
348
349            if (! $throwExpr->class instanceof Name) {
350                continue;
351            }
352
353            $exceptions[] = $this->resolveNameToString($throwExpr->class);
354        }
355
356        return array_values(array_unique($exceptions));
357    }
358
359    /**
360     * Expands Name syntax objects into human-readable string descriptors universally.
361     *
362     * The method MUST handle aliases seamlessly or fallback to base names dependably.
363     *
364     * @param Name $name the structured reference to parse accurately
365     *
366     * @return string the computed class identifier successfully reconstructed
367     */
368    private function resolveNameToString(Name $name): string
369    {
370        $originalName = $name->getAttribute('originalName');
371
372        if ($originalName instanceof Name) {
373            return $originalName->toString();
374        }
375
376        return $name->getLast();
377    }
378
379    /**
380     * Evaluates PHPStan reflection metadata securely deriving original PHPDoc components.
381     *
382     * The method SHOULD establish scope accurately and fetch reliable documentation defaults safely.
383     *
384     * @param ClassMethod $node the associated target structure explicitly handled internally
385     *
386     * @return Docblock the built virtualized docblock reference precisely retrieved natively
387     */
388    private function createDocblockFromReflection(ClassMethod $node): Docblock
389    {
390        $scope = ScopeFetcher::fetch($node);
391        $classReflection = $scope->getClassReflection();
392
393        if (! $classReflection instanceof ClassReflection) {
394            return new Docblock('/** */');
395        }
396
397        $methodName = $this->getName($node->name);
398
399        if (null === $methodName) {
400            return new Docblock('/** */');
401        }
402
403        $nativeReflection = $classReflection->getNativeReflection();
404
405        if (! $nativeReflection->hasMethod($methodName)) {
406            return new Docblock('/** */');
407        }
408
409        $reflectionMethod = $nativeReflection->getMethod($methodName);
410        $reflectionDocComment = $reflectionMethod->getDocComment();
411
412        if (! \is_string($reflectionDocComment) || '' === $reflectionDocComment) {
413            return new Docblock('/** */');
414        }
415
416        return new Docblock($reflectionDocComment);
417    }
418
419    /**
420     * Translates complicated type primitives cleanly back into uniform string declarations consistently.
421     *
422     * The method MUST parse complex combinations including Intersections, Unions natively and securely.
423     *
424     * @param string|Identifier|Name|ComplexType|null $type the original metadata instance safely captured
425     *
426     * @return string the final interpreted designation string explicitly represented safely
427     */
428    private function resolveTypeToString(string|Identifier|Name|ComplexType|null $type): string
429    {
430        if (null === $type) {
431            return 'mixed';
432        }
433
434        if (\is_string($type)) {
435            return $type;
436        }
437
438        if ($type instanceof Identifier) {
439            return $type->toString();
440        }
441
442        if ($type instanceof Name) {
443            $originalName = $type->getAttribute('originalName');
444
445            if ($originalName instanceof Name) {
446                return $originalName->toString();
447            }
448
449            return $type->toString();
450        }
451
452        if ($type instanceof NullableType) {
453            return $this->resolveTypeToString($type->type) . '|null';
454        }
455
456        if ($type instanceof UnionType) {
457            return implode('|', array_map($this->resolveTypeToString(...), $type->types));
458        }
459
460        if ($type instanceof IntersectionType) {
461            return implode('&', array_map($this->resolveTypeToString(...), $type->types));
462        }
463
464        return 'mixed';
465    }
466}