Generator   A
last analyzed

Complexity

Total Complexity 23

Size/Duplication

Total Lines 162
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 55
c 1
b 0
f 0
dl 0
loc 162
ccs 57
cts 57
cp 1
rs 10
wmc 23

6 Methods

Rating   Name   Duplication   Size   Complexity  
B structureEndpoints() 0 35 8
A scriptExists() 0 5 3
A splitPath() 0 19 4
A generate() 0 19 5
A __construct() 0 3 1
A tryFs() 0 18 2
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 8
    public function __construct(callable $generate = null)
29
    {
30 8
        $this->generateCode = $generate ?? new GenerateFunction();
31
    }
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 7
    public function generate(string $name, string $file, callable $getRoutes, bool $overwrite): void
43
    {
44 7
        if (!$overwrite && $this->scriptExists($file)) {
45 1
            return;
46
        }
47
48 6
        $routes = $getRoutes();
49 6
        $structure = $this->structureEndpoints($routes);
50
51 5
        $code = ($this->generateCode)($name, $routes, $structure);
52 5
        if (!is_string($code)) {
53 1
            throw new \LogicException("Expected code as string, got " . gettype($code));
54
        }
55
56 4
        if (!is_dir(dirname($file))) {
57 2
            $this->tryFs('mkdir', dirname($file), self::DIR_MODE, true);
58
        }
59
60 3
        $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 5
    protected function tryFs(callable $fn, ...$args)
71
    {
72 5
        $level = error_reporting();
73 5
        error_reporting($level ^ (E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE));
74
75 5
        error_clear_last();
76
77
        try {
78 5
            $ret = $fn(...$args);
79 5
        } finally {
80 5
            error_reporting($level);
81
        }
82
83 5
        if ($ret === false) {
84 2
            throw new \RuntimeException(error_get_last()['message'] ?? "Unknown error");
85
        }
86
87 3
        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 3
    protected function scriptExists(string $file): bool
100
    {
101
        /** @noinspection PhpComposerExtensionStubsInspection */
102 3
        return (function_exists('opcache_is_script_cached') && opcache_is_script_cached($file))
103 3
            || 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 7
    protected function structureEndpoints(iterable $routes): array
114
    {
115 7
        $structure = [];
116
117 7
        foreach ($routes as $key => $route) {
118 7
            if ($key === 'default') {
119 6
                $structure["\e"] = (new Endpoint(''))->withRoute('', $route, []);
120 6
                continue;
121
            }
122
123 7
            $match = Regex::match('~^\s*(?P<methods>\w+(?:\|\w+)*)\s+(?P<path>/\S*)\s*$~', $key);
124
125 7
            if (!is_string($key) || !$match->hasMatch()) {
126 1
                throw new InvalidRouteException("Invalid routing key '$key': should be 'METHOD /path'");
127
            }
128
129 6
            $methods = $match->namedGroup('methods');
130 6
            [$segments, $vars] = $this->splitPath($match->namedGroup('path'));
131
132 6
            $pointer =& $structure;
133 6
            foreach ($segments as $segment) {
134 6
                $pointer[$segment] = $pointer[$segment] ?? [];
135 6
                $pointer =& $pointer[$segment];
136
            }
137
138 6
            if (!isset($pointer["\0"])) {
139 6
                $pointer["\0"] = new Endpoint('/' . join('/', $segments));
140
            }
141
142 6
            foreach (explode('|', $methods) as $method) {
143 6
                $pointer["\0"] = $pointer["\0"]->withRoute($method, $route, $vars);
144
            }
145
        }
146
147 6
        return $structure;
148
    }
149
150
    /**
151
     * Split path into segments and extract variables.
152
     *
153
     * @param string $path
154
     * @return array[]
155
     */
156 6
    protected function splitPath(string $path): array
157
    {
158 6
        if ($path === '/') {
159 6
            return [[], []];
160
        }
161
162 6
        $segments = explode('/', substr($path, 1));
163 6
        $vars = [];
164
165 6
        foreach ($segments as $index => &$segment) {
166 6
            $match = Regex::match('/^(?|:(?P<var>\w+)|\{(?P<var>\w+)\})$/', $segment);
167
168 6
            if ($match->hasMatch()) {
169 6
                $vars[$match->namedGroup('var')] = $index;
170 6
                $segment = '*';
171
            }
172
        }
173
174 6
        return [$segments, $vars];
175
    }
176
}
177