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