xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmListblockTest.php (revision ab6ac09039049460b16b8a07f72fcd82605ced15)
1685560ebSAndreas Gohr<?php
2685560ebSAndreas Gohr
3685560ebSAndreas Gohrnamespace dokuwiki\test\Parsing\ParserMode;
4685560ebSAndreas Gohr
5685560ebSAndreas Gohruse dokuwiki\Parsing\Handler\GfmLists;
6685560ebSAndreas Gohruse dokuwiki\Parsing\ModeRegistry;
7685560ebSAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmListblock;
8685560ebSAndreas Gohr
9685560ebSAndreas Gohr/**
10685560ebSAndreas Gohr * Tests for GFM list blocks.
11685560ebSAndreas Gohr *
12685560ebSAndreas Gohr * GfmListblock captures the entire list block via addSpecialPattern then
13309a0852SAndreas Gohr * sub-parses each item's body through a sub-parser acquired from
14309a0852SAndreas Gohr * ModeRegistry's pool, so the outer parser only needs gfm_listblock added;
15309a0852SAndreas Gohr * inline modes (emphasis, strong, etc.) and block modes (gfm_code) are
16309a0852SAndreas Gohr * picked up by the sub-parser.
17685560ebSAndreas Gohr */
18685560ebSAndreas Gohrclass GfmListblockTest extends ParserTestBase
19685560ebSAndreas Gohr{
20685560ebSAndreas Gohr    public function setUp(): void
21685560ebSAndreas Gohr    {
22685560ebSAndreas Gohr        parent::setUp();
23685560ebSAndreas Gohr        global $conf;
2413a62f81SAndreas Gohr        $conf['syntax'] = 'md';
25685560ebSAndreas Gohr        ModeRegistry::reset();
26685560ebSAndreas Gohr    }
27685560ebSAndreas Gohr
28685560ebSAndreas Gohr    public function tearDown(): void
29685560ebSAndreas Gohr    {
30685560ebSAndreas Gohr        ModeRegistry::reset();
31685560ebSAndreas Gohr        parent::tearDown();
32685560ebSAndreas Gohr    }
33685560ebSAndreas Gohr
34685560ebSAndreas Gohr    public function testUnorderedDash()
35685560ebSAndreas Gohr    {
36685560ebSAndreas Gohr        // Each item's body is sub-parsed and wrapped in a `nest` call so
37685560ebSAndreas Gohr        // the main handler's Block rewriter doesn't double-wrap multi-block
38685560ebSAndreas Gohr        // content. See AbstractListsRewriter / Block / Nest interaction.
39685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
40685560ebSAndreas Gohr        $this->P->parse("- A\n- B\n- C\n");
41685560ebSAndreas Gohr
42685560ebSAndreas Gohr        $expected = [
43685560ebSAndreas Gohr            ['document_start', []],
44685560ebSAndreas Gohr            ['listu_open', []],
45685560ebSAndreas Gohr            ['listitem_open', [1]],
46685560ebSAndreas Gohr            ['listcontent_open', []],
47685560ebSAndreas Gohr            ['nest', [[ ['cdata', ['A']] ]]],
48685560ebSAndreas Gohr            ['listcontent_close', []],
49685560ebSAndreas Gohr            ['listitem_close', []],
50685560ebSAndreas Gohr            ['listitem_open', [1]],
51685560ebSAndreas Gohr            ['listcontent_open', []],
52685560ebSAndreas Gohr            ['nest', [[ ['cdata', ['B']] ]]],
53685560ebSAndreas Gohr            ['listcontent_close', []],
54685560ebSAndreas Gohr            ['listitem_close', []],
55685560ebSAndreas Gohr            ['listitem_open', [1]],
56685560ebSAndreas Gohr            ['listcontent_open', []],
57685560ebSAndreas Gohr            ['nest', [[ ['cdata', ['C']] ]]],
58685560ebSAndreas Gohr            ['listcontent_close', []],
59685560ebSAndreas Gohr            ['listitem_close', []],
60685560ebSAndreas Gohr            ['listu_close', []],
61685560ebSAndreas Gohr            ['document_end', []],
62685560ebSAndreas Gohr        ];
63685560ebSAndreas Gohr        $this->assertCalls($expected, $this->H->calls);
64685560ebSAndreas Gohr    }
65685560ebSAndreas Gohr
66685560ebSAndreas Gohr    public function testUnorderedAsterisk()
67685560ebSAndreas Gohr    {
68685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
69685560ebSAndreas Gohr        $this->P->parse("* A\n* B\n");
70685560ebSAndreas Gohr
71685560ebSAndreas Gohr        $names = array_column($this->H->calls, 0);
72685560ebSAndreas Gohr        $this->assertContains('listu_open', $names);
73685560ebSAndreas Gohr        $this->assertNotContains('listo_open', $names);
74685560ebSAndreas Gohr    }
75685560ebSAndreas Gohr
76685560ebSAndreas Gohr    public function testUnorderedPlus()
77685560ebSAndreas Gohr    {
78685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
79685560ebSAndreas Gohr        $this->P->parse("+ A\n+ B\n");
80685560ebSAndreas Gohr
81685560ebSAndreas Gohr        $names = array_column($this->H->calls, 0);
82685560ebSAndreas Gohr        $this->assertContains('listu_open', $names);
83685560ebSAndreas Gohr        $this->assertNotContains('listo_open', $names);
84685560ebSAndreas Gohr    }
85685560ebSAndreas Gohr
86685560ebSAndreas Gohr    public function testOrderedDot()
87685560ebSAndreas Gohr    {
88685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
89685560ebSAndreas Gohr        $this->P->parse("1. A\n2. B\n3. C\n");
90685560ebSAndreas Gohr
91685560ebSAndreas Gohr        $names = array_column($this->H->calls, 0);
92685560ebSAndreas Gohr        $this->assertContains('listo_open', $names);
93685560ebSAndreas Gohr        $this->assertNotContains('listu_open', $names);
94685560ebSAndreas Gohr    }
95685560ebSAndreas Gohr
96685560ebSAndreas Gohr    public function testOrderedParen()
97685560ebSAndreas Gohr    {
98685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
99685560ebSAndreas Gohr        $this->P->parse("1) A\n2) B\n");
100685560ebSAndreas Gohr
101685560ebSAndreas Gohr        $names = array_column($this->H->calls, 0);
102685560ebSAndreas Gohr        $this->assertContains('listo_open', $names);
103685560ebSAndreas Gohr    }
104685560ebSAndreas Gohr
105685560ebSAndreas Gohr    public function testOrderedStartNumber()
106685560ebSAndreas Gohr    {
107685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
108685560ebSAndreas Gohr        $this->P->parse("5. A\n6. B\n");
109685560ebSAndreas Gohr
110f7c6e4acSAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start');
111f7c6e4acSAndreas Gohr        $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open');
112685560ebSAndreas Gohr        $open = array_values($opens)[0];
113f7c6e4acSAndreas Gohr        $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number');
114f7c6e4acSAndreas Gohr
115f7c6e4acSAndreas Gohr        $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
116f7c6e4acSAndreas Gohr        $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1');
117685560ebSAndreas Gohr    }
118685560ebSAndreas Gohr
119685560ebSAndreas Gohr    public function testOrderedDefaultStartNotEmittedSpecially()
120685560ebSAndreas Gohr    {
121f7c6e4acSAndreas Gohr        // For start=1 the rewriter emits the plain listo_open instruction so
122f7c6e4acSAndreas Gohr        // unmodified plugin renderers (which only override listo_open) keep
123f7c6e4acSAndreas Gohr        // working. The wire shape is bare [].
124685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
125685560ebSAndreas Gohr        $this->P->parse("1. A\n2. B\n");
126685560ebSAndreas Gohr
127685560ebSAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
128685560ebSAndreas Gohr        $open = array_values($opens)[0];
129685560ebSAndreas Gohr        $this->assertSame([], $open[1]);
130f7c6e4acSAndreas Gohr
131f7c6e4acSAndreas Gohr        $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start');
132f7c6e4acSAndreas Gohr        $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start');
133685560ebSAndreas Gohr    }
134685560ebSAndreas Gohr
135685560ebSAndreas Gohr    public function testNestedTwoLevels()
136685560ebSAndreas Gohr    {
137685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
138685560ebSAndreas Gohr        $this->P->parse("- A\n  - B\n- C\n");
139685560ebSAndreas Gohr
140685560ebSAndreas Gohr        $expected = [
141685560ebSAndreas Gohr            ['document_start', []],
142685560ebSAndreas Gohr            ['listu_open', []],
143685560ebSAndreas Gohr            ['listitem_open', [1, GfmLists::NODE]],
144685560ebSAndreas Gohr            ['listcontent_open', []],
145685560ebSAndreas Gohr            ['nest', [[ ['cdata', ['A']] ]]],
146685560ebSAndreas Gohr            ['listcontent_close', []],
147685560ebSAndreas Gohr            ['listu_open', []],
148685560ebSAndreas Gohr            ['listitem_open', [2]],
149685560ebSAndreas Gohr            ['listcontent_open', []],
150685560ebSAndreas Gohr            ['nest', [[ ['cdata', ['B']] ]]],
151685560ebSAndreas Gohr            ['listcontent_close', []],
152685560ebSAndreas Gohr            ['listitem_close', []],
153685560ebSAndreas Gohr            ['listu_close', []],
154685560ebSAndreas Gohr            ['listitem_close', []],
155685560ebSAndreas Gohr            ['listitem_open', [1]],
156685560ebSAndreas Gohr            ['listcontent_open', []],
157685560ebSAndreas Gohr            ['nest', [[ ['cdata', ['C']] ]]],
158685560ebSAndreas Gohr            ['listcontent_close', []],
159685560ebSAndreas Gohr            ['listitem_close', []],
160685560ebSAndreas Gohr            ['listu_close', []],
161685560ebSAndreas Gohr            ['document_end', []],
162685560ebSAndreas Gohr        ];
163685560ebSAndreas Gohr        $this->assertCalls($expected, $this->H->calls);
164685560ebSAndreas Gohr    }
165685560ebSAndreas Gohr
166685560ebSAndreas Gohr    /**
167685560ebSAndreas Gohr     * Flatten a call list, recursing into `nest` calls' inner content.
168685560ebSAndreas Gohr     * Useful for tests that just want to verify a particular instruction
169685560ebSAndreas Gohr     * appears somewhere in the rendered output regardless of nesting.
170685560ebSAndreas Gohr     */
171685560ebSAndreas Gohr    private function flatNames(array $calls): array
172685560ebSAndreas Gohr    {
173685560ebSAndreas Gohr        $names = [];
174685560ebSAndreas Gohr        foreach ($calls as $call) {
175685560ebSAndreas Gohr            $names[] = $call[0];
176685560ebSAndreas Gohr            if ($call[0] === 'nest') {
177685560ebSAndreas Gohr                $names = array_merge($names, $this->flatNames($call[1][0]));
178685560ebSAndreas Gohr            }
179685560ebSAndreas Gohr        }
180685560ebSAndreas Gohr        return $names;
181685560ebSAndreas Gohr    }
182685560ebSAndreas Gohr
183685560ebSAndreas Gohr    public function testNestedThreeLevels()
184685560ebSAndreas Gohr    {
185685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
186685560ebSAndreas Gohr        $this->P->parse("- A\n  - B\n    - C\n");
187685560ebSAndreas Gohr
188685560ebSAndreas Gohr        $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
189685560ebSAndreas Gohr        $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens));
190685560ebSAndreas Gohr        $this->assertSame([1, 2, 3], $levels);
191685560ebSAndreas Gohr    }
192685560ebSAndreas Gohr
193685560ebSAndreas Gohr    public function testInlineFormatting()
194685560ebSAndreas Gohr    {
195685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
196685560ebSAndreas Gohr        $this->P->parse("- **bold** text\n");
197685560ebSAndreas Gohr
198685560ebSAndreas Gohr        $names = $this->flatNames($this->H->calls);
199685560ebSAndreas Gohr        $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item');
200685560ebSAndreas Gohr        $this->assertContains('strong_close', $names);
201685560ebSAndreas Gohr    }
202685560ebSAndreas Gohr
203685560ebSAndreas Gohr    public function testMarkerCharSwitchKeepsOneList()
204685560ebSAndreas Gohr    {
205685560ebSAndreas Gohr        // CommonMark: changing marker character (`-` → `+`) starts a new list.
206685560ebSAndreas Gohr        // Our simpler model groups by type ('u' / 'o') only, so `-` and `+`
207685560ebSAndreas Gohr        // share one <ul>. Deliberate simplification — the rewriter doesn't
208685560ebSAndreas Gohr        // distinguish marker characters within the same type.
209685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
210685560ebSAndreas Gohr        $this->P->parse("- A\n+ B\n");
211685560ebSAndreas Gohr
212685560ebSAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
213685560ebSAndreas Gohr        $this->assertCount(1, $opens, 'marker-character change does not split unordered lists');
214685560ebSAndreas Gohr    }
215685560ebSAndreas Gohr
216685560ebSAndreas Gohr    public function testOrderedToUnorderedSplits()
217685560ebSAndreas Gohr    {
218685560ebSAndreas Gohr        // Type change (o → u) DOES split, since the rewriter does close/open
219685560ebSAndreas Gohr        // when the type differs.
220685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
221685560ebSAndreas Gohr        $this->P->parse("1. A\n- B\n");
222685560ebSAndreas Gohr
223685560ebSAndreas Gohr        $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
224685560ebSAndreas Gohr        $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
225685560ebSAndreas Gohr        $this->assertCount(1, $oOpens);
226685560ebSAndreas Gohr        $this->assertCount(1, $uOpens);
227685560ebSAndreas Gohr    }
228685560ebSAndreas Gohr
229685560ebSAndreas Gohr    public function testNotAListMidParagraph()
230685560ebSAndreas Gohr    {
231685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
232685560ebSAndreas Gohr        $this->P->parse("Foo - bar");
233685560ebSAndreas Gohr
234685560ebSAndreas Gohr        $names = array_column($this->H->calls, 0);
235685560ebSAndreas Gohr        $this->assertNotContains('listu_open', $names);
236685560ebSAndreas Gohr        $this->assertNotContains('listo_open', $names);
237685560ebSAndreas Gohr    }
238685560ebSAndreas Gohr
239685560ebSAndreas Gohr    public function testEmptyMarkerEol()
240685560ebSAndreas Gohr    {
241685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
242685560ebSAndreas Gohr        $this->P->parse("-\n");
243685560ebSAndreas Gohr
244685560ebSAndreas Gohr        $names = array_column($this->H->calls, 0);
245685560ebSAndreas Gohr        $this->assertContains('listu_open', $names, 'a bare marker still opens a list');
246685560ebSAndreas Gohr        $this->assertContains('listitem_open', $names);
247685560ebSAndreas Gohr    }
248685560ebSAndreas Gohr
249685560ebSAndreas Gohr    public function testHeaderRejectedInsideItem()
250685560ebSAndreas Gohr    {
251685560ebSAndreas Gohr        // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item
252685560ebSAndreas Gohr        // body must NOT produce a header instruction.
253685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
254685560ebSAndreas Gohr        $this->P->parse("- foo\n  # bar\n");
255685560ebSAndreas Gohr
256685560ebSAndreas Gohr        $names = $this->flatNames($this->H->calls);
257685560ebSAndreas Gohr        $this->assertNotContains('header', $names);
258685560ebSAndreas Gohr        $this->assertNotContains('section_open', $names);
259685560ebSAndreas Gohr    }
260685560ebSAndreas Gohr
261685560ebSAndreas Gohr    public function testFencedCodeInsideItem()
262685560ebSAndreas Gohr    {
263685560ebSAndreas Gohr        // After the dedent step strips the 2-space prefix from the body,
264685560ebSAndreas Gohr        // the fence sits at column 0 from the sub-parser's point of view
265685560ebSAndreas Gohr        // and gfm_code matches it.
266685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
267685560ebSAndreas Gohr        $this->P->parse("- foo\n  ```\n  hello\n  ```\n");
268685560ebSAndreas Gohr
269685560ebSAndreas Gohr        $names = $this->flatNames($this->H->calls);
270685560ebSAndreas Gohr        $this->assertContains('code', $names, 'fenced code inside item must be parsed');
271685560ebSAndreas Gohr    }
272685560ebSAndreas Gohr
273685560ebSAndreas Gohr    public function testMultiParagraphItemIsLoose()
274685560ebSAndreas Gohr    {
275685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
276685560ebSAndreas Gohr        $this->P->parse("- foo\n\n  bar\n");
277685560ebSAndreas Gohr
278685560ebSAndreas Gohr        // Loose item: the nest contains two p_open / p_close pairs (one per
279685560ebSAndreas Gohr        // paragraph) since the outer-only stripping in filterSubCalls only
280685560ebSAndreas Gohr        // collapses single-paragraph items.
281685560ebSAndreas Gohr        $names = $this->flatNames($this->H->calls);
282685560ebSAndreas Gohr        $pOpens = array_filter($names, static fn($n) => $n === 'p_open');
283685560ebSAndreas Gohr        $this->assertGreaterThanOrEqual(2, count($pOpens),
284685560ebSAndreas Gohr            'multi-paragraph items must keep both p_open calls');
285685560ebSAndreas Gohr    }
286685560ebSAndreas Gohr
287685560ebSAndreas Gohr    public function testSortValue()
288685560ebSAndreas Gohr    {
289685560ebSAndreas Gohr        $mode = new GfmListblock();
290685560ebSAndreas Gohr        $this->assertSame(10, $mode->getSort());
291685560ebSAndreas Gohr    }
292685560ebSAndreas Gohr
293685560ebSAndreas Gohr    /**
294685560ebSAndreas Gohr     * Regression: an item's sub-parsed content must reach the main handler
295685560ebSAndreas Gohr     * inside a `nest` call. Without the wrap, the main handler's Block
296685560ebSAndreas Gohr     * rewriter wraps the item content in another `<p>` (it already has
297685560ebSAndreas Gohr     * its own `<p>` from the sub-parser), producing nested paragraph tags.
298685560ebSAndreas Gohr     */
299685560ebSAndreas Gohr    public function testItemContentIsWrappedInNest()
300685560ebSAndreas Gohr    {
301685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
302685560ebSAndreas Gohr        $this->P->parse("- foo\n");
303685560ebSAndreas Gohr
304685560ebSAndreas Gohr        $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest');
305685560ebSAndreas Gohr        $this->assertCount(1, $nests, 'each item body should land in one nest call');
306685560ebSAndreas Gohr    }
307685560ebSAndreas Gohr
308685560ebSAndreas Gohr    /**
309685560ebSAndreas Gohr     * Regression: multiple consecutive blank lines inside a list block must
310685560ebSAndreas Gohr     * NOT terminate the list. Spec example 242 (`- Foo\n\n      bar\n\n\n
311685560ebSAndreas Gohr     * baz`) ends with a triple blank between two indented continuations and
312685560ebSAndreas Gohr     * expects all three to remain inside one list item.
313685560ebSAndreas Gohr     */
314685560ebSAndreas Gohr    public function testTripleBlankBetweenContinuationsKeepsListOpen()
315685560ebSAndreas Gohr    {
316685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
317685560ebSAndreas Gohr        $this->P->parse("- Foo\n\n      bar\n\n\n      baz\n");
318685560ebSAndreas Gohr
319685560ebSAndreas Gohr        // The list should bracket all three indented lines: `- Foo`, `bar`,
320685560ebSAndreas Gohr        // and `baz` all live inside a single `<ul>`. We assert there is
321685560ebSAndreas Gohr        // exactly one listu_open / listu_close pair (no early termination
322685560ebSAndreas Gohr        // splitting `baz` into a separate top-level block).
323685560ebSAndreas Gohr        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
324685560ebSAndreas Gohr        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
325685560ebSAndreas Gohr        $this->assertCount(1, $opens,
326685560ebSAndreas Gohr            'triple blank line between continuations must not split the list');
327685560ebSAndreas Gohr        $this->assertCount(1, $closes);
328685560ebSAndreas Gohr    }
329685560ebSAndreas Gohr
330685560ebSAndreas Gohr    /**
331685560ebSAndreas Gohr     * Regression: blank lines between items (any number) must not split the
332685560ebSAndreas Gohr     * list. Spec example 270 stresses two-blank cases.
333685560ebSAndreas Gohr     */
334685560ebSAndreas Gohr    public function testMultipleBlanksBetweenItemsKeepsOneList()
335685560ebSAndreas Gohr    {
336685560ebSAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
337685560ebSAndreas Gohr        $this->P->parse("- one\n\n\n- two\n");
338685560ebSAndreas Gohr
339685560ebSAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
340685560ebSAndreas Gohr        $this->assertCount(1, $opens, 'blank lines between items must stay inside the list');
341685560ebSAndreas Gohr    }
342*ab6ac090SAndreas Gohr
343*ab6ac090SAndreas Gohr    public function testSingleBlankBetweenSiblingsKeepsOneList()
344*ab6ac090SAndreas Gohr    {
345*ab6ac090SAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
346*ab6ac090SAndreas Gohr        $this->P->parse("- one\n\n- two\n");
347*ab6ac090SAndreas Gohr
348*ab6ac090SAndreas Gohr        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
349*ab6ac090SAndreas Gohr        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
350*ab6ac090SAndreas Gohr        $items  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
351*ab6ac090SAndreas Gohr        $this->assertCount(1, $opens, 'single blank between siblings must not split the list');
352*ab6ac090SAndreas Gohr        $this->assertCount(1, $closes);
353*ab6ac090SAndreas Gohr        $this->assertCount(2, $items, 'both items must end up in the same list');
354*ab6ac090SAndreas Gohr    }
355*ab6ac090SAndreas Gohr
356*ab6ac090SAndreas Gohr    /**
357*ab6ac090SAndreas Gohr     * A blank line between items does not interact with the list-type-switch
358*ab6ac090SAndreas Gohr     * rule: `- one\n\n1. two\n` still produces two separate lists, the same
359*ab6ac090SAndreas Gohr     * as without the blank (cf. testOrderedToUnorderedSplits).
360*ab6ac090SAndreas Gohr     */
361*ab6ac090SAndreas Gohr    public function testSingleBlankAcrossTypeSwitchStillSplits()
362*ab6ac090SAndreas Gohr    {
363*ab6ac090SAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
364*ab6ac090SAndreas Gohr        $this->P->parse("- one\n\n1. two\n");
365*ab6ac090SAndreas Gohr
366*ab6ac090SAndreas Gohr        $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
367*ab6ac090SAndreas Gohr        $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open');
368*ab6ac090SAndreas Gohr        $this->assertCount(1, $uOpens);
369*ab6ac090SAndreas Gohr        $this->assertCount(1, $oOpens);
370*ab6ac090SAndreas Gohr    }
371*ab6ac090SAndreas Gohr
372*ab6ac090SAndreas Gohr    /**
373*ab6ac090SAndreas Gohr     * Spec example 79 shape (`1.  foo\n\n    - bar\n`): a blank line followed
374*ab6ac090SAndreas Gohr     * by a deeper-indented marker nests the inner list inside the first item
375*ab6ac090SAndreas Gohr     * rather than starting a sibling. Renderer-shape differences (the GFM
376*ab6ac090SAndreas Gohr     * `<p>foo</p>` wrapper) are out of scope; we only pin the structural
377*ab6ac090SAndreas Gohr     * call sequence here.
378*ab6ac090SAndreas Gohr     */
379*ab6ac090SAndreas Gohr    public function testSingleBlankBeforeIndentedMarkerNests()
380*ab6ac090SAndreas Gohr    {
381*ab6ac090SAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
382*ab6ac090SAndreas Gohr        $this->P->parse("1.  foo\n\n    - bar\n");
383*ab6ac090SAndreas Gohr
384*ab6ac090SAndreas Gohr        $names = array_column($this->H->calls, 0);
385*ab6ac090SAndreas Gohr        $oOpenIdx  = array_search('listo_open',  $names, true);
386*ab6ac090SAndreas Gohr        $uOpenIdx  = array_search('listu_open',  $names, true);
387*ab6ac090SAndreas Gohr        $uCloseIdx = array_search('listu_close', $names, true);
388*ab6ac090SAndreas Gohr        $oCloseIdx = array_search('listo_close', $names, true);
389*ab6ac090SAndreas Gohr
390*ab6ac090SAndreas Gohr        $this->assertNotFalse($oOpenIdx,  'outer ordered list must open');
391*ab6ac090SAndreas Gohr        $this->assertNotFalse($uOpenIdx,  'inner unordered list must open');
392*ab6ac090SAndreas Gohr        $this->assertNotFalse($uCloseIdx, 'inner unordered list must close');
393*ab6ac090SAndreas Gohr        $this->assertNotFalse($oCloseIdx, 'outer ordered list must close');
394*ab6ac090SAndreas Gohr
395*ab6ac090SAndreas Gohr        $this->assertLessThan($uOpenIdx,  $oOpenIdx,  'inner list must open after outer list');
396*ab6ac090SAndreas Gohr        $this->assertLessThan($uCloseIdx, $uOpenIdx,  'inner list must close before reopening');
397*ab6ac090SAndreas Gohr        $this->assertLessThan($oCloseIdx, $uCloseIdx, 'outer list must close after inner list');
398*ab6ac090SAndreas Gohr
399*ab6ac090SAndreas Gohr        $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listo_open')));
400*ab6ac090SAndreas Gohr        $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listu_open')));
401*ab6ac090SAndreas Gohr    }
402*ab6ac090SAndreas Gohr
403*ab6ac090SAndreas Gohr    /**
404*ab6ac090SAndreas Gohr     * Negative bound: the blank-line tolerance only spans blanks that are
405*ab6ac090SAndreas Gohr     * followed by another marker or by indented continuation. Blank lines
406*ab6ac090SAndreas Gohr     * followed by column-0 non-list content terminate the list.
407*ab6ac090SAndreas Gohr     */
408*ab6ac090SAndreas Gohr    public function testBlanksFollowedByNonMarkerTerminate()
409*ab6ac090SAndreas Gohr    {
410*ab6ac090SAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
411*ab6ac090SAndreas Gohr        $this->P->parse("- one\n\n\n\nunrelated\n");
412*ab6ac090SAndreas Gohr
413*ab6ac090SAndreas Gohr        $opens  = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
414*ab6ac090SAndreas Gohr        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close');
415*ab6ac090SAndreas Gohr        $this->assertCount(1, $opens);
416*ab6ac090SAndreas Gohr        $this->assertCount(1, $closes);
417*ab6ac090SAndreas Gohr
418*ab6ac090SAndreas Gohr        // The trailing column-0 line must reach the main handler as content
419*ab6ac090SAndreas Gohr        // outside the list, not be absorbed into an item body.
420*ab6ac090SAndreas Gohr        $names = array_column($this->H->calls, 0);
421*ab6ac090SAndreas Gohr        $closeIdx = array_search('listu_close', $names, true);
422*ab6ac090SAndreas Gohr        $tail = array_slice($this->H->calls, $closeIdx + 1);
423*ab6ac090SAndreas Gohr        $tailText = '';
424*ab6ac090SAndreas Gohr        foreach ($tail as $call) {
425*ab6ac090SAndreas Gohr            if ($call[0] === 'cdata') {
426*ab6ac090SAndreas Gohr                $tailText .= $call[1][0];
427*ab6ac090SAndreas Gohr            }
428*ab6ac090SAndreas Gohr        }
429*ab6ac090SAndreas Gohr        $this->assertStringContainsString('unrelated', $tailText,
430*ab6ac090SAndreas Gohr            'content after a terminated list lands in top-level cdata');
431*ab6ac090SAndreas Gohr    }
432*ab6ac090SAndreas Gohr
433*ab6ac090SAndreas Gohr    /**
434*ab6ac090SAndreas Gohr     * Boundary between "blank between items" (handled here) and "blank
435*ab6ac090SAndreas Gohr     * inside an item body" (handled by the sub-parser's Block rewriter).
436*ab6ac090SAndreas Gohr     * Item one's body has an internal blank with an indented continuation;
437*ab6ac090SAndreas Gohr     * item two is a sibling at the same level. We must end up with one list
438*ab6ac090SAndreas Gohr     * containing two items, with the loose body of item one preserved as
439*ab6ac090SAndreas Gohr     * separate paragraphs in its sub-parsed nest.
440*ab6ac090SAndreas Gohr     */
441*ab6ac090SAndreas Gohr    public function testBlankInsideItemBodyDoesNotBreakSibling()
442*ab6ac090SAndreas Gohr    {
443*ab6ac090SAndreas Gohr        $this->P->addMode('gfm_listblock', new GfmListblock());
444*ab6ac090SAndreas Gohr        $this->P->parse("- one\n\n  cont\n- two\n");
445*ab6ac090SAndreas Gohr
446*ab6ac090SAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open');
447*ab6ac090SAndreas Gohr        $items = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open');
448*ab6ac090SAndreas Gohr        $this->assertCount(1, $opens);
449*ab6ac090SAndreas Gohr        $this->assertCount(2, $items, 'first item must not swallow the second marker');
450*ab6ac090SAndreas Gohr
451*ab6ac090SAndreas Gohr        $flat = $this->flatNames($this->H->calls);
452*ab6ac090SAndreas Gohr        $pOpens = array_filter($flat, static fn($n) => $n === 'p_open');
453*ab6ac090SAndreas Gohr        $this->assertGreaterThanOrEqual(2, count($pOpens),
454*ab6ac090SAndreas Gohr            'multi-paragraph item one keeps its sub-parsed paragraph breaks');
455*ab6ac090SAndreas Gohr    }
456685560ebSAndreas Gohr}
457