Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.43% covered (warning)
81.43%
57 / 70
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProjectCapabilitiesResolver
81.43% covered (warning)
81.43%
57 / 70
50.00% covered (danger)
50.00%
4 / 8
41.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 resolveDefaultPackageName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveApiDirectories
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 resolveRelativeApiDirectory
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolveHasPhpSourceFiles
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
8.19
 hasPhpSourceFileInDirectory
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 normalizeAutoloadPaths
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
19.94
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\Project;
21
22use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
23use FastForward\DevTools\Filesystem\FilesystemInterface;
24use FilesystemIterator;
25use RecursiveDirectoryIterator;
26use RecursiveIteratorIterator;
27use function array_key_first;
28use function array_values;
29use function is_dir;
30use function is_file;
31use function rtrim;
32use function str_ends_with;
33use function strtolower;
34
35/**
36 * Resolves which repository surfaces are available to documentation, testing, and wiki tooling.
37 */
38  class ProjectCapabilitiesResolver implements ProjectCapabilitiesResolverInterface
39{
40    /**
41     * @var list<string> Composer autoload sections that MAY expose API source directories
42     */
43    private const array API_AUTOLOAD_TYPES = ['psr-4', 'psr-0', 'classmap'];
44
45    /**
46     * Creates a capability resolver backed by Composer autoload metadata and filesystem checks.
47     *
48     * @param ComposerJsonInterface $composer the composer.json accessor for autoload metadata
49     * @param FilesystemInterface $filesystem the filesystem used to resolve project-relative paths
50     */
51    public function __construct(
52        private ComposerJsonInterface $composer,
53        private FilesystemInterface $filesystem,
54    ) {}
55
56    /**
57     * Resolves which documentation, testing, and wiki surfaces are available for the current repository.
58     *
59     * @param string $testsPath the project-relative tests directory to inspect
60     * @param string $guideDirectory the project-relative guide directory to inspect
61     * @param string $wikiTarget the project-relative wiki output target to inspect
62     */
63    public function resolve(
64        string $testsPath = ProjectCapabilitiesResolverInterface::DEFAULT_TESTS_PATH,
65        string $guideDirectory = ProjectCapabilitiesResolverInterface::DEFAULT_GUIDE_DIRECTORY,
66        string $wikiTarget = ProjectCapabilitiesResolverInterface::DEFAULT_WIKI_TARGET,
67    ): ProjectCapabilities {
68        $psr4Autoload = $this->composer->getAutoload('psr-4');
69        $apiDirectories = $this->resolveApiDirectories();
70
71        return new ProjectCapabilities(
72            $apiDirectories,
73            $this->resolveDefaultPackageName($psr4Autoload),
74            $this->filesystem->exists($guideDirectory),
75            $this->filesystem->exists($testsPath),
76            $this->filesystem->exists($wikiTarget),
77            $this->resolveHasPhpSourceFiles($apiDirectories),
78        );
79    }
80
81    /**
82     * Resolves the default API package name from the first PSR-4 namespace entry when available.
83     *
84     * @param array<string, mixed> $psr4Autoload the PSR-4 autoload map from composer.json
85     */
86    private function resolveDefaultPackageName(array $psr4Autoload): ?string
87    {
88        $defaultPackageName = array_key_first($psr4Autoload);
89
90        if (! \is_string($defaultPackageName) || '' === $defaultPackageName) {
91            return null;
92        }
93
94        return rtrim($defaultPackageName, '\\');
95    }
96
97    /**
98     * Resolves project-relative API directories exposed by Composer autoload configuration.
99     *
100     * @return list<string>
101     */
102    private function resolveApiDirectories(): array
103    {
104        $directories = [];
105
106        foreach (self::API_AUTOLOAD_TYPES as $autoloadType) {
107            foreach ($this->normalizeAutoloadPaths($this->composer->getAutoload($autoloadType)) as $path) {
108                $relativePath = $this->resolveRelativeApiDirectory($path);
109
110                if (null === $relativePath) {
111                    continue;
112                }
113
114                $directories[$relativePath] = $relativePath;
115            }
116        }
117
118        return array_values($directories);
119    }
120
121    /**
122     * Resolves a Composer autoload path into a project-relative API directory when it exists.
123     *
124     * @param string $path the Composer autoload path candidate
125     */
126    private function resolveRelativeApiDirectory(string $path): ?string
127    {
128        $absolutePath = $this->filesystem->getAbsolutePath($path);
129
130        if (! \is_string($absolutePath)) {
131            return null;
132        }
133
134        if (! is_dir($absolutePath)) {
135            return null;
136        }
137
138        return $this->filesystem->makePathRelative($absolutePath);
139    }
140
141    /**
142     * Resolves whether Composer autoload metadata exposes testable PHP source for the repository.
143     *
144     * @param list<string> $apiDirectories the resolved API directories exposed by Composer autoload metadata
145     */
146    private function resolveHasPhpSourceFiles(array $apiDirectories): bool
147    {
148        foreach ($apiDirectories as $path) {
149            $absolutePath = $this->filesystem->getAbsolutePath($path);
150
151            if (! \is_string($absolutePath)) {
152                continue;
153            }
154
155            if ($this->hasPhpSourceFileInDirectory($absolutePath)) {
156                return true;
157            }
158        }
159
160        foreach (self::API_AUTOLOAD_TYPES as $autoloadType) {
161            foreach ($this->normalizeAutoloadPaths($this->composer->getAutoload($autoloadType)) as $path) {
162                $absolutePath = $this->filesystem->getAbsolutePath($path);
163
164                if (! \is_string($absolutePath)) {
165                    continue;
166                }
167
168                if ($this->hasPhpSourceFileInDirectory($absolutePath)) {
169                    return true;
170                }
171            }
172        }
173
174        return false;
175    }
176
177    /**
178     * Detects whether a Composer autoload path points to a PHP source file or contains one recursively.
179     *
180     * @param string $path an absolute composer autoload path
181     */
182    private function hasPhpSourceFileInDirectory(string $path): bool
183    {
184        if (is_file($path)) {
185            return str_ends_with(strtolower($path), '.php');
186        }
187
188        if (! is_dir($path)) {
189            return false;
190        }
191
192        $iterator = new RecursiveIteratorIterator(
193            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
194        );
195
196        foreach ($iterator as $file) {
197            if (! $file->isFile()) {
198                continue;
199            }
200
201            if (str_ends_with(strtolower((string) $file->getFilename()), '.php')) {
202                return true;
203            }
204        }
205
206        return false;
207    }
208
209    /**
210     * Flattens Composer autoload path definitions into a normalized list of non-empty paths.
211     *
212     * @param array<string, mixed> $autoload the Composer autoload section to normalize
213     *
214     * @return list<string>
215     */
216    private function normalizeAutoloadPaths(array $autoload): array
217    {
218        $paths = [];
219
220        foreach ($autoload as $path) {
221            if (\is_string($path) && '' !== $path) {
222                $paths[] = $path;
223
224                continue;
225            }
226
227            if (! \is_array($path)) {
228                continue;
229            }
230
231            foreach ($path as $nestedPath) {
232                if (! \is_string($nestedPath)) {
233                    continue;
234                }
235
236                if ('' === $nestedPath) {
237                    continue;
238                }
239
240                $paths[] = $nestedPath;
241            }
242        }
243
244        return $paths;
245    }
246}