* $loader = new \Packetery\Nette\Loaders\RobotLoader; * $loader->addDirectory('app'); * $loader->excludeDirectory('app/exclude'); * $loader->setTempDirectory('temp'); * $loader->register(); * */ class RobotLoader { use \Packetery\Nette\SmartObject; private const RETRY_LIMIT = 3; /** @var string[] */ public $ignoreDirs = ['.*', '*.old', '*.bak', '*.tmp', 'temp']; /** @var string[] */ public $acceptFiles = ['*.php']; /** @var bool */ private $autoRebuild = \true; /** @var bool */ private $reportParseErrors = \true; /** @var string[] */ private $scanPaths = []; /** @var string[] */ private $excludeDirs = []; /** @var array class => [file, time] */ private $classes = []; /** @var bool */ private $cacheLoaded = \false; /** @var bool */ private $refreshed = \false; /** @var array class => counter */ private $missingClasses = []; /** @var array file => mtime */ private $emptyFiles = []; /** @var string|null */ private $tempDirectory; /** @var bool */ private $needSave = \false; public function __construct() { if (!\extension_loaded('tokenizer')) { throw new \Packetery\Nette\NotSupportedException('PHP extension Tokenizer is not loaded.'); } } public function __destruct() { if ($this->needSave) { $this->saveCache(); } } /** * Register autoloader. */ public function register(bool $prepend = \false) : self { \spl_autoload_register([$this, 'tryLoad'], \true, $prepend); return $this; } /** * Handles autoloading of classes, interfaces or traits. */ public function tryLoad(string $type) : void { $this->loadCache(); $missing = $this->missingClasses[$type] ?? null; if ($missing >= self::RETRY_LIMIT) { return; } [$file, $mtime] = $this->classes[$type] ?? null; if ($this->autoRebuild) { if (!$this->refreshed) { if (!$file || !\is_file($file)) { $this->refreshClasses(); [$file] = $this->classes[$type] ?? null; $this->needSave = \true; } elseif (\filemtime($file) !== $mtime) { $this->updateFile($file); [$file] = $this->classes[$type] ?? null; $this->needSave = \true; } } if (!$file || !\is_file($file)) { $this->missingClasses[$type] = ++$missing; $this->needSave = $this->needSave || $file || $missing <= self::RETRY_LIMIT; unset($this->classes[$type]); $file = null; } } if ($file) { (static function ($file) { require $file; })($file); } } /** * Add path or paths to list. * @param string ...$paths absolute path */ public function addDirectory(...$paths) : self { if (\is_array($paths[0] ?? null)) { \trigger_error(__METHOD__ . '() use variadics ...$paths to add an array of paths.', \E_USER_WARNING); $paths = $paths[0]; } $this->scanPaths = \array_merge($this->scanPaths, $paths); return $this; } public function reportParseErrors(bool $on = \true) : self { $this->reportParseErrors = $on; return $this; } /** * Excludes path or paths from list. * @param string ...$paths absolute path */ public function excludeDirectory(...$paths) : self { if (\is_array($paths[0] ?? null)) { \trigger_error(__METHOD__ . '() use variadics ...$paths to add an array of paths.', \E_USER_WARNING); $paths = $paths[0]; } $this->excludeDirs = \array_merge($this->excludeDirs, $paths); return $this; } /** * @return array class => filename */ public function getIndexedClasses() : array { $this->loadCache(); $res = []; foreach ($this->classes as $class => [$file]) { $res[$class] = $file; } return $res; } /** * Rebuilds class list cache. */ public function rebuild() : void { $this->cacheLoaded = \true; $this->classes = $this->missingClasses = $this->emptyFiles = []; $this->refreshClasses(); if ($this->tempDirectory) { $this->saveCache(); } } /** * Refreshes class list cache. */ public function refresh() : void { $this->loadCache(); if (!$this->refreshed) { $this->refreshClasses(); $this->saveCache(); } } /** * Refreshes $this->classes & $this->emptyFiles. */ private function refreshClasses() : void { $this->refreshed = \true; // prevents calling refreshClasses() or updateFile() in tryLoad() $files = $this->emptyFiles; $classes = []; foreach ($this->classes as $class => [$file, $mtime]) { $files[$file] = $mtime; $classes[$file][] = $class; } $this->classes = $this->emptyFiles = []; foreach ($this->scanPaths as $path) { $iterator = \is_file($path) ? [new SplFileInfo($path)] : $this->createFileIterator($path); foreach ($iterator as $fileInfo) { $mtime = $fileInfo->getMTime(); $file = $fileInfo->getPathname(); $foundClasses = isset($files[$file]) && $files[$file] === $mtime ? $classes[$file] ?? [] : $this->scanPhp($file); if (!$foundClasses) { $this->emptyFiles[$file] = $mtime; } $files[$file] = $mtime; $classes[$file] = []; // prevents the error when adding the same file twice foreach ($foundClasses as $class) { if (isset($this->classes[$class])) { throw new \Packetery\Nette\InvalidStateException("Ambiguous class {$class} resolution; defined in {$this->classes[$class][0]} and in {$file}."); } $this->classes[$class] = [$file, $mtime]; unset($this->missingClasses[$class]); } } } } /** * Creates an iterator scaning directory for PHP files, subdirectories and 'netterobots.txt' files. * @throws \Packetery\Nette\IOException if path is not found */ private function createFileIterator(string $dir) : \Packetery\Nette\Utils\Finder { if (!\is_dir($dir)) { throw new \Packetery\Nette\IOException("File or directory '{$dir}' not found."); } $dir = \realpath($dir) ?: $dir; // realpath does not work in phar if (\is_string($ignoreDirs = $this->ignoreDirs)) { \trigger_error(self::class . ': $ignoreDirs must be an array.', \E_USER_WARNING); $ignoreDirs = \preg_split('#[,\\s]+#', $ignoreDirs); } $disallow = []; foreach (\array_merge($ignoreDirs, $this->excludeDirs) as $item) { if ($item = \realpath($item)) { $disallow[\str_replace('\\', '/', $item)] = \true; } } if (\is_string($acceptFiles = $this->acceptFiles)) { \trigger_error(self::class . ': $acceptFiles must be an array.', \E_USER_WARNING); $acceptFiles = \preg_split('#[,\\s]+#', $acceptFiles); } $iterator = \Packetery\Nette\Utils\Finder::findFiles($acceptFiles)->filter(function (SplFileInfo $file) use(&$disallow) { return $file->getRealPath() === \false ? \true : !isset($disallow[\str_replace('\\', '/', $file->getRealPath())]); })->from($dir)->exclude($ignoreDirs)->filter($filter = function (SplFileInfo $dir) use(&$disallow) { if ($dir->getRealPath() === \false) { return \true; } $path = \str_replace('\\', '/', $dir->getRealPath()); if (\is_file("{$path}/netterobots.txt")) { foreach (\file("{$path}/netterobots.txt") as $s) { if (\preg_match('#^(?:disallow\\s*:)?\\s*(\\S+)#i', $s, $matches)) { $disallow[$path . \rtrim('/' . \ltrim($matches[1], '/'), '/')] = \true; } } } return !isset($disallow[$path]); }); $filter(new SplFileInfo($dir)); return $iterator; } private function updateFile(string $file) : void { foreach ($this->classes as $class => [$prevFile]) { if ($file === $prevFile) { unset($this->classes[$class]); } } $foundClasses = \is_file($file) ? $this->scanPhp($file) : []; foreach ($foundClasses as $class) { [$prevFile, $prevMtime] = $this->classes[$class] ?? null; if (isset($prevFile) && @\filemtime($prevFile) !== $prevMtime) { // @ file may not exists $this->updateFile($prevFile); [$prevFile] = $this->classes[$class] ?? null; } if (isset($prevFile)) { throw new \Packetery\Nette\InvalidStateException("Ambiguous class {$class} resolution; defined in {$prevFile} and in {$file}."); } $this->classes[$class] = [$file, \filemtime($file)]; } } /** * Searches classes, interfaces and traits in PHP file. * @return string[] */ private function scanPhp(string $file) : array { $code = \file_get_contents($file); $expected = \false; $namespace = $name = ''; $level = $minLevel = 0; $classes = []; try { $tokens = \token_get_all($code, \TOKEN_PARSE); } catch (\ParseError $e) { if ($this->reportParseErrors) { $rp = new \ReflectionProperty($e, 'file'); $rp->setAccessible(\true); $rp->setValue($e, $file); throw $e; } $tokens = []; } foreach ($tokens as $token) { if (\is_array($token)) { switch ($token[0]) { case \T_COMMENT: case \T_DOC_COMMENT: case \T_WHITESPACE: continue 2; case \T_STRING: case \PHP_VERSION_ID < 80000 ? \T_NS_SEPARATOR : \T_NAME_QUALIFIED: if ($expected) { $name .= $token[1]; } continue 2; case \T_NAMESPACE: case \T_CLASS: case \T_INTERFACE: case \T_TRAIT: $expected = $token[0]; $name = ''; continue 2; case \T_CURLY_OPEN: case \T_DOLLAR_OPEN_CURLY_BRACES: $level++; } } if ($expected) { switch ($expected) { case \T_CLASS: case \T_INTERFACE: case \T_TRAIT: if ($name && $level === $minLevel) { $classes[] = $namespace . $name; } break; case \T_NAMESPACE: $namespace = $name ? $name . '\\' : ''; $minLevel = $token === '{' ? 1 : 0; } $expected = null; } if ($token === '{') { $level++; } elseif ($token === '}') { $level--; } } return $classes; } /********************* caching ****************d*g**/ /** * Sets auto-refresh mode. */ public function setAutoRefresh(bool $on = \true) : self { $this->autoRebuild = $on; return $this; } /** * Sets path to temporary directory. */ public function setTempDirectory(string $dir) : self { \Packetery\Nette\Utils\FileSystem::createDir($dir); $this->tempDirectory = $dir; return $this; } /** * Loads class list from cache. */ private function loadCache() : void { if ($this->cacheLoaded) { return; } $this->cacheLoaded = \true; $file = $this->getCacheFile(); // Solving atomicity to work everywhere is really pain in the ass. // 1) We want to do as little as possible IO calls on production and also directory and file can be not writable (#19) // so on Linux we include the file directly without shared lock, therefore, the file must be created atomically by renaming. // 2) On Windows file cannot be renamed-to while is open (ie by include() #11), so we have to acquire a lock. $lock = \defined('PHP_WINDOWS_VERSION_BUILD') ? $this->acquireLock("{$file}.lock", \LOCK_SH) : null; $data = @(include $file); // @ file may not exist if (\is_array($data)) { [$this->classes, $this->missingClasses, $this->emptyFiles] = $data; return; } if ($lock) { \flock($lock, \LOCK_UN); // release shared lock so we can get exclusive } $lock = $this->acquireLock("{$file}.lock", \LOCK_EX); // while waiting for exclusive lock, someone might have already created the cache $data = @(include $file); // @ file may not exist if (\is_array($data)) { [$this->classes, $this->missingClasses, $this->emptyFiles] = $data; return; } $this->classes = $this->missingClasses = $this->emptyFiles = []; $this->refreshClasses(); $this->saveCache($lock); // On Windows concurrent creation and deletion of a file can cause a error 'permission denied', // therefore, we will not delete the lock file. Windows is peace of shit. } /** * Writes class list to cache. * @param resource $lock */ private function saveCache($lock = null) : void { // we have to acquire a lock to be able safely rename file // on Linux: that another thread does not rename the same named file earlier // on Windows: that the file is not read by another thread $file = $this->getCacheFile(); $lock = $lock ?: $this->acquireLock("{$file}.lock", \LOCK_EX); $code = "classes, $this->missingClasses, $this->emptyFiles], \true) . ";\n"; if (\file_put_contents("{$file}.tmp", $code) !== \strlen($code) || !\rename("{$file}.tmp", $file)) { @\unlink("{$file}.tmp"); // @ file may not exist throw new \RuntimeException("Unable to create '{$file}'."); } if (\function_exists('opcache_invalidate')) { @\opcache_invalidate($file, \true); // @ can be restricted } } /** @return resource */ private function acquireLock(string $file, int $mode) { $handle = @\fopen($file, 'w'); // @ is escalated to exception if (!$handle) { throw new \RuntimeException("Unable to create file '{$file}'. " . \error_get_last()['message']); } elseif (!@\flock($handle, $mode)) { // @ is escalated to exception throw new \RuntimeException('Unable to acquire ' . ($mode & \LOCK_EX ? 'exclusive' : 'shared') . " lock on file '{$file}'. " . \error_get_last()['message']); } return $handle; } private function getCacheFile() : string { if (!$this->tempDirectory) { throw new \LogicException('Set path to temporary directory using setTempDirectory().'); } return $this->tempDirectory . '/' . \md5(\serialize($this->getCacheKey())) . '.php'; } protected function getCacheKey() : array { return [$this->ignoreDirs, $this->acceptFiles, $this->scanPaths, $this->excludeDirs, 'v2']; } }