xref: /dokuwiki/_test/tests/Parsing/ModeRegistryTest.php (revision b414dba2b10d2f550b453d752c86bb62343bec93)
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('gfm_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'] = 'dw';
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        // and `hr` replaced by the unified `gfm_quote` and `gfm_hr`
198        // that cover both DW and GFM dialects).
199        $expected = [
200            'listblock', 'preformatted', 'notoc', 'nocache',
201            'header', 'table', 'linebreak', 'footnote',
202            'gfm_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 dw syntax setting");
211        }
212    }
213
214    /** DW-only modes must be absent when syntax is 'md' */
215    function testGetModesDwModesSkippedInMarkdownOnly()
216    {
217        global $conf;
218        $conf['syntax'] = 'md';
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',
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 md-only mode");
231        }
232    }
233
234    /** Always-loaded modes must still be present in md-only mode */
235    function testGetModesAlwaysModesPresentInMarkdownOnly()
236    {
237        global $conf;
238        $conf['syntax'] = 'md';
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', 'gfm_hr', '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 md 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',
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'] = 'md';
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'] = 'md';
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 (`dw`, `md`, `dw+md`, `md+dw`).
446     * Entries are expanded into one data set per (mode, syntax) pair so
447     * 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'                         => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
478            'subscript'                      => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
479            'superscript'                    => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
480            'footnote'                       => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
481            'eol'                            => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
482            'preformatted'                   => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
483            'gfm_quote'                      => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
484            'gfm_hr'                         => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
485            'externallink'                   => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
486            'emaillink'                      => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
487            'windowssharelink'               => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
488            'notoc'                          => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
489            'nocache'                        => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
490            'rss'                            => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
491            'smiley'                         => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
492            'acronym'                        => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
493            'entity'                         => ['dw' => true,  'md' => true,  'dw+md' => true,  'md+dw' => true ],
494            // DW-always (features with MD counterparts but no delimiter clash)
495            'emphasis'                       => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
496            'deleted'                        => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
497            'code'                           => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
498            'header'                         => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
499            'linebreak'                      => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
500            'internallink'                   => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
501            'media'                          => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
502            'listblock'                      => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => false],
503            'table'                          => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
504            'monospace'                      => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
505            'unformatted'                    => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
506            'file'                           => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => true ],
507            // MD-always (`*` / `~~` have no conflicting DW counterpart)
508            'gfm_emphasis'                   => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
509            'gfm_emphasis_strong'            => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
510            'gfm_deleted'                    => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
511            'gfm_backtick_single'            => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
512            'gfm_backtick_double'            => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
513            'gfm_header'                     => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
514            'gfm_link'                       => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
515            'gfm_media'                      => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
516            'gfm_code'                       => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
517            'gfm_file'                       => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
518            'gfm_table'                      => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
519            'gfm_escape'                     => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
520            'gfm_linebreak'                  => ['dw' => false, 'md' => true,  'dw+md' => true,  'md+dw' => true ],
521            // MD-preferred (`_`, `__`, `___` clash with Underline in DW)
522            'gfm_emphasis_underscore'        => ['dw' => false, 'md' => true,  'dw+md' => false, 'md+dw' => true ],
523            'gfm_strong_underscore'          => ['dw' => false, 'md' => true,  'dw+md' => false, 'md+dw' => true ],
524            'gfm_emphasis_strong_underscore' => ['dw' => false, 'md' => true,  'dw+md' => false, 'md+dw' => true ],
525            'gfm_listblock'                  => ['dw' => false, 'md' => true,  'dw+md' => false, 'md+dw' => true ],
526            // DW-preferred (Underline's `__` clashes with GFM strong)
527            'underline'                      => ['dw' => true,  'md' => false, 'dw+md' => true,  'md+dw' => false],
528        ];
529
530        $cases = [];
531        foreach ($rules as $mode => $bySyntax) {
532            foreach ($bySyntax as $syntax => $shouldLoad) {
533                $cases["$mode in $syntax"] = [$mode, $syntax, $shouldLoad];
534            }
535        }
536        return $cases;
537    }
538
539}
540