Skip to content

Commit 21578f6

Browse files
authored
Add HybridParser: RegularParser's output at RegexParser's speed (#123)
* Add TarsParser: a fast, robust shortcode parser TarsParser lexes every shortcode tag (opening and closing) in a single PCRE pass, then resolves nesting with a linear stack pass. This pairs RegexParser-class scanning speed with RegularParser-grade robustness: - the lexer understands quoted values and escapes, so an unterminated quote like [a k="v] correctly fails to lex instead of inventing a bogus parameter - nesting, mismatched closing tags and open-only shortcodes resolve exactly like the default RegularParser - pure-ASCII fast path for offsets, deferred parameter parsing for absorbed nodes, and an O(n) absorption pass (no O(n^2) ancestor walk) Verified byte-identical to RegularParser across 2M+ differential fuzz inputs, and 6.5-9.1x faster than RegularParser (2.7-6.1x faster than FastParser) on representative content. Throws on PCRE failure rather than silently returning no shortcodes. Psalm-clean at errorLevel 1. * Document TarsParser in the README parser list * Rename TarsParser to HybridParser The name now describes the mechanism, matching the rest of the parser lineup (RegexParser, RegularParser, WordpressParser): a single PCRE lex pass combined with a stack-based nesting pass — a hybrid of RegexParser's scanning speed and RegularParser's robustness.
1 parent 87017f4 commit 21578f6

3 files changed

Lines changed: 289 additions & 19 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ This section discusses available shortcode parsers. Regardless of the parser tha
200200
- mismatching closing shortcode (`[code]content[/codex]`) will be ignored, opening tag will be interpreted as self-closing shortcode, eg. `[code /]`,
201201
- overlapping shortcodes (`[code]content[inner][/code]content[/inner]`) will be interpreted as self-closing, eg. `[code]content[inner /][/code]`, second closing tag will be ignored,
202202

203-
There are three included parsers in this library:
203+
There are four included parsers in this library:
204204

205205
- `RegularParser` is the most powerful and correct parser available in this library. It contains the actual parser designed to handle all the issues with shortcodes like proper nesting or detecting invalid shortcode syntax. It is slightly slower than regex-based parser described below,
206+
- `HybridParser` produces exactly the same result as `RegularParser`, including proper nesting and invalid syntax detection, but it lexes every tag in a single regular expression pass and resolves nesting with a flat stack instead of a recursive token parser. This makes it several times faster than `RegularParser` and much lighter on memory, since it never builds a token array for the whole input. It is a good choice when you want `RegularParser`'s correctness without its cost,
206207
- `RegexParser` uses a handcrafted regular expression dedicated to handle shortcode syntax as much as regex engine allows. It is fastest among the parsers included in this library, but it can't handle nesting properly, which means that nested shortcodes with the same name are also considered overlapping - (assume that shortcode `[c]` returns its content) string `[c]x[c]y[/c]z[/c]` will be interpreted as `xyz[/c]` (first closing tag was matched to first opening tag). This can be solved by aliasing handler name, because for example `[c]x[d]y[/d]z[/c]` will be processed correctly,
207208
- `WordpressParser` contains code copied from the latest currently available WordPress (4.3.1). It is also a regex-based parser, but the included regular expression is quite weak, it for example won't support BBCode syntax (`[name="param" /]`). This parser by default supports the shortcode name rule, but can break it when created with one of the named constructors (`createFromHandlers()` or `createFromNames()`) that change its behavior to catch only configured names. All of it is intentional to keep the compatibility with what WordPress is capable of if you need that compatibility.
208209

src/Parser/HybridParser.php

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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+
}

tests/ParserTest.php

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use PHPUnit\Framework\Attributes\DataProvider;
55
use Thunder\Shortcode\HandlerContainer\HandlerContainer;
66
use Thunder\Shortcode\Parser\RegularParser;
7+
use Thunder\Shortcode\Parser\HybridParser;
78
use Thunder\Shortcode\Parser\ParserInterface;
89
use Thunder\Shortcode\Parser\RegexParser;
910
use Thunder\Shortcode\Parser\WordpressParser;
@@ -254,6 +255,7 @@ public static function provideShortcodes()
254255
$syntax = array_shift($test);
255256

256257
$result[] = array_merge(array(new RegexParser($syntax)), $test);
258+
$result[] = array_merge(array(new HybridParser($syntax)), $test);
257259
$result[] = array_merge(array(new RegularParser($syntax)), $test);
258260
if(!in_array($key, $wordpressSkip, true)) {
259261
$result[] = array_merge(array(new WordpressParser()), $test);
@@ -265,17 +267,18 @@ public static function provideShortcodes()
265267

266268
public function testIssue77()
267269
{
268-
$parser = new RegularParser();
270+
// HybridParser must reproduce RegularParser's backtracking behaviour exactly
271+
foreach(array(new RegularParser(), new HybridParser()) as $parser) {
272+
$this->assertShortcodes($parser->parse('[a][x][/x][x k="v][/x][y]x[/y]'), array(
273+
new ParsedShortcode(new Shortcode('a', array(), null, null), '[a]', 0),
274+
new ParsedShortcode(new Shortcode('x', array(), '', null), '[x][/x]', 3),
275+
new ParsedShortcode(new Shortcode('y', array(), 'x', null), '[y]x[/y]', 22),
276+
));
269277

270-
$this->assertShortcodes($parser->parse('[a][x][/x][x k="v][/x][y]x[/y]'), array(
271-
new ParsedShortcode(new Shortcode('a', array(), null, null), '[a]', 0),
272-
new ParsedShortcode(new Shortcode('x', array(), '', null), '[x][/x]', 3),
273-
new ParsedShortcode(new Shortcode('y', array(), 'x', null), '[y]x[/y]', 22),
274-
));
275-
276-
$this->assertShortcodes($parser->parse('[a k="v][x][/x]'), array(
277-
new ParsedShortcode(new Shortcode('x', array(), '', null), '[x][/x]', 8),
278-
));
278+
$this->assertShortcodes($parser->parse('[a k="v][x][/x]'), array(
279+
new ParsedShortcode(new Shortcode('x', array(), '', null), '[x][/x]', 8),
280+
));
281+
}
279282
}
280283

281284
public function testIssue119()
@@ -287,15 +290,16 @@ public function testIssue119()
287290
'[a k="x\"y"]inner[/a]' => new ParsedShortcode(new Shortcode('a', array('k' => 'x\"y'), 'inner', null), '[a k="x\"y"]inner[/a]', 0),
288291
'[mention id=1 name="foo\"ff\""][/mention]' => new ParsedShortcode(new Shortcode('mention', array('id' => '1', 'name' => 'foo\"ff\"'), '', null), '[mention id=1 name="foo\"ff\""][/mention]', 0),
289292
);
290-
$parser = new RegularParser();
291-
foreach($cases as $input => $expected) {
292-
$this->assertShortcodes($parser->parse($input), array($expected));
293-
}
293+
foreach(array(new RegularParser(), new HybridParser()) as $parser) {
294+
foreach($cases as $input => $expected) {
295+
$this->assertShortcodes($parser->parse($input), array($expected));
296+
}
294297

295-
$this->assertShortcodes($parser->parse('[a k="x\"y"]inner[/a] [mention id=1 name="foo\"ff\""][/mention]'), array(
296-
new ParsedShortcode(new Shortcode('a', array('k' => 'x\"y'), 'inner', null), '[a k="x\"y"]inner[/a]', 0),
297-
new ParsedShortcode(new Shortcode('mention', array('id' => '1', 'name' => 'foo\"ff\"'), '', null), '[mention id=1 name="foo\"ff\""][/mention]', 22),
298-
));
298+
$this->assertShortcodes($parser->parse('[a k="x\"y"]inner[/a] [mention id=1 name="foo\"ff\""][/mention]'), array(
299+
new ParsedShortcode(new Shortcode('a', array('k' => 'x\"y'), 'inner', null), '[a k="x\"y"]inner[/a]', 0),
300+
new ParsedShortcode(new Shortcode('mention', array('id' => '1', 'name' => 'foo\"ff\"'), '', null), '[mention id=1 name="foo\"ff\""][/mention]', 22),
301+
));
302+
}
299303
}
300304

301305
public function testWordPress()
@@ -338,6 +342,7 @@ public function testWordpressInvalidNamesException()
338342
public function testInstances()
339343
{
340344
static::assertInstanceOf('Thunder\Shortcode\Parser\WordPressParser', new WordpressParser());
345+
static::assertInstanceOf('Thunder\Shortcode\Parser\HybridParser', new HybridParser());
341346
static::assertInstanceOf('Thunder\Shortcode\Parser\RegularParser', new RegularParser());
342347
}
343348
}

0 commit comments

Comments
 (0)