Skip to content

Commit 17d4716

Browse files
committed
Add tag parser tests
1 parent 85a7a73 commit 17d4716

1 file changed

Lines changed: 264 additions & 0 deletions

File tree

libs/phpdoc/tests/Parser/TagParserTest.php

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,20 @@
44

55
namespace TypeLang\PhpDoc\Tests\Parser;
66

7+
use PHPUnit\Framework\Attributes\DataProvider;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use TypeLang\PhpDoc\DocBlock\Description\DescriptionInterface;
10+
use TypeLang\PhpDoc\DocBlock\Description\TaggedDescription;
11+
use TypeLang\PhpDoc\DocBlock\Tag\InvalidTag;
712
use TypeLang\PhpDoc\DocBlock\Tag\TagFactory;
13+
use TypeLang\PhpDoc\DocBlock\Tag\TagInterface;
14+
use TypeLang\PhpDoc\Exception\EmptyTagLineException;
15+
use TypeLang\PhpDoc\Exception\EmptyTagNameException;
16+
use TypeLang\PhpDoc\Exception\InvalidTagNameException;
17+
use TypeLang\PhpDoc\Exception\InvalidTagPrefixException;
18+
use TypeLang\PhpDoc\Exception\ParsingExceptionInterface;
19+
use TypeLang\PhpDoc\Parser\Description\BalancedBraceAwareParser;
20+
use TypeLang\PhpDoc\Parser\Description\DescriptionParserInterface;
821
use TypeLang\PhpDoc\Parser\Tag\RegexTagParser;
922
use TypeLang\PhpDoc\Parser\Tag\TagParserInterface;
1023
use TypeLang\PhpDoc\Tests\TestCase;
@@ -20,4 +33,255 @@ public static function parserDataProvider(): iterable
2033
new RegexTagParser(new TagFactory()),
2134
];
2235
}
36+
37+
/**
38+
* Well-formed definitions, each mapped to the tag name and the string value
39+
* of the description the parser is expected to produce (or `null` when the
40+
* definition carries no description).
41+
*
42+
* @return iterable<string, array{TagParserInterface, string, string, string|null}>
43+
*/
44+
public static function validTagDataProvider(): iterable
45+
{
46+
$cases = [
47+
'name only, no description' => ['@see', 'see', null],
48+
'name followed by a description' => ['@param int $x foo', 'param', 'int $x foo'],
49+
// Only the leading whitespace of the suffix is stripped.
50+
'leading whitespace of the suffix is trimmed' => ['@see Foo::bar()', 'see', 'Foo::bar()'],
51+
'hyphenated vendor name' => ['@psalm-param string $s', 'psalm-param', 'string $s'],
52+
'colon inside the name' => ['@foo:bar baz', 'foo:bar', 'baz'],
53+
'fully qualified name with backslashes' => ['@\\Vendor\\Attribute value', '\\Vendor\\Attribute', 'value'],
54+
// Word characters, hyphens, underscores, backslashes and colons all
55+
// form a name, and any of them may be the first character.
56+
'digit as the first name character' => ['@123 desc', '123', 'desc'],
57+
'hyphen as the first name character' => ['@-x rest', '-x', 'rest'],
58+
'digits after the first name character' => ['@v1 desc', 'v1', 'desc'],
59+
// The name ends at the first character that is not part of a name.
60+
'name stops at the first non-name character' => ['@see, other', 'see', ', other'],
61+
];
62+
63+
return self::bind($cases);
64+
}
65+
66+
/**
67+
* Malformed definitions, each mapped to the exact failure reason class, the
68+
* (always empty) tag name and the string value of the description that is
69+
* salvaged from the input (or `null` when nothing is left to salvage).
70+
*
71+
* @return iterable<string, array{TagParserInterface, string, class-string<\Throwable>, string, string|null}>
72+
*/
73+
public static function invalidTagDataProvider(): iterable
74+
{
75+
$cases = [
76+
'empty definition' => ['', EmptyTagLineException::class, '', null],
77+
'missing "@" prefix' => ['foo bar', InvalidTagPrefixException::class, '', 'foo bar'],
78+
'"@" followed by whitespace' => ['@ foo', EmptyTagNameException::class, '', ' foo'],
79+
'bare "@"' => ['@', EmptyTagNameException::class, '', null],
80+
// Punctuation such as "!" is not a name character.
81+
'"@" followed by punctuation' => ['@!bad', EmptyTagNameException::class, '', '!bad'],
82+
];
83+
84+
return self::bind($cases);
85+
}
86+
87+
/**
88+
* The malformed definitions of {@see invalidTagDataProvider()}, reduced to
89+
* just the input string.
90+
*
91+
* @return iterable<string, array{TagParserInterface, string}>
92+
*/
93+
public static function malformedDefinitionDataProvider(): iterable
94+
{
95+
$definitions = [
96+
'empty definition' => '',
97+
'missing "@" prefix' => 'foo bar',
98+
'"@" followed by whitespace' => '@ foo',
99+
'bare "@"' => '@',
100+
'"@" followed by punctuation' => '@!bad',
101+
];
102+
103+
return self::bind(\array_map(static fn(string $input): array => [$input], $definitions));
104+
}
105+
106+
/**
107+
* Definitions that survive a full round trip: stringifying the produced tag
108+
* reproduces the original definition byte for byte.
109+
*
110+
* @return iterable<string, array{TagParserInterface, string}>
111+
*/
112+
public static function roundTripDataProvider(): iterable
113+
{
114+
$inputs = [
115+
'name only' => '@see',
116+
'name and description' => '@param int $x foo',
117+
'hyphenated name' => '@psalm-param string $s',
118+
'colon in name' => '@foo:bar baz',
119+
'qualified name' => '@\\Vendor\\Attribute value',
120+
'digit-led name' => '@123 desc',
121+
'digits in name' => '@v1 desc',
122+
'unicode name' => '@ключ значение',
123+
'inline tag in description' => '@see {@link X}',
124+
];
125+
126+
return self::bind(\array_map(static fn(string $input): array => [$input], $inputs));
127+
}
128+
129+
#[Test]
130+
#[DataProvider('validTagDataProvider')]
131+
public function validDefinitionBecomesTagWithParsedNameAndDescription(
132+
TagParserInterface $parser,
133+
string $definition,
134+
string $expectedName,
135+
?string $expectedDescription,
136+
): void {
137+
$tag = $parser->parse($definition, self::descriptions());
138+
139+
self::assertNotInstanceOf(InvalidTag::class, $tag);
140+
self::assertSame($expectedName, $tag->name);
141+
self::assertTagDescription($expectedDescription, $tag);
142+
}
143+
144+
#[Test]
145+
#[DataProvider('invalidTagDataProvider')]
146+
public function malformedDefinitionBecomesInvalidTagWithReason(
147+
TagParserInterface $parser,
148+
string $definition,
149+
string $expectedReason,
150+
string $expectedName,
151+
?string $expectedDescription,
152+
): void {
153+
$tag = $parser->parse($definition, self::descriptions());
154+
155+
self::assertInstanceOf(InvalidTag::class, $tag);
156+
self::assertInstanceOf($expectedReason, $tag->reason);
157+
self::assertSame($expectedName, $tag->name);
158+
self::assertTagDescription($expectedDescription, $tag);
159+
}
160+
161+
/**
162+
* Every failure reason belongs to the {@see InvalidTagNameException} branch
163+
* of the {@see ParsingExceptionInterface} hierarchy.
164+
*/
165+
#[Test]
166+
#[DataProvider('malformedDefinitionDataProvider')]
167+
public function invalidTagReasonIsAnInvalidTagNameException(
168+
TagParserInterface $parser,
169+
string $definition,
170+
): void {
171+
$tag = $parser->parse($definition, self::descriptions());
172+
173+
self::assertInstanceOf(InvalidTag::class, $tag);
174+
self::assertInstanceOf(InvalidTagNameException::class, $tag->reason);
175+
self::assertInstanceOf(ParsingExceptionInterface::class, $tag->reason);
176+
}
177+
178+
/**
179+
* The parser reports malformed input by returning an {@see InvalidTag}
180+
* rather than throwing, even though the interface permits a
181+
* {@see ParsingExceptionInterface}.
182+
*/
183+
#[Test]
184+
#[DataProvider('parserDataProvider')]
185+
public function parseReportsFailuresWithoutThrowing(TagParserInterface $parser): void
186+
{
187+
foreach (['', '@', '@ foo', '@!bad', 'no-at-sign'] as $definition) {
188+
self::assertInstanceOf(TagInterface::class, $parser->parse($definition, self::descriptions()));
189+
}
190+
}
191+
192+
#[Test]
193+
#[DataProvider('roundTripDataProvider')]
194+
public function stringCastReproducesDefinition(TagParserInterface $parser, string $definition): void
195+
{
196+
self::assertSame($definition, (string) $parser->parse($definition, self::descriptions()));
197+
}
198+
199+
/**
200+
* The suffix is handed to the injected description parser, so an inline
201+
* `{@...}` tag inside it is parsed into a nested {@see TaggedDescription}.
202+
*/
203+
#[Test]
204+
#[DataProvider('parserDataProvider')]
205+
public function suffixIsParsedByTheInjectedDescriptionParser(TagParserInterface $parser): void
206+
{
207+
$tag = $parser->parse('@see {@link X}', self::descriptions());
208+
209+
self::assertSame('see', $tag->name);
210+
self::assertInstanceOf(TaggedDescription::class, $tag->description);
211+
self::assertCount(1, $tag->description->tags);
212+
self::assertSame('link', $tag->description->tags[0]->name);
213+
self::assertSame('{@link X}', (string) $tag->description);
214+
}
215+
216+
/**
217+
* A definition without the leading "@" is not a tag at all: its whole text
218+
* is preserved as the {@see InvalidTag} description.
219+
*/
220+
#[Test]
221+
#[DataProvider('parserDataProvider')]
222+
public function definitionWithoutPrefixKeepsWholeTextAsDescription(TagParserInterface $parser): void
223+
{
224+
$tag = $parser->parse('just some text', self::descriptions());
225+
226+
self::assertInstanceOf(InvalidTag::class, $tag);
227+
self::assertInstanceOf(InvalidTagPrefixException::class, $tag->reason);
228+
self::assertTagDescription('just some text', $tag);
229+
}
230+
231+
/**
232+
* A name is matched by `\w` under the `/u` (UTF-8) modifier, so it accepts
233+
* Unicode letters beyond ASCII, e.g. a Cyrillic tag name.
234+
*/
235+
#[Test]
236+
#[DataProvider('parserDataProvider')]
237+
public function unicodeTagNameIsSupported(TagParserInterface $parser): void
238+
{
239+
$tag = $parser->parse('@ключ значение', self::descriptions());
240+
241+
self::assertNotInstanceOf(InvalidTag::class, $tag);
242+
self::assertSame('ключ', $tag->name);
243+
self::assertTagDescription('значение', $tag);
244+
}
245+
246+
/**
247+
* A shared description parser used as the delegate for suffix parsing,
248+
* wired exactly as in production.
249+
*/
250+
private static function descriptions(): DescriptionParserInterface
251+
{
252+
return new BalancedBraceAwareParser(new RegexTagParser(new TagFactory()));
253+
}
254+
255+
/**
256+
* Binds every case to each parser from {@see parserDataProvider()},
257+
* prefixing the case name with the parser name.
258+
*
259+
* @param iterable<string, list<mixed>> $cases
260+
* @return iterable<string, list<mixed>>
261+
*/
262+
private static function bind(iterable $cases): iterable
263+
{
264+
foreach (self::parserDataProvider() as $parserName => [$parser]) {
265+
foreach ($cases as $caseName => $case) {
266+
yield $parserName . ': ' . $caseName => [$parser, ...$case];
267+
}
268+
}
269+
}
270+
271+
/**
272+
* Asserts that the tag carries either no description
273+
* ($expected === null) or a {@see DescriptionInterface} whose string value
274+
* equals $expected.
275+
*/
276+
private static function assertTagDescription(?string $expected, TagInterface $tag): void
277+
{
278+
if ($expected === null) {
279+
self::assertNull($tag->description);
280+
281+
return;
282+
}
283+
284+
self::assertInstanceOf(DescriptionInterface::class, $tag->description);
285+
self::assertSame($expected, (string) $tag->description);
286+
}
23287
}

0 commit comments

Comments
 (0)