From 523597deab532590b914c128e0921ea406527cfb Mon Sep 17 00:00:00 2001 From: NickSdot Date: Sat, 5 Sep 2026 22:07:02 +0800 Subject: [PATCH] feat: added FileEmptyLastLine{Sniffer,Fixer} --- docbookcs.xml.dist | 1 + src/Fix/Fixer/FileEmptyLastLineFixer.php | 40 +++++++ src/Sniff/FileEmptyLastLineSniffer.php | 51 ++++++++ src/Sniff/SniffException.php | 13 ++ tests/Unit/Fix/FileEmptyLastLineFixerTest.php | 88 ++++++++++++++ tests/Unit/Fix/FixerInputValidationTest.php | 11 ++ .../Unit/Fix/WhitespaceConcernFixersTest.php | 25 ++++ tests/Unit/Runner/SourceScopeTest.php | 18 +++ .../Sniff/FileEmptyLastLineSnifferTest.php | 112 ++++++++++++++++++ 9 files changed, 359 insertions(+) create mode 100644 src/Fix/Fixer/FileEmptyLastLineFixer.php create mode 100644 src/Sniff/FileEmptyLastLineSniffer.php create mode 100644 src/Sniff/SniffException.php create mode 100644 tests/Unit/Fix/FileEmptyLastLineFixerTest.php create mode 100644 tests/Unit/Sniff/FileEmptyLastLineSnifferTest.php 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)); + } +}