Files
kiri-container/Scanner.php
T
2025-12-31 01:50:09 +08:00

553 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Kiri\Di;
use Kiri\Di\Inject\Container;
use Kiri\Abstracts\Component;
use Kiri\Di\Inject\Skip;
use Psr\Container\ContainerInterface;
use ReflectionClass;
use ReflectionMethod;
use DirectoryIterator;
use Throwable;
class Scanner extends Component
{
#[Container(ContainerInterface::class)]
public ContainerInterface $container;
public array $files = [];
private array $fileMtimes = [];
private ?bool $hasOpcache = null;
private string $basePath;
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'
];
public function __construct()
{
$this->basePath = $_SERVER['PWD'] ?? APP_PATH ?? getcwd();
}
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
public function scan(string $path, ?string $cacheFile = null): void
{
if ($this->config['cache_enabled']) {
$cacheFile = $cacheFile ?? $this->getDefaultCacheFile();
if ($this->loadFromCache($cacheFile, $path)) {
$this->processCachedFiles();
return;
}
}
$this->scanDirectory($path);
if ($this->config['cache_enabled']) {
$this->saveToCache($cacheFile);
}
}
private function scanDirectory(string $path, int $depth = 0): void
{
if ($depth > $this->config['max_depth']) {
return;
}
try {
$dir = new DirectoryIterator($path);
} catch (Throwable $e) {
$this->logError($e, ['path' => $path, 'action' => 'open_directory']);
return;
}
foreach ($dir as $item) {
if ($this->shouldSkipItem($item)) {
continue;
}
$realPath = $item->getRealPath();
if ($item->isDir()) {
if ($this->shouldSkipDirectory($realPath)) {
continue;
}
if ($item->isLink() && !$this->config['follow_links']) {
continue;
}
$this->scanDirectory($realPath, $depth + 1);
} elseif ($item->isFile()) {
$this->processFile($realPath);
}
}
}
private function shouldSkipItem(DirectoryIterator $item): bool
{
return $item->isDot() || str_starts_with($item->getFilename(), '.');
}
private function shouldSkipDirectory(string $path): bool
{
$path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$skipDirs = array_merge(
$this->config['skip_directories'],
config('site.scanner.skip', [])
);
if (in_array($path, $skipDirs)) {
return true;
}
foreach ($this->config['skip_patterns'] as $pattern) {
if (strpos($path, $pattern) !== false) {
return true;
}
}
return false;
}
private function processFile(string $path): void
{
if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'])) {
return;
}
if (in_array($path, $this->files)) {
return;
}
if (!$this->shouldProcessFile($path)) {
return;
}
$this->files[] = $path;
try {
$this->loadAndParseFile($path);
} catch (Throwable $e) {
$this->logError($e, [
'file' => $path,
'action' => 'process_file',
]);
if ($e instanceof \ParseError) {
throw $e;
}
}
}
private function shouldProcessFile(string $path): bool
{
if (!file_exists($path)) {
return false;
}
$mtime = filemtime($path);
if (!isset($this->fileMtimes[$path]) || $this->fileMtimes[$path] < $mtime) {
$this->fileMtimes[$path] = $mtime;
return true;
}
return false;
}
private function loadAndParseFile(string $path): void
{
$this->optimizeWithOpcache($path);
require_once $path;
// 尝试获取类名
$class = $this->getClassNameFromPath($path);
if ($class && class_exists($class)) {
$this->analyzeClass($class);
}
}
/**
* 从文件路径获取类名(支持多种策略)
*/
private function getClassNameFromPath(string $path): ?string
{
$strategy = $this->config['class_name_strategy'];
// 移除基础路径前缀,类似原代码逻辑
$relativePath = str_replace($this->basePath, '', $path);
$relativePath = str_replace('.php', '', $relativePath);
return $this->renamePathToClassName($relativePath);
}
/**
* 检查是否可以安全地从文件内容提取类名
*/
private function canExtractClassName(): bool
{
// 如果有tokenizer扩展,优先使用它
return function_exists('token_get_all');
}
/**
* 从文件内容提取类名(原代码没有的功能)
*/
private function extractClassNameFromFile(string $path): ?string
{
if (!$this->canExtractClassName()) {
return null;
}
$content = @file_get_contents($path);
if (!$content) {
return null;
}
$tokens = @token_get_all($content);
if (!$tokens) {
return null;
}
$namespace = '';
$class = '';
$collectingNamespace = false;
$collectingClass = false;
var_dump($tokens[1]);
foreach ($tokens as $token) {
if (is_array($token)) {
if ($token[0] === T_NAMESPACE) {
$namespace = '';
$collectingNamespace = true;
} elseif ($collectingNamespace && $token[0] === T_STRING) {
$namespace .= $token[1];
} elseif ($collectingNamespace && $token[0] === T_NS_SEPARATOR) {
$namespace .= '\\';
} elseif ($collectingNamespace && $token[0] === T_WHITESPACE) {
continue;
} elseif ($token[0] === T_CLASS) {
$collectingClass = true;
} elseif ($collectingClass && $token[0] === T_STRING) {
$class = $token[1];
break;
} elseif ($collectingNamespace) {
$collectingNamespace = false;
}
} else {
if ($collectingNamespace && ($token === ';' || $token === '{')) {
$collectingNamespace = false;
}
if ($token === '{') {
$collectingClass = false;
}
}
}
if (!$class) {
return null;
}
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);
});
$parts = array_map('ucfirst', $parts);
return implode('\\', $parts);
}
private function optimizeWithOpcache(string $path): void
{
if ($this->hasOpcache === null) {
$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',
]);
}
}
}
}
private function analyzeClass(string $class): void
{
try {
$reflect = $this->container->getReflectionClass($class);
if (!$reflect->isInstantiable() ||
$reflect->isTrait() ||
$reflect->isEnum() ||
$reflect->isInterface()) {
return;
}
if ($this->shouldSkipClass($reflect)) {
return;
}
$this->analyzeClassMethods($reflect, $class);
} catch (Throwable $e) {
$this->logError($e, [
'class' => $class,
'action' => 'analyze_class',
]);
}
}
private function shouldSkipClass(ReflectionClass $reflect): bool
{
$attributes = array_map(
fn($attr) => $attr->getName(),
$reflect->getAttributes()
);
return in_array(Skip::class, $attributes, true) ||
in_array(\Attribute::class, $attributes, true);
}
private function analyzeClassMethods(ReflectionClass $reflect, string $class): void
{
foreach ($reflect->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isStatic() || $method->getDeclaringClass()->getName() !== $class) {
continue;
}
$this->processMethodAttributes($method, $class);
}
}
private function processMethodAttributes(ReflectionMethod $method, string $class): void
{
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(),
'attribute' => $attributeName,
'action' => 'process_attribute',
]);
}
}
}
private function getDefaultCacheFile(): string
{
$cacheDir = sys_get_temp_dir() . '/kiri-scanner-cache/';
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$projectHash = md5(__DIR__);
return $cacheDir . 'scanner_' . $projectHash . '.json';
}
private function loadFromCache(string $cacheFile, string $path): bool
{
if (!file_exists($cacheFile)) {
return false;
}
if (filemtime($cacheFile) + $this->config['cache_ttl'] < time()) {
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'])) {
return false;
}
$this->files = $data['files'];
$this->fileMtimes = $data['mtimes'];
return true;
}
private function hasDirectoryChanged(string $path, string $cacheFile): bool
{
$cacheMtime = filemtime($cacheFile);
$dirIterator = new DirectoryIterator($path);
foreach ($dirIterator as $item) {
if ($item->isDot()) {
continue;
}
$itemPath = $item->getRealPath();
if (filemtime($itemPath) > $cacheMtime) {
return true;
}
}
return false;
}
private function saveToCache(string $cacheFile): void
{
$data = [
'files' => $this->files,
'mtimes' => $this->fileMtimes,
'timestamp' => time(),
'version' => '1.1',
];
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();
foreach ($files as $file) {
if (filemtime($file) + (7 * 24 * 3600) < $now) {
unlink($file);
}
}
}
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'),
]);
if (function_exists('error')) {
error($e, $fullContext);
} else {
error_log(json_encode($fullContext));
}
if ($this->config['debug'] && php_sapi_name() === 'cli') {
echo "Scanner Error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n";
if (!empty($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';
if (file_exists($absolutePath)) {
$this->processFile($absolutePath);
}
}
/**
* 原代码的skipNames方法(保持兼容性)
*/
protected function skipNames(ReflectionClass $reflect): array
{
return array_map(
fn($attr) => $attr->getName(),
$reflect->getAttributes()
);
}
public function getStats(): array
{
return [
'total_files' => count($this->files),
'cached_mtimes' => count($this->fileMtimes),
'base_path' => $this->basePath,
'config' => $this->config,
];
}
public function reset(): void
{
$this->files = [];
$this->fileMtimes = [];
$this->hasOpcache = null;
}
}