1<?php 2 3namespace dokuwiki\test\Parsing\ParserMode; 4 5use dokuwiki\Parsing\Handler\GfmLists; 6use dokuwiki\Parsing\ModeRegistry; 7use dokuwiki\Parsing\ParserMode\GfmListblock; 8 9/** 10 * Tests for GFM list blocks. 11 * 12 * GfmListblock captures the entire list block via addSpecialPattern then 13 * sub-parses each item's body through ModeRegistry::getSubParser(), so the 14 * outer parser only needs gfm_listblock added; inline modes (emphasis, 15 * strong, etc.) and block modes (gfm_code) are picked up by the sub-parser. 16 */ 17class GfmListblockTest extends ParserTestBase 18{ 19 public function setUp(): void 20 { 21 parent::setUp(); 22 global $conf; 23 $conf['syntax'] = 'markdown'; 24 ModeRegistry::reset(); 25 } 26 27 public function tearDown(): void 28 { 29 ModeRegistry::reset(); 30 parent::tearDown(); 31 } 32 33 public function testUnorderedDash() 34 { 35 // Each item's body is sub-parsed and wrapped in a `nest` call so 36 // the main handler's Block rewriter doesn't double-wrap multi-block 37 // content. See AbstractListsRewriter / Block / Nest interaction. 38 $this->P->addMode('gfm_listblock', new GfmListblock()); 39 $this->P->parse("- A\n- B\n- C\n"); 40 41 $expected = [ 42 ['document_start', []], 43 ['listu_open', []], 44 ['listitem_open', [1]], 45 ['listcontent_open', []], 46 ['nest', [[ ['cdata', ['A']] ]]], 47 ['listcontent_close', []], 48 ['listitem_close', []], 49 ['listitem_open', [1]], 50 ['listcontent_open', []], 51 ['nest', [[ ['cdata', ['B']] ]]], 52 ['listcontent_close', []], 53 ['listitem_close', []], 54 ['listitem_open', [1]], 55 ['listcontent_open', []], 56 ['nest', [[ ['cdata', ['C']] ]]], 57 ['listcontent_close', []], 58 ['listitem_close', []], 59 ['listu_close', []], 60 ['document_end', []], 61 ]; 62 $this->assertCalls($expected, $this->H->calls); 63 } 64 65 public function testUnorderedAsterisk() 66 { 67 $this->P->addMode('gfm_listblock', new GfmListblock()); 68 $this->P->parse("* A\n* B\n"); 69 70 $names = array_column($this->H->calls, 0); 71 $this->assertContains('listu_open', $names); 72 $this->assertNotContains('listo_open', $names); 73 } 74 75 public function testUnorderedPlus() 76 { 77 $this->P->addMode('gfm_listblock', new GfmListblock()); 78 $this->P->parse("+ A\n+ B\n"); 79 80 $names = array_column($this->H->calls, 0); 81 $this->assertContains('listu_open', $names); 82 $this->assertNotContains('listo_open', $names); 83 } 84 85 public function testOrderedDot() 86 { 87 $this->P->addMode('gfm_listblock', new GfmListblock()); 88 $this->P->parse("1. A\n2. B\n3. C\n"); 89 90 $names = array_column($this->H->calls, 0); 91 $this->assertContains('listo_open', $names); 92 $this->assertNotContains('listu_open', $names); 93 } 94 95 public function testOrderedParen() 96 { 97 $this->P->addMode('gfm_listblock', new GfmListblock()); 98 $this->P->parse("1) A\n2) B\n"); 99 100 $names = array_column($this->H->calls, 0); 101 $this->assertContains('listo_open', $names); 102 } 103 104 public function testOrderedStartNumber() 105 { 106 $this->P->addMode('gfm_listblock', new GfmListblock()); 107 $this->P->parse("5. A\n6. B\n"); 108 109 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 110 $this->assertCount(1, $opens); 111 $open = array_values($opens)[0]; 112 $this->assertSame([null, 5], $open[1], 'listo_open must carry the first item start number'); 113 } 114 115 public function testOrderedDefaultStartNotEmittedSpecially() 116 { 117 // For start=1 the rewriter omits the start argument entirely (the 118 // renderer would suppress it anyway). The wire shape is bare []. 119 $this->P->addMode('gfm_listblock', new GfmListblock()); 120 $this->P->parse("1. A\n2. B\n"); 121 122 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 123 $open = array_values($opens)[0]; 124 $this->assertSame([], $open[1]); 125 } 126 127 public function testNestedTwoLevels() 128 { 129 $this->P->addMode('gfm_listblock', new GfmListblock()); 130 $this->P->parse("- A\n - B\n- C\n"); 131 132 $expected = [ 133 ['document_start', []], 134 ['listu_open', []], 135 ['listitem_open', [1, GfmLists::NODE]], 136 ['listcontent_open', []], 137 ['nest', [[ ['cdata', ['A']] ]]], 138 ['listcontent_close', []], 139 ['listu_open', []], 140 ['listitem_open', [2]], 141 ['listcontent_open', []], 142 ['nest', [[ ['cdata', ['B']] ]]], 143 ['listcontent_close', []], 144 ['listitem_close', []], 145 ['listu_close', []], 146 ['listitem_close', []], 147 ['listitem_open', [1]], 148 ['listcontent_open', []], 149 ['nest', [[ ['cdata', ['C']] ]]], 150 ['listcontent_close', []], 151 ['listitem_close', []], 152 ['listu_close', []], 153 ['document_end', []], 154 ]; 155 $this->assertCalls($expected, $this->H->calls); 156 } 157 158 /** 159 * Flatten a call list, recursing into `nest` calls' inner content. 160 * Useful for tests that just want to verify a particular instruction 161 * appears somewhere in the rendered output regardless of nesting. 162 */ 163 private function flatNames(array $calls): array 164 { 165 $names = []; 166 foreach ($calls as $call) { 167 $names[] = $call[0]; 168 if ($call[0] === 'nest') { 169 $names = array_merge($names, $this->flatNames($call[1][0])); 170 } 171 } 172 return $names; 173 } 174 175 public function testNestedThreeLevels() 176 { 177 $this->P->addMode('gfm_listblock', new GfmListblock()); 178 $this->P->parse("- A\n - B\n - C\n"); 179 180 $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); 181 $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens)); 182 $this->assertSame([1, 2, 3], $levels); 183 } 184 185 public function testInlineFormatting() 186 { 187 $this->P->addMode('gfm_listblock', new GfmListblock()); 188 $this->P->parse("- **bold** text\n"); 189 190 $names = $this->flatNames($this->H->calls); 191 $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item'); 192 $this->assertContains('strong_close', $names); 193 } 194 195 public function testMarkerCharSwitchKeepsOneList() 196 { 197 // CommonMark: changing marker character (`-` → `+`) starts a new list. 198 // Our simpler model groups by type ('u' / 'o') only, so `-` and `+` 199 // share one <ul>. Deliberate simplification — the rewriter doesn't 200 // distinguish marker characters within the same type. 201 $this->P->addMode('gfm_listblock', new GfmListblock()); 202 $this->P->parse("- A\n+ B\n"); 203 204 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 205 $this->assertCount(1, $opens, 'marker-character change does not split unordered lists'); 206 } 207 208 public function testOrderedToUnorderedSplits() 209 { 210 // Type change (o → u) DOES split, since the rewriter does close/open 211 // when the type differs. 212 $this->P->addMode('gfm_listblock', new GfmListblock()); 213 $this->P->parse("1. A\n- B\n"); 214 215 $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 216 $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 217 $this->assertCount(1, $oOpens); 218 $this->assertCount(1, $uOpens); 219 } 220 221 public function testNotAListMidParagraph() 222 { 223 $this->P->addMode('gfm_listblock', new GfmListblock()); 224 $this->P->parse("Foo - bar"); 225 226 $names = array_column($this->H->calls, 0); 227 $this->assertNotContains('listu_open', $names); 228 $this->assertNotContains('listo_open', $names); 229 } 230 231 public function testEmptyMarkerEol() 232 { 233 $this->P->addMode('gfm_listblock', new GfmListblock()); 234 $this->P->parse("-\n"); 235 236 $names = array_column($this->H->calls, 0); 237 $this->assertContains('listu_open', $names, 'a bare marker still opens a list'); 238 $this->assertContains('listitem_open', $names); 239 } 240 241 public function testHeaderRejectedInsideItem() 242 { 243 // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item 244 // body must NOT produce a header instruction. 245 $this->P->addMode('gfm_listblock', new GfmListblock()); 246 $this->P->parse("- foo\n # bar\n"); 247 248 $names = $this->flatNames($this->H->calls); 249 $this->assertNotContains('header', $names); 250 $this->assertNotContains('section_open', $names); 251 } 252 253 public function testFencedCodeInsideItem() 254 { 255 // After the dedent step strips the 2-space prefix from the body, 256 // the fence sits at column 0 from the sub-parser's point of view 257 // and gfm_code matches it. 258 $this->P->addMode('gfm_listblock', new GfmListblock()); 259 $this->P->parse("- foo\n ```\n hello\n ```\n"); 260 261 $names = $this->flatNames($this->H->calls); 262 $this->assertContains('code', $names, 'fenced code inside item must be parsed'); 263 } 264 265 public function testMultiParagraphItemIsLoose() 266 { 267 $this->P->addMode('gfm_listblock', new GfmListblock()); 268 $this->P->parse("- foo\n\n bar\n"); 269 270 // Loose item: the nest contains two p_open / p_close pairs (one per 271 // paragraph) since the outer-only stripping in filterSubCalls only 272 // collapses single-paragraph items. 273 $names = $this->flatNames($this->H->calls); 274 $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 275 $this->assertGreaterThanOrEqual(2, count($pOpens), 276 'multi-paragraph items must keep both p_open calls'); 277 } 278 279 public function testSortValue() 280 { 281 $mode = new GfmListblock(); 282 $this->assertSame(10, $mode->getSort()); 283 } 284 285 /** 286 * Regression: an item's sub-parsed content must reach the main handler 287 * inside a `nest` call. Without the wrap, the main handler's Block 288 * rewriter wraps the item content in another `<p>` (it already has 289 * its own `<p>` from the sub-parser), producing nested paragraph tags. 290 */ 291 public function testItemContentIsWrappedInNest() 292 { 293 $this->P->addMode('gfm_listblock', new GfmListblock()); 294 $this->P->parse("- foo\n"); 295 296 $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest'); 297 $this->assertCount(1, $nests, 'each item body should land in one nest call'); 298 } 299 300 /** 301 * Regression: multiple consecutive blank lines inside a list block must 302 * NOT terminate the list. Spec example 242 (`- Foo\n\n bar\n\n\n 303 * baz`) ends with a triple blank between two indented continuations and 304 * expects all three to remain inside one list item. 305 */ 306 public function testTripleBlankBetweenContinuationsKeepsListOpen() 307 { 308 $this->P->addMode('gfm_listblock', new GfmListblock()); 309 $this->P->parse("- Foo\n\n bar\n\n\n baz\n"); 310 311 // The list should bracket all three indented lines: `- Foo`, `bar`, 312 // and `baz` all live inside a single `<ul>`. We assert there is 313 // exactly one listu_open / listu_close pair (no early termination 314 // splitting `baz` into a separate top-level block). 315 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 316 $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); 317 $this->assertCount(1, $opens, 318 'triple blank line between continuations must not split the list'); 319 $this->assertCount(1, $closes); 320 } 321 322 /** 323 * Regression: blank lines between items (any number) must not split the 324 * list. Spec example 270 stresses two-blank cases. 325 */ 326 public function testMultipleBlanksBetweenItemsKeepsOneList() 327 { 328 $this->P->addMode('gfm_listblock', new GfmListblock()); 329 $this->P->parse("- one\n\n\n- two\n"); 330 331 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 332 $this->assertCount(1, $opens, 'blank lines between items must stay inside the list'); 333 } 334} 335