xref: /dokuwiki/_test/tests/Parsing/ModeRegistryTest.php (revision 3dabe4e0a0d70b79a7aced8ac8a36d4b37a61024)
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            'gfm_table'                      => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
466            // MD-preferred (`_`, `__`, `___` clash with Underline in DW)
467            'gfm_emphasis_underscore'        => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
468            'gfm_strong_underscore'          => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
469            'gfm_emphasis_strong_underscore' => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
470            'gfm_listblock'                  => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
471            // DW-preferred (Underline's `__` clashes with GFM strong)
472            'underline'                      => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => false],
473        ];
474
475        $cases = [];
476        foreach ($rules as $mode => $bySyntax) {
477            foreach ($bySyntax as $syntax => $shouldLoad) {
478                $cases["$mode in $syntax"] = [$mode, $syntax, $shouldLoad];
479            }
480        }
481        return $cases;
482    }
483
484}
485