1: <?php declare(strict_types=1);
2:
3: namespace Salient\PHPDoc;
4:
5: use Salient\Contract\Core\Readable;
6: use Salient\Core\Concern\ReadsProtectedProperties;
7: use Salient\PHPDoc\Exception\InvalidTagValueException;
8: use Salient\PHPDoc\Tag\AbstractTag;
9: use Salient\PHPDoc\Tag\ParamTag;
10: use Salient\PHPDoc\Tag\ReturnTag;
11: use Salient\PHPDoc\Tag\TemplateTag;
12: use Salient\PHPDoc\Tag\VarTag;
13: use Salient\Utility\Arr;
14: use Salient\Utility\Regex;
15: use Salient\Utility\Str;
16: use InvalidArgumentException;
17: use OutOfRangeException;
18: use UnexpectedValueException;
19:
20: /**
21: * A PSR-5 PHPDoc
22: *
23: * Summaries that break over multiple lines are unwrapped. Descriptions and tags
24: * may contain Markdown, including fenced code blocks.
25: *
26: * @property-read string|null $Summary Summary (if provided)
27: * @property-read string|null $Description Description (if provided)
28: * @property-read string[] $Tags Original tags, in order of appearance
29: * @property-read array<string,string[]> $TagsByName Original tag metadata, indexed by tag name
30: * @property-read array<string,ParamTag> $Params "@param" tags, indexed by name
31: * @property-read ReturnTag|null $Return "@return" tag (if provided)
32: * @property-read VarTag[] $Vars "@var" tags
33: * @property-read array<string,TemplateTag> $Templates "@template" tags, indexed by name
34: * @property-read class-string|null $Class
35: * @property-read string|null $Member
36: */
37: final class PHPDoc implements Readable
38: {
39: use ReadsProtectedProperties;
40:
41: private const PHP_DOCBLOCK = '`^' . PHPDocRegex::PHP_DOCBLOCK . '$`D';
42: private const PHPDOC_TAG = '`^' . PHPDocRegex::PHPDOC_TAG . '`';
43: private const PHPDOC_TYPE = '`^' . PHPDocRegex::PHPDOC_TYPE . '$`D';
44: private const NEXT_PHPDOC_TYPE = '`^' . PHPDocRegex::PHPDOC_TYPE . '`';
45:
46: private const STANDARD_TAGS = [
47: 'param',
48: 'readonly',
49: 'return',
50: 'throws',
51: 'var',
52: 'template',
53: 'template-covariant',
54: 'template-contravariant',
55: 'internal',
56: ];
57:
58: protected ?string $Summary = null;
59: protected ?string $Description = null;
60: /** @var string[] */
61: protected array $Tags = [];
62: /** @var array<string,string[]> */
63: protected array $TagsByName = [];
64: /** @var array<string,ParamTag> */
65: protected array $Params = [];
66: protected ?ReturnTag $Return = null;
67: /** @var VarTag[] */
68: protected array $Vars = [];
69: /** @var array<string,TemplateTag> */
70: protected array $Templates = [];
71: /** @var class-string|null */
72: protected ?string $Class;
73: protected ?string $Member;
74: /** @var string[] */
75: private array $Lines;
76: private ?string $NextLine;
77:
78: /**
79: * Creates a new PHPDoc object from a PHP DocBlock
80: *
81: * @param class-string|null $class
82: */
83: public function __construct(
84: string $docBlock,
85: ?string $classDocBlock = null,
86: ?string $class = null,
87: ?string $member = null
88: ) {
89: if (!Regex::match(self::PHP_DOCBLOCK, $docBlock, $matches)) {
90: throw new InvalidArgumentException('Invalid DocBlock');
91: }
92:
93: $this->Class = $class;
94: $this->Member = $member;
95:
96: // - Remove comment delimiters
97: // - Normalise line endings
98: // - Remove leading asterisks and trailing whitespace
99: // - Trim the entire PHPDoc
100: // - Split into string[]
101: $this->Lines = explode("\n", trim(Regex::replace(
102: '/(?:^\h*+\* ?|\h+$)/m',
103: '',
104: Str::setEol($matches['content']),
105: )));
106:
107: $this->NextLine = reset($this->Lines);
108:
109: if (!Regex::match(self::PHPDOC_TAG, $this->NextLine)) {
110: $this->Summary = Str::coalesce(
111: $this->getLinesUntil('/^$/', true, true),
112: null,
113: );
114:
115: if (
116: $this->NextLine !== null
117: && !Regex::match(self::PHPDOC_TAG, $this->NextLine)
118: ) {
119: $this->Description = rtrim($this->getLinesUntil(self::PHPDOC_TAG));
120: }
121: }
122:
123: $index = -1;
124: while ($this->Lines && Regex::match(
125: self::PHPDOC_TAG,
126: $text = $this->getLinesUntil(self::PHPDOC_TAG),
127: $matches,
128: )) {
129: $this->Tags[++$index] = $text;
130:
131: // Remove the tag name and any subsequent whitespace
132: $text = ltrim(substr($text, strlen($matches[0])));
133: $tag = ltrim($matches['tag'], '\\');
134: $this->TagsByName[$tag][] = $text;
135:
136: // Use `strtok(" \t\n\r")` to extract metadata that may be followed
137: // by a multi-line description, otherwise the first word of any
138: // descriptions that start on the next line will be extracted too
139: $metaCount = 0;
140: switch ($tag) {
141: // @param [type] $<name> [description]
142: case 'param':
143: $text = $this->removeType($text, $type);
144: $token = strtok($text, " \t\n\r");
145: if ($token === false) {
146: $this->throw('No name', $tag);
147: }
148: $reference = false;
149: if ($token[0] === '&') {
150: $reference = true;
151: $token = $this->maybeExpandToken(substr($token, 1), $metaCount);
152: }
153: $variadic = false;
154: if (substr($token, 0, 3) === '...') {
155: $variadic = true;
156: $token = $this->maybeExpandToken(substr($token, 3), $metaCount);
157: }
158: if ($token !== '' && $token[0] !== '$') {
159: $this->throw("Invalid name '%s'", $tag, $token);
160: }
161: $name = rtrim(substr($token, 1));
162: if ($name !== '') {
163: $metaCount++;
164: $this->Params[$name] = new ParamTag(
165: $name,
166: $type,
167: $reference,
168: $variadic,
169: $this->removeValues($text, $metaCount),
170: $class,
171: $member,
172: );
173: }
174: break;
175:
176: // @return <type> [description]
177: case 'return':
178: $text = $this->removeType($text, $type);
179: if ($type === null) {
180: $this->throw('No type', $tag);
181: }
182: $this->Return = new ReturnTag(
183: $type,
184: $this->removeValues($text, $metaCount),
185: $class,
186: $member,
187: );
188: break;
189:
190: // @var <type> [$<name>] [description]
191: case 'var':
192: $name = null;
193: // Assume the first token is a type
194: $text = $this->removeType($text, $type);
195: if ($type === null) {
196: $this->throw('No type', $tag);
197: }
198: $token = strtok($text, " \t");
199: // Also assume that if a name is given, it's for a variable
200: // and not a constant
201: if ($token !== false && $token[0] === '$') {
202: $name = rtrim(substr($token, 1));
203: $metaCount++;
204: }
205:
206: $var = new VarTag(
207: $type,
208: $name,
209: $this->removeValues($text, $metaCount),
210: $class,
211: $member,
212: );
213: if ($name !== null) {
214: $this->Vars[$name] = $var;
215: } else {
216: $this->Vars[] = $var;
217: }
218: break;
219:
220: // - @template <name> [of <type>]
221: // - @template-(covariant|contravariant) <name> [of <type>]
222: case 'template-covariant':
223: case 'template-contravariant':
224: case 'template':
225: $token = strtok($text, " \t");
226: if ($token === false) {
227: $this->throw('No name', $tag);
228: }
229: $name = rtrim($token);
230: $metaCount++;
231: $token = strtok(" \t");
232: $type = 'mixed';
233: if ($token === 'of' || $token === 'as') {
234: $metaCount++;
235: $token = strtok('');
236: if ($token !== false) {
237: $metaCount++;
238: $this->removeType($token, $type);
239: }
240: }
241: /** @var "covariant"|"contravariant"|null */
242: $variance = explode('-', $tag, 2)[1] ?? null;
243: $this->Templates[$name] = new TemplateTag(
244: $name,
245: $type,
246: $variance,
247: $class,
248: $member,
249: );
250: break;
251: }
252: }
253:
254: // Release strtok's copy of the string most recently passed to it
255: strtok('', '');
256:
257: // Rearrange this:
258: //
259: // /**
260: // * Summary
261: // *
262: // * @var int Description.
263: // */
264: //
265: // Like this:
266: //
267: // /**
268: // * Summary
269: // *
270: // * Description.
271: // *
272: // * @var int
273: // */
274: //
275: if (count($this->Vars) === 1) {
276: $var = reset($this->Vars);
277: $description = $var->getDescription();
278: if ($description !== null) {
279: if ($this->Summary === null) {
280: $this->Summary = $description;
281: } elseif ($this->Summary !== $description) {
282: $this->Description
283: .= ($this->Description !== null ? "\n\n" : '')
284: . $description;
285: }
286: $key = key($this->Vars);
287: $this->Vars[$key] = $var->withDescription(null);
288: }
289: }
290:
291: // Remove empty strings, reducing tags with no content to an empty array
292: foreach ($this->TagsByName as &$tags) {
293: $tags = Arr::whereNotEmpty($tags);
294: }
295: unset($tags);
296:
297: // Merge @template types from the declaring class, if available
298: if ($classDocBlock !== null) {
299: $phpDoc = new self($classDocBlock, null, $class);
300: foreach ($phpDoc->Templates as $name => $tag) {
301: $this->Templates[$name] ??= $tag;
302: }
303: }
304: }
305:
306: private function maybeExpandToken(
307: string $token,
308: int &$metaCount,
309: string $delimiters = " \t"
310: ): string {
311: if ($token === '') {
312: $token = strtok($delimiters);
313: if ($token === false) {
314: return '';
315: }
316: $metaCount++;
317: }
318: return $token;
319: }
320:
321: /**
322: * Remove a PHPDoc type and any subsequent whitespace from the given text
323: *
324: * If a PHPDoc type is found at the start of `$text`, it is assigned to
325: * `$type` and removed from `$text` before it is left-trimmed and returned.
326: * Otherwise, `null` is assigned to `$type` and `$text` is returned as-is.
327: *
328: * @param-out string|null $type
329: */
330: private function removeType(string $text, ?string &$type): string
331: {
332: if (Regex::match(self::NEXT_PHPDOC_TYPE, $text, $matches, \PREG_OFFSET_CAPTURE)) {
333: [$type, $offset] = $matches[0];
334: return ltrim(substr_replace($text, '', $offset, strlen($type)));
335: }
336: $type = null;
337: return $text;
338: }
339:
340: /**
341: * Remove whitespace-delimited values from the given text, then trim and
342: * return it if non-empty, otherwise return null
343: */
344: private function removeValues(string $text, int $count): ?string
345: {
346: return Str::coalesce(rtrim(Regex::split('/\s++/', $text, $count + 1)[$count] ?? ''), null);
347: }
348:
349: /**
350: * Collect and implode $this->NextLine and subsequent lines until, but not
351: * including, the next line that matches $pattern
352: *
353: * If `$unwrap` is `false`, `$pattern` is ignored between code fences, which
354: * start and end when a line contains 3 or more backticks or tildes and no
355: * other text aside from an optional info string after the opening fence.
356: *
357: * @param bool $discard If `true`, lines matching `$pattern` are discarded,
358: * otherwise they are left in {@see $this->Lines}.
359: * @param bool $unwrap If `true`, lines are joined with " " instead of "\n".
360: *
361: * @phpstan-impure
362: */
363: private function getLinesUntil(
364: string $pattern,
365: bool $discard = false,
366: bool $unwrap = false
367: ): string {
368: $lines = [];
369: $inFence = false;
370:
371: do {
372: $lines[] = $line = $this->getLine();
373:
374: if (!$unwrap) {
375: if (
376: (!$inFence && Regex::match('/^(```+|~~~+)/', $line, $fence))
377: || ($inFence && isset($fence[0]) && $line === $fence[0])
378: ) {
379: $inFence = !$inFence;
380: }
381:
382: if ($inFence) {
383: continue;
384: }
385: }
386:
387: if ($this->NextLine === null) {
388: break;
389: }
390:
391: if (Regex::match($pattern, $this->NextLine)) {
392: if (!$discard) {
393: break;
394: }
395: do {
396: $this->getLine();
397: if (
398: $this->NextLine === null
399: || !Regex::match($pattern, $this->NextLine)
400: ) {
401: break 2;
402: }
403: } while (true);
404: }
405: } while ($this->Lines);
406:
407: if ($inFence) {
408: throw new UnexpectedValueException('Unterminated code fence in DocBlock');
409: }
410:
411: return implode($unwrap ? ' ' : "\n", $lines);
412: }
413:
414: /**
415: * Shift the next line off the beginning of $this->Lines, assign its
416: * successor to $this->NextLine, and return it
417: *
418: * @phpstan-impure
419: */
420: private function getLine(): string
421: {
422: if (!$this->Lines) {
423: // @codeCoverageIgnoreStart
424: throw new OutOfRangeException('No more lines');
425: // @codeCoverageIgnoreEnd
426: }
427:
428: $line = array_shift($this->Lines);
429: $this->NextLine = $this->Lines ? reset($this->Lines) : null;
430:
431: return $line;
432: }
433:
434: public function unwrap(?string $value): ?string
435: {
436: return $value === null
437: ? null
438: : Regex::replace('/\s++/', ' ', $value);
439: }
440:
441: /**
442: * Get the PHPDoc's template tags, optionally including class templates and
443: * any templates inherited from parent classes
444: *
445: * @return array<string,TemplateTag>
446: */
447: public function getTemplates(bool $all = false): array
448: {
449: if ($this->Class === null || $all) {
450: return $this->Templates;
451: }
452:
453: foreach ($this->Templates as $name => $template) {
454: if (
455: $template->getClass() !== $this->Class
456: || ($this->Member !== null && $template->getMember() !== $this->Member)
457: ) {
458: continue;
459: }
460: $templates[$name] = $template;
461: }
462:
463: return $templates ?? [];
464: }
465:
466: /**
467: * True if the PHPDoc contains more than a summary and/or variable type
468: * information
469: */
470: public function hasDetail(): bool
471: {
472: if ($this->Description !== null) {
473: return true;
474: }
475:
476: foreach ([...$this->Params, $this->Return, ...$this->Vars] as $tag) {
477: if (
478: $tag
479: && ($description = $tag->getDescription()) !== null
480: && $description !== $this->Summary
481: ) {
482: return true;
483: }
484: }
485:
486: if (array_filter(
487: array_diff_key($this->TagsByName, array_flip(self::STANDARD_TAGS)),
488: fn(string $key): bool =>
489: !Regex::match('/^(phpstan|psalm)-/', $key),
490: \ARRAY_FILTER_USE_KEY
491: )) {
492: return true;
493: }
494:
495: return false;
496: }
497:
498: private function mergeTag(?AbstractTag &$ours, ?AbstractTag $theirs): void
499: {
500: if ($theirs === null) {
501: return;
502: }
503:
504: if ($ours === null) {
505: $ours = $theirs;
506: return;
507: }
508:
509: $ours = $ours->inherit($theirs);
510: }
511:
512: /**
513: * Add missing values from an instance that represents the same structural
514: * element in a parent class or interface
515: */
516: public function mergeInherited(PHPDoc $parent): void
517: {
518: $this->Summary ??= $parent->Summary;
519: $this->Description ??= $parent->Description;
520: $this->Tags = Arr::extend($this->Tags, ...$parent->Tags);
521: foreach ($parent->TagsByName as $name => $tags) {
522: $this->TagsByName[$name] = Arr::extend(
523: $this->TagsByName[$name] ?? [],
524: ...$tags,
525: );
526: }
527: foreach ($parent->Params as $name => $theirs) {
528: $this->mergeTag($this->Params[$name], $theirs);
529: }
530: $this->mergeTag($this->Return, $parent->Return);
531: if (isset($parent->Vars[0])) {
532: $this->mergeTag($this->Vars[0], $parent->Vars[0]);
533: }
534: foreach ($parent->Templates as $name => $theirs) {
535: $this->Templates[$name] ??= $theirs;
536: }
537: }
538:
539: /**
540: * @param array<class-string|int,string> $docBlocks
541: * @param array<class-string|int,string|null>|null $classDocBlocks
542: * @param class-string $fallbackClass
543: */
544: public static function fromDocBlocks(
545: array $docBlocks,
546: ?array $classDocBlocks = null,
547: ?string $member = null,
548: ?string $fallbackClass = null
549: ): ?self {
550: if (!$docBlocks) {
551: return null;
552: }
553: foreach ($docBlocks as $key => $docBlock) {
554: $class = is_string($key) ? $key : null;
555: $phpDoc = new self(
556: $docBlock,
557: $classDocBlocks[$key] ?? null,
558: $class ?? $fallbackClass,
559: $member,
560: );
561:
562: if ($phpDoc->Summary === null
563: && $phpDoc->Description === null
564: && (!$phpDoc->Tags
565: || array_keys($phpDoc->TagsByName) === ['inheritDoc'])) {
566: continue;
567: }
568:
569: $parser ??= $phpDoc;
570:
571: if ($phpDoc !== $parser) {
572: $parser->mergeInherited($phpDoc);
573: }
574: }
575:
576: return $parser ?? null;
577: }
578:
579: /**
580: * Normalise a PHPDoc type
581: *
582: * If `$strict` is `true`, an exception is thrown if `$type` is not a valid
583: * PHPDoc type.
584: */
585: public static function normaliseType(string $type, bool $strict = false): string
586: {
587: if (!Regex::match(self::PHPDOC_TYPE, trim($type), $matches)) {
588: if ($strict) {
589: throw new InvalidArgumentException(sprintf(
590: "Invalid PHPDoc type '%s'",
591: $type,
592: ));
593: }
594: return self::replace([$type])[0];
595: }
596:
597: $types = Str::splitDelimited('|', $type, true, null, Str::PRESERVE_QUOTED);
598:
599: // Move `null` to the end of union types
600: $notNull = [];
601: foreach ($types as $t) {
602: $t = ltrim($t, '?');
603: if (strcasecmp($t, 'null')) {
604: $notNull[] = $t;
605: }
606: }
607:
608: if ($notNull !== $types) {
609: $types = $notNull;
610: $nullable = true;
611: }
612:
613: // Simplify composite types
614: $phpTypeRegex = Regex::delimit('^' . Regex::PHP_TYPE . '$', '/');
615: foreach ($types as &$type) {
616: $brackets = false;
617: if ($type !== '' && $type[0] === '(' && $type[-1] === ')') {
618: $brackets = true;
619: $type = substr($type, 1, -1);
620: }
621: $split = array_unique(self::replace(explode('&', $type)));
622: $type = implode('&', $split);
623: if ($brackets && (
624: count($split) > 1
625: || !Regex::match($phpTypeRegex, $type)
626: )) {
627: $type = "($type)";
628: }
629: }
630:
631: $types = array_unique(self::replace($types));
632: if ($nullable ?? false) {
633: $types[] = 'null';
634: }
635:
636: return implode('|', $types);
637: }
638:
639: /**
640: * @param string[] $types
641: * @return string[]
642: */
643: private static function replace(array $types): array
644: {
645: return Regex::replace(
646: ['/\bclass-string<(?:mixed|object)>/i', '/(?:\bmixed&|&mixed\b)/i'],
647: ['class-string', ''],
648: $types,
649: );
650: }
651:
652: /**
653: * @param string|int|float ...$args
654: * @return never
655: */
656: private function throw(string $message, ?string $tag, ...$args): void
657: {
658: if ($tag !== null) {
659: $message .= ' for @%s';
660: $args[] = $tag;
661: }
662:
663: $message .= ' in DocBlock';
664:
665: if (isset($this->Class)) {
666: $message .= ' of %s';
667: $args[] = $this->Class;
668: if (isset($this->Member)) {
669: $message .= '::%s';
670: $args[] = $this->Member;
671: }
672: }
673:
674: throw new InvalidTagValueException(sprintf($message, ...$args));
675: }
676: }
677: