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