├── composer.json └── src ├── Construct.php └── ConstructFinder.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/construct-finder", 3 | "description": "Finds classes, interfaces, traits, and enums.", 4 | "type": "library", 5 | "require": { 6 | "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~7.4.0" 7 | }, 8 | "require-dev": { 9 | "phpstan/phpstan": "^1.3", 10 | "phpunit/phpunit": "^9.5" 11 | }, 12 | "license": "MIT", 13 | "autoload": { 14 | "psr-4": { 15 | "League\\ConstructFinder\\": "src/" 16 | } 17 | }, 18 | "authors": [ 19 | { 20 | "name": "Frank de Jonge", 21 | "email": "info@frankdejonge.nl" 22 | } 23 | ], 24 | "scripts": { 25 | "phpstan": "phpstan analyse -l max -c phpstan.neon.dist src --ansi --memory-limit=192M", 26 | "phpunit": "phpunit --coverage-text", 27 | "test": [ 28 | "@phpunit", 29 | "@phpstan" 30 | ] 31 | }, 32 | "scripts-descriptions": { 33 | "phpstan": "Runs complete codebase static analysis", 34 | "phpunit": "Runs unit and functional testing", 35 | "test": "Runs full test suite" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Construct.php: -------------------------------------------------------------------------------- 1 | name = $name; 32 | $this->type = $type; 33 | } 34 | 35 | /** 36 | * @return class-string 37 | */ 38 | public function name(): string 39 | { 40 | return $this->name; 41 | } 42 | 43 | /** 44 | * @return 'trait'|'class'|'enum'|'interface' 45 | */ 46 | public function type(): string 47 | { 48 | return $this->type; 49 | } 50 | 51 | /** 52 | * @return class-string 53 | */ 54 | public function __toString(): string 55 | { 56 | return $this->name; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ConstructFinder.php: -------------------------------------------------------------------------------- 1 | */ 44 | private array $locations; 45 | 46 | /** @var array */ 47 | private array $excludes = []; 48 | 49 | /** 50 | * @param array $locations 51 | */ 52 | public function __construct(array $locations) 53 | { 54 | $this->locations = $locations; 55 | } 56 | 57 | public function exclude(string ...$patterns): self 58 | { 59 | $this->excludes = $this->prepPatterns($patterns); 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | public function findAll(): array 68 | { 69 | $listing = $this->processExcludes($this->listAllFiles()); 70 | $constructs = iterator_to_array($this->collectConstructs($listing), false); 71 | 72 | usort($constructs, fn(Construct $a, Construct $b) => $a->name() <=> $b->name()); 73 | 74 | return $constructs; 75 | } 76 | 77 | /** 78 | * @return array 79 | */ 80 | public function findAllNames(): array 81 | { 82 | return $this->convertConstructsToStrings($this->findAll()); 83 | } 84 | 85 | /** 86 | * @param array $constructs 87 | * @return array 88 | */ 89 | private function convertConstructsToStrings(array $constructs): array 90 | { 91 | $classNames = []; 92 | 93 | foreach ($constructs as $construct) { 94 | $classNames[] = $construct->name(); 95 | } 96 | 97 | return $classNames; 98 | } 99 | 100 | /** 101 | * @param 'trait'|'class'|'enum'|'interface' $type 102 | * @return array 103 | */ 104 | public function findOfType(string $type): array 105 | { 106 | $all = $this->findAll(); 107 | 108 | return array_values(array_filter($all, fn(Construct $c) => $c->type() === $type)); 109 | } 110 | 111 | /** 112 | * @return array 113 | */ 114 | public function findClasses(): array 115 | { 116 | return $this->findOfType('class'); 117 | } 118 | 119 | /** 120 | * @return array 121 | */ 122 | public function findClassNames(): array 123 | { 124 | return $this->convertConstructsToStrings($this->findClasses()); 125 | } 126 | 127 | /** 128 | * @return array 129 | */ 130 | public function findEnums(): array 131 | { 132 | return $this->findOfType('enum'); 133 | } 134 | 135 | /** 136 | * @return array 137 | */ 138 | public function findEnumNames(): array 139 | { 140 | return $this->convertConstructsToStrings($this->findEnums()); 141 | } 142 | 143 | /** 144 | * @return array 145 | */ 146 | public function findInterfaces(): array 147 | { 148 | return $this->findOfType('interface'); 149 | } 150 | 151 | /** 152 | * @return array 153 | */ 154 | public function findInterfaceNames(): array 155 | { 156 | return $this->convertConstructsToStrings($this->findInterfaces()); 157 | } 158 | 159 | /** 160 | * @return array 161 | */ 162 | public function findTraits(): array 163 | { 164 | return $this->findOfType('trait'); 165 | } 166 | 167 | /** 168 | * @return array 169 | */ 170 | public function findTraitNames(): array 171 | { 172 | return $this->convertConstructsToStrings($this->findTraits()); 173 | } 174 | 175 | /** 176 | * @return Generator 177 | */ 178 | private function locatePathsIn(string $directory): Generator 179 | { 180 | $iterator = new RecursiveIteratorIterator( 181 | new RecursiveDirectoryIterator($directory) 182 | ); 183 | 184 | /** @var SplFileInfo $file */ 185 | foreach ($iterator as $file) { 186 | if ( ! $file->isFile()) { 187 | continue; 188 | } 189 | 190 | $realPath = $file->getRealPath(); 191 | 192 | if ($realPath === false || substr($realPath, -4) !== '.php') { 193 | continue; 194 | } 195 | 196 | yield $realPath; 197 | } 198 | } 199 | 200 | public static function locatedIn(string ...$directory): self 201 | { 202 | return new self($directory); 203 | } 204 | 205 | /** 206 | * @return array 207 | */ 208 | private static function findConstructsInPath(string $path): array 209 | { 210 | $source = file_get_contents($path) ?: ''; 211 | $classes = []; 212 | $interestingTokens = [T_CLASS => 'class', T_INTERFACE => 'interface', T_TRAIT => 'trait']; 213 | 214 | if (defined('T_ENUM')) { 215 | $interestingTokens[T_ENUM] = 'enum'; 216 | } 217 | 218 | $tokens = token_get_all($source, TOKEN_PARSE); 219 | 220 | $tokens = array_filter( 221 | $tokens, 222 | fn($token) => ! in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_WHITESPACE]), 223 | ); 224 | $tokens = array_values($tokens); 225 | 226 | $namespace = ''; 227 | 228 | foreach ($tokens as $index => $token) { 229 | if ( ! is_array($token)) { 230 | continue; 231 | } 232 | 233 | if ($token[0] === T_NAMESPACE) { 234 | $namespace = self::collectNamespace($index + 1, $tokens); 235 | } 236 | 237 | if (array_key_exists($token[0], $interestingTokens) === false || self::isAnonymousClass($index, $tokens)) { 238 | continue; 239 | } 240 | 241 | $classToken = $tokens[$index + 1]; 242 | $type = $interestingTokens[$token[0]]; 243 | $name = trim("$namespace\\$classToken[1]", '\\'); 244 | // @phpstan-ignore-next-line since we know $name is a class-string 245 | $classes[] = new Construct($name, $type); 246 | } 247 | 248 | return $classes; 249 | } 250 | 251 | /** 252 | * @param array|string> $tokens 253 | */ 254 | private static function collectNamespace(int $index, array $tokens): string 255 | { 256 | $token = $tokens[$index] ?? ''; 257 | 258 | if ( ! is_array($token)) { 259 | return ''; 260 | } 261 | 262 | if (defined('T_NAME_QUALIFIED') && $token[0] === T_NAME_QUALIFIED) { 263 | return (string) $token[1]; 264 | } 265 | 266 | $parts = []; 267 | 268 | while (true) { 269 | $token = $tokens[$index] ?? ''; 270 | $index++; 271 | 272 | if ( ! is_array($token)) { 273 | break; 274 | } 275 | 276 | if ( ! in_array($token[0], [T_NS_SEPARATOR, T_STRING])) { 277 | break; 278 | } 279 | 280 | $parts[] = $token[1]; 281 | } 282 | 283 | return implode('', $parts); 284 | } 285 | 286 | /** 287 | * @param array|string> $tokens 288 | */ 289 | private static function isAnonymousClass(int $index, array $tokens): bool 290 | { 291 | // Anonymous class written as: new class (...) 292 | if (self::isNew($index - 1, $tokens)) { 293 | return true; 294 | } 295 | 296 | // Anonymous class written as: new readonly class (...) 297 | return self::isReadonly($index - 1, $tokens) 298 | && self::isNew($index - 2, $tokens); 299 | } 300 | 301 | /** 302 | * @param array|string> $tokens 303 | */ 304 | private static function isNew(int $index, array $tokens): bool 305 | { 306 | $token = $tokens[$index] ?? ''; 307 | 308 | if ( ! is_array($token)) { 309 | return false; 310 | } 311 | 312 | $type = $token[0] ?? ''; 313 | 314 | return $type === T_NEW; 315 | } 316 | 317 | /** 318 | * @param array|string> $tokens 319 | */ 320 | private static function isReadonly(int $index, array $tokens): bool 321 | { 322 | if (PHP_VERSION_ID < 80300) { 323 | return false; 324 | } 325 | 326 | $token = $tokens[$index] ?? ''; 327 | 328 | if ( ! is_array($token)) { 329 | return false; 330 | } 331 | 332 | $type = $token[0] ?? ''; 333 | 334 | return $type === T_READONLY; 335 | } 336 | 337 | /** 338 | * @param array $patterns 339 | * @return array 340 | */ 341 | private function prepPatterns(array $patterns): array 342 | { 343 | $wildcard = preg_quote('*', '~'); 344 | 345 | foreach ($patterns as $i => $pattern) { 346 | $patterns[$i] = str_replace($wildcard, '(.+)', preg_quote($pattern, '~')); 347 | } 348 | 349 | return $patterns; 350 | } 351 | 352 | /** 353 | * @return Generator 354 | */ 355 | private function listAllFiles(): Generator 356 | { 357 | foreach ($this->locations as $location) { 358 | yield from $this->locatePathsIn($location); 359 | } 360 | } 361 | 362 | /** 363 | * @param Generator $listing 364 | * 365 | * @return Generator 366 | */ 367 | private function processExcludes(Generator $listing): Generator 368 | { 369 | foreach ($listing as $path) { 370 | foreach ($this->excludes as $pattern) { 371 | if (preg_match("~^$pattern$~", $path) === 1) { 372 | goto exclude; 373 | } 374 | } 375 | 376 | yield $path; 377 | exclude: 378 | } 379 | } 380 | 381 | /** 382 | * @param Generator $listing 383 | * 384 | * @return Generator 385 | */ 386 | private function collectConstructs(Generator $listing): Generator 387 | { 388 | foreach ($listing as $path) { 389 | yield from $this->findConstructsInPath($path); 390 | } 391 | } 392 | } 393 | --------------------------------------------------------------------------------