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); |
|
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('"','\"',\$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 &<>" |
|
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
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 | } |