packages/requirements/tests/Unit/Config/Markdown/NodesTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
8use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
9use League\CommonMark\Node\Block\Paragraph;
10use League\CommonMark\Node\Inline\Newline;
11use League\CommonMark\Node\Inline\Text;
12use PHPUnit\Framework\Attributes\CoversClass;
13use PHPUnit\Framework\Attributes\DataProvider;
14use PHPUnit\Framework\Attributes\Small;
15use PHPUnit\Framework\TestCase;
16use Requirements\Config\Markdown\Nodes;
17use Requirements\Input\InvalidInputException;
18use Tests\Fake\MarkdownNodes;
19
20#[CoversClass(Nodes::class)]
21#[Small]
22final class NodesTest extends TestCase
23{
24    public function testTextReturnsTheLiteralOfATextNode(): void
25    {
26        self::assertSame('plain', Nodes::text(new Text('plain')));
27    }
28
29    public function testTextReturnsTheCodeWithoutBackticks(): void
30    {
31        self::assertSame('shall', Nodes::text(new Code('shall')));
32    }
33
34    public function testTextKeepsTheBackticksOfCodeAsALiteral(): void
35    {
36        self::assertSame('`shall`', Nodes::text(new Code('shall'), true));
37    }
38
39    public function testTextReturnsALineBreakForANewline(): void
40    {
41        self::assertSame("\n", Nodes::text(new Newline(Newline::SOFTBREAK)));
42    }
43
44    /**
45     * @throws CommonMarkException
46     */
47    #[DataProvider('providerInlineText')]
48    public function testTextJoinsTheInlineChildren(string $markdown, bool $literals, string $expected): void
49    {
50        self::assertSame($expected, Nodes::text(MarkdownNodes::first($markdown), $literals));
51    }
52
53    /**
54     * @return array<string, array{string, bool, string}>
55     */
56    public static function providerInlineText(): array
57    {
58        return [
59            'emphasis and code' => ["a **b** _c_ `d`\ne", false, "a b c d\ne"],
60            'code literal' => ["a **b** _c_ `d`\ne", true, "a b c `d`\ne"],
61            'nested code literal' => ['**`shall`**', true, '`shall`'],
62            'heading' => ['# SPEC-001', false, 'SPEC-001'],
63            'escaped characters' => ['\\*not emphasis\\*', false, '*not emphasis*'],
64        ];
65    }
66
67    /**
68     * @throws CommonMarkException
69     */
70    #[DataProvider('providerUnsupportedInline')]
71    public function testTextRejectsUnsupportedInlineMarkdown(string $markdown): void
72    {
73        $this->expectException(InvalidInputException::class);
74        $this->expectExceptionMessage('Unsupported inline Markdown; use text, emphasis or code spans. Put links in reference or design fields.');
75        Nodes::text(MarkdownNodes::first($markdown));
76    }
77
78    /**
79     * @return array<string, array{string}>
80     */
81    public static function providerUnsupportedInline(): array
82    {
83        return [
84            'link' => ['The reader shall emit [a tree](design.md).'],
85            'image' => ['![grammar](grammar.svg)'],
86            'inline HTML' => ['a <b>tree</b>'],
87            'link inside emphasis' => ['*[a](b)*'],
88        ];
89    }
90
91    /**
92     * @throws CommonMarkException
93     */
94    public function testItemsReturnsTheItemsOfABulletList(): void
95    {
96        $items = Nodes::items(MarkdownNodes::first("- a\n- b\n- c\n"));
97        self::assertCount(3, $items);
98        self::assertSame('b', Nodes::text(Nodes::paragraph($items[1])));
99    }
100
101    /**
102     * @throws CommonMarkException
103     */
104    #[DataProvider('providerNotBulletLists')]
105    public function testItemsRejectsAnythingButABulletList(string $markdown): void
106    {
107        $this->expectException(InvalidInputException::class);
108        $this->expectExceptionMessage('Expected a bullet list.');
109        Nodes::items(MarkdownNodes::first($markdown));
110    }
111
112    /**
113     * @return array<string, array{string}>
114     */
115    public static function providerNotBulletLists(): array
116    {
117        return [
118            'ordered list' => ['1. grammar'],
119            'paragraph' => ['grammar'],
120            'quotation' => ['> - grammar'],
121        ];
122    }
123
124    /**
125     * @throws CommonMarkException
126     */
127    public function testParagraphReturnsTheSingleParagraphOfAListItem(): void
128    {
129        $items = Nodes::items(MarkdownNodes::first("- a *b*\n"));
130        self::assertSame('a b', Nodes::text(Nodes::paragraph($items[0])));
131    }
132
133    /**
134     * @throws CommonMarkException
135     */
136    #[DataProvider('providerNotSingleParagraphs')]
137    public function testParagraphRejectsAnItemWithoutASingleParagraph(string $markdown): void
138    {
139        $items = Nodes::items(MarkdownNodes::first($markdown));
140        $this->expectException(InvalidInputException::class);
141        $this->expectExceptionMessage('Expected a single paragraph in this list item.');
142        Nodes::paragraph($items[0]);
143    }
144
145    /**
146     * @return array<string, array{string}>
147     */
148    public static function providerNotSingleParagraphs(): array
149    {
150        return [
151            'two paragraphs' => ["- a\n\n  b\n"],
152            'paragraph and list' => ["- a\n  - b\n"],
153            'quotation' => ["- > a\n"],
154            'empty item' => ["-\n- b\n"],
155        ];
156    }
157
158    /**
159     * @throws CommonMarkException
160     */
161    public function testLinkReturnsTheSingleLinkOfAParagraph(): void
162    {
163        $link = Nodes::link(MarkdownNodes::first('[REQ-001](reference.yaml#req-001)'));
164        self::assertSame('reference.yaml#req-001', $link->getUrl());
165        self::assertSame('REQ-001', Nodes::text($link));
166    }
167
168    /**
169     * @throws CommonMarkException
170     */
171    #[DataProvider('providerNotSingleLinks')]
172    public function testLinkRejectsAnythingButOneLinkWithADestination(string $markdown): void
173    {
174        $this->expectException(InvalidInputException::class);
175        $this->expectExceptionMessage('Expected one Markdown link with a nonempty destination.');
176        Nodes::link(MarkdownNodes::first($markdown));
177    }
178
179    /**
180     * @return array<string, array{string}>
181     */
182    public static function providerNotSingleLinks(): array
183    {
184        return [
185            'text' => ['REQ-001'],
186            'link and text' => ['[REQ-001](a.md) and more'],
187            'text and link' => ['see [REQ-001](a.md)'],
188            'empty destination' => ['[REQ-001]()'],
189            'image' => ['![REQ-001](a.svg)'],
190        ];
191    }
192
193    /**
194     * @throws CommonMarkException
195     */
196    #[DataProvider('providerFieldHeadings')]
197    public function testFieldReturnsTheLowercaseNameOfABoldHeading(string $markdown, ?string $expected): void
198    {
199        self::assertSame($expected, Nodes::field(MarkdownNodes::first($markdown)));
200    }
201
202    /**
203     * @return array<string, array{string, ?string}>
204     */
205    public static function providerFieldHeadings(): array
206    {
207        return [
208            'field' => ['**tests**', 'tests'],
209            'uppercase' => ['**Unsupported Reason**', 'unsupported reason'],
210            'bold followed by text' => ['**runner:** target', null],
211            'text followed by bold' => ['a **tests**', null],
212            'emphasis' => ['*tests*', null],
213            'plain paragraph' => ['tests', null],
214            'bold heading' => ['# **tests**', null],
215            'bullet list' => ['- **tests**', null],
216        ];
217    }
218
219    /**
220     * @throws CommonMarkException
221     */
222    public function testFieldRejectsUnsupportedInlineMarkdownInTheName(): void
223    {
224        $this->expectException(InvalidInputException::class);
225        $this->expectExceptionMessage('Unsupported inline Markdown');
226        Nodes::field(MarkdownNodes::first('**[tests](a.md)**'));
227    }
228
229    /**
230     * @throws CommonMarkException
231     */
232    #[DataProvider('providerPairs')]
233    public function testPairSplitsTheBoldNameAndItsValue(string $markdown, string $name, string $value): void
234    {
235        $paragraph = MarkdownNodes::first($markdown);
236        self::assertInstanceOf(Paragraph::class, $paragraph);
237        self::assertSame([$name, $value], Nodes::pair($paragraph));
238    }
239
240    /**
241     * @return array<string, array{string, string, string}>
242     */
243    public static function providerPairs(): array
244    {
245        return [
246            'colon inside the bold name' => ['**runner:** target', 'runner', 'target'],
247            'colon after the bold name' => ['**runner**: target', 'runner', 'target'],
248            'spaced colon after the bold name' => ['**runner** :  target  ', 'runner', 'target'],
249            'empty value' => ['**values:**', 'values', ''],
250            'value with emphasis and code' => ['**owner:** *Parser* `team`', 'owner', 'Parser team'],
251            'empty name' => ['**:** 1', '', '1'],
252        ];
253    }
254
255    /**
256     * @throws CommonMarkException
257     */
258    #[DataProvider('providerNotPairs')]
259    public function testPairRejectsAParagraphWithoutABoldNameAndColon(string $markdown, string $message): void
260    {
261        $paragraph = MarkdownNodes::first($markdown);
262        self::assertInstanceOf(Paragraph::class, $paragraph);
263        $this->expectException(InvalidInputException::class);
264        $this->expectExceptionMessage($message);
265        Nodes::pair($paragraph);
266    }
267
268    /**
269     * @return array<string, array{string, string}>
270     */
271    public static function providerNotPairs(): array
272    {
273        return [
274            'no bold name' => ['runner: target', 'Expected a bold field name followed by a colon.'],
275            'no colon' => ['**runner** target', 'Expected a colon after the bold field name.'],
276            'colon later' => ['**runner** target: x', 'Expected a colon after the bold field name.'],
277        ];
278    }
279
280    #[DataProvider('providerEscapes')]
281    public function testEscapeProtectsMarkdownSyntax(string $text, string $expected): void
282    {
283        self::assertSame($expected, Nodes::escape($text));
284    }
285
286    /**
287     * @return array<string, array{string, string}>
288     */
289    public static function providerEscapes(): array
290    {
291        return [
292            'plain' => ['The reader shall emit a tree.', 'The reader shall emit a tree.'],
293            'inline syntax' => ['\\ ` * _ [ ] < > & !', '\\\\ \\` \\* \\_ \\[ \\] \\< \\> \\& \\!'],
294            'heading' => ['# title', '\\# title'],
295            'setext underline' => ["a\n===", "a\n\\==="],
296            'plus bullet' => ['+ entry', '\\+ entry'],
297            'dash bullet' => ['- Entry', '\\- Entry'],
298            'indented bullet' => ["a\n  - b", "a\n  \\- b"],
299            'ordered entry' => ['1. Entry', '1\\. Entry'],
300            'parenthesized ordered entry' => ['12) Entry', '12\\) Entry'],
301            'number inside text' => ['Version 1. Entry', 'Version 1. Entry'],
302            'dash inside text' => ['a - b', 'a - b'],
303        ];
304    }
305
306    #[DataProvider('providerDestinations')]
307    public function testDestinationBracketsDestinationsThatNeedIt(string $url, string $expected): void
308    {
309        self::assertSame($expected, Nodes::destination($url));
310    }
311
312    /**
313     * @return array<string, array{string, string}>
314     */
315    public static function providerDestinations(): array
316    {
317        return [
318            'plain' => ['https://example.org/a.svg', 'https://example.org/a.svg'],
319            'space' => ['my source.html', '<my%20source.html>'],
320            'parentheses' => ['a(b).html', '<a(b).html>'],
321            'angle brackets' => ['<a>', '<%3Ca%3E>'],
322            'line feed' => ["a\nb", '<a%0Ab>'],
323            'carriage return' => ["a\rb", '<a%0Db>'],
324            'tab' => ["a\tb", "<a\tb>"],
325        ];
326    }
327
328    #[DataProvider('providerAnchors')]
329    public function testAnchorLowercasesAndDropsDots(string $id, string $expected): void
330    {
331        self::assertSame($expected, Nodes::anchor($id));
332    }
333
334    /**
335     * @return array<string, array{string, string}>
336     */
337    public static function providerAnchors(): array
338    {
339        return [
340            'item ID' => ['REQ-001', 'req-001'],
341            'dotted ID' => ['A.B.C-1', 'abc-1'],
342        ];
343    }
344}
345