Completed
Push — master ( 0f01d6...50d1bf )
by Josh
02:25
created

Quick::export()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 5
nop 1
dl 0
loc 14
rs 9.2
c 0
b 0
f 0
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2017 The s9e Authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\TextFormatter\Configurator\RendererGenerators\PHP;
9
10
use Closure;
11
use RuntimeException;
12
use s9e\TextFormatter\Configurator\Helpers\RegexpBuilder;
13
14
class Quick
15
{
16
	/**
17
	* Generate the Quick renderer's source
18
	*
19
	* @param  array  $compiledTemplates Array of tagName => compiled template
20
	* @return string
21
	*/
22
	public static function getSource(array $compiledTemplates)
23
	{
24
		$map         = ['dynamic' => [], 'php' => [], 'static' => []];
25
		$tagNames    = [];
26
		$unsupported = [];
27
28
		// Ignore system tags
29
		unset($compiledTemplates['br']);
30
		unset($compiledTemplates['e']);
31
		unset($compiledTemplates['i']);
32
		unset($compiledTemplates['p']);
33
		unset($compiledTemplates['s']);
34
35
		foreach ($compiledTemplates as $tagName => $php)
36
		{
37
			$rendering = self::getRenderingStrategy($php);
38
			if ($rendering === false)
39
			{
40
				$unsupported[] = $tagName;
41
				continue;
42
			}
43
44
			foreach ($rendering as $i => list($strategy, $replacement))
45
			{
46
				$match = (($i) ? '/' : '') . $tagName;
47
				$map[$strategy][$match] = $replacement;
48
			}
49
50
			// Record the names of tags whose template does not contain a passthrough
51
			if (!isset($rendering[1]))
52
			{
53
				$tagNames[] = $tagName;
54
			}
55
		}
56
57
		$php = [];
58
		$php[] = '	/** {@inheritdoc} */';
59
		$php[] = '	public $enableQuickRenderer=true;';
60
		$php[] = '	/** {@inheritdoc} */';
61
		$php[] = '	protected $static=' . self::export($map['static']) . ';';
62
		$php[] = '	/** {@inheritdoc} */';
63
		$php[] = '	protected $dynamic=' . self::export($map['dynamic']) . ';';
64
65
		$quickSource = '';
66
		if (!empty($map['php']))
67
		{
68
			list($quickBranches, $quickSource) = self::generateBranchTable('$qb', $map['php']);
69
			$php[] = '	/** {@inheritdoc} */';
70
			$php[] = '	protected $quickBranches=' . self::export($quickBranches) . ';';
71
		}
72
73
		// Build a regexp that matches all the tags
74
		$regexp  = '(<(?:(?!/)(';
75
		$regexp .= ($tagNames) ? RegexpBuilder::fromList($tagNames) : '(?!)';
76
		$regexp .= ')(?: [^>]*)?>.*?</\\1|(/?(?!br/|p>)[^ />]+)[^>]*?(/)?)>)s';
77
		$php[] = '	/** {@inheritdoc} */';
78
		$php[] = '	protected $quickRegexp=' . var_export($regexp, true) . ';';
79
80
		// Build a regexp that matches tags that cannot be rendered with the Quick renderer
81
		if (!empty($unsupported))
82
		{
83
			$regexp = '(<(?:[!?]|' . RegexpBuilder::fromList($unsupported) . '[ />]))';
84
			$php[]  = '	/** {@inheritdoc} */';
85
			$php[]  = '	protected $quickRenderingTest=' . var_export($regexp, true) . ';';
86
		}
87
88
		$php[] = '	/** {@inheritdoc} */';
89
		$php[] = '	protected function renderQuickTemplate($qb, $xml)';
90
		$php[] = '	{';
91
		$php[] = '		$attributes=$this->matchAttributes($xml);';
92
		$php[] = "		\$html='';" . $quickSource;
93
		$php[] = '';
94
		$php[] = '		return $html;';
95
		$php[] = '	}';
96
97
		return implode("\n", $php);
98
	}
99
100
	/**
101
	* Export an array as PHP
102
	*
103
	* @param  array  $arr
104
	* @return string
105
	*/
106
	protected static function export(array $arr)
107
	{
108
		$exportKeys = (array_keys($arr) !== range(0, count($arr) - 1));
109
		ksort($arr);
110
111
		$entries = [];
112
		foreach ($arr as $k => $v)
113
		{
114
			$entries[] = (($exportKeys) ? var_export($k, true) . '=>' : '')
115
			           . ((is_array($v)) ? self::export($v) : var_export($v, true));
116
		}
117
118
		return '[' . implode(',', $entries) . ']';
119
	}
120
121
	/**
122
	* Compute the rendering strategy for a compiled template
123
	*
124
	* @param  string     $php Template compiled for the PHP renderer
125
	* @return array|bool      An array containing the type of replacement ('static', 'dynamic' or
126
	*                         'php') and the replacement, or FALSE
127
	*/
128
	public static function getRenderingStrategy($php)
129
	{
130
		$chunks = explode('$this->at($node);', $php);
131
		$renderings = [];
132
133
		// If there is zero or one passthrough, we try string replacements first
134
		if (count($chunks) <= 2)
135
		{
136
			foreach ($chunks as $k => $chunk)
137
			{
138
				// Try a static replacement first
139
				$rendering = self::getStaticRendering($chunk);
140
				if ($rendering !== false)
141
				{
142
					$renderings[$k] = ['static', $rendering];
143
					continue;
144
				}
145
146
				// If this is the first chunk, we can try a dynamic replacement. This wouldn't work
147
				// for the second chunk because we wouldn't have access to the attribute values
148
				if ($k === 0)
149
				{
150
					$rendering = self::getDynamicRendering($chunk);
151
					if ($rendering !== false)
152
					{
153
						$renderings[$k] = ['dynamic', $rendering];
154
						continue;
155
					}
156
				}
157
158
				$renderings[$k] = false;
159
			}
160
161
			// If we can completely render a template with string replacements, we return now
162
			if (!in_array(false, $renderings, true))
163
			{
164
				return $renderings;
165
			}
166
		}
167
168
		// Test whether this template can be rendered with the Quick rendering
169
		$phpRenderings = self::getQuickRendering($php);
170
		if ($phpRenderings === false)
171
		{
172
			return false;
173
		}
174
175
		// Keep string rendering where possible, use PHP rendering wherever else
176
		foreach ($phpRenderings as $i => $phpRendering)
177
		{
178
			if (!isset($renderings[$i]) || $renderings[$i] === false || strpos($phpRendering, '$this->attributes[]') !== false)
179
			{
180
				$renderings[$i] = ['php', $phpRendering];
181
			}
182
		}
183
184
		return $renderings;
185
	}
186
187
	/**
188
	* Generate the code for rendering a compiled template with the Quick renderer
189
	*
190
	* Parse and record every code path that contains a passthrough. Parse every if-else structure.
191
	* When the whole structure is parsed, there are 3 possible situations:
192
	*  - no code path contains a passthrough, in which case we discard the data
193
	*  - all the code paths including the mandatory "else" branch contain a passthrough, in which
194
	*    case we keep the data
195
	*  - only some code paths contain a passthrough, in which case we throw an exception
196
	*
197
	* @param  string     $php Template compiled for the PHP renderer
198
	* @return array|bool      An array containing one or two strings of PHP, or FALSE
199
	*/
200
	protected static function getQuickRendering($php)
201
	{
202
		// xsl:apply-templates elements with a select expression are not supported
203
		if (preg_match('(\\$this->at\\((?!\\$node\\);))', $php))
204
		{
205
			return false;
206
		}
207
208
		// Tokenize the PHP and add an empty token as terminator
209
		$tokens   = token_get_all('<?php ' . $php);
210
		$tokens[] = [0, ''];
211
212
		// Remove the first token, which is a T_OPEN_TAG
213
		array_shift($tokens);
214
		$cnt = count($tokens);
215
216
		// Prepare the main branch
217
		$branch = [
218
			// We purposefully use a value that can never match
219
			'braces'      => -1,
220
			'branches'    => [],
221
			'head'        => '',
222
			'passthrough' => 0,
223
			'statement'   => '',
224
			'tail'        => ''
225
		];
226
227
		$braces = 0;
228
		$i = 0;
229
		do
230
		{
231
			// Test whether we've reached a passthrough
232
			if ($tokens[$i    ][0] === T_VARIABLE
233
			 && $tokens[$i    ][1] === '$this'
234
			 && $tokens[$i + 1][0] === T_OBJECT_OPERATOR
235
			 && $tokens[$i + 2][0] === T_STRING
236
			 && $tokens[$i + 2][1] === 'at'
237
			 && $tokens[$i + 3]    === '('
238
			 && $tokens[$i + 4][0] === T_VARIABLE
239
			 && $tokens[$i + 4][1] === '$node'
240
			 && $tokens[$i + 5]    === ')'
241
			 && $tokens[$i + 6]    === ';')
242
			{
243
				if (++$branch['passthrough'] > 1)
244
				{
245
					// Multiple passthroughs are not supported
246
					return false;
247
				}
248
249
				// Skip to the semi-colon
250
				$i += 6;
251
252
				continue;
253
			}
254
255
			$key = ($branch['passthrough']) ? 'tail' : 'head';
256
			$branch[$key] .= (is_array($tokens[$i])) ? $tokens[$i][1] : $tokens[$i];
257
258
			if ($tokens[$i] === '{')
259
			{
260
				++$braces;
261
				continue;
262
			}
263
264
			if ($tokens[$i] === '}')
265
			{
266
				--$braces;
267
268
				if ($branch['braces'] === $braces)
269
				{
270
					// Remove the last brace from the branch's content
271
					$branch[$key] = substr($branch[$key], 0, -1);
272
273
					// Jump back to the parent branch
274
					$branch =& $branch['parent'];
275
276
					// Copy the current index to look ahead
277
					$j = $i;
278
279
					// Skip whitespace
280
					while ($tokens[++$j][0] === T_WHITESPACE);
281
282
					// Test whether this is the last brace of an if-else structure by looking for
283
					// an additional elseif/else case
284
					if ($tokens[$j][0] !== T_ELSEIF
285
					 && $tokens[$j][0] !== T_ELSE)
286
					{
287
						$passthroughs = self::getBranchesPassthrough($branch['branches']);
288
289
						if ($passthroughs === [0])
290
						{
291
							// No branch was passthrough, move their PHP source back to this branch
292
							// then discard the data
293
							foreach ($branch['branches'] as $child)
294
							{
295
								$branch['head'] .= $child['statement'] . '{' . $child['head'] . '}';
296
							}
297
298
							$branch['branches'] = [];
299
							continue;
300
						}
301
302
						if ($passthroughs === [1])
303
						{
304
							// All branches were passthrough, so their parent is passthrough
305
							++$branch['passthrough'];
306
307
							continue;
308
						}
309
310
						// Mixed branches (with/out passthrough) are not supported
311
						return false;
312
					}
313
				}
314
315
				continue;
316
			}
317
318
			// We don't have to record child branches if we know that current branch is passthrough.
319
			// If a child branch contains a passthrough, it will be treated as a multiple
320
			// passthrough and we will abort
321
			if ($branch['passthrough'])
322
			{
323
				continue;
324
			}
325
326
			if ($tokens[$i][0] === T_IF
327
			 || $tokens[$i][0] === T_ELSEIF
328
			 || $tokens[$i][0] === T_ELSE)
329
			{
330
				// Remove the statement from the branch's content
331
				$branch[$key] = substr($branch[$key], 0, -strlen($tokens[$i][1]));
332
333
				// Create a new branch
334
				$branch['branches'][] = [
335
					'braces'      => $braces,
336
					'branches'    => [],
337
					'head'        => '',
338
					'parent'      => &$branch,
339
					'passthrough' => 0,
340
					'statement'   => '',
341
					'tail'        => ''
342
				];
343
344
				// Jump to the new branch
345
				$branch =& $branch['branches'][count($branch['branches']) - 1];
346
347
				// Record the PHP statement
348
				do
349
				{
350
					$branch['statement'] .= (is_array($tokens[$i])) ? $tokens[$i][1] : $tokens[$i];
351
				}
352
				while ($tokens[++$i] !== '{');
353
354
				// Account for the brace in the statement
355
				++$braces;
356
			}
357
		}
358
		while (++$i < $cnt);
359
360
		list($head, $tail) = self::buildPHP($branch['branches']);
361
		$head  = $branch['head'] . $head;
362
		$tail .= $branch['tail'];
363
364
		// Convert the PHP renderer source to the format used in the Quick renderer
365
		self::convertPHP($head, $tail, (bool) $branch['passthrough']);
366
367
		// Test whether any method call was left unconverted. If so, we cannot render this template
368
		if (preg_match('((?<!-|\\$this)->)', $head . $tail))
369
		{
370
			return false;
371
		}
372
373
		return ($branch['passthrough']) ? [$head, $tail] : [$head];
374
	}
375
376
	/**
377
	* Convert the two sides of a compiled template to quick rendering
378
	*
379
	* @param  string &$head
380
	* @param  string &$tail
381
	* @param  bool    $passthrough
382
	* @return void
383
	*/
384
	protected static function convertPHP(&$head, &$tail, $passthrough)
385
	{
386
		// Test whether the attributes must be saved when rendering the head because they're needed
387
		// when rendering the tail
388
		$saveAttributes = (bool) preg_match('(\\$node->(?:get|has)Attribute)', $tail);
389
390
		// Collect the names of all the attributes so that we can initialize them with a null value
391
		// to avoid undefined variable notices. We exclude attributes that seem to be in an if block
392
		// that tests its existence beforehand. This last part is not an accurate process as it
393
		// would be much more expensive to do it accurately but where it fails the only consequence
394
		// is we needlessly add the attribute to the list. There is no difference in functionality
395
		preg_match_all(
396
			"(\\\$node->getAttribute\\('([^']+)'\\))",
397
			preg_replace_callback(
398
				'(if\\(\\$node->hasAttribute\\(([^\\)]+)[^}]+)',
399
				function ($m)
400
				{
401
					return str_replace('$node->getAttribute(' . $m[1] . ')', '', $m[0]);
402
				},
403
				$head . $tail
404
			),
405
			$matches
406
		);
407
		$attrNames = array_unique($matches[1]);
408
409
		// Replace the source in $head and $tail
410
		self::replacePHP($head);
411
		self::replacePHP($tail);
412
413
		if (!$passthrough && strpos($head, '$node->textContent') !== false)
414
		{
415
			$head = '$textContent=$this->getQuickTextContent($xml);' . str_replace('$node->textContent', '$textContent', $head);
416
		}
417
418
		if (!empty($attrNames))
419
		{
420
			ksort($attrNames);
421
			$head = "\$attributes+=['" . implode("'=>null,'", $attrNames) . "'=>null];" . $head;
422
		}
423
424
		if ($saveAttributes)
425
		{
426
			$head .= '$this->attributes[]=$attributes;';
427
			$tail  = '$attributes=array_pop($this->attributes);' . $tail;
428
		}
429
	}
430
431
	/**
432
	* Replace the PHP code used in a compiled template to be used by the Quick renderer
433
	*
434
	* @param  string &$php
435
	* @return void
436
	*/
437
	protected static function replacePHP(&$php)
438
	{
439
		// Expression that matches a $node->getAttribute() call and captures its string argument
440
		$getAttribute = "\\\$node->getAttribute\\(('[^']+')\\)";
441
442
		$replacements = [
443
			'$this->out' => '$html',
444
445
			// An attribute value escaped as ENT_NOQUOTES. We only need to unescape quotes
446
			'(htmlspecialchars\\(' . $getAttribute . ',' . ENT_NOQUOTES . '\\))'
447
				=> "str_replace('&quot;','\"',\$attributes[\$1])",
448
449
			// An attribute value escaped as ENT_COMPAT can be used as-is
450
			'(htmlspecialchars\\(' . $getAttribute . ',' . ENT_COMPAT . '\\))' => '$attributes[$1]',
451
452
			// Character replacement can be performed directly on the escaped value provided that it
453
			// is then escaped as ENT_COMPAT and that replacements do not interfere with the escaping
454
			// of the characters &<>" or their representation &amp;&lt;&gt;&quot;
455
			'(htmlspecialchars\\(strtr\\(' . $getAttribute . ",('[^\"&\\\\';<>aglmopqtu]+'),('[^\"&\\\\'<>]+')\\)," . ENT_COMPAT . '\\))'
456
				=> 'strtr($attributes[$1],$2,$3)',
457
458
			// A comparison between two attributes. No need to unescape
459
			'(' . $getAttribute . '(!?=+)' . $getAttribute . ')'
460
				=> '$attributes[$1]$2$attributes[$3]',
461
462
			// A comparison between an attribute and a literal string. Rather than unescape the
463
			// attribute value, we escape the literal. This applies to comparisons using XPath's
464
			// contains() as well (translated to PHP's strpos())
465
			'(' . $getAttribute . "===('.*?(?<!\\\\)(?:\\\\\\\\)*'))s"
466
				=> function ($m)
467
				{
468
					return '$attributes[' . $m[1] . ']===' . htmlspecialchars($m[2], ENT_COMPAT);
469
				},
470
471
			"(('.*?(?<!\\\\)(?:\\\\\\\\)*')===" . $getAttribute . ')s'
472
				=> function ($m)
473
				{
474
					return htmlspecialchars($m[1], ENT_COMPAT) . '===$attributes[' . $m[2] . ']';
475
				},
476
477
			'(strpos\\(' . $getAttribute . ",('.*?(?<!\\\\)(?:\\\\\\\\)*')\\)([!=]==(?:0|false)))s"
478
				=> function ($m)
479
				{
480
					return 'strpos($attributes[' . $m[1] . "]," . htmlspecialchars($m[2], ENT_COMPAT) . ')' . $m[3];
481
				},
482
483
			"(strpos\\(('.*?(?<!\\\\)(?:\\\\\\\\)*')," . $getAttribute . '\\)([!=]==(?:0|false)))s'
484
				=> function ($m)
485
				{
486
					return 'strpos(' . htmlspecialchars($m[1], ENT_COMPAT) . ',$attributes[' . $m[2] . '])' . $m[3];
487
				},
488
489
			// An attribute value used in an arithmetic comparison or operation does not need to be
490
			// unescaped. The same applies to empty(), isset() and conditionals
491
			'(' . $getAttribute . '(?=(?:==|[-+*])\\d+))'        => '$attributes[$1]',
492
			'((?<!\\w)(\\d+(?:==|[-+*]))' . $getAttribute . ')'  => '$1$attributes[$2]',
493
			"(empty\\(\\\$node->getAttribute\\(('[^']+')\\)\\))" => 'empty($attributes[$1])',
494
			"(\\\$node->hasAttribute\\(('[^']+')\\))"            => 'isset($attributes[$1])',
495
			'if($node->attributes->length)' => 'if($this->hasNonNullValues($attributes))',
496
497
			// In all other situations, unescape the attribute value before use
498
			"(\\\$node->getAttribute\\(('[^']+')\\))" => 'htmlspecialchars_decode($attributes[$1])'
499
		];
500
501
		foreach ($replacements as $match => $replace)
502
		{
503
			if ($replace instanceof Closure)
504
			{
505
				$php = preg_replace_callback($match, $replace, $php);
506
			}
507
			elseif ($match[0] === '(')
508
			{
509
				$php = preg_replace($match, $replace, $php);
510
			}
511
			else
512
			{
513
				$php = str_replace($match, $replace, $php);
514
			}
515
		}
516
	}
517
518
	/**
519
	* Build the source for the two sides of a templates based on the structure extracted from its
520
	* original source
521
	*
522
	* @param  array    $branches
523
	* @return string[]
524
	*/
525
	protected static function buildPHP(array $branches)
526
	{
527
		$return = ['', ''];
528
		foreach ($branches as $branch)
529
		{
530
			$return[0] .= $branch['statement'] . '{' . $branch['head'];
531
			$return[1] .= $branch['statement'] . '{';
532
533
			if ($branch['branches'])
534
			{
535
				list($head, $tail) = self::buildPHP($branch['branches']);
536
537
				$return[0] .= $head;
538
				$return[1] .= $tail;
539
			}
540
541
			$return[0] .= '}';
542
			$return[1] .= $branch['tail'] . '}';
543
		}
544
545
		return $return;
546
	}
547
548
	/**
549
	* Get the unique values for the "passthrough" key of given branches
550
	*
551
	* @param  array     $branches
552
	* @return integer[]
553
	*/
554
	protected static function getBranchesPassthrough(array $branches)
555
	{
556
		$values = [];
557
		foreach ($branches as $branch)
558
		{
559
			$values[] = $branch['passthrough'];
560
		}
561
562
		// If the last branch isn't an "else", we act as if there was an additional branch with no
563
		// passthrough
564
		if ($branch['statement'] !== 'else')
565
		{
566
			$values[] = 0;
567
		}
568
569
		return array_unique($values);
570
	}
571
572
	/**
573
	* Get a string suitable as a preg_replace() replacement for given PHP code
574
	*
575
	* @param  string     $php Original code
576
	* @return array|bool      Array of [regexp, replacement] if possible, or FALSE otherwise
577
	*/
578
	protected static function getDynamicRendering($php)
579
	{
580
		$rendering = '';
581
582
		$literal   = "(?<literal>'((?>[^'\\\\]+|\\\\['\\\\])*)')";
583
		$attribute = "(?<attribute>htmlspecialchars\\(\\\$node->getAttribute\\('([^']+)'\\),2\\))";
584
		$value     = "(?<value>$literal|$attribute)";
585
		$output    = "(?<output>\\\$this->out\\.=$value(?:\\.(?&value))*;)";
586
587
		$copyOfAttribute = "(?<copyOfAttribute>if\\(\\\$node->hasAttribute\\('([^']+)'\\)\\)\\{\\\$this->out\\.=' \\g-1=\"'\\.htmlspecialchars\\(\\\$node->getAttribute\\('\\g-1'\\),2\\)\\.'\"';\\})";
588
589
		$regexp = '(^(' . $output . '|' . $copyOfAttribute . ')*$)';
590
591
		if (!preg_match($regexp, $php, $m))
592
		{
593
			return false;
594
		}
595
596
		// Attributes that are copied in the replacement
597
		$copiedAttributes = [];
598
599
		// Attributes whose value is used in the replacement
600
		$usedAttributes = [];
601
602
		$regexp = '(' . $output . '|' . $copyOfAttribute . ')A';
603
		$offset = 0;
604
		while (preg_match($regexp, $php, $m, 0, $offset))
605
		{
606
			// Test whether it's normal output or a copy of attribute
607
			if ($m['output'])
608
			{
609
				// 12 === strlen('$this->out.=')
610
				$offset += 12;
611
612
				while (preg_match('(' . $value . ')A', $php, $m, 0, $offset))
613
				{
614
					// Test whether it's a literal or an attribute value
615
					if ($m['literal'])
616
					{
617
						// Unescape the literal
618
						$str = stripslashes(substr($m[0], 1, -1));
619
620
						// Escape special characters
621
						$rendering .= preg_replace('([\\\\$](?=\\d))', '\\\\$0', $str);
622
					}
623
					else
624
					{
625
						$attrName = end($m);
626
627
						// Generate a unique ID for this attribute name, we'll use it as a
628
						// placeholder until we have the full list of captures and we can replace it
629
						// with the capture number
630
						if (!isset($usedAttributes[$attrName]))
631
						{
632
							$usedAttributes[$attrName] = uniqid($attrName, true);
633
						}
634
635
						$rendering .= $usedAttributes[$attrName];
636
					}
637
638
					// Skip the match plus the next . or ;
639
					$offset += 1 + strlen($m[0]);
640
				}
641
			}
642
			else
643
			{
644
				$attrName = end($m);
645
646
				if (!isset($copiedAttributes[$attrName]))
647
				{
648
					$copiedAttributes[$attrName] = uniqid($attrName, true);
649
				}
650
651
				$rendering .= $copiedAttributes[$attrName];
652
				$offset += strlen($m[0]);
653
			}
654
		}
655
656
		// Gather the names of the attributes used in the replacement either by copy or by value
657
		$attrNames = array_keys($copiedAttributes + $usedAttributes);
658
659
		// Sort them alphabetically
660
		sort($attrNames);
661
662
		// Keep a copy of the attribute names to be used in the fillter subpattern
663
		$remainingAttributes = array_combine($attrNames, $attrNames);
664
665
		// Prepare the final regexp
666
		$regexp = '(^[^ ]+';
667
		$index  = 0;
668
		foreach ($attrNames as $attrName)
669
		{
670
			// Add a subpattern that matches (and skips) any attribute definition that is not one of
671
			// the remaining attributes we're trying to match
672
			$regexp .= '(?> (?!' . RegexpBuilder::fromList($remainingAttributes) . '=)[^=]+="[^"]*")*';
673
			unset($remainingAttributes[$attrName]);
674
675
			$regexp .= '(';
676
677
			if (isset($copiedAttributes[$attrName]))
678
			{
679
				self::replacePlaceholder($rendering, $copiedAttributes[$attrName], ++$index);
680
			}
681
			else
682
			{
683
				$regexp .= '?>';
684
			}
685
686
			$regexp .= ' ' . $attrName . '="';
687
688
			if (isset($usedAttributes[$attrName]))
689
			{
690
				$regexp .= '(';
691
692
				self::replacePlaceholder($rendering, $usedAttributes[$attrName], ++$index);
693
			}
694
695
			$regexp .= '[^"]*';
696
697
			if (isset($usedAttributes[$attrName]))
698
			{
699
				$regexp .= ')';
700
			}
701
702
			$regexp .= '")?';
703
		}
704
705
		$regexp .= '.*)s';
706
707
		return [$regexp, $rendering];
708
	}
709
710
	/**
711
	* Get a string suitable as a str_replace() replacement for given PHP code
712
	*
713
	* @param  string      $php Original code
714
	* @return bool|string      Static replacement if possible, or FALSE otherwise
715
	*/
716
	protected static function getStaticRendering($php)
717
	{
718
		if ($php === '')
719
		{
720
			return '';
721
		}
722
723
		$regexp = "(^\\\$this->out\.='((?>[^'\\\\]+|\\\\['\\\\])*)';\$)";
724
		if (preg_match($regexp, $php, $m))
725
		{
726
			return stripslashes($m[1]);
727
		}
728
729
		return false;
730
	}
731
732
	/**
733
	* Replace all instances of a uniqid with a PCRE replacement in a string
734
	*
735
	* @param  string  &$str    PCRE replacement
736
	* @param  string   $uniqid Unique ID
737
	* @param  integer  $index  Capture index
738
	* @return void
739
	*/
740
	protected static function replacePlaceholder(&$str, $uniqid, $index)
741
	{
742
		$str = preg_replace_callback(
743
			'(' . preg_quote($uniqid) . '(.))',
744
			function ($m) use ($index)
745
			{
746
				// Replace with $1 where unambiguous and ${1} otherwise
747
				if (is_numeric($m[1]))
748
				{
749
					return '${' . $index . '}' . $m[1];
750
				}
751
				else
752
				{
753
					return '$' . $index . $m[1];
754
				}
755
			},
756
			$str
757
		);
758
	}
759
760
	/**
761
	* Generate a series of conditionals
762
	*
763
	* @param  string $expr       Expression tested for equality
764
	* @param  array  $statements List of PHP statements
765
	* @return string
766
	*/
767
	public static function generateConditionals($expr, array $statements)
768
	{
769
		$keys = array_keys($statements);
770
		$cnt  = count($statements);
771
		$min  = (int) $keys[0];
772
		$max  = (int) $keys[$cnt - 1];
773
774
		if ($cnt <= 4)
775
		{
776
			if ($cnt === 1)
777
			{
778
				return end($statements);
779
			}
780
781
			$php = '';
782
			$k = $min;
783
			do
784
			{
785
				$php .= 'if(' . $expr . '===' . $k . '){' . $statements[$k] . '}else';
786
			}
787
			while (++$k < $max);
788
789
			$php .= '{' . $statements[$max] . '}';
790
			
791
			return $php;
792
		}
793
794
		$cutoff = ceil($cnt / 2);
795
		$chunks = array_chunk($statements, $cutoff, true);
796
797
		return 'if(' . $expr . '<' . key($chunks[1]) . '){' . self::generateConditionals($expr, array_slice($statements, 0, $cutoff, true)) . '}else' . self::generateConditionals($expr, array_slice($statements, $cutoff, null, true));
798
	}
799
800
	/**
801
	* Generate a branch table (with its source) for an array of PHP statements
802
	*
803
	* @param  string $expr       PHP expression used to determine the branch
804
	* @param  array  $statements Map of [value => statement]
805
	* @return array              Two elements: first is the branch table, second is the source
806
	*/
807
	public static function generateBranchTable($expr, array $statements)
808
	{
809
		// Map of [statement => id]
810
		$branchTable = [];
811
812
		// Map of [value => id]
813
		$branchIds = [];
814
815
		// Sort the PHP statements by the value used to identify their branch
816
		ksort($statements);
817
818
		foreach ($statements as $value => $statement)
819
		{
820
			if (!isset($branchIds[$statement]))
821
			{
822
				$branchIds[$statement] = count($branchIds);
823
			}
824
825
			$branchTable[$value] = $branchIds[$statement];
826
		}
827
828
		return [$branchTable, self::generateConditionals($expr, array_keys($branchIds))];
829
	}
830
}