Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.10% covered (warning)
88.10%
74 / 84
47.06% covered (danger)
47.06%
8 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryFixer
88.10% covered (warning)
88.10%
74 / 84
47.06% covered (danger)
47.06%
8 / 17
54.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getFixablePart
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
8.15
 fix
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 visitWordsQueryNode
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 acceptableString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 visitPhraseQueryNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 visitPhrasePrefixNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitFuzzyNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitPrefixNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitWildcardNode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 visitEmptyQueryNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitKeywordFeatureNode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 visitParsedBooleanNode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 visitBooleanClause
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 visitNegatedNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visitNamespaceHeader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch\Parser\AST\Visitor;
4
5use CirrusSearch\Parser\AST\BooleanClause;
6use CirrusSearch\Parser\AST\EmptyQueryNode;
7use CirrusSearch\Parser\AST\FuzzyNode;
8use CirrusSearch\Parser\AST\KeywordFeatureNode;
9use CirrusSearch\Parser\AST\NamespaceHeaderNode;
10use CirrusSearch\Parser\AST\NegatedNode;
11use CirrusSearch\Parser\AST\ParsedBooleanNode;
12use CirrusSearch\Parser\AST\ParsedNode;
13use CirrusSearch\Parser\AST\ParsedQuery;
14use CirrusSearch\Parser\AST\PhrasePrefixNode;
15use CirrusSearch\Parser\AST\PhraseQueryNode;
16use CirrusSearch\Parser\AST\PrefixNode;
17use CirrusSearch\Parser\AST\WildcardNode;
18use CirrusSearch\Parser\AST\WordsQueryNode;
19use Wikimedia\Assert\Assert;
20use Wikimedia\HtmlArmor\HtmlArmor;
21
22/**
23 * Inspect a query and determine what parts of it can be sent to a typo correction mechanism and
24 * provide a method to fix the query once the corrected substring is known.
25 */
26class QueryFixer implements Visitor {
27    /**
28     * @var \SplObjectStorage
29     */
30    private static $cache;
31
32    /**
33     * @var ParsedQuery
34     */
35    private $parsedQuery;
36
37    /**
38     * @var bool
39     */
40    private $visited = false;
41
42    /**
43     * @var ParsedNode|null
44     */
45    private $node;
46
47    /**
48     * @var bool
49     */
50    private $hasQMarkInWildcard = false;
51
52    /**
53     * @var int
54     */
55    private $currentSize = 0;
56
57    /**
58     * @var bool true when this branch is "negated".
59     */
60    private $inNegation;
61
62    /**
63     * @var bool
64     */
65    private $isComplex = false;
66
67    public function __construct( ParsedQuery $query ) {
68        $this->parsedQuery = $query;
69    }
70
71    /**
72     * @param ParsedQuery $query
73     * @return self
74     */
75    public static function build( ParsedQuery $query ) {
76        if ( self::$cache === null || count( self::$cache ) > 100 ) {
77            // Build the cache for the first time or drop it for a new empty one just in case this class
78            // is used from a maint script that treats/parses millions of queries
79            self::$cache = new \SplObjectStorage();
80        }
81
82        $fixer = self::$cache[$query] ?? null;
83        if ( $fixer === null ) {
84            $fixer = new self( $query );
85            self::$cache[$query] = $fixer;
86        }
87        return $fixer;
88    }
89
90    /**
91     * Get the longest phrase that is subject to typo correction.
92     * It's generally a set of consecutive words.
93     *
94     * @return string|null
95     */
96    public function getFixablePart() {
97        if ( !$this->visited ) {
98            $this->visited = true;
99            $this->parsedQuery->getRoot()->accept( $this );
100        }
101
102        if ( $this->isComplex ) {
103            $this->node = null;
104        }
105
106        if ( $this->hasQMarkInWildcard && $this->parsedQuery->hasCleanup( ParsedQuery::CLEANUP_QMARK_STRIPPING ) ) {
107            // We may not be able to reconstruct this kind of queries properly
108            // If a question mark is legimetely removed we agree that it's OK to present the user
109            // with its original query minus the question marks.
110            // But if the user explicitely escaped the question mark so that it generates a valid
111            // wildcard query we don't attempt to re-escape the resulting query.
112            $this->node = null;
113        }
114
115        if ( $this->node === null ) {
116            return null;
117        }
118
119        if ( $this->node instanceof KeywordFeatureNode ) {
120            return $this->node->getValue();
121        } elseif ( $this->node instanceof WordsQueryNode ) {
122            return $this->node->getWords();
123        } else {
124            /** @phan-suppress-next-line PhanImpossibleCondition I agree, this is impossible. */
125            Assert::invariant( false, "Unsupported node type " . get_class( $this->node ) );
126            return null;
127        }
128    }
129
130    /**
131     * Replace the fixable part of the visited query with the provided replacement
132     *
133     * @param HtmlArmor|string $replacement If HtmlArmor is provided all modifications will be
134     *  html safe and HtmlArmor will be returned. If a string is provided no escaping will occur.
135     * @return HtmlArmor|string|null
136     */
137    public function fix( $replacement ) {
138        Assert::precondition( $this->visited, "getFixablePart must be called before trying to fix the query" );
139        if ( $this->node === null ) {
140            return null;
141        }
142
143        $escapeBoundaries = false;
144        if ( $replacement instanceof HtmlArmor ) {
145            $escapeBoundaries = true;
146            $replacement = HtmlArmor::getHtml( $replacement );
147            if ( $replacement === null ) {
148                throw new \InvalidArgumentException( '$replacement cannot be null nor wrap a null value' );
149            }
150        }
151        $replacement = preg_replace( '/[~?*"\\\\]/', '\\\\$0', $replacement );
152
153        $prefix = "";
154        if ( $this->parsedQuery->hasCleanup( ParsedQuery::TILDE_HEADER ) ) {
155            $prefix .= "~";
156        }
157        $prefix .= substr( $this->parsedQuery->getQuery(), 0, $this->node->getStartOffset() );
158        if ( $this->node instanceof KeywordFeatureNode ) {
159            $prefix .= $this->node->getKey() . ':';
160        }
161
162        $suffix = substr( $this->parsedQuery->getQuery(), $this->node->getEndOffset() );
163
164        if ( $escapeBoundaries ) {
165            $prefix = htmlspecialchars( $prefix );
166            $suffix = htmlspecialchars( $suffix );
167            $fixed = $prefix . $replacement . $suffix;
168            return new HtmlArmor( $fixed );
169        }
170
171        return $prefix . $replacement . $suffix;
172    }
173
174    public function visitWordsQueryNode( WordsQueryNode $node ) {
175        if ( $this->inNegation ) {
176            return;
177        }
178        $siz = mb_strlen( $node->getWords() );
179        if ( $siz > $this->currentSize ) {
180            if ( !$this->acceptableString( $node->getWords() ) ) {
181                return;
182            }
183            $this->node = $node;
184            $this->currentSize = $siz;
185        }
186    }
187
188    /**
189     * Determine if this substring of the query is suitable for being fixed.
190     * Excludes string with chars that may require escaping (*, ?, " and \)
191     * @param string $str
192     * @return bool
193     */
194    private function acceptableString( $str ) {
195        // We ignore word parts that we me have to escape
196        // when presenting the query back to the user
197        return preg_match( '/[*?"\\\\]/', $str ) !== 1;
198    }
199
200    public function visitPhraseQueryNode( PhraseQueryNode $node ) {
201        $this->isComplex = true;
202    }
203
204    public function visitPhrasePrefixNode( PhrasePrefixNode $node ) {
205        $this->isComplex = true;
206    }
207
208    public function visitFuzzyNode( FuzzyNode $node ) {
209        $this->isComplex = true;
210    }
211
212    public function visitPrefixNode( PrefixNode $node ) {
213        $this->isComplex = true;
214    }
215
216    public function visitWildcardNode( WildcardNode $node ) {
217        if ( str_contains( $node->getWildcardQuery(), '?' ) ) {
218            $this->hasQMarkInWildcard = true;
219        }
220        $this->isComplex = true;
221    }
222
223    public function visitEmptyQueryNode( EmptyQueryNode $node ) {
224    }
225
226    public function visitKeywordFeatureNode( KeywordFeatureNode $node ) {
227        // FIXME: fixing intitle is perhaps a side effect of the original cirrus query parser
228        if ( !$this->inNegation && $node->getKey() === 'intitle' && $node->getDelimiter() === '' ) {
229            $siz = strlen( $node->getValue() );
230            if ( $siz > $this->currentSize && $this->acceptableString( $node->getValue() ) ) {
231                $this->node = $node;
232                $this->currentSize = $siz;
233            }
234        }
235    }
236
237    public function visitParsedBooleanNode( ParsedBooleanNode $node ) {
238        foreach ( $node->getClauses() as $clause ) {
239            $this->visitBooleanClause( $clause );
240        }
241    }
242
243    public function visitBooleanClause( BooleanClause $clause ) {
244        if ( $clause->isExplicit() ) {
245            $this->isComplex = true;
246        }
247        $oldNegated = $this->inNegation;
248        $node = $clause->getNode();
249        if ( $node instanceof KeywordFeatureNode && $node->getKey() === 'intitle' && $node->getDelimiter() === '' ) {
250            // Inhibits the fixer when it sees an un-acceptable value inside a keyword (legacy browsertest_176)
251            $this->isComplex = $this->isComplex || !$this->acceptableString( $node->getValue() );
252        }
253        if ( $clause->getOccur() === BooleanClause::MUST_NOT ) {
254            if ( !$node instanceof KeywordFeatureNode ) {
255                // FIXME: (legacy) only negated keywords were accepted
256                $this->isComplex = true;
257            }
258            $this->inNegation = !$this->inNegation;
259        }
260
261        $clause->getNode()->accept( $this );
262        $this->inNegation = $oldNegated;
263    }
264
265    final public function visitNegatedNode( NegatedNode $node ) {
266        /** @phan-suppress-next-line PhanImpossibleCondition I agree, this is impossible. */
267        Assert::invariant( false, 'NegatedNode should be optimized at parse time' );
268    }
269
270    final public function visitNamespaceHeader( NamespaceHeaderNode $node ) {
271        /** @phan-suppress-next-line PhanImpossibleCondition I agree, this is impossible. */
272        Assert::invariant( false, 'Not yet part of the AST, should not be visited.' );
273    }
274}