xref: /dokuwiki/inc/Parsing/Lexer/ParallelRegex.php (revision 1c00c02121477c81b95fe750b94acdf109cba20a)
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