1<?php 2 3declare(strict_types=1); 4 5/** 6 * This file is part of phpDocumentor. 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 * 11 * @link http://phpdoc.org 12 */ 13 14namespace phpDocumentor\Reflection; 15 16use ArrayIterator; 17use InvalidArgumentException; 18use phpDocumentor\Reflection\Types\Array_; 19use phpDocumentor\Reflection\Types\ClassString; 20use phpDocumentor\Reflection\Types\Collection; 21use phpDocumentor\Reflection\Types\Compound; 22use phpDocumentor\Reflection\Types\Context; 23use phpDocumentor\Reflection\Types\Expression; 24use phpDocumentor\Reflection\Types\Integer; 25use phpDocumentor\Reflection\Types\Intersection; 26use phpDocumentor\Reflection\Types\Iterable_; 27use phpDocumentor\Reflection\Types\Nullable; 28use phpDocumentor\Reflection\Types\Object_; 29use phpDocumentor\Reflection\Types\String_; 30use RuntimeException; 31use function array_key_exists; 32use function array_pop; 33use function array_values; 34use function class_exists; 35use function class_implements; 36use function count; 37use function end; 38use function in_array; 39use function key; 40use function preg_split; 41use function strpos; 42use function strtolower; 43use function trim; 44use const PREG_SPLIT_DELIM_CAPTURE; 45use const PREG_SPLIT_NO_EMPTY; 46 47final class TypeResolver 48{ 49 /** @var string Definition of the ARRAY operator for types */ 50 private const OPERATOR_ARRAY = '[]'; 51 52 /** @var string Definition of the NAMESPACE operator in PHP */ 53 private const OPERATOR_NAMESPACE = '\\'; 54 55 /** @var int the iterator parser is inside a compound context */ 56 private const PARSER_IN_COMPOUND = 0; 57 58 /** @var int the iterator parser is inside a nullable expression context */ 59 private const PARSER_IN_NULLABLE = 1; 60 61 /** @var int the iterator parser is inside an array expression context */ 62 private const PARSER_IN_ARRAY_EXPRESSION = 2; 63 64 /** @var int the iterator parser is inside a collection expression context */ 65 private const PARSER_IN_COLLECTION_EXPRESSION = 3; 66 67 /** 68 * @var array<string, string> List of recognized keywords and unto which Value Object they map 69 * @psalm-var array<string, class-string<Type>> 70 */ 71 private $keywords = [ 72 'string' => Types\String_::class, 73 'class-string' => Types\ClassString::class, 74 'int' => Types\Integer::class, 75 'integer' => Types\Integer::class, 76 'bool' => Types\Boolean::class, 77 'boolean' => Types\Boolean::class, 78 'real' => Types\Float_::class, 79 'float' => Types\Float_::class, 80 'double' => Types\Float_::class, 81 'object' => Object_::class, 82 'mixed' => Types\Mixed_::class, 83 'array' => Array_::class, 84 'resource' => Types\Resource_::class, 85 'void' => Types\Void_::class, 86 'null' => Types\Null_::class, 87 'scalar' => Types\Scalar::class, 88 'callback' => Types\Callable_::class, 89 'callable' => Types\Callable_::class, 90 'false' => PseudoTypes\False_::class, 91 'true' => PseudoTypes\True_::class, 92 'self' => Types\Self_::class, 93 '$this' => Types\This::class, 94 'static' => Types\Static_::class, 95 'parent' => Types\Parent_::class, 96 'iterable' => Iterable_::class, 97 ]; 98 99 /** 100 * @var FqsenResolver 101 * @psalm-readonly 102 */ 103 private $fqsenResolver; 104 105 /** 106 * Initializes this TypeResolver with the means to create and resolve Fqsen objects. 107 */ 108 public function __construct(?FqsenResolver $fqsenResolver = null) 109 { 110 $this->fqsenResolver = $fqsenResolver ?: new FqsenResolver(); 111 } 112 113 /** 114 * Analyzes the given type and returns the FQCN variant. 115 * 116 * When a type is provided this method checks whether it is not a keyword or 117 * Fully Qualified Class Name. If so it will use the given namespace and 118 * aliases to expand the type to a FQCN representation. 119 * 120 * This method only works as expected if the namespace and aliases are set; 121 * no dynamic reflection is being performed here. 122 * 123 * @uses Context::getNamespaceAliases() to check whether the first part of the relative type name should not be 124 * replaced with another namespace. 125 * @uses Context::getNamespace() to determine with what to prefix the type name. 126 * 127 * @param string $type The relative or absolute type. 128 */ 129 public function resolve(string $type, ?Context $context = null) : Type 130 { 131 $type = trim($type); 132 if (!$type) { 133 throw new InvalidArgumentException('Attempted to resolve "' . $type . '" but it appears to be empty'); 134 } 135 136 if ($context === null) { 137 $context = new Context(''); 138 } 139 140 // split the type string into tokens `|`, `?`, `<`, `>`, `,`, `(`, `)`, `[]`, '<', '>' and type names 141 $tokens = preg_split( 142 '/(\\||\\?|<|>|&|, ?|\\(|\\)|\\[\\]+)/', 143 $type, 144 -1, 145 PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE 146 ); 147 148 if ($tokens === false) { 149 throw new InvalidArgumentException('Unable to split the type string "' . $type . '" into tokens'); 150 } 151 152 /** @var ArrayIterator<int, string|null> $tokenIterator */ 153 $tokenIterator = new ArrayIterator($tokens); 154 155 return $this->parseTypes($tokenIterator, $context, self::PARSER_IN_COMPOUND); 156 } 157 158 /** 159 * Analyse each tokens and creates types 160 * 161 * @param ArrayIterator<int, string|null> $tokens the iterator on tokens 162 * @param int $parserContext on of self::PARSER_* constants, indicating 163 * the context where we are in the parsing 164 */ 165 private function parseTypes(ArrayIterator $tokens, Context $context, int $parserContext) : Type 166 { 167 $types = []; 168 $token = ''; 169 $compoundToken = '|'; 170 while ($tokens->valid()) { 171 $token = $tokens->current(); 172 if ($token === null) { 173 throw new RuntimeException( 174 'Unexpected nullable character' 175 ); 176 } 177 178 if ($token === '|' || $token === '&') { 179 if (count($types) === 0) { 180 throw new RuntimeException( 181 'A type is missing before a type separator' 182 ); 183 } 184 185 if (!in_array($parserContext, [ 186 self::PARSER_IN_COMPOUND, 187 self::PARSER_IN_ARRAY_EXPRESSION, 188 self::PARSER_IN_COLLECTION_EXPRESSION, 189 ], true) 190 ) { 191 throw new RuntimeException( 192 'Unexpected type separator' 193 ); 194 } 195 196 $compoundToken = $token; 197 $tokens->next(); 198 } elseif ($token === '?') { 199 if (!in_array($parserContext, [ 200 self::PARSER_IN_COMPOUND, 201 self::PARSER_IN_ARRAY_EXPRESSION, 202 self::PARSER_IN_COLLECTION_EXPRESSION, 203 ], true) 204 ) { 205 throw new RuntimeException( 206 'Unexpected nullable character' 207 ); 208 } 209 210 $tokens->next(); 211 $type = $this->parseTypes($tokens, $context, self::PARSER_IN_NULLABLE); 212 $types[] = new Nullable($type); 213 } elseif ($token === '(') { 214 $tokens->next(); 215 $type = $this->parseTypes($tokens, $context, self::PARSER_IN_ARRAY_EXPRESSION); 216 217 $token = $tokens->current(); 218 if ($token === null) { // Someone did not properly close their array expression .. 219 break; 220 } 221 222 $tokens->next(); 223 224 $resolvedType = new Expression($type); 225 226 $types[] = $resolvedType; 227 } elseif ($parserContext === self::PARSER_IN_ARRAY_EXPRESSION && $token[0] === ')') { 228 break; 229 } elseif ($token === '<') { 230 if (count($types) === 0) { 231 throw new RuntimeException( 232 'Unexpected collection operator "<", class name is missing' 233 ); 234 } 235 236 $classType = array_pop($types); 237 if ($classType !== null) { 238 if ((string) $classType === 'class-string') { 239 $types[] = $this->resolveClassString($tokens, $context); 240 } else { 241 $types[] = $this->resolveCollection($tokens, $classType, $context); 242 } 243 } 244 245 $tokens->next(); 246 } elseif ($parserContext === self::PARSER_IN_COLLECTION_EXPRESSION 247 && ($token === '>' || trim($token) === ',') 248 ) { 249 break; 250 } elseif ($token === self::OPERATOR_ARRAY) { 251 end($types); 252 $last = key($types); 253 $lastItem = $types[$last]; 254 if ($lastItem instanceof Expression) { 255 $lastItem = $lastItem->getValueType(); 256 } 257 258 $types[$last] = new Array_($lastItem); 259 260 $tokens->next(); 261 } else { 262 $type = $this->resolveSingleType($token, $context); 263 $tokens->next(); 264 if ($parserContext === self::PARSER_IN_NULLABLE) { 265 return $type; 266 } 267 268 $types[] = $type; 269 } 270 } 271 272 if ($token === '|' || $token === '&') { 273 throw new RuntimeException( 274 'A type is missing after a type separator' 275 ); 276 } 277 278 if (count($types) === 0) { 279 if ($parserContext === self::PARSER_IN_NULLABLE) { 280 throw new RuntimeException( 281 'A type is missing after a nullable character' 282 ); 283 } 284 285 if ($parserContext === self::PARSER_IN_ARRAY_EXPRESSION) { 286 throw new RuntimeException( 287 'A type is missing in an array expression' 288 ); 289 } 290 291 if ($parserContext === self::PARSER_IN_COLLECTION_EXPRESSION) { 292 throw new RuntimeException( 293 'A type is missing in a collection expression' 294 ); 295 } 296 } elseif (count($types) === 1) { 297 return $types[0]; 298 } 299 300 if ($compoundToken === '|') { 301 return new Compound(array_values($types)); 302 } 303 304 return new Intersection(array_values($types)); 305 } 306 307 /** 308 * resolve the given type into a type object 309 * 310 * @param string $type the type string, representing a single type 311 * 312 * @return Type|Array_|Object_ 313 * 314 * @psalm-mutation-free 315 */ 316 private function resolveSingleType(string $type, Context $context) : object 317 { 318 switch (true) { 319 case $this->isKeyword($type): 320 return $this->resolveKeyword($type); 321 case $this->isFqsen($type): 322 return $this->resolveTypedObject($type); 323 case $this->isPartialStructuralElementName($type): 324 return $this->resolveTypedObject($type, $context); 325 326 // @codeCoverageIgnoreStart 327 default: 328 // I haven't got the foggiest how the logic would come here but added this as a defense. 329 throw new RuntimeException( 330 'Unable to resolve type "' . $type . '", there is no known method to resolve it' 331 ); 332 } 333 334 // @codeCoverageIgnoreEnd 335 } 336 337 /** 338 * Adds a keyword to the list of Keywords and associates it with a specific Value Object. 339 * 340 * @psalm-param class-string<Type> $typeClassName 341 */ 342 public function addKeyword(string $keyword, string $typeClassName) : void 343 { 344 if (!class_exists($typeClassName)) { 345 throw new InvalidArgumentException( 346 'The Value Object that needs to be created with a keyword "' . $keyword . '" must be an existing class' 347 . ' but we could not find the class ' . $typeClassName 348 ); 349 } 350 351 if (!in_array(Type::class, class_implements($typeClassName), true)) { 352 throw new InvalidArgumentException( 353 'The class "' . $typeClassName . '" must implement the interface "phpDocumentor\Reflection\Type"' 354 ); 355 } 356 357 $this->keywords[$keyword] = $typeClassName; 358 } 359 360 /** 361 * Detects whether the given type represents a PHPDoc keyword. 362 * 363 * @param string $type A relative or absolute type as defined in the phpDocumentor documentation. 364 * 365 * @psalm-mutation-free 366 */ 367 private function isKeyword(string $type) : bool 368 { 369 return array_key_exists(strtolower($type), $this->keywords); 370 } 371 372 /** 373 * Detects whether the given type represents a relative structural element name. 374 * 375 * @param string $type A relative or absolute type as defined in the phpDocumentor documentation. 376 * 377 * @psalm-mutation-free 378 */ 379 private function isPartialStructuralElementName(string $type) : bool 380 { 381 return ($type[0] !== self::OPERATOR_NAMESPACE) && !$this->isKeyword($type); 382 } 383 384 /** 385 * Tests whether the given type is a Fully Qualified Structural Element Name. 386 * 387 * @psalm-mutation-free 388 */ 389 private function isFqsen(string $type) : bool 390 { 391 return strpos($type, self::OPERATOR_NAMESPACE) === 0; 392 } 393 394 /** 395 * Resolves the given keyword (such as `string`) into a Type object representing that keyword. 396 * 397 * @psalm-mutation-free 398 */ 399 private function resolveKeyword(string $type) : Type 400 { 401 $className = $this->keywords[strtolower($type)]; 402 403 return new $className(); 404 } 405 406 /** 407 * Resolves the given FQSEN string into an FQSEN object. 408 * 409 * @psalm-mutation-free 410 */ 411 private function resolveTypedObject(string $type, ?Context $context = null) : Object_ 412 { 413 return new Object_($this->fqsenResolver->resolve($type, $context)); 414 } 415 416 /** 417 * Resolves class string 418 * 419 * @param ArrayIterator<int, (string|null)> $tokens 420 */ 421 private function resolveClassString(ArrayIterator $tokens, Context $context) : Type 422 { 423 $tokens->next(); 424 425 $classType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION); 426 427 if (!$classType instanceof Object_ || $classType->getFqsen() === null) { 428 throw new RuntimeException( 429 $classType . ' is not a class string' 430 ); 431 } 432 433 $token = $tokens->current(); 434 if ($token !== '>') { 435 if (empty($token)) { 436 throw new RuntimeException( 437 'class-string: ">" is missing' 438 ); 439 } 440 441 throw new RuntimeException( 442 'Unexpected character "' . $token . '", ">" is missing' 443 ); 444 } 445 446 return new ClassString($classType->getFqsen()); 447 } 448 449 /** 450 * Resolves the collection values and keys 451 * 452 * @param ArrayIterator<int, (string|null)> $tokens 453 * 454 * @return Array_|Iterable_|Collection 455 */ 456 private function resolveCollection(ArrayIterator $tokens, Type $classType, Context $context) : Type 457 { 458 $isArray = ((string) $classType === 'array'); 459 $isIterable = ((string) $classType === 'iterable'); 460 461 // allow only "array", "iterable" or class name before "<" 462 if (!$isArray && !$isIterable 463 && (!$classType instanceof Object_ || $classType->getFqsen() === null)) { 464 throw new RuntimeException( 465 $classType . ' is not a collection' 466 ); 467 } 468 469 $tokens->next(); 470 471 $valueType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION); 472 $keyType = null; 473 474 $token = $tokens->current(); 475 if ($token !== null && trim($token) === ',') { 476 // if we have a comma, then we just parsed the key type, not the value type 477 $keyType = $valueType; 478 if ($isArray) { 479 // check the key type for an "array" collection. We allow only 480 // strings or integers. 481 if (!$keyType instanceof String_ && 482 !$keyType instanceof Integer && 483 !$keyType instanceof Compound 484 ) { 485 throw new RuntimeException( 486 'An array can have only integers or strings as keys' 487 ); 488 } 489 490 if ($keyType instanceof Compound) { 491 foreach ($keyType->getIterator() as $item) { 492 if (!$item instanceof String_ && 493 !$item instanceof Integer 494 ) { 495 throw new RuntimeException( 496 'An array can have only integers or strings as keys' 497 ); 498 } 499 } 500 } 501 } 502 503 $tokens->next(); 504 // now let's parse the value type 505 $valueType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION); 506 } 507 508 $token = $tokens->current(); 509 if ($token !== '>') { 510 if (empty($token)) { 511 throw new RuntimeException( 512 'Collection: ">" is missing' 513 ); 514 } 515 516 throw new RuntimeException( 517 'Unexpected character "' . $token . '", ">" is missing' 518 ); 519 } 520 521 if ($isArray) { 522 return new Array_($valueType, $keyType); 523 } 524 525 if ($isIterable) { 526 return new Iterable_($valueType, $keyType); 527 } 528 529 if ($classType instanceof Object_) { 530 return $this->makeCollectionFromObject($classType, $valueType, $keyType); 531 } 532 533 throw new RuntimeException('Invalid $classType provided'); 534 } 535 536 /** 537 * @psalm-pure 538 */ 539 private function makeCollectionFromObject(Object_ $object, Type $valueType, ?Type $keyType = null) : Collection 540 { 541 return new Collection($object->getFqsen(), $valueType, $keyType); 542 } 543} 544