1<?php 2 3/** 4 * Lexer adapted from Simple Test: http://sourceforge.net/projects/simpletest/ 5 * For an intro to the Lexer see: 6 * https://web.archive.org/web/20120125041816/http://www.phppatterns.com/docs/develop/simple_test_lexer_notes 7 * 8 * @author Marcus Baker http://www.lastcraft.com 9 */ 10 11namespace dokuwiki\Parsing\Lexer; 12 13/** 14 * Compounded regular expression. 15 * 16 * Any of the contained patterns could match and when one does it's label is returned. 17 */ 18class ParallelRegex 19{ 20 /** @var string[] patterns to match */ 21 protected $patterns = []; 22 /** @var string[] labels for above patterns */ 23 protected $labels = []; 24 /** @var string the compound regex matching all patterns */ 25 protected $regex; 26 /** @var bool case sensitive matching? */ 27 protected $case; 28 29 /** 30 * Constructor. Starts with no patterns. 31 * 32 * @param boolean $case True for case sensitive, false 33 * for insensitive. 34 */ 35 public function __construct($case) 36 { 37 $this->case = $case; 38 } 39 40 /** 41 * Adds a pattern with an optional label. 42 * 43 * @param mixed $pattern Perl style regex. Must be UTF-8 44 * encoded. If its a string, the (, ) 45 * lose their meaning unless they 46 * form part of a lookahead or 47 * lookbehind assertation. 48 * @param bool|string $label Label of regex to be returned 49 * on a match. Label must be ASCII 50 */ 51 public function addPattern($pattern, $label = true) 52 { 53 $count = count($this->patterns); 54 $this->patterns[$count] = $pattern; 55 $this->labels[$count] = $label; 56 $this->regex = null; 57 } 58 59 /** 60 * Lists the registered patterns together with their labels, in 61 * registration order. 62 * 63 * The label tells how the lexer treats a match: true for a plain 64 * pattern consumed in place, Lexer::MODE_EXIT for an exit pattern, 65 * a mode name prefixed with Lexer::MODE_SPECIAL_PREFIX for a special 66 * pattern, and a bare mode name for an entry pattern into that mode. 67 * 68 * @return array[] list of ['pattern' => string, 'label' => bool|string] 69 */ 70 public function getPatterns() 71 { 72 return array_map( 73 static fn($pattern, $label) => ['pattern' => $pattern, 'label' => $label], 74 $this->patterns, 75 $this->labels 76 ); 77 } 78 79 /** 80 * Attempts to split the string against all patterns at once. 81 * 82 * When `$offset` is non-zero, the match begins at that byte position in 83 * `$subject`, but the full subject is still passed to PCRE so any 84 * lookbehinds in the patterns can see characters before the offset. 85 * This is essential for inline-formatting closers like 86 * `(?<=[^\s])\*\*`, whose preceding non-whitespace character may have 87 * been consumed as part of a previous token (e.g. a `[[link]]`). 88 * 89 * @param string $subject String to match against. 90 * @param array $split The split result: array containing, pre-match, match & post-match strings 91 * @param int $offset Byte offset into `$subject` at which to start matching. 92 * @return boolean True on success. 93 * 94 * @author Christopher Smith <chris@jalakai.co.uk> 95 */ 96 public function split($subject, &$split, $offset = 0) 97 { 98 if (count($this->patterns) == 0) { 99 return false; 100 } 101 102 if (! preg_match($this->getCompoundedRegex(), $subject, $matches, PREG_OFFSET_CAPTURE, $offset)) { 103 if (function_exists('preg_last_error')) { 104 $err = preg_last_error(); 105 switch ($err) { 106 case PREG_BACKTRACK_LIMIT_ERROR: 107 msg('A PCRE backtrack error occured. Try to increase the pcre.backtrack_limit in php.ini', -1); 108 break; 109 case PREG_JIT_STACKLIMIT_ERROR: 110 msg('A PCRE JIT stacklimit error occured. Try to disable pcre.jit in php.ini', -1); 111 break; 112 case PREG_RECURSION_LIMIT_ERROR: 113 msg('A PCRE recursion error occured. Try to increase the pcre.recursion_limit in php.ini', -1); 114 break; 115 case PREG_BAD_UTF8_ERROR: 116 msg('A PCRE UTF-8 error occured. This might be caused by a faulty plugin', -1); 117 break; 118 case PREG_INTERNAL_ERROR: 119 msg('A PCRE internal error occured. This might be caused by a faulty plugin', -1); 120 break; 121 } 122 } 123 124 $split = [substr($subject, $offset), "", ""]; 125 return false; 126 } 127 128 $idx = count($matches) - 2; 129 $matchText = (string) $matches[0][0]; 130 // Byte offset from PREG_OFFSET_CAPTURE; cast makes the int type 131 // obvious to static analysers that don't model the flag. 132 $matchStart = (int) $matches[0][1]; 133 $pre = substr($subject, $offset, $matchStart - $offset); 134 $post = substr($subject, $matchStart + strlen($matchText)); 135 $split = [$pre, $matchText, $post]; 136 137 return $this->labels[$idx] ?? true; 138 } 139 140 /** 141 * Translates a pattern from the lexer convention into plain PCRE 142 * syntax: bare ( and ) match literally — only the (?...) group forms 143 * keep their regex meaning — and / needs no escaping despite the 144 * /-delimited compound. Any fragment embedded into a /-delimited 145 * regex alongside the registered patterns must go through this 146 * translation to compose correctly. 147 * 148 * @param string $pattern pattern in the lexer convention 149 * @return string plain PCRE pattern fragment 150 */ 151 public static function escapePattern($pattern) 152 { 153 /* 154 * decompose the input pattern into "(", "(?", ")", 155 * "[...]", "[]..]", "[^]..]", "[...[:...:]..]", "\x"... 156 * elements. 157 */ 158 preg_match_all('/\\\\.|' . 159 '\(\?|' . 160 '[()]|' . 161 '\[\^?\]?(?:\\\\.|\[:[^]]*:\]|[^]\\\\])*\]|' . 162 '[^[()\\\\]+/', $pattern, $elts); 163 164 $escaped = ""; 165 $level = 0; 166 167 foreach ($elts[0] as $elt) { 168 /* 169 * for "(", ")" remember the nesting level, add "\" 170 * only to the non-"(?" ones. 171 */ 172 173 switch ($elt) { 174 case '(': 175 $escaped .= '\('; 176 break; 177 case ')': 178 if ($level > 0) 179 $level--; /* closing (? */ 180 else $escaped .= '\\'; 181 $escaped .= ')'; 182 break; 183 case '(?': 184 $level++; 185 $escaped .= '(?'; 186 break; 187 default: 188 if (str_starts_with($elt, '\\')) 189 $escaped .= $elt; 190 else $escaped .= str_replace('/', '\/', $elt); 191 } 192 } 193 return $escaped; 194 } 195 196 /** 197 * Compounds the patterns into a single 198 * regular expression separated with the 199 * "or" operator. Caches the regex. 200 * Will automatically escape (, ) and / tokens. 201 * 202 * @return null|string 203 */ 204 protected function getCompoundedRegex() 205 { 206 if ($this->regex == null) { 207 $groups = array_map( 208 static fn($pattern) => '(' . self::escapePattern($pattern) . ')', 209 $this->patterns 210 ); 211 $this->regex = "/" . implode("|", $groups) . "/" . $this->getPerlMatchingFlags(); 212 } 213 return $this->regex; 214 } 215 216 /** 217 * Accessor for perl regex mode flags to use. 218 * @return string Perl regex flags. 219 */ 220 protected function getPerlMatchingFlags() 221 { 222 return ($this->case ? "msS" : "msSi"); 223 } 224} 225