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_start'); 110 $this->assertCount(1, $opens, 'non-default start emits listo_open_start, not listo_open'); 111 $open = array_values($opens)[0]; 112 $this->assertSame([5], $open[1], 'listo_open_start must carry the first item start number'); 113 114 $plainOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 115 $this->assertCount(0, $plainOpens, 'plain listo_open is not emitted when start != 1'); 116 } 117 118 public function testOrderedDefaultStartNotEmittedSpecially() 119 { 120 // For start=1 the rewriter emits the plain listo_open instruction so 121 // unmodified plugin renderers (which only override listo_open) keep 122 // working. The wire shape is bare []. 123 $this->P->addMode('gfm_listblock', new GfmListblock()); 124 $this->P->parse("1. A\n2. B\n"); 125 126 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 127 $open = array_values($opens)[0]; 128 $this->assertSame([], $open[1]); 129 130 $startOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open_start'); 131 $this->assertCount(0, $startOpens, 'start=1 must not emit listo_open_start'); 132 } 133 134 public function testNestedTwoLevels() 135 { 136 $this->P->addMode('gfm_listblock', new GfmListblock()); 137 $this->P->parse("- A\n - B\n- C\n"); 138 139 $expected = [ 140 ['document_start', []], 141 ['listu_open', []], 142 ['listitem_open', [1, GfmLists::NODE]], 143 ['listcontent_open', []], 144 ['nest', [[ ['cdata', ['A']] ]]], 145 ['listcontent_close', []], 146 ['listu_open', []], 147 ['listitem_open', [2]], 148 ['listcontent_open', []], 149 ['nest', [[ ['cdata', ['B']] ]]], 150 ['listcontent_close', []], 151 ['listitem_close', []], 152 ['listu_close', []], 153 ['listitem_close', []], 154 ['listitem_open', [1]], 155 ['listcontent_open', []], 156 ['nest', [[ ['cdata', ['C']] ]]], 157 ['listcontent_close', []], 158 ['listitem_close', []], 159 ['listu_close', []], 160 ['document_end', []], 161 ]; 162 $this->assertCalls($expected, $this->H->calls); 163 } 164 165 /** 166 * Flatten a call list, recursing into `nest` calls' inner content. 167 * Useful for tests that just want to verify a particular instruction 168 * appears somewhere in the rendered output regardless of nesting. 169 */ 170 private function flatNames(array $calls): array 171 { 172 $names = []; 173 foreach ($calls as $call) { 174 $names[] = $call[0]; 175 if ($call[0] === 'nest') { 176 $names = array_merge($names, $this->flatNames($call[1][0])); 177 } 178 } 179 return $names; 180 } 181 182 public function testNestedThreeLevels() 183 { 184 $this->P->addMode('gfm_listblock', new GfmListblock()); 185 $this->P->parse("- A\n - B\n - C\n"); 186 187 $itemOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listitem_open'); 188 $levels = array_map(static fn($c) => $c[1][0], array_values($itemOpens)); 189 $this->assertSame([1, 2, 3], $levels); 190 } 191 192 public function testInlineFormatting() 193 { 194 $this->P->addMode('gfm_listblock', new GfmListblock()); 195 $this->P->parse("- **bold** text\n"); 196 197 $names = $this->flatNames($this->H->calls); 198 $this->assertContains('strong_open', $names, 'inline strong must be parsed inside item'); 199 $this->assertContains('strong_close', $names); 200 } 201 202 public function testMarkerCharSwitchKeepsOneList() 203 { 204 // CommonMark: changing marker character (`-` → `+`) starts a new list. 205 // Our simpler model groups by type ('u' / 'o') only, so `-` and `+` 206 // share one <ul>. Deliberate simplification — the rewriter doesn't 207 // distinguish marker characters within the same type. 208 $this->P->addMode('gfm_listblock', new GfmListblock()); 209 $this->P->parse("- A\n+ B\n"); 210 211 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 212 $this->assertCount(1, $opens, 'marker-character change does not split unordered lists'); 213 } 214 215 public function testOrderedToUnorderedSplits() 216 { 217 // Type change (o → u) DOES split, since the rewriter does close/open 218 // when the type differs. 219 $this->P->addMode('gfm_listblock', new GfmListblock()); 220 $this->P->parse("1. A\n- B\n"); 221 222 $oOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listo_open'); 223 $uOpens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 224 $this->assertCount(1, $oOpens); 225 $this->assertCount(1, $uOpens); 226 } 227 228 public function testNotAListMidParagraph() 229 { 230 $this->P->addMode('gfm_listblock', new GfmListblock()); 231 $this->P->parse("Foo - bar"); 232 233 $names = array_column($this->H->calls, 0); 234 $this->assertNotContains('listu_open', $names); 235 $this->assertNotContains('listo_open', $names); 236 } 237 238 public function testEmptyMarkerEol() 239 { 240 $this->P->addMode('gfm_listblock', new GfmListblock()); 241 $this->P->parse("-\n"); 242 243 $names = array_column($this->H->calls, 0); 244 $this->assertContains('listu_open', $names, 'a bare marker still opens a list'); 245 $this->assertContains('listitem_open', $names); 246 } 247 248 public function testHeaderRejectedInsideItem() 249 { 250 // Sub-parser excludes BASEONLY (gfm_header), so `# bar` inside an item 251 // body must NOT produce a header instruction. 252 $this->P->addMode('gfm_listblock', new GfmListblock()); 253 $this->P->parse("- foo\n # bar\n"); 254 255 $names = $this->flatNames($this->H->calls); 256 $this->assertNotContains('header', $names); 257 $this->assertNotContains('section_open', $names); 258 } 259 260 public function testFencedCodeInsideItem() 261 { 262 // After the dedent step strips the 2-space prefix from the body, 263 // the fence sits at column 0 from the sub-parser's point of view 264 // and gfm_code matches it. 265 $this->P->addMode('gfm_listblock', new GfmListblock()); 266 $this->P->parse("- foo\n ```\n hello\n ```\n"); 267 268 $names = $this->flatNames($this->H->calls); 269 $this->assertContains('code', $names, 'fenced code inside item must be parsed'); 270 } 271 272 public function testMultiParagraphItemIsLoose() 273 { 274 $this->P->addMode('gfm_listblock', new GfmListblock()); 275 $this->P->parse("- foo\n\n bar\n"); 276 277 // Loose item: the nest contains two p_open / p_close pairs (one per 278 // paragraph) since the outer-only stripping in filterSubCalls only 279 // collapses single-paragraph items. 280 $names = $this->flatNames($this->H->calls); 281 $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 282 $this->assertGreaterThanOrEqual(2, count($pOpens), 283 'multi-paragraph items must keep both p_open calls'); 284 } 285 286 public function testSortValue() 287 { 288 $mode = new GfmListblock(); 289 $this->assertSame(10, $mode->getSort()); 290 } 291 292 /** 293 * Regression: an item's sub-parsed content must reach the main handler 294 * inside a `nest` call. Without the wrap, the main handler's Block 295 * rewriter wraps the item content in another `<p>` (it already has 296 * its own `<p>` from the sub-parser), producing nested paragraph tags. 297 */ 298 public function testItemContentIsWrappedInNest() 299 { 300 $this->P->addMode('gfm_listblock', new GfmListblock()); 301 $this->P->parse("- foo\n"); 302 303 $nests = array_filter($this->H->calls, static fn($c) => $c[0] === 'nest'); 304 $this->assertCount(1, $nests, 'each item body should land in one nest call'); 305 } 306 307 /** 308 * Regression: multiple consecutive blank lines inside a list block must 309 * NOT terminate the list. Spec example 242 (`- Foo\n\n bar\n\n\n 310 * baz`) ends with a triple blank between two indented continuations and 311 * expects all three to remain inside one list item. 312 */ 313 public function testTripleBlankBetweenContinuationsKeepsListOpen() 314 { 315 $this->P->addMode('gfm_listblock', new GfmListblock()); 316 $this->P->parse("- Foo\n\n bar\n\n\n baz\n"); 317 318 // The list should bracket all three indented lines: `- Foo`, `bar`, 319 // and `baz` all live inside a single `<ul>`. We assert there is 320 // exactly one listu_open / listu_close pair (no early termination 321 // splitting `baz` into a separate top-level block). 322 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 323 $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_close'); 324 $this->assertCount(1, $opens, 325 'triple blank line between continuations must not split the list'); 326 $this->assertCount(1, $closes); 327 } 328 329 /** 330 * Regression: blank lines between items (any number) must not split the 331 * list. Spec example 270 stresses two-blank cases. 332 */ 333 public function testMultipleBlanksBetweenItemsKeepsOneList() 334 { 335 $this->P->addMode('gfm_listblock', new GfmListblock()); 336 $this->P->parse("- one\n\n\n- two\n"); 337 338 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'listu_open'); 339 $this->assertCount(1, $opens, 'blank lines between items must stay inside the list'); 340 } 341} 342