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