xref: /dokuwiki/inc/Parsing/ModeRegistry.php (revision 9255329a6ed66938c8cd0229ee2fbb1f65786fa2)
1<?php
2
3namespace dokuwiki\Parsing;
4
5use dokuwiki\Extension\PluginInterface;
6use dokuwiki\Extension\SyntaxPlugin;
7use dokuwiki\Parsing\ParserMode\Acronym;
8use dokuwiki\Parsing\ParserMode\AbstractMode;
9use dokuwiki\Parsing\ParserMode\Camelcaselink;
10use dokuwiki\Parsing\ParserMode\Entity;
11use dokuwiki\Parsing\ParserMode\Smiley;
12use dokuwiki\Parsing\Handler;
13use dokuwiki\Parsing\Parser;
14
15/**
16 * The set of parser modes for a single parse, plus the mode taxonomy.
17 *
18 * A ModeRegistry is built once per parse (see p_get_instructions) and
19 * carries the parse-specific state: the active syntax flavour, the
20 * block-EOL bookkeeping, and the sub-parser pool. It is a short-lived
21 * value, not a singleton — two parses in the same request (e.g. a plugin
22 * rendering bundled DW text inside an otherwise-Markdown page) get two
23 * independent registries.
24 *
25 * Three distinct concepts meet here; keep them apart:
26 *
27 *   1. The user's configured syntax PREFERENCE is a setting. Its source
28 *      of truth is $conf['syntax']. Read it only in UI code (editor
29 *      toolbar, admin settings, syntax-preference plugins) — never from
30 *      inside the parser. $conf['syntax'] enters the parser exactly once,
31 *      at the top-level entry point, as this registry's constructor
32 *      argument.
33 *
34 *   2. The active parse's syntax is a PARAMETER of this registry
35 *      (getSyntax / isDwPreferred / isMdPreferred). Every mode descends from
36 *      AbstractMode, which Parser::addMode() injects this registry into, so a
37 *      mode reads it via $this->registry; a plugin handle()/render() reads
38 *      $handler->getModeRegistry(). No code inside inc/Parsing/ reads
39 *      $conf['syntax'] directly.
40 *
41 *   3. The mode TAXONOMY — which mode names belong to which category — is
42 *      owned by this registry instance ($this->categories), seeded from the
43 *      immutable DEFAULT_CATEGORIES and extended with plugin_* entries during
44 *      loadPluginModes(). Core reads it through the instance accessors
45 *      (getModesForCategories / getCategories). The legacy global
46 *      $PARSER_MODES is kept only as a deprecated mirror, published during
47 *      loadPluginModes() for third-party plugins that read the array directly
48 *      and for the bundled info plugin — no core code reads it.
49 */
50class ModeRegistry
51{
52    // Category constants (preserving the historical 'substition' typo)
53    public const CATEGORY_CONTAINER    = 'container';
54    public const CATEGORY_BASEONLY     = 'baseonly';
55    public const CATEGORY_FORMATTING   = 'formatting';
56    public const CATEGORY_SUBSTITUTION = 'substition';
57    public const CATEGORY_PROTECTED    = 'protected';
58    public const CATEGORY_DISABLED     = 'disabled';
59    public const CATEGORY_PARAGRAPHS   = 'paragraphs';
60
61    /**
62     * The built-in mode taxonomy: category => list of mode names.
63     *
64     * Immutable defaults. Each registry starts from a copy of this in
65     * $this->categories; loadPluginModes() then merges plugin_* entries into
66     * that copy. Being a const, it is never mutated and so needs no resetting
67     * between parses or tests.
68     */
69    protected const DEFAULT_CATEGORIES = [
70        self::CATEGORY_CONTAINER  => ['listblock', 'table', 'gfm_listblock', 'gfm_table', 'gfm_quote', 'gfm_hr'],
71        self::CATEGORY_BASEONLY   => ['header', 'gfm_header'],
72        self::CATEGORY_FORMATTING => [
73            'strong', 'emphasis', 'underline', 'monospace',
74            'subscript', 'superscript', 'deleted', 'footnote',
75            'gfm_emphasis', 'gfm_emphasis_underscore', 'gfm_strong_underscore',
76            'gfm_emphasis_strong', 'gfm_emphasis_strong_underscore',
77            'gfm_deleted', 'gfm_backtick_single', 'gfm_backtick_double',
78        ],
79        self::CATEGORY_SUBSTITUTION => [
80            'acronym', 'smiley', 'wordblock', 'entity',
81            'camelcaselink', 'internallink', 'media', 'externallink',
82            'linebreak', 'emaillink', 'windowssharelink', 'filelink',
83            'notoc', 'nocache', 'multiplyentity', 'quotes', 'rss',
84            'gfm_link', 'gfm_media', 'gfm_escape', 'gfm_linebreak',
85            'gfm_html_entity',
86        ],
87        self::CATEGORY_PROTECTED  => ['preformatted', 'code', 'file', 'gfm_code', 'gfm_file'],
88        self::CATEGORY_DISABLED   => ['unformatted'],
89        self::CATEGORY_PARAGRAPHS => ['eol'],
90    ];
91
92    /** @var array{sort: int, mode: string, obj: AbstractMode}[]|null */
93    protected ?array $modes = null;
94
95    /** @var array<string, array{parsers: Parser[], inUse: int}> Pool of sub-parsers per exclusion-set identifier. */
96    protected array $subParsers = [];
97
98    /** @var string[] Modes that handle their own line endings (skip EOL connection) */
99    protected array $blockEolModes = [];
100
101    /** @var string the syntax flavour this parse runs under (dw, md, dw+md, md+dw) */
102    protected string $syntax;
103
104    /** @var array<string, string[]> this parse's mode taxonomy (defaults + plugin modes) */
105    protected array $categories = self::DEFAULT_CATEGORIES;
106
107    /**
108     * @param string $syntax the syntax flavour for this parse: one of
109     *     'dw', 'md', 'dw+md', 'md+dw'. This is the active-parse parameter,
110     *     not the user preference — see the class docblock.
111     */
112    public function __construct(string $syntax)
113    {
114        $this->syntax = $syntax;
115    }
116
117    /**
118     * The syntax flavour of this parse.
119     *
120     * @return string one of 'dw', 'md', 'dw+md', 'md+dw'
121     */
122    public function getSyntax(): string
123    {
124        return $this->syntax;
125    }
126
127    /**
128     * Get all mode names in the given categories of this parse's taxonomy.
129     *
130     * @param string[] $categories One or more CATEGORY_* constants
131     * @return string[] Unique list of mode names
132     */
133    public function getModesForCategories(array $categories): array
134    {
135        $modes = [];
136        foreach ($categories as $cat) {
137            if (isset($this->categories[$cat])) {
138                $modes = array_merge($modes, $this->categories[$cat]);
139            }
140        }
141        return array_unique($modes);
142    }
143
144    /**
145     * Get this parse's raw category map.
146     *
147     * Returns the taxonomy as loaded so far. The built-in categories are
148     * present from construction, but the plugin_* mode names are only merged in
149     * once getModes() has run its plugin loading. Call getModes() first if you
150     * need the complete taxonomy including syntax plugins.
151     *
152     * @return array<string, string[]> Category name => list of mode names
153     */
154    public function getCategories(): array
155    {
156        return $this->categories;
157    }
158
159    /**
160     * Register a mode in a category of this parse's taxonomy.
161     *
162     * @param string $category One of the CATEGORY_* constants
163     * @param string $modeName The mode name to register
164     * @return void
165     */
166    public function registerMode(string $category, string $modeName): void
167    {
168        $this->categories[$category][] = $modeName;
169    }
170
171    /**
172     * Register a mode that handles its own line endings.
173     * Modes registered here will be skipped by Eol's connectTo().
174     *
175     * @param string $mode The mode name
176     * @return void
177     */
178    public function registerBlockEolMode(string $mode): void
179    {
180        $this->blockEolModes[] = $mode;
181    }
182
183    /**
184     * Get all modes that handle their own line endings.
185     *
186     * @return string[]
187     */
188    public function getBlockEolModes(): array
189    {
190        return $this->blockEolModes;
191    }
192
193    /**
194     * Whether DokuWiki is the preferred syntax (`dw` or `dw+md`).
195     *
196     * Modes that have to choose between DW-flavored and MD-flavored
197     * behavior at runtime read this flag. Compare with isMdPreferred()
198     * — exactly one of the two is true for any valid `$conf['syntax']`
199     * setting.
200     */
201    public function isDwPreferred(): bool
202    {
203        return in_array($this->syntax, ['dw', 'dw+md'], true);
204    }
205
206    /**
207     * Whether Markdown is the preferred syntax (`md` or `md+dw`).
208     */
209    public function isMdPreferred(): bool
210    {
211        return in_array($this->syntax, ['md', 'md+dw'], true);
212    }
213
214    /**
215     * Get all parser modes, fully instantiated and sorted by priority.
216     *
217     * This includes syntax plugins, built-in modes, formatting modes, and
218     * data-driven modes (smileys, acronyms, entities). Built once per
219     * registry and memoised for that registry's (short) lifetime.
220     *
221     * @return array[] Each entry is ['sort' => int, 'mode' => string, 'obj' => AbstractMode]
222     */
223    public function getModes(): array
224    {
225        if ($this->modes !== null) {
226            return $this->modes;
227        }
228
229        $this->modes = [];
230        $loadDw = in_array($this->syntax, ['dw', 'dw+md', 'md+dw'], true);
231        $loadMd = in_array($this->syntax, ['md', 'dw+md', 'md+dw'], true);
232
233        $this->loadPluginModes();
234        $this->loadAlwaysModes();
235        if ($loadDw) $this->loadDokuWikiModes();
236        if ($loadMd) $this->loadMarkdownModes();
237        $this->loadDataModes();
238
239        usort($this->modes, self::sortModes(...));
240        return $this->modes;
241    }
242
243    //region Sub-parser pool
244
245    /**
246     * Acquire a sub-parser for the given exclusion set.
247     *
248     * The registry maintains a pool of sub-parsers per exclusion key.
249     * Each acquire returns the next free instance from that pool;
250     * releaseSubParser must be called (with the same exclusion set)
251     * once the caller is done. If all instances in a pool are already
252     * checked out — re-entrancy on the same key — a fresh instance is
253     * built and appended to the pool. Real-world nesting for any one
254     * mode tops out at a handful of levels, so pool growth is bounded.
255     *
256     * Use this primitive when the caller wants to hold the parser
257     * across multiple parse() calls (e.g. iterating over list items).
258     * For single-shot use, prefer {@see withSubParser} so release is
259     * automatic.
260     *
261     * The returned Parser is shared infrastructure: callers must call
262     * `$parser->getHandler()->reset()` before each parse() to avoid
263     * inheriting state from a previous use.
264     *
265     * @param string[] $excludeCategories CATEGORY_* constants whose modes should be excluded
266     * @param string[] $excludeModes specific mode names to exclude in addition to category-based exclusions
267     */
268    public function acquireSubParser(
269        array $excludeCategories = [self::CATEGORY_BASEONLY],
270        array $excludeModes = []
271    ): Parser {
272        $key = $this->subParserKey($excludeCategories, $excludeModes);
273        $entry = $this->subParsers[$key] ?? ['parsers' => [], 'inUse' => 0];
274
275        if ($entry['inUse'] >= count($entry['parsers'])) {
276            $entry['parsers'][] = $this->buildSubParser($excludeCategories, $excludeModes);
277        }
278        $parser = $entry['parsers'][$entry['inUse']];
279        $entry['inUse']++;
280        $this->subParsers[$key] = $entry;
281        return $parser;
282    }
283
284    /**
285     * Release a previously-acquired sub-parser back to its pool.
286     *
287     * Should be paired with a prior {@see acquireSubParser} call for
288     * the same exclusion set. Callers must release in LIFO order with
289     * respect to other acquires on the same key — the implementation
290     * does not enforce LIFO, but out-of-order release would silently
291     * hand the same parser to two callers, so the caller is responsible
292     * for the discipline. Wrapping each acquire/release pair in a
293     * single try/finally (or using {@see withSubParser}) makes the
294     * ordering correct by construction.
295     *
296     * Throws if no acquire is outstanding for the given key — that
297     * indicates an acquire/release imbalance bug in the caller.
298     *
299     * @param string[] $excludeCategories
300     * @param string[] $excludeModes
301     * @throws \RuntimeException on release without a matching acquire
302     */
303    public function releaseSubParser(
304        array $excludeCategories = [self::CATEGORY_BASEONLY],
305        array $excludeModes = []
306    ): void {
307        $key = $this->subParserKey($excludeCategories, $excludeModes);
308        if (!isset($this->subParsers[$key]) || $this->subParsers[$key]['inUse'] <= 0) {
309            throw new \RuntimeException(
310                "releaseSubParser called without matching acquireSubParser for key '$key'"
311            );
312        }
313        $this->subParsers[$key]['inUse']--;
314    }
315
316    /**
317     * Run a callback with an exclusively-held sub-parser.
318     *
319     * Convenience wrapper around acquire/release. The parser is checked
320     * out for the duration of the callback, then released even if the
321     * callback throws. Preferred shape for single-shot sub-parses
322     * (one parse() call per acquire); use the explicit pair for cases
323     * where the parser is held across a loop or other longer scope.
324     *
325     * @template T
326     * @param string[] $excludeCategories
327     * @param string[] $excludeModes
328     * @param callable(Parser): T $fn
329     * @return T
330     */
331    public function withSubParser(
332        array $excludeCategories,
333        array $excludeModes,
334        callable $fn
335    ) {
336        $parser = $this->acquireSubParser($excludeCategories, $excludeModes);
337        try {
338            return $fn($parser);
339        } finally {
340            $this->releaseSubParser($excludeCategories, $excludeModes);
341        }
342    }
343
344    /**
345     * Build a fresh Parser preconfigured with every active mode except
346     * the ones excluded.
347     *
348     * Mode objects are cloned before being attached so that
349     * Parser::addMode() pointing each mode at the sub-parser's lexer does not
350     * clobber the main parser's mode references.
351     *
352     * @param string[] $excludeCategories
353     * @param string[] $excludeModes
354     */
355    protected function buildSubParser(
356        array $excludeCategories,
357        array $excludeModes
358    ): Parser {
359        $categories = $this->getCategories();
360        $excluded = $excludeModes;
361        foreach ($excludeCategories as $cat) {
362            $excluded = array_merge($excluded, $categories[$cat] ?? []);
363        }
364
365        $parser = new Parser(new Handler($this), $this);
366        foreach ($this->getModes() as $m) {
367            if (in_array($m['mode'], $excluded, true)) continue;
368            // Mode objects expose a single $Lexer slot which Parser::addMode()
369            // overwrites at registration time. The objects in $this->modes are
370            // already attached to the main parser's lexer; reusing them here
371            // would clobber that reference and break the main parse. Clone so
372            // the sub-parser gets its own copy with its own $Lexer slot.
373            $parser->addMode($m['mode'], clone $m['obj']);
374        }
375        return $parser;
376    }
377
378    /**
379     * Build the cache key used to identify a sub-parser exclusion set.
380     */
381    protected function subParserKey(array $excludeCategories, array $excludeModes): string
382    {
383        return implode(',', $excludeCategories) . '|' . implode(',', $excludeModes);
384    }
385
386    //endregion
387
388    //region Mode loading
389
390    /**
391     * Load syntax plugin modes and register them in their categories.
392     */
393    protected function loadPluginModes(): void
394    {
395        global $PARSER_MODES;
396
397        // Publish this parse's taxonomy into the deprecated global mirror right
398        // before plugins load — third-party plugins read $PARSER_MODES directly
399        // (often from their constructor) and the info plugin reads it at render.
400        // Core never reads the mirror; it reads $this->categories. The mirror is
401        // kept in sync incrementally below so a plugin loaded later sees the
402        // modes registered by plugins loaded before it (historical behaviour).
403        // @deprecated reading $PARSER_MODES directly — use the ModeRegistry API.
404        $PARSER_MODES = $this->categories;
405
406        $plugins = plugin_list('syntax');
407        foreach ($plugins as $p) {
408            $obj = plugin_load('syntax', $p);
409            if (!$obj instanceof PluginInterface) continue;
410            $this->categories[$obj->getType()][] = "plugin_$p";
411            $PARSER_MODES[$obj->getType()][] = "plugin_$p";
412            $this->modes[] = [
413                'sort' => $obj->getSort(),
414                'mode' => "plugin_$p",
415                'obj'  => $obj,
416            ];
417            unset($obj);
418        }
419    }
420
421    /**
422     * Load modes that have no equivalent in the other syntax.
423     * These are always active regardless of the syntax setting.
424     */
425    protected function loadAlwaysModes(): void
426    {
427        global $conf;
428
429        $modes = [
430            'strong', 'subscript', 'superscript',
431            'footnote', 'eol', 'preformatted',
432            'gfm_quote', 'gfm_hr',
433            'externallink', 'emaillink', 'windowssharelink',
434            'notoc', 'nocache', 'rss',
435        ];
436
437        if ($conf['typography']) {
438            $modes[] = 'quotes';
439            $modes[] = 'multiplyentity';
440        }
441
442        $this->instantiateModes($modes);
443    }
444
445    /**
446     * Load DokuWiki-specific modes for features that also exist in Markdown.
447     * Skipped when syntax is 'md'.
448     */
449    protected function loadDokuWikiModes(): void
450    {
451        $modes = [
452            'emphasis', 'deleted', 'code', 'header',
453            'linebreak', 'internallink', 'media', 'table',
454            'monospace', 'unformatted', 'file',
455        ];
456
457        // Underline only loads when DokuWiki is preferred. In MD-preferred
458        // modes, `__` means strong (via gfm_strong_underscore) and loading
459        // Underline here would conflict.
460        //
461        // Listblock only loads when DokuWiki is preferred. In MD-preferred
462        // modes, GfmListblock owns the `-`/`*`/`+` markers and zero-indent
463        // top-level items, which conflicts with DokuWiki's required-2-space-
464        // indent list model.
465        if ($this->isDwPreferred()) {
466            $modes[] = 'underline';
467            $modes[] = 'listblock';
468        }
469
470        $this->instantiateModes($modes);
471    }
472
473    /**
474     * Load Markdown-specific modes for features that also exist in DokuWiki.
475     * Skipped when syntax is 'dw'.
476     */
477    protected function loadMarkdownModes(): void
478    {
479        $modes = [
480            'gfm_escape', 'gfm_linebreak', 'gfm_html_entity',
481            'gfm_emphasis', 'gfm_emphasis_strong', 'gfm_deleted',
482            'gfm_backtick_single', 'gfm_backtick_double',
483            'gfm_header', 'gfm_link', 'gfm_media',
484            'gfm_code', 'gfm_file', 'gfm_table',
485        ];
486
487        // Underscore-based emphasis and strong only load when Markdown is
488        // preferred. In DW-preferred modes, `__` means underline and loading
489        // these would conflict.
490        //
491        // GfmListblock only loads when Markdown is preferred. In DW-preferred
492        // modes, the DokuWiki Listblock owns the `-`/`*` markers (with the
493        // 2-space indent rule); the two list models cannot co-exist.
494        if ($this->isMdPreferred()) {
495            $modes[] = 'gfm_emphasis_underscore';
496            $modes[] = 'gfm_strong_underscore';
497            $modes[] = 'gfm_emphasis_strong_underscore';
498            $modes[] = 'gfm_listblock';
499        }
500
501        $this->instantiateModes($modes);
502    }
503
504    /**
505     * Load data-driven modes that require constructor arguments
506     * (smileys, acronyms, entities) and optional config-gated modes.
507     */
508    protected function loadDataModes(): void
509    {
510        global $conf;
511
512        $obj = new Smiley(array_keys(getSmileys()));
513        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'smiley', 'obj' => $obj];
514
515        $obj = new Acronym(array_keys(getAcronyms()));
516        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'acronym', 'obj' => $obj];
517
518        $obj = new Entity(array_keys(getEntities()));
519        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'entity', 'obj' => $obj];
520
521        if (!empty($conf['camelcase'])) {
522            $obj = new Camelcaselink();
523            $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'camelcaselink', 'obj' => $obj];
524        }
525    }
526
527    /**
528     * Instantiate mode classes by name and add them to the mode list.
529     *
530     * Mode names are split on `_` and each segment is PascalCased to form the
531     * class name (e.g. `gfm_emphasis_underscore` → `GfmEmphasisUnderscore`,
532     * `internallink` → `Internallink`, `strong` → `Strong`).
533     *
534     * @param string[] $modeNames
535     */
536    protected function instantiateModes(array $modeNames): void
537    {
538        foreach ($modeNames as $mode) {
539            $class = implode('', array_map(ucfirst(...), explode('_', $mode))); // snake_case to PascalCase
540            $class = 'dokuwiki\\Parsing\\ParserMode\\' . $class; // prepend namespace
541            $obj = new $class();
542            $this->modes[] = [
543                'sort' => $obj->getSort(),
544                'mode' => $mode,
545                'obj'  => $obj,
546            ];
547        }
548    }
549
550    //endregion
551
552    /**
553     * Callback function for usort
554     *
555     * @param array $a
556     * @param array $b
557     * @return int
558     */
559    public static function sortModes(array $a, array $b): int
560    {
561        return $a['sort'] <=> $b['sort'];
562    }
563}
564