From 1cb2cbd54e3df415418592003631a276e36bc322 Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Thu, 7 Aug 2025 20:04:36 +0200 Subject: [PATCH] feat: add option to include class name in DocBlock generation --- README.md | 4 +- src/Generators/DocBlockHeader.php | 5 +- src/Rules/DocBlockHeaderFixer.php | 65 ++++- tests/src/Generators/DocBlockHeaderTest.php | 42 ++++ tests/src/Rules/DocBlockHeaderFixerTest.php | 265 ++++++++++++++++++-- 5 files changed, 349 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c7096fc..bf88e15 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ return (new PhpCsFixer\Config()) ], 'preserve_existing' => true, 'separate' => 'none', + 'add_class_name' => true, ], ]) ; @@ -98,7 +99,8 @@ return (new PhpCsFixer\Config()) 'package' => 'PhpDocBlockHeaderFixer', ], preserveExisting: true, - separate: \KonradMichalik\PhpDocBlockHeaderFixer\Model\Separate::None + separate: \KonradMichalik\PhpDocBlockHeaderFixer\Enum\Separate::None, + addClassName: true )->__toArray() ]) ; diff --git a/src/Generators/DocBlockHeader.php b/src/Generators/DocBlockHeader.php index 8111cb6..fbae28c 100644 --- a/src/Generators/DocBlockHeader.php +++ b/src/Generators/DocBlockHeader.php @@ -35,6 +35,7 @@ private function __construct( public readonly array $annotations, public readonly bool $preserveExisting, public readonly Separate $separate, + public readonly bool $addClassName, ) {} /** @@ -44,10 +45,11 @@ public static function create( array $annotations, bool $preserveExisting = true, Separate $separate = Separate::Both, + bool $addClassName = false, ): self { self::validateAnnotations($annotations); - return new self($annotations, $preserveExisting, $separate); + return new self($annotations, $preserveExisting, $separate, $addClassName); } /** @@ -60,6 +62,7 @@ public function __toArray(): array 'annotations' => $this->annotations, 'preserve_existing' => $this->preserveExisting, 'separate' => $this->separate->value, + 'add_class_name' => $this->addClassName, ], ]; } diff --git a/src/Rules/DocBlockHeaderFixer.php b/src/Rules/DocBlockHeaderFixer.php index 7535a13..f82faf0 100644 --- a/src/Rules/DocBlockHeaderFixer.php +++ b/src/Rules/DocBlockHeaderFixer.php @@ -81,6 +81,10 @@ public function getConfigurationDefinition(): FixerConfigurationResolverInterfac ->setAllowedValues(Separate::getList()) ->setDefault(Separate::Both->value) ->getOption(), + (new FixerOptionBuilder('add_class_name', 'Add class name before annotations')) + ->setAllowedTypes(['bool']) + ->setDefault(false) + ->getOption(), ]); } @@ -103,27 +107,50 @@ protected function applyFix(SplFileInfo $file, Tokens $tokens): void continue; } - $this->processClassDocBlock($tokens, $index, $annotations); + $className = $this->getClassName($tokens, $index); + $this->processClassDocBlock($tokens, $index, $annotations, $className); } } /** * @param array> $annotations */ - private function processClassDocBlock(Tokens $tokens, int $classIndex, array $annotations): void + private function processClassDocBlock(Tokens $tokens, int $classIndex, array $annotations, string $className): void { $existingDocBlockIndex = $this->findExistingDocBlock($tokens, $classIndex); $preserveExisting = $this->resolvedConfiguration['preserve_existing'] ?? true; if (null !== $existingDocBlockIndex) { if ($preserveExisting) { - $this->mergeWithExistingDocBlock($tokens, $existingDocBlockIndex, $annotations); + $this->mergeWithExistingDocBlock($tokens, $existingDocBlockIndex, $annotations, $className); } else { - $this->replaceDocBlock($tokens, $existingDocBlockIndex, $annotations); + $this->replaceDocBlock($tokens, $existingDocBlockIndex, $annotations, $className); } } else { - $this->insertNewDocBlock($tokens, $classIndex, $annotations); + $this->insertNewDocBlock($tokens, $classIndex, $annotations, $className); + } + } + + private function getClassName(Tokens $tokens, int $classIndex): string + { + // Look for the class name token after the 'class' keyword + for ($i = $classIndex + 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 + if ($token->isGivenKind(T_STRING)) { + return $token->getContent(); + } + + // If we hit anything else, stop looking + break; } + + return ''; } private function findExistingDocBlock(Tokens $tokens, int $classIndex): ?int @@ -151,29 +178,29 @@ private function findExistingDocBlock(Tokens $tokens, int $classIndex): ?int /** * @param array> $annotations */ - private function mergeWithExistingDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations): void + private function mergeWithExistingDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations, string $className): void { $existingContent = $tokens[$docBlockIndex]->getContent(); $existingAnnotations = $this->parseExistingAnnotations($existingContent); $mergedAnnotations = $this->mergeAnnotations($existingAnnotations, $annotations); - $newDocBlock = $this->buildDocBlock($mergedAnnotations); + $newDocBlock = $this->buildDocBlock($mergedAnnotations, $className); $tokens[$docBlockIndex] = new Token([T_DOC_COMMENT, $newDocBlock]); } /** * @param array> $annotations */ - private function replaceDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations): void + private function replaceDocBlock(Tokens $tokens, int $docBlockIndex, array $annotations, string $className): void { - $newDocBlock = $this->buildDocBlock($annotations); + $newDocBlock = $this->buildDocBlock($annotations, $className); $tokens[$docBlockIndex] = new Token([T_DOC_COMMENT, $newDocBlock]); } /** * @param array> $annotations */ - private function insertNewDocBlock(Tokens $tokens, int $classIndex, array $annotations): void + private function insertNewDocBlock(Tokens $tokens, int $classIndex, array $annotations, string $className): void { $separate = $this->resolvedConfiguration['separate'] ?? 'both'; $insertIndex = $this->findInsertPosition($tokens, $classIndex); @@ -186,7 +213,7 @@ private function insertNewDocBlock(Tokens $tokens, int $classIndex, array $annot } // Add the DocBlock - $docBlock = $this->buildDocBlock($annotations); + $docBlock = $this->buildDocBlock($annotations, $className); $tokensToInsert[] = new Token([T_DOC_COMMENT, $docBlock]); // Add separation after comment if needed @@ -255,14 +282,26 @@ private function mergeAnnotations(array $existing, array $new): array /** * @param array> $annotations */ - private function buildDocBlock(array $annotations): string + private function buildDocBlock(array $annotations, string $className): string { - if (empty($annotations)) { + $addClassName = $this->resolvedConfiguration['add_class_name'] ?? false; + + if (empty($annotations) && !$addClassName) { return "/**\n */"; } $docBlock = "/**\n"; + // Add class name with dot if configured + if ($addClassName && !empty($className)) { + $docBlock .= " * {$className}.\n"; + + // Add empty line after class name if there are annotations + if (!empty($annotations)) { + $docBlock .= " *\n"; + } + } + foreach ($annotations as $tag => $value) { if (empty($value)) { $docBlock .= " * @{$tag}\n"; diff --git a/tests/src/Generators/DocBlockHeaderTest.php b/tests/src/Generators/DocBlockHeaderTest.php index db8ed8f..a0e1156 100644 --- a/tests/src/Generators/DocBlockHeaderTest.php +++ b/tests/src/Generators/DocBlockHeaderTest.php @@ -91,6 +91,7 @@ public function testToArrayReturnsCorrectStructure(): void 'annotations' => $annotations, 'preserve_existing' => false, 'separate' => 'top', + 'add_class_name' => false, ], ]; @@ -109,6 +110,7 @@ public function testToArrayWithDefaultParameters(): void 'annotations' => $annotations, 'preserve_existing' => true, 'separate' => 'both', + 'add_class_name' => false, ], ]; @@ -268,4 +270,44 @@ public function testClassIsFinal(): void self::assertTrue($reflection->isFinal()); } + + public function testCreateWithAddClassName(): void + { + $annotations = ['author' => 'John Doe']; + $docBlockHeader = DocBlockHeader::create( + $annotations, + true, + Separate::None, + true, + ); + + self::assertSame($annotations, $docBlockHeader->annotations); + self::assertTrue($docBlockHeader->preserveExisting); + self::assertSame(Separate::None, $docBlockHeader->separate); + self::assertTrue($docBlockHeader->addClassName); + } + + public function testToArrayWithAddClassName(): void + { + $annotations = ['author' => 'John Doe']; + $docBlockHeader = DocBlockHeader::create( + $annotations, + false, + Separate::Top, + true, + ); + + $result = $docBlockHeader->__toArray(); + + $expected = [ + 'KonradMichalik/docblock_header_comment' => [ + 'annotations' => $annotations, + 'preserve_existing' => false, + 'separate' => 'top', + 'add_class_name' => true, + ], + ]; + + self::assertSame($expected, $result); + } } diff --git a/tests/src/Rules/DocBlockHeaderFixerTest.php b/tests/src/Rules/DocBlockHeaderFixerTest.php index cc4f66e..13788ff 100644 --- a/tests/src/Rules/DocBlockHeaderFixerTest.php +++ b/tests/src/Rules/DocBlockHeaderFixerTest.php @@ -73,7 +73,7 @@ public function testBuildDocBlockWithEmptyAnnotations(): void $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); $method->setAccessible(true); - $result = $method->invoke($this->fixer, []); + $result = $method->invoke($this->fixer, [], 'TestClass'); self::assertSame("/**\n */", $result); } @@ -83,7 +83,7 @@ public function testBuildDocBlockWithSingleAnnotation(): void $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); $method->setAccessible(true); - $result = $method->invoke($this->fixer, ['author' => 'John Doe ']); + $result = $method->invoke($this->fixer, ['author' => 'John Doe '], ''); $expected = "/**\n * @author John Doe \n */"; self::assertSame($expected, $result); @@ -98,7 +98,7 @@ public function testBuildDocBlockWithMultipleAnnotations(): void 'author' => 'John Doe ', 'license' => 'MIT', 'package' => 'MyPackage', - ]); + ], ''); $expected = "/**\n * @author John Doe \n * @license MIT\n * @package MyPackage\n */"; self::assertSame($expected, $result); @@ -115,7 +115,7 @@ public function testBuildDocBlockWithArrayValue(): void 'Jane Smith ', ], 'license' => 'MIT', - ]); + ], ''); $expected = "/**\n * @author John Doe \n * @author Jane Smith \n * @license MIT\n */"; self::assertSame($expected, $result); @@ -129,7 +129,7 @@ public function testBuildDocBlockWithEmptyValue(): void $result = $method->invoke($this->fixer, [ 'deprecated' => '', 'author' => 'John Doe', - ]); + ], ''); $expected = "/**\n * @deprecated\n * @author John Doe\n */"; self::assertSame($expected, $result); @@ -143,7 +143,7 @@ public function testBuildDocBlockWithNullValue(): void $result = $method->invoke($this->fixer, [ 'internal' => null, 'license' => 'MIT', - ]); + ], ''); $expected = "/**\n * @internal\n * @license MIT\n */"; self::assertSame($expected, $result); @@ -160,7 +160,7 @@ public function testBuildDocBlockWithMixedEmptyAndNonEmptyValues(): void 'internal' => null, 'license' => 'MIT', 'api' => '', - ]); + ], ''); $expected = "/**\n * @deprecated\n * @author John Doe\n * @internal\n * @license MIT\n * @api\n */"; self::assertSame($expected, $result); @@ -178,12 +178,13 @@ public function testConfigurationDefinition(): void $configDefinition = $this->fixer->getConfigurationDefinition(); $options = $configDefinition->getOptions(); - self::assertCount(3, $options); + self::assertCount(4, $options); $optionNames = array_map(fn ($option) => $option->getName(), $options); self::assertContains('annotations', $optionNames); self::assertContains('preserve_existing', $optionNames); self::assertContains('separate', $optionNames); + self::assertContains('add_class_name', $optionNames); } public function testParseExistingAnnotations(): void @@ -284,7 +285,7 @@ public function testProcessClassDocBlockWithNewDocBlock(): void $method->setAccessible(true); $this->fixer->configure(['separate' => 'none']); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); $expected = "generateCode()); @@ -300,7 +301,7 @@ public function testProcessClassDocBlockWithExistingDocBlockPreserve(): void $method->setAccessible(true); $this->fixer->configure(['preserve_existing' => true]); - $method->invoke($this->fixer, $tokens, 2, $annotations); + $method->invoke($this->fixer, $tokens, 2, $annotations, 'Foo'); self::assertStringContainsString('@license MIT', $tokens->generateCode()); self::assertStringContainsString('@author John Doe', $tokens->generateCode()); @@ -316,7 +317,7 @@ public function testProcessClassDocBlockWithExistingDocBlockReplace(): void $method->setAccessible(true); $this->fixer->configure(['preserve_existing' => false]); - $method->invoke($this->fixer, $tokens, 2, $annotations); + $method->invoke($this->fixer, $tokens, 2, $annotations, 'Foo'); self::assertStringNotContainsString('@license MIT', $tokens->generateCode()); self::assertStringContainsString('@author John Doe', $tokens->generateCode()); @@ -370,7 +371,7 @@ public function testMergeWithExistingDocBlock(): void $method = new ReflectionMethod($this->fixer, 'mergeWithExistingDocBlock'); $method->setAccessible(true); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); self::assertStringContainsString('@license MIT', $tokens->generateCode()); self::assertStringContainsString('@author John Doe', $tokens->generateCode()); @@ -385,7 +386,7 @@ public function testReplaceDocBlock(): void $method = new ReflectionMethod($this->fixer, 'replaceDocBlock'); $method->setAccessible(true); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); self::assertStringNotContainsString('@license MIT', $tokens->generateCode()); self::assertStringContainsString('@author John Doe', $tokens->generateCode()); @@ -401,7 +402,7 @@ public function testInsertNewDocBlockWithSeparateNone(): void $method->setAccessible(true); $this->fixer->configure(['separate' => 'none']); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); $expected = "generateCode()); @@ -417,7 +418,7 @@ public function testInsertNewDocBlockWithSeparateTop(): void $method->setAccessible(true); $this->fixer->configure(['separate' => 'top']); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); $result = $tokens->generateCode(); self::assertStringContainsString('@author John Doe', $result); @@ -434,7 +435,7 @@ public function testInsertNewDocBlockWithSeparateBottom(): void $method->setAccessible(true); $this->fixer->configure(['separate' => 'bottom']); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); $result = $tokens->generateCode(); self::assertStringContainsString('@author John Doe', $result); @@ -451,7 +452,7 @@ public function testInsertNewDocBlockWithSeparateBoth(): void $method->setAccessible(true); $this->fixer->configure(['separate' => 'both']); - $method->invoke($this->fixer, $tokens, 1, $annotations); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'Foo'); $result = $tokens->generateCode(); self::assertStringContainsString('@author John Doe', $result); @@ -509,4 +510,234 @@ public function testFindInsertPositionWithAttribute(): void self::assertSame(1, $result); } + + public function testBuildDocBlockWithClassName(): void + { + $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure(['add_class_name' => true]); + $result = $method->invoke($this->fixer, ['author' => 'John Doe'], 'MyClass'); + + $expected = "/**\n * MyClass.\n *\n * @author John Doe\n */"; + self::assertSame($expected, $result); + } + + public function testBuildDocBlockWithClassNameOnly(): void + { + $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure(['add_class_name' => true]); + $result = $method->invoke($this->fixer, [], 'MyClass'); + + $expected = "/**\n * MyClass.\n */"; + self::assertSame($expected, $result); + } + + public function testBuildDocBlockWithClassNameDisabled(): void + { + $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure(['add_class_name' => false]); + $result = $method->invoke($this->fixer, ['author' => 'John Doe'], 'MyClass'); + + $expected = "/**\n * @author John Doe\n */"; + self::assertSame($expected, $result); + } + + public function testBuildDocBlockWithEmptyClassName(): void + { + $method = new ReflectionMethod($this->fixer, 'buildDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure(['add_class_name' => true]); + $result = $method->invoke($this->fixer, ['author' => 'John Doe'], ''); + + $expected = "/**\n * @author John Doe\n */"; + self::assertSame($expected, $result); + } + + public function testGetClassName(): void + { + $code = 'fixer, 'getClassName'); + $method->setAccessible(true); + + $result = $method->invoke($this->fixer, $tokens, 1); + + self::assertSame('MyTestClass', $result); + } + + public function testGetClassNameWithModifiers(): void + { + $code = 'fixer, 'getClassName'); + $method->setAccessible(true); + + // Find the class token index + $classIndex = null; + for ($i = 0; $i < $tokens->count(); ++$i) { + if ($tokens[$i]->isGivenKind(T_CLASS)) { + $classIndex = $i; + break; + } + } + + $result = $method->invoke($this->fixer, $tokens, $classIndex); + + self::assertSame('FinalClass', $result); + } + + public function testGetClassNameHitsBreakOnNonStringToken(): void + { + // Use a valid class with annotations to find a non-T_STRING token after class + $code = 'fixer, 'getClassName'); + $method->setAccessible(true); + + // Find the opening brace token (it comes after class name) + $braceIndex = null; + for ($i = 0; $i < $tokens->count(); ++$i) { + if ('{' === $tokens[$i]->getContent()) { + $braceIndex = $i - 1; // Use position just before brace + break; + } + } + + // Call from a position where the next non-whitespace token is '{', not T_STRING + // This should trigger the break on line 150 + $result = $method->invoke($this->fixer, $tokens, $braceIndex); + + // The method should find no T_STRING after this position and break, returning empty + self::assertSame('', $result); + } + + public function testGetClassNameReturnsEmptyWhenLoopCompletes(): void + { + $code = 'fixer, 'getClassName'); + $method->setAccessible(true); + + // Find the closing brace token - when we call getClassName from there, + // the loop should complete without finding anything and return empty string (line 153) + $braceIndex = null; + for ($i = 0; $i < $tokens->count(); ++$i) { + if ('}' === $tokens[$i]->getContent()) { + $braceIndex = $i; + break; + } + } + + $result = $method->invoke($this->fixer, $tokens, $braceIndex); + + // Should return empty string because we're at the end and loop completes reaching line 153 + self::assertSame('', $result); + } + + public function testApplyFixWithClassNameEnabled(): void + { + $code = 'fixer, 'applyFix'); + $method->setAccessible(true); + + $this->fixer->configure([ + 'annotations' => ['author' => 'John Doe'], + 'add_class_name' => true, + 'separate' => 'none', + ]); + $method->invoke($this->fixer, $file, $tokens); + + $expected = "generateCode()); + } + + public function testApplyFixWithMultipleClassesAndClassName(): void + { + $code = 'fixer, 'applyFix'); + $method->setAccessible(true); + + $this->fixer->configure([ + 'annotations' => ['author' => 'John Doe'], + 'add_class_name' => true, + 'separate' => 'none', + ]); + $method->invoke($this->fixer, $file, $tokens); + + $result = $tokens->generateCode(); + self::assertStringContainsString('FirstClass.', $result); + self::assertStringContainsString('SecondClass.', $result); + self::assertStringContainsString('@author John Doe', $result); + } + + public function testMergeWithExistingDocBlockWithClassName(): void + { + $code = " 'John Doe']; + + $method = new ReflectionMethod($this->fixer, 'mergeWithExistingDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure(['add_class_name' => true]); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'TestClass'); + + $result = $tokens->generateCode(); + self::assertStringContainsString('TestClass.', $result); + self::assertStringContainsString('@license MIT', $result); + self::assertStringContainsString('@author John Doe', $result); + } + + public function testReplaceDocBlockWithClassName(): void + { + $code = " 'John Doe']; + + $method = new ReflectionMethod($this->fixer, 'replaceDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure(['add_class_name' => true]); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'TestClass'); + + $result = $tokens->generateCode(); + self::assertStringContainsString('TestClass.', $result); + self::assertStringNotContainsString('@license MIT', $result); + self::assertStringContainsString('@author John Doe', $result); + } + + public function testInsertNewDocBlockWithClassNameAndSeparateNone(): void + { + $code = ' 'John Doe']; + + $method = new ReflectionMethod($this->fixer, 'insertNewDocBlock'); + $method->setAccessible(true); + + $this->fixer->configure([ + 'add_class_name' => true, + 'separate' => 'none', + ]); + $method->invoke($this->fixer, $tokens, 1, $annotations, 'TestClass'); + + $expected = "generateCode()); + } }