Completed
Branch PHPRenderer (6c13a8)
by Josh
10:40
created

Quick::getStaticRendering()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 1
dl 0
loc 16
rs 9.4285
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 RuntimeException;
11
use s9e\TextFormatter\Configurator\Helpers\RegexpBuilder;
12
13
class Quick
14
{
15
	/**
16
	* Generate the Quick renderer's source
17
	*
18
	* @param  array  $compiledTemplates Array of tagName => compiled template
19
	* @return string
20
	*/
21
	public static function getSource(array $compiledTemplates)
22
	{
23
		$map         = ['dynamic' => [], 'php' => [], 'static' => []];
24
		$tagNames    = [];
25
		$unsupported = [];
26
27
		// Ignore system tags
28
		unset($compiledTemplates['br']);
29
		unset($compiledTemplates['e']);
30
		unset($compiledTemplates['i']);
31
		unset($compiledTemplates['p']);
32
		unset($compiledTemplates['s']);
33
34
		foreach ($compiledTemplates as $tagName => $php)
35
		{
36
			if (preg_match('(^(?:br|[ieps])$)', $tagName))
37
			{
38
				continue;
39
			}
40
41
			$rendering = self::getRenderingStrategy($php);
42
			if ($rendering === false)
43
			{
44
				$unsupported[] = $tagName;
45
				continue;
46
			}
47
48
			foreach ($rendering as $i => list($strategy, $replacement))
49
			{
50
				$match = (($i) ? '/' : '') . $tagName;
51
				$map[$strategy][$match] = $replacement;
52
			}
53
54
			// Record the names of tags whose template does not contain a passthrough
55
			if (!isset($rendering[1]))
56
			{
57
				$tagNames[] = $tagName;
58
			}
59
		}
60
61
		$php = [];
62
		$php[] = '	/** {@inheritdoc} */';
63
		$php[] = '	public $enableQuickRenderer=true;';
64
		$php[] = '	/** {@inheritdoc} */';
65
		$php[] = '	protected $static=' . self::export($map['static']) . ';';
66
		$php[] = '	/** {@inheritdoc} */';
67
		$php[] = '	protected $dynamic=' . self::export($map['dynamic']) . ';';
68
69
		$quickSource = '';
70
		if (!empty($map['php']))
71
		{
72
			list($quickBranches, $quickSource) = self::generateBranchTable('$qb', $map['php']);
73
			$php[] = '	/** {@inheritdoc} */';
74
			$php[] = '	protected $quickBranches=' . self::export($quickBranches) . ';';
75
		}
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, ['useLookahead' => true]) . '[ />]))';
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)->(?!params\\[))', $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
		if (strpos($php, "\$this->out.='';") !== false)
450
		{
451
			die($php);
0 ignored issues
show
Coding Style Compatibility introduced by
The method replacePHP() contains an exit expression.

An exit expression should only be used in rare cases. For example, if you write a short command line script.

In most cases however, using an exit expression makes the code untestable and often causes incompatibilities with other libraries. Thus, unless you are absolutely sure it is required here, we recommend to refactor your code to avoid its usage.

Loading history...
452
		}
453
454
		$php = str_replace('$this->out', '$html', $php);
455
456
		// Expression that matches a $node->getAttribute() call and captures its string argument
457
		$getAttribute = "\\\$node->getAttribute\\(('[^']+')\\)";
458
459
		// An attribute value escaped as ENT_NOQUOTES. We only need to unescape quotes
460
		$php = preg_replace(
461
			'(htmlspecialchars\\(' . $getAttribute . ',' . ENT_NOQUOTES . '\\))',
462
			"str_replace('&quot;','\"',\$attributes[\$1])",
463
			$php
464
		);
465
466
		// An attribute value escaped as ENT_COMPAT can be used as-is
467
		$php = preg_replace(
468
			'(htmlspecialchars\\(' . $getAttribute . ',' . ENT_COMPAT . '\\))',
469
			'$attributes[$1]',
470
			$php
471
		);
472
473
		// Character replacement can be performed directly on the escaped value provided that it is
474
		// then escaped as ENT_COMPAT and that replacements do not interfere with the escaping of
475
		// the characters &<>" or their representation &amp;&lt;&gt;&quot;
476
		$php = preg_replace(
477
			'(htmlspecialchars\\(strtr\\(' . $getAttribute . ",('[^\"&\\\\';<>aglmopqtu]+'),('[^\"&\\\\'<>]+')\\)," . ENT_COMPAT . '\\))',
478
			'strtr($attributes[$1],$2,$3)',
479
			$php
480
		);
481
482
		// A comparison between two attributes. No need to unescape
483
		$php = preg_replace(
484
			'(' . $getAttribute . '(!?=+)' . $getAttribute . ')',
485
			'$attributes[$1]$2$attributes[$3]',
486
			$php
487
		);
488
489
		// A comparison between an attribute and a literal string. Rather than unescape the
490
		// attribute value, we escape the literal. This applies to comparisons using XPath's
491
		// contains() as well (translated to PHP's strpos())
492
		$php = preg_replace_callback(
493
			'(' . $getAttribute . "===('.*?(?<!\\\\)(?:\\\\\\\\)*'))s",
494
			function ($m)
495
			{
496
				return '$attributes[' . $m[1] . ']===' . htmlspecialchars($m[2], ENT_COMPAT);
497
			},
498
			$php
499
		);
500
		$php = preg_replace_callback(
501
			"(('.*?(?<!\\\\)(?:\\\\\\\\)*')===" . $getAttribute . ')s',
502
			function ($m)
503
			{
504
				return htmlspecialchars($m[1], ENT_COMPAT) . '===$attributes[' . $m[2] . ']';
505
			},
506
			$php
507
		);
508
		$php = preg_replace_callback(
509
			'(strpos\\(' . $getAttribute . ",('.*?(?<!\\\\)(?:\\\\\\\\)*')\\)([!=]==(?:0|false)))s",
510
			function ($m)
511
			{
512
				return 'strpos($attributes[' . $m[1] . "]," . htmlspecialchars($m[2], ENT_COMPAT) . ')' . $m[3];
513
			},
514
			$php
515
		);
516
		$php = preg_replace_callback(
517
			"(strpos\\(('.*?(?<!\\\\)(?:\\\\\\\\)*')," . $getAttribute . '\\)([!=]==(?:0|false)))s',
518
			function ($m)
519
			{
520
				return 'strpos(' . htmlspecialchars($m[1], ENT_COMPAT) . ',$attributes[' . $m[2] . '])' . $m[3];
521
			},
522
			$php
523
		);
524
525
		// An attribute value used in an arithmetic comparison or operation does not need to be
526
		// unescaped. The same applies to empty() and isset()
527
		$php = preg_replace(
528
			'(' . $getAttribute . '(?=(?:==|[-+*])\\d+))',
529
			'$attributes[$1]',
530
			$php
531
		);
532
		$php = preg_replace(
533
			'((?<!\\w)(\\d+(?:==|[-+*]))' . $getAttribute . ')',
534
			'$1$attributes[$2]',
535
			$php
536
		);
537
		$php = preg_replace(
538
			"(empty\\(\\\$node->getAttribute\\(('[^']+')\\)\\))",
539
			'empty($attributes[$1])',
540
			$php
541
		);
542
		$php = preg_replace(
543
			"(\\\$node->hasAttribute\\(('[^']+')\\))",
544
			'isset($attributes[$1])',
545
			$php
546
		);
547
		$php = str_replace(
548
			'($node->attributes->length)',
549
			'($this->hasNonNullValues($attributes))',
550
			$php
551
		);
552
553
		// In all other situations, unescape the attribute value before use
554
		$php = preg_replace(
555
			"(\\\$node->getAttribute\\(('[^']+')\\))",
556
			'htmlspecialchars_decode($attributes[$1])',
557
			$php
558
		);
559
	}
560
561
	/**
562
	* Build the source for the two sides of a templates based on the structure extracted from its
563
	* original source
564
	*
565
	* @param  array    $branches
566
	* @return string[]
567
	*/
568
	protected static function buildPHP(array $branches)
569
	{
570
		$return = ['', ''];
571
		foreach ($branches as $branch)
572
		{
573
			$return[0] .= $branch['statement'] . '{' . $branch['head'];
574
			$return[1] .= $branch['statement'] . '{';
575
576
			if ($branch['branches'])
577
			{
578
				list($head, $tail) = self::buildPHP($branch['branches']);
579
580
				$return[0] .= $head;
581
				$return[1] .= $tail;
582
			}
583
584
			$return[0] .= '}';
585
			$return[1] .= $branch['tail'] . '}';
586
		}
587
588
		return $return;
589
	}
590
591
	/**
592
	* Get the unique values for the "passthrough" key of given branches
593
	*
594
	* @param  array     $branches
595
	* @return integer[]
596
	*/
597
	protected static function getBranchesPassthrough(array $branches)
598
	{
599
		$values = [];
600
		foreach ($branches as $branch)
601
		{
602
			$values[] = $branch['passthrough'];
603
		}
604
605
		// If the last branch isn't an "else", we act as if there was an additional branch with no
606
		// passthrough
607
		if ($branch['statement'] !== 'else')
608
		{
609
			$values[] = 0;
610
		}
611
612
		return array_unique($values);
613
	}
614
615
	/**
616
	* Get a string suitable as a preg_replace() replacement for given PHP code
617
	*
618
	* @param  string     $php Original code
619
	* @return array|bool      Array of [regexp, replacement] if possible, or FALSE otherwise
620
	*/
621
	protected static function getDynamicRendering($php)
622
	{
623
		$rendering = '';
624
625
		$literal   = "(?<literal>'((?>[^'\\\\]+|\\\\['\\\\])*)')";
626
		$attribute = "(?<attribute>htmlspecialchars\\(\\\$node->getAttribute\\('([^']+)'\\),2\\))";
627
		$value     = "(?<value>$literal|$attribute)";
628
		$output    = "(?<output>\\\$this->out\\.=$value(?:\\.(?&value))*;)";
629
630
		$copyOfAttribute = "(?<copyOfAttribute>if\\(\\\$node->hasAttribute\\('([^']+)'\\)\\)\\{\\\$this->out\\.=' \\g-1=\"'\\.htmlspecialchars\\(\\\$node->getAttribute\\('\\g-1'\\),2\\)\\.'\"';\\})";
631
632
		$regexp = '(^(' . $output . '|' . $copyOfAttribute . ')*$)';
633
634
		if (!preg_match($regexp, $php, $m))
635
		{
636
			return false;
637
		}
638
639
		// Attributes that are copied in the replacement
640
		$copiedAttributes = [];
641
642
		// Attributes whose value is used in the replacement
643
		$usedAttributes = [];
644
645
		$regexp = '(' . $output . '|' . $copyOfAttribute . ')A';
646
		$offset = 0;
647
		while (preg_match($regexp, $php, $m, 0, $offset))
648
		{
649
			// Test whether it's normal output or a copy of attribute
650
			if ($m['output'])
651
			{
652
				// 12 === strlen('$this->out.=')
653
				$offset += 12;
654
655
				while (preg_match('(' . $value . ')A', $php, $m, 0, $offset))
656
				{
657
					// Test whether it's a literal or an attribute value
658
					if ($m['literal'])
659
					{
660
						// Unescape the literal
661
						$str = stripslashes(substr($m[0], 1, -1));
662
663
						// Escape special characters
664
						$rendering .= preg_replace('([\\\\$](?=\\d))', '\\\\$0', $str);
665
					}
666
					else
667
					{
668
						$attrName = end($m);
669
670
						// Generate a unique ID for this attribute name, we'll use it as a
671
						// placeholder until we have the full list of captures and we can replace it
672
						// with the capture number
673
						if (!isset($usedAttributes[$attrName]))
674
						{
675
							$usedAttributes[$attrName] = uniqid($attrName, true);
676
						}
677
678
						$rendering .= $usedAttributes[$attrName];
679
					}
680
681
					// Skip the match plus the next . or ;
682
					$offset += 1 + strlen($m[0]);
683
				}
684
			}
685
			else
686
			{
687
				$attrName = end($m);
688
689
				if (!isset($copiedAttributes[$attrName]))
690
				{
691
					$copiedAttributes[$attrName] = uniqid($attrName, true);
692
				}
693
694
				$rendering .= $copiedAttributes[$attrName];
695
				$offset += strlen($m[0]);
696
			}
697
		}
698
699
		// Gather the names of the attributes used in the replacement either by copy or by value
700
		$attrNames = array_keys($copiedAttributes + $usedAttributes);
701
702
		// Sort them alphabetically
703
		sort($attrNames);
704
705
		// Keep a copy of the attribute names to be used in the fillter subpattern
706
		$remainingAttributes = array_combine($attrNames, $attrNames);
707
708
		// Prepare the final regexp
709
		$regexp = '(^[^ ]+';
710
		$index  = 0;
711
		foreach ($attrNames as $attrName)
712
		{
713
			// Add a subpattern that matches (and skips) any attribute definition that is not one of
714
			// the remaining attributes we're trying to match
715
			$regexp .= '(?> (?!' . RegexpBuilder::fromList($remainingAttributes) . '=)[^=]+="[^"]*")*';
716
			unset($remainingAttributes[$attrName]);
717
718
			$regexp .= '(';
719
720
			if (isset($copiedAttributes[$attrName]))
721
			{
722
				self::replacePlaceholder($rendering, $copiedAttributes[$attrName], ++$index);
723
			}
724
			else
725
			{
726
				$regexp .= '?>';
727
			}
728
729
			$regexp .= ' ' . $attrName . '="';
730
731
			if (isset($usedAttributes[$attrName]))
732
			{
733
				$regexp .= '(';
734
735
				self::replacePlaceholder($rendering, $usedAttributes[$attrName], ++$index);
736
			}
737
738
			$regexp .= '[^"]*';
739
740
			if (isset($usedAttributes[$attrName]))
741
			{
742
				$regexp .= ')';
743
			}
744
745
			$regexp .= '")?';
746
		}
747
748
		$regexp .= '.*)s';
749
750
		return [$regexp, $rendering];
751
	}
752
753
	/**
754
	* Get a string suitable as a str_replace() replacement for given PHP code
755
	*
756
	* @param  string      $php Original code
757
	* @return bool|string      Static replacement if possible, or FALSE otherwise
758
	*/
759
	protected static function getStaticRendering($php)
760
	{
761
		if ($php === '')
762
		{
763
			return '';
764
		}
765
766
		$regexp = "(^\\\$this->out\.='((?>[^'\\\\]+|\\\\['\\\\])*)';\$)";
767
768
		if (!preg_match($regexp, $php, $m))
769
		{
770
			return false;
771
		}
772
773
		return stripslashes($m[1]);
774
	}
775
776
	/**
777
	* Replace all instances of a uniqid with a PCRE replacement in a string
778
	*
779
	* @param  string  &$str    PCRE replacement
780
	* @param  string   $uniqid Unique ID
781
	* @param  integer  $index  Capture index
782
	* @return void
783
	*/
784
	protected static function replacePlaceholder(&$str, $uniqid, $index)
785
	{
786
		$str = preg_replace_callback(
787
			'(' . preg_quote($uniqid) . '(.))',
788
			function ($m) use ($index)
789
			{
790
				// Replace with $1 where unambiguous and ${1} otherwise
791
				if (is_numeric($m[1]))
792
				{
793
					return '${' . $index . '}' . $m[1];
794
				}
795
				else
796
				{
797
					return '$' . $index . $m[1];
798
				}
799
			},
800
			$str
801
		);
802
	}
803
804
	/**
805
	* Generate a series of conditionals
806
	*
807
	* @param  string $expr       Expression tested for equality
808
	* @param  array  $statements List of PHP statements
809
	* @return string
810
	*/
811
	public static function generateConditionals($expr, array $statements)
812
	{
813
		$keys = array_keys($statements);
814
		$cnt  = count($statements);
815
		$min  = (int) $keys[0];
816
		$max  = (int) $keys[$cnt - 1];
817
818
		if ($cnt <= 4)
819
		{
820
			if ($cnt === 1)
821
			{
822
				return end($statements);
823
			}
824
825
			$php = '';
826
			$k = $min;
827
			do
828
			{
829
				$php .= 'if(' . $expr . '===' . $k . '){' . $statements[$k] . '}else';
830
			}
831
			while (++$k < $max);
832
833
			$php .= '{' . $statements[$max] . '}';
834
			
835
			return $php;
836
		}
837
838
		$cutoff = ceil($cnt / 2);
839
		$chunks = array_chunk($statements, $cutoff, true);
840
841
		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));
842
	}
843
844
	/**
845
	* Generate a branch table (with its source) for an array of PHP statements
846
	*
847
	* @param  string $expr       PHP expression used to determine the branch
848
	* @param  array  $statements Map of [value => statement]
849
	* @return array              Two elements: first is the branch table, second is the source
850
	*/
851
	public static function generateBranchTable($expr, array $statements)
852
	{
853
		// Map of [statement => id]
854
		$branchTable = [];
855
856
		// Map of [value => id]
857
		$branchIds = [];
858
859
		// Sort the PHP statements by the value used to identify their branch
860
		ksort($statements);
861
862
		foreach ($statements as $value => $statement)
863
		{
864
			if (!isset($branchIds[$statement]))
865
			{
866
				$branchIds[$statement] = count($branchIds);
867
			}
868
869
			$branchTable[$value] = $branchIds[$statement];
870
		}
871
872
		return [$branchTable, self::generateConditionals($expr, array_keys($branchIds))];
873
	}
874
}