Skip to content

Commit c565aa9

Browse files
committed
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
1 parent 7bae675 commit c565aa9

File tree

6 files changed

+165
-4
lines changed

6 files changed

+165
-4
lines changed

src/DocBlock/StandardTagFactory.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use phpDocumentor\Reflection\DocBlock\Tags\Factory\TemplateFactory;
3333
use phpDocumentor\Reflection\DocBlock\Tags\Factory\ThrowsFactory;
3434
use phpDocumentor\Reflection\DocBlock\Tags\Factory\VarFactory;
35+
use phpDocumentor\Reflection\DocBlock\Tags\ExpectedFormat;
3536
use phpDocumentor\Reflection\DocBlock\Tags\Generic;
3637
use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag;
3738
use phpDocumentor\Reflection\DocBlock\Tags\Link as LinkTag;
@@ -54,6 +55,8 @@
5455
use function call_user_func_array;
5556
use function get_class;
5657
use function is_object;
58+
use function is_string;
59+
use function is_subclass_of;
5760
use function preg_match;
5861
use function sprintf;
5962
use function strpos;
@@ -258,12 +261,29 @@ private function createTag(string $body, string $name, TypeContext $context): Ta
258261
/** @phpstan-var callable(string): ?Tag $callable */
259262
$tag = call_user_func_array($callable, $arguments);
260263

261-
return $tag ?? InvalidTag::create($body, $name);
264+
return $tag ?? $this->createInvalidTag($handlerClassName, $body, $name);
262265
} catch (InvalidArgumentException $e) {
263-
return InvalidTag::create($body, $name)->withError($e);
266+
return $this->createInvalidTag($handlerClassName, $body, $name)->withError($e);
264267
}
265268
}
266269

270+
/**
271+
* @param class-string<Tag>|Tag|Factory $handlerClassName
272+
*/
273+
private function createInvalidTag($handlerClassName, string $body, string $name): InvalidTag
274+
{
275+
$invalidTag = InvalidTag::create($body, $name);
276+
277+
if (is_string($handlerClassName) && is_subclass_of($handlerClassName, ExpectedFormat::class)) {
278+
$invalidTag = $invalidTag->withFormatHint(
279+
$handlerClassName::getExpectedFormat(),
280+
$handlerClassName::getDocumentationUrl()
281+
);
282+
}
283+
284+
return $invalidTag;
285+
}
286+
267287
/**
268288
* Determines the Fully Qualified Class Name of the Factory or Tag (containing a Factory Method `create`).
269289
*

src/DocBlock/Tags/Author.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,21 @@
2424
/**
2525
* Reflection class for an {@}author tag in a Docblock.
2626
*/
27-
final class Author extends BaseTag
27+
final class Author extends BaseTag implements ExpectedFormat
2828
{
2929
/** @var string register that this is the author tag. */
3030
protected string $name = 'author';
3131

32+
public static function getExpectedFormat(): string
33+
{
34+
return 'name [<email@example.com>]';
35+
}
36+
37+
public static function getDocumentationUrl(): ?string
38+
{
39+
return 'https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/author.html';
40+
}
41+
3242
/** @var string The name of the author */
3343
private string $authorName;
3444

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of phpDocumentor.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*
11+
* @link http://phpdoc.org
12+
*/
13+
14+
namespace phpDocumentor\Reflection\DocBlock\Tags;
15+
16+
/**
17+
* Tag handlers may implement this contract to describe what they expect as input. When the handler rejects a body
18+
* and an {@see InvalidTag} is produced, the factory forwards these hints to the invalid tag so downstream tooling
19+
* (for example phpDocumentor's error reporting) can explain the expected syntax instead of only showing the raw
20+
* exception.
21+
*/
22+
interface ExpectedFormat
23+
{
24+
/**
25+
* Returns a short, human-readable description of the expected tag body, e.g. "name [<email>]".
26+
*/
27+
public static function getExpectedFormat(): string;
28+
29+
/**
30+
* Returns a URL pointing to the canonical documentation for the tag, or null when none is available.
31+
*/
32+
public static function getDocumentationUrl(): ?string;
33+
}

src/DocBlock/Tags/InvalidTag.php

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ final class InvalidTag implements Tag
4040

4141
private ?Throwable $throwable = null;
4242

43+
private ?string $expectedFormat = null;
44+
45+
private ?string $documentationUrl = null;
46+
4347
private function __construct(string $name, string $body)
4448
{
4549
$this->name = $name;
@@ -51,6 +55,23 @@ public function getException(): ?Throwable
5155
return $this->throwable;
5256
}
5357

58+
/**
59+
* Returns a short description of the format the corresponding tag handler expected, or null when the handler
60+
* did not advertise one via {@see ExpectedFormat}.
61+
*/
62+
public function getExpectedFormat(): ?string
63+
{
64+
return $this->expectedFormat;
65+
}
66+
67+
/**
68+
* Returns a URL pointing to the canonical documentation of the tag, or null when none is available.
69+
*/
70+
public function getDocumentationUrl(): ?string
71+
{
72+
return $this->documentationUrl;
73+
}
74+
5475
public function getName(): string
5576
{
5677
return $this->name;
@@ -64,12 +85,35 @@ public static function create(string $body, string $name = ''): self
6485
public function withError(Throwable $exception): self
6586
{
6687
$this->flattenExceptionBacktrace($exception);
67-
$tag = new self($this->name, $this->body);
88+
$tag = $this->copy();
6889
$tag->throwable = $exception;
6990

7091
return $tag;
7192
}
7293

94+
/**
95+
* Returns a copy of this invalid tag that also carries hints about the syntax expected by the originating tag
96+
* handler. Both arguments are optional so callers can advertise whichever information they have.
97+
*/
98+
public function withFormatHint(?string $expectedFormat, ?string $documentationUrl = null): self
99+
{
100+
$tag = $this->copy();
101+
$tag->expectedFormat = $expectedFormat;
102+
$tag->documentationUrl = $documentationUrl;
103+
104+
return $tag;
105+
}
106+
107+
private function copy(): self
108+
{
109+
$tag = new self($this->name, $this->body);
110+
$tag->throwable = $this->throwable;
111+
$tag->expectedFormat = $this->expectedFormat;
112+
$tag->documentationUrl = $this->documentationUrl;
113+
114+
return $tag;
115+
}
116+
73117
/**
74118
* Removes all complex types from backtrace
75119
*

tests/unit/DocBlock/StandardTagFactoryTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use phpDocumentor\Reflection\Assets\CustomServiceInterface;
2222
use phpDocumentor\Reflection\Assets\CustomTagFactory;
2323
use phpDocumentor\Reflection\DocBlock\Tags\Author;
24+
use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag;
2425
use phpDocumentor\Reflection\DocBlock\Tags\Deprecated;
2526
use phpDocumentor\Reflection\DocBlock\Tags\Extends_;
2627
use phpDocumentor\Reflection\DocBlock\Tags\Formatter;
@@ -135,6 +136,28 @@ public function testCreatingASpecificTag(): void
135136
$this->assertSame('author', $tag->getName());
136137
}
137138

139+
/**
140+
* @uses \phpDocumentor\Reflection\DocBlock\StandardTagFactory::addService
141+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\Author
142+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\BaseTag
143+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\InvalidTag
144+
*
145+
* @covers ::__construct
146+
* @covers ::create
147+
*/
148+
public function testInvalidTagReceivesFormatHintFromHandler(): void
149+
{
150+
$context = new Context('');
151+
$tagFactory = StandardTagFactory::createInstance(m::mock(FqsenResolver::class));
152+
153+
$tag = $tagFactory->create('@author Mike <not-an-email>', $context);
154+
155+
$this->assertInstanceOf(InvalidTag::class, $tag);
156+
$this->assertSame('author', $tag->getName());
157+
$this->assertSame(Author::getExpectedFormat(), $tag->getExpectedFormat());
158+
$this->assertSame(Author::getDocumentationUrl(), $tag->getDocumentationUrl());
159+
}
160+
138161
/**
139162
* @uses \phpDocumentor\Reflection\DocBlock\StandardTagFactory::addService
140163
* @uses \phpDocumentor\Reflection\DocBlock\Tags\See

tests/unit/DocBlock/Tags/InvalidTagTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,37 @@ public function testCreationWithoutError(): void
3131
self::assertSame('name', $tag->getName());
3232
self::assertSame('@name Body', $tag->render());
3333
self::assertNull($tag->getException());
34+
self::assertNull($tag->getExpectedFormat());
35+
self::assertNull($tag->getDocumentationUrl());
36+
}
37+
38+
/**
39+
* @covers ::withFormatHint
40+
* @covers ::getExpectedFormat
41+
* @covers ::getDocumentationUrl
42+
*/
43+
public function testCreationWithFormatHint(): void
44+
{
45+
$tag = InvalidTag::create('Body', 'name')
46+
->withFormatHint('expected format', 'https://example.com/doc');
47+
48+
self::assertSame('expected format', $tag->getExpectedFormat());
49+
self::assertSame('https://example.com/doc', $tag->getDocumentationUrl());
50+
}
51+
52+
/**
53+
* @covers ::withFormatHint
54+
* @covers ::withError
55+
*/
56+
public function testFormatHintSurvivesWithError(): void
57+
{
58+
$tag = InvalidTag::create('Body', 'name')
59+
->withFormatHint('expected format', 'https://example.com/doc')
60+
->withError(new Exception('boom'));
61+
62+
self::assertSame('expected format', $tag->getExpectedFormat());
63+
self::assertSame('https://example.com/doc', $tag->getDocumentationUrl());
64+
self::assertNotNull($tag->getException());
3465
}
3566

3667
/**

0 commit comments

Comments
 (0)