1<?php 2 3namespace Doctrine\Common\Annotations; 4 5use Doctrine\Common\Annotations\Annotation\Attribute; 6use Doctrine\Common\Annotations\Annotation\Attributes; 7use Doctrine\Common\Annotations\Annotation\Enum; 8use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; 9use Doctrine\Common\Annotations\Annotation\Target; 10use ReflectionClass; 11use ReflectionException; 12use ReflectionProperty; 13use RuntimeException; 14use stdClass; 15 16use function array_keys; 17use function array_map; 18use function array_pop; 19use function array_values; 20use function class_exists; 21use function constant; 22use function count; 23use function defined; 24use function explode; 25use function gettype; 26use function implode; 27use function in_array; 28use function interface_exists; 29use function is_array; 30use function is_object; 31use function json_encode; 32use function ltrim; 33use function preg_match; 34use function reset; 35use function rtrim; 36use function sprintf; 37use function stripos; 38use function strlen; 39use function strpos; 40use function strrpos; 41use function strtolower; 42use function substr; 43use function trim; 44 45use const PHP_VERSION_ID; 46 47/** 48 * A parser for docblock annotations. 49 * 50 * It is strongly discouraged to change the default annotation parsing process. 51 */ 52final class DocParser 53{ 54 /** 55 * An array of all valid tokens for a class name. 56 * 57 * @phpstan-var list<int> 58 */ 59 private static $classIdentifiers = [ 60 DocLexer::T_IDENTIFIER, 61 DocLexer::T_TRUE, 62 DocLexer::T_FALSE, 63 DocLexer::T_NULL, 64 ]; 65 66 /** 67 * The lexer. 68 * 69 * @var DocLexer 70 */ 71 private $lexer; 72 73 /** 74 * Current target context. 75 * 76 * @var int 77 */ 78 private $target; 79 80 /** 81 * Doc parser used to collect annotation target. 82 * 83 * @var DocParser 84 */ 85 private static $metadataParser; 86 87 /** 88 * Flag to control if the current annotation is nested or not. 89 * 90 * @var bool 91 */ 92 private $isNestedAnnotation = false; 93 94 /** 95 * Hashmap containing all use-statements that are to be used when parsing 96 * the given doc block. 97 * 98 * @var array<string, class-string> 99 */ 100 private $imports = []; 101 102 /** 103 * This hashmap is used internally to cache results of class_exists() 104 * look-ups. 105 * 106 * @var array<class-string, bool> 107 */ 108 private $classExists = []; 109 110 /** 111 * Whether annotations that have not been imported should be ignored. 112 * 113 * @var bool 114 */ 115 private $ignoreNotImportedAnnotations = false; 116 117 /** 118 * An array of default namespaces if operating in simple mode. 119 * 120 * @var string[] 121 */ 122 private $namespaces = []; 123 124 /** 125 * A list with annotations that are not causing exceptions when not resolved to an annotation class. 126 * 127 * The names must be the raw names as used in the class, not the fully qualified 128 * 129 * @var bool[] indexed by annotation name 130 */ 131 private $ignoredAnnotationNames = []; 132 133 /** 134 * A list with annotations in namespaced format 135 * that are not causing exceptions when not resolved to an annotation class. 136 * 137 * @var bool[] indexed by namespace name 138 */ 139 private $ignoredAnnotationNamespaces = []; 140 141 /** @var string */ 142 private $context = ''; 143 144 /** 145 * Hash-map for caching annotation metadata. 146 * 147 * @var array<class-string, mixed[]> 148 */ 149 private static $annotationMetadata = [ 150 Annotation\Target::class => [ 151 'is_annotation' => true, 152 'has_constructor' => true, 153 'has_named_argument_constructor' => false, 154 'properties' => [], 155 'targets_literal' => 'ANNOTATION_CLASS', 156 'targets' => Target::TARGET_CLASS, 157 'default_property' => 'value', 158 'attribute_types' => [ 159 'value' => [ 160 'required' => false, 161 'type' => 'array', 162 'array_type' => 'string', 163 'value' => 'array<string>', 164 ], 165 ], 166 ], 167 Annotation\Attribute::class => [ 168 'is_annotation' => true, 169 'has_constructor' => false, 170 'has_named_argument_constructor' => false, 171 'targets_literal' => 'ANNOTATION_ANNOTATION', 172 'targets' => Target::TARGET_ANNOTATION, 173 'default_property' => 'name', 174 'properties' => [ 175 'name' => 'name', 176 'type' => 'type', 177 'required' => 'required', 178 ], 179 'attribute_types' => [ 180 'value' => [ 181 'required' => true, 182 'type' => 'string', 183 'value' => 'string', 184 ], 185 'type' => [ 186 'required' => true, 187 'type' => 'string', 188 'value' => 'string', 189 ], 190 'required' => [ 191 'required' => false, 192 'type' => 'boolean', 193 'value' => 'boolean', 194 ], 195 ], 196 ], 197 Annotation\Attributes::class => [ 198 'is_annotation' => true, 199 'has_constructor' => false, 200 'has_named_argument_constructor' => false, 201 'targets_literal' => 'ANNOTATION_CLASS', 202 'targets' => Target::TARGET_CLASS, 203 'default_property' => 'value', 204 'properties' => ['value' => 'value'], 205 'attribute_types' => [ 206 'value' => [ 207 'type' => 'array', 208 'required' => true, 209 'array_type' => Annotation\Attribute::class, 210 'value' => 'array<' . Annotation\Attribute::class . '>', 211 ], 212 ], 213 ], 214 Annotation\Enum::class => [ 215 'is_annotation' => true, 216 'has_constructor' => true, 217 'has_named_argument_constructor' => false, 218 'targets_literal' => 'ANNOTATION_PROPERTY', 219 'targets' => Target::TARGET_PROPERTY, 220 'default_property' => 'value', 221 'properties' => ['value' => 'value'], 222 'attribute_types' => [ 223 'value' => [ 224 'type' => 'array', 225 'required' => true, 226 ], 227 'literal' => [ 228 'type' => 'array', 229 'required' => false, 230 ], 231 ], 232 ], 233 Annotation\NamedArgumentConstructor::class => [ 234 'is_annotation' => true, 235 'has_constructor' => false, 236 'has_named_argument_constructor' => false, 237 'targets_literal' => 'ANNOTATION_CLASS', 238 'targets' => Target::TARGET_CLASS, 239 'default_property' => null, 240 'properties' => [], 241 'attribute_types' => [], 242 ], 243 ]; 244 245 /** 246 * Hash-map for handle types declaration. 247 * 248 * @var array<string, string> 249 */ 250 private static $typeMap = [ 251 'float' => 'double', 252 'bool' => 'boolean', 253 // allow uppercase Boolean in honor of George Boole 254 'Boolean' => 'boolean', 255 'int' => 'integer', 256 ]; 257 258 /** 259 * Constructs a new DocParser. 260 */ 261 public function __construct() 262 { 263 $this->lexer = new DocLexer(); 264 } 265 266 /** 267 * Sets the annotation names that are ignored during the parsing process. 268 * 269 * The names are supposed to be the raw names as used in the class, not the 270 * fully qualified class names. 271 * 272 * @param bool[] $names indexed by annotation name 273 * 274 * @return void 275 */ 276 public function setIgnoredAnnotationNames(array $names) 277 { 278 $this->ignoredAnnotationNames = $names; 279 } 280 281 /** 282 * Sets the annotation namespaces that are ignored during the parsing process. 283 * 284 * @param bool[] $ignoredAnnotationNamespaces indexed by annotation namespace name 285 * 286 * @return void 287 */ 288 public function setIgnoredAnnotationNamespaces($ignoredAnnotationNamespaces) 289 { 290 $this->ignoredAnnotationNamespaces = $ignoredAnnotationNamespaces; 291 } 292 293 /** 294 * Sets ignore on not-imported annotations. 295 * 296 * @param bool $bool 297 * 298 * @return void 299 */ 300 public function setIgnoreNotImportedAnnotations($bool) 301 { 302 $this->ignoreNotImportedAnnotations = (bool) $bool; 303 } 304 305 /** 306 * Sets the default namespaces. 307 * 308 * @param string $namespace 309 * 310 * @return void 311 * 312 * @throws RuntimeException 313 */ 314 public function addNamespace($namespace) 315 { 316 if ($this->imports) { 317 throw new RuntimeException('You must either use addNamespace(), or setImports(), but not both.'); 318 } 319 320 $this->namespaces[] = $namespace; 321 } 322 323 /** 324 * Sets the imports. 325 * 326 * @param array<string, class-string> $imports 327 * 328 * @return void 329 * 330 * @throws RuntimeException 331 */ 332 public function setImports(array $imports) 333 { 334 if ($this->namespaces) { 335 throw new RuntimeException('You must either use addNamespace(), or setImports(), but not both.'); 336 } 337 338 $this->imports = $imports; 339 } 340 341 /** 342 * Sets current target context as bitmask. 343 * 344 * @param int $target 345 * 346 * @return void 347 */ 348 public function setTarget($target) 349 { 350 $this->target = $target; 351 } 352 353 /** 354 * Parses the given docblock string for annotations. 355 * 356 * @param string $input The docblock string to parse. 357 * @param string $context The parsing context. 358 * 359 * @throws AnnotationException 360 * @throws ReflectionException 361 * 362 * @phpstan-return list<object> Array of annotations. If no annotations are found, an empty array is returned. 363 */ 364 public function parse($input, $context = '') 365 { 366 $pos = $this->findInitialTokenPosition($input); 367 if ($pos === null) { 368 return []; 369 } 370 371 $this->context = $context; 372 373 $this->lexer->setInput(trim(substr($input, $pos), '* /')); 374 $this->lexer->moveNext(); 375 376 return $this->Annotations(); 377 } 378 379 /** 380 * Finds the first valid annotation 381 * 382 * @param string $input The docblock string to parse 383 */ 384 private function findInitialTokenPosition($input): ?int 385 { 386 $pos = 0; 387 388 // search for first valid annotation 389 while (($pos = strpos($input, '@', $pos)) !== false) { 390 $preceding = substr($input, $pos - 1, 1); 391 392 // if the @ is preceded by a space, a tab or * it is valid 393 if ($pos === 0 || $preceding === ' ' || $preceding === '*' || $preceding === "\t") { 394 return $pos; 395 } 396 397 $pos++; 398 } 399 400 return null; 401 } 402 403 /** 404 * Attempts to match the given token with the current lookahead token. 405 * If they match, updates the lookahead token; otherwise raises a syntax error. 406 * 407 * @param int $token Type of token. 408 * 409 * @return bool True if tokens match; false otherwise. 410 * 411 * @throws AnnotationException 412 */ 413 private function match(int $token): bool 414 { 415 if (! $this->lexer->isNextToken($token)) { 416 throw $this->syntaxError($this->lexer->getLiteral($token)); 417 } 418 419 return $this->lexer->moveNext(); 420 } 421 422 /** 423 * Attempts to match the current lookahead token with any of the given tokens. 424 * 425 * If any of them matches, this method updates the lookahead token; otherwise 426 * a syntax error is raised. 427 * 428 * @throws AnnotationException 429 * 430 * @phpstan-param list<mixed[]> $tokens 431 */ 432 private function matchAny(array $tokens): bool 433 { 434 if (! $this->lexer->isNextTokenAny($tokens)) { 435 throw $this->syntaxError(implode(' or ', array_map([$this->lexer, 'getLiteral'], $tokens))); 436 } 437 438 return $this->lexer->moveNext(); 439 } 440 441 /** 442 * Generates a new syntax error. 443 * 444 * @param string $expected Expected string. 445 * @param mixed[]|null $token Optional token. 446 */ 447 private function syntaxError(string $expected, ?array $token = null): AnnotationException 448 { 449 if ($token === null) { 450 $token = $this->lexer->lookahead; 451 } 452 453 $message = sprintf('Expected %s, got ', $expected); 454 $message .= $this->lexer->lookahead === null 455 ? 'end of string' 456 : sprintf("'%s' at position %s", $token['value'], $token['position']); 457 458 if (strlen($this->context)) { 459 $message .= ' in ' . $this->context; 460 } 461 462 $message .= '.'; 463 464 return AnnotationException::syntaxError($message); 465 } 466 467 /** 468 * Attempts to check if a class exists or not. This never goes through the PHP autoloading mechanism 469 * but uses the {@link AnnotationRegistry} to load classes. 470 * 471 * @param class-string $fqcn 472 */ 473 private function classExists(string $fqcn): bool 474 { 475 if (isset($this->classExists[$fqcn])) { 476 return $this->classExists[$fqcn]; 477 } 478 479 // first check if the class already exists, maybe loaded through another AnnotationReader 480 if (class_exists($fqcn, false)) { 481 return $this->classExists[$fqcn] = true; 482 } 483 484 // final check, does this class exist? 485 return $this->classExists[$fqcn] = AnnotationRegistry::loadAnnotationClass($fqcn); 486 } 487 488 /** 489 * Collects parsing metadata for a given annotation class 490 * 491 * @param class-string $name The annotation name 492 * 493 * @throws AnnotationException 494 * @throws ReflectionException 495 */ 496 private function collectAnnotationMetadata(string $name): void 497 { 498 if (self::$metadataParser === null) { 499 self::$metadataParser = new self(); 500 501 self::$metadataParser->setIgnoreNotImportedAnnotations(true); 502 self::$metadataParser->setIgnoredAnnotationNames($this->ignoredAnnotationNames); 503 self::$metadataParser->setImports([ 504 'enum' => Enum::class, 505 'target' => Target::class, 506 'attribute' => Attribute::class, 507 'attributes' => Attributes::class, 508 'namedargumentconstructor' => NamedArgumentConstructor::class, 509 ]); 510 511 // Make sure that annotations from metadata are loaded 512 class_exists(Enum::class); 513 class_exists(Target::class); 514 class_exists(Attribute::class); 515 class_exists(Attributes::class); 516 class_exists(NamedArgumentConstructor::class); 517 } 518 519 $class = new ReflectionClass($name); 520 $docComment = $class->getDocComment(); 521 522 // Sets default values for annotation metadata 523 $constructor = $class->getConstructor(); 524 $metadata = [ 525 'default_property' => null, 526 'has_constructor' => $constructor !== null && $constructor->getNumberOfParameters() > 0, 527 'constructor_args' => [], 528 'properties' => [], 529 'property_types' => [], 530 'attribute_types' => [], 531 'targets_literal' => null, 532 'targets' => Target::TARGET_ALL, 533 'is_annotation' => strpos($docComment, '@Annotation') !== false, 534 ]; 535 536 $metadata['has_named_argument_constructor'] = $metadata['has_constructor'] 537 && $class->implementsInterface(NamedArgumentConstructorAnnotation::class); 538 539 // verify that the class is really meant to be an annotation 540 if ($metadata['is_annotation']) { 541 self::$metadataParser->setTarget(Target::TARGET_CLASS); 542 543 foreach (self::$metadataParser->parse($docComment, 'class @' . $name) as $annotation) { 544 if ($annotation instanceof Target) { 545 $metadata['targets'] = $annotation->targets; 546 $metadata['targets_literal'] = $annotation->literal; 547 548 continue; 549 } 550 551 if ($annotation instanceof NamedArgumentConstructor) { 552 $metadata['has_named_argument_constructor'] = $metadata['has_constructor']; 553 if ($metadata['has_named_argument_constructor']) { 554 // choose the first argument as the default property 555 $metadata['default_property'] = $constructor->getParameters()[0]->getName(); 556 } 557 } 558 559 if (! ($annotation instanceof Attributes)) { 560 continue; 561 } 562 563 foreach ($annotation->value as $attribute) { 564 $this->collectAttributeTypeMetadata($metadata, $attribute); 565 } 566 } 567 568 // if not has a constructor will inject values into public properties 569 if ($metadata['has_constructor'] === false) { 570 // collect all public properties 571 foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { 572 $metadata['properties'][$property->name] = $property->name; 573 574 $propertyComment = $property->getDocComment(); 575 if ($propertyComment === false) { 576 continue; 577 } 578 579 $attribute = new Attribute(); 580 581 $attribute->required = (strpos($propertyComment, '@Required') !== false); 582 $attribute->name = $property->name; 583 $attribute->type = (strpos($propertyComment, '@var') !== false && 584 preg_match('/@var\s+([^\s]+)/', $propertyComment, $matches)) 585 ? $matches[1] 586 : 'mixed'; 587 588 $this->collectAttributeTypeMetadata($metadata, $attribute); 589 590 // checks if the property has @Enum 591 if (strpos($propertyComment, '@Enum') === false) { 592 continue; 593 } 594 595 $context = 'property ' . $class->name . '::$' . $property->name; 596 597 self::$metadataParser->setTarget(Target::TARGET_PROPERTY); 598 599 foreach (self::$metadataParser->parse($propertyComment, $context) as $annotation) { 600 if (! $annotation instanceof Enum) { 601 continue; 602 } 603 604 $metadata['enum'][$property->name]['value'] = $annotation->value; 605 $metadata['enum'][$property->name]['literal'] = (! empty($annotation->literal)) 606 ? $annotation->literal 607 : $annotation->value; 608 } 609 } 610 611 // choose the first property as default property 612 $metadata['default_property'] = reset($metadata['properties']); 613 } elseif ($metadata['has_named_argument_constructor']) { 614 foreach ($constructor->getParameters() as $parameter) { 615 $metadata['constructor_args'][$parameter->getName()] = [ 616 'position' => $parameter->getPosition(), 617 'default' => $parameter->isOptional() ? $parameter->getDefaultValue() : null, 618 ]; 619 } 620 } 621 } 622 623 self::$annotationMetadata[$name] = $metadata; 624 } 625 626 /** 627 * Collects parsing metadata for a given attribute. 628 * 629 * @param mixed[] $metadata 630 */ 631 private function collectAttributeTypeMetadata(array &$metadata, Attribute $attribute): void 632 { 633 // handle internal type declaration 634 $type = self::$typeMap[$attribute->type] ?? $attribute->type; 635 636 // handle the case if the property type is mixed 637 if ($type === 'mixed') { 638 return; 639 } 640 641 // Evaluate type 642 $pos = strpos($type, '<'); 643 if ($pos !== false) { 644 // Checks if the property has array<type> 645 $arrayType = substr($type, $pos + 1, -1); 646 $type = 'array'; 647 648 if (isset(self::$typeMap[$arrayType])) { 649 $arrayType = self::$typeMap[$arrayType]; 650 } 651 652 $metadata['attribute_types'][$attribute->name]['array_type'] = $arrayType; 653 } else { 654 // Checks if the property has type[] 655 $pos = strrpos($type, '['); 656 if ($pos !== false) { 657 $arrayType = substr($type, 0, $pos); 658 $type = 'array'; 659 660 if (isset(self::$typeMap[$arrayType])) { 661 $arrayType = self::$typeMap[$arrayType]; 662 } 663 664 $metadata['attribute_types'][$attribute->name]['array_type'] = $arrayType; 665 } 666 } 667 668 $metadata['attribute_types'][$attribute->name]['type'] = $type; 669 $metadata['attribute_types'][$attribute->name]['value'] = $attribute->type; 670 $metadata['attribute_types'][$attribute->name]['required'] = $attribute->required; 671 } 672 673 /** 674 * Annotations ::= Annotation {[ "*" ]* [Annotation]}* 675 * 676 * @throws AnnotationException 677 * @throws ReflectionException 678 * 679 * @phpstan-return list<object> 680 */ 681 private function Annotations(): array 682 { 683 $annotations = []; 684 685 while ($this->lexer->lookahead !== null) { 686 if ($this->lexer->lookahead['type'] !== DocLexer::T_AT) { 687 $this->lexer->moveNext(); 688 continue; 689 } 690 691 // make sure the @ is preceded by non-catchable pattern 692 if ( 693 $this->lexer->token !== null && 694 $this->lexer->lookahead['position'] === $this->lexer->token['position'] + strlen( 695 $this->lexer->token['value'] 696 ) 697 ) { 698 $this->lexer->moveNext(); 699 continue; 700 } 701 702 // make sure the @ is followed by either a namespace separator, or 703 // an identifier token 704 $peek = $this->lexer->glimpse(); 705 if ( 706 ($peek === null) 707 || ($peek['type'] !== DocLexer::T_NAMESPACE_SEPARATOR && ! in_array( 708 $peek['type'], 709 self::$classIdentifiers, 710 true 711 )) 712 || $peek['position'] !== $this->lexer->lookahead['position'] + 1 713 ) { 714 $this->lexer->moveNext(); 715 continue; 716 } 717 718 $this->isNestedAnnotation = false; 719 $annot = $this->Annotation(); 720 if ($annot === false) { 721 continue; 722 } 723 724 $annotations[] = $annot; 725 } 726 727 return $annotations; 728 } 729 730 /** 731 * Annotation ::= "@" AnnotationName MethodCall 732 * AnnotationName ::= QualifiedName | SimpleName 733 * QualifiedName ::= NameSpacePart "\" {NameSpacePart "\"}* SimpleName 734 * NameSpacePart ::= identifier | null | false | true 735 * SimpleName ::= identifier | null | false | true 736 * 737 * @return object|false False if it is not a valid annotation. 738 * 739 * @throws AnnotationException 740 * @throws ReflectionException 741 */ 742 private function Annotation() 743 { 744 $this->match(DocLexer::T_AT); 745 746 // check if we have an annotation 747 $name = $this->Identifier(); 748 749 if ( 750 $this->lexer->isNextToken(DocLexer::T_MINUS) 751 && $this->lexer->nextTokenIsAdjacent() 752 ) { 753 // Annotations with dashes, such as "@foo-" or "@foo-bar", are to be discarded 754 return false; 755 } 756 757 // only process names which are not fully qualified, yet 758 // fully qualified names must start with a \ 759 $originalName = $name; 760 761 if ($name[0] !== '\\') { 762 $pos = strpos($name, '\\'); 763 $alias = ($pos === false) ? $name : substr($name, 0, $pos); 764 $found = false; 765 $loweredAlias = strtolower($alias); 766 767 if ($this->namespaces) { 768 foreach ($this->namespaces as $namespace) { 769 if ($this->classExists($namespace . '\\' . $name)) { 770 $name = $namespace . '\\' . $name; 771 $found = true; 772 break; 773 } 774 } 775 } elseif (isset($this->imports[$loweredAlias])) { 776 $namespace = ltrim($this->imports[$loweredAlias], '\\'); 777 $name = ($pos !== false) 778 ? $namespace . substr($name, $pos) 779 : $namespace; 780 $found = $this->classExists($name); 781 } elseif ( 782 ! isset($this->ignoredAnnotationNames[$name]) 783 && isset($this->imports['__NAMESPACE__']) 784 && $this->classExists($this->imports['__NAMESPACE__'] . '\\' . $name) 785 ) { 786 $name = $this->imports['__NAMESPACE__'] . '\\' . $name; 787 $found = true; 788 } elseif (! isset($this->ignoredAnnotationNames[$name]) && $this->classExists($name)) { 789 $found = true; 790 } 791 792 if (! $found) { 793 if ($this->isIgnoredAnnotation($name)) { 794 return false; 795 } 796 797 throw AnnotationException::semanticalError(sprintf( 798 <<<'EXCEPTION' 799The annotation "@%s" in %s was never imported. Did you maybe forget to add a "use" statement for this annotation? 800EXCEPTION 801 , 802 $name, 803 $this->context 804 )); 805 } 806 } 807 808 $name = ltrim($name, '\\'); 809 810 if (! $this->classExists($name)) { 811 throw AnnotationException::semanticalError(sprintf( 812 'The annotation "@%s" in %s does not exist, or could not be auto-loaded.', 813 $name, 814 $this->context 815 )); 816 } 817 818 // at this point, $name contains the fully qualified class name of the 819 // annotation, and it is also guaranteed that this class exists, and 820 // that it is loaded 821 822 // collects the metadata annotation only if there is not yet 823 if (! isset(self::$annotationMetadata[$name])) { 824 $this->collectAnnotationMetadata($name); 825 } 826 827 // verify that the class is really meant to be an annotation and not just any ordinary class 828 if (self::$annotationMetadata[$name]['is_annotation'] === false) { 829 if ($this->isIgnoredAnnotation($originalName) || $this->isIgnoredAnnotation($name)) { 830 return false; 831 } 832 833 throw AnnotationException::semanticalError(sprintf( 834 <<<'EXCEPTION' 835The class "%s" is not annotated with @Annotation. 836Are you sure this class can be used as annotation? 837If so, then you need to add @Annotation to the _class_ doc comment of "%s". 838If it is indeed no annotation, then you need to add @IgnoreAnnotation("%s") to the _class_ doc comment of %s. 839EXCEPTION 840 , 841 $name, 842 $name, 843 $originalName, 844 $this->context 845 )); 846 } 847 848 //if target is nested annotation 849 $target = $this->isNestedAnnotation ? Target::TARGET_ANNOTATION : $this->target; 850 851 // Next will be nested 852 $this->isNestedAnnotation = true; 853 854 //if annotation does not support current target 855 if ((self::$annotationMetadata[$name]['targets'] & $target) === 0 && $target) { 856 throw AnnotationException::semanticalError( 857 sprintf( 858 <<<'EXCEPTION' 859Annotation @%s is not allowed to be declared on %s. You may only use this annotation on these code elements: %s. 860EXCEPTION 861 , 862 $originalName, 863 $this->context, 864 self::$annotationMetadata[$name]['targets_literal'] 865 ) 866 ); 867 } 868 869 $arguments = $this->MethodCall(); 870 $values = $this->resolvePositionalValues($arguments, $name); 871 872 if (isset(self::$annotationMetadata[$name]['enum'])) { 873 // checks all declared attributes 874 foreach (self::$annotationMetadata[$name]['enum'] as $property => $enum) { 875 // checks if the attribute is a valid enumerator 876 if (isset($values[$property]) && ! in_array($values[$property], $enum['value'])) { 877 throw AnnotationException::enumeratorError( 878 $property, 879 $name, 880 $this->context, 881 $enum['literal'], 882 $values[$property] 883 ); 884 } 885 } 886 } 887 888 // checks all declared attributes 889 foreach (self::$annotationMetadata[$name]['attribute_types'] as $property => $type) { 890 if ( 891 $property === self::$annotationMetadata[$name]['default_property'] 892 && ! isset($values[$property]) && isset($values['value']) 893 ) { 894 $property = 'value'; 895 } 896 897 // handle a not given attribute or null value 898 if (! isset($values[$property])) { 899 if ($type['required']) { 900 throw AnnotationException::requiredError( 901 $property, 902 $originalName, 903 $this->context, 904 'a(n) ' . $type['value'] 905 ); 906 } 907 908 continue; 909 } 910 911 if ($type['type'] === 'array') { 912 // handle the case of a single value 913 if (! is_array($values[$property])) { 914 $values[$property] = [$values[$property]]; 915 } 916 917 // checks if the attribute has array type declaration, such as "array<string>" 918 if (isset($type['array_type'])) { 919 foreach ($values[$property] as $item) { 920 if (gettype($item) !== $type['array_type'] && ! $item instanceof $type['array_type']) { 921 throw AnnotationException::attributeTypeError( 922 $property, 923 $originalName, 924 $this->context, 925 'either a(n) ' . $type['array_type'] . ', or an array of ' . $type['array_type'] . 's', 926 $item 927 ); 928 } 929 } 930 } 931 } elseif (gettype($values[$property]) !== $type['type'] && ! $values[$property] instanceof $type['type']) { 932 throw AnnotationException::attributeTypeError( 933 $property, 934 $originalName, 935 $this->context, 936 'a(n) ' . $type['value'], 937 $values[$property] 938 ); 939 } 940 } 941 942 if (self::$annotationMetadata[$name]['has_named_argument_constructor']) { 943 if (PHP_VERSION_ID >= 80000) { 944 return new $name(...$values); 945 } 946 947 $positionalValues = []; 948 foreach (self::$annotationMetadata[$name]['constructor_args'] as $property => $parameter) { 949 $positionalValues[$parameter['position']] = $parameter['default']; 950 } 951 952 foreach ($values as $property => $value) { 953 if (! isset(self::$annotationMetadata[$name]['constructor_args'][$property])) { 954 throw AnnotationException::creationError(sprintf( 955 <<<'EXCEPTION' 956The annotation @%s declared on %s does not have a property named "%s" 957that can be set through its named arguments constructor. 958Available named arguments: %s 959EXCEPTION 960 , 961 $originalName, 962 $this->context, 963 $property, 964 implode(', ', array_keys(self::$annotationMetadata[$name]['constructor_args'])) 965 )); 966 } 967 968 $positionalValues[self::$annotationMetadata[$name]['constructor_args'][$property]['position']] = $value; 969 } 970 971 return new $name(...$positionalValues); 972 } 973 974 // check if the annotation expects values via the constructor, 975 // or directly injected into public properties 976 if (self::$annotationMetadata[$name]['has_constructor'] === true) { 977 return new $name($values); 978 } 979 980 $instance = new $name(); 981 982 foreach ($values as $property => $value) { 983 if (! isset(self::$annotationMetadata[$name]['properties'][$property])) { 984 if ($property !== 'value') { 985 throw AnnotationException::creationError(sprintf( 986 <<<'EXCEPTION' 987The annotation @%s declared on %s does not have a property named "%s". 988Available properties: %s 989EXCEPTION 990 , 991 $originalName, 992 $this->context, 993 $property, 994 implode(', ', self::$annotationMetadata[$name]['properties']) 995 )); 996 } 997 998 // handle the case if the property has no annotations 999 $property = self::$annotationMetadata[$name]['default_property']; 1000 if (! $property) { 1001 throw AnnotationException::creationError(sprintf( 1002 'The annotation @%s declared on %s does not accept any values, but got %s.', 1003 $originalName, 1004 $this->context, 1005 json_encode($values) 1006 )); 1007 } 1008 } 1009 1010 $instance->{$property} = $value; 1011 } 1012 1013 return $instance; 1014 } 1015 1016 /** 1017 * MethodCall ::= ["(" [Values] ")"] 1018 * 1019 * @return mixed[] 1020 * 1021 * @throws AnnotationException 1022 * @throws ReflectionException 1023 */ 1024 private function MethodCall(): array 1025 { 1026 $values = []; 1027 1028 if (! $this->lexer->isNextToken(DocLexer::T_OPEN_PARENTHESIS)) { 1029 return $values; 1030 } 1031 1032 $this->match(DocLexer::T_OPEN_PARENTHESIS); 1033 1034 if (! $this->lexer->isNextToken(DocLexer::T_CLOSE_PARENTHESIS)) { 1035 $values = $this->Values(); 1036 } 1037 1038 $this->match(DocLexer::T_CLOSE_PARENTHESIS); 1039 1040 return $values; 1041 } 1042 1043 /** 1044 * Values ::= Array | Value {"," Value}* [","] 1045 * 1046 * @return mixed[] 1047 * 1048 * @throws AnnotationException 1049 * @throws ReflectionException 1050 */ 1051 private function Values(): array 1052 { 1053 $values = [$this->Value()]; 1054 1055 while ($this->lexer->isNextToken(DocLexer::T_COMMA)) { 1056 $this->match(DocLexer::T_COMMA); 1057 1058 if ($this->lexer->isNextToken(DocLexer::T_CLOSE_PARENTHESIS)) { 1059 break; 1060 } 1061 1062 $token = $this->lexer->lookahead; 1063 $value = $this->Value(); 1064 1065 $values[] = $value; 1066 } 1067 1068 $namedArguments = []; 1069 $positionalArguments = []; 1070 foreach ($values as $k => $value) { 1071 if (is_object($value) && $value instanceof stdClass) { 1072 $namedArguments[$value->name] = $value->value; 1073 } else { 1074 $positionalArguments[$k] = $value; 1075 } 1076 } 1077 1078 return ['named_arguments' => $namedArguments, 'positional_arguments' => $positionalArguments]; 1079 } 1080 1081 /** 1082 * Constant ::= integer | string | float | boolean 1083 * 1084 * @return mixed 1085 * 1086 * @throws AnnotationException 1087 */ 1088 private function Constant() 1089 { 1090 $identifier = $this->Identifier(); 1091 1092 if (! defined($identifier) && strpos($identifier, '::') !== false && $identifier[0] !== '\\') { 1093 [$className, $const] = explode('::', $identifier); 1094 1095 $pos = strpos($className, '\\'); 1096 $alias = ($pos === false) ? $className : substr($className, 0, $pos); 1097 $found = false; 1098 $loweredAlias = strtolower($alias); 1099 1100 switch (true) { 1101 case ! empty($this->namespaces): 1102 foreach ($this->namespaces as $ns) { 1103 if (class_exists($ns . '\\' . $className) || interface_exists($ns . '\\' . $className)) { 1104 $className = $ns . '\\' . $className; 1105 $found = true; 1106 break; 1107 } 1108 } 1109 1110 break; 1111 1112 case isset($this->imports[$loweredAlias]): 1113 $found = true; 1114 $className = ($pos !== false) 1115 ? $this->imports[$loweredAlias] . substr($className, $pos) 1116 : $this->imports[$loweredAlias]; 1117 break; 1118 1119 default: 1120 if (isset($this->imports['__NAMESPACE__'])) { 1121 $ns = $this->imports['__NAMESPACE__']; 1122 1123 if (class_exists($ns . '\\' . $className) || interface_exists($ns . '\\' . $className)) { 1124 $className = $ns . '\\' . $className; 1125 $found = true; 1126 } 1127 } 1128 1129 break; 1130 } 1131 1132 if ($found) { 1133 $identifier = $className . '::' . $const; 1134 } 1135 } 1136 1137 /** 1138 * Checks if identifier ends with ::class and remove the leading backslash if it exists. 1139 */ 1140 if ( 1141 $this->identifierEndsWithClassConstant($identifier) && 1142 ! $this->identifierStartsWithBackslash($identifier) 1143 ) { 1144 return substr($identifier, 0, $this->getClassConstantPositionInIdentifier($identifier)); 1145 } 1146 1147 if ($this->identifierEndsWithClassConstant($identifier) && $this->identifierStartsWithBackslash($identifier)) { 1148 return substr($identifier, 1, $this->getClassConstantPositionInIdentifier($identifier) - 1); 1149 } 1150 1151 if (! defined($identifier)) { 1152 throw AnnotationException::semanticalErrorConstants($identifier, $this->context); 1153 } 1154 1155 return constant($identifier); 1156 } 1157 1158 private function identifierStartsWithBackslash(string $identifier): bool 1159 { 1160 return $identifier[0] === '\\'; 1161 } 1162 1163 private function identifierEndsWithClassConstant(string $identifier): bool 1164 { 1165 return $this->getClassConstantPositionInIdentifier($identifier) === strlen($identifier) - strlen('::class'); 1166 } 1167 1168 /** 1169 * @return int|false 1170 */ 1171 private function getClassConstantPositionInIdentifier(string $identifier) 1172 { 1173 return stripos($identifier, '::class'); 1174 } 1175 1176 /** 1177 * Identifier ::= string 1178 * 1179 * @throws AnnotationException 1180 */ 1181 private function Identifier(): string 1182 { 1183 // check if we have an annotation 1184 if (! $this->lexer->isNextTokenAny(self::$classIdentifiers)) { 1185 throw $this->syntaxError('namespace separator or identifier'); 1186 } 1187 1188 $this->lexer->moveNext(); 1189 1190 $className = $this->lexer->token['value']; 1191 1192 while ( 1193 $this->lexer->lookahead !== null && 1194 $this->lexer->lookahead['position'] === ($this->lexer->token['position'] + 1195 strlen($this->lexer->token['value'])) && 1196 $this->lexer->isNextToken(DocLexer::T_NAMESPACE_SEPARATOR) 1197 ) { 1198 $this->match(DocLexer::T_NAMESPACE_SEPARATOR); 1199 $this->matchAny(self::$classIdentifiers); 1200 1201 $className .= '\\' . $this->lexer->token['value']; 1202 } 1203 1204 return $className; 1205 } 1206 1207 /** 1208 * Value ::= PlainValue | FieldAssignment 1209 * 1210 * @return mixed 1211 * 1212 * @throws AnnotationException 1213 * @throws ReflectionException 1214 */ 1215 private function Value() 1216 { 1217 $peek = $this->lexer->glimpse(); 1218 1219 if ($peek['type'] === DocLexer::T_EQUALS) { 1220 return $this->FieldAssignment(); 1221 } 1222 1223 return $this->PlainValue(); 1224 } 1225 1226 /** 1227 * PlainValue ::= integer | string | float | boolean | Array | Annotation 1228 * 1229 * @return mixed 1230 * 1231 * @throws AnnotationException 1232 * @throws ReflectionException 1233 */ 1234 private function PlainValue() 1235 { 1236 if ($this->lexer->isNextToken(DocLexer::T_OPEN_CURLY_BRACES)) { 1237 return $this->Arrayx(); 1238 } 1239 1240 if ($this->lexer->isNextToken(DocLexer::T_AT)) { 1241 return $this->Annotation(); 1242 } 1243 1244 if ($this->lexer->isNextToken(DocLexer::T_IDENTIFIER)) { 1245 return $this->Constant(); 1246 } 1247 1248 switch ($this->lexer->lookahead['type']) { 1249 case DocLexer::T_STRING: 1250 $this->match(DocLexer::T_STRING); 1251 1252 return $this->lexer->token['value']; 1253 1254 case DocLexer::T_INTEGER: 1255 $this->match(DocLexer::T_INTEGER); 1256 1257 return (int) $this->lexer->token['value']; 1258 1259 case DocLexer::T_FLOAT: 1260 $this->match(DocLexer::T_FLOAT); 1261 1262 return (float) $this->lexer->token['value']; 1263 1264 case DocLexer::T_TRUE: 1265 $this->match(DocLexer::T_TRUE); 1266 1267 return true; 1268 1269 case DocLexer::T_FALSE: 1270 $this->match(DocLexer::T_FALSE); 1271 1272 return false; 1273 1274 case DocLexer::T_NULL: 1275 $this->match(DocLexer::T_NULL); 1276 1277 return null; 1278 1279 default: 1280 throw $this->syntaxError('PlainValue'); 1281 } 1282 } 1283 1284 /** 1285 * FieldAssignment ::= FieldName "=" PlainValue 1286 * FieldName ::= identifier 1287 * 1288 * @throws AnnotationException 1289 * @throws ReflectionException 1290 */ 1291 private function FieldAssignment(): stdClass 1292 { 1293 $this->match(DocLexer::T_IDENTIFIER); 1294 $fieldName = $this->lexer->token['value']; 1295 1296 $this->match(DocLexer::T_EQUALS); 1297 1298 $item = new stdClass(); 1299 $item->name = $fieldName; 1300 $item->value = $this->PlainValue(); 1301 1302 return $item; 1303 } 1304 1305 /** 1306 * Array ::= "{" ArrayEntry {"," ArrayEntry}* [","] "}" 1307 * 1308 * @return mixed[] 1309 * 1310 * @throws AnnotationException 1311 * @throws ReflectionException 1312 */ 1313 private function Arrayx(): array 1314 { 1315 $array = $values = []; 1316 1317 $this->match(DocLexer::T_OPEN_CURLY_BRACES); 1318 1319 // If the array is empty, stop parsing and return. 1320 if ($this->lexer->isNextToken(DocLexer::T_CLOSE_CURLY_BRACES)) { 1321 $this->match(DocLexer::T_CLOSE_CURLY_BRACES); 1322 1323 return $array; 1324 } 1325 1326 $values[] = $this->ArrayEntry(); 1327 1328 while ($this->lexer->isNextToken(DocLexer::T_COMMA)) { 1329 $this->match(DocLexer::T_COMMA); 1330 1331 // optional trailing comma 1332 if ($this->lexer->isNextToken(DocLexer::T_CLOSE_CURLY_BRACES)) { 1333 break; 1334 } 1335 1336 $values[] = $this->ArrayEntry(); 1337 } 1338 1339 $this->match(DocLexer::T_CLOSE_CURLY_BRACES); 1340 1341 foreach ($values as $value) { 1342 [$key, $val] = $value; 1343 1344 if ($key !== null) { 1345 $array[$key] = $val; 1346 } else { 1347 $array[] = $val; 1348 } 1349 } 1350 1351 return $array; 1352 } 1353 1354 /** 1355 * ArrayEntry ::= Value | KeyValuePair 1356 * KeyValuePair ::= Key ("=" | ":") PlainValue | Constant 1357 * Key ::= string | integer | Constant 1358 * 1359 * @throws AnnotationException 1360 * @throws ReflectionException 1361 * 1362 * @phpstan-return array{mixed, mixed} 1363 */ 1364 private function ArrayEntry(): array 1365 { 1366 $peek = $this->lexer->glimpse(); 1367 1368 if ( 1369 $peek['type'] === DocLexer::T_EQUALS 1370 || $peek['type'] === DocLexer::T_COLON 1371 ) { 1372 if ($this->lexer->isNextToken(DocLexer::T_IDENTIFIER)) { 1373 $key = $this->Constant(); 1374 } else { 1375 $this->matchAny([DocLexer::T_INTEGER, DocLexer::T_STRING]); 1376 $key = $this->lexer->token['value']; 1377 } 1378 1379 $this->matchAny([DocLexer::T_EQUALS, DocLexer::T_COLON]); 1380 1381 return [$key, $this->PlainValue()]; 1382 } 1383 1384 return [null, $this->Value()]; 1385 } 1386 1387 /** 1388 * Checks whether the given $name matches any ignored annotation name or namespace 1389 */ 1390 private function isIgnoredAnnotation(string $name): bool 1391 { 1392 if ($this->ignoreNotImportedAnnotations || isset($this->ignoredAnnotationNames[$name])) { 1393 return true; 1394 } 1395 1396 foreach (array_keys($this->ignoredAnnotationNamespaces) as $ignoredAnnotationNamespace) { 1397 $ignoredAnnotationNamespace = rtrim($ignoredAnnotationNamespace, '\\') . '\\'; 1398 1399 if (stripos(rtrim($name, '\\') . '\\', $ignoredAnnotationNamespace) === 0) { 1400 return true; 1401 } 1402 } 1403 1404 return false; 1405 } 1406 1407 /** 1408 * Resolve positional arguments (without name) to named ones 1409 * 1410 * @param array<string,mixed> $arguments 1411 * 1412 * @return array<string,mixed> 1413 */ 1414 private function resolvePositionalValues(array $arguments, string $name): array 1415 { 1416 $positionalArguments = $arguments['positional_arguments'] ?? []; 1417 $values = $arguments['named_arguments'] ?? []; 1418 1419 if ( 1420 self::$annotationMetadata[$name]['has_named_argument_constructor'] 1421 && self::$annotationMetadata[$name]['default_property'] !== null 1422 ) { 1423 // We must ensure that we don't have positional arguments after named ones 1424 $positions = array_keys($positionalArguments); 1425 $lastPosition = null; 1426 foreach ($positions as $position) { 1427 if ( 1428 ($lastPosition === null && $position !== 0) || 1429 ($lastPosition !== null && $position !== $lastPosition + 1) 1430 ) { 1431 throw $this->syntaxError('Positional arguments after named arguments is not allowed'); 1432 } 1433 1434 $lastPosition = $position; 1435 } 1436 1437 foreach (self::$annotationMetadata[$name]['constructor_args'] as $property => $parameter) { 1438 $position = $parameter['position']; 1439 if (isset($values[$property]) || ! isset($positionalArguments[$position])) { 1440 continue; 1441 } 1442 1443 $values[$property] = $positionalArguments[$position]; 1444 } 1445 } else { 1446 if (count($positionalArguments) > 0 && ! isset($values['value'])) { 1447 if (count($positionalArguments) === 1) { 1448 $value = array_pop($positionalArguments); 1449 } else { 1450 $value = array_values($positionalArguments); 1451 } 1452 1453 $values['value'] = $value; 1454 } 1455 } 1456 1457 return $values; 1458 } 1459} 1460