diff --git a/README.md b/README.md index 5cd8d9e..0eea272 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ -This packages contains a [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) rule to automatically fix the class header regarding PHP DocBlocks. +This packages contains a [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) rule to automatically fix the header regarding PHP DocBlocks for classes, interfaces, traits and enums. > [!warning] > This package is in early development stage and may change significantly in the future. Use it at your own risk. @@ -26,6 +26,10 @@ class MyClass // ... } } + +interface MyInterface {} +trait MyTrait {} +enum MyEnum {} ``` **After:** @@ -76,7 +80,7 @@ return (new PhpCsFixer\Config()) ], 'preserve_existing' => true, 'separate' => 'none', - 'add_class_name' => true, + 'add_structure_name' => true, ], ]) ; @@ -100,7 +104,7 @@ return (new PhpCsFixer\Config()) ], preserveExisting: true, separate: \KonradMichalik\PhpDocBlockHeaderFixer\Enum\Separate::None, - addClassName: true + addStructureName: true )->__toArray() ]) ; diff --git a/src/Generators/DocBlockHeader.php b/src/Generators/DocBlockHeader.php index fbae28c..caee8c4 100644 --- a/src/Generators/DocBlockHeader.php +++ b/src/Generators/DocBlockHeader.php @@ -35,7 +35,7 @@ private function __construct( public readonly array $annotations, public readonly bool $preserveExisting, public readonly Separate $separate, - public readonly bool $addClassName, + public readonly bool $addStructureName, ) {} /** @@ -45,11 +45,11 @@ public static function create( array $annotations, bool $preserveExisting = true, Separate $separate = Separate::Both, - bool $addClassName = false, + bool $addStructureName = false, ): self { self::validateAnnotations($annotations); - return new self($annotations, $preserveExisting, $separate, $addClassName); + return new self($annotations, $preserveExisting, $separate, $addStructureName); } /** @@ -62,7 +62,7 @@ public function __toArray(): array 'annotations' => $this->annotations, 'preserve_existing' => $this->preserveExisting, 'separate' => $this->separate->value, - 'add_class_name' => $this->addClassName, + 'add_structure_name' => $this->addStructureName, ], ]; } diff --git a/src/Rules/DocBlockHeaderFixer.php b/src/Rules/DocBlockHeaderFixer.php index 5e806a6..46144d2 100644 --- a/src/Rules/DocBlockHeaderFixer.php +++ b/src/Rules/DocBlockHeaderFixer.php @@ -51,7 +51,7 @@ final class DocBlockHeaderFixer extends AbstractFixer implements ConfigurableFix public function getDefinition(): FixerDefinitionInterface { return new FixerDefinition( - 'Add configurable DocBlock annotations before class declarations.', + 'Add configurable DocBlock annotations before class, interface, trait, and enum declarations.', [], ); } @@ -63,7 +63,10 @@ public function getName(): string public function isCandidate(Tokens $tokens): bool { - return $tokens->isTokenKindFound(T_CLASS); + return $tokens->isTokenKindFound(T_CLASS) + || $tokens->isTokenKindFound(T_INTERFACE) + || $tokens->isTokenKindFound(T_TRAIT) + || $tokens->isTokenKindFound(T_ENUM); } public function getConfigurationDefinition(): FixerConfigurationResolverInterface @@ -81,7 +84,7 @@ public function getConfigurationDefinition(): FixerConfigurationResolverInterfac ->setAllowedValues(Separate::getList()) ->setDefault(Separate::None->value) ->getOption(), - (new FixerOptionBuilder('add_class_name', 'Add class name before annotations')) + (new FixerOptionBuilder('add_structure_name', 'Add structure name before annotations')) ->setAllowedTypes(['bool']) ->setDefault(false) ->getOption(), @@ -103,45 +106,45 @@ protected function applyFix(SplFileInfo $file, Tokens $tokens): void for ($index = 0, $limit = $tokens->count(); $index < $limit; ++$index) { $token = $tokens[$index]; - if (!$token->isGivenKind(T_CLASS)) { + if (!$token->isGivenKind([T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { continue; } - $className = $this->getClassName($tokens, $index); - $this->processClassDocBlock($tokens, $index, $annotations, $className); + $structureName = $this->getStructureName($tokens, $index); + $this->processStructureDocBlock($tokens, $index, $annotations, $structureName); } } /** * @param array> $annotations */ - private function processClassDocBlock(Tokens $tokens, int $classIndex, array $annotations, string $className): void + private function processStructureDocBlock(Tokens $tokens, int $structureIndex, array $annotations, string $structureName): void { - $existingDocBlockIndex = $this->findExistingDocBlock($tokens, $classIndex); + $existingDocBlockIndex = $this->findExistingDocBlock($tokens, $structureIndex); $preserveExisting = $this->resolvedConfiguration['preserve_existing'] ?? true; if (null !== $existingDocBlockIndex) { if ($preserveExisting) { - $this->mergeWithExistingDocBlock($tokens, $existingDocBlockIndex, $annotations, $className); + $this->mergeWithExistingDocBlock($tokens, $existingDocBlockIndex, $annotations, $structureName); } else { - $this->replaceDocBlock($tokens, $existingDocBlockIndex, $annotations, $className); + $this->replaceDocBlock($tokens, $existingDocBlockIndex, $annotations, $structureName); } } else { - $this->insertNewDocBlock($tokens, $classIndex, $annotations, $className); + $this->insertNewDocBlock($tokens, $structureIndex, $annotations, $structureName); } } - private function getClassName(Tokens $tokens, int $classIndex): string + private function getStructureName(Tokens $tokens, int $structureIndex): string { - // Look for the class name token after the 'class' keyword - for ($i = $classIndex + 1, $limit = $tokens->count(); $i < $limit; ++$i) { + // Look for the structure name token after the keyword (class/interface/trait/enum) + for ($i = $structureIndex + 1, $limit = $tokens->count(); $i < $limit; ++$i) { $token = $tokens[$i]; if ($token->isWhitespace()) { continue; } - // The first non-whitespace token after 'class' should be the class name + // The first non-whitespace token after the keyword should be the structure name if ($token->isGivenKind(T_STRING)) { return $token->getContent(); } @@ -153,9 +156,9 @@ private function getClassName(Tokens $tokens, int $classIndex): string return ''; } - private function findExistingDocBlock(Tokens $tokens, int $classIndex): ?int + private function findExistingDocBlock(Tokens $tokens, int $structureIndex): ?int { - for ($i = $classIndex - 1; $i >= 0; --$i) { + for ($i = $structureIndex - 1; $i >= 0; --$i) { $token = $tokens[$i]; if ($token->isWhitespace()) { @@ -178,32 +181,32 @@ private function findExistingDocBlock(Tokens $tokens, int $classIndex): ?int /** * @param array> $annotations */ - private function mergeWithExistingDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations, string $className): void + private function mergeWithExistingDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations, string $structureName): void { $existingContent = $tokens[$docBlockIndex]->getContent(); $existingAnnotations = $this->parseExistingAnnotations($existingContent); $mergedAnnotations = $this->mergeAnnotations($existingAnnotations, $annotations); - $newDocBlock = $this->buildDocBlock($mergedAnnotations, $className); + $newDocBlock = $this->buildDocBlock($mergedAnnotations, $structureName); $tokens[$docBlockIndex] = new Token([T_DOC_COMMENT, $newDocBlock]); } /** * @param array> $annotations */ - private function replaceDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations, string $className): void + private function replaceDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations, string $structureName): void { - $newDocBlock = $this->buildDocBlock($annotations, $className); + $newDocBlock = $this->buildDocBlock($annotations, $structureName); $tokens[$docBlockIndex] = new Token([T_DOC_COMMENT, $newDocBlock]); } /** * @param array> $annotations */ - private function insertNewDocBlock(Tokens $tokens, int $classIndex, array $annotations, string $className): void + private function insertNewDocBlock(Tokens $tokens, int $structureIndex, array $annotations, string $structureName): void { $separate = $this->resolvedConfiguration['separate'] ?? 'none'; - $insertIndex = $this->findInsertPosition($tokens, $classIndex); + $insertIndex = $this->findInsertPosition($tokens, $structureIndex); $tokensToInsert = []; @@ -213,14 +216,14 @@ private function insertNewDocBlock(Tokens $tokens, int $classIndex, array $annot } // Add the DocBlock - $docBlock = $this->buildDocBlock($annotations, $className); + $docBlock = $this->buildDocBlock($annotations, $structureName); $tokensToInsert[] = new Token([T_DOC_COMMENT, $docBlock]); // For compatibility with no_blank_lines_after_phpdoc, only add bottom separation when 'separate' is not 'none' // This prevents conflicts with PHP-CS-Fixer rules that manage DocBlock spacing if (in_array($separate, ['bottom', 'both'], true)) { - // Check if there's already whitespace after the class declaration - $nextToken = $tokens[$classIndex] ?? null; + // Check if there's already whitespace after the structure declaration + $nextToken = $tokens[$structureIndex] ?? null; if (null !== $nextToken && !$nextToken->isWhitespace()) { $tokensToInsert[] = new Token([T_WHITESPACE, "\n"]); } @@ -229,12 +232,12 @@ private function insertNewDocBlock(Tokens $tokens, int $classIndex, array $annot $tokens->insertAt($insertIndex, $tokensToInsert); } - private function findInsertPosition(Tokens $tokens, int $classIndex): int + private function findInsertPosition(Tokens $tokens, int $structureIndex): int { - $insertIndex = $classIndex; + $insertIndex = $structureIndex; // Look backwards for attributes, final, abstract keywords - for ($i = $classIndex - 1; $i >= 0; --$i) { + for ($i = $structureIndex - 1; $i >= 0; --$i) { $token = $tokens[$i]; if ($token->isWhitespace()) { @@ -287,21 +290,21 @@ private function mergeAnnotations(array $existing, array $new): array /** * @param array> $annotations */ - private function buildDocBlock(array $annotations, string $className): string + private function buildDocBlock(array $annotations, string $structureName): string { - $addClassName = $this->resolvedConfiguration['add_class_name'] ?? false; + $addStructureName = $this->resolvedConfiguration['add_structure_name'] ?? false; - if (empty($annotations) && !$addClassName) { + if (empty($annotations) && !$addStructureName) { return "/**\n */"; } $docBlock = "/**\n"; - // Add class name with dot if configured - if ($addClassName && !empty($className)) { - $docBlock .= " * {$className}.\n"; + // Add structure name with dot if configured + if ($addStructureName && !empty($structureName)) { + $docBlock .= " * {$structureName}.\n"; - // Add empty line after class name if there are annotations - compatible with phpdoc_separation + // Add empty line after structure name if there are annotations - compatible with phpdoc_separation if (!empty($annotations)) { $docBlock .= " *\n"; } diff --git a/tests/src/Generators/DocBlockHeaderTest.php b/tests/src/Generators/DocBlockHeaderTest.php index 241f5d2..333964e 100644 --- a/tests/src/Generators/DocBlockHeaderTest.php +++ b/tests/src/Generators/DocBlockHeaderTest.php @@ -91,7 +91,7 @@ public function testToArrayReturnsCorrectStructure(): void 'annotations' => $annotations, 'preserve_existing' => false, 'separate' => 'top', - 'add_class_name' => false, + 'add_structure_name' => false, ], ]; @@ -110,7 +110,7 @@ public function testToArrayWithDefaultParameters(): void 'annotations' => $annotations, 'preserve_existing' => true, 'separate' => 'both', - 'add_class_name' => false, + 'add_structure_name' => false, ], ]; @@ -270,7 +270,7 @@ public function testClassIsFinal(): void self::assertTrue($reflection->isFinal()); } - public function testCreateWithAddClassName(): void + public function testCreateWithAddStructureName(): void { $annotations = ['author' => 'John Doe']; $docBlockHeader = DocBlockHeader::create( @@ -283,10 +283,10 @@ public function testCreateWithAddClassName(): void self::assertSame($annotations, $docBlockHeader->annotations); self::assertTrue($docBlockHeader->preserveExisting); self::assertSame(Separate::None, $docBlockHeader->separate); - self::assertTrue($docBlockHeader->addClassName); + self::assertTrue($docBlockHeader->addStructureName); } - public function testToArrayWithAddClassName(): void + public function testToArrayWithAddStructureName(): void { $annotations = ['author' => 'John Doe']; $docBlockHeader = DocBlockHeader::create( @@ -303,7 +303,7 @@ public function testToArrayWithAddClassName(): void 'annotations' => $annotations, 'preserve_existing' => false, 'separate' => 'top', - 'add_class_name' => true, + 'add_structure_name' => true, ], ]; diff --git a/tests/src/Rules/DocBlockHeaderFixerTest.php b/tests/src/Rules/DocBlockHeaderFixerTest.php index ccc56ff..acb9f68 100644 --- a/tests/src/Rules/DocBlockHeaderFixerTest.php +++ b/tests/src/Rules/DocBlockHeaderFixerTest.php @@ -46,7 +46,7 @@ public function testGetDefinition(): void { $definition = $this->fixer->getDefinition(); - self::assertSame('Add configurable DocBlock annotations before class declarations.', $definition->getSummary()); + self::assertSame('Add configurable DocBlock annotations before class, interface, trait, and enum declarations.', $definition->getSummary()); } public function testGetName(): void @@ -183,7 +183,7 @@ public function testConfigurationDefinition(): void self::assertContains('annotations', $optionNames); self::assertContains('preserve_existing', $optionNames); self::assertContains('separate', $optionNames); - self::assertContains('add_class_name', $optionNames); + self::assertContains('add_structure_name', $optionNames); } public function testParseExistingAnnotations(): void @@ -280,7 +280,7 @@ public function testProcessClassDocBlockWithNewDocBlock(): void $tokens = Tokens::fromCode($code); $annotations = ['author' => 'John Doe']; - $method = new ReflectionMethod($this->fixer, 'processClassDocBlock'); + $method = new ReflectionMethod($this->fixer, 'processStructureDocBlock'); $method->setAccessible(true); $this->fixer->configure(['separate' => 'none']); @@ -296,7 +296,7 @@ public function testProcessClassDocBlockWithExistingDocBlockPreserve(): void $tokens = Tokens::fromCode($code); $annotations = ['author' => 'John Doe']; - $method = new ReflectionMethod($this->fixer, 'processClassDocBlock'); + $method = new ReflectionMethod($this->fixer, 'processStructureDocBlock'); $method->setAccessible(true); $this->fixer->configure(['preserve_existing' => true]); @@ -312,7 +312,7 @@ public function testProcessClassDocBlockWithExistingDocBlockReplace(): void $tokens = Tokens::fromCode($code); $annotations = ['author' => 'John Doe']; - $method = new ReflectionMethod($this->fixer, 'processClassDocBlock'); + $method = new ReflectionMethod($this->fixer, 'processStructureDocBlock'); $method->setAccessible(true); $this->fixer->configure(['preserve_existing' => false]); @@ -515,7 +515,7 @@ public function testBuildDocBlockWithClassName(): void $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); $method->setAccessible(true); - $this->fixer->configure(['add_class_name' => true]); + $this->fixer->configure(['add_structure_name' => true]); $result = $method->invoke($this->fixer, ['author' => 'John Doe'], 'MyClass'); $expected = "/**\n * MyClass.\n *\n * @author John Doe\n */"; @@ -527,7 +527,7 @@ public function testBuildDocBlockWithClassNameOnly(): void $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); $method->setAccessible(true); - $this->fixer->configure(['add_class_name' => true]); + $this->fixer->configure(['add_structure_name' => true]); $result = $method->invoke($this->fixer, [], 'MyClass'); $expected = "/**\n * MyClass.\n */"; @@ -539,7 +539,7 @@ public function testBuildDocBlockWithClassNameDisabled(): void $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); $method->setAccessible(true); - $this->fixer->configure(['add_class_name' => false]); + $this->fixer->configure(['add_structure_name' => false]); $result = $method->invoke($this->fixer, ['author' => 'John Doe'], 'MyClass'); $expected = "/**\n * @author John Doe\n */"; @@ -551,7 +551,7 @@ public function testBuildDocBlockWithEmptyClassName(): void $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); $method->setAccessible(true); - $this->fixer->configure(['add_class_name' => true]); + $this->fixer->configure(['add_structure_name' => true]); $result = $method->invoke($this->fixer, ['author' => 'John Doe'], ''); $expected = "/**\n * @author John Doe\n */"; @@ -563,7 +563,7 @@ public function testGetClassName(): void $code = 'fixer, 'getClassName'); + $method = new ReflectionMethod($this->fixer, 'getStructureName'); $method->setAccessible(true); $result = $method->invoke($this->fixer, $tokens, 1); @@ -576,7 +576,7 @@ public function testGetClassNameWithModifiers(): void $code = 'fixer, 'getClassName'); + $method = new ReflectionMethod($this->fixer, 'getStructureName'); $method->setAccessible(true); // Find the class token index @@ -599,7 +599,7 @@ public function testGetClassNameHitsBreakOnNonStringToken(): void $code = 'fixer, 'getClassName'); + $method = new ReflectionMethod($this->fixer, 'getStructureName'); $method->setAccessible(true); // Find the opening brace token (it comes after class name) @@ -619,15 +619,81 @@ public function testGetClassNameHitsBreakOnNonStringToken(): void self::assertSame('', $result); } - public function testGetClassNameReturnsEmptyWhenLoopCompletes(): void + public function testGetStructureNameInterface(): void + { + $code = 'fixer, 'getStructureName'); + $method->setAccessible(true); + + // Find the interface token index + $interfaceIndex = null; + for ($i = 0; $i < $tokens->count(); ++$i) { + if ($tokens[$i]->isGivenKind(T_INTERFACE)) { + $interfaceIndex = $i; + break; + } + } + + $result = $method->invoke($this->fixer, $tokens, $interfaceIndex); + + self::assertSame('MyInterface', $result); + } + + public function testGetStructureNameTrait(): void + { + $code = 'fixer, 'getStructureName'); + $method->setAccessible(true); + + // Find the trait token index + $traitIndex = null; + for ($i = 0; $i < $tokens->count(); ++$i) { + if ($tokens[$i]->isGivenKind(T_TRAIT)) { + $traitIndex = $i; + break; + } + } + + $result = $method->invoke($this->fixer, $tokens, $traitIndex); + + self::assertSame('MyTrait', $result); + } + + public function testGetStructureNameEnum(): void + { + $code = 'fixer, 'getStructureName'); + $method->setAccessible(true); + + // Find the enum token index + $enumIndex = null; + for ($i = 0; $i < $tokens->count(); ++$i) { + if ($tokens[$i]->isGivenKind(T_ENUM)) { + $enumIndex = $i; + break; + } + } + + $result = $method->invoke($this->fixer, $tokens, $enumIndex); + + self::assertSame('MyEnum', $result); + } + + public function testGetStructureNameReturnsEmptyWhenLoopCompletes(): void { $code = 'fixer, 'getClassName'); + $method = new ReflectionMethod($this->fixer, 'getStructureName'); $method->setAccessible(true); - // Find the closing brace token - when we call getClassName from there, + // Find the closing brace token - when we call getStructureName from there, // the loop should complete without finding anything and return empty string (line 153) $braceIndex = null; for ($i = 0; $i < $tokens->count(); ++$i) { @@ -639,10 +705,88 @@ public function testGetClassNameReturnsEmptyWhenLoopCompletes(): void $result = $method->invoke($this->fixer, $tokens, $braceIndex); - // Should return empty string because we're at the end and loop completes reaching line 153 + // Should return empty string because we're at the end and loop completes self::assertSame('', $result); } + public function testIsCandidateInterface(): void + { + $tokens = Tokens::fromCode('fixer->isCandidate($tokens)); + } + + public function testIsCandidateTrait(): void + { + $tokens = Tokens::fromCode('fixer->isCandidate($tokens)); + } + + public function testIsCandidateEnum(): void + { + $tokens = Tokens::fromCode('fixer->isCandidate($tokens)); + } + + public function testApplyFixAddsDocBlockToInterface(): void + { + $code = 'fixer, 'applyFix'); + $method->setAccessible(true); + + $this->fixer->configure([ + 'annotations' => ['author' => 'John Doe'], + 'separate' => 'none', + ]); + $method->invoke($this->fixer, $file, $tokens); + + $expected = "generateCode()); + } + + public function testApplyFixAddsDocBlockToTrait(): void + { + $code = 'fixer, 'applyFix'); + $method->setAccessible(true); + + $this->fixer->configure([ + 'annotations' => ['author' => 'Jane Doe'], + 'separate' => 'none', + ]); + $method->invoke($this->fixer, $file, $tokens); + + $expected = "generateCode()); + } + + public function testApplyFixAddsDocBlockToEnum(): void + { + $code = 'fixer, 'applyFix'); + $method->setAccessible(true); + + $this->fixer->configure([ + 'annotations' => ['license' => 'MIT'], + 'separate' => 'none', + ]); + $method->invoke($this->fixer, $file, $tokens); + + $expected = "generateCode()); + } + public function testApplyFixWithClassNameEnabled(): void { $code = 'fixer->configure([ 'annotations' => ['author' => 'John Doe'], - 'add_class_name' => true, + 'add_structure_name' => true, 'separate' => 'none', ]); $method->invoke($this->fixer, $file, $tokens); @@ -674,7 +818,7 @@ public function testApplyFixWithMultipleClassesAndClassName(): void $this->fixer->configure([ 'annotations' => ['author' => 'John Doe'], - 'add_class_name' => true, + 'add_structure_name' => true, 'separate' => 'none', ]); $method->invoke($this->fixer, $file, $tokens); @@ -694,7 +838,7 @@ public function testMergeWithExistingDocBlockWithClassName(): void $method = new ReflectionMethod($this->fixer, 'mergeWithExistingDocBlock'); $method->setAccessible(true); - $this->fixer->configure(['add_class_name' => true]); + $this->fixer->configure(['add_structure_name' => true]); $method->invoke($this->fixer, $tokens, 1, $annotations, 'TestClass'); $result = $tokens->generateCode(); @@ -712,7 +856,7 @@ public function testReplaceDocBlockWithClassName(): void $method = new ReflectionMethod($this->fixer, 'replaceDocBlock'); $method->setAccessible(true); - $this->fixer->configure(['add_class_name' => true]); + $this->fixer->configure(['add_structure_name' => true]); $method->invoke($this->fixer, $tokens, 1, $annotations, 'TestClass'); $result = $tokens->generateCode(); @@ -731,7 +875,7 @@ public function testInsertNewDocBlockWithClassNameAndSeparateNone(): void $method->setAccessible(true); $this->fixer->configure([ - 'add_class_name' => true, + 'add_structure_name' => true, 'separate' => 'none', ]); $method->invoke($this->fixer, $tokens, 1, $annotations, 'TestClass');