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 13*309a0852SAndreas Gohr * sub-parses each item's body through a sub-parser acquired from 14*309a0852SAndreas Gohr * ModeRegistry's pool, so the outer parser only needs gfm_listblock added; 15*309a0852SAndreas Gohr * inline modes (emphasis, strong, etc.) and block modes (gfm_code) are 16*309a0852SAndreas 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; 24685560ebSAndreas Gohr $conf['syntax'] = 'markdown'; 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 } 342685560ebSAndreas Gohr} 343