Quick::getSource()   B
last analyzed

Complexity

Conditions 9
Paths 64

Size

Total Lines 74
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 47
CRAP Score 9

Importance

Changes 0
Metric Value
eloc 46
dl 0
loc 74
ccs 47
cts 47
cp 1
rs 7.6226
c 0
b 0
f 0
cc 9
nc 64
nop 1
crap 9

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 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 17
				$unsupported[] = $tagName;
41 17
				continue;
42
			}
43
44 346
			foreach ($renderings as $i => list($strategy, $replacement))
45
			{
46 346
				$match = (($i) ? '/' : '') . $tagName;
47 346
				$map[$strategy][$match] = $replacement;
48
			}
49
50
			// Record the names of tags whose template does not contain a passthrough
51 346
			if (!isset($renderings[1]))
52
			{
53 210
				$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 216
			$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 17
			$regexp = '((?<=<)(?:[!?]|' . RegexpBuilder::fromList($unsupported) . '[ />]))';
82 17
			$php[]  = '	/** {@inheritdoc} */';
83 17
			$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 391
	public static function getRenderingStrategy($php)
126
	{
127 391
		$phpRenderings = self::getQuickRendering($php);
128 391
		if (empty($phpRenderings))
129
		{
130 24
			return [];
131
		}
132 370
		$renderings = self::getStringRenderings($php);
133
134
		// Keep string rendering where possible, use PHP rendering wherever else
135 370
		foreach ($phpRenderings as $i => $phpRendering)
136
		{
137 370
			if (!isset($renderings[$i]) || strpos($phpRendering, '$this->attributes[]') !== false)
138
			{
139 226
				$renderings[$i] = ['php', $phpRendering];
140
			}
141
		}
142
143 370
		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 391
	protected static function getQuickRendering($php)
160
	{
161
		// xsl:apply-templates elements with a select expression and switch statements are not supported
162 391
		if (preg_match('(\\$this->at\\((?!\\$node\\);)|switch\()', $php))
163
		{
164 14
			return [];
165
		}
166
167
		// Tokenize the PHP and add an empty token as terminator
168 377
		$tokens   = token_get_all('<?php ' . $php);
169 377
		$tokens[] = [0, ''];
170
171
		// Remove the first token, which is a T_OPEN_TAG
172 377
		array_shift($tokens);
173 377
		$cnt = count($tokens);
174
175
		// Prepare the main branch
176
		$branch = [
177
			// We purposefully use a value that can never match
178 377
			'braces'      => -1,
179
			'branches'    => [],
180
			'head'        => '',
181
			'passthrough' => 0,
182
			'statement'   => '',
183
			'tail'        => ''
184
		];
185
186 377
		$braces = 0;
187 377
		$i = 0;
188
		do
189
		{
190
			// Test whether we've reached a passthrough
191 377
			if ($tokens[$i    ][0] === T_VARIABLE
192 377
			 && $tokens[$i    ][1] === '$this'
193 377
			 && $tokens[$i + 1][0] === T_OBJECT_OPERATOR
194 377
			 && $tokens[$i + 2][0] === T_STRING
195 377
			 && $tokens[$i + 2][1] === 'at'
196 377
			 && $tokens[$i + 3]    === '('
197 377
			 && $tokens[$i + 4][0] === T_VARIABLE
198 377
			 && $tokens[$i + 4][1] === '$node'
199 377
			 && $tokens[$i + 5]    === ')'
200 377
			 && $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 377
			$key = ($branch['passthrough']) ? 'tail' : 'head';
215 377
			$branch[$key] .= (is_array($tokens[$i])) ? $tokens[$i][1] : $tokens[$i];
216
217 377
			if ($tokens[$i] === '{')
218
			{
219 2
				++$braces;
220 2
				continue;
221
			}
222
223 377
			if ($tokens[$i] === '}')
224
			{
225 168
				--$braces;
226
227 168
				if ($branch['braces'] === $braces)
228
				{
229
					// Remove the last brace from the branch's content
230 167
					$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 167
					$branch =& $branch['parent'];
234
235
					// Copy the current index to look ahead
236 167
					$j = $i;
237
238
					// Skip whitespace
239 167
					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 167
					if ($tokens[$j][0] !== T_ELSEIF && $tokens[$j][0] !== T_ELSE)
244
					{
245 167
						$passthroughs = self::getBranchesPassthrough($branch['branches']);
246 167
						if ($passthroughs === [0])
247
						{
248
							// No branch was passthrough, move their PHP source back to this branch
249
							// then discard the data
250 161
							foreach ($branch['branches'] as $child)
251
							{
252 161
								$branch['head'] .= $child['statement'] . '{' . $child['head'] . '}';
253
							}
254
255 161
							$branch['branches'] = [];
256 161
							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 84
				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 377
			if ($branch['passthrough'])
279
			{
280 155
				continue;
281
			}
282
283 370
			if ($tokens[$i][0] === T_IF
284 370
			 || $tokens[$i][0] === T_ELSEIF
285 370
			 || $tokens[$i][0] === T_ELSE)
286
			{
287
				// Remove the statement from the branch's content
288 167
				$branch[$key] = substr($branch[$key], 0, -strlen($tokens[$i][1]));
289
290
				// Create a new branch
291 167
				$branch['branches'][] = [
292 167
					'braces'      => $braces,
293
					'branches'    => [],
294 167
					'head'        => '',
295 167
					'parent'      => &$branch,
296 167
					'passthrough' => 0,
297 167
					'statement'   => '',
298 167
					'tail'        => ''
299
				];
300
301
				// Jump to the new branch
302 167
				$branch =& $branch['branches'][count($branch['branches']) - 1];
303
304
				// Record the PHP statement
305
				do
306
				{
307 167
					$branch['statement'] .= (is_array($tokens[$i])) ? $tokens[$i][1] : $tokens[$i];
308
				}
309 167
				while ($tokens[++$i] !== '{');
310
311
				// Account for the brace in the statement
312 167
				++$braces;
313
			}
314
		}
315 377
		while (++$i < $cnt);
316
317 374
		list($head, $tail) = self::buildPHP($branch['branches']);
318 374
		$head  = $branch['head'] . $head;
319 374
		$tail .= $branch['tail'];
320
321
		// Convert the PHP renderer source to the format used in the Quick renderer
322 374
		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 374
		if (preg_match('((?<!-|\\$this)->)', $head . $tail))
326
		{
327 6
			return [];
328
		}
329
330 370
		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 374
	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 374
		$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 374
		preg_match_all(
353 374
			"(\\\$node->getAttribute\\('([^']+)'\\))",
354 374
			preg_replace_callback(
355 374
				'(if\\(\\$node->hasAttribute\\(([^\\)]+)[^}]+)',
356 374
				function ($m)
357
				{
358 116
					return str_replace('$node->getAttribute(' . $m[1] . ')', '', $m[0]);
359 374
				},
360 374
				$head . $tail
361
			),
362
			$matches
363
		);
364 374
		$attrNames = array_unique($matches[1]);
365
366
		// Replace the source in $head and $tail
367 374
		self::replacePHP($head);
368 374
		self::replacePHP($tail);
369
370 374
		if (!$passthrough && strpos($head, '$node->textContent') !== false)
371
		{
372 9
			$head = '$textContent=$this->getQuickTextContent($xml);' . str_replace('$node->textContent', '$textContent', $head);
373
		}
374
375 374
		if (!empty($attrNames))
376
		{
377 273
			ksort($attrNames);
378 273
			$head = "\$attributes+=['" . implode("'=>null,'", $attrNames) . "'=>null];" . $head;
379
		}
380
381 374
		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 374
	protected static function replacePHP(&$php)
395
	{
396
		// Expression that matches a $node->getAttribute() call and captures its string argument
397 374
		$getAttribute = "\\\$node->getAttribute\\(('[^']+')\\)";
398
399
		// Expression that matches a single-quoted string literal
400 374
		$string       = "'(?:[^\\\\']|\\\\.)*+'";
401
402
		$replacements = [
403 374
			'$this->out' => '$html',
404
405
			// An attribute value escaped as ENT_NOQUOTES. We only need to unescape quotes
406 374
			'(htmlspecialchars\\(' . $getAttribute . ',' . ENT_NOQUOTES . '\\))'
407 374
				=> "str_replace('&quot;','\"',\$attributes[\$1]??'')",
408
409
			// One or several attribute values escaped as ENT_COMPAT can be used as-is
410 374
			'((\\.?)htmlspecialchars\\((' . $getAttribute . '(?:\\.' . $getAttribute . ')*),' . ENT_COMPAT . '\\)(\\.?))'
411 374
				=> function ($m) use ($getAttribute)
412
				{
413 198
					$replacement = (strpos($m[0], '.') === false) ? '($attributes[$1]??\'\')' : '$attributes[$1]';
414 374
415
					return $m[1] . preg_replace('(' . $getAttribute . ')', $replacement, $m[2]) . $m[5];
416
				},
417
418
			// Character replacement can be performed directly on the escaped value provided that it
419 374
			// is then escaped as ENT_COMPAT and that replacements do not interfere with the escaping
420 374
			// of the characters &<>" or their representation &amp;&lt;&gt;&quot;
421
			'(htmlspecialchars\\(strtr\\(' . $getAttribute . ",('[^\"&\\\\';<>aglmopqtu]+'),('[^\"&\\\\'<>]+')\\)," . ENT_COMPAT . '\\))'
422
				=> 'strtr($attributes[$1]??\'\',$2,$3)',
423 374
424 374
			// A comparison between two attributes. No need to unescape
425
			'(' . $getAttribute . '(!?=+)' . $getAttribute . ')'
426
				=> '$attributes[$1]$2$attributes[$3]',
427
428
			// A comparison between an attribute and a literal string. Rather than unescape the
429 374
			// attribute value, we escape the literal. This applies to comparisons using XPath's
430 374
			// contains() as well (translated to PHP's strpos())
431
			'(' . $getAttribute . '([!=]==)(' . $string . '))s'
432 11
				=> function ($m)
433 374
				{
434
					return '$attributes[' . $m[1] . ']' . $m[2] . htmlspecialchars($m[3], ENT_COMPAT);
435 374
				},
436 374
437
			'((' . $string . ')([!=]==)' . $getAttribute . ')s'
438 2
				=> function ($m)
439 374
				{
440
					return htmlspecialchars($m[1], ENT_COMPAT) . $m[2] . '$attributes[' . $m[3] . ']';
441 374
				},
442 374
443
			'(strpos\\(' . $getAttribute . ',(' . $string . ')\\)([!=]==(?:0|false)))s'
444 42
				=> function ($m)
445 374
				{
446
					return 'strpos($attributes[' . $m[1] . "]??''," . htmlspecialchars($m[2], ENT_COMPAT) . ')' . $m[3];
447 374
				},
448 374
449
			'(strpos\\((' . $string . '),' . $getAttribute . '\\)([!=]==(?:0|false)))s'
450 3
				=> function ($m)
451 374
				{
452
					return 'strpos(' . htmlspecialchars($m[1], ENT_COMPAT) . ',$attributes[' . $m[2] . "]??'')" . $m[3];
453 374
				},
454 374
455
			'(str_(contains|(?:end|start)s_with)\\(' . $getAttribute . ',(' . $string . ')\\))s'
456
				=> function ($m)
457 374
				{
458
					return 'str_' . $m[1] . '($attributes[' . $m[2] . "]??''," . htmlspecialchars($m[3], ENT_COMPAT) . ')';
459 374
				},
460 374
461
			'(str_(contains|(?:end|start)s_with)\\((' . $string . '),' . $getAttribute . '\\))s'
462
				=> function ($m)
463 374
				{
464
					return 'str_' . $m[1] . '(' . htmlspecialchars($m[2], ENT_COMPAT) . ',$attributes[' . $m[3] . "]??'')";
465
				},
466
467 374
			// An attribute value used in an arithmetic comparison or operation does not need to be
468 374
			// unescaped. The same applies to empty(), isset() and conditionals
469 374
			'(' . $getAttribute . '(?=(?:[!=]=|[-+*])\\d+))'  => '$attributes[$1]',
470 374
			'(\\b(\\d+(?:[!=]=|[-+*]))' . $getAttribute . ')' => '$1$attributes[$2]',
471 374
			'(empty\\(' . $getAttribute . '\\))'              => 'empty($attributes[$1])',
472
			"(\\\$node->hasAttribute\\(('[^']+')\\))"         => 'isset($attributes[$1])',
473
			'if($node->attributes->length)'                   => 'if($this->hasNonNullValues($attributes))',
474 374
475
			// In all other situations, unescape the attribute value before use
476
			'(' . $getAttribute . ')' => 'htmlspecialchars_decode($attributes[$1]??\'\')'
477 374
		];
478
479 374
		foreach ($replacements as $match => $replace)
480
		{
481 374
			if ($replace instanceof Closure)
482
			{
483 374
				$php = preg_replace_callback($match, $replace, $php);
484
			}
485 374
			elseif ($match[0] === '(')
486
			{
487
				$php = preg_replace($match, $replace, $php);
488
			}
489 374
			else
490
			{
491
				$php = str_replace($match, $replace, $php);
492
			}
493
		}
494
	}
495
496
	/**
497
	* Build the source for the two sides of a templates based on the structure extracted from its
498
	* original source
499
	*
500
	* @param  array    $branches
501 374
	* @return string[]
502
	*/
503 374
	protected static function buildPHP(array $branches)
504 374
	{
505
		$return = ['', ''];
506 25
		foreach ($branches as $branch)
507 25
		{
508
			$return[0] .= $branch['statement'] . '{' . $branch['head'];
509 25
			$return[1] .= $branch['statement'] . '{';
510
511 1
			if ($branch['branches'])
512
			{
513 1
				list($head, $tail) = self::buildPHP($branch['branches']);
514 1
515
				$return[0] .= $head;
516
				$return[1] .= $tail;
517 25
			}
518 25
519
			$return[0] .= '}';
520
			$return[1] .= $branch['tail'] . '}';
521 374
		}
522
523
		return $return;
524
	}
525
526
	/**
527
	* Get the unique values for the "passthrough" key of given branches
528
	*
529
	* @param  array     $branches
530 167
	* @return integer[]
531
	*/
532 167
	protected static function getBranchesPassthrough(array $branches)
533 167
	{
534
		$values = [];
535 167
		foreach ($branches as $branch)
536
		{
537
			$values[] = $branch['passthrough'];
538
		}
539
540 167
		// If the last branch isn't an "else", we act as if there was an additional branch with no
541
		// passthrough
542 121
		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 535. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
543
		{
544
			$values[] = 0;
545 167
		}
546
547
		return array_unique($values);
548
	}
549
550
	/**
551
	* Get a string suitable as a preg_replace() replacement for given PHP code
552
	*
553
	* @param  string     $php Original code
554 297
	* @return array|bool      Array of [regexp, replacement] if possible, or FALSE otherwise
555
	*/
556 297
	protected static function getDynamicRendering($php)
557
	{
558 297
		$rendering = '';
559 297
560 297
		$literal   = "(?<literal>'((?>[^'\\\\]+|\\\\['\\\\])*)')";
561 297
		$attribute = "(?<attribute>htmlspecialchars\\(\\\$node->getAttribute\\('([^']+)'\\),2\\))";
562
		$value     = "(?<value>$literal|$attribute)";
563 297
		$output    = "(?<output>\\\$this->out\\.=$value(?:\\.(?&value))*;)";
564
565 297
		$copyOfAttribute = "(?<copyOfAttribute>if\\(\\\$node->hasAttribute\\('([^']+)'\\)\\)\\{\\\$this->out\\.=' \\g-1=\"'\\.htmlspecialchars\\(\\\$node->getAttribute\\('\\g-1'\\),2\\)\\.'\"';\\})";
566 297
567
		$regexp = '(^(' . $output . '|' . $copyOfAttribute . ')*$)';
568 201
		if (!preg_match($regexp, $php, $m))
569
		{
570
			return false;
571
		}
572 102
573
		// Attributes that are copied in the replacement
574
		$copiedAttributes = [];
575 102
576
		// Attributes whose value is used in the replacement
577 102
		$usedAttributes = [];
578 102
579 102
		$regexp = '(' . $output . '|' . $copyOfAttribute . ')A';
580
		$offset = 0;
581
		while (preg_match($regexp, $php, $m, 0, $offset))
582 102
		{
583
			// Test whether it's normal output or a copy of attribute
584
			if ($m['output'])
585 102
			{
586
				// 12 === strlen('$this->out.=')
587 102
				$offset += 12;
588
589
				while (preg_match('(' . $value . ')A', $php, $m, 0, $offset))
590 102
				{
591
					// Test whether it's a literal or an attribute value
592
					if ($m['literal'])
593 102
					{
594
						// Unescape the literal
595
						$str = stripslashes(substr($m[0], 1, -1));
596 102
597
						// Escape special characters
598
						$rendering .= preg_replace('([\\\\$](?=\\d))', '\\\\$0', $str);
599
					}
600 101
					else
601
					{
602
						$attrName = end($m);
603
604
						// Generate a unique ID for this attribute name, we'll use it as a
605 101
						// placeholder until we have the full list of captures and we can replace it
606
						// with the capture number
607 101
						if (!isset($usedAttributes[$attrName]))
608
						{
609
							$usedAttributes[$attrName] = uniqid($attrName, true);
610 101
						}
611
612
						$rendering .= $usedAttributes[$attrName];
613
					}
614 102
615
					// Skip the match plus the next . or ;
616
					$offset += 1 + strlen($m[0]);
617
				}
618
			}
619 28
			else
620
			{
621 28
				$attrName = end($m);
622
623 28
				if (!isset($copiedAttributes[$attrName]))
624
				{
625
					$copiedAttributes[$attrName] = uniqid($attrName, true);
626 28
				}
627 28
628
				$rendering .= $copiedAttributes[$attrName];
629
				$offset += strlen($m[0]);
630
			}
631
		}
632 102
633
		// Gather the names of the attributes used in the replacement either by copy or by value
634
		$attrNames = array_keys($copiedAttributes + $usedAttributes);
635 102
636
		// Sort them alphabetically
637
		sort($attrNames);
638 102
639
		// Keep a copy of the attribute names to be used in the fillter subpattern
640
		$remainingAttributes = array_combine($attrNames, $attrNames);
641 102
642 102
		// Prepare the final regexp
643 102
		$regexp = '(^[^ ]+';
644
		$index  = 0;
645
		foreach ($attrNames as $attrName)
646
		{
647 102
			// Add a subpattern that matches (and skips) any attribute definition that is not one of
648 102
			// the remaining attributes we're trying to match
649
			$regexp .= '(?> (?!' . RegexpBuilder::fromList($remainingAttributes) . '=)[^=]+="[^"]*")*';
650 102
			unset($remainingAttributes[$attrName]);
651
652 102
			$regexp .= '(';
653
654 28
			if (isset($copiedAttributes[$attrName]))
655
			{
656
				self::replacePlaceholder($rendering, $copiedAttributes[$attrName], ++$index);
657
			}
658 101
			else
659
			{
660
				$regexp .= '?>';
661 102
			}
662
663 102
			$regexp .= ' ' . $attrName . '="';
664
665 101
			if (isset($usedAttributes[$attrName]))
666
			{
667 101
				$regexp .= '(';
668
669
				self::replacePlaceholder($rendering, $usedAttributes[$attrName], ++$index);
670 102
			}
671
672 102
			$regexp .= '[^"]*';
673
674 101
			if (isset($usedAttributes[$attrName]))
675
			{
676
				$regexp .= ')';
677 102
			}
678
679
			$regexp .= '")?';
680 102
		}
681
682 102
		$regexp .= '.*)s';
683
684
		return [$regexp, $rendering];
685
	}
686
687
	/**
688
	* Get a string suitable as a str_replace() replacement for given PHP code
689
	*
690
	* @param  string      $php Original code
691 369
	* @return bool|string      Static replacement if possible, or FALSE otherwise
692
	*/
693 369
	protected static function getStaticRendering($php)
694
	{
695 9
		if ($php === '')
696
		{
697
			return '';
698 363
		}
699 363
700
		$regexp = "(^\\\$this->out\.='((?>[^'\\\\]|\\\\['\\\\])*+)';\$)";
701 154
		if (preg_match($regexp, $php, $m))
702
		{
703
			return stripslashes($m[1]);
704 300
		}
705
706
		return false;
707
	}
708
709
	/**
710
	* Get string rendering strategies for given chunks
711
	*
712
	* @param  string $php
713 370
	* @return array
714
	*/
715 370
	protected static function getStringRenderings($php)
716 370
	{
717
		$chunks = explode('$this->at($node);', $php);
718
		if (count($chunks) > 2)
719 25
		{
720
			// Can't use string replacements if there are more than one xsl:apply-templates
721
			return [];
722 369
		}
723 369
724
		$renderings = [];
725
		foreach ($chunks as $k => $chunk)
726 369
		{
727 369
			// Try a static replacement first
728
			$rendering = self::getStaticRendering($chunk);
729 162
			if ($rendering !== false)
730
			{
731 300
				$renderings[$k] = ['static', $rendering];
732
			}
733
			elseif ($k === 0)
734
			{
735 297
				// If this is the first chunk, we can try a dynamic replacement. This wouldn't work
736 297
				// for the second chunk because we wouldn't have access to the attribute values
737
				$rendering = self::getDynamicRendering($chunk);
738 102
				if ($rendering !== false)
739
				{
740
					$renderings[$k] = ['dynamic', $rendering];
741
				}
742
			}
743 369
		}
744
745
		return $renderings;
746
	}
747
748
	/**
749
	* Replace all instances of a uniqid with a PCRE replacement in a string
750
	*
751
	* @param  string  &$str    PCRE replacement
752
	* @param  string   $uniqid Unique ID
753
	* @param  integer  $index  Capture index
754 102
	* @return void
755
	*/
756 102
	protected static function replacePlaceholder(&$str, $uniqid, $index)
757 102
	{
758 102
		$str = preg_replace_callback(
759
			'(' . preg_quote($uniqid) . '(.))',
760
			function ($m) use ($index)
761 102
			{
762
				// Replace with $1 where unambiguous and ${1} otherwise
763 1
				if (is_numeric($m[1]))
764
				{
765
					return '${' . $index . '}' . $m[1];
766
				}
767 101
				else
768
				{
769 102
					return '$' . $index . $m[1];
770
				}
771
			},
772
			$str
773
		);
774
	}
775
}