diff --git a/Scanner.php b/Scanner.php index 5e85fcf..259a394 100644 --- a/Scanner.php +++ b/Scanner.php @@ -89,7 +89,7 @@ class Scanner extends Component return $this->changeSet; } - public function scanFiles(array $files, ?string $scopePath = null, ?string $cacheFile = null): ChangeSet + public function scanFiles(array $files, ?string $scopePath = null, ?string $cacheFile = null, bool $force = false): ChangeSet { $this->changeSet = new ChangeSet(); $this->visitedFiles = []; @@ -108,7 +108,7 @@ class Scanner extends Component foreach (array_values(array_unique($files)) as $file) { $path = $this->normalizePath($file); if (file_exists($path)) { - $this->processFile($path); + $this->processFile($path, $force); continue; } @@ -127,6 +127,66 @@ class Scanner extends Component return $this->changeSet; } + public function expandDependentFiles(array $files, ?string $scopePath = null): array + { + $expanded = []; + foreach (array_values(array_unique($files)) as $file) { + $path = $this->normalizePath($file); + $expanded[$path] = true; + } + + if ($scopePath === null || $expanded === []) { + return array_keys($expanded); + } + + $scopePath = $this->normalizePath($scopePath); + $scopeFiles = $this->collectFiles($scopePath); + $symbols = []; + foreach (array_keys($expanded) as $file) { + if (!file_exists($file)) { + continue; + } + foreach ($this->parseDeclaredSymbols($file) as $class => $kind) { + $symbols[$class] = $kind; + } + } + + if ($symbols === []) { + return array_keys($expanded); + } + + $processedSymbols = []; + do { + $newSymbols = array_diff_key($symbols, $processedSymbols); + if ($newSymbols === []) { + break; + } + $processedSymbols += $newSymbols; + $changed = false; + + foreach ($scopeFiles as $file) { + $file = $this->normalizePath($file); + if (isset($expanded[$file]) || !file_exists($file)) { + continue; + } + + $dependencies = $this->parseFileDependencies($file); + if (array_intersect_key($dependencies, $symbols) === []) { + continue; + } + + $expanded[$file] = true; + foreach ($this->parseDeclaredSymbols($file) as $class => $kind) { + if (!isset($symbols[$class])) { + $symbols[$class] = $kind; + $changed = true; + } + } + } + } while ($changed); + + return array_keys($expanded); + } private function scanDirectory(string $path, int $depth = 0): void { if ($depth > $this->config['max_depth']) { @@ -191,7 +251,7 @@ class Scanner extends Component return false; } - private function processFile(string $path): void + private function processFile(string $path, bool $force = false): void { $path = $this->normalizePath($path); if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'], true)) { @@ -199,7 +259,7 @@ class Scanner extends Component } $this->visitedFiles[$path] = true; - if (!$this->shouldProcessFile($path)) { + if (!$force && !$this->shouldProcessFile($path)) { return; } @@ -247,7 +307,7 @@ class Scanner extends Component $this->optimizeWithOpcache($path); self::$currentFile = $path; - $classes = $this->parseDeclaredClasses($path); + $classes = array_keys($this->parseDeclaredSymbols($path)); $loadFile = $classes === [] ? (bool)$this->config['require_files_without_classes'] : false; foreach ($classes as $class) { @@ -305,7 +365,7 @@ class Scanner extends Component } } - private function parseDeclaredClasses(string $path): array + private function parseDeclaredSymbols(string $path): array { $tokens = token_get_all(file_get_contents($path) ?: ''); $namespace = ''; @@ -336,10 +396,10 @@ class Scanner extends Component continue; } - $classes[] = ltrim($namespace . '\\' . $name, '\\'); + $classes[ltrim($namespace . '\\' . $name, '\\')] = $this->getClassLikeKind($tokens, $i); } - return array_values(array_unique($classes)); + return $classes; } private function parseNamespace(array $tokens, int $offset): string @@ -410,6 +470,190 @@ class Scanner extends Component return false; } + private function parseFileDependencies(string $path): array + { + $tokens = token_get_all(file_get_contents($path) ?: ''); + $namespace = ''; + $imports = []; + $dependencies = []; + $count = count($tokens); + $braceDepth = 0; + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + if ($token === '{') { + $braceDepth++; + continue; + } + if ($token === '}') { + $braceDepth = max(0, $braceDepth - 1); + continue; + } + if (!is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $namespace = $this->parseNamespace($tokens, $i + 1); + continue; + } + + if ($token[0] === T_USE) { + if ($braceDepth === 0) { + foreach ($this->parseUseImports($tokens, $i + 1, $namespace) as $alias => $class) { + $imports[$alias] = $class; + } + continue; + } + + foreach ($this->parseNameList($tokens, $i + 1, [';', '{']) as $name) { + $dependencies[$this->resolveClassName($name, $namespace, $imports)] = true; + } + continue; + } + + if ($token[0] === T_EXTENDS || $token[0] === T_IMPLEMENTS) { + foreach ($this->parseNameList($tokens, $i + 1, ['{']) as $name) { + $dependencies[$this->resolveClassName($name, $namespace, $imports)] = true; + } + } + } + + return $dependencies; + } + + private function parseUseImports(array $tokens, int $offset, string $namespace): array + { + $imports = []; + $count = count($tokens); + for ($i = $offset; $i < $count; $i++) { + $token = $tokens[$i]; + if ($token === ';') { + break; + } + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + if (is_array($token) && in_array($token[0], [T_FUNCTION, T_CONST], true)) { + return []; + } + + $name = $this->collectQualifiedName($tokens, $i); + if ($name === null) { + continue; + } + + $alias = null; + $j = $i + 1; + while ($j < $count) { + $next = $tokens[$j]; + if ($next === ',' || $next === ';') { + break; + } + if (is_array($next) && $next[0] === T_AS) { + $aliasOffset = $j + 1; + $alias = $this->collectQualifiedName($tokens, $aliasOffset); + break; + } + $j++; + } + + $class = trim($name, '\\'); + $short = $alias ?: basename(str_replace('\\', '/', $class)); + $imports[strtolower($short)] = $class; + $i = $j; + } + + return $imports; + } + + private function parseNameList(array $tokens, int $offset, array $stoppers): array + { + $names = []; + $count = count($tokens); + for ($i = $offset; $i < $count; $i++) { + $token = $tokens[$i]; + if (is_string($token) && in_array($token, $stoppers, true)) { + break; + } + + $name = $this->collectQualifiedName($tokens, $i); + if ($name !== null) { + $names[] = $name; + } + } + + return $names; + } + + private function collectQualifiedName(array $tokens, int &$offset): ?string + { + $count = count($tokens); + $name = ''; + $started = false; + for ($i = $offset; $i < $count; $i++) { + $token = $tokens[$i]; + if (!$started && is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + if (is_array($token) && in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR], true)) { + $name .= $token[1]; + $started = true; + $offset = $i; + continue; + } + + break; + } + + return $name === '' ? null : $name; + } + + private function resolveClassName(string $name, string $namespace, array $imports): string + { + $name = trim($name, '\\'); + if ($name === '') { + return $name; + } + + $parts = explode('\\', $name); + $first = strtolower($parts[0]); + if (isset($imports[$first])) { + array_shift($parts); + return $imports[$first] . ($parts === [] ? '' : '\\' . implode('\\', $parts)); + } + + return $namespace === '' ? $name : $namespace . '\\' . $name; + } + + private function getClassLikeKind(array $tokens, int $offset): string + { + $token = $tokens[$offset]; + if (!is_array($token)) { + return 'class'; + } + if ($token[0] === T_TRAIT) { + return 'trait'; + } + if ($token[0] === T_INTERFACE) { + return 'interface'; + } + if (defined('T_ENUM') && $token[0] === T_ENUM) { + return 'enum'; + } + + for ($i = $offset - 1; $i >= 0; $i--) { + $previous = $tokens[$i]; + if (is_array($previous) && in_array($previous[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return is_array($previous) && $previous[0] === T_ABSTRACT ? 'abstract_class' : 'class'; + } + + return 'class'; + } private function getDeclaredSymbols(): array { return array_merge(get_declared_classes(), get_declared_interfaces(), get_declared_traits()); @@ -454,7 +698,8 @@ class Scanner extends Component private function analyzeClassMethods(ReflectionClass $reflect, string $class): void { foreach ($reflect->getMethods() as $method) { - if ($method->isStatic() || $method->getDeclaringClass()->getName() !== $class) { + $declaringClass = $method->getDeclaringClass(); + if ($method->isStatic() || ($declaringClass->getName() !== $class && !$declaringClass->isTrait())) { continue; }