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