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