|
| 1 | +<?php |
| 2 | +namespace Thunder\Shortcode\Parser; |
| 3 | + |
| 4 | +use Thunder\Shortcode\Shortcode\ParsedShortcode; |
| 5 | +use Thunder\Shortcode\Shortcode\Shortcode; |
| 6 | +use Thunder\Shortcode\Syntax\CommonSyntax; |
| 7 | +use Thunder\Shortcode\Syntax\SyntaxInterface; |
| 8 | + |
| 9 | +/** |
| 10 | + * HybridParser - a fast, robust shortcode parser. |
| 11 | + * |
| 12 | + * Strategy: a single PCRE pass lexes every individual shortcode tag (both |
| 13 | + * opening and closing) in C, then a linear stack-based pass resolves nesting |
| 14 | + * in PHP. This combines RegexParser's raw scanning speed with RegularParser's |
| 15 | + * robustness: the lexer regex understands quoted values and escapes, so an |
| 16 | + * unterminated quote like `[a k="v]` correctly fails to lex as a tag instead |
| 17 | + * of inventing a bogus parameter. Nesting, mismatched closing tags and |
| 18 | + * open-only shortcodes are then resolved exactly like the default parser. |
| 19 | + * |
| 20 | + * @author Andy Miller |
| 21 | + * |
| 22 | + * @psalm-type HybridNode = array{ |
| 23 | + * 0: string, 1: string, 2: string|null, 3: int, 4: int, 5: int, |
| 24 | + * 6: int|null, 7: bool, 8: int|null, 9: int|null, 10: bool |
| 25 | + * } |
| 26 | + */ |
| 27 | +final class HybridParser implements ParserInterface |
| 28 | +{ |
| 29 | + /** @var non-empty-string */ |
| 30 | + private $tagRegex; |
| 31 | + /** @var non-empty-string */ |
| 32 | + private $paramRegex; |
| 33 | + /** @var non-empty-string */ |
| 34 | + private $delimiter; |
| 35 | + /** @var positive-int */ |
| 36 | + private $delimiterLength; |
| 37 | + |
| 38 | + /** @param SyntaxInterface|null $syntax */ |
| 39 | + public function __construct($syntax = null) |
| 40 | + { |
| 41 | + if(null !== $syntax && false === $syntax instanceof SyntaxInterface) { |
| 42 | + throw new \LogicException('Parameter $syntax must be an instance of SyntaxInterface.'); |
| 43 | + } |
| 44 | + |
| 45 | + $syntax = $syntax ?: new CommonSyntax(); |
| 46 | + $this->delimiter = $syntax->getParameterValueDelimiter(); |
| 47 | + $this->delimiterLength = strlen($this->delimiter); |
| 48 | + |
| 49 | + $openTag = preg_quote($syntax->getOpeningTag(), '~'); |
| 50 | + $closeTag = preg_quote($syntax->getClosingTag(), '~'); |
| 51 | + $marker = preg_quote($syntax->getClosingTagMarker(), '~'); |
| 52 | + $separator = preg_quote($syntax->getParameterValueSeparator(), '~'); |
| 53 | + $delimiter = preg_quote($this->delimiter, '~'); |
| 54 | + |
| 55 | + $space = '\s*'; |
| 56 | + $special = $openTag.'|'.$closeTag.'|'.$marker.'|'.$separator.'|'.$delimiter; |
| 57 | + $notSpecial = '(?!'.$special.')'; |
| 58 | + // a single "string token": one escape sequence, or one maximal run of |
| 59 | + // non-special, non-whitespace characters (possessive so it never gives back) |
| 60 | + $stringToken = '(?:\\\\.|(?:'.$notSpecial.'[^\s\\\\])++)'; |
| 61 | + // a value globs consecutive string tokens; atomic so the lexer commits like |
| 62 | + // RegularParser instead of backtracking into a different tokenization |
| 63 | + $stringRun = '(?>'.$stringToken.'+)'; |
| 64 | + // a delimited value; the body is possessive so an escape sequence is never |
| 65 | + // given back to let the value re-close at an earlier (escaped) delimiter |
| 66 | + $quoted = $delimiter.'(?:\\\\.|(?!'.$delimiter.').)*+'.$delimiter; |
| 67 | + $value = '(?>'.$quoted.'|'.$stringRun.')'; |
| 68 | + // shortcode name; must end on a token boundary so `[foo.bar]` is rejected wholesale |
| 69 | + $name = '[a-zA-Z0-9_*-]+'; |
| 70 | + $boundary = '(?=\s|'.$special.'|$)'; |
| 71 | + // a parameter name is a single string token, not a glued run |
| 72 | + $parameters = '(?<params>(?:'.$space.$stringToken.'(?:'.$space.$separator.$space.$value.')?)*+)'; |
| 73 | + $bbCode = '(?:'.$separator.$space.'(?<bbCode>'.$value.')'.$space.')?+'; |
| 74 | + |
| 75 | + $closingTagRule = $openTag.$space.$marker.$space.'(?<cname>'.$name.')'.$space.$closeTag; |
| 76 | + $openingTagRule = $openTag.$space.'(?<name>'.$name.')'.$boundary.$space.$bbCode.$parameters.$space.'(?<self>'.$marker.')?'.$space.$closeTag; |
| 77 | + |
| 78 | + $this->tagRegex = '~(?:'.$closingTagRule.'|'.$openingTagRule.')~us'; |
| 79 | + $this->paramRegex = '~'.$space.'(?<pn>'.$stringToken.')(?:'.$space.$separator.$space.'(?<pv>'.$value.'))?~us'; |
| 80 | + } |
| 81 | + |
| 82 | + /** |
| 83 | + * @param string $text |
| 84 | + * |
| 85 | + * @return ParsedShortcode[] |
| 86 | + */ |
| 87 | + public function parse($text) |
| 88 | + { |
| 89 | + $count = preg_match_all($this->tagRegex, $text, $matches, PREG_OFFSET_CAPTURE); |
| 90 | + if(false === $count || preg_last_error() !== PREG_NO_ERROR) { |
| 91 | + throw new \RuntimeException(sprintf('PCRE failure `%s`.', preg_last_error())); |
| 92 | + } |
| 93 | + if(0 === $count) { |
| 94 | + return array(); |
| 95 | + } |
| 96 | + |
| 97 | + // pure-ASCII text lets us treat byte offsets as character offsets directly |
| 98 | + $ascii = !preg_match('~[\x80-\xff]~', $text); |
| 99 | + $lastByte = 0; |
| 100 | + $lastChar = 0; |
| 101 | + |
| 102 | + /** @psalm-var list<HybridNode> $nodes */ |
| 103 | + $nodes = array(); |
| 104 | + /** @psalm-var list<int> $stack */ |
| 105 | + $stack = array(); |
| 106 | + $depth = 0; |
| 107 | + $closeNames = $matches['cname']; |
| 108 | + $names = $matches['name']; |
| 109 | + $selfMarkers = $matches['self']; |
| 110 | + $bbCodes = $matches['bbCode']; |
| 111 | + $paramStrings = $matches['params']; |
| 112 | + |
| 113 | + foreach($matches[0] as $index => $whole) { |
| 114 | + $byteStart = $whole[1]; |
| 115 | + $byteEnd = $byteStart + strlen($whole[0]); |
| 116 | + |
| 117 | + if(isset($closeNames[$index][1]) && $closeNames[$index][1] !== -1) { |
| 118 | + // closing tag: match the innermost open node of the same name. |
| 119 | + // RegularParser rejects a closing name that is falsy in PHP (`'0'`) |
| 120 | + // via `if(!$closingName = ...)`, so we faithfully ignore it too. |
| 121 | + $closeName = $closeNames[$index][0]; |
| 122 | + if('0' === $closeName) { |
| 123 | + continue; |
| 124 | + } |
| 125 | + for($stackIndex = $depth - 1; $stackIndex >= 0; $stackIndex--) { |
| 126 | + $nodeIndex = $stack[$stackIndex]; |
| 127 | + if($nodes[$nodeIndex][0] === $closeName) { |
| 128 | + $nodes[$nodeIndex][7] = true; // closed |
| 129 | + $nodes[$nodeIndex][8] = $byteStart; // closeStart |
| 130 | + $nodes[$nodeIndex][9] = $byteEnd; // closeEnd |
| 131 | + $stack = array_slice($stack, 0, $stackIndex); |
| 132 | + $depth = $stackIndex; |
| 133 | + break; |
| 134 | + } |
| 135 | + } |
| 136 | + continue; |
| 137 | + } |
| 138 | + |
| 139 | + // opening tag — char offset (byte offset is fine for pure-ASCII text) |
| 140 | + if($ascii) { |
| 141 | + $offset = $byteStart; |
| 142 | + } else { |
| 143 | + if($byteStart > $lastByte) { |
| 144 | + /** @psalm-suppress PossiblyFalseArgument */ |
| 145 | + $lastChar += mb_strlen(substr($text, $lastByte, $byteStart - $lastByte), 'utf-8'); |
| 146 | + $lastByte = $byteStart; |
| 147 | + } |
| 148 | + $offset = $lastChar; |
| 149 | + } |
| 150 | + |
| 151 | + $selfClosing = isset($selfMarkers[$index][1]) && $selfMarkers[$index][1] !== -1; |
| 152 | + |
| 153 | + // node tuple: [0]name [1]paramsRaw [2]bbCodeRaw [3]offset [4]start |
| 154 | + // [5]openEnd [6]parent [7]closed [8]closeStart [9]closeEnd [10]selfClosing |
| 155 | + // parameter/bbCode parsing is deferred to build() so absorbed nodes never pay for it |
| 156 | + $nodes[] = array( |
| 157 | + $names[$index][0], |
| 158 | + isset($paramStrings[$index][1]) && $paramStrings[$index][1] !== -1 ? $paramStrings[$index][0] : '', |
| 159 | + isset($bbCodes[$index][1]) && $bbCodes[$index][1] !== -1 ? $bbCodes[$index][0] : null, |
| 160 | + $offset, |
| 161 | + $byteStart, |
| 162 | + $byteEnd, |
| 163 | + $depth ? $stack[$depth - 1] : null, |
| 164 | + $selfClosing, |
| 165 | + $selfClosing ? $byteEnd : null, |
| 166 | + $selfClosing ? $byteEnd : null, |
| 167 | + $selfClosing, |
| 168 | + ); |
| 169 | + |
| 170 | + if(false === $selfClosing) { |
| 171 | + $stack[$depth++] = count($nodes) - 1; |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + return $this->build($nodes, $text); |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * @psalm-param array<int, HybridNode> $nodes |
| 180 | + * @param string $text |
| 181 | + * |
| 182 | + * @return ParsedShortcode[] |
| 183 | + */ |
| 184 | + private function build(array $nodes, $text) |
| 185 | + { |
| 186 | + $shortcodes = array(); |
| 187 | + // A node is absorbed (part of a closed ancestor's content) iff its parent is |
| 188 | + // closed or itself absorbed. Parents always precede children, so a single |
| 189 | + // forward pass resolves this in O(1) per node instead of walking ancestors. |
| 190 | + /** @psalm-var array<int,bool> $absorbed */ |
| 191 | + $absorbed = array(); |
| 192 | + foreach($nodes as $index => $node) { |
| 193 | + $parent = $node[6]; |
| 194 | + if(null !== $parent && ($nodes[$parent][7] || $absorbed[$parent])) { |
| 195 | + $absorbed[$index] = true; |
| 196 | + continue; |
| 197 | + } |
| 198 | + $absorbed[$index] = false; |
| 199 | + |
| 200 | + if($node[7]) { |
| 201 | + // a closed node always has integer close offsets (set on close or self-close) |
| 202 | + /** @psalm-suppress PossiblyNullOperand */ |
| 203 | + $content = $node[10] ? null : substr($text, $node[5], $node[8] - $node[5]); |
| 204 | + /** @psalm-suppress PossiblyNullOperand */ |
| 205 | + $shortcodeText = substr($text, $node[4], $node[9] - $node[4]); |
| 206 | + } else { |
| 207 | + $content = null; |
| 208 | + $shortcodeText = substr($text, $node[4], $node[5] - $node[4]); |
| 209 | + } |
| 210 | + |
| 211 | + $parameters = '' === $node[1] ? array() : $this->parseParameters($node[1]); |
| 212 | + $bbCode = null === $node[2] ? null : $this->extractValue($node[2]); |
| 213 | + |
| 214 | + /** @psalm-suppress PossiblyFalseArgument */ |
| 215 | + $shortcode = new Shortcode($node[0], $parameters, $content, $bbCode); |
| 216 | + /** @psalm-suppress PossiblyFalseArgument */ |
| 217 | + $shortcodes[] = new ParsedShortcode($shortcode, $shortcodeText, $node[3]); |
| 218 | + } |
| 219 | + |
| 220 | + return $shortcodes; |
| 221 | + } |
| 222 | + |
| 223 | + /** |
| 224 | + * @param string $text |
| 225 | + * |
| 226 | + * @psalm-return array<string,string|null> |
| 227 | + */ |
| 228 | + private function parseParameters($text) |
| 229 | + { |
| 230 | + if('' === $text || false === preg_match_all($this->paramRegex, $text, $matches, PREG_SET_ORDER)) { |
| 231 | + return array(); |
| 232 | + } |
| 233 | + |
| 234 | + $parameters = array(); |
| 235 | + foreach($matches as $match) { |
| 236 | + if(!isset($match['pn']) || '' === $match['pn']) { |
| 237 | + continue; |
| 238 | + } |
| 239 | + $hasValue = isset($match['pv']) && '' !== $match['pv']; |
| 240 | + $parameters[$match['pn']] = $hasValue ? $this->extractValue($match['pv']) : null; |
| 241 | + } |
| 242 | + |
| 243 | + return $parameters; |
| 244 | + } |
| 245 | + |
| 246 | + /** |
| 247 | + * @param string $value |
| 248 | + * |
| 249 | + * @return string |
| 250 | + * @psalm-suppress InvalidFalsableReturnType |
| 251 | + */ |
| 252 | + private function extractValue($value) |
| 253 | + { |
| 254 | + $length = $this->delimiterLength; |
| 255 | + if(strlen($value) >= 2 * $length |
| 256 | + && strncmp($value, $this->delimiter, $length) === 0 |
| 257 | + && substr($value, -$length) === $this->delimiter) { |
| 258 | + /** @psalm-suppress FalsableReturnStatement */ |
| 259 | + return substr($value, $length, -$length); |
| 260 | + } |
| 261 | + |
| 262 | + return $value; |
| 263 | + } |
| 264 | +} |
0 commit comments