Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.97% covered (success)
95.97%
119 / 124
80.00% covered (warning)
80.00%
20 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComposerJson
95.97% covered (success)
95.97%
119 / 124
80.00% covered (warning)
80.00%
20 / 25
64
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getKeywords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHomepage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReadme
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTime
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getLicense
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getAuthors
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getSupport
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getFunding
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getAutoload
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getAutoloadDev
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getMinimumStability
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getScripts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getExtra
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getBin
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getSuggest
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getComments
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 readComposerJsonFile
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 readComposerInstalledManifest
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 decodeJson
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
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\Composer\Json;
21
22use RuntimeException;
23use Composer\InstalledVersions;
24use DateTimeImmutable;
25use FastForward\DevTools\Composer\Json\Schema\Author;
26use FastForward\DevTools\Composer\Json\Schema\AuthorInterface;
27use FastForward\DevTools\Composer\Json\Schema\Funding;
28use FastForward\DevTools\Composer\Json\Schema\Support;
29use FastForward\DevTools\Composer\Json\Schema\SupportInterface;
30use FastForward\DevTools\Path\WorkingProjectPathResolver;
31use UnderflowException;
32use function Safe\file_get_contents;
33use function Safe\json_decode;
34
35/**
36 * Represents a specialized reader for a Composer JSON file.
37 *
38 * This class SHALL provide convenient accessors for commonly used
39 * `composer.json` metadata after reading and caching the file contents.
40 * Consumers SHOULD use this class when they need normalized access to
41 * package-level metadata. The internal data cache MUST reflect the
42 * contents returned by the underlying JSON file reader at construction
43 * time.
44 */
45 class ComposerJson implements ComposerJsonInterface
46{
47    /**
48     * Stores the decoded Composer JSON document contents.
49     *
50     * This property MUST contain the data read from the target Composer
51     * file during construction. Consumers SHOULD treat the structure as
52     * internal implementation detail and SHALL rely on accessor methods
53     * instead of direct access.
54     *
55     * @var array<string, mixed>
56     */
57    private array $data;
58
59    /**
60     * Stores the installed packages configuration.
61     *
62     * This property MUST contain the data read from the installed packages
63     * configuration file during construction. Consumers SHOULD treat the
64     * structure as internal implementation detail and SHALL rely on accessor
65     * methods instead of direct access.
66     *
67     * @var array<string, mixed>
68     */
69    private array $installed;
70
71    /**
72     * Initializes the Composer JSON reader.
73     *
74     * When no path is provided, the default Composer file location
75     * returned by Composer's factory SHALL be used. The constructor MUST
76     * immediately read and cache the JSON document contents so that
77     * subsequent accessor methods can operate on the in-memory data.
78     *
79     * @param string|null $path The absolute or relative path to a
80     *                          Composer JSON file. When omitted, the
81     *                          default Composer file path SHALL be used.
82     *
83     * @throws RuntimeException when $path is'nt provided and COMPOSER environment variable is set to a directory
84     * @throws RuntimeException when composer manifest files cannot be read or parsed
85     */
86    public function __construct(?string $path = null)
87    {
88        $pathLocal = WorkingProjectPathResolver::getProjectPath('composer.json');
89
90        $path ??= $pathLocal;
91        $installedJsonPath = \dirname($pathLocal) . '/vendor/composer/installed.json';
92
93        $this->data = $this->readComposerJsonFile($path);
94        $this->installed = $this->readComposerInstalledManifest($installedJsonPath);
95    }
96
97    /**
98     * Returns the package name declared in the Composer file.
99     *
100     * This method SHALL return the value of the `name` key when present.
101     * If the package name is not defined, the method MUST return an
102     * empty string.
103     *
104     * @return string the package name, or an empty string when undefined
105     */
106    public function getName(): string
107    {
108        return $this->data['name'];
109    }
110
111    /**
112     * Returns the package description declared in the Composer file.
113     *
114     * This method SHALL return the value of the `description` key when
115     * present. If the description is not defined, the method MUST return
116     * an empty string.
117     *
118     * @return string the package description, or an empty string when undefined
119     */
120    public function getDescription(): string
121    {
122        return $this->data['description'];
123    }
124
125    /**
126     * Returns the package version.
127     *
128     * This method SHOULD return the installed package version when it can be
129     * resolved through Composer's installed versions metadata. When that value
130     * cannot be resolved, the method SHALL fall back to the `version` value
131     * declared in the Composer file. If neither source provides a usable value,
132     * the method MUST return an empty string.
133     *
134     * @return string the package version, or an empty string when undefined
135     */
136    public function getVersion(): string
137    {
138        return $this->data['version'] ?? InstalledVersions::getVersion($this->getName());
139    }
140
141    /**
142     * Returns the package type declared in the Composer file.
143     *
144     * This method SHALL return the value of the `type` key when present.
145     * If the package type is not defined, the method MUST return an empty
146     * string.
147     *
148     * @return string the package type, or an empty string when undefined
149     */
150    public function getType(): string
151    {
152        return $this->data['type'] ?? 'library';
153    }
154
155    /**
156     * Returns the package keywords declared in the Composer file.
157     *
158     * This method SHALL return the `keywords` values in declaration order
159     * whenever available. Non-string values MUST be ignored. If the section
160     * is absent, the method MUST return an empty array.
161     *
162     * @return array<int, string> the package keywords, or an empty array when undefined
163     */
164    public function getKeywords(): array
165    {
166        return $this->data['keywords'] ?? [];
167    }
168
169    /**
170     * Returns the package homepage URL declared in the Composer file.
171     *
172     * This method SHALL return the value of the `homepage` key when present.
173     * If the homepage is not defined, the method MUST return an empty string.
174     *
175     * @return string the homepage URL, or an empty string when undefined
176     */
177    public function getHomepage(): string
178    {
179        return $this->data['homepage'] ?? '';
180    }
181
182    /**
183     * Returns the readme path or reference declared in the Composer file.
184     *
185     * This method SHALL return the value of the `readme` key when present.
186     * If the readme value is not defined, the method MUST return an empty
187     * string.
188     *
189     * @return string the readme value, or an empty string when undefined
190     */
191    public function getReadme(): string
192    {
193        return $this->data['readme'] ?? '';
194    }
195
196    /**
197     * Returns the package time metadata as an immutable date-time instance.
198     *
199     * This method SHALL attempt to create a DateTimeImmutable instance from the
200     * `time` field. When the field is not present or is not a valid date-time
201     * string, the current immutable date-time SHALL be returned.
202     *
203     * @return DateTimeImmutable|null the package time metadata as an immutable date-time value
204     */
205    public function getTime(): ?DateTimeImmutable
206    {
207        $packages = $this->installed['packages'] ?? [];
208
209        if (isset($packages[$this->getName()])) {
210            return new DateTimeImmutable($packages[$this->getName()]['time']);
211        }
212
213        if (isset($this->data['time'])) {
214            return new DateTimeImmutable($this->data['time']);
215        }
216
217        return null;
218    }
219
220    /**
221     * Returns the package license when it can be resolved to a single value.
222     *
223     * This method SHALL return the `license` value directly when it is a
224     * string. When the license is an array containing exactly one item,
225     * that single item SHALL be returned. When the license field is not
226     * present, is empty, or cannot be resolved to exactly one string
227     * value, the method MUST return null.
228     *
229     * @return string|null the resolved license identifier, or null when no
230     *                     single license value can be determined
231     */
232    public function getLicense(): ?string
233    {
234        $license = $this->data['license'] ?? [];
235
236        if (\is_string($license)) {
237            return $license;
238        }
239
240        if (\is_array($license) && 1 === \count($license) && \is_string($license[0] ?? null)) {
241            return $license[0];
242        }
243
244        return null;
245    }
246
247    /**
248     * Returns the package authors declared in the Composer file.
249     *
250     * This method SHALL normalize each author entry to an AuthorInterface
251     * implementation. When `$onlyFirstAuthor` is `true`, the first normalized
252     * author MUST be returned. If no author is declared, an UnderflowException
253     * SHALL be thrown. When `$onlyFirstAuthor` is `false`, all normalized
254     * authors MUST be returned as an iterable.
255     *
256     * @param bool $onlyFirstAuthor determines whether only the first declared
257     *                              author SHALL be returned instead of the full
258     *                              author list
259     *
260     * @return AuthorInterface|iterable<int, AuthorInterface> the first declared
261     *                                                        author when
262     *                                                        `$onlyFirstAuthor`
263     *                                                        is `true`, or the full
264     *                                                        authors list when
265     *                                                        `$onlyFirstAuthor`
266     *                                                        is `false`
267     */
268    public function getAuthors(bool $onlyFirstAuthor = false): AuthorInterface|iterable
269    {
270        $authors = array_map(static fn(array $author): Author => new Author(
271            $author['name'] ?? '',
272            $author['email'] ?? '',
273            $author['homepage'] ?? '',
274            $author['role'] ?? '',
275        ), $this->data['authors'] ?? []);
276
277        if ($onlyFirstAuthor) {
278            if ([] === $authors) {
279                throw new UnderflowException('No author entries were declared in the Composer file.');
280            }
281
282            return $authors[0];
283        }
284
285        return $authors;
286    }
287
288    /**
289     * Returns the support metadata declared in the Composer file.
290     *
291     * This method SHALL return a SupportInterface implementation built from
292     * the `support` section. When the section is absent, an empty support
293     * object MUST be returned.
294     *
295     * @return SupportInterface the support metadata object
296     */
297    public function getSupport(): SupportInterface
298    {
299        $support = $this->data['support'] ?? [];
300
301        if (! \is_array($support)) {
302            $support = [];
303        }
304
305        return new Support(
306            $support['email'] ?? '',
307            $support['issues'] ?? '',
308            $support['forum'] ?? '',
309            $support['wiki'] ?? '',
310            $support['irc'] ?? '',
311            $support['source'] ?? '',
312            $support['docs'] ?? '',
313            $support['rss'] ?? '',
314            $support['chat'] ?? '',
315            $support['security'] ?? '',
316        );
317    }
318
319    /**
320     * Returns the funding entries declared in the Composer file.
321     *
322     * This method SHALL normalize each funding entry into a Funding value
323     * object. Invalid or non-array entries MUST be ignored. If the section
324     * is absent, the method MUST return an empty array.
325     *
326     * @return array<int, Funding> the funding entries, or an empty array when undefined
327     */
328    public function getFunding(): array
329    {
330        $funding = $this->data['funding'] ?? [];
331
332        if (! \is_array($funding)) {
333            return [];
334        }
335
336        $entries = [];
337
338        foreach ($funding as $entry) {
339            if (! \is_array($entry)) {
340                continue;
341            }
342
343            $entries[] = new Funding($entry['type'] ?? '', $entry['url'] ?? '');
344        }
345
346        return $entries;
347    }
348
349    /**
350     * Returns the autoload configuration for the requested autoload type.
351     *
352     * This method SHALL inspect the `autoload` section and return the
353     * nested configuration for the requested type, such as `psr-4`.
354     * When the `autoload` section or the requested type is not defined,
355     * the method MUST return an empty array.
356     *
357     * @param string|null $type The autoload mapping type to retrieve. This
358     *                          defaults to the complete section when null.
359     *
360     * @return array<string, mixed> the autoload configuration for the requested
361     *                              type, or an empty array when unavailable
362     */
363    public function getAutoload(?string $type = null): array
364    {
365        $autoload = $this->data['autoload'] ?? [];
366
367        if (! \is_array($autoload)) {
368            return [];
369        }
370
371        if (null === $type) {
372            return $autoload;
373        }
374
375        $mapping = $autoload[$type] ?? [];
376
377        return \is_array($mapping) ? $mapping : [];
378    }
379
380    /**
381     * Returns the development autoload configuration for the requested type.
382     *
383     * This method SHALL inspect the `autoload-dev` section and return the
384     * nested configuration for the requested type. When the section or the
385     * requested type is not defined, the method MUST return an empty array.
386     *
387     * @param string|null $type The development autoload mapping type to
388     *                          retrieve. This defaults to the complete section
389     *                          when null.
390     *
391     * @return array<string, mixed> the autoload-dev configuration for the
392     *                              requested type, or an empty array when unavailable
393     */
394    public function getAutoloadDev(?string $type = null): array
395    {
396        $autoloadDev = $this->data['autoload-dev'] ?? [];
397
398        if (! \is_array($autoloadDev)) {
399            return [];
400        }
401
402        if (null === $type) {
403            return $autoloadDev;
404        }
405
406        $mapping = $autoloadDev[$type] ?? [];
407
408        return \is_array($mapping) ? $mapping : [];
409    }
410
411    /**
412     * Returns the minimum stability declared in the Composer file.
413     *
414     * This method SHALL return the value of the `minimum-stability` key when
415     * present. If the key is absent, the method MUST return an empty string.
416     *
417     * @return string the minimum stability value
418     */
419    public function getMinimumStability(): string
420    {
421        return $this->data['minimum-stability'] ?? 'stable';
422    }
423
424    /**
425     * Returns configuration data from the Composer `config` section.
426     *
427     * This method SHALL return the complete `config` section when `$config`
428     * is null. When a specific key is requested, the method SHALL return the
429     * matching value if it is an array or a string. Any non-array scalar
430     * value MUST be cast to string. If the section or key is absent, an
431     * empty array SHALL be returned when `$config` is null, otherwise an
432     * empty string SHALL be returned.
433     *
434     * @param string|null $config the configuration key to retrieve, or null
435     *                            to retrieve the complete config section
436     *
437     * @return array<string, mixed>|string the requested config value or the full
438     *                                     config structure, depending on the
439     *                                     requested key
440     */
441    public function getConfig(?string $config): array|string
442    {
443        $configuration = $this->data['config'] ?? [];
444
445        if (! \is_array($configuration)) {
446            return null === $config ? [] : '';
447        }
448
449        if (null === $config) {
450            return $configuration;
451        }
452
453        $value = $configuration[$config] ?? '';
454
455        if (\is_array($value)) {
456            return $value;
457        }
458
459        return \is_string($value) ? $value : (string) $value;
460    }
461
462    /**
463     * Returns the scripts declared in the Composer file.
464     *
465     * This method SHALL return the `scripts` section when present. If the
466     * section is absent or invalid, the method MUST return an empty array.
467     *
468     * @return array<string, mixed> the Composer scripts configuration
469     */
470    public function getScripts(): array
471    {
472        $scripts = $this->data['scripts'] ?? [];
473
474        return \is_array($scripts) ? $scripts : [];
475    }
476
477    /**
478     * Returns the extra configuration section declared in the Composer file.
479     *
480     * This method SHALL return the complete `extra` section when `$extra` is
481     * null. When a specific extra key is requested, the method SHALL return
482     * the matching value only when that value is an array. If the section or
483     * requested key is absent, the method MUST return an empty array.
484     *
485     * @param string|null $extra the extra configuration key to retrieve, or
486     *                           null to retrieve the complete extra section
487     *
488     * @return array<string, mixed> the extra configuration data, or an empty
489     *                              array when undefined
490     */
491    public function getExtra(?string $extra = null): array
492    {
493        $extraConfiguration = $this->data['extra'] ?? [];
494
495        if (! \is_array($extraConfiguration)) {
496            return [];
497        }
498
499        if (null === $extra) {
500            return $extraConfiguration;
501        }
502
503        $value = $extraConfiguration[$extra] ?? [];
504
505        return \is_array($value) ? $value : [];
506    }
507
508    /**
509     * Returns the executable binary declarations from the Composer file.
510     *
511     * This method SHALL return the `bin` value as declared when it is a
512     * string or an array. If the section is absent or invalid, the method
513     * MUST return an empty array.
514     *
515     * @return string|array<int, string> the declared binary path or paths
516     */
517    public function getBin(): string|array
518    {
519        $bin = $this->data['bin'] ?? [];
520
521        if (\is_string($bin)) {
522            return $bin;
523        }
524
525        if (! \is_array($bin)) {
526            return [];
527        }
528
529        return array_values(array_filter($bin, \is_string(...)));
530    }
531
532    /**
533     * Returns the package suggestions declared in the Composer file.
534     *
535     * This method SHALL return the `suggest` section as a string map.
536     * Non-string keys or values MUST be ignored. If the section is absent,
537     * the method MUST return an empty array.
538     *
539     * @return array<string, string> the package suggestion map
540     */
541    public function getSuggest(): array
542    {
543        $suggest = $this->data['suggest'] ?? [];
544
545        if (! \is_array($suggest)) {
546            return [];
547        }
548
549        $result = [];
550
551        foreach ($suggest as $package => $description) {
552            if (! \is_string($package)) {
553                continue;
554            }
555
556            if (! \is_string($description)) {
557                continue;
558            }
559
560            $result[$package] = $description;
561        }
562
563        return $result;
564    }
565
566    /**
567     * Returns comment metadata associated with the Composer file.
568     *
569     * Since standard Composer JSON does not define a comments section, this
570     * method SHALL return the `_comment` key when present and valid. When
571     * comment metadata is unavailable, the method MUST return an empty array.
572     *
573     * @return array<int|string, mixed> the comment metadata, or an empty array when unavailable
574     */
575    public function getComments(): array
576    {
577        $comments = $this->data['_comment'] ?? [];
578
579        if (\is_string($comments)) {
580            return [$comments];
581        }
582
583        return \is_array($comments) ? $comments : [];
584    }
585
586    /**
587     * Reads and decodes a composer manifest file.
588     *
589     * @param string $path the manifest path
590     *
591     * @return array<string, mixed> the parsed payload
592     */
593    private function readComposerJsonFile(string $path): array
594    {
595        if (! file_exists($path)) {
596            throw new RuntimeException(\sprintf('Unable to read composer manifest file at path: %s', $path));
597        }
598
599        return $this->decodeJson($path);
600    }
601
602    /**
603     * Reads and decodes the composer installed manifest.
604     *
605     * @param string $path installed manifest path
606     *
607     * @return array<string, mixed> the parsed payload
608     */
609    private function readComposerInstalledManifest(string $path): array
610    {
611        if (! file_exists($path)) {
612            return [];
613        }
614
615        return $this->decodeJson($path);
616    }
617
618    /**
619     * Decodes a JSON file.
620     *
621     * @param string $path the file path
622     *
623     * @return array<string, mixed> the decoded payload
624     */
625    private function decodeJson(string $path): array
626    {
627        $contents = file_get_contents($path);
628
629        if (false === $contents) {
630            throw new RuntimeException(\sprintf('Unable to read composer manifest file at path: %s', $path));
631        }
632
633        $data = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
634
635        return \is_array($data) ? $data : [];
636    }
637}