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