Completed
Branch scrutinzer (0e2b92)
by Akihito
01:09
created

Compiler::getPaths()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
rs 9.2888
c 0
b 0
f 0
cc 5
nc 4
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace BEAR\Package;
6
7
use BEAR\AppMeta\AbstractAppMeta;
8
use BEAR\AppMeta\Meta;
9
use BEAR\Package\Provide\Error\NullPage;
10
use BEAR\Resource\Exception\ParameterException;
11
use BEAR\Resource\NamedParameterInterface;
12
use BEAR\Resource\Uri;
13
use BEAR\Sunday\Extension\Application\AppInterface;
14
use Composer\Autoload\ClassLoader;
15
use Doctrine\Common\Annotations\Reader;
16
use Ray\Di\AbstractModule;
17
use Ray\Di\Exception\Unbound;
18
use Ray\Di\InjectorInterface;
19
use Ray\ObjectGrapher\ObjectGrapher;
20
use ReflectionClass;
21
use RuntimeException;
22
use Throwable;
23
24
use function array_keys;
25
use function assert;
26
use function class_exists;
27
use function count;
28
use function file_exists;
29
use function file_put_contents;
30
use function get_class;
31
use function in_array;
32
use function interface_exists;
33
use function is_callable;
34
use function is_float;
35
use function is_int;
36
use function memory_get_peak_usage;
37
use function microtime;
38
use function number_format;
39
use function preg_quote;
40
use function preg_replace;
41
use function printf;
42
use function property_exists;
43
use function realpath;
44
use function sort;
45
use function spl_autoload_register;
46
use function sprintf;
47
use function strpos;
48
use function substr;
49
use function trait_exists;
50
51
use const PHP_EOL;
52
53
final class Compiler
54
{
55
    /** @var string[] */
56
    private $classes = [];
57
58
    /** @var InjectorInterface */
59
    private $injector;
60
61
    /** @var string */
62
    private $context;
63
64
    /** @var string */
65
    private $appDir;
66
67
    /** @var Meta */
68
    private $appMeta;
69
70
    /** @var array<int, string> */
71
    private $compiled = [];
72
73
    /** @var array<string, string> */
74
    private $failed = [];
75
76
    /** @var list<string> */
77
    private $overwritten = [];
78
79
    /**
80
     * @param string $appName application name "MyVendor|MyProject"
81
     * @param string $context application context "prod-app"
82
     * @param string $appDir  application path
83
     */
84
    public function __construct(string $appName, string $context, string $appDir)
85
    {
86
        $this->registerLoader($appDir);
87
        $this->hookNullObjectClass($appDir);
88
        $this->context = $context;
89
        $this->appDir = $appDir;
90
        $this->appMeta = new Meta($appName, $context, $appDir);
91
        /** @psalm-suppress MixedAssignment */
92
        $this->injector = Injector::getInstance($appName, $context, $appDir);
93
    }
94
95
    /**
96
     * Compile application
97
     *
98
     * @return 0|1 exit code
0 ignored issues
show
Documentation introduced by
The doc-type 0|1 could not be parsed: Unknown type name "0" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
99
     */
100
    public function compile(): int
101
    {
102
        $preload = $this->compilePreload($this->appMeta, $this->context);
103
        $module = (new Module())($this->appMeta, $this->context);
104
        $this->compileSrc($module);
105
        echo PHP_EOL;
106
        $this->compileDiScripts($this->appMeta);
107
        $dot = $this->failed ? '' : $this->compileObjectGraphDotFile($module);
108
        $start = $_SERVER['REQUEST_TIME_FLOAT'];
109
        assert(is_float($start));
110
        $time = number_format(microtime(true) - $start, 2);
111
        $memory = number_format(memory_get_peak_usage() / (1024 * 1024), 3);
112
        echo PHP_EOL;
113
        printf("Compilation (1/2) took %f seconds and used %fMB of memory\n", $time, $memory);
114
        printf("Success: %d Failed: %d\n", count($this->compiled), count($this->failed));
115
        printf("preload.php: %s\n", $this->getFileInfo($preload));
116
        printf("module.dot: %s\n", $dot ? $this->getFileInfo($dot) : 'n/a');
117
118
        foreach ($this->failed as $depedencyIndex => $error) {
119
            printf("UNBOUND: %s for %s \n", $error, $depedencyIndex);
120
        }
121
122
        return $this->failed ? 1 : 0;
123
    }
124
125
    public function dumpAutoload(): int
126
    {
127
        echo PHP_EOL;
128
        $this->invokeTypicalRequest();
129
        $paths = $this->getPaths($this->classes);
130
        $autolaod = $this->saveAutoloadFile($this->appMeta->appDir, $paths);
131
        $start = $_SERVER['REQUEST_TIME_FLOAT'];
132
        assert(is_float($start));
133
        $time = number_format(microtime(true) - $start, 2);
134
        $memory = number_format(memory_get_peak_usage() / (1024 * 1024), 3);
135
        printf("Compilation (2/2) took %f seconds and used %fMB of memory\n", $time, $memory);
136
        printf("autoload.php: %s\n", $this->getFileInfo($autolaod));
137
138
        return 0;
139
    }
140
141
    public function registerLoader(string $appDir): void
142
    {
143
        $loaderFile = $appDir . '/vendor/autoload.php';
144
        if (! file_exists($loaderFile)) {
145
            throw new RuntimeException('no loader');
146
        }
147
148
        $loader = require $loaderFile;
149
        assert($loader instanceof ClassLoader);
150
        spl_autoload_register(
151
            /** @var class-string $class */
0 ignored issues
show
Documentation introduced by
The doc-type class-string could not be parsed: Unknown type name "class-string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
152
            function (string $class) use ($loader): void {
153
                $loader->loadClass($class);
154
                if ($class !== NullPage::class) {
155
                    $this->classes[] = $class;
156
                }
157
            }
158
        );
159
    }
160
161
    public function compileDiScripts(AbstractAppMeta $appMeta): void
162
    {
163
        $reader = $this->injector->getInstance(Reader::class);
164
        assert($reader instanceof Reader);
165
        $namedParams = $this->injector->getInstance(NamedParameterInterface::class);
166
        assert($namedParams instanceof NamedParameterInterface);
167
        // create DI factory class and AOP compiled class for all resources and save $app cache.
168
        $app = $this->injector->getInstance(AppInterface::class);
169
        assert($app instanceof AppInterface);
170
171
        // check resource injection and create annotation cache
172
        $metas = $appMeta->getResourceListGenerator();
173
        /** @var array{0: string, 1:string} $meta */
174
        foreach ($metas as $meta) {
175
            [$className] = $meta;
0 ignored issues
show
Bug introduced by
The variable $className does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
176
            assert(class_exists($className));
177
            $this->scanClass($reader, $namedParams, $className);
178
        }
179
    }
180
181
    public function compileSrc(AbstractModule $module): AbstractModule
182
    {
183
        $container = $module->getContainer()->getContainer();
184
        $dependencies = array_keys($container);
185
        sort($dependencies);
186
        foreach ($dependencies as $dependencyIndex) {
187
            $pos = strpos((string) $dependencyIndex, '-');
188
            assert(is_int($pos));
189
            $interface = substr((string) $dependencyIndex, 0, $pos);
190
            $name = substr((string) $dependencyIndex, $pos + 1);
191
            $this->getInstance($interface, $name);
192
        }
193
194
        return $module;
195
    }
196
197
    private function getFileInfo(string $filename): string
198
    {
199
        if (in_array($filename, $this->overwritten, true)) {
200
            return $filename . ' (overwritten)';
201
        }
202
203
        return $filename;
204
    }
205
206
    /**
207
     * @param array<string> $paths
208
     */
209
    private function saveAutoloadFile(string $appDir, array $paths): string
210
    {
211
        $requiredFile = '';
212
        foreach ($paths as $path) {
213
            $requiredFile .= sprintf(
214
                "require %s';\n",
215
                $this->getRelativePath($appDir, $path)
216
            );
217
        }
218
219
        $autoloadFile = sprintf("<?php
220
221
// %s autoload
222
223
%s
224
require __DIR__ . '/vendor/autoload.php';
225
", $this->context, $requiredFile);
226
        $fileName = realpath($appDir) . '/autoload.php';
227
        $this->putFileContents($fileName, $autoloadFile);
228
229
        return $fileName;
230
    }
231
232
    private function compilePreload(AbstractAppMeta $appMeta, string $context): string
233
    {
234
        $this->loadResources($appMeta->name, $context, $appMeta->appDir);
235
        $paths = $this->getPaths($this->classes);
236
        $requiredOnceFile = '';
237
        foreach ($paths as $path) {
238
            $requiredOnceFile .= sprintf(
239
                "require_once %s';\n",
240
                $this->getRelativePath($appMeta->appDir, $path)
241
            );
242
        }
243
244
        $preloadFile = sprintf("<?php
245
246
// %s preload
247
248
require __DIR__ . '/vendor/autoload.php';
249
250
%s", $this->context, $requiredOnceFile);
251
        $fileName = realpath($appMeta->appDir) . '/preload.php';
252
        $this->putFileContents($fileName, $preloadFile);
253
254
        return $fileName;
255
    }
256
257
    private function getRelativePath(string $rootDir, string $file): string
258
    {
259
        $dir = (string) realpath($rootDir);
260
        if (strpos($file, $dir) !== false) {
261
            return (string) preg_replace('#^' . preg_quote($dir, '#') . '#', "__DIR__ . '", $file);
262
        }
263
264
        return $file;
265
    }
266
267
    /**
268
     * @psalm-suppress MixedFunctionCall
269
     * @psalm-suppress NoInterfaceProperties
270
     */
271
    private function invokeTypicalRequest(): void
272
    {
273
        $app = $this->injector->getInstance(AppInterface::class);
274
        assert($app instanceof AppInterface);
275
        assert(property_exists($app, 'resource'));
276
        $ro = new NullPage();
277
        $ro->uri = new Uri('app://self/');
278
        /** @psalm-suppress MixedMethodCall */
279
        $app->resource->get->object($ro)();
0 ignored issues
show
Bug introduced by
Accessing resource on the interface BEAR\Sunday\Extension\Application\AppInterface suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
280
    }
281
282
    /**
283
     * Save annotation and method meta information
284
     *
285
     * @param class-string<T> $className
0 ignored issues
show
Documentation introduced by
The doc-type class-string<T> could not be parsed: Unknown type name "class-string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
286
     *
287
     * @template T
288
     */
289
    private function scanClass(Reader $reader, NamedParameterInterface $namedParams, string $className): void
290
    {
291
        $class = new ReflectionClass($className);
292
        $instance = $class->newInstanceWithoutConstructor();
293
        if (! $instance instanceof $className) {
294
            return; // @codeCoverageIgnore
295
        }
296
297
        $reader->getClassAnnotations($class);
298
        $methods = $class->getMethods();
299
        $log = sprintf('M %s:', $className);
300
        foreach ($methods as $method) {
301
            $methodName = $method->getName();
0 ignored issues
show
Bug introduced by
Consider using $method->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
302
            if ($this->isMagicMethod($methodName)) {
303
                continue;
304
            }
305
306
            if (substr($methodName, 0, 2) === 'on') {
307
                $log .= sprintf(' %s', $methodName);
308
                $this->saveNamedParam($namedParams, $instance, $methodName);
309
            }
310
311
            // method annotation
312
            $reader->getMethodAnnotations($method);
313
            $log .= sprintf('@ %s', $methodName);
314
        }
315
316
//        echo $log . PHP_EOL;
317
    }
318
319
    private function isMagicMethod(string $method): bool
320
    {
321
        return in_array($method, ['__sleep', '__wakeup', 'offsetGet', 'offsetSet', 'offsetExists', 'offsetUnset', 'count', 'ksort', 'asort', 'jsonSerialize'], true);
322
    }
323
324
    private function saveNamedParam(NamedParameterInterface $namedParameter, object $instance, string $method): void
325
    {
326
        // named parameter
327
        if (! in_array($method, ['onGet', 'onPost', 'onPut', 'onPatch', 'onDelete', 'onHead'], true)) {
328
            return;  // @codeCoverageIgnore
329
        }
330
331
        $callable = [$instance, $method];
332
        if (! is_callable($callable)) {
333
            return;  // @codeCoverageIgnore
334
        }
335
336
        try {
337
            $namedParameter->getParameters($callable, []);
338
        } catch (ParameterException $e) {
339
            return;
340
        }
341
    }
342
343
    /**
344
     * @param array<string> $classes
345
     *
346
     * @return array<string>
347
     */
348
    private function getPaths(array $classes): array
349
    {
350
        $paths = [];
351
        foreach ($classes as $class) {
352
            // could be phpdoc tag by annotation loader
353
            if ($this->isNotAutoloadble($class)) {
354
                continue;
355
            }
356
357
            /** @var class-string $class */
0 ignored issues
show
Documentation introduced by
The doc-type class-string could not be parsed: Unknown type name "class-string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
358
            $filePath = (string) (new ReflectionClass($class))->getFileName(); // @phpstan-ignore-line
359
            if (! file_exists($filePath) || is_int(strpos($filePath, 'phar'))) {
360
                continue; // @codeCoverageIgnore
361
            }
362
363
            $paths[] = $this->getRelativePath($this->appDir, $filePath);
364
        }
365
366
        return $paths;
367
    }
368
369
    private function isNotAutoloadble(string $class): bool
370
    {
371
        return ! class_exists($class, false) && ! interface_exists($class, false) && ! trait_exists($class, false);
372
    }
373
374
    private function loadResources(string $appName, string $context, string $appDir): void
375
    {
376
        $meta = new Meta($appName, $context, $appDir);
377
378
        $resMetas = $meta->getGenerator('*');
379
        foreach ($resMetas as $resMeta) {
380
            $this->getInstance($resMeta->class);
381
        }
382
    }
383
384
    private function getInstance(string $interface, string $name = ''): void
385
    {
386
        $dependencyIndex = $interface . '-' . $name;
387
        if (in_array($dependencyIndex, $this->compiled, true)) {
388
            // @codeCoverageIgnoreStart
389
            printf("S %s:%s\n", $interface, $name);
390
391
            return;
392
393
            // @codeCoverageIgnoreEnd
394
        }
395
396
        try {
397
            $this->injector->getInstance($interface, $name);
398
            $this->compiled[] = $dependencyIndex;
399
            $this->progress('.');
400
        } catch (Unbound $e) {
401
            if ($dependencyIndex === 'Ray\Aop\MethodInvocation-') {
402
                return;
403
            }
404
405
            $this->failed[$dependencyIndex] = $e->getMessage();
406
            $this->progress('F');
407
            // @codeCoverageIgnoreStart
408
        } catch (Throwable $e) {
409
            $this->failed[$dependencyIndex] = sprintf('%s: %s', get_class($e), $e->getMessage());
410
            $this->progress('F');
411
            // @codeCoverageIgnoreEnd
412
        }
413
    }
414
415
    private function progress(string $char): void
416
    {
417
        /**
418
         * @var int
419
         */
420
        static $cnt = 0;
421
422
        echo $char;
423
        $cnt++;
424
        if ($cnt === 60) {
425
            $cnt = 0;
426
            echo PHP_EOL;
427
        }
428
    }
429
430
    private function hookNullObjectClass(string $appDir): void
431
    {
432
        $compileScript = realpath($appDir) . '/.compile.php';
433
        if (file_exists($compileScript)) {
434
            require $compileScript;
435
        }
436
    }
437
438
    private function putFileContents(string $fileName, string $content): void
439
    {
440
        if (file_exists($fileName)) {
441
            $this->overwritten[] = $fileName;
442
        }
443
444
        file_put_contents($fileName, $content);
445
    }
446
447
    private function compileObjectGraphDotFile(AbstractModule $module): string
448
    {
449
        $dotFile = sprintf('%s/module.dot', $this->appDir);
450
        $this->putFileContents($dotFile, (new ObjectGrapher())($module));
451
452
        return $dotFile;
453
    }
454
}
455