1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\event;
15
16/**
17* Class php_exporter
18* Crawls through a list of files and grabs all php-events
19*/
20class php_exporter
21{
22	/** @var string Path where we look for files*/
23	protected $path;
24
25	/** @var string phpBB Root Path */
26	protected $root_path;
27
28	/** @var string The minimum version for the events to return */
29	protected $min_version;
30
31	/** @var string The maximum version for the events to return */
32	protected $max_version;
33
34	/** @var string */
35	protected $current_file;
36
37	/** @var string */
38	protected $current_event;
39
40	/** @var int */
41	protected $current_event_line;
42
43	/** @var array */
44	protected $events;
45
46	/** @var array */
47	protected $file_lines;
48
49	/**
50	* @param string $phpbb_root_path
51	* @param mixed $extension	String 'vendor/ext' to filter, null for phpBB core
52	* @param string $min_version
53	* @param string $max_version
54	*/
55	public function __construct($phpbb_root_path, $extension = null, $min_version = null, $max_version = null)
56	{
57		$this->root_path = $phpbb_root_path;
58		$this->path = $phpbb_root_path;
59		$this->events = $this->file_lines = array();
60		$this->current_file = $this->current_event = '';
61		$this->current_event_line = 0;
62		$this->min_version = $min_version;
63		$this->max_version = $max_version;
64
65		$this->path = $this->root_path;
66		if ($extension)
67		{
68			$this->path .= 'ext/' . $extension . '/';
69		}
70	}
71
72	/**
73	* Get the list of all events
74	*
75	* @return array		Array with events: name => details
76	*/
77	public function get_events()
78	{
79		return $this->events;
80	}
81
82	/**
83	* Set current event data
84	*
85	* @param string	$name	Name of the current event (used for error messages)
86	* @param int	$line	Line where the current event is placed in
87	* @return null
88	*/
89	public function set_current_event($name, $line)
90	{
91		$this->current_event = $name;
92		$this->current_event_line = $line;
93	}
94
95	/**
96	* Set the content of this file
97	*
98	* @param array $content		Array with the lines of the file
99	* @return null
100	*/
101	public function set_content($content)
102	{
103		$this->file_lines = $content;
104	}
105
106	/**
107	* Crawl the phpBB/ directory for php events
108	* @return int	The number of events found
109	*/
110	public function crawl_phpbb_directory_php()
111	{
112		$files = $this->get_recursive_file_list();
113		$this->events = array();
114		foreach ($files as $file)
115		{
116			$this->crawl_php_file($file);
117		}
118		ksort($this->events);
119
120		return count($this->events);
121	}
122
123	/**
124	* Returns a list of files in $dir
125	*
126	* @return	array	List of files (including the path)
127	*/
128	public function get_recursive_file_list()
129	{
130		try
131		{
132			$iterator = new \RecursiveIteratorIterator(
133				new \phpbb\event\recursive_event_filter_iterator(
134					new \RecursiveDirectoryIterator(
135						$this->path,
136						\FilesystemIterator::SKIP_DOTS
137					),
138					$this->path
139				),
140				\RecursiveIteratorIterator::LEAVES_ONLY
141			);
142		}
143		catch (\Exception $e)
144		{
145			return array();
146		}
147
148		$files = array();
149		foreach ($iterator as $file_info)
150		{
151			/** @var \RecursiveDirectoryIterator $file_info */
152			$relative_path = $iterator->getInnerIterator()->getSubPathname();
153			$files[] = str_replace(DIRECTORY_SEPARATOR, '/', $relative_path);
154		}
155
156		return $files;
157	}
158
159	/**
160	* Format the php events as a wiki table
161	*
162	* @param string $action
163	* @return string
164	*/
165	public function export_events_for_wiki($action = '')
166	{
167		if ($action === 'diff')
168		{
169			$wiki_page = '=== PHP Events (Hook Locations) ===' . "\n";
170		}
171		else
172		{
173			$wiki_page = '= PHP Events (Hook Locations) =' . "\n";
174		}
175		$wiki_page .= '{| class="sortable zebra" cellspacing="0" cellpadding="5"' . "\n";
176		$wiki_page .= '! Identifier !! Placement !! Arguments !! Added in Release !! Explanation' . "\n";
177		foreach ($this->events as $event)
178		{
179			$wiki_page .= '|- id="' . $event['event'] . '"' . "\n";
180			$wiki_page .= '| [[#' . $event['event'] . '|' . $event['event'] . ']] || ' . $event['file'] . ' || ' . implode(', ', $event['arguments']) . ' || ' . $event['since'] . ' || ' . $event['description'] . "\n";
181		}
182		$wiki_page .= '|}' . "\n";
183
184		return $wiki_page;
185	}
186
187	/**
188	* @param string $file
189	* @return int Number of events found in this file
190	* @throws \LogicException
191	*/
192	public function crawl_php_file($file)
193	{
194		$this->current_file = $file;
195		$this->file_lines = array();
196		$content = file_get_contents($this->path . $this->current_file);
197		$num_events_found = 0;
198
199		if (strpos($content, 'dispatcher->trigger_event(') || strpos($content, 'dispatcher->dispatch('))
200		{
201			$this->set_content(explode("\n", $content));
202			for ($i = 0, $num_lines = count($this->file_lines); $i < $num_lines; $i++)
203			{
204				$event_line = false;
205				$found_trigger_event = strpos($this->file_lines[$i], 'dispatcher->trigger_event(');
206				$arguments = array();
207				if ($found_trigger_event !== false)
208				{
209					$event_line = $i;
210					$this->set_current_event($this->get_event_name($event_line, false), $event_line);
211
212					// Find variables of the event
213					$arguments = $this->get_vars_from_array();
214					$doc_vars = $this->get_vars_from_docblock();
215					$this->validate_vars_docblock_array($arguments, $doc_vars);
216				}
217				else
218				{
219					$found_dispatch = strpos($this->file_lines[$i], 'dispatcher->dispatch(');
220					if ($found_dispatch !== false)
221					{
222						$event_line = $i;
223						$this->set_current_event($this->get_event_name($event_line, true), $event_line);
224					}
225				}
226
227				if ($event_line)
228				{
229					// Validate @event
230					$event_line_num = $this->find_event();
231					$this->validate_event($this->current_event, $this->file_lines[$event_line_num]);
232
233					// Validate @since
234					$since_line_num = $this->find_since();
235					$since = $this->validate_since($this->file_lines[$since_line_num]);
236
237					$changed_line_nums = $this->find_changed('changed');
238					if (empty($changed_line_nums))
239					{
240						$changed_line_nums = $this->find_changed('change');
241					}
242					$changed_versions = array();
243					if (!empty($changed_line_nums))
244					{
245						foreach ($changed_line_nums as $changed_line_num)
246						{
247							$changed_versions[] = $this->validate_changed($this->file_lines[$changed_line_num]);
248						}
249					}
250
251					if (!$this->version_is_filtered($since))
252					{
253						$valid_version = false;
254						foreach ($changed_versions as $changed)
255						{
256							$valid_version = $valid_version || $this->version_is_filtered($changed);
257						}
258
259						if (!$valid_version)
260						{
261							continue;
262						}
263					}
264
265					// Find event description line
266					$description_line_num = $this->find_description();
267					$description_lines = array();
268
269					while (true)
270					{
271						$description_line = substr(trim($this->file_lines[$description_line_num]), strlen('*'));
272						$description_line = trim(str_replace("\t", " ", $description_line));
273
274						// Reached end of description if line is a tag
275						if (strlen($description_line) && $description_line[0] == '@')
276						{
277							break;
278						}
279
280						$description_lines[] = $description_line;
281						$description_line_num++;
282					}
283
284					// If there is an empty line between description and first tag, remove it
285					if (!strlen(end($description_lines)))
286					{
287						array_pop($description_lines);
288					}
289
290					$description = trim(implode('<br/>', $description_lines));
291
292					if (isset($this->events[$this->current_event]))
293					{
294						throw new \LogicException("The event '{$this->current_event}' from file "
295							. "'{$this->current_file}:{$event_line_num}' already exists in file "
296							. "'{$this->events[$this->current_event]['file']}'", 10);
297					}
298
299					sort($arguments);
300					$this->events[$this->current_event] = array(
301						'event'			=> $this->current_event,
302						'file'			=> $this->current_file,
303						'arguments'		=> $arguments,
304						'since'			=> $since,
305						'description'	=> $description,
306					);
307					$num_events_found++;
308				}
309			}
310		}
311
312		return $num_events_found;
313	}
314
315	/**
316	 * The version to check
317	 *
318	 * @param string $version
319	 * @return bool
320	 */
321	protected function version_is_filtered($version)
322	{
323		return (!$this->min_version || phpbb_version_compare($this->min_version, $version, '<='))
324			&& (!$this->max_version || phpbb_version_compare($this->max_version, $version, '>='));
325	}
326
327	/**
328	* Find the name of the event inside the dispatch() line
329	*
330	* @param int $event_line
331	* @param bool $is_dispatch Do we look for dispatch() or trigger_event() ?
332	* @return string	Name of the event
333	* @throws \LogicException
334	*/
335	public function get_event_name($event_line, $is_dispatch)
336	{
337		$event_text_line = $this->file_lines[$event_line];
338		$event_text_line = ltrim($event_text_line, "\t ");
339
340		if ($is_dispatch)
341		{
342			$regex = '#\$[a-z](?:[a-z0-9_]|->)*';
343			$regex .= '->dispatch\((\[)?';
344			$regex .= '\'' . $this->preg_match_event_name() . '(?(1)\', \'(?2))+\'';
345			$regex .= '(?(1)\])\);#';
346		}
347		else
348		{
349			$regex = '#extract\(\$[a-z](?:[a-z0-9_]|->)*';
350			$regex .= '->trigger_event\((\[)?';
351			$regex .= '\'' . $this->preg_match_event_name() . '(?(1)\', \'(?2))+\'';
352			$regex .= '(?(1)\]), compact\(\$vars\)\)\);#';
353		}
354
355		$match = array();
356		preg_match($regex, $event_text_line, $match);
357		if (!isset($match[2]))
358		{
359			throw new \LogicException("Can not find event name in line '{$event_text_line}' "
360				. "in file '{$this->current_file}:{$event_line}'", 1);
361		}
362
363		return $match[2];
364	}
365
366	/**
367	* Returns a regex match for the event name
368	*
369	* @return string
370	*/
371	protected function preg_match_event_name()
372	{
373		return '([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)';
374	}
375
376	/**
377	* Find the $vars array
378	*
379	* @return array		List of variables
380	* @throws \LogicException
381	*/
382	public function get_vars_from_array()
383	{
384		$line = ltrim($this->file_lines[$this->current_event_line - 1], "\t");
385		if ($line === ');' || $line === '];')
386		{
387			$vars_array = $this->get_vars_from_multi_line_array();
388		}
389		else
390		{
391			$vars_array = $this->get_vars_from_single_line_array($line);
392		}
393
394		foreach ($vars_array as $var)
395		{
396			if (!preg_match('#^[a-z_][a-z0-9_]*$#i', $var))
397			{
398				throw new \LogicException("Found invalid var '{$var}' in array for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 3);
399			}
400		}
401
402		sort($vars_array);
403		return $vars_array;
404	}
405
406	/**
407	* Find the variables in single line array
408	*
409	* @param	string	$line
410	* @param	bool	$throw_multiline	Throw an exception when there are too
411	*										many arguments in one line.
412	* @return array		List of variables
413	* @throws \LogicException
414	*/
415	public function get_vars_from_single_line_array($line, $throw_multiline = true)
416	{
417		$match = array();
418		preg_match('#^\$vars = (?:(\[)|array\()\'([a-z0-9_\' ,]+)\'(?(1)\]|\));$#i', $line, $match);
419
420		if (isset($match[2]))
421		{
422			$vars_array = explode("', '", $match[2]);
423			if ($throw_multiline && count($vars_array) > 6)
424			{
425				throw new \LogicException('Should use multiple lines for $vars definition '
426					. "for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
427			}
428			return $vars_array;
429		}
430		else
431		{
432			throw new \LogicException("Can not find '\$vars = array();'-line for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
433		}
434	}
435
436	/**
437	* Find the variables in single line array
438	*
439	* @return array		List of variables
440	* @throws \LogicException
441	*/
442	public function get_vars_from_multi_line_array()
443	{
444		$current_vars_line = 2;
445		$var_lines = array();
446		while (!in_array(ltrim($this->file_lines[$this->current_event_line - $current_vars_line], "\t"), ['$vars = array(', '$vars = [']))
447		{
448			$var_lines[] = substr(trim($this->file_lines[$this->current_event_line - $current_vars_line]), 0, -1);
449
450			$current_vars_line++;
451			if ($current_vars_line > $this->current_event_line)
452			{
453				// Reached the start of the file
454				throw new \LogicException("Can not find end of \$vars array for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
455			}
456		}
457
458		return $this->get_vars_from_single_line_array('$vars = array(' . implode(", ", $var_lines) . ');', false);
459	}
460
461	/**
462	* Find the $vars array
463	*
464	* @return array		List of variables
465	* @throws \LogicException
466	*/
467	public function get_vars_from_docblock()
468	{
469		$doc_vars = array();
470		$current_doc_line = 1;
471		$found_comment_end = false;
472		while (ltrim($this->file_lines[$this->current_event_line - $current_doc_line], "\t") !== '/**')
473		{
474			if (ltrim($this->file_lines[$this->current_event_line - $current_doc_line], "\t ") === '*/')
475			{
476				$found_comment_end = true;
477			}
478
479			if ($found_comment_end)
480			{
481				$var_line = trim($this->file_lines[$this->current_event_line - $current_doc_line]);
482				$var_line = preg_replace('!\s+!', ' ', $var_line);
483				if (strpos($var_line, '* @var ') === 0)
484				{
485					$doc_line = explode(' ', $var_line, 5);
486					if (count($doc_line) !== 5)
487					{
488						throw new \LogicException("Found invalid line '{$this->file_lines[$this->current_event_line - $current_doc_line]}' "
489						. "for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
490					}
491					$doc_vars[] = $doc_line[3];
492				}
493			}
494
495			$current_doc_line++;
496			if ($current_doc_line > $this->current_event_line)
497			{
498				// Reached the start of the file
499				throw new \LogicException("Can not find end of docblock for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
500			}
501		}
502
503		if (empty($doc_vars))
504		{
505			// Reached the start of the file
506			throw new \LogicException("Can not find @var lines for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 3);
507		}
508
509		foreach ($doc_vars as $var)
510		{
511			if (!preg_match('#^[a-z_][a-z0-9_]*$#i', $var))
512			{
513				throw new \LogicException("Found invalid @var '{$var}' in docblock for event "
514					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 4);
515			}
516		}
517
518		sort($doc_vars);
519		return $doc_vars;
520	}
521
522	/**
523	* Find the "@since" Information line
524	*
525	* @return int Absolute line number
526	* @throws \LogicException
527	*/
528	public function find_since()
529	{
530		return $this->find_tag('since', array('event', 'var'));
531	}
532
533	/**
534	* Find the "@changed" Information lines
535	*
536	* @param string $tag_name Should be 'change', not 'changed'
537	* @return array Absolute line numbers
538	* @throws \LogicException
539	*/
540	public function find_changed($tag_name)
541	{
542		$lines = array();
543		$last_line = 0;
544		try
545		{
546			while ($line = $this->find_tag($tag_name, array('since'), $last_line))
547			{
548				$lines[] = $line;
549				$last_line = $line;
550			}
551		}
552		catch (\LogicException $e)
553		{
554			// Not changed? No problem!
555		}
556
557		return $lines;
558	}
559
560	/**
561	* Find the "@event" Information line
562	*
563	* @return int Absolute line number
564	*/
565	public function find_event()
566	{
567		return $this->find_tag('event', array());
568	}
569
570	/**
571	* Find a "@*" Information line
572	*
573	* @param string $find_tag		Name of the tag we are trying to find
574	* @param array $disallowed_tags		List of tags that must not appear between
575	*									the tag and the actual event
576	* @param int $skip_to_line		Skip lines until this one
577	* @return int Absolute line number
578	* @throws \LogicException
579	*/
580	public function find_tag($find_tag, $disallowed_tags, $skip_to_line = 0)
581	{
582		$find_tag_line = $skip_to_line ? $this->current_event_line - $skip_to_line + 1 : 0;
583		$found_comment_end = ($skip_to_line) ? true : false;
584		while (strpos(ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t "), '* @' . $find_tag . ' ') !== 0)
585		{
586			if ($found_comment_end && ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t") === '/**')
587			{
588				// Reached the start of this doc block
589				throw new \LogicException("Can not find '@{$find_tag}' information for event "
590					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
591			}
592
593			foreach ($disallowed_tags as $disallowed_tag)
594			{
595				if ($found_comment_end && strpos(ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t "), '* @' . $disallowed_tag) === 0)
596				{
597					// Found @var after the @since
598					throw new \LogicException("Found '@{$disallowed_tag}' information after '@{$find_tag}' for event "
599						. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 3);
600				}
601			}
602
603			if (ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t ") === '*/')
604			{
605				$found_comment_end = true;
606			}
607
608			$find_tag_line++;
609			if ($find_tag_line >= $this->current_event_line)
610			{
611				// Reached the start of the file
612				throw new \LogicException("Can not find '@{$find_tag}' information for event "
613					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
614			}
615		}
616
617		return $this->current_event_line - $find_tag_line;
618	}
619
620	/**
621	* Find a "@*" Information line
622	*
623	* @return int Absolute line number
624	* @throws \LogicException
625	*/
626	public function find_description()
627	{
628		$find_desc_line = 0;
629		while (ltrim($this->file_lines[$this->current_event_line - $find_desc_line], "\t") !== '/**')
630		{
631			$find_desc_line++;
632			if ($find_desc_line > $this->current_event_line)
633			{
634				// Reached the start of the file
635				throw new \LogicException("Can not find a description for event "
636					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
637			}
638		}
639
640		$find_desc_line = $this->current_event_line - $find_desc_line + 1;
641
642		$desc = trim($this->file_lines[$find_desc_line]);
643		if (strpos($desc, '* @') === 0 || $desc[0] !== '*' || substr($desc, 1) == '')
644		{
645			// First line of the doc block is a @-line, empty or only contains "*"
646			throw new \LogicException("Can not find a description for event "
647				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
648		}
649
650		return $find_desc_line;
651	}
652
653	/**
654	* Validate "@since" Information
655	*
656	* @param string $line
657	* @return string
658	* @throws \LogicException
659	*/
660	public function validate_since($line)
661	{
662		$match = array();
663		preg_match('#^\* @since (\d+\.\d+\.\d+(?:-(?:a|b|RC|pl)\d+)?)$#', ltrim($line, "\t "), $match);
664		if (!isset($match[1]))
665		{
666			throw new \LogicException("Invalid '@since' information for event "
667				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'");
668		}
669
670		return $match[1];
671	}
672
673	/**
674	* Validate "@changed" Information
675	*
676	* @param string $line
677	* @return string
678	* @throws \LogicException
679	*/
680	public function validate_changed($line)
681	{
682		$match = array();
683		$line = str_replace("\t", ' ', ltrim($line, "\t "));
684		preg_match('#^\* @changed (\d+\.\d+\.\d+(?:-(?:a|b|RC|pl)\d+)?)( (?:.*))?$#', $line, $match);
685		if (!isset($match[2]))
686		{
687			throw new \LogicException("Invalid '@changed' information for event "
688				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'");
689		}
690
691		return $match[2];
692	}
693
694	/**
695	* Validate "@event" Information
696	*
697	* @param string $event_name
698	* @param string $line
699	* @return string
700	* @throws \LogicException
701	*/
702	public function validate_event($event_name, $line)
703	{
704		$event = substr(ltrim($line, "\t "), strlen('* @event '));
705
706		if ($event !== trim($event))
707		{
708			throw new \LogicException("Invalid '@event' information for event "
709				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
710		}
711
712		if ($event !== $event_name)
713		{
714			throw new \LogicException("Event name does not match '@event' tag for event "
715				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
716		}
717
718		return $event;
719	}
720
721	/**
722	* Validates that two arrays contain the same strings
723	*
724	* @param array $vars_array		Variables found in the array line
725	* @param array $vars_docblock	Variables found in the doc block
726	* @return null
727	* @throws \LogicException
728	*/
729	public function validate_vars_docblock_array($vars_array, $vars_docblock)
730	{
731		$vars_array = array_unique($vars_array);
732		$vars_docblock = array_unique($vars_docblock);
733		$sizeof_vars_array = count($vars_array);
734
735		if ($sizeof_vars_array !== count($vars_docblock) || $sizeof_vars_array !== count(array_intersect($vars_array, $vars_docblock)))
736		{
737			throw new \LogicException("\$vars array does not match the list of '@var' tags for event "
738				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'");
739		}
740	}
741}
742