Skip to content

Commit fcb8c67

Browse files
committed
fix: ignore expanded entity nodes in source-backed sniffs
1 parent b080668 commit fcb8c67

11 files changed

Lines changed: 247 additions & 8 deletions

phpunit.xml.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<testsuite name="unit">
1919
<directory>tests/Unit</directory>
2020
</testsuite>
21+
<testsuite name="integration">
22+
<directory>tests/Integration</directory>
23+
</testsuite>
2124
</testsuites>
2225

2326
<source restrictNotices="true" restrictWarnings="true">
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Runner;
6+
7+
final class EntityExpansionMarker
8+
{
9+
private const string START = 'docbook-cs:entity-expansion:start';
10+
private const string END = 'docbook-cs:entity-expansion:end';
11+
12+
public static function wrap(string $content): string
13+
{
14+
return '<!--' . self::START . '-->'
15+
. $content
16+
. '<!--' . self::END . '-->';
17+
}
18+
19+
public static function contains(\DOMNode $node): bool
20+
{
21+
for ($current = $node; $current->parentNode !== null; $current = $current->parentNode) {
22+
if (self::isBetweenMarkers($current)) {
23+
return true;
24+
}
25+
}
26+
27+
return false;
28+
}
29+
30+
private static function isBetweenMarkers(\DOMNode $node): bool
31+
{
32+
$nestedMarkers = 0;
33+
34+
for ($sibling = $node->previousSibling; $sibling !== null; $sibling = $sibling->previousSibling) {
35+
if (self::isEnd($sibling)) {
36+
$nestedMarkers++;
37+
continue;
38+
}
39+
40+
if (!self::isStart($sibling)) {
41+
continue;
42+
}
43+
44+
if ($nestedMarkers === 0) {
45+
return true;
46+
}
47+
48+
$nestedMarkers--;
49+
}
50+
51+
return false;
52+
}
53+
54+
private static function isStart(\DOMNode $node): bool
55+
{
56+
return $node instanceof \DOMComment && $node->textContent === self::START;
57+
}
58+
59+
private static function isEnd(\DOMNode $node): bool
60+
{
61+
return $node instanceof \DOMComment && $node->textContent === self::END;
62+
}
63+
}

src/Runner/EntityPreprocessor.php

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,28 @@ public function process(string $xml): string
2525
return $this->expandEntities($xml);
2626
}
2727

28-
private function expandEntities(string $content): string
28+
public function processForParsing(string $xml): string
29+
{
30+
$xml = $this->stripDoctype($xml);
31+
32+
return $this->expandEntities($xml, markXmlExpansions: true);
33+
}
34+
35+
private function expandEntities(string $content, bool $markXmlExpansions = false): string
2936
{
3037
$maxDepth = 20;
3138

3239
for ($i = 0; $i < $maxDepth; $i++) {
3340
$changed = false;
3441

3542
$content = preg_replace_callback(
36-
'/<!--[\s\S]*?-->|' . self::ENTITY_PATTERN . '/',
37-
function (array $matches) use (&$changed): string {
38-
// If this is a comment, return as is
39-
if (str_starts_with($matches[0], '<!--')) {
43+
'/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|' . self::ENTITY_PATTERN . '/',
44+
function (array $matches) use (&$changed, $markXmlExpansions): string {
45+
if (
46+
str_starts_with($matches[0], '<!--')
47+
|| str_starts_with($matches[0], '<![CDATA[')
48+
|| str_starts_with($matches[0], '<?')
49+
) {
4050
return $matches[0];
4151
}
4252

@@ -51,9 +61,11 @@ function (array $matches) use (&$changed): string {
5161

5262
$changed = true;
5363

54-
$value = $this->entities[$name];
64+
$value = $this->stripXmlDeclaration($this->entities[$name]);
5565

56-
return $this->stripXmlDeclaration($value);
66+
return $markXmlExpansions && $this->containsXmlElement($value)
67+
? EntityExpansionMarker::wrap($value)
68+
: $value;
5769
},
5870
$content,
5971
) ?: $content;
@@ -66,6 +78,11 @@ function (array $matches) use (&$changed): string {
6678
return $content;
6779
}
6880

81+
private function containsXmlElement(string $content): bool
82+
{
83+
return preg_match('/<\s*[a-zA-Z_][\w:.-]*(?:\s|\/?>)/', $content) === 1;
84+
}
85+
6986
private function stripDoctype(string $xmlContent): string
7087
{
7188
$start = stripos($xmlContent, '<!DOCTYPE');

src/Runner/XmlFileProcessor.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ private function processContent(
6969
FileReport $fileReport,
7070
?array $changedLines = null,
7171
): FileReport {
72-
$content = $this->preprocessor->process($content);
72+
$content = $this->preprocessor->processForParsing($content);
7373

7474
$document = $this->parseXml($content, $filePath, $fileReport);
7575
if ($document === null) {

src/Sniff/AbstractSniff.php

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

77
use DocbookCS\Report\Severity;
88
use DocbookCS\Report\Violation;
9+
use DocbookCS\Runner\EntityExpansionMarker;
910

1011
abstract class AbstractSniff implements SniffInterface
1112
{
@@ -22,6 +23,11 @@ protected function getProperty(string $name, string $default = ''): string
2223
return $this->properties[$name] ?? $default;
2324
}
2425

26+
protected function isSourceBacked(\DOMNode $node): bool
27+
{
28+
return !EntityExpansionMarker::contains($node);
29+
}
30+
2531
/** @throws \LogicException if an invalid severity level is configured */
2632
protected function createViolation(
2733
string $filePath,

src/Sniff/ExceptionNameSniff.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ public function process(\DOMDocument $document, string $content, string $filePat
3838

3939
/** @var \DOMElement $node */
4040
foreach ($classnames as $node) {
41+
if (!$this->isSourceBacked($node)) {
42+
continue;
43+
}
44+
4145
$text = trim($node->textContent);
4246

4347
if ($text === '') {

src/Sniff/SimparaSniff.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ public function process(\DOMDocument $document, string $content, string $filePat
111111

112112
/** @var \DOMElement $para */
113113
foreach ($paras as $para) {
114+
if (!$this->isSourceBacked($para)) {
115+
continue;
116+
}
117+
114118
$parent = $para->parentNode;
115119
if (
116120
$parent instanceof \DOMElement
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Tests\Integration\Runner;
6+
7+
use DocbookCS\Runner\EntityExpansionMarker;
8+
use DocbookCS\Runner\EntityPreprocessor;
9+
use PHPUnit\Framework\Attributes\CoversClass;
10+
use PHPUnit\Framework\Attributes\Test;
11+
use PHPUnit\Framework\TestCase;
12+
13+
#[
14+
CoversClass(EntityExpansionMarker::class),
15+
CoversClass(EntityPreprocessor::class),
16+
]
17+
final class EntityExpansionMarkerTest extends TestCase
18+
{
19+
#[Test]
20+
public function itMarksXmlExpansionsForParsingOnly(): void
21+
{
22+
$preprocessor = new EntityPreprocessor([
23+
'expanded' => '<para>Expanded</para>',
24+
]);
25+
$source = '<root>&expanded;</root>';
26+
$document = $this->parse($preprocessor->processForParsing($source));
27+
$para = $document->getElementsByTagName('para')->item(0);
28+
$root = $document->documentElement;
29+
30+
self::assertSame('<root><para>Expanded</para></root>', $preprocessor->process($source));
31+
self::assertInstanceOf(\DOMElement::class, $para);
32+
self::assertInstanceOf(\DOMElement::class, $root);
33+
self::assertTrue(EntityExpansionMarker::contains($para));
34+
self::assertFalse(EntityExpansionMarker::contains($root));
35+
}
36+
37+
#[Test]
38+
public function itRecognizesNestedExpansionMarkers(): void
39+
{
40+
$preprocessor = new EntityPreprocessor([
41+
'outer' => '<wrapper>&inner;<after/></wrapper>',
42+
'inner' => '<para/>',
43+
]);
44+
$document = $this->parse($preprocessor->processForParsing('<root>&outer;</root>'));
45+
46+
foreach (['wrapper', 'para', 'after'] as $elementName) {
47+
$element = $document->getElementsByTagName($elementName)->item(0);
48+
self::assertInstanceOf(\DOMElement::class, $element);
49+
self::assertTrue(EntityExpansionMarker::contains($element));
50+
}
51+
}
52+
53+
#[Test]
54+
public function itDoesNotExpandLiteralEntityText(): void
55+
{
56+
$preprocessor = new EntityPreprocessor(['value' => 'expanded']);
57+
$source = '<root><!-- &value; --><![CDATA[&value;]]><?test &value;?>&value;</root>';
58+
59+
self::assertSame(
60+
'<root><!-- &value; --><![CDATA[&value;]]><?test &value;?>expanded</root>',
61+
$preprocessor->process($source),
62+
);
63+
}
64+
65+
private function parse(string $xml): \DOMDocument
66+
{
67+
$document = new \DOMDocument();
68+
$document->loadXML($xml);
69+
70+
return $document;
71+
}
72+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DocbookCS\Tests\Integration\Sniff;
6+
7+
use DocbookCS\Report\Violation;
8+
use DocbookCS\Runner\EntityExpansionMarker;
9+
use DocbookCS\Runner\EntityPreprocessor;
10+
use DocbookCS\Sniff\AbstractSniff;
11+
use DocbookCS\Sniff\ExceptionNameSniff;
12+
use DocbookCS\Sniff\SimparaSniff;
13+
use PHPUnit\Framework\Attributes\CoversClass;
14+
use PHPUnit\Framework\Attributes\Test;
15+
use PHPUnit\Framework\TestCase;
16+
17+
#[
18+
CoversClass(AbstractSniff::class),
19+
CoversClass(EntityExpansionMarker::class),
20+
CoversClass(EntityPreprocessor::class),
21+
CoversClass(ExceptionNameSniff::class),
22+
CoversClass(SimparaSniff::class),
23+
CoversClass(Violation::class),
24+
]
25+
final class EntityExpandedSniffTest extends TestCase
26+
{
27+
#[Test]
28+
public function simparaIgnoresExpandedElements(): void
29+
{
30+
$source = '<root><para>Source</para>&expanded;</root>';
31+
$document = $this->processedDocument($source, '<para>Expanded</para>');
32+
33+
$violations = new SimparaSniff()->process($document, $source, 'file.xml');
34+
35+
self::assertCount(1, $violations);
36+
self::assertSame(1, $violations[0]->line);
37+
}
38+
39+
#[Test]
40+
public function exceptionNameIgnoresExpandedElements(): void
41+
{
42+
$source = '<root><classname>RuntimeException</classname>&expanded;</root>';
43+
$document = $this->processedDocument(
44+
$source,
45+
'<classname>ExpandedException</classname>',
46+
);
47+
48+
$violations = new ExceptionNameSniff()->process($document, $source, 'file.xml');
49+
50+
self::assertCount(1, $violations);
51+
self::assertSame(1, $violations[0]->line);
52+
}
53+
54+
private function processedDocument(string $source, string $expanded): \DOMDocument
55+
{
56+
$content = new EntityPreprocessor(['expanded' => $expanded])->processForParsing($source);
57+
$document = new \DOMDocument();
58+
$document->loadXML($content);
59+
60+
return $document;
61+
}
62+
}

tests/Unit/Sniff/ExceptionNameSniffTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
namespace DocbookCS\Tests\Unit\Sniff;
66

77
use DocbookCS\Report\Violation;
8+
use DocbookCS\Runner\EntityExpansionMarker;
89
use DocbookCS\Sniff\ExceptionNameSniff;
910
use PHPUnit\Framework\Attributes\CoversClass;
1011
use PHPUnit\Framework\Attributes\Test;
12+
use PHPUnit\Framework\Attributes\UsesClass;
1113
use PHPUnit\Framework\TestCase;
1214

1315
#[
1416
CoversClass(ExceptionNameSniff::class),
1517
CoversClass(Violation::class),
18+
//
19+
UsesClass(EntityExpansionMarker::class),
1620
]
1721
final class ExceptionNameSniffTest extends TestCase
1822
{

0 commit comments

Comments
 (0)