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 a sub-parser acquired from 14 * ModeRegistry's pool, so the outer parser only needs gfm_listblock added; 15 * inline modes (emphasis, strong, etc.) and block modes (gfm_code) are 16 * picked up by the sub-parser. 17 */ 18class GfmListblockTest extends ParserTestBase 19{ 20 public function setUp(): void 21 { 22 parent::setUp(); 23 global $conf; 24 $conf['syntax'] = 'md'; 25 ModeRegistry::reset(); 26 } 27 28 public function tearDown(): void 29 { 30 ModeRegistry::reset(); 31 parent::tearDown(); 32 } 33 34 public function testUnorderedDash() 35 { 36 // Each item's body is sub-parsed and wrapped in a `nest` call so 37 // the main handler's Block rewriter doesn't double-wrap multi-block 38 // content. See AbstractListsRewriter / Block / Nest interaction. 39 $this->P->addMode('gfm_listblock', new GfmListblock()); 40 $this->P->parse("- A\n- B\n- C\n"); 41 42 $expected = [ 43 ['document_start', []], 44 ['listu_open', []], 45 ['listitem_open', [1]], 46 ['listcontent_open', []], 47 ['nest', [[ ['cdata', ['A']] ]]], 48 ['listcontent_close', []], 49 ['listitem_close', []], 50 ['listitem_open', [1]], 51 ['listcontent_open', []], 52 ['nest', [[ ['cdata', ['B']] ]]], 53 ['listcontent_close', []], 54 ['listitem_close', []], 55 ['listitem_open', [1]], 56 ['listcontent_open', []], 57 ['nest', [[ ['cdata', ['C']] ]]], 58 ['listcontent_close', []], 59 ['listitem_close', []], 60 ['listu_close', []], 61 ['document_end', []], 62 ]; 63 $this->assertCalls($expected, $this->H->calls); 64 } 65 66 public function testUnorderedAsterisk() 67 { 68 $this->P->addMode('gfm_listblock', new GfmListblock()); 69 $this->P->parse("* A\n* B\n"); 70 71 $names = array_column($this->H->calls, 0); 72 $this->assertContains('listu_open', $names); 73 $this->assertNotContains('listo_open', $names); 74 } 75 76 public function testUnorderedPlus() 77 { 78 $this->P->addMode('gfm_listblock', new GfmListblock()); 79 $this->P->parse("+ A\n+ B\n"); 80 81 $names = array_column($this->H->calls, 0); 82 $this->assertContains('listu_open', $names); 83 $this->assertNotContains('listo_open', $names); 84 } 85 86 public function testOrderedDot() 87 { 88 $this->P->addMode('gfm_listblock', new GfmListblock()); 89 $this->P->parse("1. A\n2. B\n3. C\n"); 90 91 $names = array_column($this->H->calls, 0); 92 $this->assertContains('listo_open', $names); 93 $this->assertNotContains('listu_open', $names); 94 } 95 96 public function testOrderedParen() 97 { 98 $this->P->addMode('gfm_listblock', new GfmListblock()); 99 $this->P->parse("1) A\n2) B\n"); 100 101 $names = array_column($this->H->calls, 0); 102 $this->assertContains('listo_open', $names); 103 } 104 105 public function testOrderedStartNumber() 106 { 107 $this->P->addMode('gfm_listblock', new GfmListblock()); 108 $this->P->parse("5. A\n6. B\n"); 109 110 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); 111 $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open'); 112 $open = array_values($opens)[0]; 113 $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number'); 114 115 $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 116 $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1'); 117 } 118 119 public function testOrderedDefaultStartNotEmittedSpecially() 120 { 121 // For start=1 the rewriter emits the plain listo_open instruction so 122 // unmodified plugin renderers (which only override listo_open) keep 123 // working. The wire shape is bare []. 124 $this->P->addMode('gfm_listblock', new GfmListblock()); 125 $this->P->parse("1. A\n2. B\n"); 126 127 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 128 $open = array_values($opens)[0]; 129 $this->assertSame([], $open[1]); 130 131 $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); 132 $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start'); 133 } 134 135 public function testNestedTwoLevels() 136 { 137 $this->P->addMode('gfm_listblock', new GfmListblock()); 138 $this->P->parse("- A\n - B\n- C\n"); 139 140 $expected = [ 141 ['document_start', []], 142 ['listu_open', []], 143 ['listitem_open', [1, GfmLists::NODE]], 144 ['listcontent_open', []], 145 ['nest', [[ ['cdata', ['A']] ]]], 146 ['listcontent_close', []], 147 ['listu_open', []], 148 ['listitem_open', [2]], 149 ['listcontent_open', []], 150 ['nest', [[ ['cdata', ['B']] ]]], 151 ['listcontent_close', []], 152 ['listitem_close', []], 153 ['listu_close', []], 154 ['listitem_close', []], 155 ['listitem_open', [1]], 156 ['listcontent_open', []], 157 ['nest', [[ ['cdata', ['C']] ]]], 158 ['listcontent_close', []], 159 ['listitem_close', []], 160 ['listu_close', []], 161 ['document_end', []], 162 ]; 163 $this->assertCalls($expected, $this->H->calls); 164 } 165 166 /** 167 * Flatten a call list, recursing into `nest` calls' inner content. 168 * Useful for tests that just want to verify a particular instruction 169 * appears somewhere in the rendered output regardless of nesting. 170 */ 171 private function flatNames(array $calls): array 172 { 173 $names = []; 174 foreach ($calls as $call) { 175 $names[] = $call[0]; 176 if ($call[0] === 'nest') { 177 $names = array_merge($names, $this->flatNames($call[1][0])); 178 } 179 } 180 return $names; 181 } 182 183 public function testNestedThreeLevels() 184 { 185 $this->P->addMode('gfm_listblock', new GfmListblock()); 186 $this->P->parse("- A\n - B\n - C\n"); 187 188 $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); 189 $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens)); 190 $this->assertSame([1, 2, 3], $levels); 191 } 192 193 public function testInlineFormatting() 194 { 195 $this->P->addMode('gfm_listblock', new GfmListblock()); 196 $this->P->parse("- **bold** text\n"); 197 198 $names = $this->flatNames($this->H->calls); 199 $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item'); 200 $this->assertContains('strong_close', $names); 201 } 202 203 public function testMarkerCharSwitchKeepsOneList() 204 { 205 // CommonMark: changing marker character (`-` → `+`) starts a new list. 206 // Our simpler model groups by type ('u' / 'o') only, so `-` and `+` 207 // share one <ul>. Deliberate simplification — the rewriter doesn't 208 // distinguish marker characters within the same type. 209 $this->P->addMode('gfm_listblock', new GfmListblock()); 210 $this->P->parse("- A\n+ B\n"); 211 212 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 213 $this->assertCount(1, $opens, 'marker-character change does not split unordered lists'); 214 } 215 216 public function testOrderedToUnorderedSplits() 217 { 218 // Type change (o → u) DOES split, since the rewriter does close/open 219 // when the type differs. 220 $this->P->addMode('gfm_listblock', new GfmListblock()); 221 $this->P->parse("1. A\n- B\n"); 222 223 $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 224 $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 225 $this->assertCount(1, $oOpens); 226 $this->assertCount(1, $uOpens); 227 } 228 229 public function testNotAListMidParagraph() 230 { 231 $this->P->addMode('gfm_listblock', new GfmListblock()); 232 $this->P->parse("Foo - bar"); 233 234 $names = array_column($this->H->calls, 0); 235 $this->assertNotContains('listu_open', $names); 236 $this->assertNotContains('listo_open', $names); 237 } 238 239 public function testEmptyMarkerEol() 240 { 241 $this->P->addMode('gfm_listblock', new GfmListblock()); 242 $this->P->parse("-\n"); 243 244 $names = array_column($this->H->calls, 0); 245 $this->assertContains('listu_open', $names, 'a bare marker still opens a list'); 246 $this->assertContains('listitem_open', $names); 247 } 248 249 public function testHeaderRejectedInsideItem() 250 { 251 // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item 252 // body must NOT produce a header instruction. 253 $this->P->addMode('gfm_listblock', new GfmListblock()); 254 $this->P->parse("- foo\n # bar\n"); 255 256 $names = $this->flatNames($this->H->calls); 257 $this->assertNotContains('header', $names); 258 $this->assertNotContains('section_open', $names); 259 } 260 261 public function testFencedCodeInsideItem() 262 { 263 // After the dedent step strips the 2-space prefix from the body, 264 // the fence sits at column 0 from the sub-parser's point of view 265 // and gfm_code matches it. 266 $this->P->addMode('gfm_listblock', new GfmListblock()); 267 $this->P->parse("- foo\n ```\n hello\n ```\n"); 268 269 $names = $this->flatNames($this->H->calls); 270 $this->assertContains('code', $names, 'fenced code inside item must be parsed'); 271 } 272 273 public function testMultiParagraphItemIsLoose() 274 { 275 $this->P->addMode('gfm_listblock', new GfmListblock()); 276 $this->P->parse("- foo\n\n bar\n"); 277 278 // Loose item: the nest contains two p_open / p_close pairs (one per 279 // paragraph) since the outer-only stripping in filterSubCalls only 280 // collapses single-paragraph items. 281 $names = $this->flatNames($this->H->calls); 282 $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 283 $this->assertGreaterThanOrEqual(2, count($pOpens), 284 'multi-paragraph items must keep both p_open calls'); 285 } 286 287 public function testSortValue() 288 { 289 $mode = new GfmListblock(); 290 $this->assertSame(10, $mode->getSort()); 291 } 292 293 /** 294 * Regression: an item's sub-parsed content must reach the main handler 295 * inside a `nest` call. Without the wrap, the main handler's Block 296 * rewriter wraps the item content in another `<p>` (it already has 297 * its own `<p>` from the sub-parser), producing nested paragraph tags. 298 */ 299 public function testItemContentIsWrappedInNest() 300 { 301 $this->P->addMode('gfm_listblock', new GfmListblock()); 302 $this->P->parse("- foo\n"); 303 304 $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest'); 305 $this->assertCount(1, $nests, 'each item body should land in one nest call'); 306 } 307 308 /** 309 * Regression: multiple consecutive blank lines inside a list block must 310 * NOT terminate the list. Spec example 242 (`- Foo\n\n bar\n\n\n 311 * baz`) ends with a triple blank between two indented continuations and 312 * expects all three to remain inside one list item. 313 */ 314 public function testTripleBlankBetweenContinuationsKeepsListOpen() 315 { 316 $this->P->addMode('gfm_listblock', new GfmListblock()); 317 $this->P->parse("- Foo\n\n bar\n\n\n baz\n"); 318 319 // The list should bracket all three indented lines: `- Foo`, `bar`, 320 // and `baz` all live inside a single `<ul>`. We assert there is 321 // exactly one listu_open / listu_close pair (no early termination 322 // splitting `baz` into a separate top-level block). 323 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 324 $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); 325 $this->assertCount(1, $opens, 326 'triple blank line between continuations must not split the list'); 327 $this->assertCount(1, $closes); 328 } 329 330 /** 331 * Regression: blank lines between items (any number) must not split the 332 * list. Spec example 270 stresses two-blank cases. 333 */ 334 public function testMultipleBlanksBetweenItemsKeepsOneList() 335 { 336 $this->P->addMode('gfm_listblock', new GfmListblock()); 337 $this->P->parse("- one\n\n\n- two\n"); 338 339 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 340 $this->assertCount(1, $opens, 'blank lines between items must stay inside the list'); 341 } 342 343 public function testSingleBlankBetweenSiblingsKeepsOneList() 344 { 345 $this->P->addMode('gfm_listblock', new GfmListblock()); 346 $this->P->parse("- one\n\n- two\n"); 347 348 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 349 $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); 350 $items = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); 351 $this->assertCount(1, $opens, 'single blank between siblings must not split the list'); 352 $this->assertCount(1, $closes); 353 $this->assertCount(2, $items, 'both items must end up in the same list'); 354 } 355 356 /** 357 * A blank line between items does not interact with the list-type-switch 358 * rule: `- one\n\n1. two\n` still produces two separate lists, the same 359 * as without the blank (cf. testOrderedToUnorderedSplits). 360 */ 361 public function testSingleBlankAcrossTypeSwitchStillSplits() 362 { 363 $this->P->addMode('gfm_listblock', new GfmListblock()); 364 $this->P->parse("- one\n\n1. two\n"); 365 366 $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 367 $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 368 $this->assertCount(1, $uOpens); 369 $this->assertCount(1, $oOpens); 370 } 371 372 /** 373 * Spec example 79 shape (`1. foo\n\n - bar\n`): a blank line followed 374 * by a deeper-indented marker nests the inner list inside the first item 375 * rather than starting a sibling. Renderer-shape differences (the GFM 376 * `<p>foo</p>` wrapper) are out of scope; we only pin the structural 377 * call sequence here. 378 */ 379 public function testSingleBlankBeforeIndentedMarkerNests() 380 { 381 $this->P->addMode('gfm_listblock', new GfmListblock()); 382 $this->P->parse("1. foo\n\n - bar\n"); 383 384 $names = array_column($this->H->calls, 0); 385 $oOpenIdx = array_search('listo_open', $names, true); 386 $uOpenIdx = array_search('listu_open', $names, true); 387 $uCloseIdx = array_search('listu_close', $names, true); 388 $oCloseIdx = array_search('listo_close', $names, true); 389 390 $this->assertNotFalse($oOpenIdx, 'outer ordered list must open'); 391 $this->assertNotFalse($uOpenIdx, 'inner unordered list must open'); 392 $this->assertNotFalse($uCloseIdx, 'inner unordered list must close'); 393 $this->assertNotFalse($oCloseIdx, 'outer ordered list must close'); 394 395 $this->assertLessThan($uOpenIdx, $oOpenIdx, 'inner list must open after outer list'); 396 $this->assertLessThan($uCloseIdx, $uOpenIdx, 'inner list must close before reopening'); 397 $this->assertLessThan($oCloseIdx, $uCloseIdx, 'outer list must close after inner list'); 398 399 $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listo_open'))); 400 $this->assertSame(1, count(array_filter($names, static fn($n) => $n === 'listu_open'))); 401 } 402 403 /** 404 * Negative bound: the blank-line tolerance only spans blanks that are 405 * followed by another marker or by indented continuation. Blank lines 406 * followed by column-0 non-list content terminate the list. 407 */ 408 public function testBlanksFollowedByNonMarkerTerminate() 409 { 410 $this->P->addMode('gfm_listblock', new GfmListblock()); 411 $this->P->parse("- one\n\n\n\nunrelated\n"); 412 413 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 414 $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); 415 $this->assertCount(1, $opens); 416 $this->assertCount(1, $closes); 417 418 // The trailing column-0 line must reach the main handler as content 419 // outside the list, not be absorbed into an item body. 420 $names = array_column($this->H->calls, 0); 421 $closeIdx = array_search('listu_close', $names, true); 422 $tail = array_slice($this->H->calls, $closeIdx + 1); 423 $tailText = ''; 424 foreach ($tail as $call) { 425 if ($call[0] === 'cdata') { 426 $tailText .= $call[1][0]; 427 } 428 } 429 $this->assertStringContainsString('unrelated', $tailText, 430 'content after a terminated list lands in top-level cdata'); 431 } 432 433 /** 434 * Boundary between "blank between items" (handled here) and "blank 435 * inside an item body" (handled by the sub-parser's Block rewriter). 436 * Item one's body has an internal blank with an indented continuation; 437 * item two is a sibling at the same level. We must end up with one list 438 * containing two items, with the loose body of item one preserved as 439 * separate paragraphs in its sub-parsed nest. 440 */ 441 public function testBlankInsideItemBodyDoesNotBreakSibling() 442 { 443 $this->P->addMode('gfm_listblock', new GfmListblock()); 444 $this->P->parse("- one\n\n cont\n- two\n"); 445 446 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 447 $items = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); 448 $this->assertCount(1, $opens); 449 $this->assertCount(2, $items, 'first item must not swallow the second marker'); 450 451 $flat = $this->flatNames($this->H->calls); 452 $pOpens = array_filter($flat, static fn($n) => $n === 'p_open'); 453 $this->assertGreaterThanOrEqual(2, count($pOpens), 454 'multi-paragraph item one keeps its sub-parsed paragraph breaks'); 455 } 456} 457