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