Completed
Push — master ( f71236...1eeec1 )
by Josh
12:29
created

Quick::generateConditionals()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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