Completed
Push — master ( 75e09b...8a3237 )
by Josh
20:10
created

TemplateParser::setOutputContext()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 1
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
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\Helpers;
9
10
use DOMDocument;
11
use DOMElement;
12
use DOMNode;
13
use DOMXPath;
14
use RuntimeException;
15
16
class TemplateParser
17
{
18
	/**
19
	* XSL namespace
20
	*/
21
	const XMLNS_XSL = 'http://www.w3.org/1999/XSL/Transform';
22
23
	/**
24
	* @var string Regexp that matches the names of all void elements
25
	* @link http://www.w3.org/TR/html-markup/syntax.html#void-elements
26
	*/
27
	public static $voidRegexp = '/^(?:area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/Di';
28
29
	/**
30
	* Parse a template into an internal representation
31
	*
32
	* @param  string      $template     Source template
33
	* @return DOMDocument               Internal representation
34
	*/
35
	public static function parse($template)
36
	{
37
		$xsl = '<xsl:template xmlns:xsl="' . self::XMLNS_XSL . '">' . $template . '</xsl:template>';
38
39
		$dom = new DOMDocument;
40
		$dom->loadXML($xsl);
41
42
		$ir = new DOMDocument;
43
		$ir->loadXML('<template/>');
44
45
		self::parseChildren($ir->documentElement, $dom->documentElement);
46
		self::normalize($ir);
47
48
		return $ir;
49
	}
50
51
	/**
52
	* Parse an XPath expression that is composed entirely of equality tests between a variable part
53
	* and a constant part
54
	*
55
	* @param  string      $expr
56
	* @return array|false
57
	*/
58
	public static function parseEqualityExpr($expr)
59
	{
60
		// Match an equality between a variable and a literal or the concatenation of strings
61
		$eq = '(?<equality>'
62
		    . '(?<key>@[-\\w]+|\\$\\w+|\\.)'
63
		    . '(?<operator>\\s*=\\s*)'
64
		    . '(?:'
65
		    . '(?<literal>(?<string>"[^"]*"|\'[^\']*\')|0|[1-9][0-9]*)'
66
		    . '|'
67
		    . '(?<concat>concat\\(\\s*(?&string)\\s*(?:,\\s*(?&string)\\s*)+\\))'
68
		    . ')'
69
		    . '|'
70
		    . '(?:(?<literal>(?&literal))|(?<concat>(?&concat)))(?&operator)(?<key>(?&key))'
71
		    . ')';
72
73
		// Match a string that is entirely composed of equality checks separated with "or"
74
		$regexp = '(^(?J)\\s*' . $eq . '\\s*(?:or\\s*(?&equality)\\s*)*$)';
75
76
		if (!preg_match($regexp, $expr))
77
		{
78
			return false;
79
		}
80
81
		preg_match_all("((?J)$eq)", $expr, $matches, PREG_SET_ORDER);
82
83
		$map = [];
84
		foreach ($matches as $m)
85
		{
86
			$key = $m['key'];
87
			if (!empty($m['concat']))
88
			{
89
				preg_match_all('(\'[^\']*\'|"[^"]*")', $m['concat'], $strings);
90
91
				$value = '';
92
				foreach ($strings[0] as $string)
93
				{
94
					$value .= substr($string, 1, -1);
95
				}
96
			}
97
			else
98
			{
99
				$value = $m['literal'];
100
				if ($value[0] === "'" || $value[0] === '"')
101
				{
102
					$value = substr($value, 1, -1);
103
				}
104
			}
105
106
			$map[$key][] = $value;
107
		}
108
109
		return $map;
110
	}
111
112
	//==========================================================================
113
	// General parsing
114
	//==========================================================================
115
116
	/**
117
	* Parse all the children of a given element
118
	*
119
	* @param  DOMElement $ir     Node in the internal representation that represents the parent node
120
	* @param  DOMElement $parent Parent node
121
	* @return void
122
	*/
123
	protected static function parseChildren(DOMElement $ir, DOMElement $parent)
124
	{
125
		foreach ($parent->childNodes as $child)
126
		{
127
			switch ($child->nodeType)
128
			{
129
				case XML_COMMENT_NODE:
130
					// Do nothing
131
					break;
132
133
				case XML_TEXT_NODE:
134
					if (trim($child->textContent) !== '')
135
					{
136
						self::appendOutput($ir, 'literal', $child->textContent);
137
					}
138
					break;
139
140
				case XML_ELEMENT_NODE:
141
					self::parseNode($ir, $child);
142
					break;
143
144
				default:
145
					throw new RuntimeException("Cannot parse node '" . $child->nodeName . "''");
146
			}
147
		}
148
	}
149
150
	/**
151
	* Parse a given node into the internal representation
152
	*
153
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
154
	* @param  DOMElement $node Node to parse
155
	* @return void
156
	*/
157
	protected static function parseNode(DOMElement $ir, DOMElement $node)
158
	{
159
		// XSL elements are parsed by the corresponding parseXsl* method
160
		if ($node->namespaceURI === self::XMLNS_XSL)
161
		{
162
			$methodName = 'parseXsl' . str_replace(' ', '', ucwords(str_replace('-', ' ', $node->localName)));
163
164
			if (!method_exists(__CLASS__, $methodName))
165
			{
166
				throw new RuntimeException("Element '" . $node->nodeName . "' is not supported");
167
			}
168
169
			return self::$methodName($ir, $node);
170
		}
171
172
		// Create an <element/> with a name attribute equal to given node's name
173
		$element = self::appendElement($ir, 'element');
174
		$element->setAttribute('name', $node->nodeName);
175
176
		// Append an <attribute/> element for each namespace declaration
177
		$xpath = new DOMXPath($node->ownerDocument);
178
		foreach ($xpath->query('namespace::*', $node) as $ns)
179
		{
180
			if ($node->hasAttribute($ns->nodeName))
181
			{
182
				$irAttribute = self::appendElement($element, 'attribute');
183
				$irAttribute->setAttribute('name', $ns->nodeName);
184
				self::appendOutput($irAttribute, 'literal', $ns->nodeValue);
185
			}
186
		}
187
188
		// Append an <attribute/> element for each of this node's attribute
189
		foreach ($node->attributes as $attribute)
190
		{
191
			$irAttribute = self::appendElement($element, 'attribute');
192
			$irAttribute->setAttribute('name', $attribute->nodeName);
193
194
			// Append an <output/> element to represent the attribute's value
195
			self::appendOutput($irAttribute, 'avt', $attribute->value);
196
		}
197
198
		// Parse the content of this node
199
		self::parseChildren($element, $node);
200
	}
201
202
	//==========================================================================
203
	// XSL parsing
204
	//==========================================================================
205
206
	/**
207
	* Parse an <xsl:apply-templates/> node into the internal representation
208
	*
209
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
210
	* @param  DOMElement $node <xsl:apply-templates/> node
211
	* @return void
212
	*/
213
	protected static function parseXslApplyTemplates(DOMElement $ir, DOMElement $node)
214
	{
215
		$applyTemplates = self::appendElement($ir, 'applyTemplates');
216
217
		if ($node->hasAttribute('select'))
218
		{
219
			$applyTemplates->setAttribute(
220
				'select',
221
				$node->getAttribute('select')
222
			);
223
		}
224
	}
225
226
	/**
227
	* Parse an <xsl:attribute/> node into the internal representation
228
	*
229
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
230
	* @param  DOMElement $node <xsl:attribute/> node
231
	* @return void
232
	*/
233
	protected static function parseXslAttribute(DOMElement $ir, DOMElement $node)
234
	{
235
		$attrName = $node->getAttribute('name');
236
237
		if ($attrName !== '')
238
		{
239
			$attribute = self::appendElement($ir, 'attribute');
240
241
			// Copy this attribute's name
242
			$attribute->setAttribute('name', $attrName);
243
244
			// Parse this attribute's content
245
			self::parseChildren($attribute, $node);
246
		}
247
	}
248
249
	/**
250
	* Parse an <xsl:choose/> node and its <xsl:when/> and <xsl:otherwise/> children into the
251
	* internal representation
252
	*
253
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
254
	* @param  DOMElement $node <xsl:choose/> node
255
	* @return void
256
	*/
257
	protected static function parseXslChoose(DOMElement $ir, DOMElement $node)
258
	{
259
		$switch = self::appendElement($ir, 'switch');
260
261
		foreach ($node->getElementsByTagNameNS(self::XMLNS_XSL, 'when') as $when)
262
		{
263
			// Only children of current node, exclude other descendants
264
			if ($when->parentNode !== $node)
265
			{
266
				continue;
267
			}
268
269
			// Create a <case/> element with the original test condition in @test
270
			$case = self::appendElement($switch, 'case');
271
			$case->setAttribute('test', $when->getAttribute('test'));
272
273
			// Parse this branch's content
274
			self::parseChildren($case, $when);
275
		}
276
277
		// Add the default branch, which is presumed to be last
278
		foreach ($node->getElementsByTagNameNS(self::XMLNS_XSL, 'otherwise') as $otherwise)
279
		{
280
			// Only children of current node, exclude other descendants
281
			if ($otherwise->parentNode !== $node)
282
			{
283
				continue;
284
			}
285
286
			$case = self::appendElement($switch, 'case');
287
288
			// Parse this branch's content
289
			self::parseChildren($case, $otherwise);
290
291
			// There should be only one <xsl:otherwise/> but we'll break anyway
292
			break;
293
		}
294
	}
295
296
	/**
297
	* Parse an <xsl:comment/> node into the internal representation
298
	*
299
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
300
	* @param  DOMElement $node <xsl:comment/> node
301
	* @return void
302
	*/
303
	protected static function parseXslComment(DOMElement $ir, DOMElement $node)
304
	{
305
		$comment = self::appendElement($ir, 'comment');
306
307
		// Parse this branch's content
308
		self::parseChildren($comment, $node);
309
	}
310
311
	/**
312
	* Parse an <xsl:copy-of/> node into the internal representation
313
	*
314
	* NOTE: only attributes are supported
315
	*
316
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
317
	* @param  DOMElement $node <xsl:copy-of/> node
318
	* @return void
319
	*/
320
	protected static function parseXslCopyOf(DOMElement $ir, DOMElement $node)
321
	{
322
		$expr = $node->getAttribute('select');
323
324
		// <xsl:copy-of select="@foo"/>
325
		if (preg_match('#^@([-\\w]+)$#', $expr, $m))
326
		{
327
			// Create a switch element in the IR
328
			$switch = self::appendElement($ir, 'switch');
329
			$case   = self::appendElement($switch, 'case');
330
			$case->setAttribute('test', $expr);
331
332
			// Append an attribute element
333
			$attribute = self::appendElement($case, 'attribute');
334
			$attribute->setAttribute('name', $m[1]);
335
336
			// Set the attribute's content, which is simply the copied attribute's value
337
			self::appendOutput($attribute, 'xpath', $expr);
338
339
			return;
340
		}
341
342
		// <xsl:copy-of select="@*"/>
343
		if ($expr === '@*')
344
		{
345
			self::appendElement($ir, 'copyOfAttributes');
346
347
			return;
348
		}
349
350
		throw new RuntimeException("Unsupported <xsl:copy-of/> expression '" . $expr . "'");
351
	}
352
353
	/**
354
	* Parse an <xsl:element/> node into the internal representation
355
	*
356
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
357
	* @param  DOMElement $node <xsl:element/> node
358
	* @return void
359
	*/
360
	protected static function parseXslElement(DOMElement $ir, DOMElement $node)
361
	{
362
		$elName = $node->getAttribute('name');
363
364
		if ($elName !== '')
365
		{
366
			$element = self::appendElement($ir, 'element');
367
368
			// Copy this element's name
369
			$element->setAttribute('name', $elName);
370
371
			// Parse this element's content
372
			self::parseChildren($element, $node);
373
		}
374
	}
375
376
	/**
377
	* Parse an <xsl:if/> node into the internal representation
378
	*
379
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
380
	* @param  DOMElement $node <xsl:if/> node
381
	* @return void
382
	*/
383
	protected static function parseXslIf(DOMElement $ir, DOMElement $node)
384
	{
385
		// An <xsl:if/> is represented by a <switch/> with only one <case/>
386
		$switch = self::appendElement($ir, 'switch');
387
		$case   = self::appendElement($switch, 'case');
388
		$case->setAttribute('test', $node->getAttribute('test'));
389
390
		// Parse this branch's content
391
		self::parseChildren($case, $node);
392
	}
393
394
	/**
395
	* Parse an <xsl:text/> node into the internal representation
396
	*
397
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
398
	* @param  DOMElement $node <xsl:text/> node
399
	* @return void
400
	*/
401
	protected static function parseXslText(DOMElement $ir, DOMElement $node)
402
	{
403
		self::appendOutput($ir, 'literal', $node->textContent);
404
	}
405
406
	/**
407
	* Parse an <xsl:value-of/> node into the internal representation
408
	*
409
	* @param  DOMElement $ir   Node in the internal representation that represents the node's parent
410
	* @param  DOMElement $node <xsl:value-of/> node
411
	* @return void
412
	*/
413
	protected static function parseXslValueOf(DOMElement $ir, DOMElement $node)
414
	{
415
		self::appendOutput($ir, 'xpath', $node->getAttribute('select'));
416
	}
417
418
	//==========================================================================
419
	// IR optimization
420
	//==========================================================================
421
422
	/**
423
	* Normalize an IR
424
	*
425
	* @param  DOMDocument $ir
426
	* @return void
427
	*/
428
	protected static function normalize(DOMDocument $ir)
429
	{
430
		self::addDefaultCase($ir);
431
		self::addElementIds($ir);
432
		self::addCloseTagElements($ir);
433
		self::markEmptyElements($ir);
434
		self::optimize($ir);
435
		self::markConditionalCloseTagElements($ir);
436
		self::setOutputContext($ir);
437
		self::markBranchTables($ir);
438
	}
439
440
	/**
441
	* Add an empty default <case/> to <switch/> nodes that don't have one
442
	*
443
	* @param  DOMDocument $ir
444
	* @return void
445
	*/
446
	protected static function addDefaultCase(DOMDocument $ir)
447
	{
448
		$xpath = new DOMXPath($ir);
449
		foreach ($xpath->query('//switch[not(case[not(@test)])]') as $switch)
450
		{
451
			self::appendElement($switch, 'case');
452
		}
453
	}
454
455
	/**
456
	* Add an id attribute to <element/> nodes
457
	*
458
	* @param  DOMDocument $ir
459
	* @return void
460
	*/
461
	protected static function addElementIds(DOMDocument $ir)
462
	{
463
		$id = 0;
464
		foreach ($ir->getElementsByTagName('element') as $element)
465
		{
466
			$element->setAttribute('id', ++$id);
467
		}
468
	}
469
470
	/**
471
	* Add <closeTag/> elements everywhere an open start tag should be closed
472
	*
473
	* @param  DOMDocument $ir
474
	* @return void
475
	*/
476
	protected static function addCloseTagElements(DOMDocument $ir)
477
	{
478
		$xpath = new DOMXPath($ir);
479
		$exprs = [
480
			'//applyTemplates[not(ancestor::attribute)]',
481
			'//comment',
482
			'//element',
483
			'//output[not(ancestor::attribute)]'
484
		];
485
		foreach ($xpath->query(implode('|', $exprs)) as $node)
486
		{
487
			$parentElementId = self::getParentElementId($node);
488
			if (isset($parentElementId))
489
			{
490
				$node->parentNode
491
				     ->insertBefore($ir->createElement('closeTag'), $node)
492
				     ->setAttribute('id', $parentElementId);
493
			}
494
495
			// Append a <closeTag/> to <element/> nodes to ensure that empty elements get closed
496
			if ($node->nodeName === 'element')
497
			{
498
				$id = $node->getAttribute('id');
499
				self::appendElement($node, 'closeTag')->setAttribute('id', $id);
500
			}
501
		}
502
	}
503
504
	/**
505
	* Mark conditional <closeTag/> nodes
506
	*
507
	* @param  DOMDocument $ir
508
	* @return void
509
	*/
510
	protected static function markConditionalCloseTagElements(DOMDocument $ir)
511
	{
512
		$xpath = new DOMXPath($ir);
513
		foreach ($ir->getElementsByTagName('closeTag') as $closeTag)
514
		{
515
			$id = $closeTag->getAttribute('id');
516
517
			// For each <switch/> ancestor, look for a <closeTag/> and that is either a sibling or
518
			// the descendant of a sibling, and that matches the id
519
			$query = 'ancestor::switch/'
520
			       . 'following-sibling::*/'
521
			       . 'descendant-or-self::closeTag[@id = "' . $id . '"]';
522
			foreach ($xpath->query($query, $closeTag) as $following)
523
			{
524
				// Mark following <closeTag/> nodes to indicate that the status of this tag must
525
				// be checked before it is closed
526
				$following->setAttribute('check', '');
527
528
				// Mark the current <closeTag/> to indicate that it must set a flag to indicate
529
				// that its tag has been closed
530
				$closeTag->setAttribute('set', '');
531
			}
532
		}
533
	}
534
535
	/**
536
	* Mark void elements and elements with no content
537
	*
538
	* @param  DOMDocument $ir
539
	* @return void
540
	*/
541
	protected static function markEmptyElements(DOMDocument $ir)
542
	{
543
		foreach ($ir->getElementsByTagName('element') as $element)
544
		{
545
			// Test whether this element is (maybe) void
546
			$elName = $element->getAttribute('name');
547
			if (strpos($elName, '{') !== false)
548
			{
549
				// Dynamic element names must be checked at runtime
550
				$element->setAttribute('void', 'maybe');
551
			}
552
			elseif (preg_match(self::$voidRegexp, $elName))
553
			{
554
				// Static element names can be checked right now
555
				$element->setAttribute('void', 'yes');
556
			}
557
558
			// Find whether this element is empty
559
			$isEmpty = self::isEmpty($element);
560
			if ($isEmpty === 'yes' || $isEmpty === 'maybe')
561
			{
562
				$element->setAttribute('empty', $isEmpty);
563
			}
564
		}
565
	}
566
567
	/**
568
	* Get the context type for given output element
569
	*
570
	* @param  DOMNode $output
571
	* @return string
572
	*/
573
	protected static function getOutputContext(DOMNode $output)
574
	{
575
		$xpath = new DOMXPath($output->ownerDocument);
576
		if ($xpath->evaluate('boolean(ancestor::attribute)', $output))
577
		{
578
			return 'attribute';
579
		}
580
581
		if ($xpath->evaluate('boolean(ancestor::element[@name="script"])', $output))
582
		{
583
			return 'raw';
584
		}
585
586
		return 'text';
587
	}
588
589
	/**
590
	* Get the ID of the closest "element" ancestor
591
	*
592
	* @param  DOMNode     $node Context node
593
	* @return string|null
594
	*/
595
	protected static function getParentElementId(DOMNode $node)
596
	{
597
		$parentNode = $node->parentNode;
598
		while (isset($parentNode))
599
		{
600
			if ($parentNode->nodeName === 'element')
601
			{
602
				return $parentNode->getAttribute('id');
603
			}
604
			$parentNode = $parentNode->parentNode;
605
		}
606
	}
607
608
	/**
609
	* Fill in output context
610
	*
611
	* @param  DOMDocument $ir
612
	* @return void
613
	*/
614
	protected static function setOutputContext(DOMDocument $ir)
615
	{
616
		foreach ($ir->getElementsByTagName('output') as $output)
617
		{
618
			$output->setAttribute('escape', self::getOutputContext($output));
619
		}
620
	}
621
622
	/**
623
	* Optimize an IR
624
	*
625
	* @param  DOMDocument $ir
626
	* @return void
627
	*/
628
	protected static function optimize(DOMDocument $ir)
629
	{
630
		// Get a snapshot of current internal representation
631
		$xml = $ir->saveXML();
632
633
		// Set a maximum number of loops to ward against infinite loops
634
		$remainingLoops = 10;
635
636
		// From now on, keep looping until no further modifications are applied
637
		do
638
		{
639
			$old = $xml;
640
			self::optimizeCloseTagElements($ir);
641
			$xml = $ir->saveXML();
642
		}
643
		while (--$remainingLoops > 0 && $xml !== $old);
644
645
		self::removeCloseTagSiblings($ir);
646
		self::removeContentFromVoidElements($ir);
647
		self::mergeConsecutiveLiteralOutputElements($ir);
648
		self::removeEmptyDefaultCases($ir);
649
	}
650
651
	/**
652
	* Remove redundant closeTag siblings after a switch
653
	*
654
	* If all branches of a switch have a closeTag we can remove any closeTag siblings of the switch
655
	*
656
	* @param  DOMDocument $ir
657
	* @return void
658
	*/
659
	protected static function removeCloseTagSiblings(DOMDocument $ir)
660
	{
661
		$query = '//switch[not(case[not(closeTag)])]/following-sibling::closeTag';
662
		self::removeNodes($ir, $query);
663
	}
664
665
	/**
666
	* Remove empty default cases (no test and no descendants)
667
	*
668
	* @param  DOMDocument $ir
669
	* @return void
670
	*/
671
	protected static function removeEmptyDefaultCases(DOMDocument $ir)
672
	{
673
		$query = '//case[not(@test | node())]';
674
		self::removeNodes($ir, $query);
675
	}
676
677
	/**
678
	* Merge consecutive literal outputs
679
	*
680
	* @param  DOMDocument $ir
681
	* @return void
682
	*/
683
	protected static function mergeConsecutiveLiteralOutputElements(DOMDocument $ir)
684
	{
685
		$xpath = new DOMXPath($ir);
686
		foreach ($xpath->query('//output[@type="literal"]') as $output)
687
		{
688
			while ($output->nextSibling
689
				&& $output->nextSibling->nodeName === 'output'
690
				&& $output->nextSibling->getAttribute('type') === 'literal')
691
			{
692
				$output->nodeValue
693
					= htmlspecialchars($output->nodeValue . $output->nextSibling->nodeValue);
694
				$output->parentNode->removeChild($output->nextSibling);
695
			}
696
		}
697
	}
698
699
	/**
700
	* Optimize closeTags elements
701
	*
702
	* @param  DOMDocument $ir
703
	* @return void
704
	*/
705
	protected static function optimizeCloseTagElements(DOMDocument $ir)
706
	{
707
		self::cloneCloseTagElementsIntoSwitch($ir);
708
		self::cloneCloseTagElementsOutOfSwitch($ir);
709
		self::removeRedundantCloseTagElementsInSwitch($ir);
710
		self::removeRedundantCloseTagElements($ir);
711
	}
712
713
	/**
714
	* Clone closeTag elements that follow a switch into said switch
715
	*
716
	* If there's a <closeTag/> right after a <switch/>, clone the <closeTag/> at the end of
717
	* the every <case/> that does not end with a <closeTag/>
718
	*
719
	* @param  DOMDocument $ir
720
	* @return void
721
	*/
722
	protected static function cloneCloseTagElementsIntoSwitch(DOMDocument $ir)
723
	{
724
		$xpath = new DOMXPath($ir);
725
		$query = '//switch[name(following-sibling::*) = "closeTag"]';
726
		foreach ($xpath->query($query) as $switch)
727
		{
728
			$closeTag = $switch->nextSibling;
729
			foreach ($switch->childNodes as $case)
730
			{
731
				if (!$case->lastChild || $case->lastChild->nodeName !== 'closeTag')
732
				{
733
					$case->appendChild($closeTag->cloneNode());
734
				}
735
			}
736
		}
737
	}
738
739
	/**
740
	* Clone closeTag elements from the head of a switch's cases before said switch
741
	*
742
	* If there's a <closeTag/> at the beginning of every <case/>, clone it and insert it
743
	* right before the <switch/> unless there's already one
744
	*
745
	* @param  DOMDocument $ir
746
	* @return void
747
	*/
748
	protected static function cloneCloseTagElementsOutOfSwitch(DOMDocument $ir)
749
	{
750
		$xpath = new DOMXPath($ir);
751
		$query = '//switch[not(preceding-sibling::closeTag)]';
752
		foreach ($xpath->query($query) as $switch)
753
		{
754
			foreach ($switch->childNodes as $case)
755
			{
756
				if (!$case->firstChild || $case->firstChild->nodeName !== 'closeTag')
757
				{
758
					// This case is either empty or does not start with a <closeTag/> so we skip
759
					// to the next <switch/>
760
					continue 2;
761
				}
762
			}
763
			// Insert the first child of the last <case/>, which should be the same <closeTag/>
764
			// as every other <case/>
765
			$switch->parentNode->insertBefore($switch->lastChild->firstChild->cloneNode(), $switch);
766
		}
767
	}
768
769
	/**
770
	* Remove all nodes that match given XPath query
771
	*
772
	* @param  DOMDocument $ir
773
	* @param  string      $query
774
	* @param  DOMNode     $contextNode
775
	* @return void
776
	*/
777
	protected static function removeNodes(DOMDocument $ir, $query, DOMNode $contextNode = null)
778
	{
779
		$xpath = new DOMXPath($ir);
780
		foreach ($xpath->query($query, $contextNode) as $node)
781
		{
782
			if ($node->parentNode instanceof DOMElement)
783
			{
784
				$node->parentNode->removeChild($node);
785
			}
786
		}
787
	}
788
789
	/**
790
	* Remove redundant closeTag elements from the tail of a switch's cases
791
	*
792
	* If there's a <closeTag/> right after a <switch/>, remove all <closeTag/> nodes at the
793
	* end of every <case/>
794
	*
795
	* @param  DOMDocument $ir
796
	* @return void
797
	*/
798
	protected static function removeRedundantCloseTagElementsInSwitch(DOMDocument $ir)
799
	{
800
		$xpath = new DOMXPath($ir);
801
		$query = '//switch[name(following-sibling::*) = "closeTag"]';
802
		foreach ($xpath->query($query) as $switch)
803
		{
804
			foreach ($switch->childNodes as $case)
805
			{
806
				while ($case->lastChild && $case->lastChild->nodeName === 'closeTag')
807
				{
808
					$case->removeChild($case->lastChild);
809
				}
810
			}
811
		}
812
	}
813
814
	/**
815
	* Remove redundant closeTag elements from the tail of a switch's cases
816
	*
817
	* For each <closeTag/> remove duplicate <closeTag/> nodes that are either siblings or
818
	* descendants of a sibling
819
	*
820
	* @param  DOMDocument $ir
821
	* @return void
822
	*/
823
	protected static function removeRedundantCloseTagElements(DOMDocument $ir)
824
	{
825
		$xpath = new DOMXPath($ir);
826
		foreach ($xpath->query('//closeTag') as $closeTag)
827
		{
828
			$id    = $closeTag->getAttribute('id');
829
			$query = 'following-sibling::*/descendant-or-self::closeTag[@id="' . $id . '"]';
830
831
			self::removeNodes($ir, $query, $closeTag);
832
		}
833
	}
834
835
	/**
836
	* Remove content from void elements
837
	*
838
	* For each void element, we find whichever <closeTag/> elements close it and remove everything
839
	* after
840
	*
841
	* @param  DOMDocument $ir
842
	* @return void
843
	*/
844
	protected static function removeContentFromVoidElements(DOMDocument $ir)
845
	{
846
		$xpath = new DOMXPath($ir);
847
		foreach ($xpath->query('//element[@void="yes"]') as $element)
848
		{
849
			$id    = $element->getAttribute('id');
850
			$query = './/closeTag[@id="' . $id . '"]/following-sibling::*';
851
852
			self::removeNodes($ir, $query, $element);
853
		}
854
	}
855
856
	/**
857
	* Mark switch elements that are used as branch tables
858
	*
859
	* If a switch is used for a series of equality tests against the same attribute or variable, the
860
	* attribute/variable is stored within the switch as "branch-key" and the values it is compared
861
	* against are stored JSON-encoded in the case as "branch-values". It can be used to create
862
	* optimized branch tables
863
	*
864
	* @param  DOMDocument $ir
865
	* @return void
866
	*/
867
	protected static function markBranchTables(DOMDocument $ir)
868
	{
869
		$xpath = new DOMXPath($ir);
870
871
		// Iterate over switch elements that have at least two case children with a test attribute
872
		foreach ($xpath->query('//switch[case[2][@test]]') as $switch)
873
		{
874
			$key = null;
875
			$branchValues = [];
876
877
			foreach ($switch->childNodes as $i => $case)
878
			{
879
				if (!$case->hasAttribute('test'))
880
				{
881
					continue;
882
				}
883
884
				$map = self::parseEqualityExpr($case->getAttribute('test'));
885
886
				// Test whether the expression matches an equality
887
				if ($map === false)
888
				{
889
					continue 2;
890
				}
891
892
				// Abort if there's more than 1 variable used
893
				if (count($map) !== 1)
894
				{
895
					continue 2;
896
				}
897
898
				// Test whether it uses the same key
899
				if (isset($key) && $key !== key($map))
900
				{
901
					continue 2;
902
				}
903
904
				$key = key($map);
905
				$branchValues[$i] = end($map);
906
			}
907
908
			$switch->setAttribute('branch-key', $key);
909
			foreach ($branchValues as $i => $values)
910
			{
911
				sort($values);
912
				$switch->childNodes->item($i)->setAttribute('branch-values', serialize($values));
913
			}
914
		}
915
	}
916
917
	//==========================================================================
918
	// Misc
919
	//==========================================================================
920
921
	/**
922
	* Create and append an element to given node in the IR
923
	*
924
	* @param  DOMElement $parentNode Parent node of the element
925
	* @param  string     $name       Tag name of the element
926
	* @param  string     $value      Value of the element
927
	* @return DOMElement             The created element
928
	*/
929
	protected static function appendElement(DOMElement $parentNode, $name, $value = '')
930
	{
931
		if ($value === '')
932
		{
933
			$element = $parentNode->ownerDocument->createElement($name);
934
		}
935
		else
936
		{
937
			$element = $parentNode->ownerDocument->createElement($name, $value);
938
		}
939
940
		$parentNode->appendChild($element);
941
942
		return $element;
943
	}
944
945
	/**
946
	* Append an <output/> element to given node in the IR
947
	*
948
	* @param  DOMElement $ir      Parent node
949
	* @param  string     $type    Either 'avt', 'literal' or 'xpath'
950
	* @param  string     $content Content to output
951
	* @return void
952
	*/
953
	protected static function appendOutput(DOMElement $ir, $type, $content)
954
	{
955
		// Reparse AVTs and add them as separate xpath/literal outputs
956
		if ($type === 'avt')
957
		{
958
			foreach (AVTHelper::parse($content) as $token)
959
			{
960
				$type = ($token[0] === 'expression') ? 'xpath' : 'literal';
961
				self::appendOutput($ir, $type, $token[1]);
962
			}
963
964
			return;
965
		}
966
967
		if ($type === 'xpath')
968
		{
969
			// Remove whitespace surrounding XPath expressions
970
			$content = trim($content);
971
		}
972
973
		if ($type === 'literal' && $content === '')
974
		{
975
			// Don't add empty literals
976
			return;
977
		}
978
979
		self::appendElement($ir, 'output', htmlspecialchars($content))
980
			->setAttribute('type', $type);
981
	}
982
983
	/**
984
	* Test whether given element will be empty at runtime (no content, no children)
985
	*
986
	* @param  DOMElement $ir Element in the IR
987
	* @return string         'yes', 'maybe' or 'no'
988
	*/
989
	protected static function isEmpty(DOMElement $ir)
990
	{
991
		$xpath = new DOMXPath($ir->ownerDocument);
992
993
		// Comments and elements count as not-empty and literal output is sure to output something
994
		if ($xpath->evaluate('count(comment | element | output[@type="literal"])', $ir))
995
		{
996
			return 'no';
997
		}
998
999
		// Test all branches of a <switch/>
1000
		// NOTE: this assumes that <switch/> are normalized to always have a default <case/>
1001
		$cases = [];
1002
		foreach ($xpath->query('switch/case', $ir) as $case)
1003
		{
1004
			$cases[self::isEmpty($case)] = 1;
1005
		}
1006
1007
		if (isset($cases['maybe']))
1008
		{
1009
			return 'maybe';
1010
		}
1011
1012
		if (isset($cases['no']))
1013
		{
1014
			// If all the cases are not-empty, the element is not-empty
1015
			if (!isset($cases['yes']))
1016
			{
1017
				return 'no';
1018
			}
1019
1020
			// Some 'yes' and some 'no', the element is a 'maybe'
1021
			return 'maybe';
1022
		}
1023
1024
		// Test for <apply-templates/> or XPath output
1025
		if ($xpath->evaluate('count(applyTemplates | output[@type="xpath"])', $ir))
1026
		{
1027
			// We can't know in advance whether those will produce output
1028
			return 'maybe';
1029
		}
1030
1031
		return 'yes';
1032
	}
1033
}