xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmListblockTest.php (revision f7c6e4acc95e3c5c24a819b2149a2bf3a7668f11)
1<?php
2
3namespace dokuwiki\test\Parsing\ParserMode;
4
5use dokuwiki\Parsing\Handler\GfmLists;
6use dokuwiki\Parsing\ModeRegistry;
7use dokuwiki\Parsing\ParserMode\GfmListblock;
8
9/**
10 * Tests for GFM list blocks.
11 *
12 * GfmListblock captures the entire list block via addSpecialPattern then
13 * sub-parses each item's body through ModeRegistry::getSubParser(), so the
14 * outer parser only needs gfm_listblock added; inline modes (emphasis,
15 * strong, etc.) and block modes (gfm_code) are picked up by the sub-parser.
16 */
17class GfmListblockTest extends ParserTestBase
18{
19    public function setUp(): void
20    {
21        parent::setUp();
22        global $conf;
23        $conf['syntax'] = 'markdown';
24        ModeRegistry::reset();
25    }
26
27    public function tearDown(): void
28    {
29        ModeRegistry::reset();
30        parent::tearDown();
31    }
32
33    public function testUnorderedDash()
34    {
35        // Each item's body is sub-parsed and wrapped in a `nest` call so
36        // the main handler's Block rewriter doesn't double-wrap multi-block
37        // content. See AbstractListsRewriter / Block / Nest interaction.
38        $this->P->addMode('gfm_listblock', new GfmListblock());
39        $this->P->parse("- A\n- B\n- C\n");
40
41        $expected = [
42            ['document_start', []],
43            ['listu_open', []],
44            ['listitem_open', [1]],
45            ['listcontent_open', []],
46            ['nest', [[ ['cdata', ['A']] ]]],
47            ['listcontent_close', []],
48            ['listitem_close', []],
49            ['listitem_open', [1]],
50            ['listcontent_open', []],
51            ['nest', [[ ['cdata', ['B']] ]]],
52            ['listcontent_close', []],
53            ['listitem_close', []],
54            ['listitem_open', [1]],
55            ['listcontent_open', []],
56            ['nest', [[ ['cdata', ['C']] ]]],
57            ['listcontent_close', []],
58            ['listitem_close', []],
59            ['listu_close', []],
60            ['document_end', []],
61        ];
62        $this->assertCalls($expected, $this->H->calls);
63    }
64
65    public function testUnorderedAsterisk()
66    {
67        $this->P->addMode('gfm_listblock', new GfmListblock());
68        $this->P->parse("* A\n* B\n");
69
70        $names = array_column($this->H->calls, 0);
71        $this->assertContains('listu_open', $names);
72        $this->assertNotContains('listo_open', $names);
73    }
74
75    public function testUnorderedPlus()
76    {
77        $this->P->addMode('gfm_listblock', new GfmListblock());
78        $this->P->parse("+ A\n+ B\n");
79
80        $names = array_column($this->H->calls, 0);
81        $this->assertContains('listu_open', $names);
82        $this->assertNotContains('listo_open', $names);
83    }
84
85    public function testOrderedDot()
86    {
87        $this->P->addMode('gfm_listblock', new GfmListblock());
88        $this->P->parse("1. A\n2. B\n3. C\n");
89
90        $names = array_column($this->H->calls, 0);
91        $this->assertContains('listo_open', $names);
92        $this->assertNotContains('listu_open', $names);
93    }
94
95    public function testOrderedParen()
96    {
97        $this->P->addMode('gfm_listblock', new GfmListblock());
98        $this->P->parse("1) A\n2) B\n");
99
100        $names = array_column($this->H->calls, 0);
101        $this->assertContains('listo_open', $names);
102    }
103
104    public function testOrderedStartNumber()
105    {
106        $this->P->addMode('gfm_listblock', new GfmListblock());
107        $this->P->parse("5. A\n6. B\n");
108
109        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start');
110        $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open');
111        $open = array_values($opens)[0];
112        $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number');
113
114        $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
115        $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1');
116    }
117
118    public function testOrderedDefaultStartNotEmittedSpecially()
119    {
120        // For start=1 the rewriter emits the plain listo_open instruction so
121        // unmodified plugin renderers (which only override listo_open) keep
122        // working. The wire shape is bare [].
123        $this->P->addMode('gfm_listblock', new GfmListblock());
124        $this->P->parse("1. A\n2. B\n");
125
126        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
127        $open = array_values($opens)[0];
128        $this->assertSame([], $open[1]);
129
130        $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start');
131        $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start');
132    }
133
134    public function testNestedTwoLevels()
135    {
136        $this->P->addMode('gfm_listblock', new GfmListblock());
137        $this->P->parse("- A\n  - B\n- C\n");
138
139        $expected = [
140            ['document_start', []],
141            ['listu_open', []],
142            ['listitem_open', [1, GfmLists::NODE]],
143            ['listcontent_open', []],
144            ['nest', [[ ['cdata', ['A']] ]]],
145            ['listcontent_close', []],
146            ['listu_open', []],
147            ['listitem_open', [2]],
148            ['listcontent_open', []],
149            ['nest', [[ ['cdata', ['B']] ]]],
150            ['listcontent_close', []],
151            ['listitem_close', []],
152            ['listu_close', []],
153            ['listitem_close', []],
154            ['listitem_open', [1]],
155            ['listcontent_open', []],
156            ['nest', [[ ['cdata', ['C']] ]]],
157            ['listcontent_close', []],
158            ['listitem_close', []],
159            ['listu_close', []],
160            ['document_end', []],
161        ];
162        $this->assertCalls($expected, $this->H->calls);
163    }
164
165    /**
166     * Flatten a call list, recursing into `nest` calls' inner content.
167     * Useful for tests that just want to verify a particular instruction
168     * appears somewhere in the rendered output regardless of nesting.
169     */
170    private function flatNames(array $calls): array
171    {
172        $names = [];
173        foreach ($calls as $call) {
174            $names[] = $call[0];
175            if ($call[0] === 'nest') {
176                $names = array_merge($names, $this->flatNames($call[1][0]));
177            }
178        }
179        return $names;
180    }
181
182    public function testNestedThreeLevels()
183    {
184        $this->P->addMode('gfm_listblock', new GfmListblock());
185        $this->P->parse("- A\n  - B\n    - C\n");
186
187        $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
188        $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens));
189        $this->assertSame([1, 2, 3], $levels);
190    }
191
192    public function testInlineFormatting()
193    {
194        $this->P->addMode('gfm_listblock', new GfmListblock());
195        $this->P->parse("- **bold** text\n");
196
197        $names = $this->flatNames($this->H->calls);
198        $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item');
199        $this->assertContains('strong_close', $names);
200    }
201
202    public function testMarkerCharSwitchKeepsOneList()
203    {
204        // CommonMark: changing marker character (`-` → `+`) starts a new list.
205        // Our simpler model groups by type ('u' / 'o') only, so `-` and `+`
206        // share one <ul>. Deliberate simplification — the rewriter doesn't
207        // distinguish marker characters within the same type.
208        $this->P->addMode('gfm_listblock', new GfmListblock());
209        $this->P->parse("- A\n+ B\n");
210
211        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
212        $this->assertCount(1, $opens, 'marker-character change does not split unordered lists');
213    }
214
215    public function testOrderedToUnorderedSplits()
216    {
217        // Type change (o → u) DOES split, since the rewriter does close/open
218        // when the type differs.
219        $this->P->addMode('gfm_listblock', new GfmListblock());
220        $this->P->parse("1. A\n- B\n");
221
222        $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
223        $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
224        $this->assertCount(1, $oOpens);
225        $this->assertCount(1, $uOpens);
226    }
227
228    public function testNotAListMidParagraph()
229    {
230        $this->P->addMode('gfm_listblock', new GfmListblock());
231        $this->P->parse("Foo - bar");
232
233        $names = array_column($this->H->calls, 0);
234        $this->assertNotContains('listu_open', $names);
235        $this->assertNotContains('listo_open', $names);
236    }
237
238    public function testEmptyMarkerEol()
239    {
240        $this->P->addMode('gfm_listblock', new GfmListblock());
241        $this->P->parse("-\n");
242
243        $names = array_column($this->H->calls, 0);
244        $this->assertContains('listu_open', $names, 'a bare marker still opens a list');
245        $this->assertContains('listitem_open', $names);
246    }
247
248    public function testHeaderRejectedInsideItem()
249    {
250        // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item
251        // body must NOT produce a header instruction.
252        $this->P->addMode('gfm_listblock', new GfmListblock());
253        $this->P->parse("- foo\n  # bar\n");
254
255        $names = $this->flatNames($this->H->calls);
256        $this->assertNotContains('header', $names);
257        $this->assertNotContains('section_open', $names);
258    }
259
260    public function testFencedCodeInsideItem()
261    {
262        // After the dedent step strips the 2-space prefix from the body,
263        // the fence sits at column 0 from the sub-parser's point of view
264        // and gfm_code matches it.
265        $this->P->addMode('gfm_listblock', new GfmListblock());
266        $this->P->parse("- foo\n  ```\n  hello\n  ```\n");
267
268        $names = $this->flatNames($this->H->calls);
269        $this->assertContains('code', $names, 'fenced code inside item must be parsed');
270    }
271
272    public function testMultiParagraphItemIsLoose()
273    {
274        $this->P->addMode('gfm_listblock', new GfmListblock());
275        $this->P->parse("- foo\n\n  bar\n");
276
277        // Loose item: the nest contains two p_open / p_close pairs (one per
278        // paragraph) since the outer-only stripping in filterSubCalls only
279        // collapses single-paragraph items.
280        $names = $this->flatNames($this->H->calls);
281        $pOpens = array_filter($names, static fn($n) => $n === 'p_open');
282        $this->assertGreaterThanOrEqual(2, count($pOpens),
283            'multi-paragraph items must keep both p_open calls');
284    }
285
286    public function testSortValue()
287    {
288        $mode = new GfmListblock();
289        $this->assertSame(10, $mode->getSort());
290    }
291
292    /**
293     * Regression: an item's sub-parsed content must reach the main handler
294     * inside a `nest` call. Without the wrap, the main handler's Block
295     * rewriter wraps the item content in another `<p>` (it already has
296     * its own `<p>` from the sub-parser), producing nested paragraph tags.
297     */
298    public function testItemContentIsWrappedInNest()
299    {
300        $this->P->addMode('gfm_listblock', new GfmListblock());
301        $this->P->parse("- foo\n");
302
303        $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest');
304        $this->assertCount(1, $nests, 'each item body should land in one nest call');
305    }
306
307    /**
308     * Regression: multiple consecutive blank lines inside a list block must
309     * NOT terminate the list. Spec example 242 (`- Foo\n\n      bar\n\n\n
310     * baz`) ends with a triple blank between two indented continuations and
311     * expects all three to remain inside one list item.
312     */
313    public function testTripleBlankBetweenContinuationsKeepsListOpen()
314    {
315        $this->P->addMode('gfm_listblock', new GfmListblock());
316        $this->P->parse("- Foo\n\n      bar\n\n\n      baz\n");
317
318        // The list should bracket all three indented lines: `- Foo`, `bar`,
319        // and `baz` all live inside a single `<ul>`. We assert there is
320        // exactly one listu_open / listu_close pair (no early termination
321        // splitting `baz` into a separate top-level block).
322        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
323        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
324        $this->assertCount(1, $opens,
325            'triple blank line between continuations must not split the list');
326        $this->assertCount(1, $closes);
327    }
328
329    /**
330     * Regression: blank lines between items (any number) must not split the
331     * list. Spec example 270 stresses two-blank cases.
332     */
333    public function testMultipleBlanksBetweenItemsKeepsOneList()
334    {
335        $this->P->addMode('gfm_listblock', new GfmListblock());
336        $this->P->parse("- one\n\n\n- two\n");
337
338        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
339        $this->assertCount(1, $opens, 'blank lines between items must stay inside the list');
340    }
341}
342