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