Generator   A
last analyzed

Complexity

Total Complexity 25

Size/Duplication

Total Lines 160
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
eloc 60
c 0
b 0
f 0
dl 0
loc 160
ccs 61
cts 61
cp 1
rs 10
wmc 25

6 Methods

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