setSyntax('md'); } public function testUnorderedDash() { // Each item's body is sub-parsed and wrapped in a `nest` call so // the main handler's Block rewriter doesn't double-wrap multi-block // content. See AbstractListsRewriter / Block / Nest interaction. $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- A\n- B\n- C\n"); $expected = [ ['document_start', []], ['listu_open', []], ['listitem_open', [1]], ['listcontent_open', []], ['nest', [[ ['cdata', ['A']] ]]], ['listcontent_close', []], ['listitem_close', []], ['listitem_open', [1]], ['listcontent_open', []], ['nest', [[ ['cdata', ['B']] ]]], ['listcontent_close', []], ['listitem_close', []], ['listitem_open', [1]], ['listcontent_open', []], ['nest', [[ ['cdata', ['C']] ]]], ['listcontent_close', []], ['listitem_close', []], ['listu_close', []], ['document_end', []], ]; $this->assertCalls($expected, $this->H->calls); } public function testUnorderedAsterisk() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("* A\n* B\n"); $names = array_column($this->H->calls, 0); $this->assertContains('listu_open', $names); $this->assertNotContains('listo_open', $names); } public function testUnorderedPlus() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("+ A\n+ B\n"); $names = array_column($this->H->calls, 0); $this->assertContains('listu_open', $names); $this->assertNotContains('listo_open', $names); } public function testOrderedDot() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("1. A\n2. B\n3. C\n"); $names = array_column($this->H->calls, 0); $this->assertContains('listo_open', $names); $this->assertNotContains('listu_open', $names); } public function testOrderedParen() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("1) A\n2) B\n"); $names = array_column($this->H->calls, 0); $this->assertContains('listo_open', $names); } public function testOrderedStartNumber() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("5. A\n6. B\n"); $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open'); $open = array_values($opens)[0]; $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number'); $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1'); } public function testOrderedDefaultStartNotEmittedSpecially() { // For start=1 the rewriter emits the plain listo_open instruction so // unmodified plugin renderers (which only override listo_open) keep // working. The wire shape is bare []. $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("1. A\n2. B\n"); $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); $open = array_values($opens)[0]; $this->assertSame([], $open[1]); $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start'); } public function testNestedTwoLevels() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- A\n - B\n- C\n"); $expected = [ ['document_start', []], ['listu_open', []], ['listitem_open', [1, GfmLists::NODE]], ['listcontent_open', []], ['nest', [[ ['cdata', ['A']] ]]], ['listcontent_close', []], ['listu_open', []], ['listitem_open', [2]], ['listcontent_open', []], ['nest', [[ ['cdata', ['B']] ]]], ['listcontent_close', []], ['listitem_close', []], ['listu_close', []], ['listitem_close', []], ['listitem_open', [1]], ['listcontent_open', []], ['nest', [[ ['cdata', ['C']] ]]], ['listcontent_close', []], ['listitem_close', []], ['listu_close', []], ['document_end', []], ]; $this->assertCalls($expected, $this->H->calls); } /** * Flatten a call list, recursing into `nest` calls' inner content. * Useful for tests that just want to verify a particular instruction * appears somewhere in the rendered output regardless of nesting. */ private function flatNames(array $calls): array { $names = []; foreach ($calls as $call) { $names[] = $call[0]; if ($call[0] === 'nest') { $names = array_merge($names, $this->flatNames($call[1][0])); } } return $names; } public function testNestedThreeLevels() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- A\n - B\n - C\n"); $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens)); $this->assertSame([1, 2, 3], $levels); } public function testInlineFormatting() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- **bold** text\n"); $names = $this->flatNames($this->H->calls); $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item'); $this->assertContains('strong_close', $names); } public function testMarkerCharSwitchKeepsOneList() { // CommonMark: changing marker character (`-` → `+`) starts a new list. // Our simpler model groups by type ('u' / 'o') only, so `-` and `+` // share one
` (it already has * its own `
` from the sub-parser), producing nested paragraph tags. */ public function testItemContentIsWrappedInNest() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- foo\n"); $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest'); $this->assertCount(1, $nests, 'each item body should land in one nest call'); } /** * Regression: multiple consecutive blank lines inside a list block must * NOT terminate the list. Spec example 242 (`- Foo\n\n bar\n\n\n * baz`) ends with a triple blank between two indented continuations and * expects all three to remain inside one list item. */ public function testTripleBlankBetweenContinuationsKeepsListOpen() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- Foo\n\n bar\n\n\n baz\n"); // The list should bracket all three indented lines: `- Foo`, `bar`, // and `baz` all live inside a single `
foo
` wrapper) are out of scope; we only pin the structural * call sequence here. */ public function testSingleBlankBeforeIndentedMarkerNests() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("1. foo\n\n - bar\n"); $names = array_column($this->H->calls, 0); $oOpenIdx = array_search('listo_open', $names, true); $uOpenIdx = array_search('listu_open', $names, true); $uCloseIdx = array_search('listu_close', $names, true); $oCloseIdx = array_search('listo_close', $names, true); $this->assertNotFalse($oOpenIdx, 'outer ordered list must open'); $this->assertNotFalse($uOpenIdx, 'inner unordered list must open'); $this->assertNotFalse($uCloseIdx, 'inner unordered list must close'); $this->assertNotFalse($oCloseIdx, 'outer ordered list must close'); $this->assertLessThan($uOpenIdx, $oOpenIdx, 'inner list must open after outer list'); $this->assertLessThan($uCloseIdx, $uOpenIdx, 'inner list must close before reopening'); $this->assertLessThan($oCloseIdx, $uCloseIdx, 'outer list must close after inner list'); $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listo_open'))); $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listu_open'))); } /** * Negative bound: the blank-line tolerance only spans blanks that are * followed by another marker or by indented continuation. Blank lines * followed by column-0 non-list content terminate the list. */ public function testBlanksFollowedByNonMarkerTerminate() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- one\n\n\n\nunrelated\n"); $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); $this->assertCount(1, $opens); $this->assertCount(1, $closes); // The trailing column-0 line must reach the main handler as content // outside the list, not be absorbed into an item body. $names = array_column($this->H->calls, 0); $closeIdx = array_search('listu_close', $names, true); $tail = array_slice($this->H->calls, $closeIdx + 1); $tailText = ''; foreach ($tail as $call) { if ($call[0] === 'cdata') { $tailText .= $call[1][0]; } } $this->assertStringContainsString('unrelated', $tailText, 'content after a terminated list lands in top-level cdata'); } /** * Boundary between "blank between items" (handled here) and "blank * inside an item body" (handled by the sub-parser's Block rewriter). * Item one's body has an internal blank with an indented continuation; * item two is a sibling at the same level. We must end up with one list * containing two items, with the loose body of item one preserved as * separate paragraphs in its sub-parsed nest. */ public function testBlankInsideItemBodyDoesNotBreakSibling() { $this->P->addMode('gfm_listblock', new GfmListblock()); $this->P->parse("- one\n\n cont\n- two\n"); $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); $items = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); $this->assertCount(1, $opens); $this->assertCount(2, $items, 'first item must not swallow the second marker'); $flat = $this->flatNames($this->H->calls); $pOpens = array_filter($flat, static fn($n) => $n === 'p_open'); $this->assertGreaterThanOrEqual(2, count($pOpens), 'multi-paragraph item one keeps its sub-parsed paragraph breaks'); } }