Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.31% covered (success)
98.31%
58 / 59
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Writer
98.31% covered (success)
98.31%
58 / 59
80.00% covered (warning)
80.00%
4 / 5
20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 write
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 format
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
8
 parseEntry
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 firstUnescapedWhitespacePosition
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of fast-forward/dev-tools.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/dev-tools
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\DevTools\GitAttributes;
20
21use Symfony\Component\Filesystem\Filesystem;
22
23use function Safe\preg_split;
24
25/**
26 * Persists normalized .gitattributes content.
27 *
28 * This writer SHALL align attribute declarations using the longest path spec,
29 * write the provided textual content to the target path, and MUST append a
30 * final trailing line feed for deterministic formatting.
31 */
32  class Writer implements WriterInterface
33{
34    /**
35     * @param Filesystem $filesystem the filesystem service responsible for writing the file
36     */
37    public function __construct(
38        private Filesystem $filesystem
39    ) {}
40
41    /**
42     * Writes the .gitattributes content to the specified filesystem path.
43     *
44     * @param string $gitattributesPath The filesystem path to the .gitattributes file.
45     * @param string $content The merged .gitattributes content to persist.
46     *
47     * @return void
48     */
49    public function write(string $gitattributesPath, string $content): void
50    {
51        $this->filesystem->dumpFile($gitattributesPath, $this->format($content));
52    }
53
54    /**
55     * Formats .gitattributes content with aligned attribute columns.
56     *
57     * @param string $content The merged .gitattributes content to normalize.
58     *
59     * @return string
60     */
61    private function format(string $content): string
62    {
63        $rows = [];
64        $maxPathSpecLength = 0;
65
66        foreach (preg_split('/\R/', $content) ?: [] as $line) {
67            $trimmedLine = trim((string) $line);
68
69            if ('' === $trimmedLine) {
70                $rows[] = [
71                    'type' => 'raw',
72                    'line' => '',
73                ];
74
75                continue;
76            }
77
78            if (str_starts_with($trimmedLine, '#')) {
79                $rows[] = [
80                    'type' => 'raw',
81                    'line' => $trimmedLine,
82                ];
83
84                continue;
85            }
86
87            $entry = $this->parseEntry($trimmedLine);
88
89            if (null === $entry) {
90                $rows[] = [
91                    'type' => 'raw',
92                    'line' => $trimmedLine,
93                ];
94
95                continue;
96            }
97
98            $maxPathSpecLength = max($maxPathSpecLength, \strlen($entry['path_spec']));
99            $rows[] = [
100                'type' => 'entry',
101                'path_spec' => $entry['path_spec'],
102                'attributes' => $entry['attributes'],
103            ];
104        }
105
106        $formattedLines = [];
107
108        foreach ($rows as $row) {
109            if ('entry' !== $row['type']) {
110                $formattedLines[] = $row['line'];
111
112                continue;
113            }
114
115            $formattedLines[] = str_pad($row['path_spec'], $maxPathSpecLength + 1) . $row['attributes'];
116        }
117
118        return implode("\n", $formattedLines) . "\n";
119    }
120
121    /**
122     * Parses a .gitattributes entry into its path spec and attribute segment.
123     *
124     * @param string $line The normalized .gitattributes line.
125     *
126     * @return array{path_spec: string, attributes: string}|null
127     */
128    private function parseEntry(string $line): ?array
129    {
130        $separatorPosition = $this->firstUnescapedWhitespacePosition($line);
131
132        if (null === $separatorPosition) {
133            return null;
134        }
135
136        $pathSpec = substr($line, 0, $separatorPosition);
137        $attributes = ltrim(substr($line, $separatorPosition));
138
139        if ('' === $pathSpec || '' === $attributes) {
140            return null;
141        }
142
143        return [
144            'path_spec' => $pathSpec,
145            'attributes' => $attributes,
146        ];
147    }
148
149    /**
150     * Locates the first non-escaped whitespace separator in a line.
151     *
152     * @param string $line the line to inspect
153     *
154     * @return int|null
155     */
156    private function firstUnescapedWhitespacePosition(string $line): ?int
157    {
158        $length = \strlen($line);
159
160        for ($position = 0; $position < $length; ++$position) {
161            if (! \in_array($line[$position], [' ', "\t"], true)) {
162                continue;
163            }
164
165            $backslashCount = 0;
166
167            for ($index = $position - 1; $index >= 0 && '\\' === $line[$index]; --$index) {
168                ++$backslashCount;
169            }
170
171            if (0 === $backslashCount % 2) {
172                return $position;
173            }
174        }
175
176        return null;
177    }
178}