Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.70% covered (success)
90.70%
39 / 43
90.48% covered (success)
90.48%
19 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
DevToolsPathResolver
90.70% covered (success)
90.70%
39 / 43
90.48% covered (success)
90.48%
19 / 21
28.63
0.00% covered (danger)
0.00%
0 / 1
 getPackagePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBinaryPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBinaryCommand
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getResourcesPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPackagePathRelativeToProject
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRuntimeAutoloadPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRuntimeToolBinaryPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRuntimeVendorPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreferredToolBinaryPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getPreferredVendorPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isInstalledAsDependency
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRepositoryCheckout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeVendorRelativePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 assertRelativePackagePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 resolvePackageRelativePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resolvePackageRoot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 resolveProjectPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRuntimeVendorRoot
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getProjectVendorPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preferExistingPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 relativizePathFromProject
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Path;
21
22use InvalidArgumentException;
23use Symfony\Component\Filesystem\Path;
24
25/**
26 * Resolves canonical paths for the DevTools package itself.
27 */
28 class DevToolsPathResolver
29{
30    /**
31     * @var string the relative path to the packaged DevTools binary
32     */
33    public const string BINARY = 'bin/dev-tools';
34
35    /**
36     * @var string the resources directory segment within the package
37     */
38    public const string RESOURCES = 'resources';
39
40    /**
41     * @var string the vendor install path fragment used when DevTools runs as a dependency
42     */
43    private const string VENDOR_PACKAGE_PATH = '/vendor/fast-forward/dev-tools';
44
45    /**
46     * Returns the DevTools package directory or a path under it.
47     *
48     * @param string $path the optional relative segment to append under the package directory
49     */
50    public static function getPackagePath(string $path = ''): string
51    {
52        return self::resolvePackageRelativePath($path);
53    }
54
55    /**
56     * Returns the packaged DevTools binary path.
57     */
58    public static function getBinaryPath(): string
59    {
60        return self::getPackagePath(self::BINARY);
61    }
62
63    /**
64     * Returns the packaged DevTools binary command with a subcommand.
65     *
66     * @param string $command the DevTools subcommand to append
67     */
68    public static function getBinaryCommand(string $command): string
69    {
70        $binaryPath = self::getPackagePath(self::BINARY);
71
72        return \sprintf('%s %s', $binaryPath, $command);
73    }
74
75    /**
76     * Returns the packaged resources directory or a path under it.
77     *
78     * @param string $path the optional relative segment to append under resources
79     */
80    public static function getResourcesPath(string $path = ''): string
81    {
82        return self::getPackagePath(Path::join(self::RESOURCES, $path));
83    }
84
85    /**
86     * Returns a packaged path rendered relative to the active project root when possible.
87     *
88     * When the project root and package root do not share a filesystem root,
89     * the packaged absolute path MUST be returned unchanged so globally
90     * installed DevTools can still point hooks at the packaged fallback file.
91     *
92     * @param string $path the relative path under the package root
93     * @param string $projectPath an optional project root path; defaults to the working project root
94     * @param string $packagePath an optional package root path; defaults to the current package root
95     */
96    public static function getPackagePathRelativeToProject(
97        string $path,
98        string $projectPath = '',
99        string $packagePath = '',
100    ): string {
101        return self::relativizePathFromProject(
102            self::resolvePackageRelativePath($path, $packagePath),
103            self::resolveProjectPath($projectPath),
104        );
105    }
106
107    /**
108     * Returns the active Composer autoload file for the current DevTools installation mode.
109     *
110     * When DevTools runs as a dependency, the runtime autoloader lives at the
111     * Composer vendor root. Repository checkouts instead use the package-local
112     * `vendor/autoload.php`.
113     *
114     * @param string $packagePath an optional package root path; defaults to the current package root
115     */
116    public static function getRuntimeAutoloadPath(string $packagePath = ''): string
117    {
118        return Path::join(self::getRuntimeVendorRoot($packagePath), 'autoload.php');
119    }
120
121    /**
122     * Returns the active Composer runtime binary path for the current DevTools installation mode.
123     *
124     * Repository checkouts use the package-local `vendor/bin/<binary>`, while
125     * dependency installs resolve binaries from the active Composer vendor root.
126     *
127     * @param string $binary the binary name relative to `vendor/bin`
128     * @param string $packagePath an optional package root path; defaults to the current package root
129     */
130    public static function getRuntimeToolBinaryPath(string $binary, string $packagePath = ''): string
131    {
132        return self::getRuntimeVendorPath(Path::join('bin', $binary), $packagePath);
133    }
134
135    /**
136     * Returns the active Composer vendor path for the current DevTools installation mode.
137     *
138     * Relative vendor paths MAY be passed either with or without a leading
139     * `vendor/` prefix.
140     *
141     * @param string $path the vendor-relative path to resolve
142     * @param string $packagePath an optional package root path; defaults to the current package root
143     */
144    public static function getRuntimeVendorPath(string $path, string $packagePath = ''): string
145    {
146        return Path::join(self::getRuntimeVendorRoot($packagePath), self::normalizeVendorRelativePath($path));
147    }
148
149    /**
150     * Returns the preferred tooling binary path for the active project and DevTools runtime.
151     *
152     * Consumer projects SHOULD take precedence when they provide a local
153     * `vendor/bin/<binary>` entry. If the binary is absent locally, the method
154     * MUST fall back to the active DevTools runtime binary path.
155     *
156     * @param string $binary the binary name relative to `vendor/bin`
157     * @param string $projectPath an optional project root path; defaults to the working project root
158     * @param string $packagePath an optional package root path; defaults to the current package root
159     */
160    public static function getPreferredToolBinaryPath(
161        string $binary,
162        string $projectPath = '',
163        string $packagePath = '',
164    ): string {
165        return self::preferExistingPath(
166            self::getProjectVendorPath(Path::join('bin', $binary), $projectPath),
167            self::getRuntimeToolBinaryPath($binary, $packagePath),
168        );
169    }
170
171    /**
172     * Returns the preferred Composer vendor path for the active project and DevTools runtime.
173     *
174     * Consumer projects SHOULD take precedence when they provide the requested
175     * vendor path locally. If the path is absent locally, the method MUST fall
176     * back to the active DevTools runtime vendor path.
177     *
178     * @param string $path the vendor-relative path to resolve
179     * @param string $projectPath an optional project root path; defaults to the working project root
180     * @param string $packagePath an optional package root path; defaults to the current package root
181     */
182    public static function getPreferredVendorPath(
183        string $path,
184        string $projectPath = '',
185        string $packagePath = '',
186    ): string {
187        return self::preferExistingPath(
188            self::getProjectVendorPath($path, $projectPath),
189            self::getRuntimeVendorPath($path, $packagePath),
190        );
191    }
192
193    /**
194     * Detects whether the provided path belongs to an installed vendor copy of DevTools.
195     *
196     * @param string $packagePath an optional path within the package; defaults to the package root
197     */
198    public static function isInstalledAsDependency(string $packagePath = ''): bool
199    {
200        return str_contains(self::resolvePackageRoot($packagePath), self::VENDOR_PACKAGE_PATH);
201    }
202
203    /**
204     * Detects whether the provided path belongs to the DevTools repository checkout itself.
205     *
206     * @param string $packagePath an optional path within the package; defaults to the package root
207     */
208    public static function isRepositoryCheckout(string $packagePath = ''): bool
209    {
210        return ! self::isInstalledAsDependency($packagePath);
211    }
212
213    /**
214     * Normalizes a path relative to the Composer vendor root.
215     *
216     * @param string $path the vendor-relative path to normalize
217     */
218    private static function normalizeVendorRelativePath(string $path): string
219    {
220        $path = Path::canonicalize($path);
221
222        if (str_starts_with($path, 'vendor/')) {
223            return substr($path, 7);
224        }
225
226        return $path;
227    }
228
229    /**
230     * Ensures packaged paths stay relative to the DevTools package root.
231     *
232     * @param string $path the package-relative path to validate
233     */
234    private static function assertRelativePackagePath(string $path): void
235    {
236        if ('' !== $path && Path::isAbsolute($path)) {
237            throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.');
238        }
239    }
240
241    /**
242     * Returns a canonical path under the DevTools package root.
243     *
244     * @param string $path the package-relative path to resolve
245     * @param string $packagePath an optional package root path; defaults to the current package root
246     */
247    private static function resolvePackageRelativePath(string $path = '', string $packagePath = ''): string
248    {
249        self::assertRelativePackagePath($path);
250
251        return Path::canonicalize(Path::join(self::resolvePackageRoot($packagePath), $path));
252    }
253
254    /**
255     * Returns the canonical DevTools package root.
256     *
257     * @param string $packagePath an optional package root path; defaults to the current package root
258     */
259    private static function resolvePackageRoot(string $packagePath = ''): string
260    {
261        return Path::canonicalize('' === $packagePath ? \dirname(__DIR__, 2) : $packagePath);
262    }
263
264    /**
265     * Returns the canonical working project root.
266     *
267     * @param string $projectPath an optional project root path; defaults to the working project root
268     */
269    private static function resolveProjectPath(string $projectPath = ''): string
270    {
271        return Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath));
272    }
273
274    /**
275     * Returns the active Composer vendor root for the current DevTools installation mode.
276     *
277     * @param string $packagePath an optional package root path; defaults to the current package root
278     */
279    private static function getRuntimeVendorRoot(string $packagePath = ''): string
280    {
281        $packagePath = self::resolvePackageRoot($packagePath);
282
283        if (self::isInstalledAsDependency($packagePath)) {
284            return Path::canonicalize(Path::join($packagePath, '..', '..'));
285        }
286
287        return Path::join($packagePath, 'vendor');
288    }
289
290    /**
291     * Returns a vendor path under the active project root.
292     *
293     * @param string $path the vendor-relative path to resolve
294     * @param string $projectPath an optional project root path; defaults to the working project root
295     */
296    private static function getProjectVendorPath(string $path, string $projectPath = ''): string
297    {
298        return Path::join(self::resolveProjectPath($projectPath), 'vendor', self::normalizeVendorRelativePath($path));
299    }
300
301    /**
302     * Returns the preferred path when a project-local candidate exists.
303     *
304     * @param string $preferredPath the project-local candidate path
305     * @param string $fallbackPath the runtime fallback path
306     */
307    private static function preferExistingPath(string $preferredPath, string $fallbackPath): string
308    {
309        if (file_exists($preferredPath)) {
310            return $preferredPath;
311        }
312
313        return $fallbackPath;
314    }
315
316    /**
317     * Returns a path relative to the project root when possible.
318     *
319     * When paths do not share the same filesystem root, the original absolute
320     * path MUST be returned unchanged so callers still receive a usable path.
321     *
322     * @param string $path the absolute path to relativize
323     * @param string $projectPath the absolute project root used as base path
324     */
325    private static function relativizePathFromProject(string $path, string $projectPath): string
326    {
327        try {
328            return Path::makeRelative($path, $projectPath);
329        } catch (InvalidArgumentException) {
330            return $path;
331        }
332    }
333}