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