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