44
55namespace 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 ;
712use 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 ;
821use TypeLang \PhpDoc \Parser \Tag \RegexTagParser ;
922use TypeLang \PhpDoc \Parser \Tag \TagParserInterface ;
1023use 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