Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
104 / 107
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Merger
97.20% covered (success)
97.20%
104 / 107
81.82% covered (warning)
81.82%
9 / 11
46
0.00% covered (danger)
0.00%
0 / 1
 merge
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
15
 parseExistingLines
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 keepInExportLookup
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isKeptPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 generatedDirectoryLookup
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 normalizeLine
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 extractExportIgnorePathSpec
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sortKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizePathSpec
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 normalizePathKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isLiteralPathSpec
100.00% covered (success)
100.00%
4 / 4
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\GitAttributes;
21
22use function Safe\preg_split;
23use function Safe\preg_replace;
24use function Safe\preg_match;
25
26/**
27 * Merges .gitattributes content with generated export-ignore rules.
28 *
29 * This class preserves existing custom entries while adding missing
30 * export-ignore rules for known candidate paths, deduplicates semantically
31 * equivalent entries, and sorts export-ignore rules with directories before
32 * files.
33 */
34 class Merger implements MergerInterface
35{
36    /**
37     * Merges generated export-ignore entries with existing .gitattributes content.
38     *
39     * This method:
40     * 1. Preserves custom user-defined entries in their original order
41     * 2. Adds missing generated export-ignore entries for existing paths
42     * 3. Deduplicates entries using normalized path comparison
43     * 4. Sorts export-ignore entries with directories before files
44     *
45     * @param string $existingContent The raw .gitattributes content currently stored.
46     * @param list<string> $exportIgnoreEntries the export-ignore entries to manage
47     * @param list<string> $keepInExportPaths the paths that MUST remain exported
48     *
49     * @return string The merged .gitattributes content
50     */
51    public function merge(string $existingContent, array $exportIgnoreEntries, array $keepInExportPaths = []): string
52    {
53        $nonExportIgnoreLines = [];
54        $seenNonExportIgnoreLines = [];
55        $exportIgnoreLines = [];
56        $keptExportLookup = $this->keepInExportLookup($keepInExportPaths);
57        $generatedDirectoryLookup = $this->generatedDirectoryLookup($exportIgnoreEntries);
58
59        foreach ($this->parseExistingLines($existingContent) as $line) {
60            $normalizedLine = $this->normalizeLine($line);
61
62            if ('' === $normalizedLine) {
63                continue;
64            }
65
66            $pathSpec = $this->extractExportIgnorePathSpec($normalizedLine);
67
68            if (null === $pathSpec) {
69                if (isset($seenNonExportIgnoreLines[$normalizedLine])) {
70                    continue;
71                }
72
73                $nonExportIgnoreLines[] = $normalizedLine;
74                $seenNonExportIgnoreLines[$normalizedLine] = true;
75
76                continue;
77            }
78
79            $pathKey = $this->normalizePathKey($pathSpec);
80            if ($this->isKeptPath($pathKey, $keptExportLookup)) {
81                continue;
82            }
83
84            if (isset($exportIgnoreLines[$pathKey])) {
85                continue;
86            }
87
88            $exportIgnoreLines[$pathKey] = [
89                'line' => $normalizedLine,
90                'sort_key' => $this->sortKey($pathSpec),
91                'is_directory' => str_ends_with($pathSpec, '/') || isset($generatedDirectoryLookup[$pathKey]),
92            ];
93        }
94
95        foreach ($exportIgnoreEntries as $entry) {
96            $trimmedEntry = trim($entry);
97            $pathKey = $this->normalizePathKey($trimmedEntry);
98
99            if ($this->isKeptPath($pathKey, $keptExportLookup)) {
100                continue;
101            }
102
103            if (! isset($exportIgnoreLines[$pathKey])) {
104                $exportIgnoreLines[$pathKey] = [
105                    'line' => $trimmedEntry . ' export-ignore',
106                    'sort_key' => $this->sortKey($trimmedEntry),
107                    'is_directory' => str_ends_with($trimmedEntry, '/'),
108                ];
109
110                continue;
111            }
112
113            $exportIgnoreLines[$pathKey]['is_directory'] = $exportIgnoreLines[$pathKey]['is_directory']
114                || str_ends_with($trimmedEntry, '/');
115        }
116
117        $sortedExportIgnoreLines = array_values($exportIgnoreLines);
118
119        usort(
120            $sortedExportIgnoreLines,
121            static function (array $left, array $right): int {
122                if ($left['is_directory'] !== $right['is_directory']) {
123                    return $left['is_directory'] ? -1 : 1;
124                }
125
126                $naturalOrder = strnatcasecmp($left['sort_key'], $right['sort_key']);
127
128                if (0 !== $naturalOrder) {
129                    return $naturalOrder;
130                }
131
132                return strcmp($left['sort_key'], $right['sort_key']);
133            }
134        );
135
136        return implode("\n", [...$nonExportIgnoreLines, ...array_column($sortedExportIgnoreLines, 'line')]);
137    }
138
139    /**
140     * Parses the raw .gitattributes content into trimmed non-empty lines.
141     *
142     * @param string $content The full .gitattributes content.
143     *
144     * @return list<string> the non-empty lines from the file
145     */
146    private function parseExistingLines(string $content): array
147    {
148        if ('' === $content) {
149            return [];
150        }
151
152        $lines = [];
153
154        foreach (preg_split('/\R/', $content) as $line) {
155            $trimmedLine = trim((string) $line);
156
157            if ('' === $trimmedLine) {
158                continue;
159            }
160
161            $lines[] = $trimmedLine;
162        }
163
164        return $lines;
165    }
166
167    /**
168     * Builds a lookup table for paths that MUST stay in the exported archive.
169     *
170     * @param list<string> $keepInExportPaths the configured keep-in-export paths
171     *
172     * @return array<string, true> the normalized path lookup
173     */
174    private function keepInExportLookup(array $keepInExportPaths): array
175    {
176        $lookup = [];
177
178        foreach ($keepInExportPaths as $path) {
179            $normalizedPath = $this->normalizePathKey($path);
180
181            if ('' === $normalizedPath) {
182                continue;
183            }
184
185            $lookup[$normalizedPath] = true;
186        }
187
188        return $lookup;
189    }
190
191    /**
192     * @param string $pathKey
193     * @param array<string, true> $keptExportLookup
194     */
195    private function isKeptPath(string $pathKey, array $keptExportLookup): bool
196    {
197        foreach (array_keys($keptExportLookup) as $keptPath) {
198            if ($pathKey === $keptPath || str_starts_with($pathKey . '/', $keptPath . '/')) {
199                return true;
200            }
201        }
202
203        return false;
204    }
205
206    /**
207     * Builds a lookup table of generated directory candidates.
208     *
209     * @param list<string> $exportIgnoreEntries the generated export-ignore path list
210     *
211     * @return array<string, true> the normalized directory lookup
212     */
213    private function generatedDirectoryLookup(array $exportIgnoreEntries): array
214    {
215        $lookup = [];
216
217        foreach ($exportIgnoreEntries as $entry) {
218            $trimmedEntry = trim($entry);
219
220            if (! str_ends_with($trimmedEntry, '/')) {
221                continue;
222            }
223
224            $lookup[$this->normalizePathKey($trimmedEntry)] = true;
225        }
226
227        return $lookup;
228    }
229
230    /**
231     * Normalizes a .gitattributes line for deterministic comparison and output.
232     *
233     * @param string $line the raw line to normalize
234     *
235     * @return string the normalized line
236     */
237    private function normalizeLine(string $line): string
238    {
239        $trimmedLine = trim($line);
240
241        if ('' === $trimmedLine) {
242            return '';
243        }
244
245        if (str_starts_with($trimmedLine, '#')) {
246            return $trimmedLine;
247        }
248
249        return preg_replace('/(?<!\\\\)[ \t]+/', ' ', $trimmedLine) ?? $trimmedLine;
250    }
251
252    /**
253     * Extracts the path spec from a simple export-ignore line.
254     *
255     * @param string $line the line to inspect
256     *
257     * @return string|null the extracted path spec when the line is a simple export-ignore rule
258     */
259    private function extractExportIgnorePathSpec(string $line): ?string
260    {
261        if (1 !== preg_match('/^(\S+)\s+export-ignore$/', $line, $matches)) {
262            return null;
263        }
264
265        return $matches[1];
266    }
267
268    /**
269     * Builds the natural sort key for a path spec.
270     *
271     * @param string $pathSpec the raw path spec to normalize for sorting
272     *
273     * @return string the natural sort key
274     */
275    private function sortKey(string $pathSpec): string
276    {
277        return ltrim($this->normalizePathSpec($pathSpec), '/');
278    }
279
280    /**
281     * Normalizes a gitattributes path spec for sorting.
282     *
283     * @param string $pathSpec the raw path spec to normalize
284     *
285     * @return string the normalized path spec
286     */
287    private function normalizePathSpec(string $pathSpec): string
288    {
289        $trimmedPathSpec = trim($pathSpec);
290
291        if ('' === $trimmedPathSpec) {
292            return '';
293        }
294
295        $isDirectory = str_ends_with($trimmedPathSpec, '/');
296        $normalizedPathSpec = preg_replace('#/+#', '/', '/' . ltrim($trimmedPathSpec, '/')) ?? $trimmedPathSpec;
297        $normalizedPathSpec = '/' === $normalizedPathSpec ? $normalizedPathSpec : rtrim($normalizedPathSpec, '/');
298
299        if ($isDirectory && '/' !== $normalizedPathSpec) {
300            $normalizedPathSpec .= '/';
301        }
302
303        return $normalizedPathSpec;
304    }
305
306    /**
307     * Normalizes a path spec for deduplication and keep-in-export matching.
308     *
309     * Literal root paths are compared without leading slash differences, while
310     * pattern-based specs preserve their original anchoring semantics.
311     *
312     * @param string $pathSpec the raw path spec to normalize
313     *
314     * @return string the normalized deduplication key
315     */
316    private function normalizePathKey(string $pathSpec): string
317    {
318        $normalizedPathSpec = $this->normalizePathSpec($pathSpec);
319
320        if ($this->isLiteralPathSpec($normalizedPathSpec)) {
321            return ltrim(rtrim($normalizedPathSpec, '/'), '/');
322        }
323
324        return $normalizedPathSpec;
325    }
326
327    /**
328     * Determines whether a path spec is a literal path and not a glob pattern.
329     *
330     * @param string $pathSpec the normalized path spec to inspect
331     *
332     * @return bool true when the path spec is a literal path
333     */
334    private function isLiteralPathSpec(string $pathSpec): bool
335    {
336        return ! str_contains($pathSpec, '*')
337            && ! str_contains($pathSpec, '?')
338            && ! str_contains($pathSpec, '[')
339            && ! str_contains($pathSpec, '{');
340    }
341}