Completed
Branch development (176841)
by Elk
06:59
created

BBCParser::parse()   C

Complexity

Conditions 10
Paths 34

Size

Total Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 10.0512

Importance

Changes 0
Metric Value
cc 10
nc 34
nop 1
dl 0
loc 68
rs 6.8315
c 0
b 0
f 0
ccs 23
cts 25
cp 0.92
crap 10.0512

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
 *
5
 * @package   ElkArte Forum
6
 * @copyright ElkArte Forum contributors
7
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
8
 *
9
 * This file contains code covered by:
10
 * copyright:	2011 Simple Machines (http://www.simplemachines.org)
11
 *
12
 * @version 2.0 dev
13
 *
14
 */
15
16
namespace BBC;
17
18
/**
19
 * Class BBCParser
20
 *
21
 * One of the primary functions, parsing BBC to HTML
22
 *
23
 * @package BBC
24
 */
25
class BBCParser
26
{
27
	/** The max number of iterations to perform while solving for out of order attributes */
28
	const MAX_PERMUTE_ITERATIONS = 5040;
29
30
	/** @var string  */
31
	protected $message;
32
	/** @var Codes  */
33
	protected $bbc;
34
	/** @var array  */
35
	protected $bbc_codes;
36
	/** @var array  */
37
	protected $item_codes;
38
	/** @var array  */
39
	protected $tags;
40
	/** @var int parser position in message */
41
	protected $pos;
42
	/** @var int  */
43
	protected $pos1;
44
	/** @var int  */
45
	protected $pos2;
46
	/** @var int  */
47
	protected $pos3;
48
	/** @var int  */
49
	protected $last_pos;
50
	/** @var bool  */
51
	protected $do_smileys = true;
52
	/** @var array  */
53
	protected $open_tags = array();
54
	/** @var string|null This is the actual tag that's open */
55
	protected $inside_tag;
56
	/** @var Autolink|null  */
57
	protected $autolinker;
58
	/** @var bool  */
59
	protected $possible_html;
60
	/** @var HtmlParser|null  */
61
	protected $html_parser;
62
	/** @var bool if we can cache the message or not (some tags disallow caching) */
63
	protected $can_cache = true;
64
	/** @var int footnote tracker */
65
	protected $num_footnotes = 0;
66
	/** @var string used to mark smiles in a message */
67
	protected $smiley_marker = "\r";
68
	/** @var int  */
69
	protected $lastAutoPos = 0;
70
	/** @var array content fo the footnotes */
71
	protected $fn_content = array();
72
	/** @var array  */
73
	protected $tag_possible = array();
74
	/** @var int  */
75
	protected $fn_count = 0;
76
	/** @var int  */
77
	protected $fn_num = 0;
78
79
	/**
80
	 * BBCParser constructor.
81
	 *
82
	 * @param \BBC\Codes $bbc
83
	 * @param \BBC\Autolink|null $autolinker
84
	 * @param \BBC\HtmlParser|null $html_parser
85
	 */
86 4
	public function __construct(Codes $bbc, Autolink $autolinker = null, HtmlParser $html_parser = null)
87
	{
88 4
		$this->bbc = $bbc;
89
90 4
		$this->bbc_codes = $this->bbc->getForParsing();
91 4
		$this->item_codes = $this->bbc->getItemCodes();
92
93 4
		$this->autolinker = $autolinker;
94 4
		$this->loadAutolink();
95
96 4
		$this->html_parser = $html_parser;
97 4
	}
98
99
	/**
100
	 * Reset the parser's properties for a new message
101
	 */
102 10
	public function resetParser()
103
	{
104 10
		$this->pos = -1;
105 10
		$this->pos1 = null;
106 10
		$this->pos2 = null;
107 10
		$this->last_pos = null;
108 10
		$this->open_tags = array();
109 10
		$this->inside_tag = null;
110 10
		$this->lastAutoPos = 0;
111 10
		$this->can_cache = true;
112 10
		$this->num_footnotes = 0;
113 10
	}
114
115
	/**
116
	 * Parse the BBC in a string/message
117
	 *
118
	 * @param string $message
119
	 *
120
	 * @return string
121
	 */
122 12
	public function parse($message)
123
	{
124
		// Allow addons access before entering the main parse loop, formally
125
		// called as integrate_pre_parsebbc
126 12
		call_integration_hook('integrate_pre_bbc_parser', array(&$message, &$this->bbc));
127
128 12
		$this->message = (string) $message;
129
130
		// Don't waste cycles
131 12
		if ($this->message === '')
132
		{
133 2
			return '';
134
		}
135
136
		// @todo remove from here and make the caller figure it out
137 10
		if (!$this->parsingEnabled())
138
		{
139
			return $this->message;
140
		}
141
142 10
		$this->resetParser();
143
144
		// @todo change this to <br> (it will break tests and previews and ...)
145 10
		$this->message = str_replace("\n", '<br />', $this->message);
146
147
		// Check if the message might have a link or email to save a bunch of parsing in autolink()
148 10
		$this->autolinker->setPossibleAutolink($this->message);
149
150 10
		$this->possible_html = !empty($GLOBALS['modSettings']['enablePostHTML']) && strpos($message, '&lt;') !== false;
151
152
		// Don't load the HTML Parser unless we have to
153 10
		if ($this->possible_html && $this->html_parser === null)
154
		{
155 2
			$this->loadHtmlParser();
156
		}
157
158
		// This handles pretty much all of the parsing. It is a separate method so it is easier to override and profile.
159 10
		$this->parse_loop();
160
161
		// Close any remaining tags.
162 10
		while ($tag = $this->closeOpenedTag())
163
		{
164 2
			$this->message .= $this->noSmileys($tag[Codes::ATTR_AFTER]);
165
		}
166
167 10
		if (isset($this->message[0]) && $this->message[0] === ' ')
168
		{
169
			$this->message = substr_replace($this->message, '&nbsp;', 0, 1);
170
			//$this->message = '&nbsp;' . substr($this->message, 1);
171
		}
172
173
		// Cleanup whitespace.
174 10
		$this->message = str_replace(array('  ', '<br /> ', '&#13;'), array('&nbsp; ', '<br />&nbsp;', "\n"), $this->message);
175
176
		// Finish footnotes if we have any.
177 10
		if ($this->num_footnotes > 0)
178
		{
179 2
			$this->handleFootnotes();
180
		}
181
182
		// Allow addons access to what the parser created, formally
183
		// called as integrate_post_parsebbc
184 10
		$message = $this->message;
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $message. This often makes code more readable.
Loading history...
185 10
		call_integration_hook('integrate_post_bbc_parser', array(&$message));
186 10
		$this->message = $message;
187
188 10
		return $this->message;
189
	}
190
191
	/**
192
	 * The BBC parsing loop-o-love
193
	 *
194
	 * Walks the string to parse, looking for BBC tags and passing items to the required translation functions
195
	 */
196 10
	protected function parse_loop()
197
	{
198 10
		while ($this->pos !== false)
199
		{
200 10
			$this->last_pos = isset($this->last_pos) ? max($this->pos, $this->last_pos) : $this->pos;
201 10
			$this->pos = strpos($this->message, '[', $this->pos + 1);
202
203
			// Failsafe.
204 10
			if ($this->pos === false || $this->last_pos > $this->pos)
205
			{
206 10
				$this->pos = strlen($this->message) + 1;
207
			}
208
209
			// Can't have a one letter smiley, URL, or email! (sorry.)
210 10
			if ($this->last_pos < $this->pos - 1)
211
			{
212 10
				$this->betweenTags();
213
			}
214
215
			// Are we there yet?  Are we there yet?
216 10
			if ($this->pos >= strlen($this->message) - 1)
217
			{
218 10
				return;
219
			}
220
221 8
			$next_char = strtolower($this->message[$this->pos + 1]);
222
223
			// Possibly a closer?
224 8
			if ($next_char === '/')
225
			{
226 8
				if ($this->hasOpenTags())
227
				{
228 6
					$this->handleOpenTags();
229
				}
230
231
				// We don't allow / to be used for anything but the closing character, so this can't be a tag
232 8
				continue;
233
			}
234
235
			// No tags for this character, so just keep going (fastest possible course.)
236 8
			if (!isset($this->bbc_codes[$next_char]))
237
			{
238 2
				continue;
239
			}
240
241 8
			$this->inside_tag = !$this->hasOpenTags() ? null : $this->getLastOpenedTag();
242
243 8
			if ($this->isItemCode($next_char) && isset($this->message[$this->pos + 2]) && $this->message[$this->pos + 2] === ']' && !$this->bbc->isDisabled('list') && !$this->bbc->isDisabled('li'))
244
			{
245
				// Itemcodes cannot be 0 and must be proceeded by a semi-colon, space, tab, new line, or greater than sign
246 2
				if (!($this->message[$this->pos + 1] === '0' && !in_array($this->message[$this->pos - 1], array(';', ' ', "\t", "\n", '>'))))
247
				{
248
					// Item codes are complicated buggers... they are implicit [li]s and can make [list]s!
249 2
					$this->handleItemCode();
250
				}
251
252
				// No matter what, we have to continue here.
253 2
				continue;
254
			}
255
			else
256
			{
257 8
				$tag = $this->findTag($this->bbc_codes[$next_char]);
258
			}
259
260
			// Implicitly close lists and tables if something other than what's required is in them. This is needed for itemcode.
261 8
			if ($tag === null && $this->inside_tag !== null && !empty($this->inside_tag[Codes::ATTR_REQUIRE_CHILDREN]))
262
			{
263
				$this->closeOpenedTag();
264
				$tmp = $this->noSmileys($this->inside_tag[Codes::ATTR_AFTER]);
265
				$this->message = substr_replace($this->message, $tmp, $this->pos, 0);
266
				$this->pos += strlen($tmp) - 1;
267
			}
268
269
			// No tag?  Keep looking, then.  Silly people using brackets without actual tags.
270 8
			if ($tag === null)
271
			{
272 2
				continue;
273
			}
274
275
			// Propagate the list to the child (so wrapping the disallowed tag won't work either.)
276 6
			if (isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]))
277
			{
278
				$tag[Codes::ATTR_DISALLOW_CHILDREN] = isset($tag[Codes::ATTR_DISALLOW_CHILDREN]) ? $tag[Codes::ATTR_DISALLOW_CHILDREN] + $this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN] : $this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN];
279
			}
280
281
			// Is this tag disabled?
282 6
			if ($this->bbc->isDisabled($tag[Codes::ATTR_TAG]))
283
			{
284
				$this->handleDisabled($tag);
285
			}
286
287
			// The only special case is 'html', which doesn't need to close things.
288 6
			if ($tag[Codes::ATTR_BLOCK_LEVEL] && $tag[Codes::ATTR_TAG] !== 'html' && !$this->inside_tag[Codes::ATTR_BLOCK_LEVEL])
289
			{
290 4
				$this->closeNonBlockLevel();
291
			}
292
293
			// This is the part where we actually handle the tags. I know, crazy how long it took.
294 6
			if ($this->handleTag($tag))
295
			{
296
				continue;
297
			}
298
299
			// If this is block level, eat any breaks after it.
300 6
			if ($tag[Codes::ATTR_BLOCK_LEVEL] && isset($this->message[$this->pos + 1]) && substr_compare($this->message, '<br />', $this->pos + 1, 6) === 0)
301
			{
302
				$this->message = substr_replace($this->message, '', $this->pos + 1, 6);
303
			}
304
305
			// Are we trimming outside this tag?
306 6 View Code Duplication
			if (!empty($tag[Codes::ATTR_TRIM]) && $tag[Codes::ATTR_TRIM] !== Codes::TRIM_OUTSIDE)
307
			{
308 4
				$this->trimWhiteSpace($this->pos + 1);
309
			}
310
		}
311
	}
312
313
	/**
314
	 * Process a tag once the closing character / has been found
315
	 */
316 6
	protected function handleOpenTags()
317
	{
318
		// Next closing bracket after the first character
319 6
		$this->pos2 = strpos($this->message, ']', $this->pos + 1);
320
321
		// Playing games? string = [/]
322 6
		if ($this->pos2 === $this->pos + 2)
323
		{
324
			return;
325
		}
326
327
		// Get everything between [/ and ]
328 6
		$look_for = strtolower(substr($this->message, $this->pos + 2, $this->pos2 - $this->pos - 2));
329 6
		$to_close = array();
330 6
		$block_level = null;
331
332
		do
333
		{
334
			// Get the last opened tag
335 6
			$tag = $this->closeOpenedTag();
336
337
			// No open tags
338 6
			if (!$tag)
339
			{
340
				break;
341
			}
342
343 6
			if ($tag[Codes::ATTR_BLOCK_LEVEL])
344
			{
345
				// Only find out if we need to.
346 4
				if ($block_level === false)
347
				{
348
					$this->addOpenTag($tag);
349
					break;
350
				}
351
352
				// The idea is, if we are LOOKING for a block level tag, we can close them on the way.
353 4 View Code Duplication
				if (isset($look_for[1]) && isset($this->bbc_codes[$look_for[0]]))
354
				{
355 4
					foreach ($this->bbc_codes[$look_for[0]] as $temp)
356
					{
357 4
						if ($temp[Codes::ATTR_TAG] === $look_for)
358
						{
359 4
							$block_level = $temp[Codes::ATTR_BLOCK_LEVEL];
360 4
							break;
361
						}
362
					}
363
				}
364
365 4
				if ($block_level !== true)
366
				{
367
					$block_level = false;
368
					$this->addOpenTag($tag);
369
					break;
370
				}
371
			}
372
373 6
			$to_close[] = $tag;
374 6
		} while ($tag[Codes::ATTR_TAG] !== $look_for);
375
376
		// Did we just eat through everything and not find it?
377 6
		if (!$this->hasOpenTags() && (empty($tag) || $tag[Codes::ATTR_TAG] !== $look_for))
378
		{
379
			$this->open_tags = $to_close;
380
			return;
381
		}
382 6
		elseif (!empty($to_close) && $tag[Codes::ATTR_TAG] !== $look_for)
383
		{
384 View Code Duplication
			if ($block_level === null && isset($look_for[0], $this->bbc_codes[$look_for[0]]))
385
			{
386
				foreach ($this->bbc_codes[$look_for[0]] as $temp)
387
				{
388
					if ($temp[Codes::ATTR_TAG] === $look_for)
389
					{
390
						$block_level = !empty($temp[Codes::ATTR_BLOCK_LEVEL]);
391
						break;
392
					}
393
				}
394
			}
395
396
			// We're not looking for a block level tag (or maybe even a tag that exists...)
397
			if (!$block_level)
398
			{
399
				foreach ($to_close as $tag)
400
				{
401
					$this->addOpenTag($tag);
402
				}
403
404
				return;
405
			}
406
		}
407
408 6
		foreach ($to_close as $tag)
409
		{
410 6
			$tmp = $this->noSmileys($tag[Codes::ATTR_AFTER]);
411 6
			$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 1 - $this->pos);
412 6
			$this->pos += strlen($tmp);
413 6
			$this->pos2 = $this->pos - 1;
414
415
			// See the comment at the end of the big loop - just eating whitespace ;).
416 6 View Code Duplication
			if ($tag[Codes::ATTR_BLOCK_LEVEL] && isset($this->message[$this->pos]) && substr_compare($this->message, '<br />', $this->pos, 6) === 0)
417
			{
418
				$this->message = substr_replace($this->message, '', $this->pos, 6);
419
			}
420
421
			// Trim inside whitespace
422 6 View Code Duplication
			if (!empty($tag[Codes::ATTR_TRIM]) && $tag[Codes::ATTR_TRIM] !== Codes::TRIM_INSIDE)
423
			{
424 5
				$this->trimWhiteSpace($this->pos);
425
			}
426
		}
427
428 6
		if (!empty($to_close))
429
		{
430 6
			$this->pos--;
431
		}
432 6
	}
433
434
	/**
435
	 * Turn smiley parsing on/off
436
	 *
437
	 * @param bool $toggle
438
	 * @return BBCParser
439
	 */
440
	public function doSmileys($toggle)
441
	{
442
		$this->do_smileys = (bool) $toggle;
443
444
		return $this;
445
	}
446
447
	/**
448
	 * Check if parsing is enabled
449
	 *
450
	 * @return bool
451
	 */
452 10
	public function parsingEnabled()
453
	{
454 10
		return !empty($GLOBALS['modSettings']['enableBBC']);
455
	}
456
457
	/**
458
	 * Load the HTML parsing engine
459
	 */
460 2
	public function loadHtmlParser()
461
	{
462 2
		$parser = new HtmlParser;
463 2
		call_integration_hook('integrate_bbc_load_html_parser', array(&$parser));
464 2
		$this->html_parser = $parser;
465 2
	}
466
467
	/**
468
	 * Parse the HTML in a string
469
	 *
470
	 * @param string $data
471
	 *
472
	 * @return string
473
	 */
474 2
	protected function parseHTML($data)
475
	{
476 2
		return $this->html_parser->parse($data);
477
	}
478
479
	/**
480
	 * Parse URIs and email addresses in a string to url and email BBC tags to be parsed by the BBC parser
481
	 *
482
	 * @param string $data
483
	 *
484
	 * @return null|string|string[]
485
	 */
486 10
	protected function autoLink($data)
487
	{
488 10
		if ($data === '' || $data === $this->smiley_marker || !$this->autolinker->hasPossible())
489
		{
490 8
			return $data;
491
		}
492
493
		// Are we inside tags that should be auto linked?
494 6
		if ($this->hasOpenTags())
495
		{
496 6
			foreach ($this->getOpenedTags() as $open_tag)
497
			{
498 6
				if (!$open_tag[Codes::ATTR_AUTOLINK])
499
				{
500 6
					return $data;
501
				}
502
			}
503
		}
504
505 6
		return $this->autolinker->parse($data);
506
	}
507
508
	/**
509
	 * Load the autolink regular expression to be used in autoLink()
510
	 */
511 4
	protected function loadAutolink()
512
	{
513 4
		if ($this->autolinker === null)
514
		{
515 2
			$this->autolinker = new Autolink($this->bbc);
516
		}
517 4
	}
518
519
	/**
520
	 * Find if the current character is the start of a tag and get it
521
	 *
522
	 * @param array $possible_codes
523
	 *
524
	 * @return null|array the tag that was found or null if no tag found
525
	 */
526 8
	protected function findTag(array $possible_codes)
527
	{
528 8
		$tag = null;
529 8
		$last_check = null;
530
531 8
		foreach ($possible_codes as $possible)
532
		{
533
			// Skip tags that didn't match the next X characters
534 8
			if ($possible[Codes::ATTR_TAG] === $last_check)
535
			{
536 4
				continue;
537
			}
538
539
			// The character after the possible tag or nothing
540 8
			$next_c = isset($this->message[$this->pos + 1 + $possible[Codes::ATTR_LENGTH]]) ? $this->message[$this->pos + 1 + $possible[Codes::ATTR_LENGTH]] : '';
541
542
			// This only happens if the tag is the last character of the string
543 8
			if ($next_c === '')
544
			{
545
				break;
546
			}
547
548
			// The next character must be one of these or it's not a tag
549 8
			if ($next_c !== ' ' && $next_c !== ']' && $next_c !== '=' && $next_c !== '/')
550
			{
551 8
				$last_check = $possible[Codes::ATTR_TAG];
552 8
				continue;
553
			}
554
555
			// Not a match?
556 8
			if (substr_compare($this->message, $possible[Codes::ATTR_TAG], $this->pos + 1, $possible[Codes::ATTR_LENGTH], true) !== 0)
557
			{
558 4
				$last_check = $possible[Codes::ATTR_TAG];
559 4
				continue;
560
			}
561
562 8
			$tag = $this->checkCodeAttributes($next_c, $possible);
563 8
			if ($tag === null)
564
			{
565 8
				continue;
566
			}
567
568
			// Quotes can have alternate styling, we do this php-side due to all the permutations of quotes.
569 6
			if ($tag[Codes::ATTR_TAG] === 'quote')
570
			{
571 2
				$this->alternateQuoteStyle($tag);
572
			}
573
574 6
			break;
575
		}
576
577
		// If there is a code that says you can't cache, the message can't be cached
578 8
		if ($tag !== null && $this->can_cache !== false)
579
		{
580 6
			$this->can_cache = empty($tag[Codes::ATTR_NO_CACHE]);
581
		}
582
583
		// If its a footnote, keep track of the number
584 8
		if ($tag[Codes::ATTR_TAG] === 'footnote')
585
		{
586 2
			$this->num_footnotes++;
587
		}
588
589 8
		return $tag;
590
	}
591
592
	/**
593
	 * Just alternates the applied class for quotes for themes that want to distinguish them
594
	 *
595
	 * @param array $tag
596
	 */
597 2
	protected function alternateQuoteStyle(array &$tag)
598
	{
599
		// Start with standard
600 2
		$quote_alt = false;
601 2
		foreach ($this->open_tags as $open_quote)
602
		{
603
			// Every parent quote this quote has flips the styling
604 2
			if ($open_quote[Codes::ATTR_TAG] === 'quote')
605
			{
606 2
				$quote_alt = !$quote_alt;
607
			}
608
		}
609
		// Add a class to the quote and quoteheader to style alternating blockquotes
610
		//  - Example: class="quoteheader" and class="quoteheader bbc_alt_quoteheader" on the header
611
		//             class="bbc_quote" and class="bbc_quote bbc_alternate_quote" on the blockquote
612
		// This allows simpler CSS for themes (like default) which do not use the alternate styling,
613
		// but still allow it for themes that want it.
614 2
		$tag[Codes::ATTR_BEFORE] = str_replace('<div class="quoteheader">', '<div class="quoteheader' . ($quote_alt ? ' bbc_alt_quoteheader' : '') . '">', $tag[Codes::ATTR_BEFORE]);
615 2
		$tag[Codes::ATTR_BEFORE] = str_replace('<blockquote>', '<blockquote class="bbc_quote' . ($quote_alt ? ' bbc_alternate_quote' : '') . '">', $tag[Codes::ATTR_BEFORE]);
616 2
	}
617
618
	/**
619
	 * Parses BBC codes attributes for codes that may have them
620
	 *
621
	 * @param string $next_c
622
	 * @param array $possible
623
	 * @return array|null
624
	 */
625 8
	protected function checkCodeAttributes($next_c, array $possible)
626
	{
627
		// Do we want parameters?
628 8
		if (!empty($possible[Codes::ATTR_PARAM]))
629
		{
630 4
			if ($next_c !== ' ')
631
			{
632 4
				return null;
633
			}
634
		}
635
		// parsed_content demands an immediate ] without parameters!
636 8
		elseif ($possible[Codes::ATTR_TYPE] === Codes::TYPE_PARSED_CONTENT)
637
		{
638 6
			if ($next_c !== ']')
639
			{
640 6
				return null;
641
			}
642
		}
643
		else
644
		{
645
			// Do we need an equal sign?
646 8
			if ($next_c !== '=' && in_array($possible[Codes::ATTR_TYPE], array(Codes::TYPE_UNPARSED_EQUALS, Codes::TYPE_UNPARSED_COMMAS, Codes::TYPE_UNPARSED_COMMAS_CONTENT, Codes::TYPE_UNPARSED_EQUALS_CONTENT, Codes::TYPE_PARSED_EQUALS)))
647
			{
648 4
				return null;
649
			}
650
651 8
			if ($next_c !== ']')
652
			{
653
				// An immediate ]?
654 8
				if ($possible[Codes::ATTR_TYPE] === Codes::TYPE_UNPARSED_CONTENT)
655
				{
656 6
					return null;
657
				}
658
				// Maybe we just want a /...
659 8
				elseif ($possible[Codes::ATTR_TYPE] === Codes::TYPE_CLOSED && substr_compare($this->message, '/]', $this->pos + 1 + $possible[Codes::ATTR_LENGTH], 2) !== 0 && substr_compare($this->message, ' /]', $this->pos + 1 + $possible[Codes::ATTR_LENGTH], 3) !== 0)
660
				{
661
					return null;
662
				}
663
			}
664
		}
665
666
		// Check allowed tree?
667 8 View Code Duplication
		if (isset($possible[Codes::ATTR_REQUIRE_PARENTS]) && ($this->inside_tag === null || !isset($possible[Codes::ATTR_REQUIRE_PARENTS][$this->inside_tag[Codes::ATTR_TAG]])))
668
		{
669
			return null;
670
		}
671
672 8
		if ($this->inside_tag !== null)
673
		{
674 4 View Code Duplication
			if (isset($this->inside_tag[Codes::ATTR_REQUIRE_CHILDREN]) && !isset($this->inside_tag[Codes::ATTR_REQUIRE_CHILDREN][$possible[Codes::ATTR_TAG]]))
675
			{
676
				return null;
677
			}
678
679
			// If this is in the list of disallowed child tags, don't parse it.
680 4 View Code Duplication
			if (isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]) && isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN][$possible[Codes::ATTR_TAG]]))
681
			{
682
				return null;
683
			}
684
685
			// Not allowed in this parent, replace the tags or show it like regular text
686 4
			if (isset($possible[Codes::ATTR_DISALLOW_PARENTS]) && isset($possible[Codes::ATTR_DISALLOW_PARENTS][$this->inside_tag[Codes::ATTR_TAG]]))
687
			{
688
				if (!isset($possible[Codes::ATTR_DISALLOW_BEFORE], $possible[Codes::ATTR_DISALLOW_AFTER]))
689
				{
690
					return null;
691
				}
692
693
				$possible[Codes::ATTR_BEFORE] = isset($possible[Codes::ATTR_DISALLOW_BEFORE]) ? $possible[Codes::ATTR_DISALLOW_BEFORE] : $possible[Codes::ATTR_BEFORE];
694
				$possible[Codes::ATTR_AFTER] = isset($possible[Codes::ATTR_DISALLOW_AFTER]) ? $possible[Codes::ATTR_DISALLOW_AFTER] : $possible[Codes::ATTR_AFTER];
695
			}
696
		}
697
698 8
		if (isset($possible[Codes::ATTR_TEST]) && $this->handleTest($possible))
699
		{
700 4
			return null;
701
		}
702
703
		// +1 for [, then the length of the tag, then a space
704 8
		$this->pos1 = $this->pos + 1 + $possible[Codes::ATTR_LENGTH] + 1;
705
706
		// This is long, but it makes things much easier and cleaner.
707 8
		if (!empty($possible[Codes::ATTR_PARAM]))
708
		{
709 4
			$match = $this->matchParameters($possible, $matches);
710
711
			// Didn't match our parameter list, try the next possible.
712 4
			if (!$match)
713
			{
714 4
				return null;
715
			}
716
717 2
			return $this->setupTagParameters($possible, $matches);
718
		}
719
720 6
		return $possible;
721
	}
722
723
	/**
724
	 * Called when a code has defined a test parameter
725
	 *
726
	 * @param array $possible
727
	 *
728
	 * @return bool
729
	 */
730 6
	protected function handleTest(array $possible)
731
	{
732 6
		return preg_match('~^' . $possible[Codes::ATTR_TEST] . '\]$~', substr($this->message, $this->pos + 2 + $possible[Codes::ATTR_LENGTH], strpos($this->message, ']', $this->pos) - ($this->pos + 1 + $possible[Codes::ATTR_LENGTH]))) === 0;
733
	}
734
735
	/**
736
	 * Handles item codes by converting them to lists
737
	 */
738 2
	protected function handleItemCode()
739
	{
740 2
		if (!isset($this->item_codes[$this->message[$this->pos + 1]]))
741
			return;
742
743 2
		$tag = $this->item_codes[$this->message[$this->pos + 1]];
744
745
		// First let's set up the tree: it needs to be in a list, or after an li.
746 2
		if ($this->inside_tag === null || ($this->inside_tag[Codes::ATTR_TAG] !== 'list' && $this->inside_tag[Codes::ATTR_TAG] !== 'li'))
747
		{
748 2
			$this->addOpenTag(array(
749 2
				Codes::ATTR_TAG => 'list',
750
				Codes::ATTR_TYPE => Codes::TYPE_PARSED_CONTENT,
751 2
				Codes::ATTR_AFTER => '</ul>',
752
				Codes::ATTR_BLOCK_LEVEL => true,
753
				Codes::ATTR_REQUIRE_CHILDREN => array('li' => 'li'),
754 2
				Codes::ATTR_DISALLOW_CHILDREN => isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]) ? $this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN] : null,
755 2
				Codes::ATTR_LENGTH => 4,
756
				Codes::ATTR_AUTOLINK => true,
757
			));
758 2
			$code = '<ul' . ($tag === '' ? '' : ' style="list-style-type: ' . $tag . '"') . ' class="bbc_list">';
759
		}
760
		// We're in a list item already: another itemcode?  Close it first.
761 2
		elseif ($this->inside_tag[Codes::ATTR_TAG] === 'li')
762
		{
763 2
			$this->closeOpenedTag();
764 2
			$code = '</li>';
765
		}
766
		else
767
		{
768
			$code = '';
769
		}
770
771
		// Now we open a new tag.
772 2
		$this->addOpenTag(array(
773 2
			Codes::ATTR_TAG => 'li',
774
			Codes::ATTR_TYPE => Codes::TYPE_PARSED_CONTENT,
775 2
			Codes::ATTR_AFTER => '</li>',
776
			Codes::ATTR_TRIM => Codes::TRIM_OUTSIDE,
777
			Codes::ATTR_BLOCK_LEVEL => true,
778 2
			Codes::ATTR_DISALLOW_CHILDREN => isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]) ? $this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN] : null,
779
			Codes::ATTR_AUTOLINK => true,
780 2
			Codes::ATTR_LENGTH => 2,
781
		));
782
783
		// First, open the tag...
784 2
		$code .= '<li>';
785
786 2
		$tmp = $this->noSmileys($code);
787 2
		$this->message = substr_replace($this->message, $tmp, $this->pos, 3);
788 2
		$this->pos += strlen($tmp) - 1;
789
790
		// Next, find the next break (if any.)  If there's more itemcode after it, keep it going - otherwise close!
791 2
		$this->pos2 = strpos($this->message, '<br />', $this->pos);
792 2
		$this->pos3 = strpos($this->message, '[/', $this->pos);
793
794 2
		$num_open_tags = count($this->open_tags);
795 2
		if ($this->pos2 !== false && ($this->pos3 === false || $this->pos2 <= $this->pos3))
796
		{
797
			// Can't use offset because of the ^
798
			preg_match('~^(<br />|&nbsp;|\s|\[)+~', substr($this->message, $this->pos2 + 6), $matches);
799
			//preg_match('~(<br />|&nbsp;|\s|\[)+~', $this->message, $matches, 0, $this->pos2 + 6);
800
801
			// Keep the list open if the next character after the break is a [. Otherwise, close it.
802
			$replacement = !empty($matches[0]) && substr_compare($matches[0], '[', -1, 1) === 0 ? '[/li]' : '[/li][/list]';
803
804
			$this->message = substr_replace($this->message, $replacement, $this->pos2, 0);
805
			$this->open_tags[$num_open_tags - 2][Codes::ATTR_AFTER] = '</ul>';
806
		}
807
		// Tell the [list] that it needs to close specially.
808
		else
809
		{
810
			// Move the li over, because we're not sure what we'll hit.
811 2
			$this->open_tags[$num_open_tags - 1][Codes::ATTR_AFTER] = '';
812 2
			$this->open_tags[$num_open_tags - 2][Codes::ATTR_AFTER] = '</li></ul>';
813
		}
814 2
	}
815
816
	/**
817
	 * Handle codes that are of the parsed context type
818
	 *
819
	 * @param array $tag
820
	 *
821
	 * @return bool
822
	 */
823 4
	protected function handleTypeParsedContext(array $tag)
824
	{
825
		// @todo Check for end tag first, so people can say "I like that [i] tag"?
826 4
		$this->addOpenTag($tag);
827 4
		$tmp = $this->noSmileys($tag[Codes::ATTR_BEFORE]);
828 4
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos1 - $this->pos);
829 4
		$this->pos += strlen($tmp) - 1;
830
831 4
		return false;
832
	}
833
834
	/**
835
	 * Handle codes that are of the unparsed context type
836
	 *
837
	 * @param array $tag
838
	 *
839
	 * @return bool
840
	 */
841 4
	protected function handleTypeUnparsedContext(array $tag)
842
	{
843
		// Find the next closer
844 4
		$this->pos2 = stripos($this->message, '[/' . $tag[Codes::ATTR_TAG] . ']', $this->pos1);
845
846
		// No closer
847 4
		if ($this->pos2 === false)
848
		{
849
			return true;
850
		}
851
852 4
		$data = substr($this->message, $this->pos1, $this->pos2 - $this->pos1);
853
854 4
		if (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) && isset($data[0]) && substr_compare($data, '<br />', 0, 6) === 0)
855
		{
856
			$data = substr($data, 6);
857
		}
858
859 4
		if (isset($tag[Codes::ATTR_VALIDATE]))
860
		{
861 4
			$this->filterData($tag, $data);
862
		}
863
864 4
		$code = strtr($tag[Codes::ATTR_CONTENT], array('$1' => $data));
865 4
		$tmp = $this->noSmileys($code);
866 4
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 3 + $tag[Codes::ATTR_LENGTH] - $this->pos);
867 4
		$this->pos += strlen($tmp) - 1;
868 4
		$this->last_pos = $this->pos + 1;
869
870 4
		return false;
871
	}
872
873
	/**
874
	 * Handle codes that are of the unparsed equals context type
875
	 *
876
	 * @param array $tag
877
	 *
878
	 * @return bool
879
	 */
880 2
	protected function handleUnparsedEqualsContext(array $tag)
881
	{
882
		// The value may be quoted for some tags - check.
883 2 View Code Duplication
		if (isset($tag[Codes::ATTR_QUOTED]))
884
		{
885
			$quoted = substr_compare($this->message, '&quot;', $this->pos1, 6) === 0;
886
			if ($tag[Codes::ATTR_QUOTED] !== Codes::OPTIONAL && !$quoted)
887
			{
888
				return true;
889
			}
890
891
			if ($quoted)
892
			{
893
				$this->pos1 += 6;
894
			}
895
		}
896
		else
897
		{
898 2
			$quoted = false;
899
		}
900
901 2
		$this->pos2 = strpos($this->message, $quoted === false ? ']' : '&quot;]', $this->pos1);
902 2
		if ($this->pos2 === false)
903
		{
904
			return true;
905
		}
906
907 2
		$this->pos3 = stripos($this->message, '[/' . $tag[Codes::ATTR_TAG] . ']', $this->pos2);
908 2
		if ($this->pos3 === false)
909
		{
910
			return true;
911
		}
912
913
		$data = array(
914 2
			substr($this->message, $this->pos2 + ($quoted === false ? 1 : 7), $this->pos3 - ($this->pos2 + ($quoted === false ? 1 : 7))),
915 2
			substr($this->message, $this->pos1, $this->pos2 - $this->pos1)
916
		);
917
918 2
		if (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) && substr_compare($data[0], '<br />', 0, 6) === 0)
919
		{
920
			$data[0] = substr($data[0], 6);
921
		}
922
923
		// Validation for my parking, please!
924 2
		if (isset($tag[Codes::ATTR_VALIDATE]))
925
		{
926 2
			$this->filterData($tag, $data);
927
		}
928
929 2
		$code = strtr($tag[Codes::ATTR_CONTENT], array('$1' => $data[0], '$2' => $data[1]));
930 2
		$tmp = $this->noSmileys($code);
931 2
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos3 + 3 + $tag[Codes::ATTR_LENGTH] - $this->pos);
932 2
		$this->pos += strlen($tmp) - 1;
933
934 2
		return false;
935
	}
936
937
	/**
938
	 * Handle codes that are of the closed type
939
	 *
940
	 * @param array $tag
941
	 *
942
	 * @return bool
943
	 */
944 2
	protected function handleTypeClosed(array $tag)
945
	{
946 2
		$this->pos2 = strpos($this->message, ']', $this->pos);
947 2
		$tmp = $this->noSmileys($tag[Codes::ATTR_CONTENT]);
948 2
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 1 - $this->pos);
949 2
		$this->pos += strlen($tmp) - 1;
950
951 2
		return false;
952
	}
953
954
	/**
955
	 * Handle codes that are of the unparsed commas context type
956
	 *
957
	 * @param array $tag
958
	 *
959
	 * @return bool
960
	 */
961
	protected function handleUnparsedCommasContext(array $tag)
962
	{
963
		$this->pos2 = strpos($this->message, ']', $this->pos1);
964
		if ($this->pos2 === false)
965
		{
966
			return true;
967
		}
968
969
		$this->pos3 = stripos($this->message, '[/' . $tag[Codes::ATTR_TAG] . ']', $this->pos2);
970
		if ($this->pos3 === false)
971
		{
972
			return true;
973
		}
974
975
		// We want $1 to be the content, and the rest to be csv.
976
		$data = explode(',', ',' . substr($this->message, $this->pos1, $this->pos2 - $this->pos1));
977
		$data[0] = substr($this->message, $this->pos2 + 1, $this->pos3 - $this->pos2 - 1);
978
979
		if (isset($tag[Codes::ATTR_VALIDATE]))
980
		{
981
			$this->filterData($tag, $data);
982
		}
983
984
		$code = $tag[Codes::ATTR_CONTENT];
985 View Code Duplication
		foreach ($data as $k => $d)
986
		{
987
			$code = strtr($code, array('$' . ($k + 1) => trim($d)));
988
		}
989
990
		$tmp = $this->noSmileys($code);
991
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos3 + 3 + $tag[Codes::ATTR_LENGTH] - $this->pos);
992
		$this->pos += strlen($tmp) - 1;
993
994
		return false;
995
	}
996
997
	/**
998
	 * Handle codes that are of the unparsed commas type
999
	 *
1000
	 * @param array $tag
1001
	 *
1002
	 * @return bool
1003
	 */
1004
	protected function handleUnparsedCommas(array $tag)
1005
	{
1006
		$this->pos2 = strpos($this->message, ']', $this->pos1);
1007
		if ($this->pos2 === false)
1008
		{
1009
			return true;
1010
		}
1011
1012
		$data = explode(',', substr($this->message, $this->pos1, $this->pos2 - $this->pos1));
1013
1014
		if (isset($tag[Codes::ATTR_VALIDATE]))
1015
		{
1016
			$this->filterData($tag, $data);
1017
		}
1018
1019
		// Fix after, for disabled code mainly.
1020
		foreach ($data as $k => $d)
1021
		{
1022
			$tag[Codes::ATTR_AFTER] = strtr($tag[Codes::ATTR_AFTER], array('$' . ($k + 1) => trim($d)));
1023
		}
1024
1025
		$this->addOpenTag($tag);
1026
1027
		// Replace them out, $1, $2, $3, $4, etc.
1028
		$code = $tag[Codes::ATTR_BEFORE];
1029 View Code Duplication
		foreach ($data as $k => $d)
1030
		{
1031
			$code = strtr($code, array('$' . ($k + 1) => trim($d)));
1032
		}
1033
1034
		$tmp = $this->noSmileys($code);
1035
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 1 - $this->pos);
1036
		$this->pos += strlen($tmp) - 1;
1037
1038
		return false;
1039
	}
1040
1041
	/**
1042
	 * Handle codes that are of the equals type
1043
	 *
1044
	 * @param array $tag
1045
	 *
1046
	 * @return bool
1047
	 */
1048 6
	protected function handleEquals(array $tag)
1049
	{
1050
		// The value may be quoted for some tags - check.
1051 6 View Code Duplication
		if (isset($tag[Codes::ATTR_QUOTED]))
1052
		{
1053 4
			$quoted = substr_compare($this->message, '&quot;', $this->pos1, 6) === 0;
1054 4
			if ($tag[Codes::ATTR_QUOTED] !== Codes::OPTIONAL && !$quoted)
1055
			{
1056
				return true;
1057
			}
1058
1059 4
			if ($quoted)
1060
			{
1061 4
				$this->pos1 += 6;
1062
			}
1063
		}
1064
		else
1065
		{
1066 6
			$quoted = false;
1067
		}
1068
1069 6
		$this->pos2 = strpos($this->message, $quoted === false ? ']' : '&quot;]', $this->pos1);
1070 6
		if ($this->pos2 === false)
1071
		{
1072
			return true;
1073
		}
1074
1075 6
		$data = substr($this->message, $this->pos1, $this->pos2 - $this->pos1);
1076
1077
		// Validation for my parking, please!
1078 6
		if (isset($tag[Codes::ATTR_VALIDATE]))
1079
		{
1080 6
			$this->filterData($tag, $data);
1081
		}
1082
1083
		// For parsed content, we must recurse to avoid security problems.
1084 6
		if ($tag[Codes::ATTR_TYPE] === Codes::TYPE_PARSED_EQUALS)
1085
		{
1086 2
			$data = $this->recursiveParser($data, $tag);
1087
		}
1088
1089 6
		$tag[Codes::ATTR_AFTER] = strtr($tag[Codes::ATTR_AFTER], array('$1' => $data));
1090
1091 6
		$this->addOpenTag($tag);
1092
1093 6
		$code = strtr($tag[Codes::ATTR_BEFORE], array('$1' => $data));
1094 6
		$tmp = $this->noSmileys($code);
1095 6
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + ($quoted === false ? 1 : 7) - $this->pos);
1096 6
		$this->pos += strlen($tmp) - 1;
1097
1098 6
		return false;
1099
	}
1100
1101
	/**
1102
	 * Handles a tag by its type. Offloads the actual handling to handle*() method
1103
	 *
1104
	 * @param array $tag
1105
	 *
1106
	 * @return bool true if there was something wrong and the parser should advance
1107
	 */
1108 6
	protected function handleTag(array $tag)
1109
	{
1110 6
		switch ($tag[Codes::ATTR_TYPE])
1111
		{
1112 3
			case Codes::TYPE_PARSED_CONTENT:
1113 4
				return $this->handleTypeParsedContext($tag);
1114
1115
			// Don't parse the content, just skip it.
1116 3
			case Codes::TYPE_UNPARSED_CONTENT:
1117 4
				return $this->handleTypeUnparsedContext($tag);
1118
1119
			// Don't parse the content, just skip it.
1120 3
			case Codes::TYPE_UNPARSED_EQUALS_CONTENT:
1121 2
				return $this->handleUnparsedEqualsContext($tag);
1122
1123
			// A closed tag, with no content or value.
1124 3
			case Codes::TYPE_CLOSED:
1125 2
				return $this->handleTypeClosed($tag);
1126
1127
			// This one is sorta ugly... :/
1128 3
			case Codes::TYPE_UNPARSED_COMMAS_CONTENT:
1129
				return $this->handleUnparsedCommasContext($tag);
1130
1131
			// This has parsed content, and a csv value which is unparsed.
1132 3
			case Codes::TYPE_UNPARSED_COMMAS:
1133
				return $this->handleUnparsedCommas($tag);
1134
1135
			// A tag set to a value, parsed or not.
1136 3
			case Codes::TYPE_PARSED_EQUALS:
1137 3
			case Codes::TYPE_UNPARSED_EQUALS:
1138 6
				return $this->handleEquals($tag);
1139
		}
1140
1141
		return false;
1142
	}
1143
1144
	/**
1145
	 * Text between tags
1146
	 *
1147
	 * @todo I don't know what else to call this. It's the area that isn't a tag.
1148
	 */
1149 10
	protected function betweenTags()
1150
	{
1151
		// Make sure the $this->last_pos is not negative.
1152 10
		$this->last_pos = max($this->last_pos, 0);
1153
1154
		// Pick a block of data to do some raw fixing on.
1155 10
		$data = substr($this->message, $this->last_pos, $this->pos - $this->last_pos);
1156
1157
		// This happens when the pos is > last_pos and there is a trailing \n from one of the tags having "AFTER"
1158
		// In micro-optimization tests, using substr() here doesn't prove to be slower. This is much easier to read so leave it.
1159 10
		if ($data === $this->smiley_marker)
1160
		{
1161 4
			return;
1162
		}
1163
1164
		// Take care of some HTML!
1165 10
		if ($this->possible_html && strpos($data, '&lt;') !== false)
1166
		{
1167
			// @todo new \Parser\BBC\HTML;
1168 2
			$data = $this->parseHTML($data);
1169
		}
1170
1171 10
		if (!empty($GLOBALS['modSettings']['autoLinkUrls']))
1172
		{
1173 10
			$data = $this->autoLink($data);
1174
		}
1175
1176
		// This cannot be moved earlier. It breaks tests
1177 10
		$data = str_replace("\t", '&nbsp;&nbsp;&nbsp;', $data);
1178
1179
		// If it wasn't changed, no copying or other boring stuff has to happen!
1180 10
		if (substr_compare($this->message, $data, $this->last_pos, $this->pos - $this->last_pos))
1181
		{
1182 2
			$this->message = substr_replace($this->message, $data, $this->last_pos, $this->pos - $this->last_pos);
1183
1184
			// Since we changed it, look again in case we added or removed a tag.  But we don't want to skip any.
1185 2
			$old_pos = strlen($data) + $this->last_pos;
1186 2
			$this->pos = strpos($this->message, '[', $this->last_pos);
1187 2
			$this->pos = $this->pos === false ? $old_pos : min($this->pos, $old_pos);
1188
		}
1189 10
	}
1190
1191
	/**
1192
	 * Handles special [footnote] tag processing as the tag is not rendered "inline"
1193
	 */
1194 2
	protected function handleFootnotes()
1195
	{
1196 2
		static $fn_total = 0;
1197
1198
		// @todo temporary until we have nesting
1199 2
		$this->message = str_replace(array('[footnote]', '[/footnote]'), '', $this->message);
1200
1201 2
		$this->fn_num = 0;
1202 2
		$this->fn_content = array();
1203 2
		$this->fn_count = $fn_total;
1204
1205
		// Replace our footnote text with a [1] link, save the text for use at the end of the message
1206 2
		$this->message = preg_replace_callback('~(%fn%(.*?)%fn%)~is', array($this, 'footnoteCallback'), $this->message);
1207 2
		$fn_total += $this->fn_num;
1208
1209
		// If we have footnotes, add them in at the end of the message
1210 2
		if (!empty($this->fn_num))
1211
		{
1212 2
			$this->message .= $this->smiley_marker . '<div class="bbc_footnotes">' . implode('', $this->fn_content) . '</div>' . $this->smiley_marker;
1213
		}
1214 2
	}
1215
1216
	/**
1217
	 * Final footnote conversions, builds the proper link code to footnote at base of post
1218
	 *
1219
	 * @param array $matches
1220
	 *
1221
	 * @return string
1222
	 */
1223 2
	protected function footnoteCallback(array $matches)
1224
	{
1225 2
		$this->fn_num++;
1226 2
		$this->fn_content[] = '<div class="target" id="fn' . $this->fn_num . '_' . $this->fn_count . '"><sup>' . $this->fn_num . '&nbsp;</sup>' . $matches[2] . '<a class="footnote_return" href="#ref' . $this->fn_num . '_' . $this->fn_count . '">&crarr;</a></div>';
1227
1228 2
		return '<a class="target" href="#fn' . $this->fn_num . '_' . $this->fn_count . '" id="ref' . $this->fn_num . '_' . $this->fn_count . '">[' . $this->fn_num . ']</a>';
1229
	}
1230
1231
	/**
1232
	 * Parse a tag that is disabled
1233
	 *
1234
	 * @param array $tag
1235
	 */
1236
	protected function handleDisabled(array &$tag)
1237
	{
1238
		if (!isset($tag[Codes::ATTR_DISABLED_BEFORE]) && !isset($tag[Codes::ATTR_DISABLED_AFTER]) && !isset($tag[Codes::ATTR_DISABLED_CONTENT]))
1239
		{
1240
			$tag[Codes::ATTR_BEFORE] = !empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '<div>' : '';
1241
			$tag[Codes::ATTR_AFTER] = !empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '</div>' : '';
1242
			$tag[Codes::ATTR_CONTENT] = $tag[Codes::ATTR_TYPE] === Codes::TYPE_CLOSED ? '' : (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '<div>$1</div>' : '$1');
1243
		}
1244
		elseif (isset($tag[Codes::ATTR_DISABLED_BEFORE]) || isset($tag[Codes::ATTR_DISABLED_AFTER]))
1245
		{
1246
			$tag[Codes::ATTR_BEFORE] = isset($tag[Codes::ATTR_DISABLED_BEFORE]) ? $tag[Codes::ATTR_DISABLED_BEFORE] : (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '<div>' : '');
1247
			$tag[Codes::ATTR_AFTER] = isset($tag[Codes::ATTR_DISABLED_AFTER]) ? $tag[Codes::ATTR_DISABLED_AFTER] : (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '</div>' : '');
1248
		}
1249
		else
1250
		{
1251
			$tag[Codes::ATTR_CONTENT] = $tag[Codes::ATTR_DISABLED_CONTENT];
1252
		}
1253
	}
1254
1255
	/**
1256
	 * Map required / optional tag parameters to the found tag
1257
	 *
1258
	 * @param array &$possible
1259
	 * @param array &$matches
1260
	 * @return bool
1261
	 */
1262 4
	protected function matchParameters(array &$possible, &$matches)
1263
	{
1264 4
		if (!isset($possible['regex_cache']))
1265
		{
1266 4
			$possible['regex_cache'] = array();
1267 4
			$possible['param_check'] = array();
1268 4
			$possible['optionals'] = array();
1269
1270 4
			foreach ($possible[Codes::ATTR_PARAM] as $param => $info)
0 ignored issues
show
Bug introduced by
The expression $possible[\BBC\Codes::ATTR_PARAM] of type null|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
1271
			{
1272 4
				$quote = empty($info[Codes::PARAM_ATTR_QUOTED]) ? '' : '&quot;';
1273 4
				$possible['optionals'][] = !empty($info[Codes::PARAM_ATTR_OPTIONAL]);
1274 4
				$possible['param_check'][] = ' ' . $param . '=' . $quote;
1275 4
				$possible['regex_cache'][] = '(\s+' . $param . '=' . $quote . (isset($info[Codes::PARAM_ATTR_MATCH]) ? $info[Codes::PARAM_ATTR_MATCH] : '(.+?)') . $quote . ')';
1276
			}
1277
1278 4
			$possible['regex_size'] = count($possible[Codes::ATTR_PARAM]) - 1;
1279
		}
1280
1281
		// Tag setup for this loop
1282 4
		$this->tag_possible = $possible;
1283
1284
		// Okay, this may look ugly and it is, but it's not going to happen much and it is the best way
1285
		// of allowing any order of parameters but still parsing them right.
1286 4
		$message_stub = $this->messageStub();
1287
1288
		// Set regex optional flags only if the param *is* optional and it *was not used* in this tag
1289 4
		$this->optionalParam($message_stub);
1290
1291
		// If an addon adds many parameters we can exceed max_execution time, lets prevent that
1292
		// 5040 = 7, 40,320 = 8, (N!) etc
1293 4
		$max_iterations = self::MAX_PERMUTE_ITERATIONS;
1294
1295
		// Use the same range to start each time. Most BBC is in the order that it should be in when it starts.
1296 4
		$keys = $this->setKeys();
1297
1298
		// Step, one by one, through all possible permutations of the parameters until we have a match
1299
		do
1300
		{
1301 4
			$match_preg = '~^';
1302 4
			foreach ($keys as $key)
1303
			{
1304 4
				$match_preg .= $this->tag_possible['regex_cache'][$key];
1305
			}
1306 4
			$match_preg .= '\]~i';
1307
1308
			// Check if this combination of parameters matches the user input
1309 4
			$match = preg_match($match_preg, $message_stub, $matches) !== 0;
1310 4
		} while (!$match && --$max_iterations && ($keys = pc_next_permutation($keys, $possible['regex_size'])));
1311
1312 4
		return $match;
1313
	}
1314
1315
	/**
1316
	 * Sorts the params so they are in a required to optional order.
1317
	 *
1318
	 * Supports the assumption that the params as defined in CODES is the preferred / common
1319
	 * order they are found in, and inserted by, the editor toolbar.
1320
	 *
1321
	 * @return array
1322
	 */
1323 4
	private function setKeys()
1324
	{
1325 4
		$control_order = array();
1326 4
		$this->tag_possible['regex_keys'] = range(0, $this->tag_possible['regex_size']);
1327
1328
		// Push optional params to the end of the stack but maintain current order of required ones
1329 4
		foreach ($this->tag_possible['regex_keys'] as $index => $info)
1330
		{
1331 4
			$control_order[$index] = $index;
1332
1333 4
			if ($this->tag_possible['optionals'][$index])
1334 3
				$control_order[$index] = $index + $this->tag_possible['regex_size'];
1335
		}
1336
1337 4
		array_multisort($control_order, SORT_ASC, $this->tag_possible['regex_cache']);
1338
1339 4
		return $this->tag_possible['regex_keys'];
1340
	}
1341
1342
	/**
1343
	 * Sets the optional parameter search flag only where needed.
1344
	 *
1345
	 * What it does:
1346
	 *
1347
	 * - Sets the optional ()? flag only for optional params that were not actually used
1348
	 * - This makes the permutation function match all required *and* passed parameters
1349
	 * - Returns false if an non optional tag was not found
1350
	 *
1351
	 * @param string $message_stub
1352
	 */
1353 4
	protected function optionalParam($message_stub)
1354
	{
1355
		// Set optional flag only if the param is optional and it was not used in this tag
1356 4
		foreach ($this->tag_possible['optionals'] as $index => $optional)
1357
		{
1358
			// @todo more robust, and slower, check would be a preg_match on $possible['regex_cache'][$index]
1359 4
			$param_exists = stripos($message_stub, $this->tag_possible['param_check'][$index]) !== false;
1360
1361
			// Only make unused optional tags as optional
1362 4
			if ($optional)
1363
			{
1364 2
				if ($param_exists)
1365 2
					$this->tag_possible['optionals'][$index] = false;
1366
				else
1367 3
					$this->tag_possible['regex_cache'][$index] .= '?';
1368
			}
1369
		}
1370 4
	}
1371
1372
	/**
1373
	 * Given the position in a message, extracts the tag for analysis
1374
	 *
1375
	 * What it does:
1376
	 *
1377
	 * - Given ' width=100 height=100 alt=image]....[/img]more text and [tags]...'
1378
	 * - Returns ' width=100 height=100 alt=image]....[/img]'
1379
	 *
1380
	 * @return string
1381
	 */
1382 4
	protected function messageStub()
1383
	{
1384
		// For parameter searching, swap in \n's to reduce any regex greediness
1385 4
		$message_stub = str_replace('<br />', "\n", substr($this->message, $this->pos1 - 1)) . "\n";
1386
1387
		// Attempt to pull out just this tag
1388 4
		if (preg_match('~^(?:.+?)\](?>.|(?R))*?\[\/' . $this->tag_possible[Codes::ATTR_TAG] . '\](?:.|\s)~i', $message_stub, $matches) === 1)
1389
		{
1390 4
			$message_stub = $matches[0];
1391
		}
1392
1393 4
		return $message_stub;
1394
	}
1395
1396
	/**
1397
	 * Recursively call the parser with a new Codes object
1398
	 * This allows to parse BBC in parameters like [quote author="[url]www.quotes.com[/url]"]Something famous.[/quote]
1399
	 *
1400
	 * @param string $data
1401
	 * @param array $tag
1402
	 *
1403
	 * @return string
1404
	 */
1405 2
	protected function recursiveParser($data, array $tag)
1406
	{
1407
		// @todo if parsed tags allowed is empty, return?
1408 2
		$bbc = clone $this->bbc;
1409
1410 2
		if (!empty($tag[Codes::ATTR_PARSED_TAGS_ALLOWED]))
1411
		{
1412 2
			$bbc->setParsedTags($tag[Codes::ATTR_PARSED_TAGS_ALLOWED]);
1413
		}
1414
1415
		// Do not use $this->autolinker. For some reason it causes a recursive loop
1416 2
		$autolinker = null;
1417 2
		$html = null;
1418 2
		call_integration_hook('integrate_recursive_bbc_parser', array(&$autolinker, &$html));
1419
1420 2
		$parser = new BBCParser($bbc, $autolinker, $html);
1421
1422 2
		return $parser->enableSmileys(empty($tag[Codes::ATTR_PARSED_TAGS_ALLOWED]))->parse($data);
1423
	}
1424
1425
	/**
1426
	 * Return the BBC codes in the system
1427
	 *
1428
	 * @return array
1429
	 */
1430
	public function getBBC()
1431
	{
1432
		return $this->bbc_codes;
1433
	}
1434
1435
	/**
1436
	 * Enable the parsing of smileys
1437
	 *
1438
	 * @param boolean $enable
1439
	 *
1440
	 * @return $this
1441
	 */
1442 2
	public function enableSmileys($enable = true)
1443
	{
1444 2
		$this->do_smileys = (bool) $enable;
1445
1446 2
		return $this;
1447
	}
1448
1449
	/**
1450
	 * Open a tag
1451
	 *
1452
	 * @param array $tag
1453
	 */
1454 6
	protected function addOpenTag(array $tag)
1455
	{
1456 6
		$this->open_tags[] = $tag;
1457 6
	}
1458
1459
	/**
1460
	 * @param string|bool $tag = false False closes the last open tag. Anything else finds that tag LIFO
1461
	 *
1462
	 * @return mixed
1463
	 */
1464 10
	protected function closeOpenedTag($tag = false)
1465
	{
1466 10
		if ($tag === false)
1467
		{
1468 10
			return array_pop($this->open_tags);
1469
		}
1470
		elseif (isset($this->open_tags[$tag]))
1471
		{
1472
			$return = $this->open_tags[$tag];
1473
			unset($this->open_tags[$tag]);
1474
1475
			return $return;
1476
		}
1477
	}
1478
1479
	/**
1480
	 * Check if there are any tags that are open
1481
	 *
1482
	 * @return bool
1483
	 */
1484 8
	protected function hasOpenTags()
1485
	{
1486 8
		return !empty($this->open_tags);
1487
	}
1488
1489
	/**
1490
	 * Get the last opened tag
1491
	 *
1492
	 * @return string
1493
	 */
1494 4
	protected function getLastOpenedTag()
1495
	{
1496 4
		return end($this->open_tags);
1497
	}
1498
1499
	/**
1500
	 * Get the currently opened tags
1501
	 *
1502
	 * @param bool|false $tags_only True if you want just the tag or false for the whole code
1503
	 *
1504
	 * @return array
1505
	 */
1506 6
	protected function getOpenedTags($tags_only = false)
1507
	{
1508 6
		if (!$tags_only)
1509
		{
1510 6
			return $this->open_tags;
1511
		}
1512
1513
		$tags = array();
1514
		foreach ($this->open_tags as $tag)
1515
		{
1516
			$tags[] = $tag[Codes::ATTR_TAG];
1517
		}
1518
1519
		return $tags;
1520
	}
1521
1522
	/**
1523
	 * Does what it says, removes whitespace
1524
	 *
1525
	 * @param null|int $offset = null
1526
	 */
1527 4
	protected function trimWhiteSpace($offset = null)
1528
	{
1529 4
		if (preg_match('~(<br />|&nbsp;|\s)*~', $this->message, $matches, null, $offset) !== 0 && isset($matches[0]) && $matches[0] !== '')
1530
		{
1531
			$this->message = substr_replace($this->message, '', $this->pos, strlen($matches[0]));
1532
		}
1533 4
	}
1534
1535
	/**
1536
	 * @param array $possible
1537
	 * @param array $matches
1538
	 *
1539
	 * @return array
1540
	 */
1541 2
	protected function setupTagParameters(array $possible, array $matches)
1542
	{
1543 2
		$params = array();
1544 2
		for ($i = 1, $n = count($matches); $i < $n; $i += 2)
1545
		{
1546 2
			$key = strtok(ltrim($matches[$i]), '=');
1547
1548 2
			if (isset($possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALUE]))
1549
			{
1550 2
				$params['{' . $key . '}'] = strtr($possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALUE], array('$1' => $matches[$i + 1]));
1551
			}
1552 2
			elseif (isset($possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALIDATE]))
1553
			{
1554 2
				$params['{' . $key . '}'] = $possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALIDATE]($matches[$i + 1]);
1555
			}
1556
			else
1557
			{
1558 2
				$params['{' . $key . '}'] = $matches[$i + 1];
1559
			}
1560
1561
			// Just to make sure: replace any $ or { so they can't interpolate wrongly.
1562 2
			$params['{' . $key . '}'] = str_replace(array('$', '{'), array('&#036;', '&#123;'), $params['{' . $key . '}']);
1563
		}
1564
1565 2
		foreach ($possible[Codes::ATTR_PARAM] as $p => $info)
1566
		{
1567 2
			if (!isset($params['{' . $p . '}']))
1568
			{
1569 2
				$params['{' . $p . '}'] = '';
1570
			}
1571
		}
1572
1573
		// We found our tag
1574 2
		$tag = $possible;
1575
1576
		// Put the parameters into the string.
1577 2 View Code Duplication
		if (isset($tag[Codes::ATTR_BEFORE]))
1578
		{
1579 2
			$tag[Codes::ATTR_BEFORE] = strtr($tag[Codes::ATTR_BEFORE], $params);
1580
		}
1581
1582 2 View Code Duplication
		if (isset($tag[Codes::ATTR_AFTER]))
1583
		{
1584 2
			$tag[Codes::ATTR_AFTER] = strtr($tag[Codes::ATTR_AFTER], $params);
1585
		}
1586
1587 2 View Code Duplication
		if (isset($tag[Codes::ATTR_CONTENT]))
1588
		{
1589 2
			$tag[Codes::ATTR_CONTENT] = strtr($tag[Codes::ATTR_CONTENT], $params);
1590
		}
1591
1592 2
		$this->pos1 += strlen($matches[0]) - 1;
1593
1594 2
		return $tag;
1595
	}
1596
1597
	/**
1598
	 * Check if a tag (not a code) is open
1599
	 *
1600
	 * @param string $tag
1601
	 *
1602
	 * @return bool
1603
	 */
1604
	protected function isOpen($tag)
1605
	{
1606
		foreach ($this->open_tags as $open)
1607
		{
1608
			if ($open[Codes::ATTR_TAG] === $tag)
1609
			{
1610
				return true;
1611
			}
1612
		}
1613
1614
		return false;
1615
	}
1616
1617
	/**
1618
	 * Check if a character is an item code
1619
	 *
1620
	 * @param string $char
1621
	 *
1622
	 * @return bool
1623
	 */
1624 8
	protected function isItemCode($char)
1625
	{
1626 8
		return isset($this->item_codes[$char]);
1627
	}
1628
1629
	/**
1630
	 * Close any open codes that aren't block level.
1631
	 * Used before opening a code that *is* block level
1632
	 */
1633 4
	protected function closeNonBlockLevel()
1634
	{
1635 4
		$n = count($this->open_tags) - 1;
1636 4
		while (empty($this->open_tags[$n][Codes::ATTR_BLOCK_LEVEL]) && $n >= 0)
1637
		{
1638
			$n--;
1639
		}
1640
1641
		// Close all the non block level tags so this tag isn't surrounded by them.
1642 4
		for ($i = count($this->open_tags) - 1; $i > $n; $i--)
1643
		{
1644
			$tmp = $this->noSmileys($this->open_tags[$i][Codes::ATTR_AFTER]);
1645
			$this->message = substr_replace($this->message, $tmp, $this->pos, 0);
1646
			$ot_strlen = strlen($tmp);
1647
			$this->pos += $ot_strlen;
1648
			$this->pos1 += $ot_strlen;
1649
1650
			// Trim or eat trailing stuff... see comment at the end of the big loop.
1651 View Code Duplication
			if (!empty($this->open_tags[$i][Codes::ATTR_BLOCK_LEVEL]) && substr_compare($this->message, '<br />', $this->pos, 6) === 0)
1652
			{
1653
				$this->message = substr_replace($this->message, '', $this->pos, 6);
1654
			}
1655
1656
			if (isset($this->open_tags[$i][Codes::ATTR_TRIM]) && $this->open_tags[$i][Codes::ATTR_TRIM] !== Codes::TRIM_INSIDE)
1657
			{
1658
				$this->trimWhiteSpace($this->pos);
1659
			}
1660
1661
			$this->closeOpenedTag();
1662
		}
1663 4
	}
1664
1665
	/**
1666
	 * Add markers around a string to denote that smileys should not be parsed
1667
	 *
1668
	 * @param string $string
1669
	 *
1670
	 * @return string
1671
	 */
1672 6
	protected function noSmileys($string)
1673
	{
1674 6
		return $this->smiley_marker . $string . $this->smiley_marker;
1675
	}
1676
1677
	/**
1678
	 * Checks if we can cache, some codes prevent this and require parsing each time
1679
	 *
1680
	 * @return bool
1681
	 */
1682
	public function canCache()
1683
	{
1684
		return $this->can_cache;
1685
	}
1686
1687
	/**
1688
	 * This calls Codes::ATTR_VALIDATE.
1689
	 *
1690
	 * @param array $tag
1691
	 * @param $data
1692
	 */
1693 6
	protected function filterData(array $tag, &$data)
1694
	{
1695 6
		$tag[Codes::ATTR_VALIDATE]($data, $this->bbc->getDisabled());
1696 6
	}
1697
}
1698