Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docbookcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
</sniff>

<sniff class="DocbookCS\Sniff\AttributeOrderSniff" />
<sniff class="DocbookCS\Sniff\FileEmptyLastLineSniffer" />
</sniffs>

<paths>
Expand Down
40 changes: 40 additions & 0 deletions src/Fix/Fixer/FileEmptyLastLineFixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Fix\Fixer;

use DocbookCS\Fix\Fix;
use DocbookCS\Fix\FixerException;
use DocbookCS\Violation\Violation;

final class FileEmptyLastLineFixer implements Fixer
{
private const string LINE_ENDINGS_PATTERN = '/^[\r\n]+$/D';
private const string UNTERMINATED_LINE_PATTERN = '/^[^\r\n]+$/D';

/** @throws FixerException */
public function process(Violation $violation): Fix
{
$affectedRange = $violation->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",
);
}
}
51 changes: 51 additions & 0 deletions src/Sniff/FileEmptyLastLineSniffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Sniff;

use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
use DocbookCS\Source\File;
use DocbookCS\Violation\SourceRange;

final class FileEmptyLastLineSniffer extends AbstractSniff implements Fixable
{
private const string FILE_END_PATTERN = '/(?:[\r\n]+|[^\r\n]*)\z/';
private const string REPORTING_MESSAGE = 'File must end with exactly one empty (LF) line.';

public static function getCode(): string
{
return 'DocbookCS.FileEmptyLastLine';
}

public static function getFixerClassName(): string
{
return FileEmptyLastLineFixer::class;
}

/**
* @throws \InvalidArgumentException if a generated source range is inconsistent
* @throws \OutOfBoundsException if a generated source range lies outside the source
* @throws SniffException if the file ending cannot be identified
*/
public function process(\DOMDocument $document, File $file): array
{
if (preg_match(self::FILE_END_PATTERN, $file->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))],
),
];
}
}
13 changes: 13 additions & 0 deletions src/Sniff/SniffException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Sniff;

final class SniffException extends \RuntimeException
{
public static function cannotIdentifyFileEnding(): self
{
return new self('Cannot identify file ending.');
}
}
88 changes: 88 additions & 0 deletions tests/Unit/Fix/FileEmptyLastLineFixerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Tests\Unit\Fix;

use DocbookCS\Fix\Fix;
use DocbookCS\Fix\FixApplier;
use DocbookCS\Fix\Fixer\FileEmptyLastLineFixer;
use DocbookCS\Fix\FixPlan;
use DocbookCS\Fix\FixResult;
use DocbookCS\Sniff\FileEmptyLastLineSniffer;
use DocbookCS\Source\File;
use DocbookCS\Violation\SourceRange;
use DocbookCS\Violation\Violation;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;

#[
CoversClass(FileEmptyLastLineFixer::class),
CoversClass(FileEmptyLastLineSniffer::class),
CoversClass(Fix::class),
CoversClass(FixApplier::class),
CoversClass(FixResult::class),
//
UsesClass(File::class),
UsesClass(FixPlan::class),
UsesClass(SourceRange::class),
UsesClass(Violation::class),
]
final class FileEmptyLastLineFixerTest extends TestCase
{
#[Test]
public function itTreatsTheCanonicalEndingAsANoOp(): void
{
$content = "<root/>\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<string, array{string, string}> */
public static function nonCompliantEndings(): iterable
{
yield 'missing ending' => ['<root/>', "<root/>\n"];
yield 'after line feed content' => ["<root>\n</root>", "<root>\n</root>\n"];
yield 'after carriage return and line feed content' => ["<root>\r\n</root>", "<root>\r\n</root>\n"];
yield 'after carriage return content' => ["<root>\r</root>", "<root>\r</root>\n"];
yield 'extra line feed' => ["<root/>\n\n", "<root/>\n"];
yield 'carriage return and line feed' => ["<root/>\r\n", "<root/>\n"];
yield 'carriage return' => ["<root/>\r", "<root/>\n"];
yield 'extra carriage return and line feed' => ["<root/>\r\n\r\n", "<root/>\n"];
yield 'extra carriage return' => ["<root/>\r\r", "<root/>\n"];
yield 'multiple mixed extra endings' => ["<root/>\r\n\n\r", "<root/>\n"];
}
}
11 changes: 11 additions & 0 deletions tests/Unit/Fix/FixerInputValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,7 @@
#[
CoversClass(AttributeOrderFixer::class),
CoversClass(ExceptionNameFixer::class),
CoversClass(FileEmptyLastLineFixer::class),
CoversClass(FixerException::class),
CoversClass(MixedIndentationFixer::class),
CoversClass(SimparaFixer::class),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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'),
Expand Down
25 changes: 25 additions & 0 deletions tests/Unit/Fix/WhitespaceConcernFixersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down Expand Up @@ -72,4 +76,25 @@ public function itFixesIndependentWhitespaceConcernsTogether(): void
self::assertSame(3, $result->applied);
self::assertSame(0, $result->skipped);
}

#[Test]
public function itFixesTrailingWhitespaceAndTheFileEndingTogether(): void
{
$content = "<root/> \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("<root/>\n", $result->file->content);
self::assertSame(2, $result->applied);
self::assertSame(0, $result->skipped);
}
}
18 changes: 18 additions & 0 deletions tests/Unit/Runner/SourceScopeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@
CoversClass(RunScope::class),
//
UsesClass(FileChange::class),
UsesClass(FileEmptyLastLineSniffer::class),
UsesClass(SourceRange::class),
UsesClass(Violation::class),
]
Expand Down Expand Up @@ -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', "<root>\n</root>");
$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(
Expand Down
Loading