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 13685560ebSAndreas Gohr * sub-parses each item's body through ModeRegistry::getSubParser(), so the 14685560ebSAndreas Gohr * outer parser only needs gfm_listblock added; inline modes (emphasis, 15685560ebSAndreas Gohr * strong, etc.) and block modes (gfm_code) are picked up by the sub-parser. 16685560ebSAndreas Gohr */ 17685560ebSAndreas Gohrclass GfmListblockTest extends ParserTestBase 18685560ebSAndreas Gohr{ 19685560ebSAndreas Gohr public function setUp(): void 20685560ebSAndreas Gohr { 21685560ebSAndreas Gohr parent::setUp(); 22685560ebSAndreas Gohr global $conf; 23685560ebSAndreas Gohr $conf['syntax'] = 'markdown'; 24685560ebSAndreas Gohr ModeRegistry::reset(); 25685560ebSAndreas Gohr } 26685560ebSAndreas Gohr 27685560ebSAndreas Gohr public function tearDown(): void 28685560ebSAndreas Gohr { 29685560ebSAndreas Gohr ModeRegistry::reset(); 30685560ebSAndreas Gohr parent::tearDown(); 31685560ebSAndreas Gohr } 32685560ebSAndreas Gohr 33685560ebSAndreas Gohr public function testUnorderedDash() 34685560ebSAndreas Gohr { 35685560ebSAndreas Gohr // Each item's body is sub-parsed and wrapped in a `nest` call so 36685560ebSAndreas Gohr // the main handler's Block rewriter doesn't double-wrap multi-block 37685560ebSAndreas Gohr // content. See AbstractListsRewriter / Block / Nest interaction. 38685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 39685560ebSAndreas Gohr $this->P->parse("- A\n- B\n- C\n"); 40685560ebSAndreas Gohr 41685560ebSAndreas Gohr $expected = [ 42685560ebSAndreas Gohr ['document_start', []], 43685560ebSAndreas Gohr ['listu_open', []], 44685560ebSAndreas Gohr ['listitem_open', [1]], 45685560ebSAndreas Gohr ['listcontent_open', []], 46685560ebSAndreas Gohr ['nest', [[ ['cdata', ['A']] ]]], 47685560ebSAndreas Gohr ['listcontent_close', []], 48685560ebSAndreas Gohr ['listitem_close', []], 49685560ebSAndreas Gohr ['listitem_open', [1]], 50685560ebSAndreas Gohr ['listcontent_open', []], 51685560ebSAndreas Gohr ['nest', [[ ['cdata', ['B']] ]]], 52685560ebSAndreas Gohr ['listcontent_close', []], 53685560ebSAndreas Gohr ['listitem_close', []], 54685560ebSAndreas Gohr ['listitem_open', [1]], 55685560ebSAndreas Gohr ['listcontent_open', []], 56685560ebSAndreas Gohr ['nest', [[ ['cdata', ['C']] ]]], 57685560ebSAndreas Gohr ['listcontent_close', []], 58685560ebSAndreas Gohr ['listitem_close', []], 59685560ebSAndreas Gohr ['listu_close', []], 60685560ebSAndreas Gohr ['document_end', []], 61685560ebSAndreas Gohr ]; 62685560ebSAndreas Gohr $this->assertCalls($expected, $this->H->calls); 63685560ebSAndreas Gohr } 64685560ebSAndreas Gohr 65685560ebSAndreas Gohr public function testUnorderedAsterisk() 66685560ebSAndreas Gohr { 67685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 68685560ebSAndreas Gohr $this->P->parse("* A\n* B\n"); 69685560ebSAndreas Gohr 70685560ebSAndreas Gohr $names = array_column($this->H->calls, 0); 71685560ebSAndreas Gohr $this->assertContains('listu_open', $names); 72685560ebSAndreas Gohr $this->assertNotContains('listo_open', $names); 73685560ebSAndreas Gohr } 74685560ebSAndreas Gohr 75685560ebSAndreas Gohr public function testUnorderedPlus() 76685560ebSAndreas Gohr { 77685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 78685560ebSAndreas Gohr $this->P->parse("+ A\n+ B\n"); 79685560ebSAndreas Gohr 80685560ebSAndreas Gohr $names = array_column($this->H->calls, 0); 81685560ebSAndreas Gohr $this->assertContains('listu_open', $names); 82685560ebSAndreas Gohr $this->assertNotContains('listo_open', $names); 83685560ebSAndreas Gohr } 84685560ebSAndreas Gohr 85685560ebSAndreas Gohr public function testOrderedDot() 86685560ebSAndreas Gohr { 87685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 88685560ebSAndreas Gohr $this->P->parse("1. A\n2. B\n3. C\n"); 89685560ebSAndreas Gohr 90685560ebSAndreas Gohr $names = array_column($this->H->calls, 0); 91685560ebSAndreas Gohr $this->assertContains('listo_open', $names); 92685560ebSAndreas Gohr $this->assertNotContains('listu_open', $names); 93685560ebSAndreas Gohr } 94685560ebSAndreas Gohr 95685560ebSAndreas Gohr public function testOrderedParen() 96685560ebSAndreas Gohr { 97685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 98685560ebSAndreas Gohr $this->P->parse("1) A\n2) B\n"); 99685560ebSAndreas Gohr 100685560ebSAndreas Gohr $names = array_column($this->H->calls, 0); 101685560ebSAndreas Gohr $this->assertContains('listo_open', $names); 102685560ebSAndreas Gohr } 103685560ebSAndreas Gohr 104685560ebSAndreas Gohr public function testOrderedStartNumber() 105685560ebSAndreas Gohr { 106685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 107685560ebSAndreas Gohr $this->P->parse("5. A\n6. B\n"); 108685560ebSAndreas Gohr 109*f7c6e4acSAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); 110*f7c6e4acSAndreas Gohr $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open'); 111685560ebSAndreas Gohr $open = array_values($opens)[0]; 112*f7c6e4acSAndreas Gohr $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number'); 113*f7c6e4acSAndreas Gohr 114*f7c6e4acSAndreas Gohr $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 115*f7c6e4acSAndreas Gohr $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1'); 116685560ebSAndreas Gohr } 117685560ebSAndreas Gohr 118685560ebSAndreas Gohr public function testOrderedDefaultStartNotEmittedSpecially() 119685560ebSAndreas Gohr { 120*f7c6e4acSAndreas Gohr // For start=1 the rewriter emits the plain listo_open instruction so 121*f7c6e4acSAndreas Gohr // unmodified plugin renderers (which only override listo_open) keep 122*f7c6e4acSAndreas Gohr // working. The wire shape is bare []. 123685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 124685560ebSAndreas Gohr $this->P->parse("1. A\n2. B\n"); 125685560ebSAndreas Gohr 126685560ebSAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 127685560ebSAndreas Gohr $open = array_values($opens)[0]; 128685560ebSAndreas Gohr $this->assertSame([], $open[1]); 129*f7c6e4acSAndreas Gohr 130*f7c6e4acSAndreas Gohr $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); 131*f7c6e4acSAndreas Gohr $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start'); 132685560ebSAndreas Gohr } 133685560ebSAndreas Gohr 134685560ebSAndreas Gohr public function testNestedTwoLevels() 135685560ebSAndreas Gohr { 136685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 137685560ebSAndreas Gohr $this->P->parse("- A\n - B\n- C\n"); 138685560ebSAndreas Gohr 139685560ebSAndreas Gohr $expected = [ 140685560ebSAndreas Gohr ['document_start', []], 141685560ebSAndreas Gohr ['listu_open', []], 142685560ebSAndreas Gohr ['listitem_open', [1, GfmLists::NODE]], 143685560ebSAndreas Gohr ['listcontent_open', []], 144685560ebSAndreas Gohr ['nest', [[ ['cdata', ['A']] ]]], 145685560ebSAndreas Gohr ['listcontent_close', []], 146685560ebSAndreas Gohr ['listu_open', []], 147685560ebSAndreas Gohr ['listitem_open', [2]], 148685560ebSAndreas Gohr ['listcontent_open', []], 149685560ebSAndreas Gohr ['nest', [[ ['cdata', ['B']] ]]], 150685560ebSAndreas Gohr ['listcontent_close', []], 151685560ebSAndreas Gohr ['listitem_close', []], 152685560ebSAndreas Gohr ['listu_close', []], 153685560ebSAndreas Gohr ['listitem_close', []], 154685560ebSAndreas Gohr ['listitem_open', [1]], 155685560ebSAndreas Gohr ['listcontent_open', []], 156685560ebSAndreas Gohr ['nest', [[ ['cdata', ['C']] ]]], 157685560ebSAndreas Gohr ['listcontent_close', []], 158685560ebSAndreas Gohr ['listitem_close', []], 159685560ebSAndreas Gohr ['listu_close', []], 160685560ebSAndreas Gohr ['document_end', []], 161685560ebSAndreas Gohr ]; 162685560ebSAndreas Gohr $this->assertCalls($expected, $this->H->calls); 163685560ebSAndreas Gohr } 164685560ebSAndreas Gohr 165685560ebSAndreas Gohr /** 166685560ebSAndreas Gohr * Flatten a call list, recursing into `nest` calls' inner content. 167685560ebSAndreas Gohr * Useful for tests that just want to verify a particular instruction 168685560ebSAndreas Gohr * appears somewhere in the rendered output regardless of nesting. 169685560ebSAndreas Gohr */ 170685560ebSAndreas Gohr private function flatNames(array $calls): array 171685560ebSAndreas Gohr { 172685560ebSAndreas Gohr $names = []; 173685560ebSAndreas Gohr foreach ($calls as $call) { 174685560ebSAndreas Gohr $names[] = $call[0]; 175685560ebSAndreas Gohr if ($call[0] === 'nest') { 176685560ebSAndreas Gohr $names = array_merge($names, $this->flatNames($call[1][0])); 177685560ebSAndreas Gohr } 178685560ebSAndreas Gohr } 179685560ebSAndreas Gohr return $names; 180685560ebSAndreas Gohr } 181685560ebSAndreas Gohr 182685560ebSAndreas Gohr public function testNestedThreeLevels() 183685560ebSAndreas Gohr { 184685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 185685560ebSAndreas Gohr $this->P->parse("- A\n - B\n - C\n"); 186685560ebSAndreas Gohr 187685560ebSAndreas Gohr $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); 188685560ebSAndreas Gohr $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens)); 189685560ebSAndreas Gohr $this->assertSame([1, 2, 3], $levels); 190685560ebSAndreas Gohr } 191685560ebSAndreas Gohr 192685560ebSAndreas Gohr public function testInlineFormatting() 193685560ebSAndreas Gohr { 194685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 195685560ebSAndreas Gohr $this->P->parse("- **bold** text\n"); 196685560ebSAndreas Gohr 197685560ebSAndreas Gohr $names = $this->flatNames($this->H->calls); 198685560ebSAndreas Gohr $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item'); 199685560ebSAndreas Gohr $this->assertContains('strong_close', $names); 200685560ebSAndreas Gohr } 201685560ebSAndreas Gohr 202685560ebSAndreas Gohr public function testMarkerCharSwitchKeepsOneList() 203685560ebSAndreas Gohr { 204685560ebSAndreas Gohr // CommonMark: changing marker character (`-` → `+`) starts a new list. 205685560ebSAndreas Gohr // Our simpler model groups by type ('u' / 'o') only, so `-` and `+` 206685560ebSAndreas Gohr // share one <ul>. Deliberate simplification — the rewriter doesn't 207685560ebSAndreas Gohr // distinguish marker characters within the same type. 208685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 209685560ebSAndreas Gohr $this->P->parse("- A\n+ B\n"); 210685560ebSAndreas Gohr 211685560ebSAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 212685560ebSAndreas Gohr $this->assertCount(1, $opens, 'marker-character change does not split unordered lists'); 213685560ebSAndreas Gohr } 214685560ebSAndreas Gohr 215685560ebSAndreas Gohr public function testOrderedToUnorderedSplits() 216685560ebSAndreas Gohr { 217685560ebSAndreas Gohr // Type change (o → u) DOES split, since the rewriter does close/open 218685560ebSAndreas Gohr // when the type differs. 219685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 220685560ebSAndreas Gohr $this->P->parse("1. A\n- B\n"); 221685560ebSAndreas Gohr 222685560ebSAndreas Gohr $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 223685560ebSAndreas Gohr $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 224685560ebSAndreas Gohr $this->assertCount(1, $oOpens); 225685560ebSAndreas Gohr $this->assertCount(1, $uOpens); 226685560ebSAndreas Gohr } 227685560ebSAndreas Gohr 228685560ebSAndreas Gohr public function testNotAListMidParagraph() 229685560ebSAndreas Gohr { 230685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 231685560ebSAndreas Gohr $this->P->parse("Foo - bar"); 232685560ebSAndreas Gohr 233685560ebSAndreas Gohr $names = array_column($this->H->calls, 0); 234685560ebSAndreas Gohr $this->assertNotContains('listu_open', $names); 235685560ebSAndreas Gohr $this->assertNotContains('listo_open', $names); 236685560ebSAndreas Gohr } 237685560ebSAndreas Gohr 238685560ebSAndreas Gohr public function testEmptyMarkerEol() 239685560ebSAndreas Gohr { 240685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 241685560ebSAndreas Gohr $this->P->parse("-\n"); 242685560ebSAndreas Gohr 243685560ebSAndreas Gohr $names = array_column($this->H->calls, 0); 244685560ebSAndreas Gohr $this->assertContains('listu_open', $names, 'a bare marker still opens a list'); 245685560ebSAndreas Gohr $this->assertContains('listitem_open', $names); 246685560ebSAndreas Gohr } 247685560ebSAndreas Gohr 248685560ebSAndreas Gohr public function testHeaderRejectedInsideItem() 249685560ebSAndreas Gohr { 250685560ebSAndreas Gohr // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item 251685560ebSAndreas Gohr // body must NOT produce a header instruction. 252685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 253685560ebSAndreas Gohr $this->P->parse("- foo\n # bar\n"); 254685560ebSAndreas Gohr 255685560ebSAndreas Gohr $names = $this->flatNames($this->H->calls); 256685560ebSAndreas Gohr $this->assertNotContains('header', $names); 257685560ebSAndreas Gohr $this->assertNotContains('section_open', $names); 258685560ebSAndreas Gohr } 259685560ebSAndreas Gohr 260685560ebSAndreas Gohr public function testFencedCodeInsideItem() 261685560ebSAndreas Gohr { 262685560ebSAndreas Gohr // After the dedent step strips the 2-space prefix from the body, 263685560ebSAndreas Gohr // the fence sits at column 0 from the sub-parser's point of view 264685560ebSAndreas Gohr // and gfm_code matches it. 265685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 266685560ebSAndreas Gohr $this->P->parse("- foo\n ```\n hello\n ```\n"); 267685560ebSAndreas Gohr 268685560ebSAndreas Gohr $names = $this->flatNames($this->H->calls); 269685560ebSAndreas Gohr $this->assertContains('code', $names, 'fenced code inside item must be parsed'); 270685560ebSAndreas Gohr } 271685560ebSAndreas Gohr 272685560ebSAndreas Gohr public function testMultiParagraphItemIsLoose() 273685560ebSAndreas Gohr { 274685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 275685560ebSAndreas Gohr $this->P->parse("- foo\n\n bar\n"); 276685560ebSAndreas Gohr 277685560ebSAndreas Gohr // Loose item: the nest contains two p_open / p_close pairs (one per 278685560ebSAndreas Gohr // paragraph) since the outer-only stripping in filterSubCalls only 279685560ebSAndreas Gohr // collapses single-paragraph items. 280685560ebSAndreas Gohr $names = $this->flatNames($this->H->calls); 281685560ebSAndreas Gohr $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 282685560ebSAndreas Gohr $this->assertGreaterThanOrEqual(2, count($pOpens), 283685560ebSAndreas Gohr 'multi-paragraph items must keep both p_open calls'); 284685560ebSAndreas Gohr } 285685560ebSAndreas Gohr 286685560ebSAndreas Gohr public function testSortValue() 287685560ebSAndreas Gohr { 288685560ebSAndreas Gohr $mode = new GfmListblock(); 289685560ebSAndreas Gohr $this->assertSame(10, $mode->getSort()); 290685560ebSAndreas Gohr } 291685560ebSAndreas Gohr 292685560ebSAndreas Gohr /** 293685560ebSAndreas Gohr * Regression: an item's sub-parsed content must reach the main handler 294685560ebSAndreas Gohr * inside a `nest` call. Without the wrap, the main handler's Block 295685560ebSAndreas Gohr * rewriter wraps the item content in another `<p>` (it already has 296685560ebSAndreas Gohr * its own `<p>` from the sub-parser), producing nested paragraph tags. 297685560ebSAndreas Gohr */ 298685560ebSAndreas Gohr public function testItemContentIsWrappedInNest() 299685560ebSAndreas Gohr { 300685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 301685560ebSAndreas Gohr $this->P->parse("- foo\n"); 302685560ebSAndreas Gohr 303685560ebSAndreas Gohr $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest'); 304685560ebSAndreas Gohr $this->assertCount(1, $nests, 'each item body should land in one nest call'); 305685560ebSAndreas Gohr } 306685560ebSAndreas Gohr 307685560ebSAndreas Gohr /** 308685560ebSAndreas Gohr * Regression: multiple consecutive blank lines inside a list block must 309685560ebSAndreas Gohr * NOT terminate the list. Spec example 242 (`- Foo\n\n bar\n\n\n 310685560ebSAndreas Gohr * baz`) ends with a triple blank between two indented continuations and 311685560ebSAndreas Gohr * expects all three to remain inside one list item. 312685560ebSAndreas Gohr */ 313685560ebSAndreas Gohr public function testTripleBlankBetweenContinuationsKeepsListOpen() 314685560ebSAndreas Gohr { 315685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 316685560ebSAndreas Gohr $this->P->parse("- Foo\n\n bar\n\n\n baz\n"); 317685560ebSAndreas Gohr 318685560ebSAndreas Gohr // The list should bracket all three indented lines: `- Foo`, `bar`, 319685560ebSAndreas Gohr // and `baz` all live inside a single `<ul>`. We assert there is 320685560ebSAndreas Gohr // exactly one listu_open / listu_close pair (no early termination 321685560ebSAndreas Gohr // splitting `baz` into a separate top-level block). 322685560ebSAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 323685560ebSAndreas Gohr $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); 324685560ebSAndreas Gohr $this->assertCount(1, $opens, 325685560ebSAndreas Gohr 'triple blank line between continuations must not split the list'); 326685560ebSAndreas Gohr $this->assertCount(1, $closes); 327685560ebSAndreas Gohr } 328685560ebSAndreas Gohr 329685560ebSAndreas Gohr /** 330685560ebSAndreas Gohr * Regression: blank lines between items (any number) must not split the 331685560ebSAndreas Gohr * list. Spec example 270 stresses two-blank cases. 332685560ebSAndreas Gohr */ 333685560ebSAndreas Gohr public function testMultipleBlanksBetweenItemsKeepsOneList() 334685560ebSAndreas Gohr { 335685560ebSAndreas Gohr $this->P->addMode('gfm_listblock', new GfmListblock()); 336685560ebSAndreas Gohr $this->P->parse("- one\n\n\n- two\n"); 337685560ebSAndreas Gohr 338685560ebSAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 339685560ebSAndreas Gohr $this->assertCount(1, $opens, 'blank lines between items must stay inside the list'); 340685560ebSAndreas Gohr } 341685560ebSAndreas Gohr} 342