Passed
Push — master ( 26ac52...beb008 )
by Josh
02:35
created

Quick::getBranchesPassthrough()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 16
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 16
ccs 7
cts 7
cp 1
rs 10
c 0
b 0
f 0
cc 3
nc 4
nop 1
crap 3
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2021 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 379
	public static function getSource(array $compiledTemplates)
23
	{
24 379
		$map         = ['dynamic' => [], 'php' => [], 'static' => []];
25 379
		$tagNames    = [];
26 379
		$unsupported = [];
27
28
		// Ignore system tags
29 379
		unset($compiledTemplates['br']);
30 379
		unset($compiledTemplates['e']);
31 379
		unset($compiledTemplates['i']);
32 379
		unset($compiledTemplates['p']);
33 379
		unset($compiledTemplates['s']);
34
35 379
		foreach ($compiledTemplates as $tagName => $php)
36
		{
37 360
			$renderings = self::getRenderingStrategy($php);
38 360
			if (empty($renderings))
39
			{
40 5
				$unsupported[] = $tagName;
41 5
				continue;
42
			}
43
44 358
			foreach ($renderings as $i => list($strategy, $replacement))
45
			{
46 358
				$match = (($i) ? '/' : '') . $tagName;
47 358
				$map[$strategy][$match] = $replacement;
48
			}
49
50
			// Record the names of tags whose template does not contain a passthrough
51 358
			if (!isset($renderings[1]))
52
			{
53 222
				$tagNames[] = $tagName;
54
			}
55
		}
56
57 379
		$php = [];
58 379
		$php[] = '	/** {@inheritdoc} */';
59 379
		$php[] = '	public $enableQuickRenderer=true;';
60 379
		$php[] = '	/** {@inheritdoc} */';
61 379
		$php[] = '	protected $static=' . self::export($map['static']) . ';';
62 379
		$php[] = '	/** {@inheritdoc} */';
63 379
		$php[] = '	protected $dynamic=' . self::export($map['dynamic']) . ';';
64
65 379
		$quickSource = '';
66 379
		if (!empty($map['php']))
67
		{
68 228
			$quickSource = SwitchStatement::generate('$id', $map['php']);
69
		}
70
71
		// Build a regexp that matches all the tags
72 379
		$regexp  = '(<(?:(?!/)(';
73 379
		$regexp .= ($tagNames) ? RegexpBuilder::fromList($tagNames) : '(?!)';
74 379
		$regexp .= ')(?: [^>]*)?>.*?</\\1|(/?(?!br/|p>)[^ />]+)[^>]*?(/)?)>)s';
75 379
		$php[] = '	/** {@inheritdoc} */';
76 379
		$php[] = '	protected $quickRegexp=' . var_export($regexp, true) . ';';
77
78
		// Build a regexp that matches tags that cannot be rendered with the Quick renderer
79 379
		if (!empty($unsupported))
80
		{
81 5
			$regexp = '((?<=<)(?:[!?]|' . RegexpBuilder::fromList($unsupported) . '[ />]))';
82 5
			$php[]  = '	/** {@inheritdoc} */';
83 5
			$php[]  = '	protected $quickRenderingTest=' . var_export($regexp, true) . ';';
84
		}
85
86 379
		$php[] = '	/** {@inheritdoc} */';
87 379
		$php[] = '	protected function renderQuickTemplate($id, $xml)';
88 379
		$php[] = '	{';
89 379
		$php[] = '		$attributes=$this->matchAttributes($xml);';
90 379
		$php[] = "		\$html='';" . $quickSource;
91 379
		$php[] = '';
92 379
		$php[] = '		return $html;';
93 379
		$php[] = '	}';
94
95 379
		return implode("\n", $php);
96
	}
97
98
	/**
99
	* Export an array as PHP
100
	*
101
	* @param  array  $arr
102
	* @return string
103
	*/
104 379
	protected static function export(array $arr)
105
	{
106 379
		$exportKeys = (array_keys($arr) !== range(0, count($arr) - 1));
107 379
		ksort($arr);
108
109 379
		$entries = [];
110 379
		foreach ($arr as $k => $v)
111
		{
112 182
			$entries[] = (($exportKeys) ? var_export($k, true) . '=>' : '')
113 182
			           . ((is_array($v)) ? self::export($v) : var_export($v, true));
114
		}
115
116 379
		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[]      An array containing 0 to 2 pairs of [<rendering type>, <replacement>]
124
	*/
125 390
	public static function getRenderingStrategy($php)
126
	{
127 390
		$phpRenderings = self::getQuickRendering($php);
128 390
		if (empty($phpRenderings))
129
		{
130 11
			return [];
131
		}
132 382
		$renderings = self::getStringRenderings($php);
133
134
		// Keep string rendering where possible, use PHP rendering wherever else
135 382
		foreach ($phpRenderings as $i => $phpRendering)
136
		{
137 382
			if (!isset($renderings[$i]) || strpos($phpRendering, '$this->attributes[]') !== false)
138
			{
139 238
				$renderings[$i] = ['php', $phpRendering];
140
			}
141
		}
142
143 382
		return $renderings;
144
	}
145
146
	/**
147
	* Generate the code for rendering a compiled template with the Quick renderer
148
	*
149
	* Parse and record every code path that contains a passthrough. Parse every if-else structure.
150
	* When the whole structure is parsed, there are 2 possible situations:
151
	*  - no code path contains a passthrough, in which case we discard the data
152
	*  - all the code paths including the mandatory "else" branch contain a passthrough, in which
153
	*    case we keep the data
154
	*
155
	* @param  string     $php Template compiled for the PHP renderer
156
	* @return string[]        An array containing one or two strings of PHP, or an empty array
157
	*                         if the PHP cannot be converted
158
	*/
159 390
	protected static function getQuickRendering($php)
160
	{
161
		// xsl:apply-templates elements with a select expression are not supported
162 390
		if (preg_match('(\\$this->at\\((?!\\$node\\);))', $php))
163
		{
164 1
			return [];
165
		}
166
167
		// Tokenize the PHP and add an empty token as terminator
168 389
		$tokens   = token_get_all('<?php ' . $php);
169 389
		$tokens[] = [0, ''];
170
171
		// Remove the first token, which is a T_OPEN_TAG
172 389
		array_shift($tokens);
173 389
		$cnt = count($tokens);
174
175
		// Prepare the main branch
176
		$branch = [
177
			// We purposefully use a value that can never match
178 389
			'braces'      => -1,
179
			'branches'    => [],
180
			'head'        => '',
181
			'passthrough' => 0,
182
			'statement'   => '',
183
			'tail'        => ''
184
		];
185
186 389
		$braces = 0;
187 389
		$i = 0;
188
		do
189
		{
190
			// Test whether we've reached a passthrough
191 389
			if ($tokens[$i    ][0] === T_VARIABLE
192 389
			 && $tokens[$i    ][1] === '$this'
193 389
			 && $tokens[$i + 1][0] === T_OBJECT_OPERATOR
194 389
			 && $tokens[$i + 2][0] === T_STRING
195 389
			 && $tokens[$i + 2][1] === 'at'
196 389
			 && $tokens[$i + 3]    === '('
197 389
			 && $tokens[$i + 4][0] === T_VARIABLE
198 389
			 && $tokens[$i + 4][1] === '$node'
199 389
			 && $tokens[$i + 5]    === ')'
200 389
			 && $tokens[$i + 6]    === ';')
201
			{
202 157
				if (++$branch['passthrough'] > 1)
203
				{
204
					// Multiple passthroughs are not supported
205 3
					return [];
206
				}
207
208
				// Skip to the semi-colon
209 157
				$i += 6;
210
211 157
				continue;
212
			}
213
214 389
			$key = ($branch['passthrough']) ? 'tail' : 'head';
215 389
			$branch[$key] .= (is_array($tokens[$i])) ? $tokens[$i][1] : $tokens[$i];
216
217 389
			if ($tokens[$i] === '{')
218
			{
219 14
				++$braces;
220 14
				continue;
221
			}
222
223 389
			if ($tokens[$i] === '}')
224
			{
225 180
				--$braces;
226
227 180
				if ($branch['braces'] === $braces)
228
				{
229
					// Remove the last brace from the branch's content
230 179
					$branch[$key] = substr($branch[$key], 0, -1);
0 ignored issues
show
Bug introduced by
It seems like $branch[$key] can also be of type array; however, parameter $string of substr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

230
					$branch[$key] = substr(/** @scrutinizer ignore-type */ $branch[$key], 0, -1);
Loading history...
231
232
					// Jump back to the parent branch
233 179
					$branch =& $branch['parent'];
234
235
					// Copy the current index to look ahead
236 179
					$j = $i;
237
238
					// Skip whitespace
239 179
					while ($tokens[++$j][0] === T_WHITESPACE);
240
241
					// Test whether this is the last brace of an if-else structure by looking for
242
					// an additional elseif/else case
243 179
					if ($tokens[$j][0] !== T_ELSEIF && $tokens[$j][0] !== T_ELSE)
244
					{
245 179
						$passthroughs = self::getBranchesPassthrough($branch['branches']);
246 179
						if ($passthroughs === [0])
247
						{
248
							// No branch was passthrough, move their PHP source back to this branch
249
							// then discard the data
250 173
							foreach ($branch['branches'] as $child)
251
							{
252 173
								$branch['head'] .= $child['statement'] . '{' . $child['head'] . '}';
253
							}
254
255 173
							$branch['branches'] = [];
256 173
							continue;
257
						}
258
259 27
						if ($passthroughs === [1])
260
						{
261
							// All branches were passthrough, so their parent is passthrough
262 26
							++$branch['passthrough'];
263
264 26
							continue;
265
						}
266
267
						// Mixed branches (with/out passthrough) are not supported
268 1
						return [];
269
					}
270
				}
271
272 96
				continue;
273
			}
274
275
			// We don't have to record child branches if we know that current branch is passthrough.
276
			// If a child branch contains a passthrough, it will be treated as a multiple
277
			// passthrough and we will abort
278 389
			if ($branch['passthrough'])
279
			{
280 155
				continue;
281
			}
282
283 382
			if ($tokens[$i][0] === T_IF
284 382
			 || $tokens[$i][0] === T_ELSEIF
285 382
			 || $tokens[$i][0] === T_ELSE)
286
			{
287
				// Remove the statement from the branch's content
288 179
				$branch[$key] = substr($branch[$key], 0, -strlen($tokens[$i][1]));
289
290
				// Create a new branch
291 179
				$branch['branches'][] = [
292 179
					'braces'      => $braces,
293
					'branches'    => [],
294 179
					'head'        => '',
295 179
					'parent'      => &$branch,
296 179
					'passthrough' => 0,
297 179
					'statement'   => '',
298 179
					'tail'        => ''
299
				];
300
301
				// Jump to the new branch
302 179
				$branch =& $branch['branches'][count($branch['branches']) - 1];
303
304
				// Record the PHP statement
305
				do
306
				{
307 179
					$branch['statement'] .= (is_array($tokens[$i])) ? $tokens[$i][1] : $tokens[$i];
308
				}
309 179
				while ($tokens[++$i] !== '{');
310
311
				// Account for the brace in the statement
312 179
				++$braces;
313
			}
314
		}
315 389
		while (++$i < $cnt);
316
317 386
		list($head, $tail) = self::buildPHP($branch['branches']);
318 386
		$head  = $branch['head'] . $head;
319 386
		$tail .= $branch['tail'];
320
321
		// Convert the PHP renderer source to the format used in the Quick renderer
322 386
		self::convertPHP($head, $tail, (bool) $branch['passthrough']);
323
324
		// Test whether any method call was left unconverted. If so, we cannot render this template
325 386
		if (preg_match('((?<!-|\\$this)->)', $head . $tail))
326
		{
327 6
			return [];
328
		}
329
330 382
		return ($branch['passthrough']) ? [$head, $tail] : [$head];
331
	}
332
333
	/**
334
	* Convert the two sides of a compiled template to quick rendering
335
	*
336
	* @param  string &$head
337
	* @param  string &$tail
338
	* @param  bool    $passthrough
339
	* @return void
340
	*/
341 386
	protected static function convertPHP(&$head, &$tail, $passthrough)
342
	{
343
		// Test whether the attributes must be saved when rendering the head because they're needed
344
		// when rendering the tail
345 386
		$saveAttributes = (bool) preg_match('(\\$node->(?:get|has)Attribute)', $tail);
346
347
		// Collect the names of all the attributes so that we can initialize them with a null value
348
		// to avoid undefined variable notices. We exclude attributes that seem to be in an if block
349
		// that tests its existence beforehand. This last part is not an accurate process as it
350
		// would be much more expensive to do it accurately but where it fails the only consequence
351
		// is we needlessly add the attribute to the list. There is no difference in functionality
352 386
		preg_match_all(
353 386
			"(\\\$node->getAttribute\\('([^']+)'\\))",
354 386
			preg_replace_callback(
355 386
				'(if\\(\\$node->hasAttribute\\(([^\\)]+)[^}]+)',
356 386
				function ($m)
357
				{
358 128
					return str_replace('$node->getAttribute(' . $m[1] . ')', '', $m[0]);
359 386
				},
360 386
				$head . $tail
361
			),
362
			$matches
363
		);
364 386
		$attrNames = array_unique($matches[1]);
365
366
		// Replace the source in $head and $tail
367 386
		self::replacePHP($head);
368 386
		self::replacePHP($tail);
369
370 386
		if (!$passthrough && strpos($head, '$node->textContent') !== false)
371
		{
372 9
			$head = '$textContent=$this->getQuickTextContent($xml);' . str_replace('$node->textContent', '$textContent', $head);
373
		}
374
375 386
		if (!empty($attrNames))
376
		{
377 285
			ksort($attrNames);
378 285
			$head = "\$attributes+=['" . implode("'=>null,'", $attrNames) . "'=>null];" . $head;
379
		}
380
381 386
		if ($saveAttributes)
382
		{
383 28
			$head .= '$this->attributes[]=$attributes;';
384 28
			$tail  = '$attributes=array_pop($this->attributes);' . $tail;
385
		}
386
	}
387
388
	/**
389
	* Replace the PHP code used in a compiled template to be used by the Quick renderer
390
	*
391
	* @param  string &$php
392
	* @return void
393
	*/
394 386
	protected static function replacePHP(&$php)
395
	{
396
		// Expression that matches a $node->getAttribute() call and captures its string argument
397 386
		$getAttribute = "\\\$node->getAttribute\\(('[^']+')\\)";
398
399
		// Expression that matches a single-quoted string literal
400 386
		$string       = "'(?:[^\\\\']|\\\\.)*+'";
401
402
		$replacements = [
403 386
			'$this->out' => '$html',
404
405
			// An attribute value escaped as ENT_NOQUOTES. We only need to unescape quotes
406 386
			'(htmlspecialchars\\(' . $getAttribute . ',' . ENT_NOQUOTES . '\\))'
407 386
				=> "str_replace('&quot;','\"',\$attributes[\$1])",
408
409
			// One or several attribute values escaped as ENT_COMPAT can be used as-is
410 386
			'(htmlspecialchars\\((' . $getAttribute . '(?:\\.' . $getAttribute . ')*),' . ENT_COMPAT . '\\))'
411 386
				=> function ($m) use ($getAttribute)
412
				{
413 210
					return preg_replace('(' . $getAttribute . ')', '$attributes[$1]', $m[1]);
414 386
				},
415
416
			// Character replacement can be performed directly on the escaped value provided that it
417
			// is then escaped as ENT_COMPAT and that replacements do not interfere with the escaping
418
			// of the characters &<>" or their representation &amp;&lt;&gt;&quot;
419 386
			'(htmlspecialchars\\(strtr\\(' . $getAttribute . ",('[^\"&\\\\';<>aglmopqtu]+'),('[^\"&\\\\'<>]+')\\)," . ENT_COMPAT . '\\))'
420 386
				=> 'strtr($attributes[$1],$2,$3)',
421
422
			// A comparison between two attributes. No need to unescape
423 386
			'(' . $getAttribute . '(!?=+)' . $getAttribute . ')'
424 386
				=> '$attributes[$1]$2$attributes[$3]',
425
426
			// A comparison between an attribute and a literal string. Rather than unescape the
427
			// attribute value, we escape the literal. This applies to comparisons using XPath's
428
			// contains() as well (translated to PHP's strpos())
429 386
			'(' . $getAttribute . '===(' . $string . '))s'
430 386
				=> function ($m)
431
				{
432 23
					return '$attributes[' . $m[1] . ']===' . htmlspecialchars($m[2], ENT_COMPAT);
433 386
				},
434
435 386
			'((' . $string . ')===' . $getAttribute . ')s'
436 386
				=> function ($m)
437
				{
438 2
					return htmlspecialchars($m[1], ENT_COMPAT) . '===$attributes[' . $m[2] . ']';
439 386
				},
440
441 386
			'(strpos\\(' . $getAttribute . ',(' . $string . ')\\)([!=]==(?:0|false)))s'
442 386
				=> function ($m)
443
				{
444 42
					return 'strpos($attributes[' . $m[1] . '],' . htmlspecialchars($m[2], ENT_COMPAT) . ')' . $m[3];
445 386
				},
446
447 386
			'(strpos\\((' . $string . '),' . $getAttribute . '\\)([!=]==(?:0|false)))s'
448 386
				=> function ($m)
449
				{
450 15
					return 'strpos(' . htmlspecialchars($m[1], ENT_COMPAT) . ',$attributes[' . $m[2] . '])' . $m[3];
451 386
				},
452
453 386
			'(str_(contains|(?:end|start)s_with)\\(' . $getAttribute . ',(' . $string . ')\\))s'
454 386
				=> function ($m)
455
				{
456
					return 'str_' . $m[1] . '($attributes[' . $m[2] . '],' . htmlspecialchars($m[3], ENT_COMPAT) . ')';
457 386
				},
458
459 386
			'(str_(contains|(?:end|start)s_with)\\((' . $string . '),' . $getAttribute . '\\))s'
460 386
				=> function ($m)
461
				{
462
					return 'str_' . $m[1] . '(' . htmlspecialchars($m[2], ENT_COMPAT) . ',$attributes[' . $m[3] . '])';
463 386
				},
464
465
			// An attribute value used in an arithmetic comparison or operation does not need to be
466
			// unescaped. The same applies to empty(), isset() and conditionals
467 386
			'(' . $getAttribute . '(?=(?:==|[-+*])\\d+))'  => '$attributes[$1]',
468 386
			'(\\b(\\d+(?:==|[-+*]))' . $getAttribute . ')' => '$1$attributes[$2]',
469 386
			'(empty\\(' . $getAttribute . '\\))'           => 'empty($attributes[$1])',
470 386
			"(\\\$node->hasAttribute\\(('[^']+')\\))"      => 'isset($attributes[$1])',
471 386
			'if($node->attributes->length)'                => 'if($this->hasNonNullValues($attributes))',
472
473
			// In all other situations, unescape the attribute value before use
474 386
			'(' . $getAttribute . ')' => 'htmlspecialchars_decode($attributes[$1])'
475
		];
476
477 386
		foreach ($replacements as $match => $replace)
478
		{
479 386
			if ($replace instanceof Closure)
480
			{
481 386
				$php = preg_replace_callback($match, $replace, $php);
482
			}
483 386
			elseif ($match[0] === '(')
484
			{
485 386
				$php = preg_replace($match, $replace, $php);
1 ignored issue
show
Bug introduced by
It seems like $replace can also be of type callable and callable and callable and callable and callable and callable and callable; however, parameter $replacement of preg_replace() does only seem to accept string|string[], maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

485
				$php = preg_replace($match, /** @scrutinizer ignore-type */ $replace, $php);
Loading history...
486
			}
487
			else
488
			{
489 386
				$php = str_replace($match, $replace, $php);
1 ignored issue
show
Bug introduced by
It seems like $replace can also be of type callable and callable and callable and callable and callable and callable and callable; however, parameter $replace of str_replace() does only seem to accept string|string[], maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

489
				$php = str_replace($match, /** @scrutinizer ignore-type */ $replace, $php);
Loading history...
490
			}
491
		}
492
	}
493
494
	/**
495
	* Build the source for the two sides of a templates based on the structure extracted from its
496
	* original source
497
	*
498
	* @param  array    $branches
499
	* @return string[]
500
	*/
501 386
	protected static function buildPHP(array $branches)
502
	{
503 386
		$return = ['', ''];
504 386
		foreach ($branches as $branch)
505
		{
506 25
			$return[0] .= $branch['statement'] . '{' . $branch['head'];
507 25
			$return[1] .= $branch['statement'] . '{';
508
509 25
			if ($branch['branches'])
510
			{
511 1
				list($head, $tail) = self::buildPHP($branch['branches']);
512
513 1
				$return[0] .= $head;
514 1
				$return[1] .= $tail;
515
			}
516
517 25
			$return[0] .= '}';
518 25
			$return[1] .= $branch['tail'] . '}';
519
		}
520
521 386
		return $return;
522
	}
523
524
	/**
525
	* Get the unique values for the "passthrough" key of given branches
526
	*
527
	* @param  array     $branches
528
	* @return integer[]
529
	*/
530 179
	protected static function getBranchesPassthrough(array $branches)
531
	{
532 179
		$values = [];
533 179
		foreach ($branches as $branch)
534
		{
535 179
			$values[] = $branch['passthrough'];
536
		}
537
538
		// If the last branch isn't an "else", we act as if there was an additional branch with no
539
		// passthrough
540 179
		if ($branch['statement'] !== 'else')
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $branch seems to be defined by a foreach iteration on line 533. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
541
		{
542 121
			$values[] = 0;
543
		}
544
545 179
		return array_unique($values);
546
	}
547
548
	/**
549
	* Get a string suitable as a preg_replace() replacement for given PHP code
550
	*
551
	* @param  string     $php Original code
552
	* @return array|bool      Array of [regexp, replacement] if possible, or FALSE otherwise
553
	*/
554 309
	protected static function getDynamicRendering($php)
555
	{
556 309
		$rendering = '';
557
558 309
		$literal   = "(?<literal>'((?>[^'\\\\]+|\\\\['\\\\])*)')";
559 309
		$attribute = "(?<attribute>htmlspecialchars\\(\\\$node->getAttribute\\('([^']+)'\\),2\\))";
560 309
		$value     = "(?<value>$literal|$attribute)";
561 309
		$output    = "(?<output>\\\$this->out\\.=$value(?:\\.(?&value))*;)";
562
563 309
		$copyOfAttribute = "(?<copyOfAttribute>if\\(\\\$node->hasAttribute\\('([^']+)'\\)\\)\\{\\\$this->out\\.=' \\g-1=\"'\\.htmlspecialchars\\(\\\$node->getAttribute\\('\\g-1'\\),2\\)\\.'\"';\\})";
564
565 309
		$regexp = '(^(' . $output . '|' . $copyOfAttribute . ')*$)';
566 309
		if (!preg_match($regexp, $php, $m))
567
		{
568 213
			return false;
569
		}
570
571
		// Attributes that are copied in the replacement
572 102
		$copiedAttributes = [];
573
574
		// Attributes whose value is used in the replacement
575 102
		$usedAttributes = [];
576
577 102
		$regexp = '(' . $output . '|' . $copyOfAttribute . ')A';
578 102
		$offset = 0;
579 102
		while (preg_match($regexp, $php, $m, 0, $offset))
580
		{
581
			// Test whether it's normal output or a copy of attribute
582 102
			if ($m['output'])
583
			{
584
				// 12 === strlen('$this->out.=')
585 102
				$offset += 12;
586
587 102
				while (preg_match('(' . $value . ')A', $php, $m, 0, $offset))
588
				{
589
					// Test whether it's a literal or an attribute value
590 102
					if ($m['literal'])
591
					{
592
						// Unescape the literal
593 102
						$str = stripslashes(substr($m[0], 1, -1));
594
595
						// Escape special characters
596 102
						$rendering .= preg_replace('([\\\\$](?=\\d))', '\\\\$0', $str);
597
					}
598
					else
599
					{
600 101
						$attrName = end($m);
601
602
						// Generate a unique ID for this attribute name, we'll use it as a
603
						// placeholder until we have the full list of captures and we can replace it
604
						// with the capture number
605 101
						if (!isset($usedAttributes[$attrName]))
606
						{
607 101
							$usedAttributes[$attrName] = uniqid($attrName, true);
608
						}
609
610 101
						$rendering .= $usedAttributes[$attrName];
611
					}
612
613
					// Skip the match plus the next . or ;
614 102
					$offset += 1 + strlen($m[0]);
615
				}
616
			}
617
			else
618
			{
619 28
				$attrName = end($m);
620
621 28
				if (!isset($copiedAttributes[$attrName]))
622
				{
623 28
					$copiedAttributes[$attrName] = uniqid($attrName, true);
624
				}
625
626 28
				$rendering .= $copiedAttributes[$attrName];
627 28
				$offset += strlen($m[0]);
628
			}
629
		}
630
631
		// Gather the names of the attributes used in the replacement either by copy or by value
632 102
		$attrNames = array_keys($copiedAttributes + $usedAttributes);
633
634
		// Sort them alphabetically
635 102
		sort($attrNames);
636
637
		// Keep a copy of the attribute names to be used in the fillter subpattern
638 102
		$remainingAttributes = array_combine($attrNames, $attrNames);
639
640
		// Prepare the final regexp
641 102
		$regexp = '(^[^ ]+';
642 102
		$index  = 0;
643 102
		foreach ($attrNames as $attrName)
644
		{
645
			// Add a subpattern that matches (and skips) any attribute definition that is not one of
646
			// the remaining attributes we're trying to match
647 102
			$regexp .= '(?> (?!' . RegexpBuilder::fromList($remainingAttributes) . '=)[^=]+="[^"]*")*';
648 102
			unset($remainingAttributes[$attrName]);
649
650 102
			$regexp .= '(';
651
652 102
			if (isset($copiedAttributes[$attrName]))
653
			{
654 28
				self::replacePlaceholder($rendering, $copiedAttributes[$attrName], ++$index);
655
			}
656
			else
657
			{
658 101
				$regexp .= '?>';
659
			}
660
661 102
			$regexp .= ' ' . $attrName . '="';
662
663 102
			if (isset($usedAttributes[$attrName]))
664
			{
665 101
				$regexp .= '(';
666
667 101
				self::replacePlaceholder($rendering, $usedAttributes[$attrName], ++$index);
668
			}
669
670 102
			$regexp .= '[^"]*';
671
672 102
			if (isset($usedAttributes[$attrName]))
673
			{
674 101
				$regexp .= ')';
675
			}
676
677 102
			$regexp .= '")?';
678
		}
679
680 102
		$regexp .= '.*)s';
681
682 102
		return [$regexp, $rendering];
683
	}
684
685
	/**
686
	* Get a string suitable as a str_replace() replacement for given PHP code
687
	*
688
	* @param  string      $php Original code
689
	* @return bool|string      Static replacement if possible, or FALSE otherwise
690
	*/
691 381
	protected static function getStaticRendering($php)
692
	{
693 381
		if ($php === '')
694
		{
695 9
			return '';
696
		}
697
698 375
		$regexp = "(^\\\$this->out\.='((?>[^'\\\\]|\\\\['\\\\])*+)';\$)";
699 375
		if (preg_match($regexp, $php, $m))
700
		{
701 154
			return stripslashes($m[1]);
702
		}
703
704 312
		return false;
705
	}
706
707
	/**
708
	* Get string rendering strategies for given chunks
709
	*
710
	* @param  string $php
711
	* @return array
712
	*/
713 382
	protected static function getStringRenderings($php)
714
	{
715 382
		$chunks = explode('$this->at($node);', $php);
716 382
		if (count($chunks) > 2)
717
		{
718
			// Can't use string replacements if there are more than one xsl:apply-templates
719 25
			return [];
720
		}
721
722 381
		$renderings = [];
723 381
		foreach ($chunks as $k => $chunk)
724
		{
725
			// Try a static replacement first
726 381
			$rendering = self::getStaticRendering($chunk);
727 381
			if ($rendering !== false)
728
			{
729 162
				$renderings[$k] = ['static', $rendering];
730
			}
731 312
			elseif ($k === 0)
732
			{
733
				// If this is the first chunk, we can try a dynamic replacement. This wouldn't work
734
				// for the second chunk because we wouldn't have access to the attribute values
735 309
				$rendering = self::getDynamicRendering($chunk);
736 309
				if ($rendering !== false)
737
				{
738 102
					$renderings[$k] = ['dynamic', $rendering];
739
				}
740
			}
741
		}
742
743 381
		return $renderings;
744
	}
745
746
	/**
747
	* Replace all instances of a uniqid with a PCRE replacement in a string
748
	*
749
	* @param  string  &$str    PCRE replacement
750
	* @param  string   $uniqid Unique ID
751
	* @param  integer  $index  Capture index
752
	* @return void
753
	*/
754 102
	protected static function replacePlaceholder(&$str, $uniqid, $index)
755
	{
756 102
		$str = preg_replace_callback(
757 102
			'(' . preg_quote($uniqid) . '(.))',
758 102
			function ($m) use ($index)
759
			{
760
				// Replace with $1 where unambiguous and ${1} otherwise
761 102
				if (is_numeric($m[1]))
762
				{
763 1
					return '${' . $index . '}' . $m[1];
764
				}
765
				else
766
				{
767 101
					return '$' . $index . $m[1];
768
				}
769 102
			},
770
			$str
771
		);
772
	}
773
}