Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.46% covered (success)
98.46%
64 / 65
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Writer
98.46% covered (success)
98.46%
64 / 65
83.33% covered (warning)
83.33%
5 / 6
23
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
 render
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 format
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
10
 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 * 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 FastForward\DevTools\Filesystem\FilesystemInterface;
23
24use function Safe\preg_split;
25
26/**
27 * Persists normalized .gitattributes content.
28 *
29 * This writer SHALL align attribute declarations using the longest path spec,
30 * write the provided textual content to the target path, and MUST append a
31 * final trailing line feed for deterministic formatting.
32 */
33  class Writer implements WriterInterface
34{
35    /**
36     * @param FilesystemInterface $filesystem the filesystem service responsible for writing the file
37     */
38    public function __construct(
39        private FilesystemInterface $filesystem
40    ) {}
41
42    /**
43     * Writes the .gitattributes content to the specified filesystem path.
44     *
45     * @param string $gitattributesPath The filesystem path to the .gitattributes file.
46     * @param string $content The merged .gitattributes content to persist.
47     *
48     * @return void
49     */
50    public function write(string $gitattributesPath, string $content): void
51    {
52        $this->filesystem->dumpFile($gitattributesPath, $this->render($content));
53    }
54
55    /**
56     * Renders normalized .gitattributes content.
57     *
58     * @param string $content the merged .gitattributes content to normalize
59     *
60     * @return string the normalized file content
61     */
62    public function render(string $content): string
63    {
64        return $this->format($content);
65    }
66
67    /**
68     * Formats .gitattributes content with aligned attribute columns.
69     *
70     * @param string $content The merged .gitattributes content to normalize.
71     *
72     * @return string
73     */
74    private function format(string $content): string
75    {
76        $rows = [];
77        $maxPathSpecLength = 0;
78
79        foreach (preg_split('/\R/', $content) as $line) {
80            $trimmedLine = trim((string) $line);
81
82            if ('' === $trimmedLine) {
83                $rows[] = [
84                    'type' => 'raw',
85                    'line' => '',
86                ];
87
88                continue;
89            }
90
91            if (str_starts_with($trimmedLine, '#')) {
92                $rows[] = [
93                    'type' => 'raw',
94                    'line' => $trimmedLine,
95                ];
96
97                continue;
98            }
99
100            $entry = $this->parseEntry($trimmedLine);
101
102            if (null === $entry) {
103                $rows[] = [
104                    'type' => 'raw',
105                    'line' => $trimmedLine,
106                ];
107
108                continue;
109            }
110
111            $maxPathSpecLength = max($maxPathSpecLength, \strlen($entry['path_spec']));
112            $rows[] = [
113                'type' => 'entry',
114                'path_spec' => $entry['path_spec'],
115                'attributes' => $entry['attributes'],
116            ];
117        }
118
119        $lastRowKey = array_key_last($rows);
120
121        if (null !== $lastRowKey) {
122            $lastRow = $rows[$lastRowKey];
123
124            if ('raw' === $lastRow['type'] && '' === $lastRow['line']) {
125                array_pop($rows);
126            }
127        }
128
129        $formattedLines = [];
130
131        foreach ($rows as $row) {
132            if ('entry' !== $row['type']) {
133                $formattedLines[] = $row['line'];
134
135                continue;
136            }
137
138            $formattedLines[] = str_pad($row['path_spec'], $maxPathSpecLength + 1) . $row['attributes'];
139        }
140
141        return implode("\n", $formattedLines) . "\n";
142    }
143
144    /**
145     * Parses a .gitattributes entry into its path spec and attribute segment.
146     *
147     * @param string $line The normalized .gitattributes line.
148     *
149     * @return array{path_spec: string, attributes: string}|null
150     */
151    private function parseEntry(string $line): ?array
152    {
153        $separatorPosition = $this->firstUnescapedWhitespacePosition($line);
154
155        if (null === $separatorPosition) {
156            return null;
157        }
158
159        $pathSpec = substr($line, 0, $separatorPosition);
160        $attributes = ltrim(substr($line, $separatorPosition));
161
162        if ('' === $pathSpec || '' === $attributes) {
163            return null;
164        }
165
166        return [
167            'path_spec' => $pathSpec,
168            'attributes' => $attributes,
169        ];
170    }
171
172    /**
173     * Locates the first non-escaped whitespace separator in a line.
174     *
175     * @param string $line the line to inspect
176     *
177     * @return int|null
178     */
179    private function firstUnescapedWhitespacePosition(string $line): ?int
180    {
181        $length = \strlen($line);
182
183        for ($position = 0; $position < $length; ++$position) {
184            if (! \in_array($line[$position], [' ', "\t"], true)) {
185                continue;
186            }
187
188            $backslashCount = 0;
189
190            for ($index = $position - 1; $index >= 0 && '\\' === $line[$index]; --$index) {
191                ++$backslashCount;
192            }
193
194            if (0 === $backslashCount % 2) {
195                return $position;
196            }
197        }
198
199        return null;
200    }
201}