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