Skip to content

Commit 68d08b9

Browse files
committed
Add comment regex parser
1 parent b83edc8 commit 68d08b9

1 file changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\PhpDoc\Tests\Parser\Comment;
6+
7+
use PHPUnit\Framework\Attributes\DataProvider;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use TypeLang\PhpDoc\Parser\Comment\CommentParserInterface;
10+
use TypeLang\PhpDoc\Parser\Comment\RegexCommentParser;
11+
use TypeLang\PhpDoc\Parser\Comment\Segment;
12+
use TypeLang\PhpDoc\Tests\TestCase;
13+
14+
final class RegexCommentParserTest extends TestCase
15+
{
16+
/**
17+
* All implementations of {@see CommentParserInterface} to be verified.
18+
*
19+
* @return iterable<string, array{CommentParserInterface}>
20+
*/
21+
public static function provideParsers(): iterable
22+
{
23+
foreach (self::createParsers() as $name => $parser) {
24+
yield $name => [$parser];
25+
}
26+
}
27+
28+
/**
29+
* Cross product of every parser with every "unwrapped" (not a `/** *​/`
30+
* comment) input.
31+
*
32+
* @return iterable<string, array{CommentParserInterface, string}>
33+
*/
34+
public static function provideUnwrappedInputs(): iterable
35+
{
36+
$inputs = [
37+
'plain text' => 'Just some text',
38+
'empty string' => '',
39+
'whitespace only' => ' ',
40+
'opening not at start' => 'abc /** x */',
41+
];
42+
43+
foreach (self::createParsers() as $pName => $parser) {
44+
foreach ($inputs as $iName => $input) {
45+
yield $pName . ': ' . $iName => [$parser, $input];
46+
}
47+
}
48+
}
49+
50+
/**
51+
* Cross product of every parser with every wrapped multi-line comment that
52+
* uses `\n` line endings (so byte offsets are unambiguous).
53+
*
54+
* @return iterable<string, array{CommentParserInterface, string}>
55+
*/
56+
public static function provideWrappedInputs(): iterable
57+
{
58+
$inputs = [
59+
'multiline description' => self::comment(
60+
'/**',
61+
' * Example line 1',
62+
' *',
63+
' * @tag1 type Description of tag1',
64+
' */',
65+
),
66+
'single line' => '/** Foo bar */',
67+
'leading whitespace before opening' => " \n" . self::comment(
68+
'/**',
69+
' * Hi',
70+
' */',
71+
),
72+
'multiline tag' => self::comment(
73+
'/**',
74+
' * @param int $a first',
75+
' * second line',
76+
' * @return void',
77+
' */',
78+
),
79+
'body without star prefix' => self::comment(
80+
'/**',
81+
'Plain',
82+
'*/',
83+
),
84+
];
85+
86+
foreach (self::createParsers() as $pName => $parser) {
87+
foreach ($inputs as $iName => $input) {
88+
yield $pName . ': ' . $iName => [$parser, $input];
89+
}
90+
}
91+
}
92+
93+
#[Test]
94+
#[DataProvider('provideParsers')]
95+
public function parseReturnsIterable(CommentParserInterface $parser): void
96+
{
97+
$this->assertIsIterable($parser->parse('/** example */'));
98+
}
99+
100+
#[Test]
101+
#[DataProvider('provideWrappedInputs')]
102+
public function parseYieldsOnlySegmentInstances(CommentParserInterface $parser, string $input): void
103+
{
104+
$this->assertContainsOnlyInstancesOf(Segment::class, self::segments($parser->parse($input)));
105+
}
106+
107+
#[Test]
108+
#[DataProvider('provideUnwrappedInputs')]
109+
public function unwrappedInputBecomesSingleSegment(CommentParserInterface $parser, string $input): void
110+
{
111+
$segments = self::segments($parser->parse($input));
112+
113+
$this->assertCount(1, $segments);
114+
$this->assertSame($input, $segments[0]->text);
115+
$this->assertSame(0, $segments[0]->offset);
116+
}
117+
118+
#[Test]
119+
#[DataProvider('provideWrappedInputs')]
120+
public function segmentOffsetPointsToItsTextInSource(CommentParserInterface $parser, string $input): void
121+
{
122+
foreach (self::segments($parser->parse($input)) as $segment) {
123+
$this->assertSame(
124+
\substr($input, $segment->offset, \strlen($segment->text)),
125+
$segment->text,
126+
'A segment text must be a verbatim slice of the source at its offset',
127+
);
128+
}
129+
}
130+
131+
#[Test]
132+
#[DataProvider('provideWrappedInputs')]
133+
public function segmentsAreReturnedInSourceOrder(CommentParserInterface $parser, string $input): void
134+
{
135+
$previous = -1;
136+
137+
foreach (self::segments($parser->parse($input)) as $segment) {
138+
$this->assertGreaterThan($previous, $segment->offset);
139+
$previous = $segment->offset;
140+
}
141+
}
142+
143+
#[Test]
144+
#[DataProvider('provideParsers')]
145+
public function wrappedCommentYieldsSignificantLinesInOrder(CommentParserInterface $parser): void
146+
{
147+
$input = self::comment(
148+
'/**',
149+
' * Example line 1',
150+
' *',
151+
' * @tag1 type Description of tag1',
152+
' */',
153+
);
154+
155+
$this->assertSame(
156+
['Example line 1', '@tag1 type Description of tag1'],
157+
self::trimmedTexts($parser->parse($input)),
158+
);
159+
}
160+
161+
#[Test]
162+
#[DataProvider('provideParsers')]
163+
public function blankCommentLinesAreSkipped(CommentParserInterface $parser): void
164+
{
165+
$input = self::comment(
166+
'/**',
167+
' * first',
168+
' *',
169+
' * ',
170+
' * second',
171+
' */',
172+
);
173+
174+
$this->assertSame(['first', 'second'], self::trimmedTexts($parser->parse($input)));
175+
}
176+
177+
#[Test]
178+
#[DataProvider('provideParsers')]
179+
public function singleLineCommentYieldsSingleSegment(CommentParserInterface $parser): void
180+
{
181+
$this->assertSame(['Foo bar'], self::trimmedTexts($parser->parse('/** Foo bar */')));
182+
}
183+
184+
#[Test]
185+
#[DataProvider('provideParsers')]
186+
public function leadingWhitespaceBeforeOpeningIsTreatedAsComment(CommentParserInterface $parser): void
187+
{
188+
$input = " \n" . self::comment(
189+
'/**',
190+
' * Hi',
191+
' */',
192+
);
193+
194+
$this->assertSame(['Hi'], self::trimmedTexts($parser->parse($input)));
195+
}
196+
197+
#[Test]
198+
#[DataProvider('provideParsers')]
199+
public function tagContinuationLinesBecomeSeparateSegments(CommentParserInterface $parser): void
200+
{
201+
$input = self::comment(
202+
'/**',
203+
' * @param int $a first',
204+
' * second line',
205+
' * @return void',
206+
' */',
207+
);
208+
209+
$this->assertSame(
210+
['@param int $a first', 'second line', '@return void'],
211+
self::trimmedTexts($parser->parse($input)),
212+
);
213+
}
214+
215+
/**
216+
* Cross product of every parser with line-ending fixtures. Each fixture
217+
* declares the EXACT segments (verbatim text including the trailing line
218+
* terminator, plus its byte offset) the parser is expected to produce.
219+
*
220+
* @return iterable<string, array{CommentParserInterface, string, list<array{string, int<0, max>}>}>
221+
*/
222+
public static function provideLineEndingCases(): iterable
223+
{
224+
$cases = [
225+
'LF line endings' => [
226+
"/**\n * a\n * b\n */",
227+
[["a\n", 7], ["b\n", 12]],
228+
],
229+
'CRLF line endings' => [
230+
"/**\r\n * a\r\n * b\r\n */",
231+
[["a\r\n", 8], ["b\r\n", 14]],
232+
],
233+
'CRLF tag line' => [
234+
"/**\r\n * @param int \$x d\r\n */",
235+
[["@param int \$x d\r\n", 8]],
236+
],
237+
'mixed LF and CRLF' => [
238+
"/**\r\n * a\n * b\r\n */",
239+
[["a\n", 8], ["b\r\n", 13]],
240+
],
241+
];
242+
243+
foreach (self::createParsers() as $pName => $parser) {
244+
foreach ($cases as $cName => [$input, $expected]) {
245+
yield $pName . ': ' . $cName => [$parser, $input, $expected];
246+
}
247+
}
248+
}
249+
250+
/**
251+
* The text of every segment retains its own trailing line terminator
252+
* verbatim (`\n` or `\r\n`), and offsets are byte offsets, so a `\r\n`
253+
* line shifts subsequent offsets by the extra carriage return byte.
254+
*
255+
* @param list<array{string, int<0, max>}> $expected
256+
*/
257+
#[Test]
258+
#[DataProvider('provideLineEndingCases')]
259+
public function preservesLineEndingsVerbatim(CommentParserInterface $parser, string $input, array $expected): void
260+
{
261+
$segments = self::segments($parser->parse($input));
262+
263+
$this->assertSame(\array_column($expected, 0), \array_map(
264+
static fn(Segment $segment): string => $segment->text,
265+
$segments,
266+
));
267+
268+
$this->assertSame(\array_column($expected, 1), \array_map(
269+
static fn(Segment $segment): int => $segment->offset,
270+
$segments,
271+
));
272+
}
273+
274+
/**
275+
* @return iterable<string, CommentParserInterface>
276+
*/
277+
private static function createParsers(): iterable
278+
{
279+
yield 'RegexCommentParser' => new RegexCommentParser();
280+
}
281+
282+
/**
283+
* Builds a comment source from the given lines using `\n` separators.
284+
*/
285+
private static function comment(string ...$lines): string
286+
{
287+
return \implode("\n", $lines);
288+
}
289+
290+
/**
291+
* Normalizes the {@see CommentParserInterface::parse()} result (which may be
292+
* any iterable) into a positionally indexed list of segments.
293+
*
294+
* @return list<Segment>
295+
*/
296+
private static function segments(iterable $result): array
297+
{
298+
return \is_array($result)
299+
? \array_values($result)
300+
: \iterator_to_array($result, false);
301+
}
302+
303+
/**
304+
* @return list<string>
305+
*/
306+
private static function trimmedTexts(iterable $result): array
307+
{
308+
return \array_map(
309+
static fn(Segment $segment): string => \trim($segment->text),
310+
self::segments($result),
311+
);
312+
}
313+
}

0 commit comments

Comments
 (0)