Completed
Push — master ( 01bbd5...8509de )
by Josh
17:18
created

BBCodeMonkey::parse()   D

Complexity

Conditions 10
Paths 49

Size

Total Lines 135
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 71
CRAP Score 10
Metric Value
dl 0
loc 135
ccs 71
cts 71
cp 1
rs 4.8196
cc 10
eloc 61
nc 49
nop 1
crap 10

How to fix   Long Method    Complexity   

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) 2010-2016 The s9e Authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\TextFormatter\Plugins\BBCodes\Configurator;
9
10
use Exception;
11
use InvalidArgumentException;
12
use RuntimeException;
13
use s9e\TextFormatter\Configurator;
14
use s9e\TextFormatter\Configurator\Helpers\RegexpBuilder;
15
use s9e\TextFormatter\Configurator\Items\Attribute;
16
use s9e\TextFormatter\Configurator\Items\ProgrammableCallback;
17
use s9e\TextFormatter\Configurator\Items\Tag;
18
use s9e\TextFormatter\Configurator\Items\Template;
19
20
class BBCodeMonkey
21
{
22
	/**
23
	* Expression that matches a regexp such as /foo/i
24
	*/
25
	const REGEXP = '(.).*?(?<!\\\\)(?>\\\\\\\\)*+\\g{-1}[DSUisu]*';
26
27
	/**
28
	* @var array List of pre- and post- filters that are explicitly allowed in BBCode definitions.
29
	*            We use a whitelist approach because there are so many different risky callbacks
30
	*            that it would be too easy to let something dangerous slip by, e.g.: unlink,
31
	*            system, etc...
32
	*/
33
	public $allowedFilters = [
34
		'addslashes',
35
		'dechex',
36
		'intval',
37
		'json_encode',
38
		'ltrim',
39
		'mb_strtolower',
40
		'mb_strtoupper',
41
		'rawurlencode',
42
		'rtrim',
43
		'str_rot13',
44
		'stripslashes',
45
		'strrev',
46
		'strtolower',
47
		'strtotime',
48
		'strtoupper',
49
		'trim',
50
		'ucfirst',
51
		'ucwords',
52
		'urlencode'
53
	];
54
55
	/**
56
	* @var Configurator Instance of Configurator;
57
	*/
58
	protected $configurator;
59
60
	/**
61
	* @var array Regexps used in the named subpatterns generated automatically for composite
62
	*            attributes. For instance, "foo={NUMBER},{NUMBER}" will be transformed into
63
	*            'foo={PARSE=#^(?<foo0>\\d+),(?<foo1>\\d+)$#D}'
64
	*/
65
	public $tokenRegexp = [
66
		'COLOR'      => '[a-zA-Z]+|#[0-9a-fA-F]+',
67
		'EMAIL'      => '[^@]+@.+?',
68
		'FLOAT'      => '(?>0|-?[1-9]\\d*)(?>\\.\\d+)?(?>e[1-9]\\d*)?',
69
		'ID'         => '[-a-zA-Z0-9_]+',
70
		'IDENTIFIER' => '[-a-zA-Z0-9_]+',
71
		'INT'        => '0|-?[1-9]\\d*',
72
		'INTEGER'    => '0|-?[1-9]\\d*',
73
		'NUMBER'     => '\\d+',
74
		'RANGE'      => '\\d+',
75
		'SIMPLETEXT' => '[-a-zA-Z0-9+.,_ ]+',
76
		'UINT'       => '0|[1-9]\\d*'
77
	];
78
79
	/**
80
	* @var array List of token types that are used to represent raw, unfiltered content
81
	*/
82
	public $unfilteredTokens = [
83
		'ANYTHING',
84
		'TEXT'
85
	];
86
87
	/**
88
	* Constructor
89
	*
90
	* @param  Configurator $configurator Instance of Configurator
91
	*/
92 91
	public function __construct(Configurator $configurator)
93
	{
94 91
		$this->configurator = $configurator;
95 91
	}
96
97
	/**
98
	* Create a BBCode and its underlying tag and template(s) based on its reference usage
99
	*
100
	* @param  string          $usage    BBCode usage, e.g. [B]{TEXT}[/b]
101
	* @param  string|Template $template BBCode's template
102
	* @return array                     An array containing three elements: 'bbcode', 'bbcodeName'
103
	*                                   and 'tag'
104
	*/
105 90
	public function create($usage, $template)
106
	{
107
		// Parse the BBCode usage
108 90
		$config = $this->parse($usage);
109
110
		// Create a template object for manipulation
111 76
		if (!($template instanceof Template))
112 76
		{
113 75
			$template = new Template($template);
114 75
		}
115
116
		// Replace the passthrough token in the BBCode's template
117 76
		$template->replaceTokens(
118 76
			'#\\{(?:[A-Z]+[A-Z_0-9]*|@[-\\w]+)\\}#',
119
			function ($m) use ($config)
120
			{
121 27
				$tokenId = substr($m[0], 1, -1);
122
123
				// Acknowledge {@foo} as an XPath expression even outside of attribute value
124
				// templates
125 27
				if ($tokenId[0] === '@')
126 27
				{
127 1
					return ['expression', $tokenId];
128
				}
129
130
				// Test whether this is a known token
131 26
				if (isset($config['tokens'][$tokenId]))
132 26
				{
133
					// Replace with the corresponding attribute
134 12
					return ['expression', '@' . $config['tokens'][$tokenId]];
135
				}
136
137
				// Test whether the token is used as passthrough
138 17
				if ($tokenId === $config['passthroughToken'])
139 17
				{
140 11
					return ['passthrough'];
141
				}
142
143
				// Undefined token. If it's the name of a filter, consider it's an error
144 6
				if ($this->isFilter($tokenId))
145 6
				{
146 4
					throw new RuntimeException('Token {' . $tokenId . '} is ambiguous or undefined');
147
				}
148
149
				// Use the token's name as parameter name
150 2
				return ['expression', '$' . $tokenId];
151
			}
152 76
		);
153
154
		// Prepare the return array
155
		$return = [
156 72
			'bbcode'     => $config['bbcode'],
157 72
			'bbcodeName' => $config['bbcodeName'],
158 72
			'tag'        => $config['tag']
159 72
		];
160
161
		// Set the template for this BBCode's tag
162 72
		$return['tag']->template = $template;
163
164 72
		return $return;
165
	}
166
167
	/**
168
	* Create a BBCode based on its reference usage
169
	*
170
	* @param  string $usage BBCode usage, e.g. [B]{TEXT}[/b]
171
	* @return array
172
	*/
173 90
	protected function parse($usage)
174
	{
175 90
		$tag    = new Tag;
176 90
		$bbcode = new BBCode;
177
178
		// This is the config we will return
179
		$config = [
180 90
			'tag'              => $tag,
181 90
			'bbcode'           => $bbcode,
182
			'passthroughToken' => null
183 90
		];
184
185
		// Encode maps to avoid special characters to interfere with definitions
186 90
		$usage = preg_replace_callback(
187 90
			'#(\\{(?>HASH)?MAP=)([^:]+:[^,;}]+(?>,[^:]+:[^,;}]+)*)(?=[;}])#',
188
			function ($m)
189
			{
190 8
				return $m[1] . base64_encode($m[2]);
191 90
			},
192
			$usage
193 90
		);
194
195
		// Encode regexps to avoid special characters to interfere with definitions
196 90
		$usage = preg_replace_callback(
197 90
			'#(\\{(?:PARSE|REGEXP)=)(' . self::REGEXP . '(?:,' . self::REGEXP . ')*)#',
198
			function ($m)
199
			{
200 14
				return $m[1] . base64_encode($m[2]);
201 90
			},
202
			$usage
203 90
		);
204
205
		$regexp = '(^'
206
		        // [BBCODE
207
		        . '\\[(?<bbcodeName>\\S+?)'
208
		        // ={TOKEN}
209 90
		        . '(?<defaultAttribute>=\\S+?)?'
210
		        // foo={TOKEN} bar={TOKEN1},{TOKEN2}
211 90
		        . '(?<attributes>(?:\\s+[^=]+=\\S+?)*?)?'
212
		        // ] or /] or ]{TOKEN}[/BBCODE]
213 90
		        . '\\s*(?:/?\\]|\\]\\s*(?<content>.*?)\\s*(?<endTag>\\[/\\1]))'
214 90
		        . '$)i';
215
216 90
		if (!preg_match($regexp, trim($usage), $m))
217 90
		{
218 1
			throw new InvalidArgumentException('Cannot interpret the BBCode definition');
219
		}
220
221
		// Save the BBCode's name
222 89
		$config['bbcodeName'] = BBCode::normalizeName($m['bbcodeName']);
223
224
		// Prepare the attributes definition, e.g. "foo={BAR}"
225 88
		$definitions = preg_split('#\\s+#', trim($m['attributes']), -1, PREG_SPLIT_NO_EMPTY);
226
227
		// If there's a default attribute, we prepend it to the list using the BBCode's name as
228
		// attribute name
229 88
		if (!empty($m['defaultAttribute']))
230 88
		{
231 45
			array_unshift($definitions, $m['bbcodeName'] . $m['defaultAttribute']);
232 45
		}
233
234
		// Append the content token to the attributes list under the name "content" if it's anything
235
		// but raw {TEXT} (or other unfiltered tokens)
236 88
		if (!empty($m['content']))
237 88
		{
238 38
			$regexp = '#^\\{' . RegexpBuilder::fromList($this->unfilteredTokens) . '[0-9]*\\}$#D';
239
240 38
			if (preg_match($regexp, $m['content']))
241 38
			{
242 33
				$config['passthroughToken'] = substr($m['content'], 1, -1);
243 33
			}
244
			else
245
			{
246 5
				$definitions[] = 'content=' . $m['content'];
247 5
				$bbcode->contentAttributes[] = 'content';
248
			}
249 38
		}
250
251
		// Separate the attribute definitions from the BBCode options
252 88
		$attributeDefinitions = [];
253 88
		foreach ($definitions as $definition)
254
		{
255 70
			$pos   = strpos($definition, '=');
256 70
			$name  = substr($definition, 0, $pos);
257 70
			$value = substr($definition, 1 + $pos);
258
259
			// Decode base64-encoded tokens
260 70
			$value = preg_replace_callback(
261 70
				'#(\\{(?>HASHMAP|MAP|PARSE|REGEXP)=)([A-Za-z0-9+/]+=*)#',
262 70
				function ($m)
263
				{
264 24
					return $m[1] . base64_decode($m[2]);
265 70
				},
266
				$value
267 70
			);
268
269
			// If name starts with $ then it's a BBCode options, if it starts with # it's a rule and
270
			// otherwise it's an attribute definition
271 70
			if ($name[0] === '$')
272 70
			{
273 3
				$optionName = substr($name, 1);
274 3
				$bbcode->$optionName = $this->convertValue($value);
275 3
			}
276 67
			elseif ($name[0] === '#')
277
			{
278 4
				$ruleName = substr($name, 1);
279
280
				// Supports #denyChild=foo,bar
281 4
				foreach (explode(',', $value) as $value)
282
				{
283 4
					$tag->rules->$ruleName($this->convertValue($value));
284 4
				}
285 4
			}
286
			else
287
			{
288 63
				$attrName = strtolower(trim($name));
289 63
				$attributeDefinitions[] = [$attrName, $value];
290
			}
291 88
		}
292
293
		// Add the attributes and get the token translation table
294 88
		$tokens = $this->addAttributes($attributeDefinitions, $bbcode, $tag);
295
296
		// Test whether the passthrough token is used for something else, in which case we need
297
		// to unset it
298 76
		if (isset($tokens[$config['passthroughToken']]))
299 76
		{
300 2
			$config['passthroughToken'] = null;
301 2
		}
302
303
		// Add the list of known (and only the known) tokens to the config
304 76
		$config['tokens'] = array_filter($tokens);
305
306 76
		return $config;
307
	}
308
309
	/**
310
	* Parse a string of attribute definitions and add the attributes/options to the tag/BBCode
311
	*
312
	* Attributes come in two forms. Most commonly, in the form of a single token, e.g.
313
	*   [a href={URL} title={TEXT}]
314
	*
315
	* Sometimes, however, we need to parse more than one single token. For instance, the phpBB
316
	* [FLASH] BBCode uses two tokens separated by a comma:
317
	*   [flash={NUMBER},{NUMBER}]{URL}[/flash]
318
	*
319
	* In addition, some custom BBCodes circulating for phpBB use a combination of token and static
320
	* text such as:
321
	*   [youtube]http://www.youtube.com/watch?v={SIMPLETEXT}[/youtube]
322
	*
323
	* Any attribute that is not a single token is implemented as an attribute preprocessor, with
324
	* each token generating a matching attribute. Tentatively, those  of those attributes are
325
	* created by taking the attribute preprocessor's name and appending a unique number counting the
326
	* number of created attributes. In the [FLASH] example above, an attribute preprocessor named
327
	* "flash" would be created as well as two attributes named "flash0" and "flash1" respectively.
328
	*
329
	* @link https://www.phpbb.com/community/viewtopic.php?f=46&t=2127991
330
	* @link https://www.phpbb.com/community/viewtopic.php?f=46&t=579376
331
	*
332
	* @param  array  $definitions List of attributes definitions as [[name, definition]*]
333
	* @param  BBCode $bbcode      Owner BBCode
334
	* @param  Tag    $tag         Owner tag
335
	* @return array               Array of [token id => attribute name] where FALSE in place of the
336
	*                             name indicates that the token is ambiguous (e.g. used multiple
337
	*                             times)
338
	*/
339 88
	protected function addAttributes(array $definitions, BBCode $bbcode, Tag $tag)
340
	{
341
		/**
342
		* @var array List of composites' tokens. Each element is composed of an attribute name, the
343
		*            composite's definition and an array of tokens
344
		*/
345 88
		$composites = [];
346
347
		/**
348
		* @var array Map of [tokenId => attrName]. If the same token is used in multiple attributes
349
		*            it is set to FALSE
350
		*/
351 88
		$table = [];
352
353 88
		foreach ($definitions as list($attrName, $definition))
354
		{
355
			// The first attribute defined is set as default
356 63
			if (!isset($bbcode->defaultAttribute))
357 63
			{
358 63
				$bbcode->defaultAttribute = $attrName;
359 63
			}
360
361
			// Parse the tokens in that definition
362 63
			$tokens = self::parseTokens($definition);
363
364 61
			if (empty($tokens))
365 61
			{
366 1
				throw new RuntimeException('No valid tokens found in ' . $attrName . "'s definition " . $definition);
367
			}
368
369
			// Test whether this attribute has one single all-encompassing token
370 60
			if ($tokens[0]['content'] === $definition)
371 60
			{
372 53
				$token = $tokens[0];
373
374 53
				if ($token['type'] === 'PARSE')
375 53
				{
376 8
					foreach ($token['regexps'] as $regexp)
377
					{
378 8
						$tag->attributePreprocessors->add($attrName, $regexp);
379 8
					}
380 8
				}
381 46
				elseif (isset($tag->attributes[$attrName]))
382
				{
383 1
					throw new RuntimeException("Attribute '" . $attrName . "' is declared twice");
384
				}
385
				else
386
				{
387
					// Remove the "useContent" option and add the attribute's name to the list of
388
					// attributes to use this BBCode's content
389 46
					if (!empty($token['options']['useContent']))
390 46
					{
391 3
						$bbcode->contentAttributes[] = $attrName;
392 3
					}
393 46
					unset($token['options']['useContent']);
394
395
					// Add the attribute
396 46
					$tag->attributes[$attrName] = $this->generateAttribute($token);
397
398
					// Record the token ID if applicable
399 42
					$tokenId = $token['id'];
400 42
					$table[$tokenId] = (isset($table[$tokenId]))
401 42
					                 ? false
402 3
					                 : $attrName;
403
				}
404 49
			}
405
			else
406
			{
407 10
				$composites[] = [$attrName, $definition, $tokens];
408
			}
409 81
		}
410
411 80
		foreach ($composites as list($attrName, $definition, $tokens))
412
		{
413 10
			$regexp  = '/^';
414 10
			$lastPos = 0;
415
416 10
			$usedTokens = [];
417
418 10
			foreach ($tokens as $token)
419
			{
420 10
				$tokenId   = $token['id'];
421 10
				$tokenType = $token['type'];
422
423 10
				if ($tokenType === 'PARSE')
424 10
				{
425
					// Disallow {PARSE} tokens because attribute preprocessors cannot feed into
426
					// other attribute preprocessors
427 1
					throw new RuntimeException('{PARSE} tokens can only be used has the sole content of an attribute');
428
				}
429
430
				// Ensure that tokens are only used once per definition so we don't have multiple
431
				// subpatterns using the same name
432 9
				if (isset($usedTokens[$tokenId]))
433 9
				{
434 1
					throw new RuntimeException('Token {' . $tokenId . '} used multiple times in attribute ' . $attrName . "'s definition");
435
				}
436 9
				$usedTokens[$tokenId] = 1;
437
438
				// Find the attribute name associated with this token, or create an attribute
439
				// otherwise
440 9
				if (isset($table[$tokenId]))
441 9
				{
442 3
					$matchName = $table[$tokenId];
443
444 3
					if ($matchName === false)
445 3
					{
446 1
						throw new RuntimeException('Token {' . $tokenId . "} used in attribute '" . $attrName . "' is ambiguous");
447
					}
448 2
				}
449
				else
450
				{
451
					// The name of the named subpattern and the corresponding attribute is based on
452
					// the attribute preprocessor's name, with an incremented ID that ensures we
453
					// don't overwrite existing attributes
454 7
					$i = 0;
455
					do
456
					{
457 7
						$matchName = $attrName . $i;
458 7
						++$i;
459
					}
460 7
					while (isset($tag->attributes[$matchName]));
461
462
					// Create the attribute that corresponds to this subpattern
463 7
					$attribute = $tag->attributes->add($matchName);
464
465
					// Append the corresponding filter if applicable
466 7
					if (!in_array($tokenType, $this->unfilteredTokens, true))
467 7
					{
468 6
						$filter = $this->configurator->attributeFilters->get('#' . strtolower($tokenType));
469 6
						$attribute->filterChain->append($filter);
470 6
					}
471
472
					// Record the attribute name associated with this token ID
473 7
					$table[$tokenId] = $matchName;
474
				}
475
476
				// Append the literal text between the last position and current position
477 8
				$regexp .= preg_quote(substr($definition, $lastPos, $token['pos'] - $lastPos), '/');
478
479
				// Grab the expression that corresponds to the token type, or use a catch-all
480
				// expression otherwise
481 8
				$expr = (isset($this->tokenRegexp[$tokenType]))
482 8
				      ? $this->tokenRegexp[$tokenType]
483 8
				      : '.+?';
484
485
				// Append the named subpattern. Its name is made of the attribute preprocessor's
486
				// name and the subpattern's position
487 8
				$regexp .= '(?<' . $matchName . '>' . $expr . ')';
488
489
				// Update the last position
490 8
				$lastPos = $token['pos'] + strlen($token['content']);
491 8
			}
492
493
			// Append the literal text that follows the last token and finish the regexp
494 7
			$regexp .= preg_quote(substr($definition, $lastPos), '/') . '$/D';
495
496
			// Add the attribute preprocessor to the config
497 7
			$tag->attributePreprocessors->add($attrName, $regexp);
498 77
		}
499
500
		// Now create attributes generated from attribute preprocessors. For instance, preprocessor
501
		// #(?<width>\\d+),(?<height>\\d+)# will generate two attributes named "width" and height
502
		// with a regexp filter "#^(?:\\d+)$#D", unless they were explicitly defined otherwise
503 77
		$newAttributes = [];
504 77
		foreach ($tag->attributePreprocessors as $attributePreprocessor)
505
		{
506 15
			foreach ($attributePreprocessor->getAttributes() as $attrName => $regexp)
507
			{
508 15
				if (isset($tag->attributes[$attrName]))
509 15
				{
510
					// This attribute was already explicitly defined, nothing else to add
511 8
					continue;
512
				}
513
514 7
				if (isset($newAttributes[$attrName])
515 7
				 && $newAttributes[$attrName] !== $regexp)
516 7
				{
517 1
					throw new RuntimeException("Ambiguous attribute '" . $attrName . "' created using different regexps needs to be explicitly defined");
518
				}
519
520 7
				$newAttributes[$attrName] = $regexp;
521 15
			}
522 77
		}
523
524 76
		foreach ($newAttributes as $attrName => $regexp)
525
		{
526 6
			$filter = $this->configurator->attributeFilters->get('#regexp');
527
528
			// Create the attribute using this regexp as filter
529 6
			$tag->attributes->add($attrName)->filterChain->append($filter)->setRegexp($regexp);
530 76
		}
531
532 76
		return $table;
533
	}
534
535
	/**
536
	* Convert a human-readable value to a typed PHP value
537
	*
538
	* @param  string      $value Original value
539
	* @return bool|string        Converted value
540
	*/
541 7
	protected function convertValue($value)
542
	{
543 7
		if ($value === 'true')
544 7
		{
545 2
			return true;
546
		}
547
548 5
		if ($value === 'false')
549 5
		{
550 2
			return false;
551
		}
552
553 3
		return $value;
554
	}
555
556
	/**
557
	* Parse and return all the tokens contained in a definition
558
	*
559
	* @param  string $definition
560
	* @return array
561
	*/
562 63
	protected static function parseTokens($definition)
563
	{
564
		$tokenTypes = [
565 63
			'choice' => 'CHOICE[0-9]*=(?<choices>.+?)',
566 63
			'map'    => '(?:HASH)?MAP[0-9]*=(?<map>.+?)',
567 63
			'parse'  => 'PARSE=(?<regexps>' . self::REGEXP . '(?:,' . self::REGEXP . ')*)',
568 63
			'range'  => 'RAN(?:DOM|GE)[0-9]*=(?<min>-?[0-9]+),(?<max>-?[0-9]+)',
569 63
			'regexp' => 'REGEXP[0-9]*=(?<regexp>' . self::REGEXP . ')',
570
			'other'  => '(?<other>[A-Z_]+[0-9]*)'
571 63
		];
572
573
		// Capture the content of every token in that attribute's definition. Usually there will
574
		// only be one, as in "foo={URL}" but some older BBCodes use a form of composite
575
		// attributes such as [FLASH={NUMBER},{NUMBER}]
576 63
		preg_match_all(
577 63
			'#\\{(' . implode('|', $tokenTypes) . ')(?<options>(?:;[^;]*)*)\\}#',
578 63
			$definition,
579 63
			$matches,
580 63
			PREG_SET_ORDER | PREG_OFFSET_CAPTURE
581 63
		);
582
583 63
		$tokens = [];
584 63
		foreach ($matches as $m)
585
		{
586 62
			if (isset($m['other'][0])
587 62
			 && preg_match('#^(?:CHOICE|HASHMAP|MAP|REGEXP|PARSE|RANDOM|RANGE)#', $m['other'][0]))
588 62
			{
589 2
				throw new RuntimeException("Malformed token '" . $m['other'][0] . "'");
590
			}
591
592
			$token = [
593 60
				'pos'     => $m[0][1],
594 60
				'content' => $m[0][0],
595 60
				'options' => []
596 60
			];
597
598
			// Get this token's type by looking at the start of the match
599 60
			$head = $m[1][0];
600 60
			$pos  = strpos($head, '=');
601
602 60
			if ($pos === false)
603 60
			{
604
				// {FOO}
605 32
				$token['id'] = $head;
606 32
			}
607
			else
608
			{
609
				// {FOO=...}
610 29
				$token['id'] = substr($head, 0, $pos);
611
612
				// Copy the content of named subpatterns into the token's config
613 29
				foreach ($m as $k => $v)
614
				{
615 29
					if (!is_numeric($k) && $k !== 'options' && $v[1] !== -1)
616 29
					{
617 29
						$token[$k] = $v[0];
618 29
					}
619 29
				}
620
			}
621
622
			// The token's type is its id minus the number, e.g. NUMBER1 => NUMBER
623 60
			$token['type'] = rtrim($token['id'], '0123456789');
624
625
			// Parse the options
626 60
			$options = (isset($m['options'][0])) ? $m['options'][0] : '';
627 60
			foreach (preg_split('#;+#', $options, -1, PREG_SPLIT_NO_EMPTY) as $pair)
628
			{
629 16
				$pos = strpos($pair, '=');
630
631 16
				if ($pos === false)
632 16
				{
633
					// Options with no value are set to true, e.g. {FOO;useContent}
634 11
					$k = $pair;
635 11
					$v = true;
636 11
				}
637
				else
638
				{
639 6
					$k = substr($pair, 0, $pos);
640 6
					$v = substr($pair, 1 + $pos);
641
				}
642
643 16
				$token['options'][$k] = $v;
644 60
			}
645
646
			// {PARSE} tokens can have several regexps separated with commas, we split them up here
647 60
			if ($token['type'] === 'PARSE')
648 60
			{
649
				// Match all occurences of a would-be regexp followed by a comma or the end of the
650
				// string
651 9
				preg_match_all('#' . self::REGEXP . '(?:,|$)#', $token['regexps'], $m);
652
653 9
				$regexps = [];
654 9
				foreach ($m[0] as $regexp)
655
				{
656
					// remove the potential comma at the end
657 9
					$regexps[] = rtrim($regexp, ',');
658 9
				}
659
660 9
				$token['regexps'] = $regexps;
661 9
			}
662
663 60
			$tokens[] = $token;
664 61
		}
665
666 61
		return $tokens;
667
	}
668
669
	/**
670
	* Generate an attribute based on a token
671
	*
672
	* @param  array     $token Token this attribute is based on
673
	* @return Attribute
674
	*/
675 46
	protected function generateAttribute(array $token)
676
	{
677 46
		$attribute = new Attribute;
678
679 46
		if (isset($token['options']['preFilter']))
680 46
		{
681 2
			$this->appendFilters($attribute, $token['options']['preFilter']);
682 1
			unset($token['options']['preFilter']);
683 1
		}
684
685 45
		if ($token['type'] === 'REGEXP')
686 45
		{
687 5
			$filter = $this->configurator->attributeFilters->get('#regexp');
688 5
			$attribute->filterChain->append($filter)->setRegexp($token['regexp']);
689 5
		}
690 40
		elseif ($token['type'] === 'RANGE')
691
		{
692 1
			$filter = $this->configurator->attributeFilters->get('#range');
693 1
			$attribute->filterChain->append($filter)->setRange($token['min'], $token['max']);
694 1
		}
695 39
		elseif ($token['type'] === 'RANDOM')
696
		{
697 1
			$attribute->generator = new ProgrammableCallback('mt_rand');
698 1
			$attribute->generator->addParameterByValue((int) $token['min']);
699 1
			$attribute->generator->addParameterByValue((int) $token['max']);
700 1
		}
701 38
		elseif ($token['type'] === 'CHOICE')
702
		{
703 3
			$filter = $this->configurator->attributeFilters->get('#choice');
704 3
			$attribute->filterChain->append($filter)->setValues(
705 3
				explode(',', $token['choices']),
706 3
				!empty($token['options']['caseSensitive'])
707 3
			);
708 3
			unset($token['options']['caseSensitive']);
709 3
		}
710 35
		elseif ($token['type'] === 'HASHMAP' || $token['type'] === 'MAP')
711
		{
712
			// Build the map from the string
713 10
			$map = [];
714 10
			foreach (explode(',', $token['map']) as $pair)
715
			{
716 10
				$pos = strpos($pair, ':');
717
718 10
				if ($pos === false)
719 10
				{
720 2
					throw new RuntimeException("Invalid map assignment '" . $pair . "'");
721
				}
722
723 10
				$map[substr($pair, 0, $pos)] = substr($pair, 1 + $pos);
724 10
			}
725
726
			// Create the filter then append it to the attribute
727 8
			if ($token['type'] === 'HASHMAP')
728 8
			{
729 2
				$filter = $this->configurator->attributeFilters->get('#hashmap');
730 2
				$attribute->filterChain->append($filter)->setMap(
731 2
					$map,
732 2
					!empty($token['options']['strict'])
733 2
				);
734 2
			}
735
			else
736
			{
737 6
				$filter = $this->configurator->attributeFilters->get('#map');
738 6
				$attribute->filterChain->append($filter)->setMap(
739 6
					$map,
740 6
					!empty($token['options']['caseSensitive']),
741 6
					!empty($token['options']['strict'])
742 6
				);
743
			}
744
745
			// Remove options that are not needed anymore
746 8
			unset($token['options']['caseSensitive']);
747 8
			unset($token['options']['strict']);
748 8
		}
749 25
		elseif (!in_array($token['type'], $this->unfilteredTokens, true))
750
		{
751 16
			$filter = $this->configurator->attributeFilters->get('#' . $token['type']);
752 16
			$attribute->filterChain->append($filter);
753 16
		}
754
755 43
		if (isset($token['options']['postFilter']))
756 43
		{
757 3
			$this->appendFilters($attribute, $token['options']['postFilter']);
758 2
			unset($token['options']['postFilter']);
759 2
		}
760
761
		// Set the "required" option if "required" or "optional" is set, then remove
762
		// the "optional" option
763 42
		if (isset($token['options']['required']))
764 42
		{
765 1
			$token['options']['required'] = (bool) $token['options']['required'];
766 1
		}
767 41
		elseif (isset($token['options']['optional']))
768
		{
769 2
			$token['options']['required'] = !$token['options']['optional'];
770 2
		}
771 42
		unset($token['options']['optional']);
772
773 42
		foreach ($token['options'] as $k => $v)
774
		{
775 3
			$attribute->$k = $v;
776 42
		}
777
778 42
		return $attribute;
779
	}
780
781
	/**
782
	* Append a list of filters to an attribute's filterChain
783
	*
784
	* @param  Attribute $attribute
785
	* @param  string    $filters   List of filters, separated with commas
786
	* @return void
787
	*/
788 5
	protected function appendFilters(Attribute $attribute, $filters)
789
	{
790 5
		foreach (preg_split('#\\s*,\\s*#', $filters) as $filterName)
791
		{
792 5
			if (substr($filterName, 0, 1) !== '#'
793 5
			 && !in_array($filterName, $this->allowedFilters, true))
794 5
			{
795 2
				throw new RuntimeException("Filter '" . $filterName . "' is not allowed");
796
			}
797
798 3
			$filter = $this->configurator->attributeFilters->get($filterName);
799 3
			$attribute->filterChain->append($filter);
800 3
		}
801 3
	}
802
803
	/**
804
	* Test whether a token's name is the name of a filter
805
	*
806
	* @param  string $tokenId Token ID, e.g. "TEXT1"
807
	* @return bool
808
	*/
809 6
	protected function isFilter($tokenId)
810
	{
811 6
		$filterName = rtrim($tokenId, '0123456789');
812
813 6
		if (in_array($filterName, $this->unfilteredTokens, true))
814 6
		{
815 2
			return true;
816
		}
817
818
		// Try to load the filter
819
		try
820
		{
821 4
			if ($this->configurator->attributeFilters->get('#' . $filterName))
822 2
			{
823 2
				return true;
824
			}
825
		}
826 2
		catch (Exception $e)
827
		{
828
			// Nothing to do here
829
		}
830
831 2
		return false;
832
	}
833
}