1: | <?php declare(strict_types=1); |
2: | |
3: | namespace Salient\PHPDoc; |
4: | |
5: | use Salient\Contract\Core\Immutable; |
6: | use Salient\Core\Concern\HasMutator; |
7: | use Salient\PHPDoc\Tag\AbstractTag; |
8: | use Salient\PHPDoc\Tag\ErrorTag; |
9: | use Salient\PHPDoc\Tag\GenericTag; |
10: | use Salient\PHPDoc\Tag\MethodParam; |
11: | use Salient\PHPDoc\Tag\MethodTag; |
12: | use Salient\PHPDoc\Tag\ParamTag; |
13: | use Salient\PHPDoc\Tag\PropertyTag; |
14: | use Salient\PHPDoc\Tag\ReturnTag; |
15: | use Salient\PHPDoc\Tag\TemplateTag; |
16: | use Salient\PHPDoc\Tag\VarTag; |
17: | use Salient\Utility\Exception\ShouldNotHappenException; |
18: | use Salient\Utility\Arr; |
19: | use Salient\Utility\Regex; |
20: | use Salient\Utility\Str; |
21: | use InvalidArgumentException; |
22: | use ReflectionClass; |
23: | use ReflectionClassConstant; |
24: | use ReflectionMethod; |
25: | use ReflectionProperty; |
26: | use Stringable; |
27: | |
28: | |
29: | |
30: | |
31: | |
32: | |
33: | |
34: | class PHPDoc implements Immutable, Stringable |
35: | { |
36: | use HasMutator; |
37: | |
38: | private const PHP_DOCBLOCK = '`^' . PHPDocRegex::PHP_DOCBLOCK . '$`D'; |
39: | private const PHPDOC_TAG = '`^' . PHPDocRegex::PHPDOC_TAG . '`D'; |
40: | private const BLANK_OR_PHPDOC_TAG = '`^(?:$|' . PHPDocRegex::PHPDOC_TAG . ')`D'; |
41: | |
42: | private const PARAM_TAG = '`^' |
43: | . '(?:(?<param_type>' . PHPDocRegex::PHPDOC_TYPE . ')\h++)?' |
44: | . '(?<param_reference>&\h*+)?' |
45: | . '(?<param_variadic>\.\.\.\h*+)?' |
46: | . '\$(?<param_name>[^\s]++)' |
47: | . '\s*+(?<param_description>(?s).++)?' |
48: | . '`'; |
49: | |
50: | private const RETURN_TAG = '`^' |
51: | . '(?<return_type>' . PHPDocRegex::PHPDOC_TYPE . ')' |
52: | . '\s*+(?<return_description>(?s).++)?' |
53: | . '`'; |
54: | |
55: | private const VAR_TAG = '`^' |
56: | . '(?<var_type>' . PHPDocRegex::PHPDOC_TYPE . ')' |
57: | . '(?:\h++\$(?<var_name>[^\s]++))?' |
58: | . '\s*+(?<var_description>(?s).++)?' |
59: | . '`'; |
60: | |
61: | private const METHOD_TAG = '`^' |
62: | . '(?<method_static>static\h++)?' |
63: | . '(?:(?<method_type>' . PHPDocRegex::PHPDOC_TYPE . ')\h++)?' |
64: | . '(?<method_name>[^\s(]++)' |
65: | . '\h*+\(\h*+(?:(?<method_params>' |
66: | . '(?<method_param>(?:(?&method_type)\h++)?(?:\.\.\.\h*+)?\$[^\s=,)$]++(?:\h*+=\h*+' . PHPDocRegex::PHPDOC_VALUE . ')?)' |
67: | . '(?:\h*+,\h*+(?&method_param))*+' |
68: | . ')\h*+(?:,\h*+)?)?\)' |
69: | . '\s*+(?<method_description>(?s).++)?' |
70: | . '`'; |
71: | |
72: | private const METHOD_PARAM = '`^' |
73: | . '(?:(?<param_type>' . PHPDocRegex::PHPDOC_TYPE . ')\h++)?' |
74: | . '(?<param_variadic>\.\.\.\h*+)?' |
75: | . '\$(?<param_name>[^\s=,)$]++)' |
76: | . '(?:\h*+=\h*+(?<param_default>' . PHPDocRegex::PHPDOC_VALUE . '))?' |
77: | . '`'; |
78: | |
79: | private const PROPERTY_TAG = '`^' |
80: | . '(?:(?<property_type>' . PHPDocRegex::PHPDOC_TYPE . ')\h++)?' |
81: | . '\$(?<property_name>[^\s]++)' |
82: | . '\s*+(?<property_description>(?s).++)?' |
83: | . '`'; |
84: | |
85: | private const TEMPLATE_TAG = '`^' |
86: | . '(?<template_name>[^\s]++)' |
87: | . '(?:\h++(?:of|as)\h++(?<template_type>' . PHPDocRegex::PHPDOC_TYPE . '))?' |
88: | . '(?:\h++=\h++(?<template_default>(?&template_type)))?' |
89: | . '`'; |
90: | |
91: | |
92: | |
93: | |
94: | protected const DEFAULT_TAG_PREFIXES = [ |
95: | '', |
96: | 'phpstan-', |
97: | 'psalm-', |
98: | ]; |
99: | |
100: | |
101: | |
102: | |
103: | protected const INHERITABLE_TAGS = [ |
104: | |
105: | 'author' => false, |
106: | 'copyright' => false, |
107: | 'version' => false, |
108: | 'package' => false, |
109: | 'param' => true, |
110: | 'return' => true, |
111: | 'throws' => true, |
112: | 'var' => true, |
113: | |
114: | 'method' => true, |
115: | 'property' => true, |
116: | 'property-read' => ['phpstan-', 'psalm-'], |
117: | 'property-write' => ['phpstan-', 'psalm-'], |
118: | 'mixin' => true, |
119: | |
120: | 'param-out' => true, |
121: | 'param-immediately-invoked-callable' => ['', 'phpstan-'], |
122: | 'param-later-invoked-callable' => ['', 'phpstan-'], |
123: | 'param-closure-this' => ['', 'phpstan-'], |
124: | |
125: | 'deprecated' => false, |
126: | 'readonly' => false, |
127: | ]; |
128: | |
129: | |
130: | |
131: | |
132: | protected const INHERITABLE_BY_CLASS_TAGS = [ |
133: | 'method' => true, |
134: | 'property' => true, |
135: | 'property-read' => ['phpstan-', 'psalm-'], |
136: | 'property-write' => ['phpstan-', 'psalm-'], |
137: | 'mixin' => true, |
138: | ]; |
139: | |
140: | |
141: | |
142: | |
143: | protected const MERGED_TAGS = [ |
144: | 'param' => true, |
145: | 'return' => true, |
146: | 'var' => true, |
147: | 'method' => true, |
148: | 'property' => true, |
149: | ]; |
150: | |
151: | |
152: | |
153: | |
154: | protected const STANDARD_TAGS = [ |
155: | 'param', |
156: | 'return', |
157: | 'throws', |
158: | 'var', |
159: | 'template', |
160: | 'template-covariant', |
161: | 'template-contravariant', |
162: | 'api', |
163: | 'internal', |
164: | 'inheritDoc', |
165: | 'readonly', |
166: | ]; |
167: | |
168: | protected ?string $Summary = null; |
169: | protected ?string $Description = null; |
170: | |
171: | protected array $Tags = []; |
172: | |
173: | protected array $Params = []; |
174: | protected ?ReturnTag $Return = null; |
175: | |
176: | protected array $Vars = []; |
177: | |
178: | protected array $Methods = []; |
179: | |
180: | protected array $Properties = []; |
181: | |
182: | protected array $Templates = []; |
183: | |
184: | protected array $InheritedTemplates = []; |
185: | |
186: | protected array $Errors = []; |
187: | |
188: | protected ?string $Class; |
189: | protected ?string $Member; |
190: | |
191: | protected self $Original; |
192: | |
193: | private array $Lines; |
194: | private ?string $NextLine; |
195: | |
196: | private static array $InheritableTagIndex; |
197: | |
198: | private static array $InheritableByClassTagIndex; |
199: | |
200: | |
201: | |
202: | |
203: | |
204: | |
205: | |
206: | |
207: | final public function __construct( |
208: | ?string $docBlock = null, |
209: | $classDocBlock = null, |
210: | ?string $class = null, |
211: | ?string $member = null, |
212: | array $aliases = [] |
213: | ) { |
214: | $this->Class = $class; |
215: | $this->Member = $member; |
216: | $this->Original = $this; |
217: | |
218: | if ($docBlock !== null) { |
219: | if (!Regex::match(self::PHP_DOCBLOCK, $docBlock, $matches)) { |
220: | throw new InvalidArgumentException('Invalid DocBlock'); |
221: | } |
222: | $this->parse($matches['content'], $aliases, $tags); |
223: | if ($tags) { |
224: | $this->updateTags(); |
225: | } |
226: | } |
227: | |
228: | |
229: | if ($classDocBlock !== null && $class !== null && $member !== null) { |
230: | $phpDoc = $classDocBlock instanceof self |
231: | ? $classDocBlock |
232: | : new static($classDocBlock, null, $class, null, $aliases); |
233: | foreach ($phpDoc->Templates as $name => $tag) { |
234: | $this->Templates[$name] ??= $tag; |
235: | } |
236: | } |
237: | } |
238: | |
239: | |
240: | |
241: | |
242: | |
243: | |
244: | |
245: | |
246: | public static function forClass( |
247: | ReflectionClass $class, |
248: | bool $trackInheritance = false, |
249: | array $aliases = [] |
250: | ): self { |
251: | $docBlocks = PHPDocUtil::getAllClassDocComments($class, $trackInheritance); |
252: | return self::fromDocBlocks($docBlocks, null, null, $aliases); |
253: | } |
254: | |
255: | |
256: | |
257: | |
258: | |
259: | |
260: | |
261: | |
262: | public static function forMethod( |
263: | ReflectionMethod $method, |
264: | ?ReflectionClass $class = null, |
265: | array $aliases = [] |
266: | ): self { |
267: | $docBlocks = PHPDocUtil::getAllMethodDocComments($method, $class, $classDocBlocks); |
268: | $name = $method->getName(); |
269: | return self::fromDocBlocks($docBlocks, $classDocBlocks, "{$name}()", $aliases); |
270: | } |
271: | |
272: | |
273: | |
274: | |
275: | |
276: | |
277: | |
278: | |
279: | public static function forProperty( |
280: | ReflectionProperty $property, |
281: | ?ReflectionClass $class = null, |
282: | array $aliases = [] |
283: | ): self { |
284: | $docBlocks = PHPDocUtil::getAllPropertyDocComments($property, $class, $classDocBlocks); |
285: | $name = $property->getName(); |
286: | return self::fromDocBlocks($docBlocks, $classDocBlocks, "\${$name}", $aliases); |
287: | } |
288: | |
289: | |
290: | |
291: | |
292: | |
293: | |
294: | |
295: | |
296: | public static function forConstant( |
297: | ReflectionClassConstant $constant, |
298: | ?ReflectionClass $class = null, |
299: | array $aliases = [] |
300: | ): self { |
301: | $docBlocks = PHPDocUtil::getAllConstantDocComments($constant, $class, $classDocBlocks); |
302: | $name = $constant->getName(); |
303: | return self::fromDocBlocks($docBlocks, $classDocBlocks, $name, $aliases); |
304: | } |
305: | |
306: | |
307: | |
308: | |
309: | |
310: | |
311: | |
312: | |
313: | public static function fromTags( |
314: | array $tags, |
315: | ?string $summary = null, |
316: | ?string $description = null, |
317: | ?string $class = null, |
318: | ?string $member = null |
319: | ): self { |
320: | if ($summary !== null) { |
321: | $summary = Str::coalesce(trim($summary), null); |
322: | } |
323: | if ($description !== null) { |
324: | $description = Str::coalesce(trim($description), null); |
325: | } |
326: | if ($summary === null && $description !== null) { |
327: | throw new InvalidArgumentException('$description must be empty if $summary is empty'); |
328: | } |
329: | |
330: | $phpDoc = new static(null, null, $class, $member); |
331: | $phpDoc->Summary = $summary; |
332: | $phpDoc->Description = $description; |
333: | |
334: | $count = 0; |
335: | foreach ($tags as $tag) { |
336: | if ($tag instanceof ParamTag) { |
337: | $phpDoc->Params[$tag->getName()] = $tag; |
338: | } elseif ($tag instanceof ReturnTag) { |
339: | $phpDoc->Return = $tag; |
340: | } elseif ($tag instanceof VarTag) { |
341: | $name = $tag->getName(); |
342: | if ($name !== null) { |
343: | $phpDoc->Vars[$name] = $tag; |
344: | } else { |
345: | $phpDoc->Vars[] = $tag; |
346: | } |
347: | } elseif ($tag instanceof MethodTag) { |
348: | $phpDoc->Methods[$tag->getName()] = $tag; |
349: | } elseif ($tag instanceof PropertyTag) { |
350: | $phpDoc->Properties[$tag->getName()] = $tag; |
351: | } elseif ($tag instanceof TemplateTag) { |
352: | $_class = $tag->getClass(); |
353: | if ($_class === $class || $_class === null || $class === null) { |
354: | $phpDoc->Templates[$tag->getName()] = $tag; |
355: | } else { |
356: | $phpDoc->InheritedTemplates[$_class][$tag->getName()] = $tag; |
357: | continue; |
358: | } |
359: | } elseif ($tag instanceof ErrorTag) { |
360: | $phpDoc->Errors[] = $tag; |
361: | continue; |
362: | } else { |
363: | $phpDoc->Tags[$tag->getTag()][] = $tag; |
364: | continue; |
365: | } |
366: | |
367: | $phpDoc->Tags[$tag->getTag()] ??= []; |
368: | $count++; |
369: | } |
370: | |
371: | if ($count) { |
372: | $phpDoc->updateTags(); |
373: | } |
374: | |
375: | return $phpDoc; |
376: | } |
377: | |
378: | |
379: | |
380: | |
381: | |
382: | |
383: | |
384: | public function inherit(self $parent) |
385: | { |
386: | |
387: | $byClass = $this->Class !== null |
388: | && $parent->Class !== null |
389: | && $this->Class !== $parent->Class |
390: | && $this->Member === null |
391: | && $parent->Member === null |
392: | && ((interface_exists($parent->Class) && !interface_exists($this->Class)) |
393: | || (trait_exists($parent->Class) && !trait_exists($this->Class))); |
394: | |
395: | $tags = $this->Tags; |
396: | foreach (Arr::flatten($tags) as $tag) { |
397: | $idx[(string) $tag] = true; |
398: | } |
399: | foreach (array_intersect_key( |
400: | $parent->Tags, |
401: | $byClass |
402: | ? self::getInheritableByClassTagIndex() |
403: | : self::getInheritableTagIndex(), |
404: | ) as $name => $theirs) { |
405: | foreach ($theirs as $tag) { |
406: | if (!isset($idx[(string) $tag])) { |
407: | $tags[$name][] = $tag; |
408: | } |
409: | } |
410: | } |
411: | |
412: | if ($this->Member !== null || $this->Class === null) { |
413: | $params = $this->Params; |
414: | foreach ($parent->Params as $name => $theirs) { |
415: | $params[$name] = $this->mergeTag($params[$name] ?? null, $theirs); |
416: | } |
417: | |
418: | $return = $this->mergeTag($this->Return, $parent->Return); |
419: | |
420: | $vars = $this->Vars; |
421: | if ( |
422: | count($parent->Vars) === 1 |
423: | && array_key_first($parent->Vars) === 0 |
424: | ) { |
425: | $vars[0] = $this->mergeTag($vars[0] ?? null, $parent->Vars[0]); |
426: | } |
427: | } |
428: | |
429: | if ($this->Member === null) { |
430: | $methods = $this->Methods; |
431: | foreach ($parent->Methods as $name => $theirs) { |
432: | $methods[$name] = $this->mergeTag($methods[$name] ?? null, $theirs); |
433: | } |
434: | |
435: | $properties = $this->Properties; |
436: | foreach ($parent->Properties as $name => $theirs) { |
437: | $properties[$name] = $this->mergeTag($properties[$name] ?? null, $theirs); |
438: | } |
439: | } |
440: | |
441: | $templates = $this->InheritedTemplates; |
442: | if ($parent->Class !== null && $parent->Class !== $this->Class) { |
443: | unset($templates[$parent->Class]); |
444: | $templates[$parent->Class] = $parent->Templates; |
445: | } |
446: | |
447: | if (!$byClass) { |
448: | $summary = $this->Summary; |
449: | $description = $this->Description; |
450: | if ($description !== null && $parent->Description !== null) { |
451: | $description = Regex::replace( |
452: | '/\{@inherit[Dd]oc\}/', |
453: | $parent->Description, |
454: | $description, |
455: | ); |
456: | } else { |
457: | $summary ??= $parent->Summary; |
458: | $description ??= $parent->Description; |
459: | } |
460: | } |
461: | |
462: | return $this |
463: | ->with('Summary', $summary ?? $this->Summary) |
464: | ->with('Description', $description ?? $this->Description) |
465: | ->with('Tags', $tags) |
466: | ->with('Params', $params ?? $this->Params) |
467: | ->with('Return', $return ?? $this->Return) |
468: | ->with('Vars', $vars ?? $this->Vars) |
469: | ->with('Methods', $methods ?? $this->Methods) |
470: | ->with('Properties', $properties ?? $this->Properties) |
471: | ->with('InheritedTemplates', $templates); |
472: | } |
473: | |
474: | |
475: | |
476: | |
477: | private static function getInheritableTagIndex(): array |
478: | { |
479: | return self::$InheritableTagIndex[static::class] |
480: | ??= self::doGetInheritableTagIndex(static::INHERITABLE_TAGS); |
481: | } |
482: | |
483: | |
484: | |
485: | |
486: | private static function getInheritableByClassTagIndex(): array |
487: | { |
488: | return self::$InheritableByClassTagIndex[static::class] |
489: | ??= self::doGetInheritableTagIndex(static::INHERITABLE_BY_CLASS_TAGS); |
490: | } |
491: | |
492: | |
493: | |
494: | |
495: | |
496: | private static function doGetInheritableTagIndex(array $inheritable): array |
497: | { |
498: | foreach ($inheritable as $tag => $prefixes) { |
499: | if ($prefixes === false) { |
500: | $idx[$tag] = true; |
501: | continue; |
502: | } elseif ($prefixes === true) { |
503: | $prefixes = static::DEFAULT_TAG_PREFIXES; |
504: | } |
505: | foreach ($prefixes as $prefix) { |
506: | $idx[$prefix . $tag] = true; |
507: | } |
508: | } |
509: | |
510: | return array_diff_key($idx, static::MERGED_TAGS); |
511: | } |
512: | |
513: | |
514: | |
515: | |
516: | |
517: | |
518: | |
519: | |
520: | private function mergeTag(?AbstractTag $ours, ?AbstractTag $theirs): ?AbstractTag |
521: | { |
522: | if ($theirs === null) { |
523: | return $ours; |
524: | } |
525: | |
526: | if ($ours === null) { |
527: | return $theirs; |
528: | } |
529: | |
530: | return $ours->inherit($theirs); |
531: | } |
532: | |
533: | |
534: | |
535: | |
536: | |
537: | |
538: | |
539: | |
540: | |
541: | |
542: | |
543: | |
544: | |
545: | |
546: | |
547: | public function normalise() |
548: | { |
549: | $tags = $this->Tags; |
550: | unset($tags['inheritDoc']); |
551: | |
552: | $vars = $this->Vars; |
553: | if (count($vars) === 1) { |
554: | $key = array_key_first($vars); |
555: | $var = $vars[$key]; |
556: | if ( |
557: | $this->Class !== null |
558: | && ($name = $var->getName()) !== null |
559: | && "\${$name}" === $this->Member |
560: | ) { |
561: | $vars = [$var = $var->withName(null)]; |
562: | $key = 0; |
563: | } |
564: | if ($key === 0 && ($varDesc = $var->getDescription()) !== null) { |
565: | $vars = [$var->withDescription(null)]; |
566: | if ($this->Summary === null) { |
567: | $summary = $varDesc; |
568: | } elseif ($this->Summary !== $varDesc) { |
569: | $description = Arr::implode("\n\n", [ |
570: | $this->Description, |
571: | $varDesc, |
572: | ], ''); |
573: | } |
574: | } |
575: | } |
576: | |
577: | return $this |
578: | ->with('Summary', $summary ?? $this->Summary) |
579: | ->with('Description', $description ?? $this->Description) |
580: | ->with('Tags', $tags) |
581: | ->with('Vars', $vars); |
582: | } |
583: | |
584: | |
585: | |
586: | |
587: | |
588: | |
589: | |
590: | public function flatten() |
591: | { |
592: | $phpDoc = clone $this; |
593: | $phpDoc->Original = $phpDoc; |
594: | $phpDoc->InheritedTemplates = []; |
595: | |
596: | return $phpDoc; |
597: | } |
598: | |
599: | |
600: | |
601: | |
602: | private function handlePropertyChanged(string $property): void |
603: | { |
604: | $tag = [ |
605: | 'Params' => 'param', |
606: | 'Return' => 'return', |
607: | 'Vars' => 'var', |
608: | 'Methods' => 'method', |
609: | 'Properties' => 'property', |
610: | 'Templates' => 'template', |
611: | ][$property] ?? null; |
612: | |
613: | if ($tag !== null) { |
614: | $this->updateTags($tag); |
615: | } |
616: | } |
617: | |
618: | private function updateTags(?string $tag = null): void |
619: | { |
620: | if ($tag === 'param' || $tag === null) { |
621: | if ($this->Params) { |
622: | $this->Tags['param'] = array_values($this->Params); |
623: | } else { |
624: | unset($this->Tags['param']); |
625: | } |
626: | } |
627: | |
628: | if ($tag === 'return' || $tag === null) { |
629: | if ($this->Return) { |
630: | $this->Tags['return'] = [$this->Return]; |
631: | } else { |
632: | unset($this->Tags['return']); |
633: | } |
634: | } |
635: | |
636: | if ($tag === 'var' || $tag === null) { |
637: | if ($this->Vars) { |
638: | $this->Tags['var'] = array_values($this->Vars); |
639: | } else { |
640: | unset($this->Tags['var']); |
641: | } |
642: | } |
643: | |
644: | if ($tag === 'method' || $tag === null) { |
645: | if ($this->Methods) { |
646: | $this->Tags['method'] = array_values($this->Methods); |
647: | } else { |
648: | unset($this->Tags['method']); |
649: | } |
650: | } |
651: | |
652: | if ($tag === 'property' || $tag === null) { |
653: | if ($this->Properties) { |
654: | $this->Tags['property'] = array_values($this->Properties); |
655: | } else { |
656: | unset($this->Tags['property']); |
657: | } |
658: | } |
659: | |
660: | if ($tag === 'template' || $tag === null) { |
661: | if ($templates = $this->getTemplates(false)) { |
662: | $this->Tags['template'] = array_values($templates); |
663: | } else { |
664: | unset($this->Tags['template']); |
665: | } |
666: | } |
667: | } |
668: | |
669: | |
670: | |
671: | |
672: | |
673: | |
674: | public function getOriginal() |
675: | { |
676: | return $this->Original; |
677: | } |
678: | |
679: | |
680: | |
681: | |
682: | public function getSummary(): ?string |
683: | { |
684: | return $this->Summary; |
685: | } |
686: | |
687: | |
688: | |
689: | |
690: | public function getDescription(): ?string |
691: | { |
692: | return $this->Description; |
693: | } |
694: | |
695: | |
696: | |
697: | |
698: | public function hasTag(string $name): bool |
699: | { |
700: | return isset($this->Tags[$name]); |
701: | } |
702: | |
703: | |
704: | |
705: | |
706: | |
707: | |
708: | public function getTags(): array |
709: | { |
710: | return $this->Tags; |
711: | } |
712: | |
713: | |
714: | |
715: | |
716: | |
717: | |
718: | public function getParams(): array |
719: | { |
720: | return $this->Params; |
721: | } |
722: | |
723: | |
724: | |
725: | |
726: | |
727: | |
728: | public function hasReturn(): bool |
729: | { |
730: | return $this->Return !== null; |
731: | } |
732: | |
733: | |
734: | |
735: | |
736: | public function getReturn(): ?ReturnTag |
737: | { |
738: | return $this->Return; |
739: | } |
740: | |
741: | |
742: | |
743: | |
744: | |
745: | |
746: | public function getVars(): array |
747: | { |
748: | return $this->Vars; |
749: | } |
750: | |
751: | |
752: | |
753: | |
754: | |
755: | |
756: | public function getMethods(): array |
757: | { |
758: | return $this->Methods; |
759: | } |
760: | |
761: | |
762: | |
763: | |
764: | |
765: | |
766: | public function getProperties(): array |
767: | { |
768: | return $this->Properties; |
769: | } |
770: | |
771: | |
772: | |
773: | |
774: | |
775: | |
776: | public function getTemplates(bool $includeClass = true): array |
777: | { |
778: | if ( |
779: | $includeClass |
780: | || $this->Class === null |
781: | || $this->Member === null |
782: | ) { |
783: | return $this->Templates; |
784: | } |
785: | |
786: | foreach ($this->Templates as $name => $template) { |
787: | if ($template->getMember() !== null) { |
788: | $templates[$name] = $template; |
789: | } |
790: | } |
791: | |
792: | return $templates ?? []; |
793: | } |
794: | |
795: | |
796: | |
797: | |
798: | |
799: | |
800: | public function getTemplatesForTag(AbstractTag $tag): array |
801: | { |
802: | if (!in_array($tag, $this->Tags[$tag->getTag()] ?? [], true)) { |
803: | throw new InvalidArgumentException('Tag does not belong to the PHPDoc'); |
804: | } |
805: | |
806: | $class = $tag->getClass(); |
807: | if ( |
808: | $class === $this->Class |
809: | || $class === null |
810: | || $this->Class === null |
811: | ) { |
812: | return $this->Templates; |
813: | } |
814: | |
815: | return $this->InheritedTemplates[$class] ?? []; |
816: | } |
817: | |
818: | |
819: | |
820: | |
821: | |
822: | |
823: | public function hasErrors(): bool |
824: | { |
825: | return (bool) $this->Errors; |
826: | } |
827: | |
828: | |
829: | |
830: | |
831: | |
832: | |
833: | public function getErrors(): array |
834: | { |
835: | return $this->Errors; |
836: | } |
837: | |
838: | |
839: | |
840: | |
841: | public function getClass(): ?string |
842: | { |
843: | return $this->Class; |
844: | } |
845: | |
846: | |
847: | |
848: | |
849: | public function getMember(): ?string |
850: | { |
851: | return $this->Member; |
852: | } |
853: | |
854: | |
855: | |
856: | |
857: | public function isEmpty(): bool |
858: | { |
859: | return $this->Original->Summary === null |
860: | && $this->Original->Description === null |
861: | && !$this->Original->Tags; |
862: | } |
863: | |
864: | |
865: | |
866: | |
867: | |
868: | public function hasDetail(): bool |
869: | { |
870: | if ($this->Description !== null) { |
871: | return true; |
872: | } |
873: | |
874: | foreach ([$this->Params, [$this->Return], $this->Vars] as $tags) { |
875: | foreach ($tags as $tag) { |
876: | if ( |
877: | $tag |
878: | && ($description = $tag->getDescription()) !== null |
879: | && $description !== $this->Summary |
880: | ) { |
881: | return true; |
882: | } |
883: | } |
884: | } |
885: | |
886: | foreach (array_diff( |
887: | array_keys($this->Tags), |
888: | static::STANDARD_TAGS, |
889: | ) as $key) { |
890: | if (!Regex::match('/^(?:phpstan|psalm)-|^disregard$/D', $key)) { |
891: | return true; |
892: | } |
893: | } |
894: | |
895: | return false; |
896: | } |
897: | |
898: | |
899: | |
900: | |
901: | public function __toString(): string |
902: | { |
903: | $tags = Arr::flatten(Arr::unset($this->Tags, 'template')); |
904: | |
905: | $text = Arr::implode("\n\n", [ |
906: | $this->Summary, |
907: | $this->Description, |
908: | Arr::implode("\n", $this->getTemplates(false), ''), |
909: | Arr::implode("\n", $tags, ''), |
910: | ], ''); |
911: | |
912: | if ($text === '') { |
913: | return '/** */'; |
914: | } |
915: | |
916: | return "/**\n * " . Regex::replace( |
917: | ["/\n(?!\n)/", "/\n(?=\n)/"], |
918: | ["\n * ", "\n *"], |
919: | $text, |
920: | ) . "\n */"; |
921: | } |
922: | |
923: | |
924: | |
925: | |
926: | |
927: | |
928: | |
929: | |
930: | |
931: | |
932: | public static function fromDocBlocks( |
933: | array $docBlocks, |
934: | ?array $classDocBlocks = null, |
935: | ?string $member = null, |
936: | array $aliases = [] |
937: | ): self { |
938: | foreach ($docBlocks as $key => $docBlock) { |
939: | $_phpDoc = new static( |
940: | $docBlock, |
941: | $classDocBlocks[$key] ?? null, |
942: | is_string($key) ? $key : null, |
943: | $member, |
944: | $aliases, |
945: | ); |
946: | |
947: | $phpDoc ??= $_phpDoc; |
948: | if ($_phpDoc !== $phpDoc) { |
949: | $phpDoc = $phpDoc->inherit($_phpDoc); |
950: | } |
951: | } |
952: | |
953: | return $phpDoc ?? new static(); |
954: | } |
955: | |
956: | |
957: | |
958: | |
959: | private function parse(string $content, array $aliases, ?int &$tags): void |
960: | { |
961: | |
962: | |
963: | |
964: | $this->Lines = Regex::split( |
965: | '/\h*+(?:\r\n|\n|\r)/', |
966: | trim(Regex::replace('/(?<=\r\n|\n|\r)\h*+\* ?/', '', $content)), |
967: | ); |
968: | $this->NextLine = Arr::first($this->Lines); |
969: | |
970: | if (!Regex::match(self::PHPDOC_TAG, $this->NextLine)) { |
971: | $this->Summary = Str::coalesce( |
972: | $this->getLinesUntil(self::BLANK_OR_PHPDOC_TAG, true), |
973: | null, |
974: | ); |
975: | |
976: | if ( |
977: | $this->NextLine !== null |
978: | && !Regex::match(self::PHPDOC_TAG, $this->NextLine) |
979: | ) { |
980: | $this->Description = Str::coalesce( |
981: | trim($this->getLinesUntil(self::PHPDOC_TAG)), |
982: | null, |
983: | ); |
984: | } |
985: | } |
986: | |
987: | $tags = 0; |
988: | while ($this->Lines && Regex::match( |
989: | self::PHPDOC_TAG, |
990: | $text = $this->getLinesUntil(self::PHPDOC_TAG), |
991: | $matches, |
992: | )) { |
993: | |
994: | $text = trim(substr($text, strlen($matches[0]))); |
995: | $tag = ltrim($matches['tag'], '\\'); |
996: | |
997: | try { |
998: | switch ($tag) { |
999: | |
1000: | case 'param': |
1001: | if (!Regex::match(self::PARAM_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) { |
1002: | $this->throw('Invalid syntax', $tag); |
1003: | } |
1004: | |
1005: | $name = $matches['param_name']; |
1006: | $this->Params[$name] = new ParamTag( |
1007: | $name, |
1008: | $matches['param_type'], |
1009: | $matches['param_reference'] !== null, |
1010: | $matches['param_variadic'] !== null, |
1011: | $matches['param_description'], |
1012: | $this->Class, |
1013: | $this->Member, |
1014: | $aliases, |
1015: | ); |
1016: | break; |
1017: | |
1018: | |
1019: | case 'return': |
1020: | if (!Regex::match(self::RETURN_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) { |
1021: | $this->throw('Invalid syntax', $tag); |
1022: | } |
1023: | |
1024: | $type = $matches['return_type']; |
1025: | $this->Return = new ReturnTag( |
1026: | $type, |
1027: | $matches['return_description'], |
1028: | $this->Class, |
1029: | $this->Member, |
1030: | $aliases, |
1031: | ); |
1032: | break; |
1033: | |
1034: | |
1035: | case 'var': |
1036: | if (!Regex::match(self::VAR_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) { |
1037: | $this->throw('Invalid syntax', $tag); |
1038: | } |
1039: | |
1040: | $type = $matches['var_type']; |
1041: | $name = $matches['var_name']; |
1042: | $var = new VarTag( |
1043: | $type, |
1044: | $name, |
1045: | $matches['var_description'], |
1046: | $this->Class, |
1047: | $this->Member, |
1048: | $aliases, |
1049: | ); |
1050: | if ($name !== null) { |
1051: | $this->Vars[$name] = $var; |
1052: | } else { |
1053: | $this->Vars[] = $var; |
1054: | } |
1055: | break; |
1056: | |
1057: | |
1058: | case 'method': |
1059: | if (!Regex::match(self::METHOD_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) { |
1060: | $this->throw('Invalid syntax', $tag); |
1061: | } |
1062: | |
1063: | $name = $matches['method_name']; |
1064: | $isStatic = $matches['method_static'] !== null; |
1065: | $type = $matches['method_type']; |
1066: | if ($isStatic && $type === null) { |
1067: | $isStatic = false; |
1068: | $type = 'static'; |
1069: | } |
1070: | $params = []; |
1071: | if ($matches['method_params'] !== null) { |
1072: | foreach (Str::splitDelimited( |
1073: | ',', |
1074: | $matches['method_params'], |
1075: | false, |
1076: | null, |
1077: | Str::PRESERVE_QUOTED, |
1078: | ) as $param) { |
1079: | if (!Regex::match(self::METHOD_PARAM, $param, $paramMatches, \PREG_UNMATCHED_AS_NULL)) { |
1080: | |
1081: | throw new ShouldNotHappenException(sprintf( |
1082: | '@method parameter parsing failed: %s', |
1083: | $text, |
1084: | )); |
1085: | |
1086: | } |
1087: | |
1088: | $_name = $paramMatches['param_name']; |
1089: | $params[$_name] = new MethodParam( |
1090: | $_name, |
1091: | $paramMatches['param_type'], |
1092: | $paramMatches['param_default'], |
1093: | $paramMatches['param_variadic'] !== null, |
1094: | ); |
1095: | } |
1096: | } |
1097: | $this->Methods[$name] = new MethodTag( |
1098: | $name, |
1099: | $type, |
1100: | $params, |
1101: | $isStatic, |
1102: | $matches['method_description'], |
1103: | $this->Class, |
1104: | $this->Member, |
1105: | $aliases, |
1106: | ); |
1107: | break; |
1108: | |
1109: | |
1110: | case 'property-read': |
1111: | case 'property-write': |
1112: | case 'property': |
1113: | if (!Regex::match(self::PROPERTY_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) { |
1114: | $this->throw('Invalid syntax', $tag); |
1115: | } |
1116: | |
1117: | $name = $matches['property_name']; |
1118: | $this->Properties[$name] = new PropertyTag( |
1119: | $name, |
1120: | $matches['property_type'], |
1121: | $tag === 'property-read', |
1122: | $tag === 'property-write', |
1123: | $matches['property_description'], |
1124: | $this->Class, |
1125: | $this->Member, |
1126: | $aliases, |
1127: | ); |
1128: | $tag = 'property'; |
1129: | break; |
1130: | |
1131: | |
1132: | case 'template-covariant': |
1133: | case 'template-contravariant': |
1134: | case 'template': |
1135: | if (!Regex::match(self::TEMPLATE_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) { |
1136: | $this->throw('Invalid syntax', $tag); |
1137: | } |
1138: | |
1139: | $name = $matches['template_name']; |
1140: | $this->Templates[$name] = new TemplateTag( |
1141: | $name, |
1142: | $matches['template_type'], |
1143: | $matches['template_default'], |
1144: | $tag === 'template-covariant', |
1145: | $tag === 'template-contravariant', |
1146: | $this->Class, |
1147: | $this->Member, |
1148: | $aliases, |
1149: | ); |
1150: | $tag = 'template'; |
1151: | break; |
1152: | |
1153: | default: |
1154: | $this->Tags[$tag][] = new GenericTag( |
1155: | $tag, |
1156: | Str::coalesce($text, null), |
1157: | $this->Class, |
1158: | $this->Member, |
1159: | ); |
1160: | continue 2; |
1161: | } |
1162: | } catch (InvalidArgumentException $ex) { |
1163: | $this->Errors[] = new ErrorTag( |
1164: | $tag, |
1165: | $ex->getMessage(), |
1166: | Str::coalesce($text, null), |
1167: | $this->Class, |
1168: | $this->Member, |
1169: | ); |
1170: | continue; |
1171: | } |
1172: | |
1173: | $this->Tags[$tag] ??= []; |
1174: | $tags++; |
1175: | } |
1176: | |
1177: | unset($this->Lines, $this->NextLine); |
1178: | } |
1179: | |
1180: | |
1181: | |
1182: | |
1183: | |
1184: | |
1185: | |
1186: | |
1187: | |
1188: | |
1189: | private function getLinesUntil(string $pattern, bool $unwrap = false): string |
1190: | { |
1191: | if (!$this->Lines) { |
1192: | |
1193: | throw new ShouldNotHappenException('No more lines'); |
1194: | |
1195: | } |
1196: | |
1197: | $lines = []; |
1198: | $inFence = false; |
1199: | |
1200: | do { |
1201: | $lines[] = $line = array_shift($this->Lines); |
1202: | $this->NextLine = Arr::first($this->Lines); |
1203: | |
1204: | if (!$unwrap) { |
1205: | if ( |
1206: | (!$inFence && Regex::match('/^(```+|~~~+)/', $line, $fence)) |
1207: | || ($inFence && isset($fence[0]) && $line === $fence[0]) |
1208: | ) { |
1209: | $inFence = !$inFence; |
1210: | } |
1211: | |
1212: | if ($inFence) { |
1213: | continue; |
1214: | } |
1215: | } |
1216: | |
1217: | if ($this->NextLine === null) { |
1218: | break; |
1219: | } |
1220: | |
1221: | if (Regex::match($pattern, $this->NextLine)) { |
1222: | break; |
1223: | } |
1224: | } while ($this->Lines); |
1225: | |
1226: | if ($inFence && isset($fence[0])) { |
1227: | $lines[] = $fence[0]; |
1228: | } |
1229: | |
1230: | return implode($unwrap ? ' ' : "\n", $lines); |
1231: | } |
1232: | |
1233: | |
1234: | |
1235: | |
1236: | |
1237: | private function throw(string $message, ?string $tag, ...$args): void |
1238: | { |
1239: | if ($tag !== null) { |
1240: | $message .= ' for @%s'; |
1241: | $args[] = $tag; |
1242: | } |
1243: | |
1244: | $message .= ' in DocBlock'; |
1245: | |
1246: | if (isset($this->Class)) { |
1247: | $message .= ' of %s'; |
1248: | $args[] = $this->Class; |
1249: | if (isset($this->Member)) { |
1250: | $message .= '::%s'; |
1251: | $args[] = $this->Member; |
1252: | } |
1253: | } |
1254: | |
1255: | throw new InvalidArgumentException(sprintf($message, ...$args)); |
1256: | } |
1257: | } |
1258: | |