Lines 90.00% 54 / 60
Methods 25.00% 1 / 4
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 resolve 100.00% 23 / 23 100.00% 1 / 1 6
 mergePaths 71.42% 5 / 7 0.00% 0 / 1 4.37
 removeDotSegments 88.46% 23 / 26 0.00% 0 / 1 11.19
 removeLastSegment 75.00% 3 / 4 0.00% 0 / 1 2.06
20final class UriResolver
21{
22    /**
23     * Resolve a URI reference against a base URI.
24     *
25     * The result is built by cloning `$rel` and applying `with*` mutators
26     * conditionally โ€” no new URI is ever constructed from scratch.
27     *
28     * @param UriInterface $base The base URI (e.g. the configured `base_uri`)
29     * @param UriInterface $rel  The URI reference to resolve (e.g. the request URI)
30     */
31    public static function resolve(UriInterface $base, UriInterface $rel): UriInterface
32    {
33        // ยง5.2.2: if the reference has a scheme, it is an absolute URI and
34        // overrides the base entirely.
35        if ($rel->getScheme() !== '') {
36            return $rel->withPath(self::removeDotSegments($rel->getPath()));
37        }
38
39        $result = $rel;
40
41        // Network-path reference ("//host/path") โ€” inherit the scheme from
42        // the base, but keep the reference's own authority.
43        if ($rel->getHost() !== '') {
44            return $result
45                ->withScheme($base->getScheme())
46                ->withPath(self::removeDotSegments($rel->getPath()))
47                ->withQuery($rel->getQuery());
48        }
49
50        // Relative reference โ€” inherit scheme and authority from the base.
51        $result = $result
52            ->withScheme($base->getScheme())
53            ->withUserInfo($base->getUserInfo())
54            ->withHost($base->getHost())
55            ->withPort($base->getPort());
56
57        // ยง5.2.2: empty path โ€” take the base path; query from the reference
58        // if present, else from the base. Fragment always comes from $rel
59        // (preserved by cloning).
60        if ($rel->getPath() === '') {
61            return $result
62                ->withPath($base->getPath())
63                ->withQuery($rel->getQuery() !== '' ? $rel->getQuery() : $base->getQuery());
64        }
65
66        // Absolute path ("/x") โ€” use as-is; rootless path ("x") โ€” merge
67        // with the base's directory.
68        if ($rel->getPath()[0] === '/') {
69            $targetPath = self::removeDotSegments($rel->getPath());
70        } else {
71            $targetPath = self::removeDotSegments(self::mergePaths($base, $rel->getPath()));
72        }
73
74        return $result
75            ->withPath($targetPath)
76            ->withQuery($rel->getQuery());
77    }
78
79    /**
80     * ยง5.2.3: merge a rootless reference path with the base path.
81     *
82     * If the base has an authority and an empty path, the result is "/" + R.path.
83     * Otherwise, take the base path up to and including its rightmost "/"
84     * (i.e. strip the last segment) and append R.path.
85     */
86    private static function mergePaths(UriInterface $base, string $relPath): string
87    {
88        $basePath = $base->getPath();
89
90        if ($base->getHost() !== '' && $basePath === '') {
91            return '/' . $relPath;
92        }
93
94        $lastSlash = strrpos($basePath, '/');
95        if ($lastSlash === false) {
96            return $relPath;
97        }
98
99        return substr($basePath, 0, $lastSlash + 1) . $relPath;
100    }
101
102    /**
103     * ยง5.2.4: remove dot segments ("." and "..") from a path.
104     *
105     * Implements the RFC 3986 algorithm directly.
106     */
107    private static function removeDotSegments(string $path): string
108    {
109        $output = '';
110
111        while ($path !== '') {
112            if (str_starts_with($path, '../')) {
113                $path = substr($path, 3);
114            } elseif (str_starts_with($path, './')) {
115                $path = substr($path, 2);
116            } elseif (str_starts_with($path, '/./')) {
117                $path = '/' . substr($path, 3);
118            } elseif ($path === '/.') {
119                $path = '/';
120            } elseif (str_starts_with($path, '/../')) {
121                $path = '/' . substr($path, 4);
122                $output = self::removeLastSegment($output);
123            } elseif ($path === '/..') {
124                $path = '/';
125                $output = self::removeLastSegment($output);
126            } elseif ($path === '.' || $path === '..') {
127                $path = '';
128            } else {
129                // Move the first path segment (including its leading "/"
130                // if any) to the output buffer.
131                $next = strpos($path, '/', 1);
132                if ($next === false) {
133                    $segment = $path;
134                    $path = '';
135                } else {
136                    $segment = substr($path, 0, $next);
137                    $path = substr($path, $next);
138                }
139                $output .= $segment;
140            }
141        }
142
143        return $output;
144    }
145
146    /**
147     * Remove the last path segment and its preceding "/" from the output buffer.
148     */
149    private static function removeLastSegment(string $output): string
150    {
151        $pos = strrpos($output, '/');
152        if ($pos === false) {
153            return '';
154        }
155
156        return substr($output, 0, $pos);
157    }
158}