Completed
Push — master ( 9180ab...40753a )
by Josh
16:07
created

BBCodeMonkey::addAttributes()   F

Complexity

Conditions 25
Paths 1129

Size

Total Lines 198
Code Lines 77

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 73
CRAP Score 25

Importance

Changes 0
Metric Value
dl 0
loc 198
ccs 73
cts 73
cp 1
rs 2
c 0
b 0
f 0
cc 25
eloc 77
nc 1129
nop 3
crap 25

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