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: | |
22: | |
23: | |
24: | |
25: | |
26: | |
27: | |
28: | |
29: | |
30: | |
31: | |
32: | |
33: | |
34: | |
35: | |
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: | |
61: | protected array $Tags = []; |
62: | |
63: | protected array $TagsByName = []; |
64: | |
65: | protected array $Params = []; |
66: | protected ?ReturnTag $Return = null; |
67: | |
68: | protected array $Vars = []; |
69: | |
70: | protected array $Templates = []; |
71: | |
72: | protected ?string $Class; |
73: | protected ?string $Member; |
74: | |
75: | private array $Lines; |
76: | private ?string $NextLine; |
77: | |
78: | |
79: | |
80: | |
81: | |
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: | |
97: | |
98: | |
99: | |
100: | |
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: | |
132: | $text = ltrim(substr($text, strlen($matches[0]))); |
133: | $tag = ltrim($matches['tag'], '\\'); |
134: | $this->TagsByName[$tag][] = $text; |
135: | |
136: | |
137: | |
138: | |
139: | $metaCount = 0; |
140: | switch ($tag) { |
141: | |
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: | |
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: | |
191: | case 'var': |
192: | $name = null; |
193: | |
194: | $text = $this->removeType($text, $type); |
195: | if ($type === null) { |
196: | $this->throw('No type', $tag); |
197: | } |
198: | $token = strtok($text, " \t"); |
199: | |
200: | |
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: | |
221: | |
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: | |
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: | |
255: | strtok('', ''); |
256: | |
257: | |
258: | |
259: | |
260: | |
261: | |
262: | |
263: | |
264: | |
265: | |
266: | |
267: | |
268: | |
269: | |
270: | |
271: | |
272: | |
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: | |
292: | foreach ($this->TagsByName as &$tags) { |
293: | $tags = Arr::whereNotEmpty($tags); |
294: | } |
295: | unset($tags); |
296: | |
297: | |
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: | |
323: | |
324: | |
325: | |
326: | |
327: | |
328: | |
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: | |
342: | |
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: | |
351: | |
352: | |
353: | |
354: | |
355: | |
356: | |
357: | |
358: | |
359: | |
360: | |
361: | |
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: | |
416: | |
417: | |
418: | |
419: | |
420: | private function getLine(): string |
421: | { |
422: | if (!$this->Lines) { |
423: | |
424: | throw new OutOfRangeException('No more lines'); |
425: | |
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: | |
443: | |
444: | |
445: | |
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: | |
468: | |
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: | |
514: | |
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: | |
541: | |
542: | |
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: | |
581: | |
582: | |
583: | |
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: | |
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: | |
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: | |
641: | |
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: | |
654: | |
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: | |