Completed
Pull Request — master (#3325)
by Emanuele
11:19
created

BBCParser::handleItemCode()   C

Complexity

Conditions 14
Paths 21

Size

Total Lines 75
Code Lines 43

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 43
CRAP Score 14.3594

Importance

Changes 0
Metric Value
cc 14
eloc 43
dl 0
loc 75
rs 6.2666
c 0
b 0
f 0
nc 21
nop 0
ccs 43
cts 49
cp 0.8776
crap 14.3594

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
 * @name      ElkArte Forum
6
 * @copyright ElkArte Forum contributors
7
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause
8
 *
9
 * This file contains code covered by:
10
 * copyright:	2011 Simple Machines (http://www.simplemachines.org)
11
 * license:		BSD, See included LICENSE.TXT for terms and conditions.
12
 *
13
 * @version 1.1
14
 *
15
 */
16
17
namespace BBC;
18
19
/**
20
 * Class BBCParser
21
 *
22
 * One of the primary functions, parsing BBC to HTML
23
 *
24
 * @package BBC
25
 */
26
class BBCParser
27
{
28
	/** The max number of iterations to perform while solving for out of order attributes */
29
	const MAX_PERMUTE_ITERATIONS = 5040;
30
31
	/** @var string  */
32
	protected $message;
33
	/** @var Codes  */
34
	protected $bbc;
35
	/** @var array  */
36
	protected $bbc_codes;
37
	/** @var array  */
38
	protected $item_codes;
39
	/** @var array  */
40
	protected $tags;
41
	/** @var int parser position in message */
42
	protected $pos;
43
	/** @var int  */
44
	protected $pos1;
45
	/** @var int  */
46
	protected $pos2;
47
	/** @var int  */
48
	protected $pos3;
49
	/** @var int  */
50
	protected $last_pos;
51
	/** @var bool  */
52
	protected $do_smileys = true;
53
	/** @var array  */
54
	protected $open_tags = array();
55
	/** @var string|null This is the actual tag that's open */
56
	protected $inside_tag;
57
	/** @var Autolink|null  */
58
	protected $autolinker;
59
	/** @var bool  */
60
	protected $possible_html;
61
	/** @var HtmlParser|null  */
62
	protected $html_parser;
63
	/** @var bool if we can cache the message or not (some tags disallow caching) */
64
	protected $can_cache = true;
65
	/** @var int footnote tracker */
66
	protected $num_footnotes = 0;
67
	/** @var string used to mark smiles in a message */
68
	protected $smiley_marker = "\r";
69
	/** @var int  */
70
	protected $lastAutoPos = 0;
71
	/** @var array content fo the footnotes */
72
	protected $fn_content = array();
73
	/** @var array  */
74
	protected $tag_possible = array();
75
76
	/**
77
	 * BBCParser constructor.
78
	 *
79
	 * @param \BBC\Codes $bbc
80
	 * @param \BBC\Autolink|null $autolinker
81
	 * @param \BBC\HtmlParser|null $html_parser
82
	 */
83 2
	public function __construct(Codes $bbc, Autolink $autolinker = null, HtmlParser $html_parser = null)
84
	{
85 2
		$this->bbc = $bbc;
86
87 2
		$this->bbc_codes = $this->bbc->getForParsing();
88 2
		$this->item_codes = $this->bbc->getItemCodes();
89
90 2
		$this->autolinker = $autolinker;
91 2
		$this->loadAutolink();
92
93 2
		$this->html_parser = $html_parser;
94 2
	}
95
96
	/**
97
	 * Reset the parser's properties for a new message
98
	 */
99 5
	public function resetParser()
100
	{
101 5
		$this->pos = -1;
102 5
		$this->pos1 = null;
103 5
		$this->pos2 = null;
104 5
		$this->last_pos = null;
105 5
		$this->open_tags = array();
106 5
		$this->inside_tag = null;
107 5
		$this->lastAutoPos = 0;
108 5
		$this->can_cache = true;
109 5
		$this->num_footnotes = 0;
110 5
	}
111
112
	/**
113
	 * Parse the BBC in a string/message
114
	 *
115
	 * @param string $message
116
	 *
117
	 * @return string
118
	 */
119 6
	public function parse($message)
120
	{
121
		// Allow addons access before entering the main parse loop, formally
122
		// called as integrate_pre_parsebbc
123 6
		call_integration_hook('integrate_pre_bbc_parser', array(&$message, &$this->bbc));
124
125 6
		$this->message = (string) $message;
126
127
		// Don't waste cycles
128 6
		if ($this->message === '')
129 6
		{
130 1
			return '';
131
		}
132
133
		// @todo remove from here and make the caller figure it out
134 5
		if (!$this->parsingEnabled())
135 5
		{
136
			return $this->message;
137
		}
138
139 5
		$this->resetParser();
140
141
		// @todo change this to <br> (it will break tests and previews and ...)
142 5
		$this->message = str_replace("\n", '<br />', $this->message);
143
144
		// Check if the message might have a link or email to save a bunch of parsing in autolink()
145 5
		$this->autolinker->setPossibleAutolink($this->message);
0 ignored issues
show
Bug introduced by
The method setPossibleAutolink() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

145
		$this->autolinker->/** @scrutinizer ignore-call */ 
146
                     setPossibleAutolink($this->message);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
146
147 5
		$this->possible_html = !empty($GLOBALS['modSettings']['enablePostHTML']) && strpos($message, '&lt;') !== false;
148
149
		// Don't load the HTML Parser unless we have to
150 5
		if ($this->possible_html && $this->html_parser === null)
151 5
		{
152 1
			$this->loadHtmlParser();
153 1
		}
154
155
		// This handles pretty much all of the parsing. It is a separate method so it is easier to override and profile.
156 5
		$this->parse_loop();
157
158
		// Close any remaining tags.
159 5
		while ($tag = $this->closeOpenedTag())
160
		{
161 1
			$this->message .= $this->noSmileys($tag[Codes::ATTR_AFTER]);
162 1
		}
163
164 5
		if (isset($this->message[0]) && $this->message[0] === ' ')
165 5
		{
166
			$this->message = substr_replace($this->message, '&nbsp;', 0, 1);
167
			//$this->message = '&nbsp;' . substr($this->message, 1);
168
		}
169
170
		// Cleanup whitespace.
171 5
		$this->message = str_replace(array('  ', '<br /> ', '&#13;'), array('&nbsp; ', '<br />&nbsp;', "\n"), $this->message);
172
173
		// Finish footnotes if we have any.
174 5
		if ($this->num_footnotes > 0)
175 5
		{
176 1
			$this->handleFootnotes();
177 1
		}
178
179
		// Allow addons access to what the parser created, formally
180
		// called as integrate_post_parsebbc
181 5
		$message = $this->message;
182 5
		call_integration_hook('integrate_post_bbc_parser', array(&$message));
183 5
		$this->message = $message;
184
185 5
		return $this->message;
186
	}
187
188
	/**
189
	 * The BBC parsing loop-o-love
190
	 *
191
	 * Walks the string to parse, looking for BBC tags and passing items to the required translation functions
192
	 */
193 5
	protected function parse_loop()
194
	{
195 5
		while ($this->pos !== false)
196
		{
197 5
			$this->last_pos = isset($this->last_pos) ? max($this->pos, $this->last_pos) : $this->pos;
198 5
			$this->pos = strpos($this->message, '[', $this->pos + 1);
199
200
			// Failsafe.
201 5
			if ($this->pos === false || $this->last_pos > $this->pos)
202 5
			{
203 5
				$this->pos = strlen($this->message) + 1;
204 5
			}
205
206
			// Can't have a one letter smiley, URL, or email! (sorry.)
207 5
			if ($this->last_pos < $this->pos - 1)
208 5
			{
209 5
				$this->betweenTags();
210 5
			}
211
212
			// Are we there yet?  Are we there yet?
213 5
			if ($this->pos >= strlen($this->message) - 1)
214 5
			{
215 5
				return;
216
			}
217
218 4
			$next_char = strtolower($this->message[$this->pos + 1]);
219
220
			// Possibly a closer?
221 4
			if ($next_char === '/')
222 4
			{
223 4
				if ($this->hasOpenTags())
224 4
				{
225 3
					$this->handleOpenTags();
226 3
				}
227
228
				// We don't allow / to be used for anything but the closing character, so this can't be a tag
229 4
				continue;
230
			}
231
232
			// No tags for this character, so just keep going (fastest possible course.)
233 4
			if (!isset($this->bbc_codes[$next_char]))
234 4
			{
235 1
				continue;
236
			}
237
238 4
			$this->inside_tag = !$this->hasOpenTags() ? null : $this->getLastOpenedTag();
239
240 4
			if ($this->isItemCode($next_char) && isset($this->message[$this->pos + 2]) && $this->message[$this->pos + 2] === ']' && !$this->bbc->isDisabled('list') && !$this->bbc->isDisabled('li'))
241 4
			{
242
				// Itemcodes cannot be 0 and must be proceeded by a semi-colon, space, tab, new line, or greater than sign
243 1
				if (!($this->message[$this->pos + 1] === '0' && !in_array($this->message[$this->pos - 1], array(';', ' ', "\t", "\n", '>'))))
244 1
				{
245
					// Item codes are complicated buggers... they are implicit [li]s and can make [list]s!
246 1
					$this->handleItemCode();
247 1
				}
248
249
				// No matter what, we have to continue here.
250 1
				continue;
251
			}
252
			else
253
			{
254 4
				$tag = $this->findTag($this->bbc_codes[$next_char]);
255
			}
256
257
			// Implicitly close lists and tables if something other than what's required is in them. This is needed for itemcode.
258 4
			if ($tag === null && $this->inside_tag !== null && !empty($this->inside_tag[Codes::ATTR_REQUIRE_CHILDREN]))
259 4
			{
260
				$this->closeOpenedTag();
261
				$tmp = $this->noSmileys($this->inside_tag[Codes::ATTR_AFTER]);
262
				$this->message = substr_replace($this->message, $tmp, $this->pos, 0);
263
				$this->pos += strlen($tmp) - 1;
264
			}
265
266
			// No tag?  Keep looking, then.  Silly people using brackets without actual tags.
267 4
			if ($tag === null)
268 4
			{
269 1
				continue;
270
			}
271
272
			// Propagate the list to the child (so wrapping the disallowed tag won't work either.)
273 3
			if (isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]))
274 3
			{
275
				$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];
276
			}
277
278
			// Is this tag disabled?
279 3
			if ($this->bbc->isDisabled($tag[Codes::ATTR_TAG]))
280 3
			{
281
				$this->handleDisabled($tag);
282
			}
283
284
			// The only special case is 'html', which doesn't need to close things.
285 3
			if ($tag[Codes::ATTR_BLOCK_LEVEL] && $tag[Codes::ATTR_TAG] !== 'html' && !$this->inside_tag[Codes::ATTR_BLOCK_LEVEL])
286 3
			{
287 2
				$this->closeNonBlockLevel();
288 2
			}
289
290
			// This is the part where we actually handle the tags. I know, crazy how long it took.
291 3
			if ($this->handleTag($tag))
292 3
			{
293
				continue;
294
			}
295
296
			// If this is block level, eat any breaks after it.
297 3
			if ($tag[Codes::ATTR_BLOCK_LEVEL] && isset($this->message[$this->pos + 1]) && substr_compare($this->message, '<br />', $this->pos + 1, 6) === 0)
298 3
			{
299
				$this->message = substr_replace($this->message, '', $this->pos + 1, 6);
300
			}
301
302
			// Are we trimming outside this tag?
303 3
			if (!empty($tag[Codes::ATTR_TRIM]) && $tag[Codes::ATTR_TRIM] !== Codes::TRIM_OUTSIDE)
304 3
			{
305 2
				$this->trimWhiteSpace($this->pos + 1);
306 2
			}
307 3
		}
308
	}
309
310
	/**
311
	 * Process a tag once the closing character / has been found
312
	 */
313 3
	protected function handleOpenTags()
314
	{
315
		// Next closing bracket after the first character
316 3
		$this->pos2 = strpos($this->message, ']', $this->pos + 1);
317
318
		// Playing games? string = [/]
319 3
		if ($this->pos2 === $this->pos + 2)
320 3
		{
321
			return;
322
		}
323
324
		// Get everything between [/ and ]
325 3
		$look_for = strtolower(substr($this->message, $this->pos + 2, $this->pos2 - $this->pos - 2));
326 3
		$to_close = array();
327 3
		$block_level = null;
328
329
		do
330
		{
331
			// Get the last opened tag
332 3
			$tag = $this->closeOpenedTag();
333
334
			// No open tags
335 3
			if (!$tag)
336 3
			{
337
				break;
338
			}
339
340 3
			if ($tag[Codes::ATTR_BLOCK_LEVEL])
341 3
			{
342
				// Only find out if we need to.
343 2
				if ($block_level === false)
344 2
				{
345
					$this->addOpenTag($tag);
346
					break;
347
				}
348
349
				// The idea is, if we are LOOKING for a block level tag, we can close them on the way.
350 2
				if (isset($look_for[1]) && isset($this->bbc_codes[$look_for[0]]))
351 2
				{
352 2
					foreach ($this->bbc_codes[$look_for[0]] as $temp)
353
					{
354 2
						if ($temp[Codes::ATTR_TAG] === $look_for)
355 2
						{
356 2
							$block_level = $temp[Codes::ATTR_BLOCK_LEVEL];
357 2
							break;
358
						}
359 2
					}
360 2
				}
361
362 2
				if ($block_level !== true)
363 2
				{
364
					$block_level = false;
365
					$this->addOpenTag($tag);
366
					break;
367
				}
368 2
			}
369
370 3
			$to_close[] = $tag;
371 3
		} while ($tag[Codes::ATTR_TAG] !== $look_for);
372
373
		// Did we just eat through everything and not find it?
374 3
		if (!$this->hasOpenTags() && (empty($tag) || $tag[Codes::ATTR_TAG] !== $look_for))
375 3
		{
376
			$this->open_tags = $to_close;
377
			return;
378
		}
379 3
		elseif (!empty($to_close) && $tag[Codes::ATTR_TAG] !== $look_for)
380
		{
381
			if ($block_level === null && isset($look_for[0], $this->bbc_codes[$look_for[0]]))
382
			{
383
				foreach ($this->bbc_codes[$look_for[0]] as $temp)
384
				{
385
					if ($temp[Codes::ATTR_TAG] === $look_for)
386
					{
387
						$block_level = !empty($temp[Codes::ATTR_BLOCK_LEVEL]);
388
						break;
389
					}
390
				}
391
			}
392
393
			// We're not looking for a block level tag (or maybe even a tag that exists...)
394
			if (!$block_level)
0 ignored issues
show
Bug Best Practice introduced by
The expression $block_level of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
395
			{
396
				foreach ($to_close as $tag)
397
				{
398
					$this->addOpenTag($tag);
399
				}
400
401
				return;
402
			}
403
		}
404
405 3
		foreach ($to_close as $tag)
406
		{
407 3
			$tmp = $this->noSmileys($tag[Codes::ATTR_AFTER]);
408 3
			$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 1 - $this->pos);
409 3
			$this->pos += strlen($tmp);
410 3
			$this->pos2 = $this->pos - 1;
411
412
			// See the comment at the end of the big loop - just eating whitespace ;).
413 3
			if ($tag[Codes::ATTR_BLOCK_LEVEL] && isset($this->message[$this->pos]) && substr_compare($this->message, '<br />', $this->pos, 6) === 0)
414 3
			{
415
				$this->message = substr_replace($this->message, '', $this->pos, 6);
416
			}
417
418
			// Trim inside whitespace
419 3
			if (!empty($tag[Codes::ATTR_TRIM]) && $tag[Codes::ATTR_TRIM] !== Codes::TRIM_INSIDE)
420 3
			{
421 2
				$this->trimWhiteSpace($this->pos);
422 2
			}
423 3
		}
424
425 3
		if (!empty($to_close))
426 3
		{
427 3
			$this->pos--;
428 3
		}
429 3
	}
430
431
	/**
432
	 * Turn smiley parsing on/off
433
	 *
434
	 * @param bool $toggle
435
	 * @return BBCParser
436
	 */
437
	public function doSmileys($toggle)
438
	{
439
		$this->do_smileys = (bool) $toggle;
440
441
		return $this;
442
	}
443
444
	/**
445
	 * Check if parsing is enabled
446
	 *
447
	 * @return bool
448
	 */
449 5
	public function parsingEnabled()
450
	{
451 5
		return !empty($GLOBALS['modSettings']['enableBBC']);
452
	}
453
454
	/**
455
	 * Load the HTML parsing engine
456
	 */
457 1
	public function loadHtmlParser()
458
	{
459 1
		$parser = new HtmlParser;
460 1
		call_integration_hook('integrate_bbc_load_html_parser', array(&$parser));
461 1
		$this->html_parser = $parser;
462 1
	}
463
464
	/**
465
	 * Parse the HTML in a string
466
	 *
467
	 * @param string $data
468
	 */
469 1
	protected function parseHTML($data)
470
	{
471 1
		return $this->html_parser->parse($data);
0 ignored issues
show
Bug introduced by
The method parse() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

471
		return $this->html_parser->/** @scrutinizer ignore-call */ parse($data);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
472
	}
473
474
	/**
475
	 * Parse URIs and email addresses in a string to url and email BBC tags to be parsed by the BBC parser
476
	 *
477
	 * @param string $data
478
	 */
479 5
	protected function autoLink($data)
480
	{
481 5
		if ($data === '' || $data === $this->smiley_marker || !$this->autolinker->hasPossible())
482 5
		{
483 4
			return $data;
484
		}
485
486
		// Are we inside tags that should be auto linked?
487 3
		if ($this->hasOpenTags())
488 3
		{
489 3
			foreach ($this->getOpenedTags() as $open_tag)
490
			{
491 3
				if (!$open_tag[Codes::ATTR_AUTOLINK])
492 3
				{
493 3
					return $data;
494
				}
495 1
			}
496 1
		}
497
498 3
		return $this->autolinker->parse($data);
499
	}
500
501
	/**
502
	 * Load the autolink regular expression to be used in autoLink()
503
	 */
504 2
	protected function loadAutolink()
505
	{
506 2
		if ($this->autolinker === null)
507 2
		{
508 1
			$this->autolinker = new Autolink($this->bbc);
509 1
		}
510 2
	}
511
512
	/**
513
	 * Find if the current character is the start of a tag and get it
514
	 *
515
	 * @param array $possible_codes
516
	 *
517
	 * @return null|array the tag that was found or null if no tag found
518
	 */
519 4
	protected function findTag(array $possible_codes)
520
	{
521 4
		$tag = null;
522 4
		$last_check = null;
523
524 4
		foreach ($possible_codes as $possible)
525
		{
526
			// Skip tags that didn't match the next X characters
527 4
			if ($possible[Codes::ATTR_TAG] === $last_check)
528 4
			{
529 2
				continue;
530
			}
531
532
			// The character after the possible tag or nothing
533 4
			$next_c = isset($this->message[$this->pos + 1 + $possible[Codes::ATTR_LENGTH]]) ? $this->message[$this->pos + 1 + $possible[Codes::ATTR_LENGTH]] : '';
534
535
			// This only happens if the tag is the last character of the string
536 4
			if ($next_c === '')
537 4
			{
538
				break;
539
			}
540
541
			// The next character must be one of these or it's not a tag
542 4
			if ($next_c !== ' ' && $next_c !== ']' && $next_c !== '=' && $next_c !== '/')
543 4
			{
544 4
				$last_check = $possible[Codes::ATTR_TAG];
545 4
				continue;
546
			}
547
548
			// Not a match?
549 4
			if (substr_compare($this->message, $possible[Codes::ATTR_TAG], $this->pos + 1, $possible[Codes::ATTR_LENGTH], true) !== 0)
550 4
			{
551 2
				$last_check = $possible[Codes::ATTR_TAG];
552 2
				continue;
553
			}
554
555 4
			$tag = $this->checkCodeAttributes($next_c, $possible);
556 4
			if ($tag === null)
557 4
			{
558 4
				continue;
559
			}
560
561
			// Quotes can have alternate styling, we do this php-side due to all the permutations of quotes.
562 3
			if ($tag[Codes::ATTR_TAG] === 'quote')
563 3
			{
564 1
				$this->alternateQuoteStyle($tag);
565 1
			}
566
567 3
			break;
568 4
		}
569
570
		// If there is a code that says you can't cache, the message can't be cached
571 4
		if ($tag !== null && $this->can_cache !== false)
0 ignored issues
show
introduced by
The condition $tag !== null is always false.
Loading history...
572 4
		{
573 3
			$this->can_cache = empty($tag[Codes::ATTR_NO_CACHE]);
574 3
		}
575
576
		// If its a footnote, keep track of the number
577 4
		if ($tag[Codes::ATTR_TAG] === 'footnote')
578 4
		{
579 1
			$this->num_footnotes++;
580 1
		}
581
582 4
		return $tag;
583
	}
584
585
	/**
586
	 * Just alternates the applied class for quotes for themes that want to distinguish them
587
	 *
588
	 * @param array $tag
589
	 */
590 1
	protected function alternateQuoteStyle(array &$tag)
591
	{
592
		// Start with standard
593 1
		$quote_alt = false;
594 1
		foreach ($this->open_tags as $open_quote)
595
		{
596
			// Every parent quote this quote has flips the styling
597 1
			if ($open_quote[Codes::ATTR_TAG] === 'quote')
598 1
			{
599 1
				$quote_alt = !$quote_alt;
0 ignored issues
show
introduced by
The condition $quote_alt is always false.
Loading history...
600 1
			}
601 1
		}
602
		// Add a class to the quote and quoteheader to style alternating blockquotes
603
		//  - Example: class="quoteheader" and class="quoteheader bbc_alt_quoteheader" on the header
604
		//             class="bbc_quote" and class="bbc_quote bbc_alternate_quote" on the blockquote
605
		// This allows simpler CSS for themes (like default) which do not use the alternate styling,
606
		// but still allow it for themes that want it.
607 1
		$tag[Codes::ATTR_BEFORE] = str_replace('<div class="quoteheader">', '<div class="quoteheader' . ($quote_alt ? ' bbc_alt_quoteheader' : '') . '">', $tag[Codes::ATTR_BEFORE]);
608 1
		$tag[Codes::ATTR_BEFORE] = str_replace('<blockquote>', '<blockquote class="bbc_quote' . ($quote_alt ? ' bbc_alternate_quote' : '') . '">', $tag[Codes::ATTR_BEFORE]);
609 1
	}
610
611
	/**
612
	 * Parses BBC codes attributes for codes that may have them
613
	 *
614
	 * @param string $next_c
615
	 * @param array $possible
616
	 * @return array|null
617
	 */
618 4
	protected function checkCodeAttributes($next_c, array $possible)
619
	{
620
		// Do we want parameters?
621 4
		if (!empty($possible[Codes::ATTR_PARAM]))
622 4
		{
623 2
			if ($next_c !== ' ')
624 2
			{
625 1
				return null;
626
			}
627 2
		}
628
		// parsed_content demands an immediate ] without parameters!
629 4
		elseif ($possible[Codes::ATTR_TYPE] === Codes::TYPE_PARSED_CONTENT)
630
		{
631 3
			if ($next_c !== ']')
632 3
			{
633 2
				return null;
634
			}
635 2
		}
636
		else
637
		{
638
			// Do we need an equal sign?
639 4
			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)))
640 4
			{
641 2
				return null;
642
			}
643
644 4
			if ($next_c !== ']')
645 4
			{
646
				// An immediate ]?
647 4
				if ($possible[Codes::ATTR_TYPE] === Codes::TYPE_UNPARSED_CONTENT)
648 4
				{
649 3
					return null;
650
				}
651
				// Maybe we just want a /...
652 4
				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)
653
				{
654
					return null;
655
				}
656 4
			}
657
		}
658
659
		// Check allowed tree?
660 4
		if (isset($possible[Codes::ATTR_REQUIRE_PARENTS]) && ($this->inside_tag === null || !isset($possible[Codes::ATTR_REQUIRE_PARENTS][$this->inside_tag[Codes::ATTR_TAG]])))
661 4
		{
662
			return null;
663
		}
664
665 4
		if ($this->inside_tag !== null)
666 4
		{
667 2
			if (isset($this->inside_tag[Codes::ATTR_REQUIRE_CHILDREN]) && !isset($this->inside_tag[Codes::ATTR_REQUIRE_CHILDREN][$possible[Codes::ATTR_TAG]]))
668 2
			{
669
				return null;
670
			}
671
672
			// If this is in the list of disallowed child tags, don't parse it.
673 2
			if (isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]) && isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN][$possible[Codes::ATTR_TAG]]))
674 2
			{
675
				return null;
676
			}
677
678
			// Not allowed in this parent, replace the tags or show it like regular text
679 2
			if (isset($possible[Codes::ATTR_DISALLOW_PARENTS]) && isset($possible[Codes::ATTR_DISALLOW_PARENTS][$this->inside_tag[Codes::ATTR_TAG]]))
680 2
			{
681
				if (!isset($possible[Codes::ATTR_DISALLOW_BEFORE], $possible[Codes::ATTR_DISALLOW_AFTER]))
682
				{
683
					return null;
684
				}
685
686
				$possible[Codes::ATTR_BEFORE] = isset($possible[Codes::ATTR_DISALLOW_BEFORE]) ? $possible[Codes::ATTR_DISALLOW_BEFORE] : $possible[Codes::ATTR_BEFORE];
687
				$possible[Codes::ATTR_AFTER] = isset($possible[Codes::ATTR_DISALLOW_AFTER]) ? $possible[Codes::ATTR_DISALLOW_AFTER] : $possible[Codes::ATTR_AFTER];
688
			}
689 2
		}
690
691 4
		if (isset($possible[Codes::ATTR_TEST]) && $this->handleTest($possible))
692 4
		{
693 2
			return null;
694
		}
695
696
		// +1 for [, then the length of the tag, then a space
697 4
		$this->pos1 = $this->pos + 1 + $possible[Codes::ATTR_LENGTH] + 1;
698
699
		// This is long, but it makes things much easier and cleaner.
700 4
		if (!empty($possible[Codes::ATTR_PARAM]))
701 4
		{
702 2
			$match = $this->matchParameters($possible, $matches);
703
704
			// Didn't match our parameter list, try the next possible.
705 2
			if (!$match)
706 2
			{
707 2
				return null;
708
			}
709
710 1
			return $this->setupTagParameters($possible, $matches);
711
		}
712
713 3
		return $possible;
714
	}
715
716
	/**
717
	 * Called when a code has defined a test parameter
718
	 *
719
	 * @param array $possible
720
	 *
721
	 * @return bool
722
	 */
723 3
	protected function handleTest(array $possible)
724
	{
725 3
		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;
726
	}
727
728
	/**
729
	 * Handles item codes by converting them to lists
730
	 */
731 1
	protected function handleItemCode()
732
	{
733 1
		if (!isset($this->item_codes[$this->message[$this->pos + 1]]))
734 1
			return;
735
736 1
		$tag = $this->item_codes[$this->message[$this->pos + 1]];
737
738
		// First let's set up the tree: it needs to be in a list, or after an li.
739 1
		if ($this->inside_tag === null || ($this->inside_tag[Codes::ATTR_TAG] !== 'list' && $this->inside_tag[Codes::ATTR_TAG] !== 'li'))
740 1
		{
741 1
			$this->addOpenTag(array(
742 1
				Codes::ATTR_TAG => 'list',
743 1
				Codes::ATTR_TYPE => Codes::TYPE_PARSED_CONTENT,
744 1
				Codes::ATTR_AFTER => '</ul>',
745 1
				Codes::ATTR_BLOCK_LEVEL => true,
746 1
				Codes::ATTR_REQUIRE_CHILDREN => array('li' => 'li'),
747 1
				Codes::ATTR_DISALLOW_CHILDREN => isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]) ? $this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN] : null,
748 1
				Codes::ATTR_LENGTH => 4,
749 1
				Codes::ATTR_AUTOLINK => true,
750 1
			));
751 1
			$code = '<ul' . ($tag === '' ? '' : ' style="list-style-type: ' . $tag . '"') . ' class="bbc_list">';
752 1
		}
753
		// We're in a list item already: another itemcode?  Close it first.
754 1
		elseif ($this->inside_tag[Codes::ATTR_TAG] === 'li')
755
		{
756 1
			$this->closeOpenedTag();
757 1
			$code = '</li>';
758 1
		}
759
		else
760
		{
761
			$code = '';
762
		}
763
764
		// Now we open a new tag.
765 1
		$this->addOpenTag(array(
766 1
			Codes::ATTR_TAG => 'li',
767 1
			Codes::ATTR_TYPE => Codes::TYPE_PARSED_CONTENT,
768 1
			Codes::ATTR_AFTER => '</li>',
769 1
			Codes::ATTR_TRIM => Codes::TRIM_OUTSIDE,
770 1
			Codes::ATTR_BLOCK_LEVEL => true,
771 1
			Codes::ATTR_DISALLOW_CHILDREN => isset($this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN]) ? $this->inside_tag[Codes::ATTR_DISALLOW_CHILDREN] : null,
772 1
			Codes::ATTR_AUTOLINK => true,
773 1
			Codes::ATTR_LENGTH => 2,
774 1
		));
775
776
		// First, open the tag...
777 1
		$code .= '<li>';
778
779 1
		$tmp = $this->noSmileys($code);
780 1
		$this->message = substr_replace($this->message, $tmp, $this->pos, 3);
781 1
		$this->pos += strlen($tmp) - 1;
782
783
		// Next, find the next break (if any.)  If there's more itemcode after it, keep it going - otherwise close!
784 1
		$this->pos2 = strpos($this->message, '<br />', $this->pos);
785 1
		$this->pos3 = strpos($this->message, '[/', $this->pos);
786
787 1
		$num_open_tags = count($this->open_tags);
788 1
		if ($this->pos2 !== false && ($this->pos3 === false || $this->pos2 <= $this->pos3))
789 1
		{
790
			// Can't use offset because of the ^
791
			preg_match('~^(<br />|&nbsp;|\s|\[)+~', substr($this->message, $this->pos2 + 6), $matches);
792
			//preg_match('~(<br />|&nbsp;|\s|\[)+~', $this->message, $matches, 0, $this->pos2 + 6);
793
794
			// Keep the list open if the next character after the break is a [. Otherwise, close it.
795
			$replacement = !empty($matches[0]) && substr_compare($matches[0], '[', -1, 1) === 0 ? '[/li]' : '[/li][/list]';
796
797
			$this->message = substr_replace($this->message, $replacement, $this->pos2, 0);
798
			$this->open_tags[$num_open_tags - 2][Codes::ATTR_AFTER] = '</ul>';
799
		}
800
		// Tell the [list] that it needs to close specially.
801
		else
802
		{
803
			// Move the li over, because we're not sure what we'll hit.
804 1
			$this->open_tags[$num_open_tags - 1][Codes::ATTR_AFTER] = '';
805 1
			$this->open_tags[$num_open_tags - 2][Codes::ATTR_AFTER] = '</li></ul>';
806
		}
807 1
	}
808
809
	/**
810
	 * Handle codes that are of the parsed context type
811
	 *
812
	 * @param array $tag
813
	 *
814
	 * @return bool
815
	 */
816 2
	protected function handleTypeParsedContext(array $tag)
817
	{
818
		// @todo Check for end tag first, so people can say "I like that [i] tag"?
819 2
		$this->addOpenTag($tag);
820 2
		$tmp = $this->noSmileys($tag[Codes::ATTR_BEFORE]);
821 2
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos1 - $this->pos);
822 2
		$this->pos += strlen($tmp) - 1;
823
824 2
		return false;
825
	}
826
827
	/**
828
	 * Handle codes that are of the unparsed context type
829
	 *
830
	 * @param array $tag
831
	 *
832
	 * @return bool
833
	 */
834 2
	protected function handleTypeUnparsedContext(array $tag)
835
	{
836
		// Find the next closer
837 2
		$this->pos2 = stripos($this->message, '[/' . $tag[Codes::ATTR_TAG] . ']', $this->pos1);
838
839
		// No closer
840 2
		if ($this->pos2 === false)
841 2
		{
842
			return true;
843
		}
844
845 2
		$data = substr($this->message, $this->pos1, $this->pos2 - $this->pos1);
846
847 2
		if (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) && isset($data[0]) && substr_compare($data, '<br />', 0, 6) === 0)
848 2
		{
849
			$data = substr($data, 6);
850
		}
851
852 2
		if (isset($tag[Codes::ATTR_VALIDATE]))
853 2
		{
854
			//$tag[Codes::ATTR_VALIDATE]($tag, $data, $this->bbc->getDisabled());
855 2
			$this->filterData($tag, $data);
856 2
		}
857
858 2
		$code = strtr($tag[Codes::ATTR_CONTENT], array('$1' => $data));
859 2
		$tmp = $this->noSmileys($code);
860 2
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 3 + $tag[Codes::ATTR_LENGTH] - $this->pos);
861 2
		$this->pos += strlen($tmp) - 1;
862 2
		$this->last_pos = $this->pos + 1;
863
864 2
		return false;
865
	}
866
867
	/**
868
	 * Handle codes that are of the unparsed equals context type
869
	 *
870
	 * @param array $tag
871
	 *
872
	 * @return bool
873
	 */
874 1
	protected function handleUnparsedEqualsContext(array $tag)
875
	{
876
		// The value may be quoted for some tags - check.
877 1
		if (isset($tag[Codes::ATTR_QUOTED]))
878 1
		{
879
			$quoted = substr_compare($this->message, '&quot;', $this->pos1, 6) === 0;
880
			if ($tag[Codes::ATTR_QUOTED] !== Codes::OPTIONAL && !$quoted)
881
			{
882
				return true;
883
			}
884
885
			if ($quoted)
886
			{
887
				$this->pos1 += 6;
888
			}
889
		}
890
		else
891
		{
892 1
			$quoted = false;
893
		}
894
895 1
		$this->pos2 = strpos($this->message, $quoted === false ? ']' : '&quot;]', $this->pos1);
896 1
		if ($this->pos2 === false)
897 1
		{
898
			return true;
899
		}
900
901 1
		$this->pos3 = stripos($this->message, '[/' . $tag[Codes::ATTR_TAG] . ']', $this->pos2);
902 1
		if ($this->pos3 === false)
903 1
		{
904
			return true;
905
		}
906
907
		$data = array(
908 1
			substr($this->message, $this->pos2 + ($quoted === false ? 1 : 7), $this->pos3 - ($this->pos2 + ($quoted === false ? 1 : 7))),
909 1
			substr($this->message, $this->pos1, $this->pos2 - $this->pos1)
910 1
		);
911
912 1
		if (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) && substr_compare($data[0], '<br />', 0, 6) === 0)
913 1
		{
914
			$data[0] = substr($data[0], 6);
915
		}
916
917
		// Validation for my parking, please!
918 1
		if (isset($tag[Codes::ATTR_VALIDATE]))
919 1
		{
920
			//$tag[Codes::ATTR_VALIDATE]($tag, $data, $this->bbc->getDisabled());
921 1
			$this->filterData($tag, $data);
922 1
		}
923
924 1
		$code = strtr($tag[Codes::ATTR_CONTENT], array('$1' => $data[0], '$2' => $data[1]));
925 1
		$tmp = $this->noSmileys($code);
926 1
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos3 + 3 + $tag[Codes::ATTR_LENGTH] - $this->pos);
927 1
		$this->pos += strlen($tmp) - 1;
928
929 1
		return false;
930
	}
931
932
	/**
933
	 * Handle codes that are of the closed type
934
	 *
935
	 * @param array $tag
936
	 *
937
	 * @return bool
938
	 */
939 1
	protected function handleTypeClosed(array $tag)
940
	{
941 1
		$this->pos2 = strpos($this->message, ']', $this->pos);
942 1
		$tmp = $this->noSmileys($tag[Codes::ATTR_CONTENT]);
943 1
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 1 - $this->pos);
944 1
		$this->pos += strlen($tmp) - 1;
945
946 1
		return false;
947
	}
948
949
	/**
950
	 * Handle codes that are of the unparsed commas context type
951
	 *
952
	 * @param array $tag
953
	 *
954
	 * @return bool
955
	 */
956
	protected function handleUnparsedCommasContext(array $tag)
957
	{
958
		$this->pos2 = strpos($this->message, ']', $this->pos1);
959
		if ($this->pos2 === false)
960
		{
961
			return true;
962
		}
963
964
		$this->pos3 = stripos($this->message, '[/' . $tag[Codes::ATTR_TAG] . ']', $this->pos2);
965
		if ($this->pos3 === false)
966
		{
967
			return true;
968
		}
969
970
		// We want $1 to be the content, and the rest to be csv.
971
		$data = explode(',', ',' . substr($this->message, $this->pos1, $this->pos2 - $this->pos1));
972
		$data[0] = substr($this->message, $this->pos2 + 1, $this->pos3 - $this->pos2 - 1);
973
974
		if (isset($tag[Codes::ATTR_VALIDATE]))
975
		{
976
			//$tag[Codes::ATTR_VALIDATE]($tag, $data, $this->bbc->getDisabled());
977
			$this->filterData($tag, $data);
978
		}
979
980
		$code = $tag[Codes::ATTR_CONTENT];
981
		foreach ($data as $k => $d)
982
		{
983
			$code = strtr($code, array('$' . ($k + 1) => trim($d)));
984
		}
985
986
		$tmp = $this->noSmileys($code);
987
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos3 + 3 + $tag[Codes::ATTR_LENGTH] - $this->pos);
988
		$this->pos += strlen($tmp) - 1;
989
990
		return false;
991
	}
992
993
	/**
994
	 * Handle codes that are of the unparsed commas type
995
	 *
996
	 * @param array $tag
997
	 *
998
	 * @return bool
999
	 */
1000
	protected function handleUnparsedCommas(array $tag)
1001
	{
1002
		$this->pos2 = strpos($this->message, ']', $this->pos1);
1003
		if ($this->pos2 === false)
1004
		{
1005
			return true;
1006
		}
1007
1008
		$data = explode(',', substr($this->message, $this->pos1, $this->pos2 - $this->pos1));
1009
1010
		if (isset($tag[Codes::ATTR_VALIDATE]))
1011
		{
1012
			//$tag[Codes::ATTR_VALIDATE]($tag, $data, $this->bbc->getDisabled());
1013
			$this->filterData($tag, $data);
1014
		}
1015
1016
		// Fix after, for disabled code mainly.
1017
		foreach ($data as $k => $d)
1018
		{
1019
			$tag[Codes::ATTR_AFTER] = strtr($tag[Codes::ATTR_AFTER], array('$' . ($k + 1) => trim($d)));
1020
		}
1021
1022
		$this->addOpenTag($tag);
1023
1024
		// Replace them out, $1, $2, $3, $4, etc.
1025
		$code = $tag[Codes::ATTR_BEFORE];
1026
		foreach ($data as $k => $d)
1027
		{
1028
			$code = strtr($code, array('$' . ($k + 1) => trim($d)));
1029
		}
1030
1031
		$tmp = $this->noSmileys($code);
1032
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + 1 - $this->pos);
1033
		$this->pos += strlen($tmp) - 1;
1034
1035
		return false;
1036
	}
1037
1038
	/**
1039
	 * Handle codes that are of the equals type
1040
	 *
1041
	 * @param array $tag
1042
	 *
1043
	 * @return bool
1044
	 */
1045 3
	protected function handleEquals(array $tag)
1046
	{
1047
		// The value may be quoted for some tags - check.
1048 3
		if (isset($tag[Codes::ATTR_QUOTED]))
1049 3
		{
1050 2
			$quoted = substr_compare($this->message, '&quot;', $this->pos1, 6) === 0;
1051 2
			if ($tag[Codes::ATTR_QUOTED] !== Codes::OPTIONAL && !$quoted)
1052 2
			{
1053
				return true;
1054
			}
1055
1056
			if ($quoted)
1057 2
			{
1058 1
				$this->pos1 += 6;
1059 1
			}
1060 2
		}
1061
		else
1062
		{
1063 3
			$quoted = false;
1064
		}
1065
1066 3
		$this->pos2 = strpos($this->message, $quoted === false ? ']' : '&quot;]', $this->pos1);
1067 3
		if ($this->pos2 === false)
1068 3
		{
1069
			return true;
1070
		}
1071
1072 3
		$data = substr($this->message, $this->pos1, $this->pos2 - $this->pos1);
1073
1074
		// Validation for my parking, please!
1075 3
		if (isset($tag[Codes::ATTR_VALIDATE]))
1076 3
		{
1077
			//$tag[Codes::ATTR_VALIDATE]($tag, $data, $this->bbc->getDisabled());
1078 3
			$this->filterData($tag, $data);
1079 3
		}
1080
1081
		// For parsed content, we must recurse to avoid security problems.
1082 3
		if ($tag[Codes::ATTR_TYPE] === Codes::TYPE_PARSED_EQUALS)
1083 3
		{
1084 1
			$data = $this->recursiveParser($data, $tag);
1085 1
		}
1086
1087 3
		$tag[Codes::ATTR_AFTER] = strtr($tag[Codes::ATTR_AFTER], array('$1' => $data));
1088
1089 3
		$this->addOpenTag($tag);
1090
1091 3
		$code = strtr($tag[Codes::ATTR_BEFORE], array('$1' => $data));
1092 3
		$tmp = $this->noSmileys($code);
1093 3
		$this->message = substr_replace($this->message, $tmp, $this->pos, $this->pos2 + ($quoted === false ? 1 : 7) - $this->pos);
1094 3
		$this->pos += strlen($tmp) - 1;
1095
1096 3
		return false;
1097
	}
1098
1099
	/**
1100
	 * Handles a tag by its type. Offloads the actual handling to handle*() method
1101
	 *
1102
	 * @param array $tag
1103
	 *
1104
	 * @return bool true if there was something wrong and the parser should advance
1105
	 */
1106 3
	protected function handleTag(array $tag)
1107
	{
1108 3
		switch ($tag[Codes::ATTR_TYPE])
1109
		{
1110 3
			case Codes::TYPE_PARSED_CONTENT:
1111 2
				return $this->handleTypeParsedContext($tag);
1112
1113
			// Don't parse the content, just skip it.
1114 3
			case Codes::TYPE_UNPARSED_CONTENT:
1115 2
				return $this->handleTypeUnparsedContext($tag);
1116
1117
			// Don't parse the content, just skip it.
1118 3
			case Codes::TYPE_UNPARSED_EQUALS_CONTENT:
1119 1
				return $this->handleUnparsedEqualsContext($tag);
1120
1121
			// A closed tag, with no content or value.
1122 3
			case Codes::TYPE_CLOSED:
1123 1
				return $this->handleTypeClosed($tag);
1124
1125
			// This one is sorta ugly... :/
1126 3
			case Codes::TYPE_UNPARSED_COMMAS_CONTENT:
1127
				return $this->handleUnparsedCommasContext($tag);
1128
1129
			// This has parsed content, and a csv value which is unparsed.
1130 3
			case Codes::TYPE_UNPARSED_COMMAS:
1131
				return $this->handleUnparsedCommas($tag);
1132
1133
			// A tag set to a value, parsed or not.
1134 3
			case Codes::TYPE_PARSED_EQUALS:
1135 3
			case Codes::TYPE_UNPARSED_EQUALS:
1136 3
				return $this->handleEquals($tag);
1137
		}
1138
1139
		return false;
1140
	}
1141
1142
	/**
1143
	 * Text between tags
1144
	 *
1145
	 * @todo I don't know what else to call this. It's the area that isn't a tag.
1146
	 */
1147 5
	protected function betweenTags()
1148
	{
1149
		// Make sure the $this->last_pos is not negative.
1150 5
		$this->last_pos = max($this->last_pos, 0);
1151
1152
		// Pick a block of data to do some raw fixing on.
1153 5
		$data = substr($this->message, $this->last_pos, $this->pos - $this->last_pos);
1154
1155
		// This happens when the pos is > last_pos and there is a trailing \n from one of the tags having "AFTER"
1156
		// In micro-optimization tests, using substr() here doesn't prove to be slower. This is much easier to read so leave it.
1157 5
		if ($data === $this->smiley_marker)
1158 5
		{
1159 2
			return;
1160
		}
1161
1162
		// Take care of some HTML!
1163 5
		if ($this->possible_html && strpos($data, '&lt;') !== false)
1164 5
		{
1165
			// @todo new \Parser\BBC\HTML;
1166 1
			$data = $this->parseHTML($data);
1167 1
		}
1168
1169 5
		if (!empty($GLOBALS['modSettings']['autoLinkUrls']))
1170 5
		{
1171 5
			$data = $this->autoLink($data);
1172 5
		}
1173
1174
		// This cannot be moved earlier. It breaks tests
1175 5
		$data = str_replace("\t", '&nbsp;&nbsp;&nbsp;', $data);
1176
1177
		// If it wasn't changed, no copying or other boring stuff has to happen!
1178 5
		if (substr_compare($this->message, $data, $this->last_pos, $this->pos - $this->last_pos))
1179 5
		{
1180 1
			$this->message = substr_replace($this->message, $data, $this->last_pos, $this->pos - $this->last_pos);
1181
1182
			// Since we changed it, look again in case we added or removed a tag.  But we don't want to skip any.
1183 1
			$old_pos = strlen($data) + $this->last_pos;
1184 1
			$this->pos = strpos($this->message, '[', $this->last_pos);
1185 1
			$this->pos = $this->pos === false ? $old_pos : min($this->pos, $old_pos);
1186 1
		}
1187 5
	}
1188
1189
	/**
1190
	 * Handles special [footnote] tag processing as the tag is not rendered "inline"
1191
	 */
1192 1
	protected function handleFootnotes()
1193
	{
1194 1
		global $fn_num, $fn_count;
1195 1
		static $fn_total;
1196
1197
		// @todo temporary until we have nesting
1198 1
		$this->message = str_replace(array('[footnote]', '[/footnote]'), '', $this->message);
1199
1200 1
		$fn_num = 0;
1201 1
		$this->fn_content = array();
1202 1
		$fn_count = isset($fn_total) ? $fn_total : 0;
1203
1204
		// Replace our footnote text with a [1] link, save the text for use at the end of the message
1205 1
		$this->message = preg_replace_callback('~(%fn%(.*?)%fn%)~is', array($this, 'footnoteCallback'), $this->message);
1206 1
		$fn_total += $fn_num;
1207
1208
		// If we have footnotes, add them in at the end of the message
1209 1
		if (!empty($fn_num))
0 ignored issues
show
introduced by
The condition empty($fn_num) is always true.
Loading history...
1210 1
		{
1211 1
			$this->message .=  $this->smiley_marker . '<div class="bbc_footnotes">' . implode('', $this->fn_content) . '</div>' . $this->smiley_marker;
1212 1
		}
1213 1
	}
1214
1215
	/**
1216
	 * Final footnote conversions, builds the proper link code to footnote at base of post
1217
	 *
1218
	 * @param array $matches
1219
	 *
1220
	 * @return string
1221
	 */
1222 1
	protected function footnoteCallback(array $matches)
1223
	{
1224 1
		global $fn_num, $fn_count;
1225
1226 1
		$fn_num++;
1227 1
		$this->fn_content[] = '<div class="target" id="fn' . $fn_num . '_' . $fn_count . '"><sup>' . $fn_num . '&nbsp;</sup>' . $matches[2] . '<a class="footnote_return" href="#ref' . $fn_num . '_' . $fn_count . '">&crarr;</a></div>';
1228
1229 1
		return '<a class="target" href="#fn' . $fn_num . '_' . $fn_count . '" id="ref' . $fn_num . '_' . $fn_count . '">[' . $fn_num . ']</a>';
1230
	}
1231
1232
	/**
1233
	 * Parse a tag that is disabled
1234
	 *
1235
	 * @param array $tag
1236
	 */
1237
	protected function handleDisabled(array &$tag)
1238
	{
1239
		if (!isset($tag[Codes::ATTR_DISABLED_BEFORE]) && !isset($tag[Codes::ATTR_DISABLED_AFTER]) && !isset($tag[Codes::ATTR_DISABLED_CONTENT]))
1240
		{
1241
			$tag[Codes::ATTR_BEFORE] = !empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '<div>' : '';
1242
			$tag[Codes::ATTR_AFTER] = !empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '</div>' : '';
1243
			$tag[Codes::ATTR_CONTENT] = $tag[Codes::ATTR_TYPE] === Codes::TYPE_CLOSED ? '' : (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '<div>$1</div>' : '$1');
1244
		}
1245
		elseif (isset($tag[Codes::ATTR_DISABLED_BEFORE]) || isset($tag[Codes::ATTR_DISABLED_AFTER]))
1246
		{
1247
			$tag[Codes::ATTR_BEFORE] = isset($tag[Codes::ATTR_DISABLED_BEFORE]) ? $tag[Codes::ATTR_DISABLED_BEFORE] : (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '<div>' : '');
1248
			$tag[Codes::ATTR_AFTER] = isset($tag[Codes::ATTR_DISABLED_AFTER]) ? $tag[Codes::ATTR_DISABLED_AFTER] : (!empty($tag[Codes::ATTR_BLOCK_LEVEL]) ? '</div>' : '');
1249
		}
1250
		else
1251
		{
1252
			$tag[Codes::ATTR_CONTENT] = $tag[Codes::ATTR_DISABLED_CONTENT];
1253
		}
1254
	}
1255
1256
	/**
1257
	 * Map required / optional tag parameters to the found tag
1258
	 *
1259
	 * @param array &$possible
1260
	 * @param array &$matches
1261
	 * @return bool
1262
	 */
1263 2
	protected function matchParameters(array &$possible, &$matches)
1264
	{
1265 2
		if (!isset($possible['regex_cache']))
1266 2
		{
1267 2
			$possible['regex_cache'] = array();
1268 2
			$possible['param_check'] = array();
1269 2
			$possible['optionals'] = array();
1270
1271 2
			foreach ($possible[Codes::ATTR_PARAM] as $param => $info)
1272
			{
1273 2
				$quote = empty($info[Codes::PARAM_ATTR_QUOTED]) ? '' : '&quot;';
1274 2
				$possible['optionals'][] = !empty($info[Codes::PARAM_ATTR_OPTIONAL]);
1275 2
				$possible['param_check'][] = ' ' . $param . '=' . $quote;
1276 2
				$possible['regex_cache'][] = '(\s+' . $param . '=' . $quote . (isset($info[Codes::PARAM_ATTR_MATCH]) ? $info[Codes::PARAM_ATTR_MATCH] : '(.+?)') . $quote . ')';
1277 2
			}
1278
1279 2
			$possible['regex_size'] = count($possible[Codes::ATTR_PARAM]) - 1;
1280 2
		}
1281
1282
		// Tag setup for this loop
1283 2
		$this->tag_possible = $possible;
1284
1285
		// Okay, this may look ugly and it is, but it's not going to happen much and it is the best way
1286
		// of allowing any order of parameters but still parsing them right.
1287 2
		$message_stub = $this->messageStub();
1288
1289
		// Set regex optional flags only if the param *is* optional and it *was not used* in this tag
1290 2
		$this->optionalParam($message_stub);
1291
1292
		// If an addon adds many parameters we can exceed max_execution time, lets prevent that
1293
		// 5040 = 7, 40,320 = 8, (N!) etc
1294 2
		$max_iterations = self::MAX_PERMUTE_ITERATIONS;
1295
1296
		// Use the same range to start each time. Most BBC is in the order that it should be in when it starts.
1297 2
		$keys = $this->setKeys();
1298
1299
		// Step, one by one, through all possible permutations of the parameters until we have a match
1300
		do
1301
		{
1302 2
			$match_preg = '~^';
1303 2
			foreach ($keys as $key)
1304
			{
1305 2
				$match_preg .= $this->tag_possible['regex_cache'][$key];
1306 2
			}
1307 2
			$match_preg .= '\]~i';
1308
1309
			// Check if this combination of parameters matches the user input
1310 2
			$match = preg_match($match_preg, $message_stub, $matches) !== 0;
1311 2
		} while (!$match && --$max_iterations && ($keys = pc_next_permutation($keys, $possible['regex_size'])));
1312
1313 2
		return $match;
1314
	}
1315
1316
	/**
1317
	 * Sorts the params so they are in a required to optional order.
1318
	 *
1319
	 * Supports the assumption that the params as defined in CODES is the preferred / common
1320
	 * order they are found in, and inserted by, the editor toolbar.
1321
	 *
1322
	 * @return array
1323
	 */
1324 2
	private function setKeys()
1325
	{
1326 2
		$control_order = array();
1327 2
		$this->tag_possible['regex_keys'] = range(0, $this->tag_possible['regex_size']);
1328
1329
		// Push optional params to the end of the stack but maintain current order of required ones
1330 2
		foreach ($this->tag_possible['regex_keys'] as $index => $info)
1331
		{
1332 2
			$control_order[$index] = $index;
1333
1334 2
			if ($this->tag_possible['optionals'][$index])
1335 2
				$control_order[$index] = $index + $this->tag_possible['regex_size'];
1336 2
		}
1337
1338 2
		array_multisort($control_order, SORT_ASC, $this->tag_possible['regex_cache']);
1339
1340 2
		return $this->tag_possible['regex_keys'];
1341
	}
1342
1343
	/**
1344
	 * Sets the optional parameter search flag only where needed.
1345
	 *
1346
	 * What it does:
1347
	 *
1348
	 * - Sets the optional ()? flag only for optional params that were not actually used
1349
	 * - This makes the permutation function match all required *and* passed parameters
1350
	 * - Returns false if an non optional tag was not found
1351
	 *
1352
	 * @param string $message_stub
1353
	 */
1354 2
	protected function optionalParam($message_stub)
1355
	{
1356
		// Set optional flag only if the param is optional and it was not used in this tag
1357 2
		foreach ($this->tag_possible['optionals'] as $index => $optional)
1358
		{
1359
			// @todo more robust, and slower, check would be a preg_match on $possible['regex_cache'][$index]
1360 2
			$param_exists = stripos($message_stub, $this->tag_possible['param_check'][$index]) !== false;
1361
1362
			// Only make unused optional tags as optional
1363
			if ($optional)
1364 2
			{
1365
				if ($param_exists)
1366 1
					$this->tag_possible['optionals'][$index] = false;
1367
				else
1368 1
					$this->tag_possible['regex_cache'][$index] .= '?';
1369 1
			}
1370 2
		}
1371 2
	}
1372
1373
	/**
1374
	 * Given the position in a message, extracts the tag for analysis
1375
	 *
1376
	 * What it does:
1377
	 *
1378
	 * - Given ' width=100 height=100 alt=image]....[/img]more text and [tags]...'
1379
	 * - Returns ' width=100 height=100 alt=image]....[/img]'
1380
	 *
1381
	 * @return string
1382
	 */
1383 2
	protected function messageStub()
1384
	{
1385
		// For parameter searching, swap in \n's to reduce any regex greediness
1386 2
		$message_stub = str_replace('<br />', "\n", substr($this->message, $this->pos1 - 1)) . "\n";
1387
1388
		// Attempt to pull out just this tag
1389 2
		if (preg_match('~^(?:.+?)\](?>.|(?R))*?\[\/' . $this->tag_possible[Codes::ATTR_TAG] . '\](?:.|\s)~i', $message_stub, $matches) === 1)
1390 2
		{
1391 2
			$message_stub = $matches[0];
1392 2
		}
1393
1394 2
		return $message_stub;
1395
	}
1396
1397
	/**
1398
	 * Recursively call the parser with a new Codes object
1399
	 * This allows to parse BBC in parameters like [quote author="[url]www.quotes.com[/url]"]Something famous.[/quote]
1400
	 *
1401
	 * @param string $data
1402
	 * @param array $tag
1403
	 */
1404 1
	protected function recursiveParser($data, array $tag)
1405
	{
1406
		// @todo if parsed tags allowed is empty, return?
1407 1
		$bbc = clone $this->bbc;
1408
1409 1
		if (!empty($tag[Codes::ATTR_PARSED_TAGS_ALLOWED]))
1410 1
		{
1411 1
			$bbc->setParsedTags($tag[Codes::ATTR_PARSED_TAGS_ALLOWED]);
1412 1
		}
1413
1414
		// Do not use $this->autolinker. For some reason it causes a recursive loop
1415 1
		$autolinker = null;
1416 1
		$html = null;
1417 1
		call_integration_hook('integrate_recursive_bbc_parser', array(&$autolinker, &$html));
1418
1419 1
		$parser = new \BBC\BBCParser($bbc, $autolinker, $html);
1420
1421 1
		return $parser->enableSmileys(empty($tag[Codes::ATTR_PARSED_TAGS_ALLOWED]))->parse($data);
1422
	}
1423
1424
	/**
1425
	 * Return the BBC codes in the system
1426
	 *
1427
	 * @return array
1428
	 */
1429
	public function getBBC()
1430
	{
1431
		return $this->bbc_codes;
1432
	}
1433
1434
	/**
1435
	 * Enable the parsing of smileys
1436
	 *
1437
	 * @param boolean $enable
1438
	 *
1439
	 * @return $this
1440
	 */
1441 1
	public function enableSmileys($enable = true)
1442
	{
1443 1
		$this->do_smileys = (bool) $enable;
1444
1445 1
		return $this;
1446
	}
1447
1448
	/**
1449
	 * Open a tag
1450
	 *
1451
	 * @param array $tag
1452
	 */
1453 3
	protected function addOpenTag(array $tag)
1454
	{
1455 3
		$this->open_tags[] = $tag;
1456 3
	}
1457
1458
	/**
1459
	 * @param string|bool $tag = false False closes the last open tag. Anything else finds that tag LIFO
1460
	 *
1461
	 * @return mixed
1462
	 */
1463 5
	protected function closeOpenedTag($tag = false)
1464
	{
1465 5
		if ($tag === false)
1466 5
		{
1467 5
			return array_pop($this->open_tags);
1468
		}
1469
		elseif (isset($this->open_tags[$tag]))
1470
		{
1471
			$return = $this->open_tags[$tag];
1472
			unset($this->open_tags[$tag]);
1473
1474
			return $return;
1475
		}
1476
	}
1477
1478
	/**
1479
	 * Check if there are any tags that are open
1480
	 *
1481
	 * @return bool
1482
	 */
1483 4
	protected function hasOpenTags()
1484
	{
1485 4
		return !empty($this->open_tags);
1486
	}
1487
1488
	/**
1489
	 * Get the last opened tag
1490
	 *
1491
	 * @return string
1492
	 */
1493 2
	protected function getLastOpenedTag()
1494
	{
1495 2
		return end($this->open_tags);
1496
	}
1497
1498
	/**
1499
	 * Get the currently opened tags
1500
	 *
1501
	 * @param bool|false $tags_only True if you want just the tag or false for the whole code
1502
	 *
1503
	 * @return array
1504
	 */
1505 3
	protected function getOpenedTags($tags_only = false)
1506
	{
1507 3
		if (!$tags_only)
1508 3
		{
1509 3
			return $this->open_tags;
1510
		}
1511
1512
		$tags = array();
1513
		foreach ($this->open_tags as $tag)
1514
		{
1515
			$tags[] = $tag[Codes::ATTR_TAG];
1516
		}
1517
1518
		return $tags;
1519
	}
1520
1521
	/**
1522
	 * Does what it says, removes whitespace
1523
	 *
1524
	 * @param null|int $offset = null
1525
	 */
1526 2
	protected function trimWhiteSpace($offset = null)
1527
	{
1528 2
		if (preg_match('~(<br />|&nbsp;|\s)*~', $this->message, $matches, null, $offset) !== 0 && isset($matches[0]) && $matches[0] !== '')
1529 2
		{
1530
			$this->message = substr_replace($this->message, '', $this->pos, strlen($matches[0]));
1531
		}
1532 2
	}
1533
1534
	/**
1535
	 * @param array $possible
1536
	 * @param array $matches
1537
	 *
1538
	 * @return array
1539
	 */
1540 1
	protected function setupTagParameters(array $possible, array $matches)
1541
	{
1542 1
		$params = array();
1543 1
		for ($i = 1, $n = count($matches); $i < $n; $i += 2)
1544
		{
1545 1
			$key = strtok(ltrim($matches[$i]), '=');
1546
1547 1
			if (isset($possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALUE]))
1548 1
			{
1549 1
				$params['{' . $key . '}'] = strtr($possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALUE], array('$1' => $matches[$i + 1]));
1550 1
			}
1551 1
			elseif (isset($possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALIDATE]))
1552
			{
1553 1
				$params['{' . $key . '}'] = $possible[Codes::ATTR_PARAM][$key][Codes::PARAM_ATTR_VALIDATE]($matches[$i + 1]);
1554 1
			}
1555
			else
1556
			{
1557 1
				$params['{' . $key . '}'] = $matches[$i + 1];
1558
			}
1559
1560
			// Just to make sure: replace any $ or { so they can't interpolate wrongly.
1561 1
			$params['{' . $key . '}'] = str_replace(array('$', '{'), array('&#036;', '&#123;'), $params['{' . $key . '}']);
1562 1
		}
1563
1564 1
		foreach ($possible[Codes::ATTR_PARAM] as $p => $info)
1565
		{
1566 1
			if (!isset($params['{' . $p . '}']))
1567 1
			{
1568 1
				$params['{' . $p . '}'] = '';
1569 1
			}
1570 1
		}
1571
1572
		// We found our tag
1573 1
		$tag = $possible;
1574
1575
		// Put the parameters into the string.
1576 1
		if (isset($tag[Codes::ATTR_BEFORE]))
1577 1
		{
1578 1
			$tag[Codes::ATTR_BEFORE] = strtr($tag[Codes::ATTR_BEFORE], $params);
1579 1
		}
1580
1581 1
		if (isset($tag[Codes::ATTR_AFTER]))
1582 1
		{
1583 1
			$tag[Codes::ATTR_AFTER] = strtr($tag[Codes::ATTR_AFTER], $params);
1584 1
		}
1585
1586 1
		if (isset($tag[Codes::ATTR_CONTENT]))
1587 1
		{
1588 1
			$tag[Codes::ATTR_CONTENT] = strtr($tag[Codes::ATTR_CONTENT], $params);
1589 1
		}
1590
1591 1
		$this->pos1 += strlen($matches[0]) - 1;
1592
1593 1
		return $tag;
1594
	}
1595
1596
	/**
1597
	 * Check if a tag (not a code) is open
1598
	 *
1599
	 * @param string $tag
1600
	 *
1601
	 * @return bool
1602
	 */
1603
	protected function isOpen($tag)
1604
	{
1605
		foreach ($this->open_tags as $open)
1606
		{
1607
			if ($open[Codes::ATTR_TAG] === $tag)
1608
			{
1609
				return true;
1610
			}
1611
		}
1612
1613
		return false;
1614
	}
1615
1616
	/**
1617
	 * Check if a character is an item code
1618
	 *
1619
	 * @param string $char
1620
	 *
1621
	 * @return bool
1622
	 */
1623 4
	protected function isItemCode($char)
1624
	{
1625 4
		return isset($this->item_codes[$char]);
1626
	}
1627
1628
	/**
1629
	 * Close any open codes that aren't block level.
1630
	 * Used before opening a code that *is* block level
1631
	 */
1632 2
	protected function closeNonBlockLevel()
1633
	{
1634 2
		$n = count($this->open_tags) - 1;
1635 2
		while (empty($this->open_tags[$n][Codes::ATTR_BLOCK_LEVEL]) && $n >= 0)
1636
		{
1637
			$n--;
1638
		}
1639
1640
		// Close all the non block level tags so this tag isn't surrounded by them.
1641 2
		for ($i = count($this->open_tags) - 1; $i > $n; $i--)
1642
		{
1643
			$tmp = $this->noSmileys($this->open_tags[$i][Codes::ATTR_AFTER]);
1644
			$this->message = substr_replace($this->message, $tmp, $this->pos, 0);
1645
			$ot_strlen = strlen($tmp);
1646
			$this->pos += $ot_strlen;
1647
			$this->pos1 += $ot_strlen;
1648
1649
			// Trim or eat trailing stuff... see comment at the end of the big loop.
1650
			if (!empty($this->open_tags[$i][Codes::ATTR_BLOCK_LEVEL]) && substr_compare($this->message, '<br />', $this->pos, 6) === 0)
1651
			{
1652
				$this->message = substr_replace($this->message, '', $this->pos, 6);
1653
			}
1654
1655
			if (isset($tag[Codes::ATTR_TRIM]) && $tag[Codes::ATTR_TRIM] !== Codes::TRIM_INSIDE)
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $tag seems to never exist and therefore isset should always be false.
Loading history...
1656
			{
1657
				$this->trimWhiteSpace($this->pos);
1658
			}
1659
1660
			$this->closeOpenedTag();
1661
		}
1662 2
	}
1663
1664
	/**
1665
	 * Add markers around a string to denote that smileys should not be parsed
1666
	 *
1667
	 * @param string $string
1668
	 *
1669
	 * @return string
1670
	 */
1671 3
	protected function noSmileys($string)
1672
	{
1673 3
		return $this->smiley_marker . $string . $this->smiley_marker;
1674
	}
1675
1676
	/**
1677
	 * Checks if we can cache, some codes prevent this and require parsing each time
1678
	 *
1679
	 * @return bool
1680
	 */
1681
	public function canCache()
1682
	{
1683
		return $this->can_cache;
1684
	}
1685
1686
	/**
1687
	 * This is just so I can profile it.
1688
	 *
1689
	 * @param array $tag
1690
	 * @param $data
1691
	 */
1692 3
	protected function filterData(array $tag, &$data)
1693
	{
1694 3
		$tag[Codes::ATTR_VALIDATE]($tag, $data, $this->bbc->getDisabled());
1695 3
	}
1696
}
1697