Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.30% covered (danger)
47.30%
114 / 241
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
md_exporter
47.30% covered (danger)
47.30%
114 / 241
17.65% covered (danger)
17.65%
3 / 17
1248.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 get_events
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 crawl_phpbb_directory_adm
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 crawl_phpbb_directory_styles
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 crawl_eventsmd
91.38% covered (success)
91.38%
53 / 58
0.00% covered (danger)
0.00%
0 / 1
19.23
 version_is_filtered
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 export_events_for_wiki
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 export_events_for_rst
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 export_events_for_bbcode
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 validate_event_name
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 validate_since
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 validate_changed
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 validate_version
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate_file_list
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
14.07
 crawl_file_for_events
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 validate_events_from_file
52.00% covered (warning)
52.00%
13 / 25
0.00% covered (danger)
0.00%
0 / 1
24.38
 get_recursive_file_list
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
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* Crawls through a markdown file and grabs all events
18*/
19class md_exporter
20{
21    /** @var string Path where we look for files*/
22    protected $path;
23
24    /** @var string phpBB Root Path */
25    protected $root_path;
26
27    /** @var string The minimum version for the events to return */
28    protected $min_version;
29
30    /** @var string The maximum version for the events to return */
31    protected $max_version;
32
33    /** @var string */
34    protected $filter;
35
36    /** @var string */
37    protected $current_event;
38
39    /** @var array */
40    protected $events;
41
42    /** @var array */
43    protected $events_by_file;
44
45    /**
46    * @param string $phpbb_root_path
47    * @param mixed $extension    String 'vendor/ext' to filter, null for phpBB core
48    * @param string $min_version
49    * @param string $max_version
50    */
51    public function __construct($phpbb_root_path, $extension = null, $min_version = null, $max_version = null)
52    {
53        $this->root_path = $phpbb_root_path;
54        $this->path = $this->root_path;
55        if ($extension)
56        {
57            $this->path .= 'ext/' . $extension . '/';
58        }
59
60        $this->events = array();
61        $this->events_by_file = array();
62        $this->filter = $this->current_event = '';
63        $this->min_version = $min_version;
64        $this->max_version = $max_version;
65    }
66
67    /**
68    * Get the list of all events
69    *
70    * @return array        Array with events: name => details
71    */
72    public function get_events()
73    {
74        return $this->events;
75    }
76
77    /**
78    * @param string $md_file    Relative from phpBB root
79    * @return int        Number of events found
80    * @throws \LogicException
81    */
82    public function crawl_phpbb_directory_adm($md_file)
83    {
84        $this->crawl_eventsmd($md_file, 'adm');
85
86        $file_list = $this->get_recursive_file_list($this->path  . 'adm/style/');
87        foreach ($file_list as $file)
88        {
89            $file_name = 'adm/style/' . $file;
90            $this->validate_events_from_file($file_name, $this->crawl_file_for_events($file_name));
91        }
92
93        return count($this->events);
94    }
95
96    /**
97    * @param string $md_file    Relative from phpBB root
98    * @return int        Number of events found
99    * @throws \LogicException
100    */
101    public function crawl_phpbb_directory_styles($md_file)
102    {
103        $this->crawl_eventsmd($md_file, 'styles');
104
105        $styles = array('prosilver');
106        foreach ($styles as $style)
107        {
108            $file_list = $this->get_recursive_file_list(
109                $this->path . 'styles/' . $style . '/template/'
110            );
111
112            foreach ($file_list as $file)
113            {
114                $file_name = 'styles/' . $style . '/template/' . $file;
115                $this->validate_events_from_file($file_name, $this->crawl_file_for_events($file_name));
116            }
117        }
118
119        return count($this->events);
120    }
121
122    /**
123    * @param string $md_file    Relative from phpBB root
124    * @param string $filter        Should be 'styles' or 'adm'
125    * @return int        Number of events found
126    * @throws \LogicException
127    */
128    public function crawl_eventsmd($md_file, $filter)
129    {
130        if (!file_exists($this->path . $md_file))
131        {
132            throw new \LogicException("The event docs file '{$md_file}' could not be found");
133        }
134
135        $file_content = file_get_contents($this->path . $md_file);
136        $this->filter = $filter;
137
138        $events = explode("\n\n", $file_content);
139        foreach ($events as $event)
140        {
141            // Last row of the file
142            if (strpos($event, "\n===\n") === false)
143            {
144                continue;
145            }
146
147            list($event_name, $details) = explode("\n===\n", $event, 2);
148            $this->validate_event_name($event_name);
149            $sorted_events = [$this->current_event, $event_name];
150            natsort($sorted_events);
151            $this->current_event = $event_name;
152
153            if (isset($this->events[$this->current_event]))
154            {
155                throw new \LogicException("The event '{$this->current_event}' is defined multiple times");
156            }
157
158            // Use array_values() to get actual first element and check against natural order
159            if (array_values($sorted_events)[0] === $event_name)
160            {
161                throw new \LogicException("The event '{$sorted_events[1]}' should be defined before '{$sorted_events[0]}'");
162            }
163
164            if (($this->filter == 'adm' && strpos($this->current_event, 'acp_') !== 0)
165                || ($this->filter == 'styles' && strpos($this->current_event, 'acp_') === 0))
166            {
167                continue;
168            }
169
170            list($file_details, $details) = explode("\n* Since: ", $details, 2);
171
172            $changed_versions = [];
173            if (strpos($details, "\n* Changed: ") !== false)
174            {
175                list($since, $details) = explode("\n* Changed: ", $details, 2);
176                while (strpos($details, "\n* Changed: ") !== false)
177                {
178                    list($changed, $details) = explode("\n* Changed: ", $details, 2);
179                    $changed_versions[] = $changed;
180                }
181                list($changed, $description) = explode("\n* Purpose: ", $details, 2);
182                $changed_versions[] = $changed;
183            }
184            else
185            {
186                list($since, $description) = explode("\n* Purpose: ", $details, 2);
187            }
188
189            if (str_contains($since, "\n* Deprecated: "))
190            {
191                list($since, $deprecated) = explode("\n* Deprecated: ", $since, 2);
192            }
193
194            $files = $this->validate_file_list($file_details);
195            $since = $this->validate_since($since);
196            $changes = array();
197            foreach ($changed_versions as $changed)
198            {
199                list($changed_version, $changed_description) = $this->validate_changed($changed);
200
201                if (isset($changes[$changed_version]))
202                {
203                    throw new \LogicException("Duplicate change information found for event '{$this->current_event}'");
204                }
205
206                $changes[$changed_version] = $changed_description;
207            }
208            $description = trim($description, "\n") . "\n";
209
210            if (!$this->version_is_filtered($since))
211            {
212                $is_filtered = false;
213                foreach (array_keys($changes) as $version)
214                {
215                    if ($this->version_is_filtered($version))
216                    {
217                        $is_filtered = true;
218                        break;
219                    }
220                }
221
222                if (!$is_filtered)
223                {
224                    continue;
225                }
226            }
227
228            $this->events[$event_name] = array(
229                'event'            => $this->current_event,
230                'files'            => $files,
231                'since'            => $since,
232                'deprecated'    => $deprecated ?? '',
233                'changed'        => $changes,
234                'description'    => $description,
235            );
236        }
237
238        return count($this->events);
239    }
240
241    /**
242     * The version to check
243     *
244     * @param string $version
245     * @return bool
246     */
247    protected function version_is_filtered($version)
248    {
249        return (!$this->min_version || phpbb_version_compare($this->min_version, $version, '<='))
250        && (!$this->max_version || phpbb_version_compare($this->max_version, $version, '>='));
251    }
252
253    /**
254    * Format the md events as a wiki table
255    *
256    * @param string $action
257    * @return string        Number of events found * @deprecated since 3.2
258    * @deprecated 3.3.5-RC1 (To be removed: 4.0.0-a1)
259    */
260    public function export_events_for_wiki($action = '')
261    {
262        if ($this->filter === 'adm')
263        {
264            if ($action === 'diff')
265            {
266                $wiki_page = '=== ACP Template Events ===' . "\n";
267            }
268            else
269            {
270                $wiki_page = '= ACP Template Events =' . "\n";
271            }
272            $wiki_page .= '{| class="zebra sortable" cellspacing="0" cellpadding="5"' . "\n";
273            $wiki_page .= '! Identifier !! Placement !! Added in Release !! Explanation' . "\n";
274        }
275        else
276        {
277            if ($action === 'diff')
278            {
279                $wiki_page = '=== Template Events ===' . "\n";
280            }
281            else
282            {
283                $wiki_page = '= Template Events =' . "\n";
284            }
285            $wiki_page .= '{| class="zebra sortable" cellspacing="0" cellpadding="5"' . "\n";
286            $wiki_page .= '! Identifier !! Prosilver Placement (If applicable) !! Added in Release !! Explanation' . "\n";
287        }
288
289        foreach ($this->events as $event_name => $event)
290        {
291            $wiki_page .= "|- id=\"{$event_name}\"\n";
292            $wiki_page .= "| [[#{$event_name}|{$event_name}]] || ";
293
294            if ($this->filter === 'adm')
295            {
296                $wiki_page .= implode(', ', $event['files']['adm']);
297            }
298            else
299            {
300                $wiki_page .= implode(', ', $event['files']['prosilver']);
301            }
302
303            $wiki_page .= " || {$event['since']} || " . str_replace("\n", ' ', $event['description']) . "\n";
304        }
305        $wiki_page .= '|}' . "\n";
306
307        return $wiki_page;
308    }
309
310    /**
311     * Format the md events as a rst table
312     *
313     * @param string $action
314     * @return string        Number of events found
315     */
316    public function export_events_for_rst(string $action = ''): string
317    {
318        $rst_exporter = new rst_exporter();
319
320        if ($this->filter === 'adm')
321        {
322            if ($action === 'diff')
323            {
324                $rst_exporter->add_section_header('h3', 'ACP Template Events');
325            }
326            else
327            {
328                $rst_exporter->add_section_header('h2', 'ACP Template Events');
329            }
330
331            $rst_exporter->set_columns([
332                'event'            => 'Identifier',
333                'files'            => 'Placement',
334                'since'            => 'Added in Release',
335                'description'    => 'Explanation',
336            ]);
337        }
338        else
339        {
340            if ($action === 'diff')
341            {
342                $rst_exporter->add_section_header('h3', 'Template Events');
343            }
344            else
345            {
346                $rst_exporter->add_section_header('h2', 'Template Events');
347            }
348
349            $rst_exporter->set_columns([
350                'event'            => 'Identifier',
351                'files'            => 'Prosilver Placement (If applicable)',
352                'since'            => 'Added in Release',
353                'description'    => 'Explanation',
354            ]);
355        }
356
357        $events = [];
358        foreach ($this->events as $event_name => $event)
359        {
360            $files = $this->filter === 'adm' ? implode(', ', $event['files']['adm']) : implode(', ', $event['files']['prosilver']);
361
362            $events[] = [
363                'event'            => $event_name,
364                'files'            => $files,
365                'since'            => $event['since'],
366                'description'    => str_replace("\n", '<br>', rtrim($event['description'])),
367            ];
368        }
369
370        $rst_exporter->generate_events_table($events);
371
372        return $rst_exporter->get_rst_output();
373    }
374
375    /**
376     * Format the md events as BBCode list
377     *
378     * @param string $action
379     * @return string        Events BBCode
380     */
381    public function export_events_for_bbcode(string $action = ''): string
382    {
383        if ($this->filter === 'adm')
384        {
385            if ($action === 'diff')
386            {
387                $bbcode_text = "[size=150]ACP Template Events[/size]\n";
388            }
389            else
390            {
391                $bbcode_text = "[size=200]ACP Template Events[/size]\n";
392            }
393        }
394        else
395        {
396            if ($action === 'diff')
397            {
398                $bbcode_text = "[size=150]Template Events[/size]\n";
399            }
400            else
401            {
402                $bbcode_text = "[size=200]Template Events[/size]\n";
403            }
404        }
405
406        if (!count($this->events))
407        {
408            return $bbcode_text . "[list][*][i]None[/i][/list]\n";
409        }
410
411        foreach ($this->events as $event_name => $event)
412        {
413            $bbcode_text .= "[list]\n";
414            $bbcode_text .= "[*][b]{$event_name}[/b]\n";
415
416            if ($this->filter === 'adm')
417            {
418                $bbcode_text .= "Placement: " . implode(', ', $event['files']['adm']) . "\n";
419            }
420            else
421            {
422                $bbcode_text .= "Prosilver Placement: " . implode(', ', $event['files']['prosilver']) . "\n";
423            }
424
425            $bbcode_text .= "Added in Release: {$event['since']}\n";
426            $bbcode_text .= "Explanation: {$event['description']}\n";
427            $bbcode_text .= "[/list]\n";
428        }
429
430        return $bbcode_text;
431    }
432
433    /**
434    * Validates a template event name
435    *
436    * @param $event_name
437    * @return void
438    * @throws \LogicException
439    */
440    public function validate_event_name($event_name)
441    {
442        if (!preg_match('#^([a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)+)$#', $event_name))
443        {
444            throw new \LogicException("Invalid event name '{$event_name}'");
445        }
446    }
447
448    /**
449    * Validate "Since" Information
450    *
451    * @param string $since
452    * @return string
453    * @throws \LogicException
454    */
455    public function validate_since($since)
456    {
457        if (!$this->validate_version($since))
458        {
459            throw new \LogicException("Invalid since information found for event '{$this->current_event}': {$since}");
460        }
461
462        return $since;
463    }
464
465    /**
466    * Validate "Changed" Information
467    *
468    * @param string $changed
469    * @return array<string, string> Changed information containing version and description in respective order
470    * @psalm-return array{string, string}
471    * @throws \LogicException
472    */
473    public function validate_changed($changed)
474    {
475        if (strpos($changed, ' ') !== false)
476        {
477            list($version, $description) = explode(' ', $changed, 2);
478        }
479        else
480        {
481            $version = $changed;
482            $description = '';
483        }
484
485        if (!$this->validate_version($version))
486        {
487            throw new \LogicException("Invalid changed information found for event '{$this->current_event}'");
488        }
489
490        return [$version, $description];
491    }
492
493    /**
494    * Validate "version" Information
495    *
496    * @param string $version
497    * @return bool True if valid, false otherwise
498    */
499    public function validate_version($version)
500    {
501        return (bool) preg_match('#^\d+\.\d+\.\d+(?:-(?:a|b|RC|pl)\d+)?$#', $version);
502    }
503
504    /**
505    * Validate the files list
506    *
507    * @param string $file_details
508    * @return array
509    * @throws \LogicException
510    */
511    public function validate_file_list($file_details)
512    {
513        $files_list = array(
514            'prosilver'        => array(),
515            'adm'            => array(),
516        );
517
518        // Multi file list
519        if (strpos($file_details, "* Locations:\n    + ") === 0)
520        {
521            $file_details = substr($file_details, strlen("* Locations:\n    + "));
522            $files = explode("\n    + ", $file_details);
523            foreach ($files as $file)
524            {
525                if (!preg_match('#^([^ ]+)( \([0-9]+\))?$#', $file))
526                {
527                    throw new \LogicException("Invalid event instances for file '{$file}' found for event '{$this->current_event}'", 1);
528                }
529
530                list($file) = explode(" ", $file);
531
532                if (!file_exists($this->path . $file) || substr($file, -5) !== '.html')
533                {
534                    throw new \LogicException("Invalid file '{$file}' not found for event '{$this->current_event}'", 2);
535                }
536
537                if (($this->filter !== 'adm') && strpos($file, 'styles/prosilver/template/') === 0)
538                {
539                    $files_list['prosilver'][] = substr($file, strlen('styles/prosilver/template/'));
540                }
541                else if (($this->filter === 'adm') && strpos($file, 'adm/style/') === 0)
542                {
543                    $files_list['adm'][] = substr($file, strlen('adm/style/'));
544                }
545                else
546                {
547                    throw new \LogicException("Invalid file '{$file}' not found for event '{$this->current_event}'", 3);
548                }
549
550                $this->events_by_file[$file][] = $this->current_event;
551            }
552        }
553        else if ($this->filter == 'adm')
554        {
555            $file = substr($file_details, strlen('* Location: '));
556            if (!file_exists($this->path . $file) || substr($file, -5) !== '.html')
557            {
558                throw new \LogicException("Invalid file '{$file}' not found for event '{$this->current_event}'", 1);
559            }
560
561            $files_list['adm'][] =  substr($file, strlen('adm/style/'));
562
563            $this->events_by_file[$file][] = $this->current_event;
564        }
565        else
566        {
567            throw new \LogicException("Invalid file list found for event '{$this->current_event}'", 1);
568        }
569
570        return $files_list;
571    }
572
573    /**
574    * Get all template events in a template file
575    *
576    * @param string $file
577    * @return array
578    * @throws \LogicException
579    */
580    public function crawl_file_for_events($file)
581    {
582        if (!file_exists($this->path . $file))
583        {
584            throw new \LogicException("File '{$file}' does not exist", 1);
585        }
586
587        $event_list = array();
588        $file_content = file_get_contents($this->path . $file);
589
590        preg_match_all('/(?:{%|<!--) EVENT (.*) (?:%}|-->)/U', $file_content, $event_list);
591
592        return $event_list[1];
593    }
594
595    /**
596    * Validates whether all events from $file are in the md file and vice-versa
597    *
598    * @param string $file
599    * @param array $events
600    * @return true
601    * @throws \LogicException
602    */
603    public function validate_events_from_file($file, array $events)
604    {
605        if (empty($this->events_by_file[$file]) && empty($events))
606        {
607            return true;
608        }
609        else if (empty($this->events_by_file[$file]))
610        {
611            $event_list = implode("', '", $events);
612            throw new \LogicException("File '{$file}' should not contain events, but contains: "
613                . "'{$event_list}'", 1);
614        }
615        else if (empty($events))
616        {
617            $event_list = implode("', '", $this->events_by_file[$file]);
618            throw new \LogicException("File '{$file}' contains no events, but should contain: "
619                . "'{$event_list}'", 1);
620        }
621
622        $missing_events_from_file = array();
623        foreach ($this->events_by_file[$file] as $event)
624        {
625            if (!in_array($event, $events))
626            {
627                $missing_events_from_file[] = $event;
628            }
629        }
630
631        if (!empty($missing_events_from_file))
632        {
633            $event_list = implode("', '", $missing_events_from_file);
634            throw new \LogicException("File '{$file}' does not contain events: '{$event_list}'", 2);
635        }
636
637        $missing_events_from_md = array();
638        foreach ($events as $event)
639        {
640            if (!in_array($event, $this->events_by_file[$file]))
641            {
642                $missing_events_from_md[] = $event;
643            }
644        }
645
646        if (!empty($missing_events_from_md))
647        {
648            $event_list = implode("', '", $missing_events_from_md);
649            throw new \LogicException("File '{$file}' contains additional events: '{$event_list}'", 3);
650        }
651
652        return true;
653    }
654
655    /**
656    * Returns a list of files in $dir
657    *
658    * Works recursive with any depth
659    *
660    * @param    string    $dir    Directory to go through
661    * @return    array    List of files (including directories)
662    */
663    public function get_recursive_file_list($dir)
664    {
665        try
666        {
667            $iterator = new \phpbb\finder\recursive_path_iterator(
668                $dir,
669                \RecursiveIteratorIterator::SELF_FIRST
670            );
671        }
672        catch (\Exception $e)
673        {
674            return array();
675        }
676
677        $files = array();
678        foreach ($iterator as $file_info)
679        {
680            /** @var \RecursiveDirectoryIterator $file_info */
681            if ($file_info->isDir())
682            {
683                continue;
684            }
685
686            $relative_path = $iterator->getInnerIterator()->getSubPathname();
687
688            if (substr($relative_path, -5) == '.html')
689            {
690                $files[] = str_replace(DIRECTORY_SEPARATOR, '/', $relative_path);
691            }
692        }
693
694        return $files;
695    }
696}