Passed
Push — master ( a8509b...45146e )
by Arnold
03:21
created

Generator::splitPath()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 10
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 19
ccs 11
cts 11
cp 1
crap 4
rs 9.9332
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\SwitchRoute;
6
7
use Jasny\SwitchRoute\Generator\GenerateFunction;
8
use Spatie\Regex\Regex;
9
10
/**
11
 * Generate a PHP script for HTTP routing.
12
 */
13
class Generator
14
{
15
    private const DIR_MODE = 0755;
16
17
    /**
18
     * @var callable
19
     */
20
    protected $generateCode;
21
22
23
    /**
24
     * Generator constructor.
25
     *
26
     * @param callable $generate  Callback to generate code from structure.
27
     */
28 1
    public function __construct(callable $generate = null)
29
    {
30 1
        $this->generateCode = $generate ?? new GenerateFunction();
31 1
    }
32
33
    /**
34
     * Generate a switch script based on the routes.
35
     *
36
     * @param string   $name       Class or function name that should be generated.
37
     * @param string   $file       Filename to store the script.
38
     * @param callable $getRoutes  Callback to get an array with the routes.
39
     * @param bool     $overwrite  Overwrite existing file.
40
     * @throws \RuntimeException if file could not be created.
41
     */
42
    public function generate(string $name, string $file, callable $getRoutes, bool $overwrite): void
43
    {
44
        if (!$overwrite && $this->scriptExists($file)) {
45
            return;
46
        }
47
48
        $routes = $getRoutes();
49
        $structure = $this->structureEndpoints($routes);
50
51
        $code = ($this->generateCode)($name, $routes, $structure);
52
        if (!is_string($code)) {
53
            throw new \LogicException("Expected code as string, got " . gettype($code));
54
        }
55
56
        if (!is_dir(dirname($file))) {
57
            $this->tryFs('mkdir', dirname($file), self::DIR_MODE, true);
58
        }
59
60
        $this->tryFs('file_put_contents', $file, $code);
61
    }
62
63
    /**
64
     * Try a file system function and throw a \RuntimeException on failure.
65
     *
66
     * @param callable $fn
67
     * @param mixed    ...$args
68
     * @return mixed
69
     */
70 1
    protected function tryFs(callable $fn, ...$args)
71
    {
72 1
        $level = error_reporting();
73 1
        error_reporting($level ^ (E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE));
74
75 1
        error_clear_last();
76
77
        try {
78 1
            $ret = $fn(...$args);
79 1
        } finally {
80 1
            error_reporting($level);
81
        }
82
83 1
        if ($ret === false) {
84
            throw new \RuntimeException(error_get_last()['message'] ?? "Unknown error");
85
        }
86
87 1
        return $ret;
88
    }
89
90
    /**
91
     * Check if the script exists.
92
     * Uses `opcache_is_script_cached` to prevent an unnecessary filesystem read.
93
     *
94
     * {@internal opcache isn't easily testable and mocking `opcache_is_script_cached` doesn't seem that useful.}}
95
     *
96
     * @param string $file
97
     * @return bool
98
     */
99 1
    protected function scriptExists(string $file): bool
100
    {
101
        /** @noinspection PhpComposerExtensionStubsInspection */
102 1
        return (function_exists('opcache_is_script_cached') && opcache_is_script_cached($file))
103 1
            || file_exists($file);
104
    }
105
106
    /**
107
     * Create a structure with a leaf for each endpoint.
108
     *
109
     * @param iterable $routes
110
     * @return array
111
     * @throws InvalidRouteException
112
     */
113 1
    protected function structureEndpoints(iterable $routes): array
114
    {
115 1
        $structure = [];
116
117 1
        foreach ($routes as $key => $route) {
118 1
            if ($key === 'default') {
119 1
                $structure["\e"] = (new Endpoint(''))->withRoute('', $route, []);
120 1
                continue;
121
            }
122
123 1
            $match = Regex::match('~^\s*(?P<methods>\w+(?:\|\w+)*)\s+(?P<path>/\S*)\s*$~', $key);
124
125 1
            if (!is_string($key) || !$match->hasMatch()) {
126
                throw new InvalidRouteException("Invalid routing key '$key': should be 'METHOD /path'");
127
            }
128
129 1
            $methods = $match->namedGroup('methods');
130 1
            [$segments, $vars] = $this->splitPath($match->namedGroup('path'));
131
132 1
            $pointer =& $structure;
133 1
            foreach ($segments as $segment) {
134 1
                $pointer[$segment] = $pointer[$segment] ?? [];
135 1
                $pointer =& $pointer[$segment];
136
            }
137
138 1
            if (!isset($pointer["\0"])) {
139 1
                $pointer["\0"] = new Endpoint('/' . join('/', $segments));
140
            }
141
142 1
            foreach (explode('|', $methods) as $method) {
143 1
                $pointer["\0"] = $pointer["\0"]->withRoute($method, $route, $vars);
144
            }
145
        }
146
147 1
        return $structure;
148
    }
149
150
    /**
151
     * Split path into segments and extract variables.
152
     *
153
     * @param string $path
154
     * @return array[]
155
     */
156 1
    protected function splitPath(string $path): array
157
    {
158 1
        if ($path === '/') {
159 1
            return [[], []];
160
        }
161
162 1
        $segments = explode('/', substr($path, 1));
163 1
        $vars = [];
164
165 1
        foreach ($segments as $index => &$segment) {
166 1
            $match = Regex::match('/^(?|:(?P<var>\w+)|\{(?P<var>\w+)\})$/', $segment);
167
168 1
            if ($match->hasMatch()) {
169 1
                $vars[$match->namedGroup('var')] = $index;
170 1
                $segment = '*';
171
            }
172
        }
173
174 1
        return [$segments, $vars];
175
    }
176
}
177