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: * A PSR-5 PHPDoc
30: *
31: * Summaries that break over multiple lines are unwrapped. Descriptions and tags
32: * may contain Markdown, including fenced code blocks.
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: * @var non-empty-array<string>
93: */
94: protected const DEFAULT_TAG_PREFIXES = [
95: '',
96: 'phpstan-',
97: 'psalm-',
98: ];
99:
100: /**
101: * @var non-empty-array<string,non-empty-array<string>|bool>
102: */
103: protected const INHERITABLE_TAGS = [
104: // PSR-19 Section 4
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: // "Magic" methods and properties
114: 'method' => true,
115: 'property' => true,
116: 'property-read' => ['phpstan-', 'psalm-'],
117: 'property-write' => ['phpstan-', 'psalm-'],
118: 'mixin' => true,
119: // Special parameters
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: * @var non-empty-array<string,non-empty-array<string>|bool>
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: * @var array<string,true>
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: * @var string[]
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: /** @var array<string,AbstractTag[]> */
171: protected array $Tags = [];
172: /** @var array<string,ParamTag> */
173: protected array $Params = [];
174: protected ?ReturnTag $Return = null;
175: /** @var VarTag[] */
176: protected array $Vars = [];
177: /** @var array<string,MethodTag> */
178: protected array $Methods = [];
179: /** @var array<string,PropertyTag> */
180: protected array $Properties = [];
181: /** @var array<string,TemplateTag> */
182: protected array $Templates = [];
183: /** @var array<class-string,array<string,TemplateTag>> */
184: protected array $InheritedTemplates = [];
185: /** @var ErrorTag[] */
186: protected array $Errors = [];
187: /** @var class-string|null */
188: protected ?string $Class;
189: protected ?string $Member;
190: /** @var static */
191: protected self $Original;
192: /** @var string[] */
193: private array $Lines;
194: private ?string $NextLine;
195: /** @var array<class-string<self>,array<string,true>> */
196: private static array $InheritableTagIndex;
197: /** @var array<class-string<self>,array<string,true>> */
198: private static array $InheritableByClassTagIndex;
199:
200: /**
201: * Creates a new PHPDoc object from a PHP DocBlock
202: *
203: * @param self|string|null $classDocBlock
204: * @param class-string|null $class
205: * @param array<string,class-string> $aliases
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: // Merge templates from the declaring class, if possible
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: * Creates a new PHPDoc object for a class from its doc comments
241: *
242: * @param ReflectionClass<object> $class
243: * @param array<string,class-string> $aliases
244: * @return static
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: * Creates a new PHPDoc object for a method from its doc comments
257: *
258: * @param ReflectionClass<object>|null $class
259: * @param array<string,class-string> $aliases
260: * @return static
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: * Creates a new PHPDoc object for a property from its doc comments
274: *
275: * @param ReflectionClass<object>|null $class
276: * @param array<string,class-string> $aliases
277: * @return static
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: * Creates a new PHPDoc object for a class constant from its doc comments
291: *
292: * @param ReflectionClass<object>|null $class
293: * @param array<string,class-string> $aliases
294: * @return static
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: * Creates a new PHPDoc object from an array of tag objects
308: *
309: * @param AbstractTag[] $tags
310: * @param class-string|null $class
311: * @return static
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: * Inherit values from an instance that represents the same structural
380: * element in a parent class or interface
381: *
382: * @return static
383: */
384: public function inherit(self $parent)
385: {
386: // Check if this is a class inheriting from an interface or trait
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: * @return array<string,true>
476: */
477: private static function getInheritableTagIndex(): array
478: {
479: return self::$InheritableTagIndex[static::class]
480: ??= self::doGetInheritableTagIndex(static::INHERITABLE_TAGS);
481: }
482:
483: /**
484: * @return array<string,true>
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: * @param non-empty-array<string,non-empty-array<string>|bool> $inheritable
494: * @return array<string,true>
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: * @template T of AbstractTag
515: *
516: * @param T|null $ours
517: * @param T|null $theirs
518: * @return ($ours is null ? ($theirs is null ? null : T) : T)
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: * Get a normalised instance
535: *
536: * If the PHPDoc has one `@var` tag:
537: *
538: * 1. if the class member associated with the PHPDoc is a property with the
539: * same name as the tag, the name is removed from the tag
540: * 2. if the tag has no name, its description is applied to the summary or
541: * description of the PHPDoc and removed from the tag
542: *
543: * `@inheritDoc` tags are removed.
544: *
545: * @return static
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: * Flatten values inherited from other instances and forget the initial
586: * state of the PHPDoc
587: *
588: * @return static
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: * Called after a property of the PHPDoc is changed via with()
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: * Get the state of the PHPDoc before inheriting values from other instances
671: *
672: * @return static
673: */
674: public function getOriginal()
675: {
676: return $this->Original;
677: }
678:
679: /**
680: * Get the PHPDoc's summary
681: */
682: public function getSummary(): ?string
683: {
684: return $this->Summary;
685: }
686:
687: /**
688: * Get the PHPDoc's description
689: */
690: public function getDescription(): ?string
691: {
692: return $this->Description;
693: }
694:
695: /**
696: * Check if the PHPDoc has a tag with the given name
697: */
698: public function hasTag(string $name): bool
699: {
700: return isset($this->Tags[$name]);
701: }
702:
703: /**
704: * Get the PHPDoc's tags, indexed by tag name
705: *
706: * @return array<string,AbstractTag[]>
707: */
708: public function getTags(): array
709: {
710: return $this->Tags;
711: }
712:
713: /**
714: * Get the PHPDoc's "@param" tags, indexed by name
715: *
716: * @return array<string,ParamTag>
717: */
718: public function getParams(): array
719: {
720: return $this->Params;
721: }
722:
723: /**
724: * Check if the PHPDoc has a "@return" tag
725: *
726: * @phpstan-assert-if-true !null $this->getReturn()
727: */
728: public function hasReturn(): bool
729: {
730: return $this->Return !== null;
731: }
732:
733: /**
734: * Get the PHPDoc's "@return" tag
735: */
736: public function getReturn(): ?ReturnTag
737: {
738: return $this->Return;
739: }
740:
741: /**
742: * Get the PHPDoc's "@var" tags
743: *
744: * @return VarTag[]
745: */
746: public function getVars(): array
747: {
748: return $this->Vars;
749: }
750:
751: /**
752: * Get the PHPDoc's "@method" tags, indexed by name
753: *
754: * @return array<string,MethodTag>
755: */
756: public function getMethods(): array
757: {
758: return $this->Methods;
759: }
760:
761: /**
762: * Get the PHPDoc's "@property" tags, indexed by name
763: *
764: * @return array<string,PropertyTag>
765: */
766: public function getProperties(): array
767: {
768: return $this->Properties;
769: }
770:
771: /**
772: * Get the PHPDoc's "@template" tags, indexed by name
773: *
774: * @return array<string,TemplateTag>
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: * Get "@template" tags applicable to the given tag, indexed by name
797: *
798: * @return array<string,TemplateTag>
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: * Check if any tags in the PHPDoc's original DocBlock failed to parse
820: *
821: * @phpstan-assert-if-true non-empty-array<ErrorTag> $this->getErrors()
822: */
823: public function hasErrors(): bool
824: {
825: return (bool) $this->Errors;
826: }
827:
828: /**
829: * Get any tags that failed to parse
830: *
831: * @return ErrorTag[]
832: */
833: public function getErrors(): array
834: {
835: return $this->Errors;
836: }
837:
838: /**
839: * Get the name of the class associated with the PHPDoc
840: */
841: public function getClass(): ?string
842: {
843: return $this->Class;
844: }
845:
846: /**
847: * Get the class member associated with the PHPDoc
848: */
849: public function getMember(): ?string
850: {
851: return $this->Member;
852: }
853:
854: /**
855: * Check if the PHPDoc has no content
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: * Check if the PHPDoc has data other than a summary and standard type
866: * information
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: * @inheritDoc
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: * Creates a new PHPDoc object from an array of PHP DocBlocks, each of which
925: * inherits from subsequent blocks
926: *
927: * @param array<class-string|int,string|null> $docBlocks
928: * @param array<class-string|int,self|string|null>|null $classDocBlocks
929: * @param array<string,class-string> $aliases
930: * @return static
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: * @param array<string,class-string> $aliases
958: */
959: private function parse(string $content, array $aliases, ?int &$tags): void
960: {
961: // - Remove leading asterisks after newlines
962: // - Trim the entire PHPDoc
963: // - Remove trailing whitespace and split into string[]
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: // Remove the tag name and trim whatever remains
994: $text = trim(substr($text, strlen($matches[0])));
995: $tag = ltrim($matches['tag'], '\\');
996:
997: try {
998: switch ($tag) {
999: // @param [type] $<name> [description]
1000: case 'param':
1001: if (!Regex::match(self::PARAM_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) {
1002: $this->throw('Invalid syntax', $tag);
1003: }
1004: /** @var string */
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: // @return <type> [description]
1019: case 'return':
1020: if (!Regex::match(self::RETURN_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) {
1021: $this->throw('Invalid syntax', $tag);
1022: }
1023: /** @var string */
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: // @var <type> [$<name>] [description]
1035: case 'var':
1036: if (!Regex::match(self::VAR_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) {
1037: $this->throw('Invalid syntax', $tag);
1038: }
1039: /** @var string */
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: // @method [[static] <return_type>] <name>([<param_type>] $<param_name> [= <default_value>], ...) [description]
1058: case 'method':
1059: if (!Regex::match(self::METHOD_TAG, $text, $matches, \PREG_UNMATCHED_AS_NULL)) {
1060: $this->throw('Invalid syntax', $tag);
1061: }
1062: /** @var string */
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: // @codeCoverageIgnoreStart
1081: throw new ShouldNotHappenException(sprintf(
1082: '@method parameter parsing failed: %s',
1083: $text,
1084: ));
1085: // @codeCoverageIgnoreEnd
1086: }
1087: /** @var string */
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: // @property[-(read|write)] [type] $<name> [description]
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: /** @var string */
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: // @template[-(covariant|contravariant)] <name> [(of|as) <type>] [= <type>]
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: /** @var string */
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: * Consume and implode $this->Lines values up to, but not including, the
1182: * next that matches $pattern and doesn't belong to a fenced code block
1183: *
1184: * If `$unwrap` is `true`, fenced code blocks are ignored and lines are
1185: * joined with `" "` instead of `"\n"`.
1186: *
1187: * @phpstan-impure
1188: */
1189: private function getLinesUntil(string $pattern, bool $unwrap = false): string
1190: {
1191: if (!$this->Lines) {
1192: // @codeCoverageIgnoreStart
1193: throw new ShouldNotHappenException('No more lines');
1194: // @codeCoverageIgnoreEnd
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: * @param string|int|float ...$args
1235: * @return never
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: