PluginProbe
Packeta / trunk
Packeta vtrunk
2.3.2 2.3.1 trunk 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.3.0 1.3.1 1.3.2 1.4 1.4.1 1.4.2 1.4.3 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 All 56 releases
packeta / deps / nette / robot-loader / src / RobotLoader / RobotLoader.php

RobotLoader.php in Packeta trunk, at deps/nette/robot-loader/src/RobotLoader/RobotLoader.php

450 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * This file is part of the Nette Framework (https://nette.org)
5 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6 */
7 declare (strict_types=1);
8 namespace Packetery\Nette\Loaders;
9
10 use Packetery\Nette;
11 use SplFileInfo;
12 /**
13 * Nette auto loader is responsible for loading classes and interfaces.
14 *
15 * <code>
16 * $loader = new \Packetery\Nette\Loaders\RobotLoader;
17 * $loader->addDirectory('app');
18 * $loader->excludeDirectory('app/exclude');
19 * $loader->setTempDirectory('temp');
20 * $loader->register();
21 * </code>
22 */
23 class RobotLoader
24 {
25 use \Packetery\Nette\SmartObject;
26 private const RETRY_LIMIT = 3;
27 /** @var string[] */
28 public $ignoreDirs = ['.*', '*.old', '*.bak', '*.tmp', 'temp'];
29 /** @var string[] */
30 public $acceptFiles = ['*.php'];
31 /** @var bool */
32 private $autoRebuild = \true;
33 /** @var bool */
34 private $reportParseErrors = \true;
35 /** @var string[] */
36 private $scanPaths = [];
37 /** @var string[] */
38 private $excludeDirs = [];
39 /** @var array<string, array{string, int}> class => [file, time] */
40 private $classes = [];
41 /** @var bool */
42 private $cacheLoaded = \false;
43 /** @var bool */
44 private $refreshed = \false;
45 /** @var array<string, int> class => counter */
46 private $missingClasses = [];
47 /** @var array<string, int> file => mtime */
48 private $emptyFiles = [];
49 /** @var string|null */
50 private $tempDirectory;
51 /** @var bool */
52 private $needSave = \false;
53 public function __construct()
54 {
55 if (!\extension_loaded('tokenizer')) {
56 throw new \Packetery\Nette\NotSupportedException('PHP extension Tokenizer is not loaded.');
57 }
58 }
59 public function __destruct()
60 {
61 if ($this->needSave) {
62 $this->saveCache();
63 }
64 }
65 /**
66 * Register autoloader.
67 */
68 public function register(bool $prepend = \false) : self
69 {
70 \spl_autoload_register([$this, 'tryLoad'], \true, $prepend);
71 return $this;
72 }
73 /**
74 * Handles autoloading of classes, interfaces or traits.
75 */
76 public function tryLoad(string $type) : void
77 {
78 $this->loadCache();
79 $missing = $this->missingClasses[$type] ?? null;
80 if ($missing >= self::RETRY_LIMIT) {
81 return;
82 }
83 [$file, $mtime] = $this->classes[$type] ?? null;
84 if ($this->autoRebuild) {
85 if (!$this->refreshed) {
86 if (!$file || !\is_file($file)) {
87 $this->refreshClasses();
88 [$file] = $this->classes[$type] ?? null;
89 $this->needSave = \true;
90 } elseif (\filemtime($file) !== $mtime) {
91 $this->updateFile($file);
92 [$file] = $this->classes[$type] ?? null;
93 $this->needSave = \true;
94 }
95 }
96 if (!$file || !\is_file($file)) {
97 $this->missingClasses[$type] = ++$missing;
98 $this->needSave = $this->needSave || $file || $missing <= self::RETRY_LIMIT;
99 unset($this->classes[$type]);
100 $file = null;
101 }
102 }
103 if ($file) {
104 (static function ($file) {
105 require $file;
106 })($file);
107 }
108 }
109 /**
110 * Add path or paths to list.
111 * @param string ...$paths absolute path
112 */
113 public function addDirectory(...$paths) : self
114 {
115 if (\is_array($paths[0] ?? null)) {
116 \trigger_error(__METHOD__ . '() use variadics ...$paths to add an array of paths.', \E_USER_WARNING);
117 $paths = $paths[0];
118 }
119 $this->scanPaths = \array_merge($this->scanPaths, $paths);
120 return $this;
121 }
122 public function reportParseErrors(bool $on = \true) : self
123 {
124 $this->reportParseErrors = $on;
125 return $this;
126 }
127 /**
128 * Excludes path or paths from list.
129 * @param string ...$paths absolute path
130 */
131 public function excludeDirectory(...$paths) : self
132 {
133 if (\is_array($paths[0] ?? null)) {
134 \trigger_error(__METHOD__ . '() use variadics ...$paths to add an array of paths.', \E_USER_WARNING);
135 $paths = $paths[0];
136 }
137 $this->excludeDirs = \array_merge($this->excludeDirs, $paths);
138 return $this;
139 }
140 /**
141 * @return array<string, string> class => filename
142 */
143 public function getIndexedClasses() : array
144 {
145 $this->loadCache();
146 $res = [];
147 foreach ($this->classes as $class => [$file]) {
148 $res[$class] = $file;
149 }
150 return $res;
151 }
152 /**
153 * Rebuilds class list cache.
154 */
155 public function rebuild() : void
156 {
157 $this->cacheLoaded = \true;
158 $this->classes = $this->missingClasses = $this->emptyFiles = [];
159 $this->refreshClasses();
160 if ($this->tempDirectory) {
161 $this->saveCache();
162 }
163 }
164 /**
165 * Refreshes class list cache.
166 */
167 public function refresh() : void
168 {
169 $this->loadCache();
170 if (!$this->refreshed) {
171 $this->refreshClasses();
172 $this->saveCache();
173 }
174 }
175 /**
176 * Refreshes $this->classes & $this->emptyFiles.
177 */
178 private function refreshClasses() : void
179 {
180 $this->refreshed = \true;
181 // prevents calling refreshClasses() or updateFile() in tryLoad()
182 $files = $this->emptyFiles;
183 $classes = [];
184 foreach ($this->classes as $class => [$file, $mtime]) {
185 $files[$file] = $mtime;
186 $classes[$file][] = $class;
187 }
188 $this->classes = $this->emptyFiles = [];
189 foreach ($this->scanPaths as $path) {
190 $iterator = \is_file($path) ? [new SplFileInfo($path)] : $this->createFileIterator($path);
191 foreach ($iterator as $fileInfo) {
192 $mtime = $fileInfo->getMTime();
193 $file = $fileInfo->getPathname();
194 $foundClasses = isset($files[$file]) && $files[$file] === $mtime ? $classes[$file] ?? [] : $this->scanPhp($file);
195 if (!$foundClasses) {
196 $this->emptyFiles[$file] = $mtime;
197 }
198 $files[$file] = $mtime;
199 $classes[$file] = [];
200 // prevents the error when adding the same file twice
201 foreach ($foundClasses as $class) {
202 if (isset($this->classes[$class])) {
203 throw new \Packetery\Nette\InvalidStateException("Ambiguous class {$class} resolution; defined in {$this->classes[$class][0]} and in {$file}.");
204 }
205 $this->classes[$class] = [$file, $mtime];
206 unset($this->missingClasses[$class]);
207 }
208 }
209 }
210 }
211 /**
212 * Creates an iterator scaning directory for PHP files, subdirectories and 'netterobots.txt' files.
213 * @throws \Packetery\Nette\IOException if path is not found
214 */
215 private function createFileIterator(string $dir) : \Packetery\Nette\Utils\Finder
216 {
217 if (!\is_dir($dir)) {
218 throw new \Packetery\Nette\IOException("File or directory '{$dir}' not found.");
219 }
220 $dir = \realpath($dir) ?: $dir;
221 // realpath does not work in phar
222 if (\is_string($ignoreDirs = $this->ignoreDirs)) {
223 \trigger_error(self::class . ': $ignoreDirs must be an array.', \E_USER_WARNING);
224 $ignoreDirs = \preg_split('#[,\\s]+#', $ignoreDirs);
225 }
226 $disallow = [];
227 foreach (\array_merge($ignoreDirs, $this->excludeDirs) as $item) {
228 if ($item = \realpath($item)) {
229 $disallow[\str_replace('\\', '/', $item)] = \true;
230 }
231 }
232 if (\is_string($acceptFiles = $this->acceptFiles)) {
233 \trigger_error(self::class . ': $acceptFiles must be an array.', \E_USER_WARNING);
234 $acceptFiles = \preg_split('#[,\\s]+#', $acceptFiles);
235 }
236 $iterator = \Packetery\Nette\Utils\Finder::findFiles($acceptFiles)->filter(function (SplFileInfo $file) use(&$disallow) {
237 return $file->getRealPath() === \false ? \true : !isset($disallow[\str_replace('\\', '/', $file->getRealPath())]);
238 })->from($dir)->exclude($ignoreDirs)->filter($filter = function (SplFileInfo $dir) use(&$disallow) {
239 if ($dir->getRealPath() === \false) {
240 return \true;
241 }
242 $path = \str_replace('\\', '/', $dir->getRealPath());
243 if (\is_file("{$path}/netterobots.txt")) {
244 foreach (\file("{$path}/netterobots.txt") as $s) {
245 if (\preg_match('#^(?:disallow\\s*:)?\\s*(\\S+)#i', $s, $matches)) {
246 $disallow[$path . \rtrim('/' . \ltrim($matches[1], '/'), '/')] = \true;
247 }
248 }
249 }
250 return !isset($disallow[$path]);
251 });
252 $filter(new SplFileInfo($dir));
253 return $iterator;
254 }
255 private function updateFile(string $file) : void
256 {
257 foreach ($this->classes as $class => [$prevFile]) {
258 if ($file === $prevFile) {
259 unset($this->classes[$class]);
260 }
261 }
262 $foundClasses = \is_file($file) ? $this->scanPhp($file) : [];
263 foreach ($foundClasses as $class) {
264 [$prevFile, $prevMtime] = $this->classes[$class] ?? null;
265 if (isset($prevFile) && @\filemtime($prevFile) !== $prevMtime) {
266 // @ file may not exists
267 $this->updateFile($prevFile);
268 [$prevFile] = $this->classes[$class] ?? null;
269 }
270 if (isset($prevFile)) {
271 throw new \Packetery\Nette\InvalidStateException("Ambiguous class {$class} resolution; defined in {$prevFile} and in {$file}.");
272 }
273 $this->classes[$class] = [$file, \filemtime($file)];
274 }
275 }
276 /**
277 * Searches classes, interfaces and traits in PHP file.
278 * @return string[]
279 */
280 private function scanPhp(string $file) : array
281 {
282 $code = \file_get_contents($file);
283 $expected = \false;
284 $namespace = $name = '';
285 $level = $minLevel = 0;
286 $classes = [];
287 try {
288 $tokens = \token_get_all($code, \TOKEN_PARSE);
289 } catch (\ParseError $e) {
290 if ($this->reportParseErrors) {
291 $rp = new \ReflectionProperty($e, 'file');
292 $rp->setAccessible(\true);
293 $rp->setValue($e, $file);
294 throw $e;
295 }
296 $tokens = [];
297 }
298 foreach ($tokens as $token) {
299 if (\is_array($token)) {
300 switch ($token[0]) {
301 case \T_COMMENT:
302 case \T_DOC_COMMENT:
303 case \T_WHITESPACE:
304 continue 2;
305 case \T_STRING:
306 case \PHP_VERSION_ID < 80000 ? \T_NS_SEPARATOR : \T_NAME_QUALIFIED:
307 if ($expected) {
308 $name .= $token[1];
309 }
310 continue 2;
311 case \T_NAMESPACE:
312 case \T_CLASS:
313 case \T_INTERFACE:
314 case \T_TRAIT:
315 $expected = $token[0];
316 $name = '';
317 continue 2;
318 case \T_CURLY_OPEN:
319 case \T_DOLLAR_OPEN_CURLY_BRACES:
320 $level++;
321 }
322 }
323 if ($expected) {
324 switch ($expected) {
325 case \T_CLASS:
326 case \T_INTERFACE:
327 case \T_TRAIT:
328 if ($name && $level === $minLevel) {
329 $classes[] = $namespace . $name;
330 }
331 break;
332 case \T_NAMESPACE:
333 $namespace = $name ? $name . '\\' : '';
334 $minLevel = $token === '{' ? 1 : 0;
335 }
336 $expected = null;
337 }
338 if ($token === '{') {
339 $level++;
340 } elseif ($token === '}') {
341 $level--;
342 }
343 }
344 return $classes;
345 }
346 /********************* caching ****************d*g**/
347 /**
348 * Sets auto-refresh mode.
349 */
350 public function setAutoRefresh(bool $on = \true) : self
351 {
352 $this->autoRebuild = $on;
353 return $this;
354 }
355 /**
356 * Sets path to temporary directory.
357 */
358 public function setTempDirectory(string $dir) : self
359 {
360 \Packetery\Nette\Utils\FileSystem::createDir($dir);
361 $this->tempDirectory = $dir;
362 return $this;
363 }
364 /**
365 * Loads class list from cache.
366 */
367 private function loadCache() : void
368 {
369 if ($this->cacheLoaded) {
370 return;
371 }
372 $this->cacheLoaded = \true;
373 $file = $this->getCacheFile();
374 // Solving atomicity to work everywhere is really pain in the ass.
375 // 1) We want to do as little as possible IO calls on production and also directory and file can be not writable (#19)
376 // so on Linux we include the file directly without shared lock, therefore, the file must be created atomically by renaming.
377 // 2) On Windows file cannot be renamed-to while is open (ie by include() #11), so we have to acquire a lock.
378 $lock = \defined('PHP_WINDOWS_VERSION_BUILD') ? $this->acquireLock("{$file}.lock", \LOCK_SH) : null;
379 $data = @(include $file);
380 // @ file may not exist
381 if (\is_array($data)) {
382 [$this->classes, $this->missingClasses, $this->emptyFiles] = $data;
383 return;
384 }
385 if ($lock) {
386 \flock($lock, \LOCK_UN);
387 // release shared lock so we can get exclusive
388 }
389 $lock = $this->acquireLock("{$file}.lock", \LOCK_EX);
390 // while waiting for exclusive lock, someone might have already created the cache
391 $data = @(include $file);
392 // @ file may not exist
393 if (\is_array($data)) {
394 [$this->classes, $this->missingClasses, $this->emptyFiles] = $data;
395 return;
396 }
397 $this->classes = $this->missingClasses = $this->emptyFiles = [];
398 $this->refreshClasses();
399 $this->saveCache($lock);
400 // On Windows concurrent creation and deletion of a file can cause a error 'permission denied',
401 // therefore, we will not delete the lock file. Windows is peace of shit.
402 }
403 /**
404 * Writes class list to cache.
405 * @param resource $lock
406 */
407 private function saveCache($lock = null) : void
408 {
409 // we have to acquire a lock to be able safely rename file
410 // on Linux: that another thread does not rename the same named file earlier
411 // on Windows: that the file is not read by another thread
412 $file = $this->getCacheFile();
413 $lock = $lock ?: $this->acquireLock("{$file}.lock", \LOCK_EX);
414 $code = "<?php\nreturn " . \var_export([$this->classes, $this->missingClasses, $this->emptyFiles], \true) . ";\n";
415 if (\file_put_contents("{$file}.tmp", $code) !== \strlen($code) || !\rename("{$file}.tmp", $file)) {
416 @\unlink("{$file}.tmp");
417 // @ file may not exist
418 throw new \RuntimeException("Unable to create '{$file}'.");
419 }
420 if (\function_exists('opcache_invalidate')) {
421 @\opcache_invalidate($file, \true);
422 // @ can be restricted
423 }
424 }
425 /** @return resource */
426 private function acquireLock(string $file, int $mode)
427 {
428 $handle = @\fopen($file, 'w');
429 // @ is escalated to exception
430 if (!$handle) {
431 throw new \RuntimeException("Unable to create file '{$file}'. " . \error_get_last()['message']);
432 } elseif (!@\flock($handle, $mode)) {
433 // @ is escalated to exception
434 throw new \RuntimeException('Unable to acquire ' . ($mode & \LOCK_EX ? 'exclusive' : 'shared') . " lock on file '{$file}'. " . \error_get_last()['message']);
435 }
436 return $handle;
437 }
438 private function getCacheFile() : string
439 {
440 if (!$this->tempDirectory) {
441 throw new \LogicException('Set path to temporary directory using setTempDirectory().');
442 }
443 return $this->tempDirectory . '/' . \md5(\serialize($this->getCacheKey())) . '.php';
444 }
445 protected function getCacheKey() : array
446 {
447 return [$this->ignoreDirs, $this->acceptFiles, $this->scanPaths, $this->excludeDirs, 'v2'];
448 }
449 }
450