From c565aa9bffaa84c7bbdde6be9edcd5d9caa28e7e Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 15 Apr 2026 22:21:54 +0200 Subject: [PATCH 1/3] Let tag handlers describe their expected format for InvalidTag reporting Adds an `ExpectedFormat` interface a tag handler may opt into to advertise the syntax it expects together with a link to its canonical documentation. When `StandardTagFactory` falls back to `InvalidTag` because the handler rejected the body, it forwards those hints through the new `InvalidTag::withFormatHint()` method so downstream tooling (for example phpDocumentor's error reports) can surface a helpful explanation instead of only the raw exception. `Author` implements the interface as a first example since its lack of description support is a common source of confusion (see phpDocumentor/phpDocumentor#3378). Fixes #346 --- src/DocBlock/StandardTagFactory.php | 24 +++++++++- src/DocBlock/Tags/Author.php | 12 ++++- src/DocBlock/Tags/ExpectedFormat.php | 33 +++++++++++++ src/DocBlock/Tags/InvalidTag.php | 46 ++++++++++++++++++- .../unit/DocBlock/StandardTagFactoryTest.php | 23 ++++++++++ tests/unit/DocBlock/Tags/InvalidTagTest.php | 31 +++++++++++++ 6 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 src/DocBlock/Tags/ExpectedFormat.php diff --git a/src/DocBlock/StandardTagFactory.php b/src/DocBlock/StandardTagFactory.php index 1d9e5fe3..e7314f19 100644 --- a/src/DocBlock/StandardTagFactory.php +++ b/src/DocBlock/StandardTagFactory.php @@ -32,6 +32,7 @@ use phpDocumentor\Reflection\DocBlock\Tags\Factory\TemplateFactory; use phpDocumentor\Reflection\DocBlock\Tags\Factory\ThrowsFactory; use phpDocumentor\Reflection\DocBlock\Tags\Factory\VarFactory; +use phpDocumentor\Reflection\DocBlock\Tags\ExpectedFormat; use phpDocumentor\Reflection\DocBlock\Tags\Generic; use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag; use phpDocumentor\Reflection\DocBlock\Tags\Link as LinkTag; @@ -54,6 +55,8 @@ use function call_user_func_array; use function get_class; use function is_object; +use function is_string; +use function is_subclass_of; use function preg_match; use function sprintf; use function strpos; @@ -258,12 +261,29 @@ private function createTag(string $body, string $name, TypeContext $context): Ta /** @phpstan-var callable(string): ?Tag $callable */ $tag = call_user_func_array($callable, $arguments); - return $tag ?? InvalidTag::create($body, $name); + return $tag ?? $this->createInvalidTag($handlerClassName, $body, $name); } catch (InvalidArgumentException $e) { - return InvalidTag::create($body, $name)->withError($e); + return $this->createInvalidTag($handlerClassName, $body, $name)->withError($e); } } + /** + * @param class-string|Tag|Factory $handlerClassName + */ + private function createInvalidTag($handlerClassName, string $body, string $name): InvalidTag + { + $invalidTag = InvalidTag::create($body, $name); + + if (is_string($handlerClassName) && is_subclass_of($handlerClassName, ExpectedFormat::class)) { + $invalidTag = $invalidTag->withFormatHint( + $handlerClassName::getExpectedFormat(), + $handlerClassName::getDocumentationUrl() + ); + } + + return $invalidTag; + } + /** * Determines the Fully Qualified Class Name of the Factory or Tag (containing a Factory Method `create`). * diff --git a/src/DocBlock/Tags/Author.php b/src/DocBlock/Tags/Author.php index e604ac8b..f2b225aa 100644 --- a/src/DocBlock/Tags/Author.php +++ b/src/DocBlock/Tags/Author.php @@ -24,11 +24,21 @@ /** * Reflection class for an {@}author tag in a Docblock. */ -final class Author extends BaseTag +final class Author extends BaseTag implements ExpectedFormat { /** @var string register that this is the author tag. */ protected string $name = 'author'; + public static function getExpectedFormat(): string + { + return 'name []'; + } + + public static function getDocumentationUrl(): ?string + { + return 'https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/author.html'; + } + /** @var string The name of the author */ private string $authorName; diff --git a/src/DocBlock/Tags/ExpectedFormat.php b/src/DocBlock/Tags/ExpectedFormat.php new file mode 100644 index 00000000..c1c84637 --- /dev/null +++ b/src/DocBlock/Tags/ExpectedFormat.php @@ -0,0 +1,33 @@ +]". + */ + public static function getExpectedFormat(): string; + + /** + * Returns a URL pointing to the canonical documentation for the tag, or null when none is available. + */ + public static function getDocumentationUrl(): ?string; +} diff --git a/src/DocBlock/Tags/InvalidTag.php b/src/DocBlock/Tags/InvalidTag.php index 848f34d7..70f9a935 100644 --- a/src/DocBlock/Tags/InvalidTag.php +++ b/src/DocBlock/Tags/InvalidTag.php @@ -40,6 +40,10 @@ final class InvalidTag implements Tag private ?Throwable $throwable = null; + private ?string $expectedFormat = null; + + private ?string $documentationUrl = null; + private function __construct(string $name, string $body) { $this->name = $name; @@ -51,6 +55,23 @@ public function getException(): ?Throwable return $this->throwable; } + /** + * Returns a short description of the format the corresponding tag handler expected, or null when the handler + * did not advertise one via {@see ExpectedFormat}. + */ + public function getExpectedFormat(): ?string + { + return $this->expectedFormat; + } + + /** + * Returns a URL pointing to the canonical documentation of the tag, or null when none is available. + */ + public function getDocumentationUrl(): ?string + { + return $this->documentationUrl; + } + public function getName(): string { return $this->name; @@ -64,12 +85,35 @@ public static function create(string $body, string $name = ''): self public function withError(Throwable $exception): self { $this->flattenExceptionBacktrace($exception); - $tag = new self($this->name, $this->body); + $tag = $this->copy(); $tag->throwable = $exception; return $tag; } + /** + * Returns a copy of this invalid tag that also carries hints about the syntax expected by the originating tag + * handler. Both arguments are optional so callers can advertise whichever information they have. + */ + public function withFormatHint(?string $expectedFormat, ?string $documentationUrl = null): self + { + $tag = $this->copy(); + $tag->expectedFormat = $expectedFormat; + $tag->documentationUrl = $documentationUrl; + + return $tag; + } + + private function copy(): self + { + $tag = new self($this->name, $this->body); + $tag->throwable = $this->throwable; + $tag->expectedFormat = $this->expectedFormat; + $tag->documentationUrl = $this->documentationUrl; + + return $tag; + } + /** * Removes all complex types from backtrace * diff --git a/tests/unit/DocBlock/StandardTagFactoryTest.php b/tests/unit/DocBlock/StandardTagFactoryTest.php index 114ea9e2..0c34b27d 100644 --- a/tests/unit/DocBlock/StandardTagFactoryTest.php +++ b/tests/unit/DocBlock/StandardTagFactoryTest.php @@ -21,6 +21,7 @@ use phpDocumentor\Reflection\Assets\CustomServiceInterface; use phpDocumentor\Reflection\Assets\CustomTagFactory; use phpDocumentor\Reflection\DocBlock\Tags\Author; +use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag; use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; use phpDocumentor\Reflection\DocBlock\Tags\Extends_; use phpDocumentor\Reflection\DocBlock\Tags\Formatter; @@ -135,6 +136,28 @@ public function testCreatingASpecificTag(): void $this->assertSame('author', $tag->getName()); } + /** + * @uses \phpDocumentor\Reflection\DocBlock\StandardTagFactory::addService + * @uses \phpDocumentor\Reflection\DocBlock\Tags\Author + * @uses \phpDocumentor\Reflection\DocBlock\Tags\BaseTag + * @uses \phpDocumentor\Reflection\DocBlock\Tags\InvalidTag + * + * @covers ::__construct + * @covers ::create + */ + public function testInvalidTagReceivesFormatHintFromHandler(): void + { + $context = new Context(''); + $tagFactory = StandardTagFactory::createInstance(m::mock(FqsenResolver::class)); + + $tag = $tagFactory->create('@author Mike ', $context); + + $this->assertInstanceOf(InvalidTag::class, $tag); + $this->assertSame('author', $tag->getName()); + $this->assertSame(Author::getExpectedFormat(), $tag->getExpectedFormat()); + $this->assertSame(Author::getDocumentationUrl(), $tag->getDocumentationUrl()); + } + /** * @uses \phpDocumentor\Reflection\DocBlock\StandardTagFactory::addService * @uses \phpDocumentor\Reflection\DocBlock\Tags\See diff --git a/tests/unit/DocBlock/Tags/InvalidTagTest.php b/tests/unit/DocBlock/Tags/InvalidTagTest.php index dc252cda..270fe8bb 100644 --- a/tests/unit/DocBlock/Tags/InvalidTagTest.php +++ b/tests/unit/DocBlock/Tags/InvalidTagTest.php @@ -31,6 +31,37 @@ public function testCreationWithoutError(): void self::assertSame('name', $tag->getName()); self::assertSame('@name Body', $tag->render()); self::assertNull($tag->getException()); + self::assertNull($tag->getExpectedFormat()); + self::assertNull($tag->getDocumentationUrl()); + } + + /** + * @covers ::withFormatHint + * @covers ::getExpectedFormat + * @covers ::getDocumentationUrl + */ + public function testCreationWithFormatHint(): void + { + $tag = InvalidTag::create('Body', 'name') + ->withFormatHint('expected format', 'https://example.com/doc'); + + self::assertSame('expected format', $tag->getExpectedFormat()); + self::assertSame('https://example.com/doc', $tag->getDocumentationUrl()); + } + + /** + * @covers ::withFormatHint + * @covers ::withError + */ + public function testFormatHintSurvivesWithError(): void + { + $tag = InvalidTag::create('Body', 'name') + ->withFormatHint('expected format', 'https://example.com/doc') + ->withError(new Exception('boom')); + + self::assertSame('expected format', $tag->getExpectedFormat()); + self::assertSame('https://example.com/doc', $tag->getDocumentationUrl()); + self::assertNotNull($tag->getException()); } /** From a96764ef0fa12bebd4a9a4609a6f34c2fa4b3554 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 15 Apr 2026 22:24:36 +0200 Subject: [PATCH 2/3] Sort imports, relocate Author static methods, add negative hint test --- src/DocBlock/StandardTagFactory.php | 2 +- src/DocBlock/Tags/Author.php | 20 +++++++------- .../unit/DocBlock/StandardTagFactoryTest.php | 26 ++++++++++++++++++- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/DocBlock/StandardTagFactory.php b/src/DocBlock/StandardTagFactory.php index e7314f19..f1fde370 100644 --- a/src/DocBlock/StandardTagFactory.php +++ b/src/DocBlock/StandardTagFactory.php @@ -17,6 +17,7 @@ use phpDocumentor\Reflection\DocBlock\Tags\Author; use phpDocumentor\Reflection\DocBlock\Tags\Covers; use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; +use phpDocumentor\Reflection\DocBlock\Tags\ExpectedFormat; use phpDocumentor\Reflection\DocBlock\Tags\Factory\AbstractPHPStanFactory; use phpDocumentor\Reflection\DocBlock\Tags\Factory\ExtendsFactory; use phpDocumentor\Reflection\DocBlock\Tags\Factory\Factory; @@ -32,7 +33,6 @@ use phpDocumentor\Reflection\DocBlock\Tags\Factory\TemplateFactory; use phpDocumentor\Reflection\DocBlock\Tags\Factory\ThrowsFactory; use phpDocumentor\Reflection\DocBlock\Tags\Factory\VarFactory; -use phpDocumentor\Reflection\DocBlock\Tags\ExpectedFormat; use phpDocumentor\Reflection\DocBlock\Tags\Generic; use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag; use phpDocumentor\Reflection\DocBlock\Tags\Link as LinkTag; diff --git a/src/DocBlock/Tags/Author.php b/src/DocBlock/Tags/Author.php index f2b225aa..500a4840 100644 --- a/src/DocBlock/Tags/Author.php +++ b/src/DocBlock/Tags/Author.php @@ -29,16 +29,6 @@ final class Author extends BaseTag implements ExpectedFormat /** @var string register that this is the author tag. */ protected string $name = 'author'; - public static function getExpectedFormat(): string - { - return 'name []'; - } - - public static function getDocumentationUrl(): ?string - { - return 'https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/author.html'; - } - /** @var string The name of the author */ private string $authorName; @@ -109,4 +99,14 @@ public static function create(string $body): ?self return new static($authorName, $email); } + + public static function getExpectedFormat(): string + { + return 'name []'; + } + + public static function getDocumentationUrl(): ?string + { + return 'https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/author.html'; + } } diff --git a/tests/unit/DocBlock/StandardTagFactoryTest.php b/tests/unit/DocBlock/StandardTagFactoryTest.php index 0c34b27d..8f79081a 100644 --- a/tests/unit/DocBlock/StandardTagFactoryTest.php +++ b/tests/unit/DocBlock/StandardTagFactoryTest.php @@ -21,13 +21,13 @@ use phpDocumentor\Reflection\Assets\CustomServiceInterface; use phpDocumentor\Reflection\Assets\CustomTagFactory; use phpDocumentor\Reflection\DocBlock\Tags\Author; -use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag; use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; use phpDocumentor\Reflection\DocBlock\Tags\Extends_; use phpDocumentor\Reflection\DocBlock\Tags\Formatter; use phpDocumentor\Reflection\DocBlock\Tags\Formatter\PassthroughFormatter; use phpDocumentor\Reflection\DocBlock\Tags\Generic; use phpDocumentor\Reflection\DocBlock\Tags\Implements_; +use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag; use phpDocumentor\Reflection\DocBlock\Tags\Method; use phpDocumentor\Reflection\DocBlock\Tags\Mixin; use phpDocumentor\Reflection\DocBlock\Tags\Param; @@ -158,6 +158,30 @@ public function testInvalidTagReceivesFormatHintFromHandler(): void $this->assertSame(Author::getDocumentationUrl(), $tag->getDocumentationUrl()); } + /** + * @uses \phpDocumentor\Reflection\DocBlock\StandardTagFactory::addService + * @uses \phpDocumentor\Reflection\DocBlock\Tags\Generic + * @uses \phpDocumentor\Reflection\DocBlock\Tags\BaseTag + * @uses \phpDocumentor\Reflection\DocBlock\Tags\InvalidTag + * + * @covers ::__construct + * @covers ::create + */ + public function testInvalidTagKeepsNullHintsWhenHandlerDoesNotAdvertiseAny(): void + { + $context = new Context(''); + $descriptionFactory = m::mock(DescriptionFactory::class); + $descriptionFactory->shouldReceive('create')->andThrow(new InvalidArgumentException('boom')); + $tagFactory = StandardTagFactory::createInstance(m::mock(FqsenResolver::class)); + $tagFactory->addService($descriptionFactory, DescriptionFactory::class); + + $tag = $tagFactory->create('@custom anything', $context); + + $this->assertInstanceOf(InvalidTag::class, $tag); + $this->assertNull($tag->getExpectedFormat()); + $this->assertNull($tag->getDocumentationUrl()); + } + /** * @uses \phpDocumentor\Reflection\DocBlock\StandardTagFactory::addService * @uses \phpDocumentor\Reflection\DocBlock\Tags\See From 5e02ef9a5e110818f6ba7eef24939e23d4956d05 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 15 Apr 2026 22:25:49 +0200 Subject: [PATCH 3/3] Use fully qualified FQSEN in ExpectedFormat / InvalidTag docblocks --- src/DocBlock/Tags/ExpectedFormat.php | 6 +++--- src/DocBlock/Tags/InvalidTag.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DocBlock/Tags/ExpectedFormat.php b/src/DocBlock/Tags/ExpectedFormat.php index c1c84637..4f798325 100644 --- a/src/DocBlock/Tags/ExpectedFormat.php +++ b/src/DocBlock/Tags/ExpectedFormat.php @@ -15,9 +15,9 @@ /** * Tag handlers may implement this contract to describe what they expect as input. When the handler rejects a body - * and an {@see InvalidTag} is produced, the factory forwards these hints to the invalid tag so downstream tooling - * (for example phpDocumentor's error reporting) can explain the expected syntax instead of only showing the raw - * exception. + * and an {@see \phpDocumentor\Reflection\DocBlock\Tags\InvalidTag} is produced, the factory forwards these hints to + * the invalid tag so downstream tooling (for example phpDocumentor's error reporting) can explain the expected + * syntax instead of only showing the raw exception. */ interface ExpectedFormat { diff --git a/src/DocBlock/Tags/InvalidTag.php b/src/DocBlock/Tags/InvalidTag.php index 70f9a935..16630a48 100644 --- a/src/DocBlock/Tags/InvalidTag.php +++ b/src/DocBlock/Tags/InvalidTag.php @@ -57,7 +57,7 @@ public function getException(): ?Throwable /** * Returns a short description of the format the corresponding tag handler expected, or null when the handler - * did not advertise one via {@see ExpectedFormat}. + * did not advertise one via {@see \phpDocumentor\Reflection\DocBlock\Tags\ExpectedFormat}. */ public function getExpectedFormat(): ?string {