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