xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmListblockTest.php (revision d379b73752b2756b087fd1c1c0e5886f260e6761)
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 a sub-parser acquired from
14 * ModeRegistry's pool, so the outer parser only needs gfm_listblock added;
15 * inline modes (emphasis, strong, etc.) and block modes (gfm_code) are
16 * picked up by the sub-parser.
17 */
18class GfmListblockTest extends ParserTestBase
19{
20    public function setUp(): void
21    {
22        parent::setUp();
23        global $conf;
24        $conf['syntax'] = 'md';
25        ModeRegistry::reset();
26    }
27
28    public function tearDown(): void
29    {
30        ModeRegistry::reset();
31        parent::tearDown();
32    }
33
34    public function testUnorderedDash()
35    {
36        // Each item's body is sub-parsed and wrapped in a `nest` call so
37        // the main handler's Block rewriter doesn't double-wrap multi-block
38        // content. See AbstractListsRewriter / Block / Nest interaction.
39        $this->P->addMode('gfm_listblock', new GfmListblock());
40        $this->P->parse("- A\n- B\n- C\n");
41
42        $expected = [
43            ['document_start', []],
44            ['listu_open', []],
45            ['listitem_open', [1]],
46            ['listcontent_open', []],
47            ['nest', [[ ['cdata', ['A']] ]]],
48            ['listcontent_close', []],
49            ['listitem_close', []],
50            ['listitem_open', [1]],
51            ['listcontent_open', []],
52            ['nest', [[ ['cdata', ['B']] ]]],
53            ['listcontent_close', []],
54            ['listitem_close', []],
55            ['listitem_open', [1]],
56            ['listcontent_open', []],
57            ['nest', [[ ['cdata', ['C']] ]]],
58            ['listcontent_close', []],
59            ['listitem_close', []],
60            ['listu_close', []],
61            ['document_end', []],
62        ];
63        $this->assertCalls($expected, $this->H->calls);
64    }
65
66    public function testUnorderedAsterisk()
67    {
68        $this->P->addMode('gfm_listblock', new GfmListblock());
69        $this->P->parse("* A\n* B\n");
70
71        $names = array_column($this->H->calls, 0);
72        $this->assertContains('listu_open', $names);
73        $this->assertNotContains('listo_open', $names);
74    }
75
76    public function testUnorderedPlus()
77    {
78        $this->P->addMode('gfm_listblock', new GfmListblock());
79        $this->P->parse("+ A\n+ B\n");
80
81        $names = array_column($this->H->calls, 0);
82        $this->assertContains('listu_open', $names);
83        $this->assertNotContains('listo_open', $names);
84    }
85
86    public function testOrderedDot()
87    {
88        $this->P->addMode('gfm_listblock', new GfmListblock());
89        $this->P->parse("1. A\n2. B\n3. C\n");
90
91        $names = array_column($this->H->calls, 0);
92        $this->assertContains('listo_open', $names);
93        $this->assertNotContains('listu_open', $names);
94    }
95
96    public function testOrderedParen()
97    {
98        $this->P->addMode('gfm_listblock', new GfmListblock());
99        $this->P->parse("1) A\n2) B\n");
100
101        $names = array_column($this->H->calls, 0);
102        $this->assertContains('listo_open', $names);
103    }
104
105    public function testOrderedStartNumber()
106    {
107        $this->P->addMode('gfm_listblock', new GfmListblock());
108        $this->P->parse("5. A\n6. B\n");
109
110        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start');
111        $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open');
112        $open = array_values($opens)[0];
113        $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number');
114
115        $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
116        $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1');
117    }
118
119    public function testOrderedDefaultStartNotEmittedSpecially()
120    {
121        // For start=1 the rewriter emits the plain listo_open instruction so
122        // unmodified plugin renderers (which only override listo_open) keep
123        // working. The wire shape is bare [].
124        $this->P->addMode('gfm_listblock', new GfmListblock());
125        $this->P->parse("1. A\n2. B\n");
126
127        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
128        $open = array_values($opens)[0];
129        $this->assertSame([], $open[1]);
130
131        $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start');
132        $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start');
133    }
134
135    public function testNestedTwoLevels()
136    {
137        $this->P->addMode('gfm_listblock', new GfmListblock());
138        $this->P->parse("- A\n  - B\n- C\n");
139
140        $expected = [
141            ['document_start', []],
142            ['listu_open', []],
143            ['listitem_open', [1, GfmLists::NODE]],
144            ['listcontent_open', []],
145            ['nest', [[ ['cdata', ['A']] ]]],
146            ['listcontent_close', []],
147            ['listu_open', []],
148            ['listitem_open', [2]],
149            ['listcontent_open', []],
150            ['nest', [[ ['cdata', ['B']] ]]],
151            ['listcontent_close', []],
152            ['listitem_close', []],
153            ['listu_close', []],
154            ['listitem_close', []],
155            ['listitem_open', [1]],
156            ['listcontent_open', []],
157            ['nest', [[ ['cdata', ['C']] ]]],
158            ['listcontent_close', []],
159            ['listitem_close', []],
160            ['listu_close', []],
161            ['document_end', []],
162        ];
163        $this->assertCalls($expected, $this->H->calls);
164    }
165
166    /**
167     * Flatten a call list, recursing into `nest` calls' inner content.
168     * Useful for tests that just want to verify a particular instruction
169     * appears somewhere in the rendered output regardless of nesting.
170     */
171    private function flatNames(array $calls): array
172    {
173        $names = [];
174        foreach ($calls as $call) {
175            $names[] = $call[0];
176            if ($call[0] === 'nest') {
177                $names = array_merge($names, $this->flatNames($call[1][0]));
178            }
179        }
180        return $names;
181    }
182
183    public function testNestedThreeLevels()
184    {
185        $this->P->addMode('gfm_listblock', new GfmListblock());
186        $this->P->parse("- A\n  - B\n    - C\n");
187
188        $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
189        $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens));
190        $this->assertSame([1, 2, 3], $levels);
191    }
192
193    public function testInlineFormatting()
194    {
195        $this->P->addMode('gfm_listblock', new GfmListblock());
196        $this->P->parse("- **bold** text\n");
197
198        $names = $this->flatNames($this->H->calls);
199        $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item');
200        $this->assertContains('strong_close', $names);
201    }
202
203    public function testMarkerCharSwitchKeepsOneList()
204    {
205        // CommonMark: changing marker character (`-` → `+`) starts a new list.
206        // Our simpler model groups by type ('u' / 'o') only, so `-` and `+`
207        // share one <ul>. Deliberate simplification — the rewriter doesn't
208        // distinguish marker characters within the same type.
209        $this->P->addMode('gfm_listblock', new GfmListblock());
210        $this->P->parse("- A\n+ B\n");
211
212        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
213        $this->assertCount(1, $opens, 'marker-character change does not split unordered lists');
214    }
215
216    public function testOrderedToUnorderedSplits()
217    {
218        // Type change (o → u) DOES split, since the rewriter does close/open
219        // when the type differs.
220        $this->P->addMode('gfm_listblock', new GfmListblock());
221        $this->P->parse("1. A\n- B\n");
222
223        $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
224        $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
225        $this->assertCount(1, $oOpens);
226        $this->assertCount(1, $uOpens);
227    }
228
229    public function testNotAListMidParagraph()
230    {
231        $this->P->addMode('gfm_listblock', new GfmListblock());
232        $this->P->parse("Foo - bar");
233
234        $names = array_column($this->H->calls, 0);
235        $this->assertNotContains('listu_open', $names);
236        $this->assertNotContains('listo_open', $names);
237    }
238
239    public function testEmptyMarkerEol()
240    {
241        $this->P->addMode('gfm_listblock', new GfmListblock());
242        $this->P->parse("-\n");
243
244        $names = array_column($this->H->calls, 0);
245        $this->assertContains('listu_open', $names, 'a bare marker still opens a list');
246        $this->assertContains('listitem_open', $names);
247    }
248
249    public function testHeaderRejectedInsideItem()
250    {
251        // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item
252        // body must NOT produce a header instruction.
253        $this->P->addMode('gfm_listblock', new GfmListblock());
254        $this->P->parse("- foo\n  # bar\n");
255
256        $names = $this->flatNames($this->H->calls);
257        $this->assertNotContains('header', $names);
258        $this->assertNotContains('section_open', $names);
259    }
260
261    public function testFencedCodeInsideItem()
262    {
263        // After the dedent step strips the 2-space prefix from the body,
264        // the fence sits at column 0 from the sub-parser's point of view
265        // and gfm_code matches it.
266        $this->P->addMode('gfm_listblock', new GfmListblock());
267        $this->P->parse("- foo\n  ```\n  hello\n  ```\n");
268
269        $names = $this->flatNames($this->H->calls);
270        $this->assertContains('code', $names, 'fenced code inside item must be parsed');
271    }
272
273    public function testMultiParagraphItemIsLoose()
274    {
275        $this->P->addMode('gfm_listblock', new GfmListblock());
276        $this->P->parse("- foo\n\n  bar\n");
277
278        // Loose item: the nest contains two p_open / p_close pairs (one per
279        // paragraph) since the outer-only stripping in filterSubCalls only
280        // collapses single-paragraph items.
281        $names = $this->flatNames($this->H->calls);
282        $pOpens = array_filter($names, static fn($n) => $n === 'p_open');
283        $this->assertGreaterThanOrEqual(2, count($pOpens),
284            'multi-paragraph items must keep both p_open calls');
285    }
286
287    public function testSortValue()
288    {
289        $mode = new GfmListblock();
290        $this->assertSame(10, $mode->getSort());
291    }
292
293    /**
294     * Regression: an item's sub-parsed content must reach the main handler
295     * inside a `nest` call. Without the wrap, the main handler's Block
296     * rewriter wraps the item content in another `<p>` (it already has
297     * its own `<p>` from the sub-parser), producing nested paragraph tags.
298     */
299    public function testItemContentIsWrappedInNest()
300    {
301        $this->P->addMode('gfm_listblock', new GfmListblock());
302        $this->P->parse("- foo\n");
303
304        $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest');
305        $this->assertCount(1, $nests, 'each item body should land in one nest call');
306    }
307
308    /**
309     * Regression: multiple consecutive blank lines inside a list block must
310     * NOT terminate the list. Spec example 242 (`- Foo\n\n      bar\n\n\n
311     * baz`) ends with a triple blank between two indented continuations and
312     * expects all three to remain inside one list item.
313     */
314    public function testTripleBlankBetweenContinuationsKeepsListOpen()
315    {
316        $this->P->addMode('gfm_listblock', new GfmListblock());
317        $this->P->parse("- Foo\n\n      bar\n\n\n      baz\n");
318
319        // The list should bracket all three indented lines: `- Foo`, `bar`,
320        // and `baz` all live inside a single `<ul>`. We assert there is
321        // exactly one listu_open / listu_close pair (no early termination
322        // splitting `baz` into a separate top-level block).
323        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
324        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
325        $this->assertCount(1, $opens,
326            'triple blank line between continuations must not split the list');
327        $this->assertCount(1, $closes);
328    }
329
330    /**
331     * Regression: blank lines between items (any number) must not split the
332     * list. Spec example 270 stresses two-blank cases.
333     */
334    public function testMultipleBlanksBetweenItemsKeepsOneList()
335    {
336        $this->P->addMode('gfm_listblock', new GfmListblock());
337        $this->P->parse("- one\n\n\n- two\n");
338
339        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
340        $this->assertCount(1, $opens, 'blank lines between items must stay inside the list');
341    }
342
343    public function testSingleBlankBetweenSiblingsKeepsOneList()
344    {
345        $this->P->addMode('gfm_listblock', new GfmListblock());
346        $this->P->parse("- one\n\n- two\n");
347
348        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
349        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
350        $items  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
351        $this->assertCount(1, $opens, 'single blank between siblings must not split the list');
352        $this->assertCount(1, $closes);
353        $this->assertCount(2, $items, 'both items must end up in the same list');
354    }
355
356    /**
357     * A blank line between items does not interact with the list-type-switch
358     * rule: `- one\n\n1. two\n` still produces two separate lists, the same
359     * as without the blank (cf. testOrderedToUnorderedSplits).
360     */
361    public function testSingleBlankAcrossTypeSwitchStillSplits()
362    {
363        $this->P->addMode('gfm_listblock', new GfmListblock());
364        $this->P->parse("- one\n\n1. two\n");
365
366        $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
367        $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
368        $this->assertCount(1, $uOpens);
369        $this->assertCount(1, $oOpens);
370    }
371
372    /**
373     * Spec example 79 shape (`1.  foo\n\n    - bar\n`): a blank line followed
374     * by a deeper-indented marker nests the inner list inside the first item
375     * rather than starting a sibling. Renderer-shape differences (the GFM
376     * `<p>foo</p>` wrapper) are out of scope; we only pin the structural
377     * call sequence here.
378     */
379    public function testSingleBlankBeforeIndentedMarkerNests()
380    {
381        $this->P->addMode('gfm_listblock', new GfmListblock());
382        $this->P->parse("1.  foo\n\n    - bar\n");
383
384        $names = array_column($this->H->calls, 0);
385        $oOpenIdx  = array_search('listo_open',  $names, true);
386        $uOpenIdx  = array_search('listu_open',  $names, true);
387        $uCloseIdx = array_search('listu_close', $names, true);
388        $oCloseIdx = array_search('listo_close', $names, true);
389
390        $this->assertNotFalse($oOpenIdx,  'outer ordered list must open');
391        $this->assertNotFalse($uOpenIdx,  'inner unordered list must open');
392        $this->assertNotFalse($uCloseIdx, 'inner unordered list must close');
393        $this->assertNotFalse($oCloseIdx, 'outer ordered list must close');
394
395        $this->assertLessThan($uOpenIdx,  $oOpenIdx,  'inner list must open after outer list');
396        $this->assertLessThan($uCloseIdx, $uOpenIdx,  'inner list must close before reopening');
397        $this->assertLessThan($oCloseIdx, $uCloseIdx, 'outer list must close after inner list');
398
399        $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listo_open')));
400        $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listu_open')));
401    }
402
403    /**
404     * Negative bound: the blank-line tolerance only spans blanks that are
405     * followed by another marker or by indented continuation. Blank lines
406     * followed by column-0 non-list content terminate the list.
407     */
408    public function testBlanksFollowedByNonMarkerTerminate()
409    {
410        $this->P->addMode('gfm_listblock', new GfmListblock());
411        $this->P->parse("- one\n\n\n\nunrelated\n");
412
413        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
414        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
415        $this->assertCount(1, $opens);
416        $this->assertCount(1, $closes);
417
418        // The trailing column-0 line must reach the main handler as content
419        // outside the list, not be absorbed into an item body.
420        $names = array_column($this->H->calls, 0);
421        $closeIdx = array_search('listu_close', $names, true);
422        $tail = array_slice($this->H->calls, $closeIdx + 1);
423        $tailText = '';
424        foreach ($tail as $call) {
425            if ($call[0] === 'cdata') {
426                $tailText .= $call[1][0];
427            }
428        }
429        $this->assertStringContainsString('unrelated', $tailText,
430            'content after a terminated list lands in top-level cdata');
431    }
432
433    /**
434     * Boundary between "blank between items" (handled here) and "blank
435     * inside an item body" (handled by the sub-parser's Block rewriter).
436     * Item one's body has an internal blank with an indented continuation;
437     * item two is a sibling at the same level. We must end up with one list
438     * containing two items, with the loose body of item one preserved as
439     * separate paragraphs in its sub-parsed nest.
440     */
441    public function testBlankInsideItemBodyDoesNotBreakSibling()
442    {
443        $this->P->addMode('gfm_listblock', new GfmListblock());
444        $this->P->parse("- one\n\n  cont\n- two\n");
445
446        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
447        $items = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
448        $this->assertCount(1, $opens);
449        $this->assertCount(2, $items, 'first item must not swallow the second marker');
450
451        $flat = $this->flatNames($this->H->calls);
452        $pOpens = array_filter($flat, static fn($n) => $n === 'p_open');
453        $this->assertGreaterThanOrEqual(2, count($pOpens),
454            'multi-paragraph item one keeps its sub-parsed paragraph breaks');
455    }
456}
457