PhpDocReader.php
308 lines
| 1 | <?php declare(strict_types=1); |
| 2 | |
| 3 | namespace Cybot\Dependencies\PhpDocReader; |
| 4 | |
| 5 | use Cybot\Dependencies\PhpDocReader\PhpParser\UseStatementParser; |
| 6 | use ReflectionClass; |
| 7 | use ReflectionMethod; |
| 8 | use ReflectionParameter; |
| 9 | use ReflectionProperty; |
| 10 | use Reflector; |
| 11 | |
| 12 | /** |
| 13 | * PhpDoc reader |
| 14 | */ |
| 15 | class PhpDocReader |
| 16 | { |
| 17 | /** @var UseStatementParser */ |
| 18 | private $parser; |
| 19 | |
| 20 | private const PRIMITIVE_TYPES = [ |
| 21 | 'bool' => 'bool', |
| 22 | 'boolean' => 'bool', |
| 23 | 'string' => 'string', |
| 24 | 'int' => 'int', |
| 25 | 'integer' => 'int', |
| 26 | 'float' => 'float', |
| 27 | 'double' => 'float', |
| 28 | 'array' => 'array', |
| 29 | 'object' => 'object', |
| 30 | 'callable' => 'callable', |
| 31 | 'resource' => 'resource', |
| 32 | 'mixed' => 'mixed', |
| 33 | 'iterable' => 'iterable', |
| 34 | ]; |
| 35 | |
| 36 | /** @var bool */ |
| 37 | private $ignorePhpDocErrors; |
| 38 | |
| 39 | /** |
| 40 | * @param bool $ignorePhpDocErrors Enable or disable throwing errors when PhpDoc errors occur (when parsing annotations). |
| 41 | */ |
| 42 | public function __construct(bool $ignorePhpDocErrors = false) |
| 43 | { |
| 44 | $this->parser = new UseStatementParser; |
| 45 | $this->ignorePhpDocErrors = $ignorePhpDocErrors; |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * Parse the docblock of the property to get the type (class or primitive type) of the var annotation. |
| 50 | * |
| 51 | * @return string|null Type of the property (content of var annotation) |
| 52 | * @throws AnnotationException |
| 53 | */ |
| 54 | public function getPropertyType(ReflectionProperty $property): ?string |
| 55 | { |
| 56 | return $this->readPropertyType($property, true); |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Parse the docblock of the property to get the class of the var annotation. |
| 61 | * |
| 62 | * @return string|null Type of the property (content of var annotation) |
| 63 | * @throws AnnotationException |
| 64 | */ |
| 65 | public function getPropertyClass(ReflectionProperty $property): ?string |
| 66 | { |
| 67 | return $this->readPropertyType($property, false); |
| 68 | } |
| 69 | |
| 70 | private function readPropertyType(ReflectionProperty $property, bool $allowPrimitiveTypes): ?string |
| 71 | { |
| 72 | // Get the content of the @var annotation |
| 73 | $docComment = $property->getDocComment(); |
| 74 | if (! $docComment) { |
| 75 | return null; |
| 76 | } |
| 77 | if (preg_match('/@var\s+([^\s]+)/', $docComment, $matches)) { |
| 78 | [, $type] = $matches; |
| 79 | } else { |
| 80 | return null; |
| 81 | } |
| 82 | |
| 83 | // Ignore primitive types |
| 84 | if (isset(self::PRIMITIVE_TYPES[$type])) { |
| 85 | if ($allowPrimitiveTypes) { |
| 86 | return self::PRIMITIVE_TYPES[$type]; |
| 87 | } |
| 88 | return null; |
| 89 | } |
| 90 | |
| 91 | // Ignore types containing special characters ([], <> ...) |
| 92 | if (! preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) { |
| 93 | return null; |
| 94 | } |
| 95 | |
| 96 | $class = $property->getDeclaringClass(); |
| 97 | |
| 98 | // If the class name is not fully qualified (i.e. doesn't start with a \) |
| 99 | if ($type[0] !== '\\') { |
| 100 | // Try to resolve the FQN using the class context |
| 101 | $resolvedType = $this->tryResolveFqn($type, $class, $property); |
| 102 | |
| 103 | if (! $resolvedType && ! $this->ignorePhpDocErrors) { |
| 104 | throw new AnnotationException(sprintf( |
| 105 | 'The @var annotation on %s::%s contains a non existent class "%s". ' |
| 106 | . 'Did you maybe forget to add a "use" statement for this annotation?', |
| 107 | $class->name, |
| 108 | $property->getName(), |
| 109 | $type |
| 110 | )); |
| 111 | } |
| 112 | |
| 113 | $type = $resolvedType; |
| 114 | } |
| 115 | |
| 116 | if (! $this->ignorePhpDocErrors && ! $this->classExists($type)) { |
| 117 | throw new AnnotationException(sprintf( |
| 118 | 'The @var annotation on %s::%s contains a non existent class "%s"', |
| 119 | $class->name, |
| 120 | $property->getName(), |
| 121 | $type |
| 122 | )); |
| 123 | } |
| 124 | |
| 125 | // Remove the leading \ (FQN shouldn't contain it) |
| 126 | $type = is_string($type) ? ltrim($type, '\\') : null; |
| 127 | |
| 128 | return $type; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Parse the docblock of the property to get the type (class or primitive type) of the param annotation. |
| 133 | * |
| 134 | * @return string|null Type of the property (content of var annotation) |
| 135 | * @throws AnnotationException |
| 136 | */ |
| 137 | public function getParameterType(ReflectionParameter $parameter): ?string |
| 138 | { |
| 139 | return $this->readParameterClass($parameter, true); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Parse the docblock of the property to get the class of the param annotation. |
| 144 | * |
| 145 | * @return string|null Type of the property (content of var annotation) |
| 146 | * @throws AnnotationException |
| 147 | */ |
| 148 | public function getParameterClass(ReflectionParameter $parameter): ?string |
| 149 | { |
| 150 | return $this->readParameterClass($parameter, false); |
| 151 | } |
| 152 | |
| 153 | private function readParameterClass(ReflectionParameter $parameter, bool $allowPrimitiveTypes): ?string |
| 154 | { |
| 155 | // Use reflection |
| 156 | $parameterType = $parameter->getType(); |
| 157 | if ($parameterType && $parameterType instanceof \ReflectionNamedType && ! $parameterType->isBuiltin()) { |
| 158 | return $parameterType->getName(); |
| 159 | } |
| 160 | |
| 161 | $parameterName = $parameter->name; |
| 162 | // Get the content of the @param annotation |
| 163 | $method = $parameter->getDeclaringFunction(); |
| 164 | $docComment = $method->getDocComment(); |
| 165 | if (! $docComment) { |
| 166 | return null; |
| 167 | } |
| 168 | if (preg_match('/@param\s+([^\s]+)\s+\$' . $parameterName . '/', $docComment, $matches)) { |
| 169 | [, $type] = $matches; |
| 170 | } else { |
| 171 | return null; |
| 172 | } |
| 173 | |
| 174 | // Ignore primitive types |
| 175 | if (isset(self::PRIMITIVE_TYPES[$type])) { |
| 176 | if ($allowPrimitiveTypes) { |
| 177 | return self::PRIMITIVE_TYPES[$type]; |
| 178 | } |
| 179 | return null; |
| 180 | } |
| 181 | |
| 182 | // Ignore types containing special characters ([], <> ...) |
| 183 | if (! preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) { |
| 184 | return null; |
| 185 | } |
| 186 | |
| 187 | $class = $parameter->getDeclaringClass(); |
| 188 | |
| 189 | // If the class name is not fully qualified (i.e. doesn't start with a \) |
| 190 | if ($type[0] !== '\\') { |
| 191 | // Try to resolve the FQN using the class context |
| 192 | $resolvedType = $this->tryResolveFqn($type, $class, $parameter); |
| 193 | |
| 194 | if (! $resolvedType && ! $this->ignorePhpDocErrors) { |
| 195 | throw new AnnotationException(sprintf( |
| 196 | 'The @param annotation for parameter "%s" of %s::%s contains a non existent class "%s". ' |
| 197 | . 'Did you maybe forget to add a "use" statement for this annotation?', |
| 198 | $parameterName, |
| 199 | $class->name, |
| 200 | $method->name, |
| 201 | $type |
| 202 | )); |
| 203 | } |
| 204 | |
| 205 | $type = $resolvedType; |
| 206 | } |
| 207 | |
| 208 | if (! $this->ignorePhpDocErrors && ! $this->classExists($type)) { |
| 209 | throw new AnnotationException(sprintf( |
| 210 | 'The @param annotation for parameter "%s" of %s::%s contains a non existent class "%s"', |
| 211 | $parameterName, |
| 212 | $class->name, |
| 213 | $method->name, |
| 214 | $type |
| 215 | )); |
| 216 | } |
| 217 | |
| 218 | // Remove the leading \ (FQN shouldn't contain it) |
| 219 | $type = is_string($type) ? ltrim($type, '\\') : null; |
| 220 | |
| 221 | return $type; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Attempts to resolve the FQN of the provided $type based on the $class and $member context. |
| 226 | * |
| 227 | * @return string|null Fully qualified name of the type, or null if it could not be resolved |
| 228 | */ |
| 229 | private function tryResolveFqn(string $type, ReflectionClass $class, Reflector $member): ?string |
| 230 | { |
| 231 | $alias = ($pos = strpos($type, '\\')) === false ? $type : substr($type, 0, $pos); |
| 232 | $loweredAlias = strtolower($alias); |
| 233 | |
| 234 | // Retrieve "use" statements |
| 235 | $uses = $this->parser->parseUseStatements($class); |
| 236 | |
| 237 | if (isset($uses[$loweredAlias])) { |
| 238 | // Imported classes |
| 239 | if ($pos !== false) { |
| 240 | return $uses[$loweredAlias] . substr($type, $pos); |
| 241 | } |
| 242 | return $uses[$loweredAlias]; |
| 243 | } |
| 244 | |
| 245 | if ($this->classExists($class->getNamespaceName() . '\\' . $type)) { |
| 246 | return $class->getNamespaceName() . '\\' . $type; |
| 247 | } |
| 248 | |
| 249 | if (isset($uses['__NAMESPACE__']) && $this->classExists($uses['__NAMESPACE__'] . '\\' . $type)) { |
| 250 | // Class namespace |
| 251 | return $uses['__NAMESPACE__'] . '\\' . $type; |
| 252 | } |
| 253 | |
| 254 | if ($this->classExists($type)) { |
| 255 | // No namespace |
| 256 | return $type; |
| 257 | } |
| 258 | |
| 259 | // If all fail, try resolving through related traits |
| 260 | return $this->tryResolveFqnInTraits($type, $class, $member); |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * Attempts to resolve the FQN of the provided $type based on the $class and $member context, specifically searching |
| 265 | * through the traits that are used by the provided $class. |
| 266 | * |
| 267 | * @return string|null Fully qualified name of the type, or null if it could not be resolved |
| 268 | */ |
| 269 | private function tryResolveFqnInTraits(string $type, ReflectionClass $class, Reflector $member): ?string |
| 270 | { |
| 271 | /** @var ReflectionClass[] $traits */ |
| 272 | $traits = []; |
| 273 | |
| 274 | // Get traits for the class and its parents |
| 275 | while ($class) { |
| 276 | $traits = array_merge($traits, $class->getTraits()); |
| 277 | $class = $class->getParentClass(); |
| 278 | } |
| 279 | |
| 280 | foreach ($traits as $trait) { |
| 281 | // Eliminate traits that don't have the property/method/parameter |
| 282 | if ($member instanceof ReflectionProperty && ! $trait->hasProperty($member->name)) { |
| 283 | continue; |
| 284 | } |
| 285 | if ($member instanceof ReflectionMethod && ! $trait->hasMethod($member->name)) { |
| 286 | continue; |
| 287 | } |
| 288 | if ($member instanceof ReflectionParameter && ! $trait->hasMethod($member->getDeclaringFunction()->name)) { |
| 289 | continue; |
| 290 | } |
| 291 | |
| 292 | // Run the resolver again with the ReflectionClass instance for the trait |
| 293 | $resolvedType = $this->tryResolveFqn($type, $trait, $member); |
| 294 | |
| 295 | if ($resolvedType) { |
| 296 | return $resolvedType; |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | return null; |
| 301 | } |
| 302 | |
| 303 | private function classExists(string $class): bool |
| 304 | { |
| 305 | return class_exists($class) || interface_exists($class); |
| 306 | } |
| 307 | } |
| 308 |