Completed
Push — master ( 40753a...1c136a )
by Josh
20:09
created

BBCodeMonkey   D

Complexity

Total Complexity 85

Size/Duplication

Total Lines 830
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 85
lcom 1
cbo 10
dl 0
loc 830
rs 4.4444
c 0
b 0
f 0
ccs 276
cts 276
cp 1

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
B create() 0 61 6
C parse() 0 140 12
F addAttributes() 0 198 25
A convertValue() 0 14 3
C parseTokens() 0 85 12
D generateAttribute() 0 99 15
A appendFilters() 0 14 4
B isFilter() 0 24 4
B parseOptionString() 0 26 3

How to fix   Complexity   

Complex Class

Complex classes like BBCodeMonkey often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BBCodeMonkey, and based on these observations, apply Extract Interface, too.

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