1<?php 2 3namespace dokuwiki\test\Parsing; 4 5use dokuwiki\Parsing\ModeRegistry; 6use dokuwiki\Parsing\ParserMode\ModeInterface; 7 8class ModeRegistryTest extends \DokuWikiTest 9{ 10 /** @var ModeRegistry */ 11 private $registry; 12 13 function setUp(): void 14 { 15 parent::setUp(); 16 ModeRegistry::reset(); 17 $this->registry = ModeRegistry::getInstance(); 18 } 19 20 function tearDown(): void 21 { 22 ModeRegistry::reset(); 23 parent::tearDown(); 24 } 25 26 function testSingleton() 27 { 28 $this->assertSame( 29 ModeRegistry::getInstance(), 30 ModeRegistry::getInstance() 31 ); 32 } 33 34 function testResetCreatesFreshInstance() 35 { 36 $first = ModeRegistry::getInstance(); 37 ModeRegistry::reset(); 38 $second = ModeRegistry::getInstance(); 39 $this->assertNotSame($first, $second); 40 } 41 42 function testConstructorPopulatesGlobal() 43 { 44 global $PARSER_MODES; 45 $this->assertIsArray($PARSER_MODES); 46 $this->assertArrayHasKey('container', $PARSER_MODES); 47 $this->assertArrayHasKey('formatting', $PARSER_MODES); 48 $this->assertArrayHasKey('substition', $PARSER_MODES); 49 $this->assertArrayHasKey('protected', $PARSER_MODES); 50 $this->assertArrayHasKey('disabled', $PARSER_MODES); 51 $this->assertArrayHasKey('paragraphs', $PARSER_MODES); 52 $this->assertArrayHasKey('baseonly', $PARSER_MODES); 53 } 54 55 function testGetCategories() 56 { 57 global $PARSER_MODES; 58 $this->assertSame($PARSER_MODES, $this->registry->getCategories()); 59 } 60 61 function testGetModesForSingleCategory() 62 { 63 $modes = $this->registry->getModesForCategories([ModeRegistry::CATEGORY_CONTAINER]); 64 $this->assertContains('listblock', $modes); 65 $this->assertContains('table', $modes); 66 $this->assertContains('gfm_quote', $modes); 67 $this->assertContains('hr', $modes); 68 } 69 70 function testGetModesForMultipleCategories() 71 { 72 $modes = $this->registry->getModesForCategories([ 73 ModeRegistry::CATEGORY_CONTAINER, 74 ModeRegistry::CATEGORY_BASEONLY, 75 ]); 76 $this->assertContains('listblock', $modes); 77 $this->assertContains('header', $modes); 78 } 79 80 function testGetModesForCategoriesDeduplicates() 81 { 82 $modes = $this->registry->getModesForCategories([ 83 ModeRegistry::CATEGORY_CONTAINER, 84 ModeRegistry::CATEGORY_CONTAINER, 85 ]); 86 $counts = array_count_values($modes); 87 foreach ($counts as $count) { 88 $this->assertEquals(1, $count); 89 } 90 } 91 92 function testGetModesForUnknownCategoryReturnsEmpty() 93 { 94 $modes = $this->registry->getModesForCategories(['nonexistent']); 95 $this->assertSame([], $modes); 96 } 97 98 function testRegisterMode() 99 { 100 global $PARSER_MODES; 101 $this->registry->registerMode(ModeRegistry::CATEGORY_CONTAINER, 'testmode'); 102 $this->assertContains('testmode', $PARSER_MODES[ModeRegistry::CATEGORY_CONTAINER]); 103 $this->assertContains( 104 'testmode', 105 $this->registry->getModesForCategories([ModeRegistry::CATEGORY_CONTAINER]) 106 ); 107 } 108 109 function testGlobalModificationsAreVisible() 110 { 111 global $PARSER_MODES; 112 $PARSER_MODES[ModeRegistry::CATEGORY_FORMATTING][] = 'custom_format'; 113 $modes = $this->registry->getModesForCategories([ModeRegistry::CATEGORY_FORMATTING]); 114 $this->assertContains('custom_format', $modes); 115 } 116 117 function testGetModesReturnsSortedArray() 118 { 119 $modes = $this->registry->getModes(); 120 $this->assertNotEmpty($modes); 121 122 $sortValues = array_column($modes, 'sort'); 123 $sorted = $sortValues; 124 sort($sorted); 125 $this->assertSame($sorted, $sortValues); 126 } 127 128 function testGetModesContainsExpectedKeys() 129 { 130 $modes = $this->registry->getModes(); 131 foreach ($modes as $entry) { 132 $this->assertArrayHasKey('sort', $entry); 133 $this->assertArrayHasKey('mode', $entry); 134 $this->assertArrayHasKey('obj', $entry); 135 $this->assertIsInt($entry['sort']); 136 $this->assertIsString($entry['mode']); 137 $this->assertInstanceOf(ModeInterface::class, $entry['obj']); 138 } 139 } 140 141 function testGetModesContainsBuiltinModes() 142 { 143 $modes = $this->registry->getModes(); 144 $modeNames = array_column($modes, 'mode'); 145 $this->assertContains('strong', $modeNames); 146 $this->assertContains('header', $modeNames); 147 $this->assertContains('listblock', $modeNames); 148 $this->assertContains('eol', $modeNames); 149 $this->assertContains('smiley', $modeNames); 150 $this->assertContains('acronym', $modeNames); 151 $this->assertContains('entity', $modeNames); 152 } 153 154 function testSortModes() 155 { 156 $a = ['sort' => 10, 'mode' => 'a']; 157 $b = ['sort' => 20, 'mode' => 'b']; 158 $this->assertLessThan(0, ModeRegistry::sortModes($a, $b)); 159 $this->assertGreaterThan(0, ModeRegistry::sortModes($b, $a)); 160 $this->assertEquals(0, ModeRegistry::sortModes($a, $a)); 161 } 162 163 function testBlockEolModesEmptyByDefault() 164 { 165 $this->assertSame([], $this->registry->getBlockEolModes()); 166 } 167 168 function testRegisterBlockEolMode() 169 { 170 $this->registry->registerBlockEolMode('listblock'); 171 $this->registry->registerBlockEolMode('table'); 172 $this->assertSame(['listblock', 'table'], $this->registry->getBlockEolModes()); 173 } 174 175 function testBlockEolModesResetWithInstance() 176 { 177 $this->registry->registerBlockEolMode('listblock'); 178 ModeRegistry::reset(); 179 $fresh = ModeRegistry::getInstance(); 180 $this->assertSame([], $fresh->getBlockEolModes()); 181 } 182 183 /** 184 * The default syntax setting must produce the exact same mode set as before 185 * the syntax setting was introduced (no-op guarantee). 186 */ 187 function testGetModesDefaultSyntaxMatchesLegacy() 188 { 189 global $conf; 190 $conf['syntax'] = 'dokuwiki'; 191 ModeRegistry::reset(); 192 $registry = ModeRegistry::getInstance(); 193 $modes = $registry->getModes(); 194 $modeNames = array_column($modes, 'mode'); 195 196 // All original built-in modes must be present (with `quote` 197 // replaced by the unified `gfm_quote` that covers both DW and 198 // GFM blockquote syntax). 199 $expected = [ 200 'listblock', 'preformatted', 'notoc', 'nocache', 201 'header', 'table', 'linebreak', 'footnote', 202 'hr', 'unformatted', 'code', 'file', 'gfm_quote', 203 'internallink', 'rss', 'media', 'externallink', 204 'emaillink', 'windowssharelink', 'eol', 205 'strong', 'emphasis', 'underline', 'monospace', 206 'subscript', 'superscript', 'deleted', 207 'smiley', 'acronym', 'entity', 208 ]; 209 foreach ($expected as $mode) { 210 $this->assertContains($mode, $modeNames, "Mode '$mode' missing in dokuwiki syntax setting"); 211 } 212 } 213 214 /** DW-only modes must be absent when syntax is 'markdown' */ 215 function testGetModesDwModesSkippedInMarkdownOnly() 216 { 217 global $conf; 218 $conf['syntax'] = 'markdown'; 219 ModeRegistry::reset(); 220 $registry = ModeRegistry::getInstance(); 221 $modes = $registry->getModes(); 222 $modeNames = array_column($modes, 'mode'); 223 224 $dwOnly = [ 225 'emphasis', 'deleted', 'code', 'header', 'hr', 226 'linebreak', 'internallink', 'media', 'listblock', 'table', 227 'monospace', 'unformatted', 'file', 228 ]; 229 foreach ($dwOnly as $mode) { 230 $this->assertNotContains($mode, $modeNames, "DW mode '$mode' should not load in markdown-only mode"); 231 } 232 } 233 234 /** Always-loaded modes must still be present in markdown-only mode */ 235 function testGetModesAlwaysModesPresentInMarkdownOnly() 236 { 237 global $conf; 238 $conf['syntax'] = 'markdown'; 239 ModeRegistry::reset(); 240 $registry = ModeRegistry::getInstance(); 241 $modes = $registry->getModes(); 242 $modeNames = array_column($modes, 'mode'); 243 244 $always = [ 245 'strong', 'subscript', 'superscript', 246 'footnote', 'eol', 'preformatted', 247 'gfm_quote', 'externallink', 'emaillink', 'windowssharelink', 248 'notoc', 'nocache', 'rss', 249 'smiley', 'acronym', 'entity', 250 ]; 251 foreach ($always as $mode) { 252 $this->assertContains($mode, $modeNames, "Always-loaded mode '$mode' missing in markdown syntax setting"); 253 } 254 } 255 256 /** In mixed modes, DW modes must still load (except those that are 257 * preference-gated — see provideModeLoadingCases for the per-mode rules) */ 258 function testGetModesMixedModesLoadDwModes() 259 { 260 // DW modes that load in both dw+md and md+dw (no MD-side conflict) 261 $dwAlways = [ 262 'emphasis', 'deleted', 'code', 'header', 'hr', 263 'linebreak', 'internallink', 'media', 'table', 264 'monospace', 'unformatted', 'file', 265 ]; 266 267 foreach (['dw+md', 'md+dw'] as $syntax) { 268 global $conf; 269 $conf['syntax'] = $syntax; 270 ModeRegistry::reset(); 271 $registry = ModeRegistry::getInstance(); 272 $modes = $registry->getModes(); 273 $modeNames = array_column($modes, 'mode'); 274 275 foreach ($dwAlways as $mode) { 276 $this->assertContains($mode, $modeNames, "DW mode '$mode' missing in '$syntax' syntax setting"); 277 } 278 } 279 } 280 281 function testAcquireSubParserReturnsParser() 282 { 283 $parser = $this->registry->acquireSubParser(); 284 $this->assertInstanceOf(\dokuwiki\Parsing\Parser::class, $parser); 285 $this->registry->releaseSubParser(); 286 } 287 288 function testAcquireReleaseAcquireReturnsSameInstance() 289 { 290 // Sequential acquire/release pairs on the same key reuse the 291 // pool slot — the second acquire gets the same instance because 292 // it is no longer in use. 293 $first = $this->registry->acquireSubParser(); 294 $this->registry->releaseSubParser(); 295 $second = $this->registry->acquireSubParser(); 296 $this->registry->releaseSubParser(); 297 $this->assertSame($first, $second); 298 } 299 300 function testNestedAcquireReturnsDifferentInstance() 301 { 302 // While one parser is checked out for a given exclusion key, a 303 // second acquire on the same key must hand back a different 304 // instance — the pool grows on demand to support re-entrancy. 305 $outer = $this->registry->acquireSubParser(); 306 $inner = $this->registry->acquireSubParser(); 307 try { 308 $this->assertNotSame($outer, $inner); 309 } finally { 310 $this->registry->releaseSubParser(); 311 $this->registry->releaseSubParser(); 312 } 313 } 314 315 function testWithSubParserReleasesEvenOnException() 316 { 317 try { 318 $this->registry->withSubParser([], [], static function () { 319 throw new \RuntimeException('boom'); 320 }); 321 } catch (\RuntimeException) { 322 // expected 323 } 324 // After the throw, a fresh acquire on the same key must reuse 325 // the pool slot — proving the release ran in the finally clause. 326 $first = $this->registry->acquireSubParser([], []); 327 $this->registry->releaseSubParser([], []); 328 $second = $this->registry->acquireSubParser([], []); 329 $this->registry->releaseSubParser([], []); 330 $this->assertSame($first, $second); 331 } 332 333 function testAcquireSubParserExcludesBaseonlyByDefault() 334 { 335 global $conf; 336 $conf['syntax'] = 'markdown'; 337 ModeRegistry::reset(); 338 $registry = ModeRegistry::getInstance(); 339 340 $parser = $registry->acquireSubParser(); 341 try { 342 $parser->parse("# A header\n"); 343 // gfm_header would emit `header` and `section_open`; both absent here 344 $names = array_column($parser->getHandler()->calls, 0); 345 $this->assertNotContains('header', $names); 346 $this->assertNotContains('section_open', $names); 347 } finally { 348 $registry->releaseSubParser(); 349 } 350 } 351 352 function testAcquireSubParserHonoursCustomExclusions() 353 { 354 global $conf; 355 $conf['syntax'] = 'markdown'; 356 ModeRegistry::reset(); 357 $registry = ModeRegistry::getInstance(); 358 359 // With FORMATTING also excluded, gfm_emphasis is gone and `*foo*` stays literal 360 $excludes = [ 361 ModeRegistry::CATEGORY_BASEONLY, 362 ModeRegistry::CATEGORY_FORMATTING, 363 ]; 364 $parser = $registry->acquireSubParser($excludes); 365 try { 366 $parser->parse("*foo*\n"); 367 $names = array_column($parser->getHandler()->calls, 0); 368 $this->assertNotContains('emphasis_open', $names); 369 } finally { 370 $registry->releaseSubParser($excludes); 371 } 372 } 373 374 function testSubParserPoolResetsWithRegistry() 375 { 376 $first = $this->registry->acquireSubParser(); 377 $this->registry->releaseSubParser(); 378 ModeRegistry::reset(); 379 $second = ModeRegistry::getInstance()->acquireSubParser(); 380 ModeRegistry::getInstance()->releaseSubParser(); 381 $this->assertNotSame($first, $second); 382 } 383 384 function testAcquireSubParserDoesNotClobberMainParserModes() 385 { 386 // Wire the main parser up the way real callers do: addMode() sets 387 // each mode's $Lexer to the main parser's lexer. The sub-parser must 388 // then clone these modes so its own addMode() does not overwrite 389 // those references and break the main parse. 390 $main = $this->registry->getModes(); 391 $mainParser = new \dokuwiki\Parsing\Parser(new \dokuwiki\Parsing\Handler()); 392 foreach ($main as $m) { 393 $mainParser->addMode($m['mode'], $m['obj']); 394 } 395 396 $mainLexers = []; 397 foreach ($main as $m) { 398 $this->assertNotNull( 399 $m['obj']->Lexer ?? null, 400 "precondition: main mode '{$m['mode']}' must have a Lexer attached" 401 ); 402 $mainLexers[$m['mode']] = $m['obj']->Lexer; 403 } 404 405 $this->registry->acquireSubParser(); 406 $this->registry->releaseSubParser(); 407 408 foreach ($main as $m) { 409 $this->assertSame( 410 $mainLexers[$m['mode']], 411 $m['obj']->Lexer ?? null, 412 "sub-parser must not clobber main mode '{$m['mode']}'->Lexer" 413 ); 414 } 415 } 416 417 /** 418 * Verifies that each mode is loaded in the expected combinations of 419 * `$conf['syntax']`. One data set per (mode, syntax) pair. 420 * 421 * Add new mode-gating rules to {@see provideModeLoadingCases} — each 422 * entry lists the four syntax settings and whether the mode should be 423 * loaded there. 424 * 425 * @dataProvider provideModeLoadingCases 426 */ 427 function testModeLoadingBySyntax(string $mode, string $syntax, bool $shouldLoad): void 428 { 429 global $conf; 430 $conf['syntax'] = $syntax; 431 ModeRegistry::reset(); 432 $modeNames = array_column(ModeRegistry::getInstance()->getModes(), 'mode'); 433 434 if ($shouldLoad) { 435 $this->assertContains($mode, $modeNames, "$mode must load in '$syntax'"); 436 } else { 437 $this->assertNotContains($mode, $modeNames, "$mode must NOT load in '$syntax'"); 438 } 439 } 440 441 /** 442 * Data provider for {@see testModeLoadingBySyntax}. 443 * 444 * Declares, per parser mode, whether it should be loaded in each of the 445 * four `$conf['syntax']` settings (`dokuwiki`, `markdown`, `dw+md`, 446 * `md+dw`). Entries are expanded into one data set per (mode, syntax) 447 * pair so PHPUnit reports failures with a specific label. 448 * 449 * Five gating categories are represented: 450 * 451 * - **Always**: loaded unconditionally (no syntax-specific counterpart 452 * or conflict). Covers core formatting, paragraphs, and data-driven 453 * modes (smileys, acronyms, entities). 454 * - **DW-always**: loaded whenever DokuWiki is part of the syntax. Used 455 * for features that have a Markdown counterpart but no delimiter 456 * conflict (e.g. `**bold**` for emphasis). 457 * - **DW-preferred**: loaded only when DokuWiki is the primary syntax. 458 * Used when the delimiter conflicts with a Markdown mode in MD- 459 * preferred settings (e.g. `__` clashes with GFM strong). 460 * - **MD-always**: mirror — loaded whenever Markdown is part of the 461 * syntax. Used when the delimiter has no DokuWiki counterpart (e.g. 462 * `*` for emphasis). 463 * - **MD-preferred**: mirror — loaded only when Markdown is primary. 464 * Used when the delimiter conflicts with a DokuWiki mode in DW- 465 * preferred settings (e.g. `_`, `__`, `___` clash with Underline). 466 * 467 * Add a new line to the `$rules` table to register additional mode- 468 * gating rules. 469 * 470 * @return array<string, array{0: string, 1: string, 2: bool}> map from 471 * test-case label to [mode name, syntax setting, should-load] 472 */ 473 public static function provideModeLoadingCases(): array 474 { 475 $rules = [ 476 // Always-loaded (unconditional — no syntax-specific counterpart) 477 'strong' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 478 'subscript' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 479 'superscript' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 480 'footnote' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 481 'eol' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 482 'preformatted' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 483 'gfm_quote' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 484 'externallink' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 485 'emaillink' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 486 'windowssharelink' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 487 'notoc' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 488 'nocache' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 489 'rss' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 490 'smiley' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 491 'acronym' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 492 'entity' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 493 // DW-always (features with MD counterparts but no delimiter clash) 494 'emphasis' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 495 'deleted' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 496 'code' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 497 'header' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 498 'hr' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 499 'linebreak' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 500 'internallink' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 501 'media' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 502 'listblock' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => false], 503 'table' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 504 'monospace' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 505 'unformatted' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 506 'file' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 507 // MD-always (`*` / `~~` have no conflicting DW counterpart) 508 'gfm_emphasis' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 509 'gfm_emphasis_strong' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 510 'gfm_deleted' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 511 'gfm_backtick_single' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 512 'gfm_backtick_double' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 513 'gfm_header' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 514 'gfm_link' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 515 'gfm_media' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 516 'gfm_code' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 517 'gfm_file' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 518 'gfm_table' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 519 'gfm_escape' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 520 // MD-preferred (`_`, `__`, `___` clash with Underline in DW) 521 'gfm_emphasis_underscore' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 522 'gfm_strong_underscore' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 523 'gfm_emphasis_strong_underscore' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 524 'gfm_listblock' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 525 // DW-preferred (Underline's `__` clashes with GFM strong) 526 'underline' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => false], 527 ]; 528 529 $cases = []; 530 foreach ($rules as $mode => $bySyntax) { 531 foreach ($bySyntax as $syntax => $shouldLoad) { 532 $cases["$mode in $syntax"] = [$mode, $syntax, $shouldLoad]; 533 } 534 } 535 return $cases; 536 } 537 538} 539