Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
TagCollector
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
3 / 3
10
100.00% covered (success)
100.00%
1 / 1
 submitTag
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 startParse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getTagsSeen
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2namespace MediaWiki\Extension\Hashtags;
3
4use LogicException;
5use MediaWiki\CommentFormatter\CommentParserFactory;
6
7/**
8 * @class
9 *
10 * This is a bit of a hack.
11 *
12 * We want to be able to get all the tags seen in an edit summary.
13 * It is unclear how best to implement this. We have the following
14 * restrictions:
15 *
16 *  * We want our HashtagCommentParser to essentially decorate the
17 *  core class by wrapping and forwarding methods
18 *  * It should be possible for other extensions to decorate in
19 *  a similar way
20 *  * We have no idea if the service class we get is an instance
21 *  of our class, it could be an instance from a different extension
22 *  extending it in the same way (Maybe an alternative would be to
23 *  use class_alis to dynamically specify that we extend whatever
24 *  class we get from Wikimedia Services, and then we have instaneof
25 *  and a class hierarchy that makes sense. However that kind of requires
26 *  us to know about the constructor of the unknown class)
27 *  ** Hence we cannot add any methods, because we don't know if the
28 *  class we get will have them.
29 *  * We do not want to manually construct the base class at any time
30 *  since constructors are very unstable in MW. This means we cannot
31 *  just make our own instance since we always want it to wrap an
32 *  existing base instance that someone else made.
33 *  * We want to make sure that the tags we return are actually the
34 *  ones we would actually detect (including in the event that a
35 *  different decorator modifies the input).
36 *
37 * So in short, we want to add a method to HashtagCommentParser to
38 * extract some specific data during parsing, but we never know if
39 * the instance we have will actually expose that method.
40 *
41 * One solution might be to simply wrap an additional time. If things
42 * are indempotent, then that will probably work, we just decorate it
43 * multiple times. This seems hacky though.
44 *
45 * The solution we go with here is to have another class which acts sort
46 * of like a log collector. When HashtagCommentParser is running, it sends
47 * the list of tags to our collector class. If the particular instanace has
48 * been pre-registered, then the collector saves this data, and returns it
49 * on a later method call. A bit hacky, and a bit icky in terms of data flow
50 * control, but it seems like the best of bad options.
51 */
52class TagCollector {
53
54    private array $tags = [];
55    private bool $lock = false;
56    private ?HashtagCommentParser $mostRecentParser = null;
57
58    /**
59     * This is very much not reenterant. Should only be called by
60     * HashtagCommentParser.
61     *
62     * @param HashtagCommentParser $parser To verify we are collecting tags from
63     *  the instance we think we are.
64     * @param string $tag Name of tag, including prefix.
65     * @private
66     */
67    public function submitTag( HashtagCommentParser $parser, string $tag ) {
68        if ( !$this->lock ) {
69            // We aren't collecting tags
70            return;
71        }
72        if ( $parser !== $this->mostRecentParser ) {
73            throw new LogicException( "tag parsing loop" );
74        }
75        $this->tags[] = $tag;
76    }
77
78    /**
79     * Only called by HashtagCommentParser. Used to make sure that
80     * our code is actually executing at all.
81     *
82     * @param HashtagCommentParser $parser The parser running our code. May
83     *  not be the original isntance we call preprocess on, since that may
84     *  be decorated.
85     * @private
86     */
87    public function startParse( HashtagCommentParser $parser ) {
88        if ( !$this->lock ) {
89            // Not collecting tags, so no-op
90            return;
91        }
92        if ( $this->mostRecentParser !== null ) {
93            throw new LogicException( "Edit tag parsing already started" );
94        }
95        $this->mostRecentParser = $parser;
96    }
97
98    /**
99     * Primary entrypoint. Call this to get tags from an edit summary
100     *
101     * @param CommentParserFactory $parserFactory (May or may not be HashtagCommentParserFactory
102     *  but output must wrap a HashtagCommentParser at the very least).
103     * @param string $summary The edit summary to look at
104     * @return array List of tags
105     */
106    public function getTagsSeen( CommentParserFactory $parserFactory, string $summary ) {
107        if ( $this->lock || $this->mostRecentParser !== null ) {
108            throw new LogicException( __METHOD__ . " is not reenterant" );
109        }
110        // It is important we always create a fresh parser and do not reuse.
111        $parser = $parserFactory->create();
112        $this->lock = true;
113        // Remember that $parser is not neccessarily the same as $this->mostRecentParser
114        $parser->preprocess( $summary );
115
116        if ( $this->mostRecentParser === null ) {
117            throw new LogicException( "CommentParser never called startParse" );
118        }
119        $tags = array_unique( $this->tags );
120        $this->tags = [];
121        $this->lock = false;
122        $this->mostRecentParser = null;
123        return $tags;
124    }
125
126}