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