diff --git a/src/DocBlock/StandardTagFactory.php b/src/DocBlock/StandardTagFactory.php index 1d9e5fe3..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; @@ -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..500a4840 100644 --- a/src/DocBlock/Tags/Author.php +++ b/src/DocBlock/Tags/Author.php @@ -24,7 +24,7 @@ /** * 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'; @@ -99,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/src/DocBlock/Tags/ExpectedFormat.php b/src/DocBlock/Tags/ExpectedFormat.php new file mode 100644 index 00000000..4f798325 --- /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..16630a48 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 \phpDocumentor\Reflection\DocBlock\Tags\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..8f79081a 100644 --- a/tests/unit/DocBlock/StandardTagFactoryTest.php +++ b/tests/unit/DocBlock/StandardTagFactoryTest.php @@ -27,6 +27,7 @@ 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; @@ -135,6 +136,52 @@ 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\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 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()); } /**