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
4 changes: 4 additions & 0 deletions docbookcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
</sniff>

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

<sniff class="DocbookCS\Sniff\NativeTypeSniff" />

<sniff class="DocbookCS\Sniff\MixedUnionSniff" />
</sniffs>

<paths>
Expand Down
6 changes: 5 additions & 1 deletion src/Fix/Fixer/Fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
use DocbookCS\Fix\FixerException;
use DocbookCS\Violation\Violation;

/** @template TFixerData = mixed */
interface Fixer
{
/** @throws FixerException */
/**
* @param Violation<TFixerData> $violation
* @throws FixerException
*/
public function process(Violation $violation): Fix|FixPlan;
}
34 changes: 34 additions & 0 deletions src/Fix/Fixer/MixedUnionFixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Fix\Fixer;

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

/** @implements Fixer<string> */
final class MixedUnionFixer implements Fixer
{
/**
* @param Violation<string> $violation
* @throws FixerException
*/
public function process(Violation $violation): Fix
{
$range = $violation->rangeOne();
if ($range->content === null) {
throw FixerException::cannotFixMissingContent();
}

if (
$violation->fixerData !== '<type>mixed</type>'
|| preg_match('/^<type\b[^>]*\bclass\s*=\s*(["\'])union\1[^>]*>.*<\/type>$/is', $range->content) !== 1
) {
throw FixerException::cannotFixInvalidContent($violation);
}

return Fix::fromViolationAndRange($violation, $range, $violation->fixerData);
}
}
31 changes: 31 additions & 0 deletions src/Fix/Fixer/NativeTypeFixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Fix\Fixer;

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

/** @implements Fixer<string> */
final class NativeTypeFixer implements Fixer
{
/**
* @param Violation<string> $violation
* @throws FixerException
*/
public function process(Violation $violation): Fix
{
$range = $violation->rangeOne();
if ($range->content === null) {
throw FixerException::cannotFixMissingContent();
}

if (!is_string($violation->fixerData) || preg_match('/^[a-z]+$/', $violation->fixerData) !== 1) {
throw FixerException::cannotFixInvalidContent($violation);
}

return Fix::fromViolationAndRange($violation, $range, $violation->fixerData);
}
}
15 changes: 13 additions & 2 deletions src/Sniff/AbstractSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
use DocbookCS\Violation\SourceRange;
use DocbookCS\Violation\Violation;

/**
* @template TFixerData = mixed
* @implements SniffInterface<TFixerData>
*/
abstract class AbstractSniff implements SniffInterface
{
protected Severity $severity = Severity::ERROR;
Expand Down Expand Up @@ -72,17 +76,24 @@ protected function elementNameRanges(File $file, int $beginOffset, int $untilOff

/**
* @param non-empty-list<SourceRange> $affectedRanges
* @param TFixerData $fixerData
*
* @return Violation<TFixerData>
* @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,
severity: $this->severity,
fixerData: $fixerData,
);
}
}
6 changes: 5 additions & 1 deletion src/Sniff/Fixable.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@

use DocbookCS\Fix\Fixer\Fixer;

/**
* @template TFixerData = mixed
* @extends SniffInterface<TFixerData>
*/
interface Fixable extends SniffInterface
{
/** @return class-string<Fixer> */
/** @return class-string<Fixer<TFixerData>> */
public static function getFixerClassName(): string;
}
56 changes: 56 additions & 0 deletions src/Sniff/MixedUnionDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Sniff;

final class MixedUnionDetector
{
/** @return list<array{beginOffset: int, untilOffset: int}> */
public static function matches(string $source): array
{
preg_match_all('/<\/?type\b[^>]*>/i', $source, $matches, PREG_OFFSET_CAPTURE);
/** @var list<array{beginOffset: int, contentOffset: int, union: bool, members: list<string>}> $stack */
$stack = [];
$unions = [];

foreach ($matches[0] as [$tag, $offset]) {
$offset = (int) $offset;
if (!str_starts_with($tag, '</')) {
if (str_ends_with(rtrim($tag), '/>')) {
if ($stack !== [] && $stack[array_key_last($stack)]['union']) {
$stack[array_key_last($stack)]['members'][] = '';
}
continue;
}

$stack[] = [
'beginOffset' => $offset,
'contentOffset' => $offset + strlen($tag),
'union' => preg_match('/\bclass\s*=\s*(["\'])union\1/i', $tag) === 1,
'members' => [],
];
continue;
}

if (null === $type = array_pop($stack)) {
continue;
}

$content = trim(substr($source, $type['contentOffset'], $offset - $type['contentOffset']));
if ($type['union']) {
$members = array_map('strtolower', $type['members']);
if (count($members) >= 2 && in_array('mixed', $members, true)) {
$unions[] = [
'beginOffset' => $type['beginOffset'],
'untilOffset' => $offset + strlen($tag),
];
}
} elseif ($stack !== [] && $stack[array_key_last($stack)]['union']) {
$stack[array_key_last($stack)]['members'][] = $content;
}
}

return $unions;
}
}
82 changes: 82 additions & 0 deletions src/Sniff/MixedUnionSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace DocbookCS\Sniff;

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

/**
* @extends AbstractSniff<string>
* @implements Fixable<string>
*/
final class MixedUnionSniff extends AbstractSniff implements Fixable
{
public static function getCode(): string
{
return 'DocbookCS.MixedUnion';
}

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

/**
* @throws \InvalidArgumentException if a generated source range is inconsistent
* @throws \OutOfBoundsException if a matched union lies outside the source
*/
public function process(\DOMDocument $document, File $file): array
{
$source = $file->contentWithNonElementMarkupMasked();
$synopsisRanges = $this->synopsisRanges($source);
$violations = [];

foreach (MixedUnionDetector::matches($source) as $union) {
if (!$this->isInsideSynopsis($union['beginOffset'], $synopsisRanges)) {
continue;
}

$violations[] = $this->createViolation(
$file->path,
'A union containing mixed is redundant and should be mixed.',
[SourceRange::fromFile($file, $union['beginOffset'], $union['untilOffset'])],
'<type>mixed</type>',
);
}

return $violations;
}

/** @return list<array{beginOffset: int, untilOffset: int}> */
private function synopsisRanges(string $source): array
{
preg_match_all('/<\/?(?:method|constructor)synopsis\b[^>]*>/i', $source, $matches, PREG_OFFSET_CAPTURE);
$stack = [];
$ranges = [];
foreach ($matches[0] as [$tag, $offset]) {
$offset = (int) $offset;
if (!str_starts_with($tag, '</')) {
$stack[] = $offset;
} elseif (null !== $beginOffset = array_pop($stack)) {
$ranges[] = ['beginOffset' => $beginOffset, 'untilOffset' => $offset + strlen($tag)];
}
}

return $ranges;
}

/** @param list<array{beginOffset: int, untilOffset: int}> $ranges */
private function isInsideSynopsis(int $offset, array $ranges): bool
{
foreach ($ranges as $range) {
if ($offset >= $range['beginOffset'] && $offset < $range['untilOffset']) {
return true;
}
}

return false;
}
}
Loading