diff --git a/src/Sniff/AbstractSniff.php b/src/Sniff/AbstractSniff.php index f27338e..8bc8997 100644 --- a/src/Sniff/AbstractSniff.php +++ b/src/Sniff/AbstractSniff.php @@ -12,12 +12,6 @@ abstract class AbstractSniff implements SniffInterface { - private const array NON_ELEMENT_DELIMITERS = [ - '', - ' ']]>', - ' '?>', - ]; - protected Severity $severity = Severity::ERROR; /** @var array */ @@ -91,89 +85,4 @@ protected function createViolation(string $filePath, string $message, array $aff severity: $this->severity, ); } - - protected function maskNonElementMarkup(string $source): string - { - $masked = $source; - $offset = 0; - - while (false !== $start = strpos($source, '<', $offset)) { - $endOffset = $this->nonElementMarkupEndOffset($source, $start); - - if ($endOffset === null) { - $offset = $start + 1; - continue; - } - - for ($i = $start; $i < $endOffset; $i++) { - $masked[$i] = ' '; - } - - $offset = $endOffset; - } - - return $masked; - } - - private function nonElementMarkupEndOffset(string $source, int $start): ?int - { - foreach (self::NON_ELEMENT_DELIMITERS as $opening => $closing) { - if (substr_compare($source, $opening, $start, strlen($opening)) === 0) { - return $this->offsetAfterDelimiter($source, $closing, $start); - } - } - - if (substr_compare($source, 'declarationEndOffset($source, $start); - } - - return null; - } - - private function offsetAfterDelimiter(string $source, string $delimiter, int $offset): int - { - $end = strpos($source, $delimiter, $offset); - - return $end === false ? strlen($source) : $end + strlen($delimiter); - } - - private function declarationEndOffset(string $source, int $offset): int - { - $length = strlen($source); - $quote = null; - $bracketDepth = 0; - - for ($i = $offset; $i < $length; $i++) { - $character = $source[$i]; - - if ($quote !== null) { - if ($character === $quote) { - $quote = null; - } - - continue; - } - - if ($character === '"' || $character === "'") { - $quote = $character; - continue; - } - - if ($character === '[') { - $bracketDepth++; - continue; - } - - if ($character === ']') { - $bracketDepth--; - continue; - } - - if ($character === '>' && $bracketDepth === 0) { - return $i + 1; - } - } - - return $length; - } } diff --git a/src/Sniff/AttributeOrderSniff.php b/src/Sniff/AttributeOrderSniff.php index bc9182d..a8bec79 100644 --- a/src/Sniff/AttributeOrderSniff.php +++ b/src/Sniff/AttributeOrderSniff.php @@ -43,7 +43,7 @@ public function process(\DOMDocument $document, File $file): array // Match ONLY opening tags (skip closing, comments, xml decl) preg_match_all( self::OPENING_TAG_PATTERN, - $this->maskNonElementMarkup($file->content), + $file->contentWithNonElementMarkupMasked(), $matches, PREG_OFFSET_CAPTURE, ); diff --git a/src/Sniff/ExceptionNameSniff.php b/src/Sniff/ExceptionNameSniff.php index b3a5a99..cb13c68 100644 --- a/src/Sniff/ExceptionNameSniff.php +++ b/src/Sniff/ExceptionNameSniff.php @@ -121,7 +121,7 @@ private function sourceMatches(File $file): array { preg_match_all( self::CLASSNAME_PATTERN, - $this->maskNonElementMarkup($file->content), + $file->contentWithNonElementMarkupMasked(), $matches, PREG_OFFSET_CAPTURE, ); diff --git a/src/Sniff/SimparaSniff.php b/src/Sniff/SimparaSniff.php index e090ff2..8b2854c 100644 --- a/src/Sniff/SimparaSniff.php +++ b/src/Sniff/SimparaSniff.php @@ -220,7 +220,7 @@ private function sourceMatches(File $file): array { preg_match_all( self::PARA_TAG_PATTERN, - $this->maskNonElementMarkup($file->content), + $file->contentWithNonElementMarkupMasked(), $matches, PREG_OFFSET_CAPTURE, ); diff --git a/src/Source/File.php b/src/Source/File.php index 02f8be5..0ead9eb 100644 --- a/src/Source/File.php +++ b/src/Source/File.php @@ -6,9 +6,17 @@ final class File { + private const array NON_ELEMENT_DELIMITERS = [ + '', + ' ']]>', + ' '?>', + ]; + /** @var non-empty-list|null */ private ?array $lineBeginOffsets = null; + private ?string $maskedContent = null; + public function __construct( public readonly string $path, public readonly string $content, @@ -48,6 +56,33 @@ public function withContent(string $content): self : new self($this->path, $content); } + public function contentWithNonElementMarkupMasked(): string + { + if ($this->maskedContent !== null) { + return $this->maskedContent; + } + + $masked = $this->content; + $offset = 0; + + while (false !== $start = strpos($this->content, '<', $offset)) { + $endOffset = $this->nonElementMarkupEndOffset($start); + + if ($endOffset === null) { + $offset = $start + 1; + continue; + } + + for ($i = $start; $i < $endOffset; $i++) { + $masked[$i] = ' '; + } + + $offset = $endOffset; + } + + return $this->maskedContent = $masked; + } + /** @return non-empty-list */ private function lineBeginOffsets(): array { @@ -108,6 +143,68 @@ private function lineIndexAtOffset(int $offset, array $lineBeginOffsets): int return $low; } + private function nonElementMarkupEndOffset(int $start): ?int + { + foreach (self::NON_ELEMENT_DELIMITERS as $opening => $closing) { + if (substr_compare($this->content, $opening, $start, strlen($opening)) === 0) { + return $this->offsetAfterDelimiter($closing, $start); + } + } + + if (substr_compare($this->content, 'declarationEndOffset($start); + } + + return null; + } + + private function offsetAfterDelimiter(string $delimiter, int $offset): int + { + $end = strpos($this->content, $delimiter, $offset); + + return $end === false ? strlen($this->content) : $end + strlen($delimiter); + } + + private function declarationEndOffset(int $offset): int + { + $length = strlen($this->content); + $quote = null; + $bracketDepth = 0; + + for ($i = $offset; $i < $length; $i++) { + $character = $this->content[$i]; + + if ($quote !== null) { + if ($character === $quote) { + $quote = null; + } + + continue; + } + + if ($character === '"' || $character === "'") { + $quote = $character; + continue; + } + + if ($character === '[') { + $bracketDepth++; + continue; + } + + if ($character === ']') { + $bracketDepth--; + continue; + } + + if ($character === '>' && $bracketDepth === 0) { + return $i + 1; + } + } + + return $length; + } + private function createLineAtOffset(int $lineNumber, int $lineBeginOffset): Line { $sourceLength = strlen($this->content); diff --git a/tests/Support/Sniff/ExposedAbstractSniff.php b/tests/Support/Sniff/ExposedAbstractSniff.php index 8f8fc07..3704de1 100644 --- a/tests/Support/Sniff/ExposedAbstractSniff.php +++ b/tests/Support/Sniff/ExposedAbstractSniff.php @@ -29,9 +29,4 @@ public function exposeSeverity(): Severity { return $this->severity; } - - public function exposeMaskNonElementMarkup(string $source): string - { - return $this->maskNonElementMarkup($source); - } } diff --git a/tests/Unit/Sniff/AbstractSniffTest.php b/tests/Unit/Sniff/AbstractSniffTest.php index 68b4cc4..b9684fd 100644 --- a/tests/Unit/Sniff/AbstractSniffTest.php +++ b/tests/Unit/Sniff/AbstractSniffTest.php @@ -53,13 +53,4 @@ public function itRejectsInvalidSeverityProperties(): void new ExposedAbstractSniff()->setProperty('severity', 'invalid'); } - - #[Test] - public function itMasksAnUnterminatedDeclarationThroughTheEndOfTheSource(): void - { - $source = 'exposeMaskNonElementMarkup($source); - - self::assertSame(str_repeat(' ', strlen($source)), $masked); - } } diff --git a/tests/Unit/Source/FileTest.php b/tests/Unit/Source/FileTest.php index 7cca8e3..b5f6721 100644 --- a/tests/Unit/Source/FileTest.php +++ b/tests/Unit/Source/FileTest.php @@ -85,6 +85,38 @@ public function itReusesTheCurrentRevisionForUnchangedContent(): void self::assertSame($file, $file->withContent('')); } + #[Test] + public function itMasksNonElementMarkupWithoutChangingSourceOffsets(): void + { + $content = <<<'XML' + Declared">]> + + + CDATA]]> + Instruction?> + Source + + XML; + + $masked = new File('file.xml', $content)->contentWithNonElementMarkupMasked(); + + self::assertSame(strlen($content), strlen($masked)); + self::assertSame(strpos($content, 'Source'), strpos($masked, 'Source')); + self::assertStringNotContainsString('Declared', $masked); + self::assertStringNotContainsString('Commented', $masked); + self::assertStringNotContainsString('CDATA', $masked); + self::assertStringNotContainsString('Instruction', $masked); + } + + #[Test] + public function itMasksAnUnterminatedDeclarationThroughTheEndOfTheSource(): void + { + $source = 'contentWithNonElementMarkupMasked(); + + self::assertSame(str_repeat(' ', strlen($source)), $masked); + } + #[Test] public function itRejectsOffsetsOutsideTheSource(): void {