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('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 197 $expected = [ 198 'listblock', 'preformatted', 'notoc', 'nocache', 199 'header', 'table', 'linebreak', 'footnote', 200 'hr', 'unformatted', 'code', 'file', 'quote', 201 'internallink', 'rss', 'media', 'externallink', 202 'emaillink', 'windowssharelink', 'eol', 203 'strong', 'emphasis', 'underline', 'monospace', 204 'subscript', 'superscript', 'deleted', 205 'smiley', 'acronym', 'entity', 206 ]; 207 foreach ($expected as $mode) { 208 $this->assertContains($mode, $modeNames, "Mode '$mode' missing in dokuwiki syntax setting"); 209 } 210 } 211 212 /** DW-only modes must be absent when syntax is 'markdown' */ 213 function testGetModesDwModesSkippedInMarkdownOnly() 214 { 215 global $conf; 216 $conf['syntax'] = 'markdown'; 217 ModeRegistry::reset(); 218 $registry = ModeRegistry::getInstance(); 219 $modes = $registry->getModes(); 220 $modeNames = array_column($modes, 'mode'); 221 222 $dwOnly = [ 223 'emphasis', 'deleted', 'code', 'header', 'hr', 224 'linebreak', 'internallink', 'media', 'listblock', 'table', 225 'monospace', 'unformatted', 'file', 226 ]; 227 foreach ($dwOnly as $mode) { 228 $this->assertNotContains($mode, $modeNames, "DW mode '$mode' should not load in markdown-only mode"); 229 } 230 } 231 232 /** Always-loaded modes must still be present in markdown-only mode */ 233 function testGetModesAlwaysModesPresentInMarkdownOnly() 234 { 235 global $conf; 236 $conf['syntax'] = 'markdown'; 237 ModeRegistry::reset(); 238 $registry = ModeRegistry::getInstance(); 239 $modes = $registry->getModes(); 240 $modeNames = array_column($modes, 'mode'); 241 242 $always = [ 243 'strong', 'subscript', 'superscript', 244 'footnote', 'eol', 'preformatted', 245 'quote', 'externallink', 'emaillink', 'windowssharelink', 246 'notoc', 'nocache', 'rss', 247 'smiley', 'acronym', 'entity', 248 ]; 249 foreach ($always as $mode) { 250 $this->assertContains($mode, $modeNames, "Always-loaded mode '$mode' missing in markdown syntax setting"); 251 } 252 } 253 254 /** In mixed modes, DW modes must still load (except those that are 255 * preference-gated — see provideModeLoadingCases for the per-mode rules) */ 256 function testGetModesMixedModesLoadDwModes() 257 { 258 // DW modes that load in both dw+md and md+dw (no MD-side conflict) 259 $dwAlways = [ 260 'emphasis', 'deleted', 'code', 'header', 'hr', 261 'linebreak', 'internallink', 'media', 'table', 262 'monospace', 'unformatted', 'file', 263 ]; 264 265 foreach (['dw+md', 'md+dw'] as $syntax) { 266 global $conf; 267 $conf['syntax'] = $syntax; 268 ModeRegistry::reset(); 269 $registry = ModeRegistry::getInstance(); 270 $modes = $registry->getModes(); 271 $modeNames = array_column($modes, 'mode'); 272 273 foreach ($dwAlways as $mode) { 274 $this->assertContains($mode, $modeNames, "DW mode '$mode' missing in '$syntax' syntax setting"); 275 } 276 } 277 } 278 279 function testGetSubParserReturnsParser() 280 { 281 $parser = $this->registry->getSubParser(); 282 $this->assertInstanceOf(\dokuwiki\Parsing\Parser::class, $parser); 283 } 284 285 function testGetSubParserCachesAcrossCalls() 286 { 287 $first = $this->registry->getSubParser(); 288 $second = $this->registry->getSubParser(); 289 $this->assertSame($first, $second); 290 } 291 292 function testGetSubParserExcludesBaseonlyByDefault() 293 { 294 global $conf; 295 $conf['syntax'] = 'markdown'; 296 ModeRegistry::reset(); 297 $registry = ModeRegistry::getInstance(); 298 299 $parser = $registry->getSubParser(); 300 $parser->parse("# A header\n"); 301 // gfm_header would emit `header` and `section_open`; both absent here 302 $names = array_column($parser->getHandler()->calls, 0); 303 $this->assertNotContains('header', $names); 304 $this->assertNotContains('section_open', $names); 305 } 306 307 function testGetSubParserHonoursCustomExclusions() 308 { 309 global $conf; 310 $conf['syntax'] = 'markdown'; 311 ModeRegistry::reset(); 312 $registry = ModeRegistry::getInstance(); 313 314 // With FORMATTING also excluded, gfm_emphasis is gone and `*foo*` stays literal 315 $parser = $registry->getSubParser([ 316 ModeRegistry::CATEGORY_BASEONLY, 317 ModeRegistry::CATEGORY_FORMATTING, 318 ]); 319 $parser->parse("*foo*\n"); 320 $names = array_column($parser->getHandler()->calls, 0); 321 $this->assertNotContains('emphasis_open', $names); 322 } 323 324 function testGetSubParserResetsWithRegistry() 325 { 326 $first = $this->registry->getSubParser(); 327 ModeRegistry::reset(); 328 $second = ModeRegistry::getInstance()->getSubParser(); 329 $this->assertNotSame($first, $second); 330 } 331 332 function testGetSubParserDoesNotClobberMainParserModes() 333 { 334 // Wire the main parser up the way real callers do: addMode() sets 335 // each mode's $Lexer to the main parser's lexer. The sub-parser must 336 // then clone these modes so its own addMode() does not overwrite 337 // those references and break the main parse. 338 $main = $this->registry->getModes(); 339 $mainParser = new \dokuwiki\Parsing\Parser(new \dokuwiki\Parsing\Handler()); 340 foreach ($main as $m) { 341 $mainParser->addMode($m['mode'], $m['obj']); 342 } 343 344 $mainLexers = []; 345 foreach ($main as $m) { 346 $this->assertNotNull( 347 $m['obj']->Lexer ?? null, 348 "precondition: main mode '{$m['mode']}' must have a Lexer attached" 349 ); 350 $mainLexers[$m['mode']] = $m['obj']->Lexer; 351 } 352 353 $this->registry->getSubParser(); 354 355 foreach ($main as $m) { 356 $this->assertSame( 357 $mainLexers[$m['mode']], 358 $m['obj']->Lexer ?? null, 359 "sub-parser must not clobber main mode '{$m['mode']}'->Lexer" 360 ); 361 } 362 } 363 364 /** 365 * Verifies that each mode is loaded in the expected combinations of 366 * `$conf['syntax']`. One data set per (mode, syntax) pair. 367 * 368 * Add new mode-gating rules to {@see provideModeLoadingCases} — each 369 * entry lists the four syntax settings and whether the mode should be 370 * loaded there. 371 * 372 * @dataProvider provideModeLoadingCases 373 */ 374 function testModeLoadingBySyntax(string $mode, string $syntax, bool $shouldLoad): void 375 { 376 global $conf; 377 $conf['syntax'] = $syntax; 378 ModeRegistry::reset(); 379 $modeNames = array_column(ModeRegistry::getInstance()->getModes(), 'mode'); 380 381 if ($shouldLoad) { 382 $this->assertContains($mode, $modeNames, "$mode must load in '$syntax'"); 383 } else { 384 $this->assertNotContains($mode, $modeNames, "$mode must NOT load in '$syntax'"); 385 } 386 } 387 388 /** 389 * Data provider for {@see testModeLoadingBySyntax}. 390 * 391 * Declares, per parser mode, whether it should be loaded in each of the 392 * four `$conf['syntax']` settings (`dokuwiki`, `markdown`, `dw+md`, 393 * `md+dw`). Entries are expanded into one data set per (mode, syntax) 394 * pair so PHPUnit reports failures with a specific label. 395 * 396 * Five gating categories are represented: 397 * 398 * - **Always**: loaded unconditionally (no syntax-specific counterpart 399 * or conflict). Covers core formatting, paragraphs, and data-driven 400 * modes (smileys, acronyms, entities). 401 * - **DW-always**: loaded whenever DokuWiki is part of the syntax. Used 402 * for features that have a Markdown counterpart but no delimiter 403 * conflict (e.g. `**bold**` for emphasis). 404 * - **DW-preferred**: loaded only when DokuWiki is the primary syntax. 405 * Used when the delimiter conflicts with a Markdown mode in MD- 406 * preferred settings (e.g. `__` clashes with GFM strong). 407 * - **MD-always**: mirror — loaded whenever Markdown is part of the 408 * syntax. Used when the delimiter has no DokuWiki counterpart (e.g. 409 * `*` for emphasis). 410 * - **MD-preferred**: mirror — loaded only when Markdown is primary. 411 * Used when the delimiter conflicts with a DokuWiki mode in DW- 412 * preferred settings (e.g. `_`, `__`, `___` clash with Underline). 413 * 414 * Add a new line to the `$rules` table to register additional mode- 415 * gating rules. 416 * 417 * @return array<string, array{0: string, 1: string, 2: bool}> map from 418 * test-case label to [mode name, syntax setting, should-load] 419 */ 420 public static function provideModeLoadingCases(): array 421 { 422 $rules = [ 423 // Always-loaded (unconditional — no syntax-specific counterpart) 424 'strong' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 425 'subscript' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 426 'superscript' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 427 'footnote' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 428 'eol' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 429 'preformatted' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 430 'quote' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 431 'externallink' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 432 'emaillink' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 433 'windowssharelink' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 434 'notoc' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 435 'nocache' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 436 'rss' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 437 'smiley' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 438 'acronym' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 439 'entity' => ['dokuwiki' => true, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 440 // DW-always (features with MD counterparts but no delimiter clash) 441 'emphasis' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 442 'deleted' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 443 'code' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 444 'header' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 445 'hr' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 446 'linebreak' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 447 'internallink' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 448 'media' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 449 'listblock' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => false], 450 'table' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 451 'monospace' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 452 'unformatted' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 453 'file' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => true ], 454 // MD-always (`*` / `~~` have no conflicting DW counterpart) 455 'gfm_emphasis' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 456 'gfm_emphasis_strong' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 457 'gfm_deleted' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 458 'gfm_backtick_single' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 459 'gfm_backtick_double' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 460 'gfm_header' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 461 'gfm_link' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 462 'gfm_media' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 463 'gfm_code' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 464 'gfm_file' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => true, 'md+dw' => true ], 465 // MD-preferred (`_`, `__`, `___` clash with Underline in DW) 466 'gfm_emphasis_underscore' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 467 'gfm_strong_underscore' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 468 'gfm_emphasis_strong_underscore' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 469 'gfm_listblock' => ['dokuwiki' => false, 'markdown' => true, 'dw+md' => false, 'md+dw' => true ], 470 // DW-preferred (Underline's `__` clashes with GFM strong) 471 'underline' => ['dokuwiki' => true, 'markdown' => false, 'dw+md' => true, 'md+dw' => false], 472 ]; 473 474 $cases = []; 475 foreach ($rules as $mode => $bySyntax) { 476 foreach ($bySyntax as $syntax => $shouldLoad) { 477 $cases["$mode in $syntax"] = [$mode, $syntax, $shouldLoad]; 478 } 479 } 480 return $cases; 481 } 482 483} 484