diff --git a/docbookcs.xml.dist b/docbookcs.xml.dist
index 51ee17a..bed6138 100644
--- a/docbookcs.xml.dist
+++ b/docbookcs.xml.dist
@@ -16,6 +16,7 @@
+
diff --git a/src/Fix/Fixer/FileEmptyLastLineFixer.php b/src/Fix/Fixer/FileEmptyLastLineFixer.php
new file mode 100644
index 0000000..bfbaa13
--- /dev/null
+++ b/src/Fix/Fixer/FileEmptyLastLineFixer.php
@@ -0,0 +1,40 @@
+rangeOne();
+ $affectedContent = $affectedRange->content;
+
+ if ($affectedContent === null) {
+ throw FixerException::cannotFixMissingContent();
+ }
+
+ if (preg_match(self::LINE_ENDINGS_PATTERN, $affectedContent)) {
+ return Fix::fromViolationAndRange($violation, $affectedRange, "\n");
+ }
+
+ if (!preg_match(self::UNTERMINATED_LINE_PATTERN, $affectedContent)) {
+ throw FixerException::cannotFixInvalidContent($violation);
+ }
+
+ return Fix::fromViolationAndRange(
+ $violation,
+ $affectedRange,
+ $affectedContent . "\n",
+ );
+ }
+}
diff --git a/src/Sniff/FileEmptyLastLineSniffer.php b/src/Sniff/FileEmptyLastLineSniffer.php
new file mode 100644
index 0000000..6d99be2
--- /dev/null
+++ b/src/Sniff/FileEmptyLastLineSniffer.php
@@ -0,0 +1,51 @@
+content, $matches, PREG_OFFSET_CAPTURE) !== 1) {
+ throw SniffException::cannotIdentifyFileEnding();
+ }
+
+ [$affectedContent, $beginOffset] = $matches[0];
+
+ if ($affectedContent === "\n") {
+ return [];
+ }
+
+ return [
+ $this->createViolation(
+ $file->path,
+ self::REPORTING_MESSAGE,
+ [SourceRange::fromFile($file, $beginOffset, strlen($file->content))],
+ ),
+ ];
+ }
+}
diff --git a/src/Sniff/SniffException.php b/src/Sniff/SniffException.php
new file mode 100644
index 0000000..4dc42eb
--- /dev/null
+++ b/src/Sniff/SniffException.php
@@ -0,0 +1,13 @@
+\n";
+ $source = new File('file.xml', $content);
+ $range = new SourceRange(1, strlen($content) - 1, strlen($content), "\n");
+ $violation = new Violation(
+ FileEmptyLastLineSniffer::getCode(),
+ $source->path,
+ 'violation.',
+ [$range],
+ );
+
+ $fix = new FileEmptyLastLineFixer()->process($violation);
+ $result = new FixApplier()->apply($source, [$fix]);
+
+ self::assertSame($content, $result->file->content);
+ self::assertSame(0, $result->applied);
+ self::assertSame(1, $result->skipped);
+ }
+
+ #[Test, DataProvider('nonCompliantEndings')]
+ public function itLeavesExactlyOneLfEmptyLastLine(string $content, string $expected): void
+ {
+ $source = new File('file.xml', $content);
+ $document = new \DOMDocument();
+ $document->loadXML($content);
+ $sniffer = new FileEmptyLastLineSniffer();
+ $violation = $sniffer->process($document, $source)[0];
+
+ $fix = new FileEmptyLastLineFixer()->process($violation);
+ $result = new FixApplier()->apply($source, [$fix]);
+
+ self::assertSame($expected, $result->file->content);
+ self::assertSame(1, $result->applied);
+ self::assertSame([], $sniffer->process($document, $result->file));
+ }
+
+ /** @return iterable */
+ public static function nonCompliantEndings(): iterable
+ {
+ yield 'missing ending' => ['', "\n"];
+ yield 'after line feed content' => ["\n", "\n\n"];
+ yield 'after carriage return and line feed content' => ["\r\n", "\r\n\n"];
+ yield 'after carriage return content' => ["\r", "\r\n"];
+ yield 'extra line feed' => ["\n\n", "\n"];
+ yield 'carriage return and line feed' => ["\r\n", "\n"];
+ yield 'carriage return' => ["\r", "\n"];
+ yield 'extra carriage return and line feed' => ["\r\n\r\n", "\n"];
+ yield 'extra carriage return' => ["\r\r", "\n"];
+ yield 'multiple mixed extra endings' => ["\r\n\n\r", "\n"];
+ }
+}
diff --git a/tests/Unit/Fix/FixerInputValidationTest.php b/tests/Unit/Fix/FixerInputValidationTest.php
index 7163752..e4c1608 100644
--- a/tests/Unit/Fix/FixerInputValidationTest.php
+++ b/tests/Unit/Fix/FixerInputValidationTest.php
@@ -6,6 +6,7 @@
use DocbookCS\Fix\Fixer\AttributeOrderFixer;
use DocbookCS\Fix\Fixer\ExceptionNameFixer;
+use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
use DocbookCS\Fix\Fixer\Fixer;
use DocbookCS\Fix\Fixer\MixedIndentationFixer;
use DocbookCS\Fix\Fixer\SimparaFixer;
@@ -22,6 +23,7 @@
#[
CoversClass(AttributeOrderFixer::class),
CoversClass(ExceptionNameFixer::class),
+ CoversClass(FileEmptyLastLineFixer::class),
CoversClass(FixerException::class),
CoversClass(MixedIndentationFixer::class),
CoversClass(SimparaFixer::class),
@@ -50,6 +52,7 @@ public static function missingContent(): iterable
new SourceRange(1, 0, 9),
new SourceRange(1, 10, 19),
]];
+ yield 'file empty last line' => [new FileEmptyLastLineFixer(), [new SourceRange(1, 0, 4)]];
yield 'mixed indentation' => [new MixedIndentationFixer(), [new SourceRange(1, 0, 2)]];
yield 'simpara' => [new SimparaFixer(), [
new SourceRange(1, 0, 4),
@@ -77,6 +80,14 @@ public static function invalidContent(): iterable
new SourceRange(1, 0, 5, 'class'),
new SourceRange(1, 6, 11, 'class'),
]];
+ yield 'file empty last line with mixed content' => [
+ new FileEmptyLastLineFixer(),
+ [new SourceRange(1, 0, 9, "text\ntext")],
+ ];
+ yield 'file empty last line with empty range' => [
+ new FileEmptyLastLineFixer(),
+ [new SourceRange(1, 0, 0, '')],
+ ];
yield 'mixed indentation' => [new MixedIndentationFixer(), [new SourceRange(1, 0, 2, ' ')]];
yield 'simpara' => [new SimparaFixer(), [
new SourceRange(1, 0, 4, 'span'),
diff --git a/tests/Unit/Fix/WhitespaceConcernFixersTest.php b/tests/Unit/Fix/WhitespaceConcernFixersTest.php
index 5067538..819b33f 100644
--- a/tests/Unit/Fix/WhitespaceConcernFixersTest.php
+++ b/tests/Unit/Fix/WhitespaceConcernFixersTest.php
@@ -7,9 +7,11 @@
use DocbookCS\Fix\Fix;
use DocbookCS\Fix\FixApplier;
use DocbookCS\Fix\FixPlan;
+use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
use DocbookCS\Fix\Fixer\MixedIndentationFixer;
use DocbookCS\Fix\Fixer\TrailingWhitespaceFixer;
use DocbookCS\Fix\FixResult;
+use DocbookCS\Sniff\FileEmptyLastLineSniffer;
use DocbookCS\Sniff\MixedIndentationSniff;
use DocbookCS\Sniff\TrailingWhitespaceSniff;
use DocbookCS\Source\File;
@@ -25,6 +27,8 @@
CoversClass(Fix::class),
CoversClass(FixApplier::class),
CoversClass(FixResult::class),
+ CoversClass(FileEmptyLastLineFixer::class),
+ CoversClass(FileEmptyLastLineSniffer::class),
CoversClass(MixedIndentationFixer::class),
CoversClass(MixedIndentationSniff::class),
CoversClass(TrailingWhitespaceFixer::class),
@@ -72,4 +76,25 @@ public function itFixesIndependentWhitespaceConcernsTogether(): void
self::assertSame(3, $result->applied);
self::assertSame(0, $result->skipped);
}
+
+ #[Test]
+ public function itFixesTrailingWhitespaceAndTheFileEndingTogether(): void
+ {
+ $content = " \n\n";
+ $document = new \DOMDocument();
+ $document->loadXML($content);
+ $source = new File('file.xml', $content);
+
+ $trailingViolation = new TrailingWhitespaceSniff()->process($document, $source)[0];
+ $fileEndingViolation = new FileEmptyLastLineSniffer()->process($document, $source)[0];
+
+ $result = new FixApplier()->apply($source, [
+ new TrailingWhitespaceFixer()->process($trailingViolation),
+ new FileEmptyLastLineFixer()->process($fileEndingViolation),
+ ]);
+
+ self::assertSame("\n", $result->file->content);
+ self::assertSame(2, $result->applied);
+ self::assertSame(0, $result->skipped);
+ }
}
diff --git a/tests/Unit/Runner/SourceScopeTest.php b/tests/Unit/Runner/SourceScopeTest.php
index 3d74c0a..a77a81a 100644
--- a/tests/Unit/Runner/SourceScopeTest.php
+++ b/tests/Unit/Runner/SourceScopeTest.php
@@ -7,6 +7,7 @@
use DocbookCS\Diff\FileChange;
use DocbookCS\Fix\Fix;
use DocbookCS\Runner\RunScope;
+use DocbookCS\Sniff\FileEmptyLastLineSniffer;
use DocbookCS\Source\File;
use DocbookCS\Source\Line;
use DocbookCS\Violation\SourceRange;
@@ -23,6 +24,7 @@
CoversClass(RunScope::class),
//
UsesClass(FileChange::class),
+ UsesClass(FileEmptyLastLineSniffer::class),
UsesClass(SourceRange::class),
UsesClass(Violation::class),
]
@@ -172,6 +174,22 @@ public function itAnchorsADeletionAtTheEndOfTheFile(): void
self::assertTrue($scope->includes($this->violation($untilOffset, $untilOffset, 2)));
}
+ #[Test]
+ public function itScopesAnUnterminatedFileEndingToItsLastLine(): void
+ {
+ $file = new File('file.xml', "\n");
+ $document = new \DOMDocument();
+ $document->loadXML($file->content);
+ $violation = new FileEmptyLastLineSniffer()->process($document, $file)[0];
+
+ self::assertTrue(
+ RunScope::fromFileAndFileChange($file, new FileChange($file->path, [2]))->includes($violation),
+ );
+ self::assertFalse(
+ RunScope::fromFileAndFileChange($file, new FileChange($file->path, [1]))->includes($violation),
+ );
+ }
+
private function violation(int $beginOffset, int $untilOffset, int $line): Violation
{
return new Violation(
diff --git a/tests/Unit/Sniff/FileEmptyLastLineSnifferTest.php b/tests/Unit/Sniff/FileEmptyLastLineSnifferTest.php
new file mode 100644
index 0000000..092b93b
--- /dev/null
+++ b/tests/Unit/Sniff/FileEmptyLastLineSnifferTest.php
@@ -0,0 +1,112 @@
+process("\n"));
+ }
+
+ #[Test, DataProvider('missingEndings')]
+ public function itReportsAnUnterminatedLastLine(string $content, string $affectedContent): void
+ {
+ $violation = $this->process($content)[0];
+ $beginOffset = strlen($content) - strlen($affectedContent);
+
+ self::assertSame('DocbookCS.FileEmptyLastLine', $violation->sniffCode);
+ self::assertSame('File must end with exactly one empty (LF) line.', $violation->message);
+ self::assertSame($affectedContent, $violation->rangeOne()->content);
+ self::assertSame($beginOffset, $violation->rangeOne()->beginOffset);
+ self::assertSame(strlen($content), $violation->rangeOne()->untilOffset);
+ self::assertSame(2, $violation->rangeOne()->line);
+ }
+
+ #[Test]
+ public function itReportsTheOnlyLineWhenItsEndingIsMissing(): void
+ {
+ $content = '';
+ $violation = $this->process($content)[0];
+
+ self::assertSame($content, $violation->rangeOne()->content);
+ self::assertSame(0, $violation->rangeOne()->beginOffset);
+ self::assertSame(strlen($content), $violation->rangeOne()->untilOffset);
+ self::assertSame(1, $violation->rangeOne()->line);
+ }
+
+ #[Test, DataProvider('nonCanonicalEndings')]
+ public function itReportsTheEntireNonCanonicalEnding(
+ string $content,
+ string $affectedContent,
+ int $expectedLine,
+ ): void {
+ $violation = $this->process($content)[0];
+ $beginOffset = strlen($content) - strlen($affectedContent);
+
+ self::assertSame($affectedContent, $violation->rangeOne()->content);
+ self::assertSame($beginOffset, $violation->rangeOne()->beginOffset);
+ self::assertSame(strlen($content), $violation->rangeOne()->untilOffset);
+ self::assertSame($expectedLine, $violation->rangeOne()->line);
+ }
+
+ /** @return iterable */
+ public static function missingEndings(): iterable
+ {
+ yield 'line feed file' => ["\n", ''];
+ yield 'carriage return and line feed file' => ["\r\n", ''];
+ yield 'carriage return file' => ["\r", ''];
+ yield 'line feed at byte zero' => ["\n", ''];
+ yield 'carriage return and line feed at byte zero' => ["\r\n", ''];
+ yield 'carriage return at byte zero' => ["\r", ''];
+ }
+
+ /** @return iterable */
+ public static function nonCanonicalEndings(): iterable
+ {
+ yield 'carriage return and line feed' => ["\r\n", "\r\n", 1];
+ yield 'carriage return' => ["\r", "\r", 1];
+ yield 'line feeds' => ["\n\n", "\n\n", 1];
+ yield 'carriage returns and line feeds' => ["\r\n\r\n", "\r\n\r\n", 1];
+ yield 'carriage returns' => ["\r\r", "\r\r", 1];
+ yield 'multiple mixed endings' => ["\r\n\n\r", "\r\n\n\r", 1];
+ yield 'multiline file' => ["\n\r\n", "\r\n", 2];
+ }
+
+ /** @return list */
+ private function process(string $content): array
+ {
+ $document = new \DOMDocument();
+ $document->loadXML($content);
+
+ return new FileEmptyLastLineSniffer()->process($document, new File('file.xml', $content));
+ }
+}