Completed
Push — master ( 95ed3f...289b1e )
by Josh
21:28
created

Quick::getRenderingStrategy()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 15
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 15
rs 9.2
c 0
b 0
f 0
cc 4
eloc 6
nc 3
nop 1
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2018 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
			$renderings = self::getRenderingStrategy($php);
38
			if (empty($renderings))
39
			{
40
				$unsupported[] = $tagName;
41
				continue;
42
			}
43
44
			foreach ($renderings as $i => list($strategy, $replacement))
0 ignored issues
show
Bug introduced by
The expression $renderings of type array|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

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