Skip to content

Commit 9a46c2f

Browse files
committed
feat: added FileEmptyLastLine{Sniffer,Fixer}
1 parent da88c43 commit 9a46c2f

8 files changed

Lines changed: 358 additions & 0 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Fix\Fixer;
6+
7+
use DocbookCS\Fix\Fix;
8+
use DocbookCS\Fix\FixerException;
9+
use DocbookCS\Violation\Violation;
10+
11+
final class FileEmptyLastLineFixer implements Fixer
12+
{
13+
private const string LINE_ENDINGS_PATTERN = '/^[\r\n]+$/D';
14+
private const string UNTERMINATED_LINE_PATTERN = '/^[^\r\n]+$/D';
15+
16+
/** @throws FixerException */
17+
public function process(Violation $violation): Fix
18+
{
19+
$affectedRange = $violation->rangeOne();
20+
$affectedContent = $affectedRange->content;
21+
22+
if ($affectedContent === null) {
23+
throw FixerException::cannotFixMissingContent();
24+
}
25+
26+
if (preg_match(self::LINE_ENDINGS_PATTERN, $affectedContent)) {
27+
return Fix::fromViolationAndRange($violation, $affectedRange, "\n");
28+
}
29+
30+
if (!preg_match(self::UNTERMINATED_LINE_PATTERN, $affectedContent)) {
31+
throw FixerException::cannotFixInvalidContent($violation);
32+
}
33+
34+
return Fix::fromViolationAndRange(
35+
$violation,
36+
$affectedRange,
37+
$affectedContent . "\n",
38+
);
39+
}
40+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Sniff;
6+
7+
use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
8+
use DocbookCS\Source\File;
9+
use DocbookCS\Violation\SourceRange;
10+
11+
final class FileEmptyLastLineSniffer extends AbstractSniff implements Fixable
12+
{
13+
private const string FILE_END_PATTERN = '/(?:[\r\n]+|[^\r\n]*)\z/';
14+
private const string REPORTING_MESSAGE = 'File must end with exactly one empty (LF) line.';
15+
16+
public static function getCode(): string
17+
{
18+
return 'DocbookCS.FileEmptyLastLine';
19+
}
20+
21+
public static function getFixerClassName(): string
22+
{
23+
return FileEmptyLastLineFixer::class;
24+
}
25+
26+
/**
27+
* @throws \InvalidArgumentException if a generated source range is inconsistent
28+
* @throws \OutOfBoundsException if a generated source range lies outside the source
29+
* @throws SniffException if the file ending cannot be identified
30+
*/
31+
public function process(\DOMDocument $document, File $file): array
32+
{
33+
if (preg_match(self::FILE_END_PATTERN, $file->content, $matches, PREG_OFFSET_CAPTURE) !== 1) {
34+
throw SniffException::cannotIdentifyFileEnding();
35+
}
36+
37+
[$affectedContent, $beginOffset] = $matches[0];
38+
39+
if ($affectedContent === "\n") {
40+
return [];
41+
}
42+
43+
return [
44+
$this->createViolation(
45+
$file->path,
46+
self::REPORTING_MESSAGE,
47+
[SourceRange::fromFile($file, $beginOffset, strlen($file->content))],
48+
),
49+
];
50+
}
51+
}

src/Sniff/SniffException.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Sniff;
6+
7+
final class SniffException extends \RuntimeException
8+
{
9+
public static function cannotIdentifyFileEnding(): self
10+
{
11+
return new self('Cannot identify file ending.');
12+
}
13+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Tests\Unit\Fix;
6+
7+
use DocbookCS\Fix\Fix;
8+
use DocbookCS\Fix\FixApplier;
9+
use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
10+
use DocbookCS\Fix\FixPlan;
11+
use DocbookCS\Fix\FixResult;
12+
use DocbookCS\Sniff\FileEmptyLastLineSniffer;
13+
use DocbookCS\Source\File;
14+
use DocbookCS\Violation\SourceRange;
15+
use DocbookCS\Violation\Violation;
16+
use PHPUnit\Framework\Attributes\CoversClass;
17+
use PHPUnit\Framework\Attributes\DataProvider;
18+
use PHPUnit\Framework\Attributes\Test;
19+
use PHPUnit\Framework\Attributes\UsesClass;
20+
use PHPUnit\Framework\TestCase;
21+
22+
#[
23+
CoversClass(FileEmptyLastLineFixer::class),
24+
CoversClass(FileEmptyLastLineSniffer::class),
25+
CoversClass(Fix::class),
26+
CoversClass(FixApplier::class),
27+
CoversClass(FixResult::class),
28+
//
29+
UsesClass(File::class),
30+
UsesClass(FixPlan::class),
31+
UsesClass(SourceRange::class),
32+
UsesClass(Violation::class),
33+
]
34+
final class FileEmptyLastLineFixerTest extends TestCase
35+
{
36+
#[Test]
37+
public function itTreatsTheCanonicalEndingAsANoOp(): void
38+
{
39+
$content = "<root/>\n";
40+
$source = new File('file.xml', $content);
41+
$range = new SourceRange(1, strlen($content) - 1, strlen($content), "\n");
42+
$violation = new Violation(
43+
FileEmptyLastLineSniffer::getCode(),
44+
$source->path,
45+
'violation.',
46+
[$range],
47+
);
48+
49+
$fix = new FileEmptyLastLineFixer()->process($violation);
50+
$result = new FixApplier()->apply($source, [$fix]);
51+
52+
self::assertSame($content, $result->file->content);
53+
self::assertSame(0, $result->applied);
54+
self::assertSame(1, $result->skipped);
55+
}
56+
57+
#[Test, DataProvider('nonCompliantEndings')]
58+
public function itLeavesExactlyOneLfEmptyLastLine(string $content, string $expected): void
59+
{
60+
$source = new File('file.xml', $content);
61+
$document = new \DOMDocument();
62+
$document->loadXML($content);
63+
$sniffer = new FileEmptyLastLineSniffer();
64+
$violation = $sniffer->process($document, $source)[0];
65+
66+
$fix = new FileEmptyLastLineFixer()->process($violation);
67+
$result = new FixApplier()->apply($source, [$fix]);
68+
69+
self::assertSame($expected, $result->file->content);
70+
self::assertSame(1, $result->applied);
71+
self::assertSame([], $sniffer->process($document, $result->file));
72+
}
73+
74+
/** @return iterable<string, array{string, string}> */
75+
public static function nonCompliantEndings(): iterable
76+
{
77+
yield 'missing ending' => ['<root/>', "<root/>\n"];
78+
yield 'after line feed content' => ["<root>\n</root>", "<root>\n</root>\n"];
79+
yield 'after carriage return and line feed content' => ["<root>\r\n</root>", "<root>\r\n</root>\n"];
80+
yield 'after carriage return content' => ["<root>\r</root>", "<root>\r</root>\n"];
81+
yield 'extra line feed' => ["<root/>\n\n", "<root/>\n"];
82+
yield 'carriage return and line feed' => ["<root/>\r\n", "<root/>\n"];
83+
yield 'carriage return' => ["<root/>\r", "<root/>\n"];
84+
yield 'extra carriage return and line feed' => ["<root/>\r\n\r\n", "<root/>\n"];
85+
yield 'extra carriage return' => ["<root/>\r\r", "<root/>\n"];
86+
yield 'multiple mixed extra endings' => ["<root/>\r\n\n\r", "<root/>\n"];
87+
}
88+
}

tests/Unit/Fix/FixerInputValidationTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use DocbookCS\Fix\Fixer\AttributeOrderFixer;
88
use DocbookCS\Fix\Fixer\ExceptionNameFixer;
9+
use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
910
use DocbookCS\Fix\Fixer\Fixer;
1011
use DocbookCS\Fix\Fixer\MixedIndentationFixer;
1112
use DocbookCS\Fix\Fixer\SimparaFixer;
@@ -22,6 +23,7 @@
2223
#[
2324
CoversClass(AttributeOrderFixer::class),
2425
CoversClass(ExceptionNameFixer::class),
26+
CoversClass(FileEmptyLastLineFixer::class),
2527
CoversClass(FixerException::class),
2628
CoversClass(MixedIndentationFixer::class),
2729
CoversClass(SimparaFixer::class),
@@ -50,6 +52,7 @@ public static function missingContent(): iterable
5052
new SourceRange(1, 0, 9),
5153
new SourceRange(1, 10, 19),
5254
]];
55+
yield 'file empty last line' => [new FileEmptyLastLineFixer(), [new SourceRange(1, 0, 4)]];
5356
yield 'mixed indentation' => [new MixedIndentationFixer(), [new SourceRange(1, 0, 2)]];
5457
yield 'simpara' => [new SimparaFixer(), [
5558
new SourceRange(1, 0, 4),
@@ -77,6 +80,14 @@ public static function invalidContent(): iterable
7780
new SourceRange(1, 0, 5, 'class'),
7881
new SourceRange(1, 6, 11, 'class'),
7982
]];
83+
yield 'file empty last line with mixed content' => [
84+
new FileEmptyLastLineFixer(),
85+
[new SourceRange(1, 0, 9, "text\ntext")],
86+
];
87+
yield 'file empty last line with empty range' => [
88+
new FileEmptyLastLineFixer(),
89+
[new SourceRange(1, 0, 0, '')],
90+
];
8091
yield 'mixed indentation' => [new MixedIndentationFixer(), [new SourceRange(1, 0, 2, ' ')]];
8192
yield 'simpara' => [new SimparaFixer(), [
8293
new SourceRange(1, 0, 4, 'span'),

tests/Unit/Fix/WhitespaceConcernFixersTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
use DocbookCS\Fix\Fix;
88
use DocbookCS\Fix\FixApplier;
99
use DocbookCS\Fix\FixPlan;
10+
use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
1011
use DocbookCS\Fix\Fixer\MixedIndentationFixer;
1112
use DocbookCS\Fix\Fixer\TrailingWhitespaceFixer;
1213
use DocbookCS\Fix\FixResult;
14+
use DocbookCS\Sniff\FileEmptyLastLineSniffer;
1315
use DocbookCS\Sniff\MixedIndentationSniff;
1416
use DocbookCS\Sniff\TrailingWhitespaceSniff;
1517
use DocbookCS\Source\File;
@@ -25,6 +27,8 @@
2527
CoversClass(Fix::class),
2628
CoversClass(FixApplier::class),
2729
CoversClass(FixResult::class),
30+
CoversClass(FileEmptyLastLineFixer::class),
31+
CoversClass(FileEmptyLastLineSniffer::class),
2832
CoversClass(MixedIndentationFixer::class),
2933
CoversClass(MixedIndentationSniff::class),
3034
CoversClass(TrailingWhitespaceFixer::class),
@@ -72,4 +76,25 @@ public function itFixesIndependentWhitespaceConcernsTogether(): void
7276
self::assertSame(3, $result->applied);
7377
self::assertSame(0, $result->skipped);
7478
}
79+
80+
#[Test]
81+
public function itFixesTrailingWhitespaceAndTheFileEndingTogether(): void
82+
{
83+
$content = "<root/> \n\n";
84+
$document = new \DOMDocument();
85+
$document->loadXML($content);
86+
$source = new File('file.xml', $content);
87+
88+
$trailingViolation = new TrailingWhitespaceSniff()->process($document, $source)[0];
89+
$fileEndingViolation = new FileEmptyLastLineSniffer()->process($document, $source)[0];
90+
91+
$result = new FixApplier()->apply($source, [
92+
new TrailingWhitespaceFixer()->process($trailingViolation),
93+
new FileEmptyLastLineFixer()->process($fileEndingViolation),
94+
]);
95+
96+
self::assertSame("<root/>\n", $result->file->content);
97+
self::assertSame(2, $result->applied);
98+
self::assertSame(0, $result->skipped);
99+
}
75100
}

tests/Unit/Runner/SourceScopeTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use DocbookCS\Diff\FileChange;
88
use DocbookCS\Fix\Fix;
99
use DocbookCS\Runner\RunScope;
10+
use DocbookCS\Sniff\FileEmptyLastLineSniffer;
1011
use DocbookCS\Source\File;
1112
use DocbookCS\Source\Line;
1213
use DocbookCS\Violation\SourceRange;
@@ -23,6 +24,7 @@
2324
CoversClass(RunScope::class),
2425
//
2526
UsesClass(FileChange::class),
27+
UsesClass(FileEmptyLastLineSniffer::class),
2628
UsesClass(SourceRange::class),
2729
UsesClass(Violation::class),
2830
]
@@ -172,6 +174,22 @@ public function itAnchorsADeletionAtTheEndOfTheFile(): void
172174
self::assertTrue($scope->includes($this->violation($untilOffset, $untilOffset, 2)));
173175
}
174176

177+
#[Test]
178+
public function itScopesAnUnterminatedFileEndingToItsLastLine(): void
179+
{
180+
$file = new File('file.xml', "<root>\n</root>");
181+
$document = new \DOMDocument();
182+
$document->loadXML($file->content);
183+
$violation = new FileEmptyLastLineSniffer()->process($document, $file)[0];
184+
185+
self::assertTrue(
186+
RunScope::fromFileAndFileChange($file, new FileChange($file->path, [2]))->includes($violation),
187+
);
188+
self::assertFalse(
189+
RunScope::fromFileAndFileChange($file, new FileChange($file->path, [1]))->includes($violation),
190+
);
191+
}
192+
175193
private function violation(int $beginOffset, int $untilOffset, int $line): Violation
176194
{
177195
return new Violation(

0 commit comments

Comments
 (0)