Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ComposerFundingCodec
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
5 / 5
26
100.00% covered (success)
100.00%
1 / 1
 parse
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
10
 dump
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 extractGithubSponsor
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 insertFundingEntries
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 decodeJsonContents
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
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\Funding;
21
22use function Safe\parse_url;
23use function Safe\preg_match;
24use function array_values;
25use function Safe\json_decode;
26use function Safe\json_encode;
27use function trim;
28
29/**
30 * Parses and renders Composer funding metadata.
31 */
32  class ComposerFundingCodec
33{
34    /**
35     * Parses Composer funding entries into a normalized funding profile.
36     *
37     * @param string $contents the composer.json contents
38     *
39     * @return FundingProfile the normalized funding profile
40     */
41    public function parse(string $contents): FundingProfile
42    {
43        $data = $this->decodeJsonContents($contents);
44        $funding = $data['funding'] ?? [];
45
46        if (! \is_array($funding)) {
47            return new FundingProfile();
48        }
49
50        $githubSponsors = [];
51        $customUrls = [];
52        $unsupported = [];
53
54        foreach ($funding as $entry) {
55            if (! \is_array($entry)) {
56                continue;
57            }
58
59            $type = \is_string($entry['type'] ?? null) ? trim($entry['type']) : '';
60            $url = \is_string($entry['url'] ?? null) ? trim($entry['url']) : '';
61
62            if ('' === $url) {
63                $unsupported[] = $entry;
64
65                continue;
66            }
67
68            $githubSponsor = $this->extractGithubSponsor($url);
69
70            if ('github' === $type && null !== $githubSponsor) {
71                $githubSponsors[] = $githubSponsor;
72
73                continue;
74            }
75
76            if ('custom' === $type) {
77                $customUrls[] = $url;
78
79                continue;
80            }
81
82            $unsupported[] = $entry;
83        }
84
85        return new FundingProfile(
86            array_values(array_unique($githubSponsors)),
87            array_values(array_unique($customUrls)),
88            unsupportedComposerEntries: $unsupported,
89        );
90    }
91
92    /**
93     * Applies a normalized funding profile to composer.json contents.
94     *
95     * @param string $contents the composer.json contents
96     * @param FundingProfile $profile the merged funding profile
97     *
98     * @return string the updated composer.json contents
99     */
100    public function dump(string $contents, FundingProfile $profile): string
101    {
102        $entries = [];
103
104        foreach ($profile->getGithubSponsors() as $githubSponsor) {
105            $entries[] = [
106                'type' => 'github',
107                'url' => \sprintf('https://github.com/sponsors/%s', $githubSponsor),
108            ];
109        }
110
111        foreach ($profile->getCustomUrls() as $customUrl) {
112            $entries[] = [
113                'type' => 'custom',
114                'url' => $customUrl,
115            ];
116        }
117
118        foreach ($profile->getUnsupportedComposerEntries() as $unsupportedEntry) {
119            $entries[] = $unsupportedEntry;
120        }
121
122        $data = $this->decodeJsonContents($contents);
123        unset($data['funding']);
124
125        if ([] === $entries) {
126            return json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE) . "\n";
127        }
128
129        return json_encode(
130            $this->insertFundingEntries($data, $entries),
131            \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE,
132        ) . "\n";
133    }
134
135    /**
136     * Extracts a GitHub Sponsors handle from a funding URL.
137     *
138     * @param string $url the funding URL
139     *
140     * @return string|null the sponsor handle, or null when the URL is unsupported
141     */
142    private function extractGithubSponsor(string $url): ?string
143    {
144        $host = parse_url($url, \PHP_URL_HOST);
145        $path = parse_url($url, \PHP_URL_PATH);
146
147        if (! \is_string($host) || ! \is_string($path)) {
148            return null;
149        }
150
151        if (! \in_array($host, ['github.com', 'www.github.com'], true)) {
152            return null;
153        }
154
155        if (1 !== preg_match('#^/sponsors/([^/]+)$#', $path, $matches)) {
156            return null;
157        }
158
159        return $matches[1];
160    }
161
162    /**
163     * Inserts funding entries in a stable Composer key order.
164     *
165     * @param array<string, mixed> $data the decoded composer.json payload
166     * @param array<int, array<string, mixed>> $entries the funding entries to insert
167     *
168     * @return array<string, mixed> the composer payload with funding inserted
169     */
170    private function insertFundingEntries(array $data, array $entries): array
171    {
172        $orderedData = [];
173        $inserted = false;
174
175        foreach ($data as $key => $value) {
176            $orderedData[$key] = $value;
177
178            if ('support' === $key) {
179                $orderedData['funding'] = $entries;
180                $inserted = true;
181            }
182        }
183
184        if (! $inserted) {
185            $orderedData['funding'] = $entries;
186        }
187
188        return $orderedData;
189    }
190
191    /**
192     * Decodes a Composer JSON payload to an array.
193     *
194     * @param string $contents the JSON source
195     *
196     * @return array<string, mixed> the decoded payload
197     */
198    private function decodeJsonContents(string $contents): array
199    {
200        $data = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
201
202        return \is_array($data) ? $data : [];
203    }
204}