diff --git a/ChangeSet.php b/ChangeSet.php new file mode 100644 index 0000000..f53830f --- /dev/null +++ b/ChangeSet.php @@ -0,0 +1,95 @@ +changedFiles[$file] = true; + } + + public function addRemovedFile(string $file): void + { + $this->removedFiles[$file] = true; + } + + public function addChangedClass(string $class): void + { + $this->changedClasses[$class] = true; + } + + public function addRemovedClass(string $class): void + { + $this->removedClasses[$class] = true; + } + + public function merge(ChangeSet $changeSet): self + { + foreach ($changeSet->getChangedFiles() as $file) { + $this->addChangedFile($file); + } + + foreach ($changeSet->getRemovedFiles() as $file) { + $this->addRemovedFile($file); + } + + foreach ($changeSet->getChangedClasses() as $class) { + $this->addChangedClass($class); + } + + foreach ($changeSet->getRemovedClasses() as $class) { + $this->addRemovedClass($class); + } + + return $this; + } + + public function getChangedFiles(): array + { + return array_keys($this->changedFiles); + } + + public function getRemovedFiles(): array + { + return array_keys($this->removedFiles); + } + + public function getChangedClasses(): array + { + return array_keys($this->changedClasses); + } + + public function getRemovedClasses(): array + { + return array_keys($this->removedClasses); + } + + public function hasChanges(): bool + { + return $this->changedFiles !== [] + || $this->removedFiles !== [] + || $this->changedClasses !== [] + || $this->removedClasses !== []; + } + + public function toArray(): array + { + return [ + 'changed_files' => $this->getChangedFiles(), + 'removed_files' => $this->getRemovedFiles(), + 'changed_classes' => $this->getChangedClasses(), + 'removed_classes' => $this->getRemovedClasses(), + ]; + } +} diff --git a/Container.php b/Container.php index 02310f1..f336c1e 100644 --- a/Container.php +++ b/Container.php @@ -153,6 +153,60 @@ class Container implements ContainerInterface } + public function forgetReflection(string $className): void + { + unset($this->_reflection[$className]); + } + + + public function forgetMethodParams(string $className, ?string $method = null): void + { + if (!isset($this->_parameters[$className])) { + return; + } + + if ($method === null) { + unset($this->_parameters[$className]); + return; + } + + unset($this->_parameters[$className][$method]); + if ($this->_parameters[$className] === []) { + unset($this->_parameters[$className]); + } + } + + + public function forgetClass(string $className): void + { + unset($this->_singletons[$className]); + $this->forgetReflection($className); + $this->forgetMethodParams($className); + } + + + public function forgetNamespace(string $prefix): void + { + foreach (array_keys($this->_singletons) as $className) { + if ($className !== ContainerInterface::class && str_starts_with($className, $prefix)) { + unset($this->_singletons[$className]); + } + } + + foreach (array_keys($this->_reflection) as $className) { + if (str_starts_with($className, $prefix)) { + unset($this->_reflection[$className]); + } + } + + foreach (array_keys($this->_parameters) as $className) { + if (str_starts_with($className, $prefix)) { + unset($this->_parameters[$className]); + } + } + } + + /** * @param string $className * @param array $construct diff --git a/HotReloadState.php b/HotReloadState.php new file mode 100644 index 0000000..3468434 --- /dev/null +++ b/HotReloadState.php @@ -0,0 +1,67 @@ + time(), + 'changed_files' => array_values(array_unique(array_map([$this, 'normalizePath'], array_filter($changedFiles)))), + ]; + + $directory = dirname($this->getFilePath()); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents($this->getFilePath(), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + public function consume(): array + { + return $this->peek(); + } + + public function peek(): array + { + $file = $this->getFilePath(); + if (!file_exists($file)) { + return []; + } + + $data = json_decode((string)file_get_contents($file), true); + if (!is_array($data)) { + return []; + } + + $timestamp = (int)($data['timestamp'] ?? 0); + if ($timestamp < 1 || (time() - $timestamp) > self::MAX_AGE_SECONDS) { + return []; + } + + $files = is_array($data['changed_files'] ?? null) ? $data['changed_files'] : []; + return array_values(array_unique(array_map([$this, 'normalizePath'], $files))); + } + + private function getFilePath(): string + { + $basePath = $this->normalizePath($_SERVER['PWD'] ?? APP_PATH ?? getcwd()); + $runtimePath = defined('APP_PATH') + ? rtrim(str_replace('\\', '/', APP_PATH), '/') . '/storage/.kiri-hot-reload/' + : sys_get_temp_dir() . '/kiri-hot-reload/'; + + return $runtimePath . md5($basePath) . '.json'; + } + + private function normalizePath(string $path): string + { + $resolved = realpath($path) ?: $path; + return str_replace('\\', '/', $resolved); + } +} diff --git a/ScanManifest.php b/ScanManifest.php new file mode 100644 index 0000000..eda1b77 --- /dev/null +++ b/ScanManifest.php @@ -0,0 +1,64 @@ +entries[$path] = [ + 'mtime' => $mtime, + 'classes' => array_values(array_unique($classes)), + ]; + } + + public function has(string $path): bool + { + return isset($this->entries[$path]); + } + + public function getMtime(string $path): ?int + { + return $this->entries[$path]['mtime'] ?? null; + } + + public function getClasses(string $path): array + { + return $this->entries[$path]['classes'] ?? []; + } + + public function remove(string $path): array + { + $classes = $this->getClasses($path); + unset($this->entries[$path]); + return $classes; + } + + public function all(): array + { + return $this->entries; + } + + public function paths(?string $prefix = null): array + { + if ($prefix === null) { + return array_keys($this->entries); + } + + return array_values(array_filter(array_keys($this->entries), fn(string $path) => str_starts_with($path, $prefix))); + } + + public function fromArray(array $entries): void + { + $this->entries = []; + foreach ($entries as $path => $entry) { + $mtime = (int)($entry['mtime'] ?? 0); + $classes = is_array($entry['classes'] ?? null) ? $entry['classes'] : []; + $this->set($path, $mtime, $classes); + } + } +} diff --git a/Scanner.php b/Scanner.php index 81dab53..513ac31 100644 --- a/Scanner.php +++ b/Scanner.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Kiri\Di; -use Kiri\Di\Inject\Container; +use DirectoryIterator; use Kiri\Abstracts\Component; +use Kiri\Di\Inject\Container; use Kiri\Di\Inject\Skip; use Psr\Container\ContainerInterface; use ReflectionClass; use ReflectionMethod; -use DirectoryIterator; use Throwable; class Scanner extends Component @@ -20,25 +20,34 @@ class Scanner extends Component public array $files = []; - private array $fileMtimes = []; - private ?bool $hasOpcache = null; + private array $fileMtimes = []; + + private ?bool $hasOpcache = null; + private string $basePath; + private ScanManifest $manifest; + + private ?ChangeSet $changeSet = null; + + private array $visitedFiles = []; + private array $config = [ - 'skip_patterns' => ['/vendor/', '/tests/', '/cache/', '/node_modules/'], - 'skip_directories' => [], - 'extensions' => ['php'], - 'max_depth' => 20, - 'follow_links' => false, - 'cache_enabled' => false, - 'cache_ttl' => 3600, - 'debug' => false, - 'class_name_strategy' => 'auto', // 'auto', 'extract', 'rename', or 'both' + 'skip_patterns' => ['/vendor/', '/tests/', '/cache/', '/node_modules/'], + 'skip_directories' => [], + 'extensions' => ['php'], + 'max_depth' => 20, + 'follow_links' => false, + 'cache_enabled' => false, + 'cache_ttl' => 3600, + 'debug' => false, + 'class_name_strategy' => 'auto', ]; public function __construct() { - $this->basePath = $_SERVER['PWD'] ?? APP_PATH ?? getcwd(); + $this->basePath = $this->normalizePath($_SERVER['PWD'] ?? APP_PATH ?? getcwd()); + $this->manifest = new ScanManifest(); } public function setConfig(array $config): void @@ -46,21 +55,70 @@ class Scanner extends Component $this->config = array_merge($this->config, $config); } - public function scan(string $path, ?string $cacheFile = null): void + public function scan(string $path, ?string $cacheFile = null): ChangeSet { + $path = $this->normalizePath($path); + $this->changeSet = new ChangeSet(); + $this->visitedFiles = []; + $cacheLoaded = false; + if ($this->config['cache_enabled']) { $cacheFile = $cacheFile ?? $this->getDefaultCacheFile(); if ($this->loadFromCache($cacheFile, $path)) { - $this->processCachedFiles(); - return; + $this->replayManifest($path); + return $this->changeSet; } + $cacheLoaded = $this->manifest->all() !== []; } $this->scanDirectory($path); + $this->detectRemovedFiles($path); + + if ($cacheLoaded) { + $this->replayManifest($path, $this->changeSet->getChangedFiles()); + } + + $this->syncLegacyState(); if ($this->config['cache_enabled']) { $this->saveToCache($cacheFile); } + + return $this->changeSet; + } + + public function scanFiles(array $files, ?string $scopePath = null, ?string $cacheFile = null): ChangeSet + { + $this->changeSet = new ChangeSet(); + $this->visitedFiles = []; + $cacheLoaded = false; + + if ($scopePath !== null) { + $scopePath = $this->normalizePath($scopePath); + } + + if ($this->config['cache_enabled'] && $scopePath !== null) { + $cacheFile = $cacheFile ?? $this->getDefaultCacheFile(); + $this->loadFromCache($cacheFile, $scopePath); + $cacheLoaded = $this->manifest->all() !== []; + } + + foreach (array_values(array_unique($files)) as $file) { + $path = $this->normalizePath($file); + if (file_exists($path)) { + $this->processFile($path); + continue; + } + + $this->markRemovedFile($path); + } + + if ($cacheLoaded && $scopePath !== null) { + $this->replayManifest($scopePath, $this->changeSet->getChangedFiles()); + } + + $this->syncLegacyState(); + return $this->changeSet; } private function scanDirectory(string $path, int $depth = 0): void @@ -81,8 +139,7 @@ class Scanner extends Component continue; } - $realPath = $item->getRealPath(); - + $realPath = $this->normalizePath($item->getRealPath() ?: $item->getPathname()); if ($item->isDir()) { if ($this->shouldSkipDirectory($realPath)) { continue; @@ -93,7 +150,10 @@ class Scanner extends Component } $this->scanDirectory($realPath, $depth + 1); - } elseif ($item->isFile()) { + continue; + } + + if ($item->isFile()) { $this->processFile($realPath); } } @@ -106,14 +166,13 @@ class Scanner extends Component private function shouldSkipDirectory(string $path): bool { - $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - + $path = rtrim($this->normalizePath($path), '/') . '/'; $skipDirs = array_merge( $this->config['skip_directories'], config('site.scanner.skip', []) ); - if (in_array($path, $skipDirs)) { + if (in_array($path, $skipDirs, true)) { return true; } @@ -128,25 +187,34 @@ class Scanner extends Component private function processFile(string $path): void { - if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'])) { - return; - } - - if (in_array($path, $this->files)) { + $path = $this->normalizePath($path); + if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'], true)) { return; } + $this->visitedFiles[$path] = true; if (!$this->shouldProcessFile($path)) { return; } - $this->files[] = $path; - try { - $this->loadAndParseFile($path); + $oldClasses = $this->manifest->getClasses($path); + $this->invalidateClasses($oldClasses); + $newClasses = $this->loadAndParseFile($path); + + $this->manifest->set($path, (int)filemtime($path), $newClasses); + $this->changeSet?->addChangedFile($path); + + foreach (array_diff($oldClasses, $newClasses) as $class) { + $this->changeSet?->addRemovedClass($class); + } + + foreach ($newClasses as $class) { + $this->changeSet?->addChangedClass($class); + } } catch (Throwable $e) { $this->logError($e, [ - 'file' => $path, + 'file' => $path, 'action' => 'process_file', ]); @@ -162,55 +230,51 @@ class Scanner extends Component return false; } - $mtime = filemtime($path); + $mtime = (int)filemtime($path); + $cachedMtime = $this->manifest->getMtime($path); - if (!isset($this->fileMtimes[$path]) || $this->fileMtimes[$path] < $mtime) { - $this->fileMtimes[$path] = $mtime; - return true; - } - - return false; + return $cachedMtime === null || $cachedMtime < $mtime; } - private function loadAndParseFile(string $path): void + private function loadAndParseFile(string $path): array { $this->optimizeWithOpcache($path); - require_once $path; - // 尝试获取类名 - $class = $this->getClassNameFromPath($path); - if ($class && class_exists($class)) { - $this->analyzeClass($class); + $classes = $this->getClassNamesForFile($path); + foreach ($classes as $class) { + if (class_exists($class)) { + $this->analyzeClass($class); + } } + + return $classes; } - /** - * 从文件路径获取类名(支持多种策略) - */ - private function getClassNameFromPath(string $path): ?string + private function getClassNamesForFile(string $path): array { $strategy = $this->config['class_name_strategy']; + $classes = []; - // 移除基础路径前缀,类似原代码逻辑 - $relativePath = str_replace($this->basePath, '', $path); - $relativePath = str_replace('.php', '', $relativePath); + if (in_array($strategy, ['auto', 'extract', 'both'], true)) { + $class = $this->extractClassNameFromFile($path); + if ($class !== null) { + $classes[] = $class; + } + } - return $this->renamePathToClassName($relativePath); + if ($classes === [] || in_array($strategy, ['rename', 'both'], true)) { + $classes[] = $this->renamePathToClassName($path); + } + + return array_values(array_unique(array_filter($classes))); } - /** - * 检查是否可以安全地从文件内容提取类名 - */ private function canExtractClassName(): bool { - // 如果有tokenizer扩展,优先使用它 return function_exists('token_get_all'); } - /** - * 从文件内容提取类名(原代码没有的功能) - */ private function extractClassNameFromFile(string $path): ?string { if (!$this->canExtractClassName()) { @@ -218,7 +282,7 @@ class Scanner extends Component } $content = @file_get_contents($path); - if (!$content) { + if ($content === false || $content === '') { return null; } @@ -227,64 +291,59 @@ class Scanner extends Component return null; } - $namespace = ''; - $class = ''; + $namespace = ''; + $class = ''; $collectingNamespace = false; - $collectingClass = false; + $collectingClass = false; + $previousToken = null; - var_dump($tokens[1]); foreach ($tokens as $token) { if (is_array($token)) { + $text = $token[1]; + if ($token[0] === T_NAMESPACE) { - $namespace = ''; + $namespace = ''; $collectingNamespace = true; - } elseif ($collectingNamespace && $token[0] === T_STRING) { - $namespace .= $token[1]; - } elseif ($collectingNamespace && $token[0] === T_NS_SEPARATOR) { - $namespace .= '\\'; + } elseif ($collectingNamespace && in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR], true)) { + $namespace .= $text; } elseif ($collectingNamespace && $token[0] === T_WHITESPACE) { - continue; - } elseif ($token[0] === T_CLASS) { + } elseif (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true) && $previousToken !== T_DOUBLE_COLON && $previousToken !== T_NEW) { $collectingClass = true; } elseif ($collectingClass && $token[0] === T_STRING) { - $class = $token[1]; + $class = $text; break; } elseif ($collectingNamespace) { $collectingNamespace = false; } - } else { - if ($collectingNamespace && ($token === ';' || $token === '{')) { - $collectingNamespace = false; - } - if ($token === '{') { - $collectingClass = false; + + if ($token[0] !== T_WHITESPACE) { + $previousToken = $token[0]; } + continue; } + + if ($collectingNamespace && ($token === ';' || $token === '{')) { + $collectingNamespace = false; + } + if ($token === '{') { + $collectingClass = false; + } + $previousToken = $token; } - if (!$class) { + if ($class === '') { return null; } - return $namespace ? $namespace . '\\' . $class : $class; + return $namespace !== '' ? $namespace . '\\' . $class : $class; } - /** - * 将路径重命名为类名(原代码的逻辑) - */ private function renamePathToClassName(string $path): string { - // 清理路径,移除开头和结尾的斜杠 - $path = trim($path, '/\\'); - - // 分割路径部分 - $parts = explode('/', str_replace('\\', '/', $path)); - - // 过滤空值并对每个部分应用ucfirst - $parts = array_filter($parts, function ($part) { - return !empty($part); - }); - + $relativePath = str_replace($this->basePath, '', $this->normalizePath($path)); + $relativePath = str_replace('.php', '', $relativePath); + $parts = explode('/', trim($relativePath, '/\\')); + $parts = array_values(array_filter($parts, fn(string $part) => $part !== '')); $parts = array_map('ucfirst', $parts); return implode('\\', $parts); @@ -293,21 +352,22 @@ class Scanner extends Component private function optimizeWithOpcache(string $path): void { if ($this->hasOpcache === null) { - $this->hasOpcache = function_exists('opcache_invalidate') - && function_exists('opcache_compile_file'); + $this->hasOpcache = function_exists('opcache_invalidate') && function_exists('opcache_compile_file'); } - if ($this->hasOpcache) { - try { - opcache_invalidate($path, true); - opcache_compile_file($path); - } catch (Throwable $e) { - if ($this->config['debug']) { - $this->logError($e, [ - 'file' => $path, - 'action' => 'opcache_optimize', - ]); - } + if (!$this->hasOpcache) { + return; + } + + try { + opcache_invalidate($path, true); + opcache_compile_file($path); + } catch (Throwable $e) { + if ($this->config['debug']) { + $this->logError($e, [ + 'file' => $path, + 'action' => 'opcache_optimize', + ]); } } } @@ -316,11 +376,7 @@ class Scanner extends Component { try { $reflect = $this->container->getReflectionClass($class); - - if (!$reflect->isInstantiable() || - $reflect->isTrait() || - $reflect->isEnum() || - $reflect->isInterface()) { + if (!$reflect->isInstantiable() || $reflect->isTrait() || $reflect->isEnum() || $reflect->isInterface()) { return; } @@ -329,10 +385,9 @@ class Scanner extends Component } $this->analyzeClassMethods($reflect, $class); - } catch (Throwable $e) { $this->logError($e, [ - 'class' => $class, + 'class' => $class, 'action' => 'analyze_class', ]); } @@ -340,13 +395,10 @@ class Scanner extends Component private function shouldSkipClass(ReflectionClass $reflect): bool { - $attributes = array_map( - fn($attr) => $attr->getName(), - $reflect->getAttributes() - ); + $attributes = array_map(fn($attr) => $attr->getName(), $reflect->getAttributes()); - return in_array(Skip::class, $attributes, true) || - in_array(\Attribute::class, $attributes, true); + return in_array(Skip::class, $attributes, true) + || in_array(\Attribute::class, $attributes, true); } private function analyzeClassMethods(ReflectionClass $reflect, string $class): void @@ -364,23 +416,21 @@ class Scanner extends Component { foreach ($method->getAttributes() as $attribute) { $attributeName = $attribute->getName(); - if (!class_exists($attributeName)) { continue; } try { $instance = $attribute->newInstance(); - if (method_exists($instance, 'dispatch')) { $instance->dispatch($class, $method->getName()); } } catch (Throwable $e) { $this->logError($e, [ - 'class' => $class, - 'method' => $method->getName(), + 'class' => $class, + 'method' => $method->getName(), 'attribute' => $attributeName, - 'action' => 'process_attribute', + 'action' => 'process_attribute', ]); } } @@ -388,7 +438,9 @@ class Scanner extends Component private function getDefaultCacheFile(): string { - $cacheDir = sys_get_temp_dir() . '/kiri-scanner-cache/'; + $cacheDir = defined('APP_PATH') + ? rtrim(str_replace('\\', '/', APP_PATH), '/') . '/storage/.kiri-scanner-cache/' + : sys_get_temp_dir() . '/kiri-scanner-cache/'; if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); } @@ -407,33 +459,34 @@ class Scanner extends Component return false; } - if ($this->hasDirectoryChanged($path, $cacheFile)) { - return false; - } - $data = json_decode(file_get_contents($cacheFile), true); - if (!is_array($data) || !isset($data['files'], $data['mtimes'])) { + if (!is_array($data) || !isset($data['manifest'])) { return false; } - $this->files = $data['files']; - $this->fileMtimes = $data['mtimes']; + $this->manifest->fromArray($data['manifest']); + $this->syncLegacyState(); - return true; + return !$this->hasDirectoryChanged($path); } - private function hasDirectoryChanged(string $path, string $cacheFile): bool + private function hasDirectoryChanged(string $path): bool { - $cacheMtime = filemtime($cacheFile); + $currentFiles = $this->collectFiles($path); + $cachedFiles = $this->manifest->paths(rtrim($path, '/\\') . '/'); - $dirIterator = new DirectoryIterator($path); - foreach ($dirIterator as $item) { - if ($item->isDot()) { - continue; + sort($currentFiles); + sort($cachedFiles); + if ($currentFiles !== $cachedFiles) { + return true; + } + + foreach ($currentFiles as $file) { + if (!file_exists($file)) { + return true; } - $itemPath = $item->getRealPath(); - if (filemtime($itemPath) > $cacheMtime) { + if ($this->manifest->getMtime($file) !== (int)filemtime($file)) { return true; } } @@ -444,21 +497,19 @@ class Scanner extends Component private function saveToCache(string $cacheFile): void { $data = [ - 'files' => $this->files, - 'mtimes' => $this->fileMtimes, + 'manifest' => $this->manifest->all(), 'timestamp' => time(), - 'version' => '1.1', + 'version' => '2.0', ]; file_put_contents($cacheFile, json_encode($data, JSON_PRETTY_PRINT)); - $this->cleanupOldCacheFiles(dirname($cacheFile)); } private function cleanupOldCacheFiles(string $cacheDir): void { $files = glob($cacheDir . '/scanner_*.json'); - $now = time(); + $now = time(); foreach ($files as $file) { if (filemtime($file) + (7 * 24 * 3600) < $now) { @@ -467,24 +518,15 @@ class Scanner extends Component } } - private function processCachedFiles(): void - { - foreach ($this->files as $file) { - if ($this->shouldProcessFile($file)) { - $this->loadAndParseFile($file); - } - } - } - private function logError(Throwable $e, array $context = []): void { $fullContext = array_merge($context, [ 'message' => $e->getMessage(), - 'code' => $e->getCode(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), - 'time' => date('Y-m-d H:i:s'), + 'code' => $e->getCode(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + 'time' => date('Y-m-d H:i:s'), ]); if (function_exists('error')) { @@ -495,58 +537,153 @@ class Scanner extends Component if ($this->config['debug'] && php_sapi_name() === 'cli') { echo "Scanner Error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n"; - if (!empty($context)) { + if ($context !== []) { echo 'Context: ' . json_encode($context) . "\n"; } } } - /** - * 原代码的load_directory方法(保持兼容性) - */ public function load_directory(string $path): void { $this->scan($path); } - /** - * 原代码的parseFile方法(保持兼容性) - */ protected function parseFile($file): void { - // 这里$file是相对路径,需要转换为绝对路径 - $absolutePath = $this->basePath . ltrim($file, '/') . '.php'; - + $absolutePath = $this->normalizePath($this->basePath . '/' . ltrim((string)$file, '/')) . '.php'; if (file_exists($absolutePath)) { $this->processFile($absolutePath); } } - /** - * 原代码的skipNames方法(保持兼容性) - */ protected function skipNames(ReflectionClass $reflect): array { - return array_map( - fn($attr) => $attr->getName(), - $reflect->getAttributes() - ); + return array_map(fn($attr) => $attr->getName(), $reflect->getAttributes()); } public function getStats(): array { return [ - 'total_files' => count($this->files), + 'total_files' => count($this->files), 'cached_mtimes' => count($this->fileMtimes), - 'base_path' => $this->basePath, - 'config' => $this->config, + 'base_path' => $this->basePath, + 'config' => $this->config, + 'manifest_entries' => count($this->manifest->all()), ]; } public function reset(): void { - $this->files = []; + $this->files = []; $this->fileMtimes = []; $this->hasOpcache = null; + $this->manifest = new ScanManifest(); + $this->changeSet = null; + $this->visitedFiles = []; + } + + private function detectRemovedFiles(string $path): void + { + $prefix = rtrim($path, '/\\') . '/'; + foreach ($this->manifest->paths($prefix) as $file) { + if (!isset($this->visitedFiles[$file])) { + $this->markRemovedFile($file); + } + } + } + + private function markRemovedFile(string $path): void + { + if (!$this->manifest->has($path)) { + return; + } + + $classes = $this->manifest->remove($path); + $this->invalidateClasses($classes); + foreach ($classes as $class) { + $this->changeSet?->addRemovedClass($class); + } + + $this->changeSet?->addRemovedFile($path); + } + + private function syncLegacyState(): void + { + $this->files = array_keys($this->manifest->all()); + $this->fileMtimes = []; + foreach ($this->manifest->all() as $path => $entry) { + $this->fileMtimes[$path] = (int)$entry['mtime']; + } + } + + private function collectFiles(string $path, int $depth = 0): array + { + if ($depth > $this->config['max_depth']) { + return []; + } + + $files = []; + try { + $dir = new DirectoryIterator($path); + } catch (Throwable) { + return []; + } + + foreach ($dir as $item) { + if ($this->shouldSkipItem($item)) { + continue; + } + + $realPath = $this->normalizePath($item->getRealPath() ?: $item->getPathname()); + if ($item->isDir()) { + if ($this->shouldSkipDirectory($realPath)) { + continue; + } + if ($item->isLink() && !$this->config['follow_links']) { + continue; + } + $files = array_merge($files, $this->collectFiles($realPath, $depth + 1)); + continue; + } + + if ($item->isFile() && in_array(pathinfo($realPath, PATHINFO_EXTENSION), $this->config['extensions'], true)) { + $files[] = $realPath; + } + } + + return $files; + } + + private function normalizePath(string $path): string + { + $resolved = realpath($path) ?: $path; + return str_replace('\\', '/', $resolved); + } + + private function replayManifest(string $path, array $skipPaths = []): void + { + $skip = array_fill_keys(array_map([$this, 'normalizePath'], $skipPaths), true); + $prefix = rtrim($path, '/\\') . '/'; + + foreach ($this->manifest->paths($prefix) as $file) { + if (isset($skip[$file])) { + continue; + } + + foreach ($this->manifest->getClasses($file) as $class) { + if (class_exists($class)) { + $this->analyzeClass($class); + } + } + } + } + + private function invalidateClasses(array $classes): void + { + foreach ($classes as $class) { + if (method_exists($this->container, 'forgetClass')) { + $this->container->forgetClass($class); + } + } } }