xref: /dokuwiki/inc/Parsing/Lexer/CloserPattern.php (revision 1c00c02121477c81b95fe750b94acdf109cba20a)
1<?php
2
3namespace dokuwiki\Parsing\Lexer;
4
5/**
6 * A closer-existence check guarding a lexer mode.
7 *
8 * Holds one mode's closer and boundary patterns, the scan regex compiled
9 * from them, and the memoized verdicts of earlier scans. The Lexer calls
10 * position() to ask whether a valid closer exists ahead of an entry match;
11 * memoizing the verdict keeps that check linear over a whole parse.
12 *
13 * See Lexer::addCloserPattern() for the contract the patterns follow.
14 */
15class CloserPattern
16{
17    /** @var string pattern matching a valid closer, in plain PCRE syntax */
18    protected string $closer;
19    /** @var string|null pattern the closer must occur before, in plain PCRE syntax */
20    protected ?string $boundary;
21    /** @var string|null scan regex built by compile(), null until then */
22    protected ?string $scan = null;
23    /** @var int|null start of the range proven to see the memoized closer */
24    protected ?int $successFrom = null;
25    /** @var int|null memoized closer position, valid from successFrom on */
26    protected ?int $closerPos = null;
27    /** @var int|null start of the range proven closer-free */
28    protected ?int $failFrom = null;
29    /** @var int|null exclusive end of the range proven closer-free */
30    protected ?int $failUntil = null;
31
32    /**
33     * Translates both patterns from the lexer convention into plain PCRE
34     * syntax. Building the scan regex is deferred to compile().
35     *
36     * @param string $pattern regex fragment matching the closing delimiter,
37     *                        flanking context expressed as lookarounds
38     * @param string|null $boundary regex fragment the closer must occur
39     *                              before; null to scan to the end of the
40     *                              subject
41     */
42    public function __construct(string $pattern, ?string $boundary = null)
43    {
44        $this->closer = ParallelRegex::escapePattern($pattern);
45        $this->boundary = $boundary === null ? null : ParallelRegex::escapePattern($boundary);
46    }
47
48    /**
49     * Whether compile() has built the scan regex yet.
50     *
51     * @return bool
52     */
53    public function isCompiled(): bool
54    {
55        return $this->scan !== null;
56    }
57
58    /**
59     * Builds the scan regex from the mode's patterns and the opaque spans
60     * supplied by the Lexer.
61     *
62     * It matches the earliest of three things ahead: the boundary (named
63     * group "bound"), the closer (named group "closer"), or an opaque span
64     * — a stretch the lexer consumes atomically, so a closer lookalike
65     * inside it can never really close the mode (see Lexer::opaqueSpans()).
66     * Alternative order breaks same-position ties: the boundary wins over a
67     * span starting at the same byte, and the closer is never swallowed by
68     * one. position() interprets the matches.
69     *
70     * @param string[] $opaqueSpans regex fragments in plain PCRE syntax,
71     *                              each matching one whole span the scan
72     *                              must not look into
73     * @param bool $case true for case sensitive matching
74     * @return void
75     */
76    public function compile(array $opaqueSpans, bool $case): void
77    {
78        $alternatives = [];
79        if ($this->boundary !== null) {
80            $alternatives[] = '(?<bound>' . $this->boundary . ')';
81        }
82        $alternatives[] = '(?<closer>' . $this->closer . ')';
83        $alternatives = array_merge($alternatives, $opaqueSpans);
84
85        $flags = $case ? 'ms' : 'msi';
86        $this->scan = '/' . implode('|', $alternatives) . '/' . $flags;
87    }
88
89    /**
90     * Byte position where the first valid closer at or after $from starts,
91     * or null if none occurs before the boundary (or the end of subject).
92     * Requires compile() to have run.
93     *
94     * Answers from the memo when possible, otherwise runs the scan (see
95     * compile()), hopping over opaque spans, and memoizes the verdict: on
96     * success the range from $from up to the closer, on failure the
97     * closer-free range up to the boundary (or end of subject). A verdict
98     * holds for any position in its range because the leftmost scan proves
99     * no closer lies earlier; positions inside opaque spans are never
100     * queried, since the lexer consumes those without matching entry
101     * patterns in them.
102     *
103     * @param string $subject the full subject being lexed
104     * @param int $from byte position just after the entry pattern match
105     * @return int|null
106     */
107    public function position(string $subject, int $from): ?int
108    {
109        if ($this->successFrom !== null && $from >= $this->successFrom && $from <= $this->closerPos) {
110            return $this->closerPos;
111        }
112        if ($this->failFrom !== null && $from >= $this->failFrom && $from < $this->failUntil) {
113            return null;
114        }
115
116        $pos = $from;
117        while (preg_match($this->scan, $subject, $match, PREG_OFFSET_CAPTURE, $pos) === 1) {
118            if (isset($match['bound']) && $match['bound'][1] !== -1) {
119                $this->failFrom = $from;
120                $this->failUntil = $match['bound'][1] + 1;
121                return null;
122            }
123            if (isset($match['closer']) && $match['closer'][1] !== -1) {
124                $this->successFrom = $from;
125                $this->closerPos = $match['closer'][1];
126                return $this->closerPos;
127            }
128            // an opaque span: resume behind it
129            $pos = max($match[0][1] + strlen($match[0][0]), $pos + 1);
130        }
131
132        $this->failFrom = $from;
133        $this->failUntil = strlen($subject) + 1;
134        return null;
135    }
136
137    /**
138     * Forgets all memoized scan verdicts, which only hold for the subject
139     * they were computed on. The compiled scan regex is kept — it depends
140     * only on the connected patterns, not on the subject.
141     *
142     * @return void
143     */
144    public function reset(): void
145    {
146        $this->successFrom = null;
147        $this->closerPos = null;
148        $this->failFrom = null;
149        $this->failUntil = null;
150    }
151}
152