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;
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 FilesystemInterface $filesystem the filesystem service responsible for writing the file
36     */
37    public function __construct(
38        private FilesystemInterface $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->render($content));
52    }
53
54    /**
55     * Renders normalized .gitattributes content.
56     *
57     * @param string $content the merged .gitattributes content to normalize
58     *
59     * @return string the normalized file content
60     */
61    public function render(string $content): string
62    {
63        return $this->format($content);
64    }
65
66    /**
67     * Formats .gitattributes content with aligned attribute columns.
68     *
69     * @param string $content The merged .gitattributes content to normalize.
70     *
71     * @return string
72     */
73    private function format(string $content): string
74    {
75        $rows = [];
76        $maxPathSpecLength = 0;
77
78        foreach (preg_split('/\R/', $content) as $line) {
79            $trimmedLine = trim((string) $line);
80
81            if ('' === $trimmedLine) {
82                $rows[] = [
83                    'type' => 'raw',
84                    'line' => '',
85                ];
86
87                continue;
88            }
89
90            if (str_starts_with($trimmedLine, '#')) {
91                $rows[] = [
92                    'type' => 'raw',
93                    'line' => $trimmedLine,
94                ];
95
96                continue;
97            }
98
99            $entry = $this->parseEntry($trimmedLine);
100
101            if (null === $entry) {
102                $rows[] = [
103                    'type' => 'raw',
104                    'line' => $trimmedLine,
105                ];
106
107                continue;
108            }
109
110            $maxPathSpecLength = max($maxPathSpecLength, \strlen($entry['path_spec']));
111            $rows[] = [
112                'type' => 'entry',
113                'path_spec' => $entry['path_spec'],
114                'attributes' => $entry['attributes'],
115            ];
116        }
117
118        $lastRowKey = array_key_last($rows);
119
120        if (null !== $lastRowKey) {
121            $lastRow = $rows[$lastRowKey];
122
123            if ('raw' === $lastRow['type'] && '' === $lastRow['line']) {
124                array_pop($rows);
125            }
126        }
127
128        $formattedLines = [];
129
130        foreach ($rows as $row) {
131            if ('entry' !== $row['type']) {
132                $formattedLines[] = $row['line'];
133
134                continue;
135            }
136
137            $formattedLines[] = str_pad($row['path_spec'], $maxPathSpecLength + 1) . $row['attributes'];
138        }
139
140        return implode("\n", $formattedLines) . "\n";
141    }
142
143    /**
144     * Parses a .gitattributes entry into its path spec and attribute segment.
145     *
146     * @param string $line The normalized .gitattributes line.
147     *
148     * @return array{path_spec: string, attributes: string}|null
149     */
150    private function parseEntry(string $line): ?array
151    {
152        $separatorPosition = $this->firstUnescapedWhitespacePosition($line);
153
154        if (null === $separatorPosition) {
155            return null;
156        }
157
158        $pathSpec = substr($line, 0, $separatorPosition);
159        $attributes = ltrim(substr($line, $separatorPosition));
160
161        if ('' === $pathSpec || '' === $attributes) {
162            return null;
163        }
164
165        return [
166            'path_spec' => $pathSpec,
167            'attributes' => $attributes,
168        ];
169    }
170
171    /**
172     * Locates the first non-escaped whitespace separator in a line.
173     *
174     * @param string $line the line to inspect
175     */
176    private function firstUnescapedWhitespacePosition(string $line): ?int
177    {
178        $length = \strlen($line);
179
180        for ($position = 0; $position < $length; ++$position) {
181            if (! \in_array($line[$position], [' ', "\t"], true)) {
182                continue;
183            }
184
185            $backslashCount = 0;
186
187            for ($index = $position - 1; $index >= 0 && '\\' === $line[$index]; --$index) {
188                ++$backslashCount;
189            }
190
191            if (0 === $backslashCount % 2) {
192                return $position;
193            }
194        }
195
196        return null;
197    }
198}