diff --git a/docbookcs.xml.dist b/docbookcs.xml.dist
index 51ee17a..012c7f0 100644
--- a/docbookcs.xml.dist
+++ b/docbookcs.xml.dist
@@ -16,6 +16,8 @@
+
+
diff --git a/src/Fix/Fixer/Fixer.php b/src/Fix/Fixer/Fixer.php
index a488c7f..8ddefdb 100644
--- a/src/Fix/Fixer/Fixer.php
+++ b/src/Fix/Fixer/Fixer.php
@@ -9,8 +9,12 @@
use DocbookCS\Fix\FixerException;
use DocbookCS\Violation\Violation;
+/** @template TFixerData = mixed */
interface Fixer
{
- /** @throws FixerException */
+ /**
+ * @param Violation $violation
+ * @throws FixerException
+ */
public function process(Violation $violation): Fix|FixPlan;
}
diff --git a/src/Fix/Fixer/IndentationFixer.php b/src/Fix/Fixer/IndentationFixer.php
new file mode 100644
index 0000000..afb467e
--- /dev/null
+++ b/src/Fix/Fixer/IndentationFixer.php
@@ -0,0 +1,44 @@
+ */
+final class IndentationFixer implements Fixer
+{
+ private const string INDENTATION_PATTERN = '/^[ \t]*$/D';
+
+ /** @throws FixerException */
+ public function process(Violation $violation): Fix
+ {
+ $affectedRange = $violation->rangeOne();
+
+ if ($affectedRange->content === null) {
+ throw FixerException::cannotFixMissingContent();
+ }
+
+ $expectedDepth = $violation->fixerData;
+
+ if (
+ count($violation->affectedRanges) !== 1
+ || !preg_match(self::INDENTATION_PATTERN, $affectedRange->content)
+ || !is_int($expectedDepth)
+ || $expectedDepth < 0
+ ) {
+ throw FixerException::cannotFixInvalidContent($violation);
+ }
+
+ $replacement = str_repeat(' ', $expectedDepth);
+
+ if ($affectedRange->content === $replacement) {
+ throw FixerException::cannotFixInvalidContent($violation);
+ }
+
+ return Fix::fromViolationAndRange($violation, $affectedRange, $replacement);
+ }
+}
diff --git a/src/Fix/Fixer/MixedIndentationFixer.php b/src/Fix/Fixer/MixedIndentationFixer.php
deleted file mode 100644
index 8241a46..0000000
--- a/src/Fix/Fixer/MixedIndentationFixer.php
+++ /dev/null
@@ -1,38 +0,0 @@
-rangeOne();
-
- if ($affectedRange->content === null) {
- throw FixerException::cannotFixMissingContent();
- }
-
- if (
- !preg_match(self::INDENTATION_PATTERN, $affectedRange->content)
- || !str_contains($affectedRange->content, ' ')
- || !str_contains($affectedRange->content, "\t")
- ) {
- throw FixerException::cannotFixInvalidContent($violation);
- }
-
- return Fix::fromViolationAndRange(
- $violation,
- $affectedRange,
- str_replace("\t", ' ', $affectedRange->content),
- );
- }
-}
diff --git a/src/IndentationAnalyzer.php b/src/IndentationAnalyzer.php
new file mode 100644
index 0000000..9934c4f
--- /dev/null
+++ b/src/IndentationAnalyzer.php
@@ -0,0 +1,231 @@
+\/)?(?[a-z_:][a-z0-9_.:-]*)"
+ . "(?(?:\"[^\"]*\"|'[^']*'|[^'\">])*)>/is";
+ private const string XML_SPACE_PATTERN = '/\sxml:space\s*=\s*(["\'])(preserve|default)\1/';
+
+ /**
+ * The complete set of elements using db.verbatim.attributes in DocBook 5.2+.
+ * Their whitespace must be preserved even without xml:space="preserve".
+ */
+ private const array VERBATIM_ELEMENTS = [
+ 'address',
+ 'classsynopsisinfo',
+ 'funcsynopsisinfo',
+ 'literallayout',
+ 'programlisting',
+ 'screen',
+ 'synopsis',
+ 'synopsisinfo',
+ ];
+
+ /**
+ * @return \Generator
+ * @throws \InvalidArgumentException if a generated source range is inconsistent
+ */
+ public function analyze(File $file): \Generator
+ {
+ $maskedContent = $file->contentWithNonElementMarkupMasked();
+
+ $tagIndex = 0;
+ $tags = $this->findTags($maskedContent);
+
+ /** @var list $elementStack */
+ $elementStack = [];
+
+ foreach ($file->lines() as $line) {
+
+ $firstTag = $tags[$tagIndex] ?? null;
+ $mismatch = $this->mismatchForLine($line, $maskedContent, $firstTag, $elementStack);
+
+ if ($mismatch !== null) {
+ yield $mismatch;
+ }
+
+ while (isset($tags[$tagIndex]) && $tags[$tagIndex]['untilOffset'] <= $line->offsetAfterContent()) {
+ $this->applyTagToElementStack($tags[$tagIndex], $elementStack);
+ $tagIndex++;
+ }
+ }
+ }
+
+ /**
+ * @param SourceTag|null $firstTag
+ * @param list $elementStack
+ * @return IndentationMismatch|null
+ * @throws \InvalidArgumentException if the generated source range is inconsistent
+ */
+ private function mismatchForLine(Line $line, string $maskedContent, ?array $firstTag, array $elementStack): ?array
+ {
+ preg_match(self::INDENTATION_PATTERN, $line->content, $matches);
+
+ $indentation = $matches[0] ?? '';
+ $contentOffset = $line->beginOffset + strlen($indentation);
+
+ if (!$this->shouldCheckLine($line, $contentOffset, $maskedContent, $firstTag)) {
+ return null;
+ }
+
+ $startsWithClosingTag = $firstTag !== null
+ && $firstTag['beginOffset'] === $contentOffset
+ && $firstTag['closing'];
+
+ $insidePreservedRegion = $elementStack !== []
+ && $elementStack[array_key_last($elementStack)]['preservesWhitespace'];
+
+ if (
+ $insidePreservedRegion
+ && !$this->closesPreservedRegion($startsWithClosingTag, $firstTag, $elementStack)
+ ) {
+ return null;
+ }
+
+ $expectedDepth = max(0, count($elementStack) - ($startsWithClosingTag ? 1 : 0));
+
+ if ($indentation === str_repeat(' ', $expectedDepth)) {
+ return null;
+ }
+
+ return [
+ 'range' => new SourceRange(
+ line: $line->number,
+ beginOffset: $line->beginOffset,
+ untilOffset: $contentOffset,
+ content: $indentation,
+ ),
+ 'expectedDepth' => $expectedDepth,
+ ];
+ }
+
+ /** @param SourceTag|null $firstTag */
+ private function shouldCheckLine(Line $line, int $contentOffset, string $maskedContent, ?array $firstTag): bool
+ {
+ if ($contentOffset >= $line->offsetAfterContent()) {
+ return false;
+ }
+
+ if ($maskedContent[$contentOffset] === ' ') {
+ return false;
+ }
+
+ if ($firstTag === null) {
+ return true;
+ }
+
+ return $contentOffset <= $firstTag['beginOffset'] || $contentOffset >= $firstTag['untilOffset'];
+ }
+
+ /**
+ * @param SourceTag|null $tag
+ * @param list $elementStack
+ */
+ private function closesPreservedRegion(bool $startsWithClosingTag, ?array $tag, array $elementStack): bool
+ {
+ if (!$startsWithClosingTag || $tag === null || $elementStack === []) {
+ return false;
+ }
+
+ $elementIndex = array_key_last($elementStack);
+ $element = $elementStack[$elementIndex];
+
+ $parentPreservesWhitespace = $elementIndex > 0
+ && $elementStack[$elementIndex - 1]['preservesWhitespace'];
+
+ return $element['preservesWhitespace']
+ && !$parentPreservesWhitespace
+ && $element['name'] === $tag['name'];
+ }
+
+ /**
+ * @param SourceTag $tag
+ * @param list &$elementStack
+ */
+ private function applyTagToElementStack(array $tag, array &$elementStack): void
+ {
+ if ($tag['closing']) {
+ array_pop($elementStack);
+ return;
+ }
+
+ if ($tag['selfClosing']) {
+ return;
+ }
+
+ $inherited = $elementStack !== []
+ && $elementStack[array_key_last($elementStack)]['preservesWhitespace'];
+
+ $elementStack[] = [
+ 'name' => $tag['name'],
+ 'preservesWhitespace' => $this->preservesWhitespace($tag['name'], $tag['xmlSpace'], $inherited),
+ ];
+ }
+
+ private function preservesWhitespace(string $elementName, ?string $xmlSpace, bool $inherited): bool
+ {
+ $separator = strrpos($elementName, ':');
+
+ $localName = strtolower($separator === false
+ ? $elementName
+ : substr($elementName, $separator + 1));
+
+ if (in_array($localName, self::VERBATIM_ELEMENTS, true)) {
+ return true;
+ }
+
+ return match ($xmlSpace) {
+ 'preserve' => true,
+ 'default' => false,
+ default => $inherited,
+ };
+ }
+
+ /** @return list */
+ private function findTags(string $maskedContent): array
+ {
+ $tags = [];
+
+ preg_match_all(self::TAG_PATTERN, $maskedContent, $matches, PREG_OFFSET_CAPTURE);
+
+ foreach ($matches[0] as $index => [$tag, $beginOffset]) {
+
+ preg_match(self::XML_SPACE_PATTERN, $matches['attributes'][$index][0], $xmlSpaceMatches);
+
+ $tags[] = [
+ 'beginOffset' => (int) $beginOffset,
+ 'untilOffset' => (int) $beginOffset + strlen($tag),
+ 'name' => $matches['name'][$index][0],
+ 'closing' => $matches['closing'][$index][0] !== '',
+ 'selfClosing' => str_ends_with(rtrim($tag), '/>'),
+ 'xmlSpace' => $xmlSpaceMatches[2] ?? null,
+ ];
+ }
+
+ return $tags;
+ }
+}
diff --git a/src/Sniff/AbstractSniff.php b/src/Sniff/AbstractSniff.php
index 8bc8997..b73d80a 100644
--- a/src/Sniff/AbstractSniff.php
+++ b/src/Sniff/AbstractSniff.php
@@ -10,6 +10,10 @@
use DocbookCS\Violation\SourceRange;
use DocbookCS\Violation\Violation;
+/**
+ * @template TFixerData = mixed
+ * @implements SniffInterface
+ */
abstract class AbstractSniff implements SniffInterface
{
protected Severity $severity = Severity::ERROR;
@@ -72,16 +76,23 @@ protected function elementNameRanges(File $file, int $beginOffset, int $untilOff
/**
* @param non-empty-list $affectedRanges
+ * @param TFixerData $fixerData
*
+ * @return Violation
* @throws \InvalidArgumentException if the affected ranges are inconsistent
*/
- protected function createViolation(string $filePath, string $message, array $affectedRanges): Violation
- {
+ protected function createViolation(
+ string $filePath,
+ string $message,
+ array $affectedRanges,
+ mixed $fixerData = null,
+ ): Violation {
return new Violation(
sniffCode: static::getCode(),
filePath: $filePath,
message: $message,
affectedRanges: $affectedRanges,
+ fixerData: $fixerData,
severity: $this->severity,
);
}
diff --git a/src/Sniff/Fixable.php b/src/Sniff/Fixable.php
index 270bfab..d21ce0a 100644
--- a/src/Sniff/Fixable.php
+++ b/src/Sniff/Fixable.php
@@ -6,8 +6,12 @@
use DocbookCS\Fix\Fixer\Fixer;
+/**
+ * @template TFixerData = mixed
+ * @extends SniffInterface
+ */
interface Fixable extends SniffInterface
{
- /** @return class-string */
+ /** @return class-string> */
public static function getFixerClassName(): string;
}
diff --git a/src/Sniff/IndentationSniff.php b/src/Sniff/IndentationSniff.php
new file mode 100644
index 0000000..dc44730
--- /dev/null
+++ b/src/Sniff/IndentationSniff.php
@@ -0,0 +1,67 @@
+
+ * @implements Fixable
+ */
+final class IndentationSniff extends AbstractSniff implements Fixable
+{
+ private const string REPORTING_MESSAGE = 'Expected indentation of %d %s.';
+
+ /** @var array */
+ private array $reportingMessages = [];
+
+ public function __construct(
+ private readonly IndentationAnalyzer $analyzer = new IndentationAnalyzer(),
+ ) {}
+
+ public static function getCode(): string
+ {
+ return 'DocbookCS.Indentation';
+ }
+
+ public static function getFixerClassName(): string
+ {
+ return IndentationFixer::class;
+ }
+
+ /**
+ * @throws \InvalidArgumentException if a generated source range is inconsistent
+ * @throws \OutOfBoundsException if a generated source range lies outside the source
+ */
+ public function process(\DOMDocument $document, File $file): array
+ {
+ $violations = [];
+
+ foreach ($this->analyzer->analyze($file) as $mismatch) {
+
+ $expectedDepth = $mismatch['expectedDepth'];
+
+ $violations[] = $this->createViolation(
+ $file->path,
+ $this->reportingMessage($expectedDepth),
+ [$mismatch['range']],
+ $expectedDepth,
+ );
+ }
+
+ return $violations;
+ }
+
+ private function reportingMessage(int $expectedDepth): string
+ {
+ return $this->reportingMessages[$expectedDepth] ??= sprintf(
+ self::REPORTING_MESSAGE,
+ $expectedDepth,
+ $expectedDepth === 1 ? 'space' : 'spaces',
+ );
+ }
+}
diff --git a/src/Sniff/MixedIndentationSniff.php b/src/Sniff/MixedIndentationSniff.php
deleted file mode 100644
index 9921da2..0000000
--- a/src/Sniff/MixedIndentationSniff.php
+++ /dev/null
@@ -1,59 +0,0 @@
-lines() as $line) {
- if (!preg_match(self::INDENTATION_PATTERN, $line->content, $matches)) {
- continue;
- }
-
- $indentation = $matches[0];
- if (!str_contains($indentation, ' ') || !str_contains($indentation, "\t")) {
- continue;
- }
-
- $violations[] = $this->createViolation(
- $file->path,
- self::REPORTING_MESSAGE,
- [
- SourceRange::fromFile(
- $file,
- $line->beginOffset,
- $line->beginOffset + strlen($indentation),
- ),
- ],
- );
- }
-
- return $violations;
- }
-}
diff --git a/src/Sniff/SniffInterface.php b/src/Sniff/SniffInterface.php
index bb7743e..c29bbce 100644
--- a/src/Sniff/SniffInterface.php
+++ b/src/Sniff/SniffInterface.php
@@ -11,6 +11,7 @@
* A sniff receives a DOMDocument (already loaded) and its source file,
* then returns zero or more findings for reports and optional fixes.
*/
+/** @template TFixerData = mixed */
interface SniffInterface
{
/**
@@ -21,7 +22,7 @@ public static function getCode(): string;
/**
* Apply the sniff to the given document.
*
- * @return list
+ * @return list>
*/
public function process(\DOMDocument $document, File $file): array;
diff --git a/src/Violation/Violation.php b/src/Violation/Violation.php
index 4b1ef29..5c3f44b 100644
--- a/src/Violation/Violation.php
+++ b/src/Violation/Violation.php
@@ -4,10 +4,12 @@
namespace DocbookCS\Violation;
+/** @template TFixerData = mixed */
final readonly class Violation
{
/**
* @param non-empty-list $affectedRanges
+ * @param TFixerData $fixerData
* @throws \InvalidArgumentException if the affected ranges are inconsistent
*/
public function __construct(
@@ -15,6 +17,7 @@ public function __construct(
public string $filePath,
public string $message,
public array $affectedRanges,
+ public mixed $fixerData = null,
public Severity $severity = Severity::WARNING,
) {
if ($affectedRanges === []) {
@@ -50,7 +53,7 @@ public static function fromFileReadFailure(string $filePath): self
filePath: $filePath,
message: 'Could not read file.',
affectedRanges: [
- new SourceRange(0, 0, 0)
+ new SourceRange(0, 0, 0),
],
severity: Severity::ERROR,
);
diff --git a/tests/Unit/Fix/FixerInputValidationTest.php b/tests/Unit/Fix/FixerInputValidationTest.php
index 7163752..2dd19fd 100644
--- a/tests/Unit/Fix/FixerInputValidationTest.php
+++ b/tests/Unit/Fix/FixerInputValidationTest.php
@@ -7,7 +7,7 @@
use DocbookCS\Fix\Fixer\AttributeOrderFixer;
use DocbookCS\Fix\Fixer\ExceptionNameFixer;
use DocbookCS\Fix\Fixer\Fixer;
-use DocbookCS\Fix\Fixer\MixedIndentationFixer;
+use DocbookCS\Fix\Fixer\IndentationFixer;
use DocbookCS\Fix\Fixer\SimparaFixer;
use DocbookCS\Fix\Fixer\TrailingWhitespaceFixer;
use DocbookCS\Fix\FixerException;
@@ -23,7 +23,7 @@
CoversClass(AttributeOrderFixer::class),
CoversClass(ExceptionNameFixer::class),
CoversClass(FixerException::class),
- CoversClass(MixedIndentationFixer::class),
+ CoversClass(IndentationFixer::class),
CoversClass(SimparaFixer::class),
CoversClass(TrailingWhitespaceFixer::class),
//
@@ -50,7 +50,7 @@ public static function missingContent(): iterable
new SourceRange(1, 0, 9),
new SourceRange(1, 10, 19),
]];
- yield 'mixed indentation' => [new MixedIndentationFixer(), [new SourceRange(1, 0, 2)]];
+ yield 'indentation' => [new IndentationFixer(), [new SourceRange(1, 0, 2)]];
yield 'simpara' => [new SimparaFixer(), [
new SourceRange(1, 0, 4),
new SourceRange(1, 5, 9),
@@ -77,7 +77,7 @@ public static function invalidContent(): iterable
new SourceRange(1, 0, 5, 'class'),
new SourceRange(1, 6, 11, 'class'),
]];
- yield 'mixed indentation' => [new MixedIndentationFixer(), [new SourceRange(1, 0, 2, ' ')]];
+ yield 'indentation' => [new IndentationFixer(), [new SourceRange(1, 0, 4, 'text')]];
yield 'simpara' => [new SimparaFixer(), [
new SourceRange(1, 0, 4, 'span'),
new SourceRange(1, 5, 9, 'span'),
diff --git a/tests/Unit/Fix/IndentationFixerTest.php b/tests/Unit/Fix/IndentationFixerTest.php
new file mode 100644
index 0000000..8497522
--- /dev/null
+++ b/tests/Unit/Fix/IndentationFixerTest.php
@@ -0,0 +1,200 @@
+fixture('issue_47.xml'));
+ $fileReport = new FileReport($source->path);
+
+ $fixedFile = $this->processor()->process(
+ $source,
+ $fileReport,
+ RunScope::fromFileAndFileChange($source, null),
+ );
+
+ self::assertNotNull($fixedFile);
+ self::assertSame($this->fixture('issue_47.fixed.xml'), $fixedFile->content);
+ self::assertSame(9, $fileReport->getFoundViolationCount());
+ self::assertSame(9, $fileReport->getAppliedFixesCount());
+ self::assertSame(1, $fileReport->fixingPasses);
+ self::assertFalse($fileReport->hasFinalViolations());
+ }
+
+ #[Test]
+ public function itReplacesIndentationWithTheProvidedExpectedDepth(): void
+ {
+ $violation = new Violation(
+ IndentationSniff::getCode(),
+ 'file.xml',
+ 'Expected indentation.',
+ [new SourceRange(2, 7, 8, "\t")],
+ fixerData: 3,
+ );
+
+ $fix = new IndentationFixer()->process($violation);
+
+ self::assertEquals(new Fix(
+ filePath: 'file.xml',
+ beginOffset: 7,
+ untilOffset: 8,
+ replacement: ' ',
+ sniffCode: IndentationSniff::getCode(),
+ expectedContent: "\t",
+ ), $fix);
+ }
+
+ #[Test]
+ public function itUsesUnchangedStructureWhenFixingAChangedLine(): void
+ {
+ $content = "\n \n";
+ $source = new File('file.xml', $content);
+ $fileReport = new FileReport($source->path);
+
+ $fixedFile = $this->processor()->process(
+ $source,
+ $fileReport,
+ RunScope::fromFileAndFileChange(
+ $source,
+ new FileChange($source->path, [3]),
+ ),
+ );
+
+ self::assertNotNull($fixedFile);
+ self::assertSame(
+ "\n \n",
+ $fixedFile->content,
+ );
+ self::assertSame(1, $fileReport->getAppliedFixesCount());
+ self::assertFalse($fileReport->hasFinalViolations());
+ }
+
+ #[Test, DataProvider('invalidFixerData')]
+ public function itRejectsInvalidFixerData(mixed $fixerData): void
+ {
+ $this->expectException(FixerException::class);
+
+ $violation = new Violation(
+ IndentationSniff::getCode(),
+ 'file.xml',
+ 'Expected indentation.',
+ [new SourceRange(1, 0, 1, "\t")],
+ fixerData: $fixerData,
+ );
+
+ // Bypass the PHPStan generic contract to exercise the runtime boundary.
+ new \ReflectionMethod(IndentationFixer::class, 'process')->invoke(new IndentationFixer(), $violation);
+ }
+
+ /** @return iterable */
+ public static function invalidFixerData(): iterable
+ {
+ yield 'not an integer' => ['1'];
+ yield 'negative integer' => [-1];
+ }
+
+ #[Test]
+ public function itRejectsMultipleAffectedRanges(): void
+ {
+ $this->expectException(FixerException::class);
+
+ new IndentationFixer()->process(new Violation(
+ IndentationSniff::getCode(),
+ 'file.xml',
+ 'Expected indentation.',
+ [
+ new SourceRange(1, 0, 1, "\t"),
+ new SourceRange(2, 2, 3, "\t"),
+ ],
+ fixerData: 1,
+ ));
+ }
+
+ #[Test]
+ public function itRejectsAnAlreadyCorrectReplacement(): void
+ {
+ $this->expectException(FixerException::class);
+
+ new IndentationFixer()->process(new Violation(
+ IndentationSniff::getCode(),
+ 'file.xml',
+ 'Expected indentation.',
+ [new SourceRange(1, 0, 1, ' ')],
+ fixerData: 1,
+ ));
+ }
+
+ private function fixture(string $name): string
+ {
+ $content = file_get_contents(__DIR__ . '/../../fixtures/indentation/' . $name);
+ self::assertIsString($content);
+
+ return $content;
+ }
+
+ private function processor(): XmlFileProcessor
+ {
+ return new XmlFileProcessor(new XmlSniffRunner(
+ RunMode::Fix,
+ [new IndentationSniff()],
+ ));
+ }
+}
diff --git a/tests/Unit/Fix/WhitespaceConcernFixersTest.php b/tests/Unit/Fix/WhitespaceConcernFixersTest.php
index 5067538..ac8a8f5 100644
--- a/tests/Unit/Fix/WhitespaceConcernFixersTest.php
+++ b/tests/Unit/Fix/WhitespaceConcernFixersTest.php
@@ -6,16 +6,26 @@
use DocbookCS\Fix\Fix;
use DocbookCS\Fix\FixApplier;
-use DocbookCS\Fix\FixPlan;
-use DocbookCS\Fix\Fixer\MixedIndentationFixer;
+use DocbookCS\Fix\Fixer\IndentationFixer;
use DocbookCS\Fix\Fixer\TrailingWhitespaceFixer;
+use DocbookCS\Fix\FixPlan;
use DocbookCS\Fix\FixResult;
-use DocbookCS\Sniff\MixedIndentationSniff;
+use DocbookCS\IndentationAnalyzer;
+use DocbookCS\Report\FileReport;
+use DocbookCS\Runner\EntityPreprocessor;
+use DocbookCS\Runner\RunMode;
+use DocbookCS\Runner\RunScope;
+use DocbookCS\Runner\ViolationScopeFilter;
+use DocbookCS\Runner\XmlFileProcessor;
+use DocbookCS\Runner\XmlFixRunner;
+use DocbookCS\Runner\XmlSniffRunner;
+use DocbookCS\Sniff\IndentationSniff;
use DocbookCS\Sniff\TrailingWhitespaceSniff;
use DocbookCS\Source\File;
use DocbookCS\Source\Line;
use DocbookCS\Violation\SourceRange;
use DocbookCS\Violation\Violation;
+use DocbookCS\Xml\XmlParser;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
@@ -25,16 +35,26 @@
CoversClass(Fix::class),
CoversClass(FixApplier::class),
CoversClass(FixResult::class),
- CoversClass(MixedIndentationFixer::class),
- CoversClass(MixedIndentationSniff::class),
+ CoversClass(IndentationFixer::class),
+ CoversClass(IndentationSniff::class),
CoversClass(TrailingWhitespaceFixer::class),
CoversClass(TrailingWhitespaceSniff::class),
//
+ UsesClass(EntityPreprocessor::class),
UsesClass(File::class),
+ UsesClass(FileReport::class),
UsesClass(FixPlan::class),
+ UsesClass(IndentationAnalyzer::class),
UsesClass(Line::class),
+ UsesClass(RunMode::class),
+ UsesClass(RunScope::class),
UsesClass(SourceRange::class),
+ UsesClass(ViolationScopeFilter::class),
UsesClass(Violation::class),
+ UsesClass(XmlFileProcessor::class),
+ UsesClass(XmlFixRunner::class),
+ UsesClass(XmlParser::class),
+ UsesClass(XmlSniffRunner::class),
]
final class WhitespaceConcernFixersTest extends TestCase
{
@@ -42,34 +62,23 @@ final class WhitespaceConcernFixersTest extends TestCase
public function itFixesIndependentWhitespaceConcernsTogether(): void
{
$content = " \n \t \n";
- $document = new \DOMDocument();
- $document->loadXML($content);
$source = new File('file.xml', $content);
+ $fileReport = new FileReport($source->path);
- $trailingSniffer = new TrailingWhitespaceSniff();
- $indentationSniffer = new MixedIndentationSniff();
-
- $trailingViolations = $trailingSniffer->process($document, $source);
- $indentationViolations = $indentationSniffer->process($document, $source);
-
- self::assertCount(2, $trailingViolations);
- self::assertCount(1, $indentationViolations);
-
- $fixes = [];
- $trailingFixer = new ($trailingSniffer::getFixerClassName())();
- foreach ($trailingViolations as $violation) {
- $fixes[] = $trailingFixer->process($violation);
- }
-
- $indentationFixer = new ($indentationSniffer::getFixerClassName())();
- foreach ($indentationViolations as $violation) {
- $fixes[] = $indentationFixer->process($violation);
- }
-
- $result = new FixApplier()->apply($source, $fixes);
+ $fixedFile = new XmlFileProcessor(new XmlSniffRunner(
+ RunMode::Fix,
+ [new TrailingWhitespaceSniff(), new IndentationSniff()],
+ ))->process(
+ $source,
+ $fileReport,
+ RunScope::fromFileAndFileChange($source, null),
+ );
- self::assertSame("\n \n", $result->file->content);
- self::assertSame(3, $result->applied);
- self::assertSame(0, $result->skipped);
+ self::assertNotNull($fixedFile);
+ self::assertSame("\n \n", $fixedFile->content);
+ self::assertSame(3, $fileReport->getFoundViolationCount());
+ self::assertSame(3, $fileReport->getAppliedFixesCount());
+ self::assertSame(1, $fileReport->fixingPasses);
+ self::assertFalse($fileReport->hasFinalViolations());
}
}
diff --git a/tests/Unit/Runner/SniffRunnerTest.php b/tests/Unit/Runner/SniffRunnerTest.php
index 9554e7a..3bafe65 100644
--- a/tests/Unit/Runner/SniffRunnerTest.php
+++ b/tests/Unit/Runner/SniffRunnerTest.php
@@ -144,7 +144,7 @@ public function itCallsProgressMethods(): void
#[Test]
public function itAddsFileReportsForFilesWithViolations(): void
{
- $sniff = new class implements SniffInterface {
+ $sniff = new /** @implements SniffInterface */ class implements SniffInterface {
public static function getCode(): string
{
return 'Test.ViolatingSniff';
@@ -181,7 +181,7 @@ public function setProperty(string $name, string $value): void
#[Test]
public function itStoresAbsolutePathsInFileReports(): void
{
- $sniff = new class implements SniffInterface {
+ $sniff = new /** @implements SniffInterface */ class implements SniffInterface {
public static function getCode(): string
{
return 'Test.ViolatingSniff';
@@ -371,7 +371,7 @@ public function itScansLexicallyEquivalentWideTargetsOnlyOnce(): void
#[Test]
public function itReportsNoViolationsForFilesInDiffWithoutAddedLines(): void
{
- $sniff = new class implements SniffInterface {
+ $sniff = new /** @implements SniffInterface */ class implements SniffInterface {
public static function getCode(): string
{
return 'Test.ViolatingSniff';
diff --git a/tests/Unit/Runner/XmlSniffRunnerTest.php b/tests/Unit/Runner/XmlSniffRunnerTest.php
index b31b2fc..ad2a5cb 100644
--- a/tests/Unit/Runner/XmlSniffRunnerTest.php
+++ b/tests/Unit/Runner/XmlSniffRunnerTest.php
@@ -312,7 +312,7 @@ public function itReportsNoViolationsInDiffModeWhenNoLinesWereAdded(): void
#[Test]
public function itDoesNotFixViolationsFromNonFixableSniffs(): void
{
- $sniff = new class implements SniffInterface {
+ $sniff = new /** @implements SniffInterface */ class implements SniffInterface {
public static function getCode(): string
{
return 'Test.NonFixable';
@@ -347,7 +347,7 @@ public function setProperty(string $name, string $value): void
#[Test]
public function itThrowsWhenFixableSniffReportsViolationWithoutContentInFixMode(): void
{
- $sniff = new class implements Fixable {
+ $sniff = new /** @implements Fixable */ class implements Fixable {
public static function getCode(): string
{
return 'Test.BrokenFixable';
@@ -392,7 +392,7 @@ public function setProperty(string $name, string $value): void
/** @param list $lines */
private function sniff(array $lines): SniffInterface
{
- $sniff = new class implements SniffInterface {
+ $sniff = new /** @implements SniffInterface */ class implements SniffInterface {
/** @var list */
public array $lines = [];
diff --git a/tests/Unit/Sniff/IndentationSniffTest.php b/tests/Unit/Sniff/IndentationSniffTest.php
new file mode 100644
index 0000000..50fa605
--- /dev/null
+++ b/tests/Unit/Sniff/IndentationSniffTest.php
@@ -0,0 +1,83 @@
+process($this->fixture('issue_47.xml'));
+
+ self::assertCount(9, $violations);
+ self::assertContains("\t ", array_map(
+ static fn(Violation $violation): ?string => $violation->rangeOne()->content,
+ $violations,
+ ));
+ self::assertSame('DocbookCS.Indentation', $violations[0]->sniffCode);
+ self::assertMatchesRegularExpression('/^Expected indentation of \d+ spaces?\.$/', $violations[0]->message);
+ self::assertIsInt($violations[0]->fixerData);
+ }
+
+ #[Test]
+ public function itAcceptsCanonicalOneSpaceIndentation(): void
+ {
+ self::assertSame([], $this->process($this->fixture('issue_47.fixed.xml')));
+ }
+
+ #[Test]
+ public function itLeavesDocBookAndXmlPreservedWhitespaceAlone(): void
+ {
+ self::assertSame([], $this->process($this->fixture('preserved.xml')));
+ }
+
+ #[Test]
+ public function itReportsTabsEvenWhenTheyAreNotMixedWithSpaces(): void
+ {
+ $violations = $this->process("\n\t\n");
+
+ self::assertCount(1, $violations);
+ self::assertSame("\t", $violations[0]->rangeOne()->content);
+ self::assertSame('Expected indentation of 1 space.', $violations[0]->message);
+ self::assertSame(1, $violations[0]->fixerData);
+ }
+
+ /** @return list> */
+ private function process(string $content): array
+ {
+ $document = new \DOMDocument();
+ $document->loadXML($content);
+
+ return new IndentationSniff()->process($document, new File('file.xml', $content));
+ }
+
+ private function fixture(string $name): string
+ {
+ $content = file_get_contents(__DIR__ . '/../../fixtures/indentation/' . $name);
+ self::assertIsString($content);
+
+ return $content;
+ }
+}
diff --git a/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php b/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php
index bf87642..ea0da2e 100644
--- a/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php
+++ b/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php
@@ -4,7 +4,8 @@
namespace DocbookCS\Tests\Unit\Sniff;
-use DocbookCS\Sniff\MixedIndentationSniff;
+use DocbookCS\IndentationAnalyzer;
+use DocbookCS\Sniff\IndentationSniff;
use DocbookCS\Sniff\TrailingWhitespaceSniff;
use DocbookCS\Source\File;
use DocbookCS\Source\Line;
@@ -18,10 +19,11 @@
#[
CoversClass(File::class),
CoversClass(Line::class),
- CoversClass(MixedIndentationSniff::class),
+ CoversClass(IndentationSniff::class),
CoversClass(TrailingWhitespaceSniff::class),
CoversClass(Violation::class),
//
+ UsesClass(IndentationAnalyzer::class),
UsesClass(SourceRange::class),
]
final class WhitespaceConcernSniffsTest extends TestCase
@@ -45,18 +47,18 @@ public function itReportsOnlyTrailingWhitespaceAsAffected(): void
}
#[Test]
- public function itReportsOnlyMixedLeadingIndentationAsAffected(): void
+ public function itReportsOnlyLeadingIndentationAsAffected(): void
{
$content = "\n \t\n";
$lineOffset = strlen("\n");
- $violations = new MixedIndentationSniff()->process(
+ $violations = new IndentationSniff()->process(
$this->createDocument($content),
new File('file.xml', $content),
);
self::assertCount(1, $violations);
- self::assertSame('DocbookCS.MixedIndentation', $violations[0]->sniffCode);
- self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message);
+ self::assertSame('DocbookCS.Indentation', $violations[0]->sniffCode);
+ self::assertSame('Expected indentation of 1 space.', $violations[0]->message);
self::assertSame(" \t", $violations[0]->rangeOne()->content);
self::assertSame($lineOffset, $violations[0]->rangeOne()->beginOffset);
self::assertSame($lineOffset + 2, $violations[0]->rangeOne()->untilOffset);
@@ -70,7 +72,7 @@ public function itReportsDisjointConcernsOnTheSameLine(): void
$document = $this->createDocument($content);
$source = new File('file.xml', $content);
- $indentation = new MixedIndentationSniff()->process($document, $source)[0];
+ $indentation = new IndentationSniff()->process($document, $source)[0];
$trailing = new TrailingWhitespaceSniff()->process($document, $source)[0];
self::assertSame(2, $indentation->rangeOne()->line);
@@ -81,22 +83,6 @@ public function itReportsDisjointConcernsOnTheSameLine(): void
);
}
- #[Test]
- public function itAllowsIndentationUsingOnlySpacesOrOnlyTabs(): void
- {
- foreach ([" ", "\t\t"] as $line) {
- $content = "\n{$line}\n";
-
- self::assertSame(
- [],
- new MixedIndentationSniff()->process(
- $this->createDocument($content),
- new File('file.xml', $content),
- ),
- );
- }
- }
-
private function createDocument(string $xml): \DOMDocument
{
$document = new \DOMDocument();
diff --git a/tests/Unit/Source/IndentationAnalyzerTest.php b/tests/Unit/Source/IndentationAnalyzerTest.php
new file mode 100644
index 0000000..439bb46
--- /dev/null
+++ b/tests/Unit/Source/IndentationAnalyzerTest.php
@@ -0,0 +1,141 @@
+mismatches($this->fixture('issue_47.xml')));
+ }
+
+ /** @param list $expected */
+ #[Test, DataProvider('structuralCases')]
+ public function itTracksStructuralContext(string $content, array $expected): void
+ {
+ self::assertSame($expected, $this->mismatches($content));
+ }
+
+ /** @return iterable}> */
+ public static function structuralCases(): iterable
+ {
+ yield 'xml:space default resets inherited preservation' => [
+ <<<'XML'
+
+
+
+XML,
+ [[3, '', 2]],
+ ];
+
+ yield 'preserved content is ignored but its boundaries are checked' => [
+ <<<'XML'
+
+
+unstructured preserved content
+
+
+XML,
+ [
+ [2, ' ', 1],
+ [4, ' ', 1],
+ ],
+ ];
+
+ yield 'self-closing elements do not increase the depth' => [
+ <<<'XML'
+
+
+
+
+XML,
+ [[3, ' ', 1]],
+ ];
+
+ yield 'namespace prefixes do not prevent verbatim preservation' => [
+ <<<'XML'
+
+
+unstructured preserved content
+
+
+XML,
+ [],
+ ];
+
+ yield 'all tags on a line affect following lines' => [
+ <<<'XML'
+
+
+
+XML,
+ [
+ [2, ' ', 2],
+ [3, '', 1],
+ ],
+ ];
+
+ yield 'whitespace-only lines are ignored' => [
+ "\n \t \n",
+ [],
+ ];
+ }
+
+ /** @return list */
+ private function mismatches(string $content): array
+ {
+ $mismatches = [];
+
+ foreach (new IndentationAnalyzer()->analyze(new File('file.xml', $content)) as $mismatch) {
+ self::assertNotNull($mismatch['range']->content);
+
+ $mismatches[] = [
+ $mismatch['range']->line,
+ $mismatch['range']->content,
+ $mismatch['expectedDepth'],
+ ];
+ }
+
+ return $mismatches;
+ }
+
+ private function fixture(string $name): string
+ {
+ $content = file_get_contents(__DIR__ . '/../../fixtures/indentation/' . $name);
+ self::assertIsString($content);
+
+ return $content;
+ }
+}
diff --git a/tests/Unit/Violation/AffectedRangesTest.php b/tests/Unit/Violation/AffectedRangesTest.php
index d9c050d..e44b84c 100644
--- a/tests/Unit/Violation/AffectedRangesTest.php
+++ b/tests/Unit/Violation/AffectedRangesTest.php
@@ -57,6 +57,22 @@ public function itRejectsUnorderedOrOverlappingAffectedRanges(): void
]);
}
+ #[Test]
+ public function itTransportsArbitraryFixerData(): void
+ {
+ $fixerData = ['replacement' => 'int', 'attributes' => ['role' => 'return']];
+
+ $violation = new Violation(
+ 'Test',
+ 'file.xml',
+ 'Message',
+ [new SourceRange(1, 0, 1)],
+ fixerData: $fixerData,
+ );
+
+ self::assertSame($fixerData, $violation->fixerData);
+ }
+
#[Test, DataProvider('invalidSourceRanges')]
public function itRejectsInvalidSourceRanges(int $line, int $beginOffset, int $untilOffset, ?string $content): void
{
diff --git a/tests/fixtures/indentation/issue_47.fixed.xml b/tests/fixtures/indentation/issue_47.fixed.xml
new file mode 100644
index 0000000..099b31f
--- /dev/null
+++ b/tests/fixtures/indentation/issue_47.fixed.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ ImagickPixel getColorCount
+
+
+]]>
+
+
+ The output for this will be similar to:
+
+
+
+
+
+
+
+
+
+
+
+ compression_alg
+
+ Compression algorithm. Both
+ compression_alg and compression_level
+ must be set in order to enable data compression.
+
+
+
+
+
+
diff --git a/tests/fixtures/indentation/issue_47.xml b/tests/fixtures/indentation/issue_47.xml
new file mode 100644
index 0000000..4c4bdb4
--- /dev/null
+++ b/tests/fixtures/indentation/issue_47.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ ImagickPixel getColorCount
+
+
+]]>
+
+
+The output for this will be similar to:
+
+
+
+
+
+
+
+
+
+
+
+ compression_alg
+
+ Compression algorithm. Both
+ compression_alg and compression_level
+ must be set in order to enable data compression.
+
+
+
+
+
+
diff --git a/tests/fixtures/indentation/preserved.xml b/tests/fixtures/indentation/preserved.xml
new file mode 100644
index 0000000..abcb0a0
--- /dev/null
+++ b/tests/fixtures/indentation/preserved.xml
@@ -0,0 +1,41 @@
+
+
+]>
+
+
+
+ address whitespace
+
+
+ class synopsis whitespace
+
+
+ function synopsis whitespace
+
+
+ literal layout whitespace
+
+
+ program listing whitespace
+
+ screen whitespace
+]]>
+
+ synopsis whitespace
+
+
+ synopsis information whitespace
+
+
+
+unstructured preserved whitespace
+
+
+
+ modifier whitespace
+
+
+