Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
99 / 103
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Merger
96.12% covered (success)
96.12%
99 / 103
80.00% covered (warning)
80.00%
8 / 10
42
0.00% covered (danger)
0.00%
0 / 1
 merge
94.34% covered (success)
94.34%
50 / 53
0.00% covered (danger)
0.00%
0 / 1
15.04
 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
 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 (isset($keptExportLookup[$pathKey])) {
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 (isset($keptExportLookup[$pathKey])) {
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     * Builds a lookup table of generated directory candidates.
193     *
194     * @param list<string> $exportIgnoreEntries the generated export-ignore path list
195     *
196     * @return array<string, true> the normalized directory lookup
197     */
198    private function generatedDirectoryLookup(array $exportIgnoreEntries): array
199    {
200        $lookup = [];
201
202        foreach ($exportIgnoreEntries as $entry) {
203            $trimmedEntry = trim($entry);
204
205            if (! str_ends_with($trimmedEntry, '/')) {
206                continue;
207            }
208
209            $lookup[$this->normalizePathKey($trimmedEntry)] = true;
210        }
211
212        return $lookup;
213    }
214
215    /**
216     * Normalizes a .gitattributes line for deterministic comparison and output.
217     *
218     * @param string $line the raw line to normalize
219     *
220     * @return string the normalized line
221     */
222    private function normalizeLine(string $line): string
223    {
224        $trimmedLine = trim($line);
225
226        if ('' === $trimmedLine) {
227            return '';
228        }
229
230        if (str_starts_with($trimmedLine, '#')) {
231            return $trimmedLine;
232        }
233
234        return preg_replace('/(?<!\\\\)[ \t]+/', ' ', $trimmedLine) ?? $trimmedLine;
235    }
236
237    /**
238     * Extracts the path spec from a simple export-ignore line.
239     *
240     * @param string $line the line to inspect
241     *
242     * @return string|null the extracted path spec when the line is a simple export-ignore rule
243     */
244    private function extractExportIgnorePathSpec(string $line): ?string
245    {
246        if (1 !== preg_match('/^(\S+)\s+export-ignore$/', $line, $matches)) {
247            return null;
248        }
249
250        return $matches[1];
251    }
252
253    /**
254     * Builds the natural sort key for a path spec.
255     *
256     * @param string $pathSpec the raw path spec to normalize for sorting
257     *
258     * @return string the natural sort key
259     */
260    private function sortKey(string $pathSpec): string
261    {
262        return ltrim($this->normalizePathSpec($pathSpec), '/');
263    }
264
265    /**
266     * Normalizes a gitattributes path spec for sorting.
267     *
268     * @param string $pathSpec the raw path spec to normalize
269     *
270     * @return string the normalized path spec
271     */
272    private function normalizePathSpec(string $pathSpec): string
273    {
274        $trimmedPathSpec = trim($pathSpec);
275
276        if ('' === $trimmedPathSpec) {
277            return '';
278        }
279
280        $isDirectory = str_ends_with($trimmedPathSpec, '/');
281        $normalizedPathSpec = preg_replace('#/+#', '/', '/' . ltrim($trimmedPathSpec, '/')) ?? $trimmedPathSpec;
282        $normalizedPathSpec = '/' === $normalizedPathSpec ? $normalizedPathSpec : rtrim($normalizedPathSpec, '/');
283
284        if ($isDirectory && '/' !== $normalizedPathSpec) {
285            $normalizedPathSpec .= '/';
286        }
287
288        return $normalizedPathSpec;
289    }
290
291    /**
292     * Normalizes a path spec for deduplication and keep-in-export matching.
293     *
294     * Literal root paths are compared without leading slash differences, while
295     * pattern-based specs preserve their original anchoring semantics.
296     *
297     * @param string $pathSpec the raw path spec to normalize
298     *
299     * @return string the normalized deduplication key
300     */
301    private function normalizePathKey(string $pathSpec): string
302    {
303        $normalizedPathSpec = $this->normalizePathSpec($pathSpec);
304
305        if ($this->isLiteralPathSpec($normalizedPathSpec)) {
306            return ltrim(rtrim($normalizedPathSpec, '/'), '/');
307        }
308
309        return $normalizedPathSpec;
310    }
311
312    /**
313     * Determines whether a path spec is a literal path and not a glob pattern.
314     *
315     * @param string $pathSpec the normalized path spec to inspect
316     *
317     * @return bool true when the path spec is a literal path
318     */
319    private function isLiteralPathSpec(string $pathSpec): bool
320    {
321        return ! str_contains($pathSpec, '*')
322            && ! str_contains($pathSpec, '?')
323            && ! str_contains($pathSpec, '[')
324            && ! str_contains($pathSpec, '{');
325    }
326}