Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.64% covered (warning)
68.64%
151 / 220
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
FullTextQueryStringQueryBuilder
68.64% covered (warning)
68.64%
151 / 220
50.00% covered (danger)
50.00%
8 / 16
171.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 build
66.32% covered (warning)
66.32%
63 / 95
0.00% covered (danger)
0.00%
0 / 1
35.29
 isPathologicalWildcard
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 buildDegraded
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 buildSearchTextQuery
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 buildQueryString
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getMultiTermRewriteMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 switchSearchToExactForWildcards
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 switchSearchToExact
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildFullTextSearchFields
83.87% covered (warning)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
9.34
 replaceAllPartsOfQuery
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 replacePartsOfQuery
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 buildHighlightQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildPhraseRescoreQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isPhraseRescoreNeeded
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 maybeWrapWithTokenCountRouter
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
7.61
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\Extra\Query\TokenCountRouter;
6use CirrusSearch\Search\SearchContext;
7use CirrusSearch\SearchConfig;
8use Elastica\Query\MatchAll;
9use MediaWiki\Logger\LoggerFactory;
10
11/**
12 * Builds an Elastica query backed by an elasticsearch QueryString query
13 * Has many warts and edge cases that are hardly desirable.
14 */
15class FullTextQueryStringQueryBuilder implements FullTextQueryBuilder {
16    /**
17     * @var SearchConfig
18     */
19    protected $config;
20
21    /**
22     * @var KeywordFeature[]
23     */
24    private $features;
25
26    /**
27     * @var string
28     */
29    private $queryStringQueryString = '';
30
31    /**
32     * @var bool
33     */
34    private $useTokenCountRouter;
35
36    /**
37     * @param SearchConfig $config
38     * @param KeywordFeature[] $features
39     * @param array[] $settings currently ignored
40     */
41    public function __construct( SearchConfig $config, array $features, array $settings = [] ) {
42        $this->config = $config;
43        $this->features = $features;
44        $this->useTokenCountRouter = $this->config->getElement(
45            'CirrusSearchWikimediaExtraPlugin', 'token_count_router' ) === true;
46    }
47
48    /**
49     * Search articles with provided term.
50     *
51     * @param SearchContext $searchContext
52     * @param string $term term to search
53     * searches that might be better?
54     */
55    public function build( SearchContext $searchContext, $term ) {
56        $searchContext->addSyntaxUsed( 'full_text' );
57        // Transform Mediawiki specific syntax to filters and extra
58        // (pre-escaped) query string
59        foreach ( $this->features as $feature ) {
60            $term = $feature->apply( $searchContext, $term );
61        }
62
63        if ( !$searchContext->areResultsPossible() ) {
64            return;
65        }
66
67        $term = $searchContext->escaper()->escapeQuotes( $term );
68        $term = trim( $term );
69
70        // Match quoted phrases including those containing escaped quotes.
71        // Those phrases can optionally be followed by ~ then a number (this is
72        // the phrase slop). That can optionally be followed by a ~ (this
73        // matches stemmed words in phrases). The following all match:
74        // "a", "a boat", "a\"boat", "a boat"~, "a boat"~9,
75        // "a boat"~9~, -"a boat", -"a boat"~9~
76        $slop = $this->config->get( 'CirrusSearchPhraseSlop' );
77        $matchQuotesRegex = '(?<![\]])(?<negate>-|!)?(?<main>"((?:[^"]|(?<=\\\)")+)"(?<slop>~\d+)?)(?<fuzzy>~)?';
78        $query = self::replacePartsOfQuery(
79            $term,
80            "/$matchQuotesRegex/",
81            function ( $matches ) use ( $searchContext, $slop ) {
82                $negate = $matches[ 'negate' ][ 0 ] ? 'NOT ' : '';
83                $main = $searchContext->escaper()->fixupQueryStringPart( $matches[ 'main' ][ 0 ] );
84
85                if ( !$negate && !isset( $matches[ 'fuzzy' ] ) && !isset( $matches[ 'slop' ] ) &&
86                    preg_match( '/^"([^"*]+)[*]"/', $main, $matches )
87                ) {
88                    $phraseMatch = new \Elastica\Query\MatchPhrasePrefix();
89                    $phraseMatch->setFieldQuery( "all.plain", $matches[1] );
90                    $searchContext->addNonTextQuery( $phraseMatch );
91                    $searchContext->addSyntaxUsed( 'phrase_match_prefix' );
92
93                    $phraseHighlightMatch = new \Elastica\Query\QueryString();
94                    $phraseHighlightMatch->setQuery( $matches[1] . '*' );
95                    $phraseHighlightMatch->setFields( [ 'all.plain' ] );
96                    $searchContext->addNonTextHighlightQuery( $phraseHighlightMatch );
97
98                    return [];
99                }
100
101                if ( !isset( $matches[ 'fuzzy' ] ) ) {
102                    if ( !isset( $matches[ 'slop' ] ) ) {
103                        $main .= '~' . $slop[ 'precise' ];
104                    }
105                    // Got to collect phrases that don't use the all field so we can highlight them.
106                    // The highlighter locks phrases to the fields that specify them.  It doesn't do
107                    // that with terms.
108                    return [
109                        'escaped' => $negate . self::switchSearchToExact( $searchContext, $main, true ),
110                        'nonAll' => $negate . self::switchSearchToExact( $searchContext, $main, false ),
111                    ];
112                }
113                return [ 'escaped' => $negate . $main ];
114            } );
115        // Find prefix matches and force them to only match against the plain analyzed fields.  This
116        // prevents prefix matches from getting confused by stemming.  Users really don't expect stemming
117        // in prefix queries.
118        $maxWildcards = $this->config->get( 'CirrusSearchQueryStringMaxWildcards' );
119        $query = self::replaceAllPartsOfQuery( $query, '/\w+\*(?:\w*\*?)*/u',
120            function ( $matches ) use ( $searchContext, $maxWildcards ) {
121                // hack to detect pathological wildcard
122                // relates to T102589 but elastic7 seems to have broken our fix by stopping
123                // to propagate the max_determinized_states param to the wildcard queries
124                // We might consider fixing this upstream again when switch to opensearch.
125                // In the meantine simply count the number of wildcard chars and mimic the previous
126                // if we detect such problematic queries
127                if ( self::isPathologicalWildcard( $matches[ 0 ][ 0 ], $maxWildcards ) ) {
128                    $searchContext->addWarning( 'cirrussearch-regex-too-complex-error' );
129                    $searchContext->setResultsPossible( false );
130                }
131                $term = $searchContext->escaper()->fixupQueryStringPart( $matches[ 0 ][ 0 ] );
132                return [
133                    'escaped' => self::switchSearchToExactForWildcards( $searchContext, $term ),
134                    'nonAll' => self::switchSearchToExactForWildcards( $searchContext, $term )
135                ];
136            } );
137
138        $escapedQuery = [];
139        $nonAllQuery = [];
140        $nearMatchQuery = [];
141        foreach ( $query as $queryPart ) {
142            if ( isset( $queryPart[ 'escaped' ] ) ) {
143                $escapedQuery[] = $queryPart[ 'escaped' ];
144                $nonAllQuery[] = $queryPart['nonAll'] ?? $queryPart['escaped'];
145                continue;
146            }
147            if ( isset( $queryPart[ 'raw' ] ) ) {
148                $fixed = $searchContext->escaper()->fixupQueryStringPart( $queryPart[ 'raw' ] );
149                $escapedQuery[] = $fixed;
150                $nonAllQuery[] = $fixed;
151                $nearMatchQuery[] = $queryPart[ 'raw' ];
152                continue;
153            }
154            LoggerFactory::getInstance( 'CirrusSearch' )->warning(
155                'Unknown query part: {queryPart}',
156                [ 'queryPart' => serialize( $queryPart ) ]
157            );
158        }
159
160        // Actual text query
161        $this->queryStringQueryString =
162            $searchContext->escaper()->fixupWholeQueryString( implode( ' ', $escapedQuery ) );
163        $searchContext->setCleanedSearchTerm( $this->queryStringQueryString );
164
165        if ( $this->queryStringQueryString === '' ) {
166            $searchContext->addSyntaxUsed( 'filter_only' );
167            $searchContext->setHighlightQuery( new MatchAll() );
168            return;
169        }
170
171        // Note that no escaping is required for near_match's match query.
172        $nearMatchQuery = implode( ' ', $nearMatchQuery );
173        // If the near match is made only of spaces disable it.
174        if ( preg_match( '/^\s+$/', $nearMatchQuery ) === 1 ) {
175            $nearMatchQuery = '';
176        }
177
178        $queryStringRegex =
179            '(' .
180                // quoted strings
181                $matchQuotesRegex .
182            ')|(' .
183                // patterns that are seen before tokens.
184                '(^|\s)[+!-]\S' .
185            ')|(' .
186                // patterns seen after tokens.
187                '\S(?<!\\\\)~[0-9]?(\s|$)' .
188            ')|(' .
189                // patterns that are separated from tokens by whitespace
190                // on both sides.
191                '\s(AND|OR|NOT|&&|\\|\\|)\s' .
192            ')|(' .
193                // patterns that can be at the start of the string
194                '^NOT\s' .
195            ')|(' .
196                // patterns that can be inside tokens
197                // Note that question mark stripping has already been applied
198                '(?<!\\\\)[?*]' .
199            ')';
200        if ( preg_match( "/$queryStringRegex/", $this->queryStringQueryString ) ) {
201            $searchContext->addSyntaxUsed( 'query_string' );
202        }
203        $fields = array_merge(
204            self::buildFullTextSearchFields( $searchContext, 1, '.plain', true ),
205            self::buildFullTextSearchFields( $searchContext,
206                $this->config->get( 'CirrusSearchStemmedWeight' ), '', true ) );
207        $nearMatchFields = self::buildFullTextSearchFields( $searchContext,
208            $this->config->get( 'CirrusSearchNearMatchWeight' ), '.near_match', true );
209        $nearMatchFields = array_merge( $nearMatchFields, self::buildFullTextSearchFields( $searchContext,
210            $this->config->get( 'CirrusSearchNearMatchWeight' ) * 0.75, '.near_match_asciifolding', true ) );
211        $searchContext->setMainQuery( $this->buildSearchTextQuery( $searchContext, $fields, $nearMatchFields,
212            $this->queryStringQueryString, $nearMatchQuery ) );
213
214        // The highlighter doesn't know about the weighting from the all fields so we have to send
215        // it a query without the all fields.  This swaps one in.
216        if ( $this->config->getElement( 'CirrusSearchAllFields', 'use' ) ) {
217            $nonAllFields = array_merge(
218                self::buildFullTextSearchFields( $searchContext, 1, '.plain', false ),
219                self::buildFullTextSearchFields( $searchContext,
220                    $this->config->get( 'CirrusSearchStemmedWeight' ), '', false ) );
221            $nonAllQueryString = $searchContext->escaper()
222                ->fixupWholeQueryString( implode( ' ', $nonAllQuery ) );
223            $searchContext->setHighlightQuery(
224                $this->buildHighlightQuery( $searchContext, $nonAllFields, $nonAllQueryString, 1 )
225            );
226        } else {
227            $nonAllFields = $fields;
228        }
229
230        if ( $this->isPhraseRescoreNeeded( $searchContext ) ) {
231            $rescoreFields = $fields;
232            if ( !$this->config->getElement( 'CirrusSearchAllFields', 'use' ) ) {
233                $rescoreFields = $nonAllFields;
234            }
235
236            $searchContext->setPhraseRescoreQuery( $this->buildPhraseRescoreQuery(
237                        $searchContext,
238                        $rescoreFields,
239                        $this->queryStringQueryString,
240                        $this->config->getElement( 'CirrusSearchPhraseSlop', 'boost' )
241                    ) );
242        }
243    }
244
245    private function isPathologicalWildcard( string $term, int $maxWildcard ): bool {
246        $ret = preg_match_all( "/[*?]+/", $term );
247        if ( $ret === false ) {
248            // we failed the regex, out of caution fail the query
249            return true;
250        }
251        return $ret > $maxWildcard;
252    }
253
254    /**
255     * Attempt to build a degraded query from the query already built into $context. Must be
256     * called *after* self::build().
257     *
258     * @param SearchContext $searchContext
259     * @return bool True if a degraded query was built
260     */
261    public function buildDegraded( SearchContext $searchContext ) {
262        if ( $this->queryStringQueryString === '' ) {
263            return false;
264        }
265
266        $fields = array_merge(
267            self::buildFullTextSearchFields( $searchContext, 1, '.plain', true ),
268            self::buildFullTextSearchFields( $searchContext,
269                $this->config->get( 'CirrusSearchStemmedWeight' ), '', true )
270        );
271
272        $searchContext->addSyntaxUsed( 'degraded_full_text' );
273        $searchContext->setMainQuery( new \Elastica\Query\Simple( [ 'simple_query_string' => [
274            'fields' => $fields,
275            'query' => $this->queryStringQueryString,
276            'default_operator' => 'AND',
277        ] ] ) );
278
279        return true;
280    }
281
282    /**
283     * Build the primary query used for full text search. This will be a
284     * QueryString query, and optionally a MultiMatch if a $nearMatchQuery
285     * is provided.
286     *
287     * @param SearchContext $searchContext
288     * @param string[] $fields
289     * @param string[] $nearMatchFields
290     * @param string $queryString
291     * @param string $nearMatchQuery
292     * @return \Elastica\Query\AbstractQuery
293     */
294    protected function buildSearchTextQuery(
295        SearchContext $searchContext,
296        array $fields,
297        array $nearMatchFields,
298        $queryString,
299        $nearMatchQuery
300    ) {
301        $slop = $this->config->getElement( 'CirrusSearchPhraseSlop', 'default' );
302        $queryForMostFields = $this->buildQueryString( $fields, $queryString, $slop );
303        $searchContext->addSyntaxUsed( 'full_text_querystring', 5 );
304        if ( !$nearMatchQuery ) {
305            return $queryForMostFields;
306        }
307
308        // Build one query for the full text fields and one for the near match fields so that
309        // the near match can run unescaped.
310        $bool = new \Elastica\Query\BoolQuery();
311        $bool->setMinimumShouldMatch( 1 );
312        $bool->addShould( $queryForMostFields );
313        $nearMatch = new \Elastica\Query\MultiMatch();
314        $nearMatch->setFields( $nearMatchFields );
315        $nearMatch->setQuery( $nearMatchQuery );
316        $bool->addShould( $nearMatch );
317
318        return $bool;
319    }
320
321    /**
322     * Builds the query using the QueryString, this is the default builder
323     * used by cirrus and uses a default AND between clause.
324     * The query 'the query' and the fields all and all.plain will be like
325     * (all:the OR all.plain:the) AND (all:query OR all.plain:query)
326     *
327     * @param string[] $fields
328     * @param string $queryString
329     * @param int $phraseSlop
330     * @return \Elastica\Query\QueryString
331     */
332    private function buildQueryString( array $fields, $queryString, $phraseSlop ) {
333        $query = new \Elastica\Query\QueryString( $queryString );
334        $query->setFields( $fields );
335        $query->setPhraseSlop( $phraseSlop );
336        $query->setDefaultOperator( 'AND' );
337        $query->setAllowLeadingWildcard( (bool)$this->config->get( 'CirrusSearchAllowLeadingWildcard' ) );
338        $query->setFuzzyPrefixLength( 2 );
339        $query->setRewrite( $this->getMultiTermRewriteMethod() );
340        $states = $this->config->get( 'CirrusSearchQueryStringMaxDeterminizedStates' );
341        if ( isset( $states ) ) {
342            $query->setParam( 'max_determinized_states', $states );
343        }
344        return $query;
345    }
346
347    /**
348     * the rewrite method to use for multi term queries
349     * @return string
350     */
351    protected function getMultiTermRewriteMethod() {
352        return 'top_terms_boost_1024';
353    }
354
355    /**
356     * Expand wildcard queries to the all.plain and title.plain fields if
357     * wgCirrusSearchAllFields[ 'use' ] is set to true. Fallback to all
358     * the possible fields otherwise. This prevents applying and compiling
359     * costly wildcard queries too many times.
360     *
361     * @param SearchContext $context
362     * @param string $term
363     * @return string
364     */
365    private static function switchSearchToExactForWildcards( SearchContext $context, $term ) {
366        // Try to limit the expansion of wildcards to all the subfields
367        // We still need to add title.plain with a high boost otherwise
368        // match in titles be poorly scored (actually it breaks some tests).
369        if ( $context->getConfig()->getElement( 'CirrusSearchAllFields', 'use' ) ) {
370            $titleWeight = $context->getConfig()->getElement( 'CirrusSearchWeights', 'title' );
371            $fields = [];
372            $fields[] = "title.plain:$term^{$titleWeight}";
373            $fields[] = "all.plain:$term";
374            $exact = implode( ' OR ', $fields );
375            return "($exact)";
376        } else {
377            return self::switchSearchToExact( $context, $term, false );
378        }
379    }
380
381    /**
382     * Build a QueryString query where all fields being searched are
383     * queried for $term, joined with an OR. This is primarily for the
384     * benefit of the highlighter, the primary search is typically against
385     * the special all field.
386     *
387     * @param SearchContext $context
388     * @param string $term
389     * @param bool $allFieldAllowed
390     * @return string
391     */
392    private static function switchSearchToExact( SearchContext $context, $term, $allFieldAllowed ) {
393        $exact = implode( ' OR ',
394            self::buildFullTextSearchFields( $context, 1, ".plain:$term", $allFieldAllowed ) );
395        return "($exact)";
396    }
397
398    /**
399     * Build fields searched by full text search.
400     *
401     * @param SearchContext $context
402     * @param float $weight weight to multiply by all fields
403     * @param string $fieldSuffix suffix to add to field names
404     * @param bool $allFieldAllowed can we use the all field?  False for
405     *  collecting phrases for the highlighter.
406     * @return string[] array of fields to query
407     */
408    private static function buildFullTextSearchFields(
409        SearchContext $context,
410        $weight,
411        $fieldSuffix,
412        $allFieldAllowed
413    ) {
414        $searchWeights = $context->getConfig()->get( 'CirrusSearchWeights' );
415
416        if ( $allFieldAllowed && $context->getConfig()->getElement( 'CirrusSearchAllFields', 'use' ) ) {
417            if ( $fieldSuffix === '.near_match' ) {
418                // The near match fields can't shard a root field because field fields need it -
419                // thus no suffix all.
420                return [ "all_near_match^{$weight}" ];
421            }
422            if ( $fieldSuffix === '.near_match_asciifolding' ) {
423                // The near match fields can't shard a root field because field fields need it -
424                // thus no suffix all.
425                return [ "all_near_match.asciifolding^{$weight}" ];
426            }
427            return [ "all{$fieldSuffix}^{$weight}" ];
428        }
429
430        $fields = [];
431        // Only title and redirect support near_match so skip it for everything else
432        $titleWeight = $weight * $searchWeights[ 'title' ];
433        $redirectWeight = $weight * $searchWeights[ 'redirect' ];
434        if ( $fieldSuffix === '.near_match' || $fieldSuffix === '.near_match_asciifolding' ) {
435            $fields[] = "title{$fieldSuffix}^{$titleWeight}";
436            $fields[] = "redirect.title{$fieldSuffix}^{$redirectWeight}";
437            return $fields;
438        }
439        $fields[] = "title{$fieldSuffix}^{$titleWeight}";
440        $fields[] = "redirect.title{$fieldSuffix}^{$redirectWeight}";
441        $categoryWeight = $weight * $searchWeights[ 'category' ];
442        $headingWeight = $weight * $searchWeights[ 'heading' ];
443        $openingTextWeight = $weight * $searchWeights[ 'opening_text' ];
444        $textWeight = $weight * $searchWeights[ 'text' ];
445        $auxiliaryTextWeight = $weight * $searchWeights[ 'auxiliary_text' ];
446        $fields[] = "category{$fieldSuffix}^{$categoryWeight}";
447        $fields[] = "heading{$fieldSuffix}^{$headingWeight}";
448        $fields[] = "opening_text{$fieldSuffix}^{$openingTextWeight}";
449        $fields[] = "text{$fieldSuffix}^{$textWeight}";
450        $fields[] = "auxiliary_text{$fieldSuffix}^{$auxiliaryTextWeight}";
451        $namespaces = $context->getNamespaces();
452        if ( !$namespaces || in_array( NS_FILE, $namespaces ) ) {
453            $fileTextWeight = $weight * $searchWeights[ 'file_text' ];
454            $fields[] = "file_text{$fieldSuffix}^{$fileTextWeight}";
455        }
456        return $fields;
457    }
458
459    /**
460     * Walks through an array of query pieces, as built by
461     * self::replacePartsOfQuery, and replaecs all raw pieces by the result of
462     * self::replacePartsOfQuery when called with the provided regex and
463     * callable. One query piece may turn into one or more query pieces in the
464     * result.
465     *
466     * @param array[] $query The set of query pieces to apply against
467     * @param string $regex Pieces of $queryPart that match this regex will
468     *  be provided to $callable
469     * @param callable $callable A function accepting the $matches from preg_match
470     *  and returning either a raw or escaped query piece.
471     * @return array[] The set of query pieces after applying regex and callable
472     */
473    private static function replaceAllPartsOfQuery( array $query, $regex, $callable ) {
474        $result = [];
475        foreach ( $query as $queryPart ) {
476            if ( isset( $queryPart[ 'raw' ] ) ) {
477                $result = array_merge( $result,
478                    self::replacePartsOfQuery( $queryPart[ 'raw' ], $regex, $callable ) );
479            } else {
480                $result[] = $queryPart;
481            }
482        }
483        return $result;
484    }
485
486    /**
487     * Splits a query string into one or more sequential pieces. Each piece
488     * of the query can either be raw (['raw'=>'stuff']), or escaped
489     * (['escaped'=>'stuff']). escaped can also optionally include a nonAll
490     * query (['escaped'=>'stuff','nonAll'=>'stuff']). If nonAll is not set
491     * the escaped query will be used.
492     *
493     * Pieces of $queryPart that do not match the provided $regex are tagged
494     * as 'raw' and may see further parsing. $callable receives pieces of
495     * the string that match the regex and must return either a raw or escaped
496     * query piece.
497     *
498     * @param string $queryPart Raw piece of a user supplied query string
499     * @param string $regex Pieces of $queryPart that match this regex will
500     *  be provided to $callable
501     * @param callable $callable A function accepting the $matches from preg_match
502     *  and returning either a raw or escaped query piece.
503     * @return array[] The sequential set of quer ypieces $queryPart was
504     *  converted into.
505     */
506    private static function replacePartsOfQuery( $queryPart, $regex, $callable ) {
507        $destination = [];
508        $matches = [];
509        $offset = 0;
510        while ( preg_match( $regex, $queryPart, $matches, PREG_OFFSET_CAPTURE, $offset ) ) {
511            $startOffset = $matches[0][1];
512            if ( $startOffset > $offset ) {
513                $destination[] = [
514                    'raw' => substr( $queryPart, $offset, $startOffset - $offset )
515                ];
516            }
517
518            $callableResult = call_user_func( $callable, $matches );
519            if ( $callableResult ) {
520                $destination[] = $callableResult;
521            }
522
523            $offset = $startOffset + strlen( $matches[0][0] );
524        }
525
526        if ( $offset < strlen( $queryPart ) ) {
527            $destination[] = [
528                'raw' => substr( $queryPart, $offset ),
529            ];
530        }
531
532        return $destination;
533    }
534
535    /**
536     * Builds the highlight query
537     * @param SearchContext $context
538     * @param string[] $fields
539     * @param string $queryText
540     * @param int $slop
541     * @return \Elastica\Query\AbstractQuery
542     */
543    protected function buildHighlightQuery( SearchContext $context, array $fields, $queryText, $slop ) {
544        return $this->buildQueryString( $fields, $queryText, $slop );
545    }
546
547    /**
548     * Builds the phrase rescore query
549     * @param SearchContext $context
550     * @param string[] $fields
551     * @param string $queryText
552     * @param int $slop
553     * @return \Elastica\Query\AbstractQuery
554     */
555    protected function buildPhraseRescoreQuery( SearchContext $context, array $fields, $queryText, $slop ) {
556        return $this->maybeWrapWithTokenCountRouter(
557            $queryText,
558            $this->buildQueryString( $fields, '"' . $queryText . '"', $slop )
559        );
560    }
561
562    /**
563     * Determines if a phrase rescore is needed
564     * @param SearchContext $searchContext
565     * @return bool true if we can a phrase rescore
566     */
567    protected function isPhraseRescoreNeeded( SearchContext $searchContext ) {
568        // Only do a phrase match rescore if the query doesn't include
569        // any quotes and has a space or the token count router is
570        // active.
571        // Queries without spaces are either single term or have a
572        // phrase query generated.
573        // Queries with the quote already contain a phrase query and we
574        // can't build phrase queries out of phrase queries at this
575        // point.
576        if ( !$searchContext->isSpecialKeywordUsed() &&
577            strpos( $this->queryStringQueryString, '"' ) === false &&
578            ( $this->useTokenCountRouter || strpos( $this->queryStringQueryString, ' ' ) !== false )
579        ) {
580            return true;
581        }
582        return false;
583    }
584
585    protected function maybeWrapWithTokenCountRouter( $queryText, \Elastica\Query\AbstractQuery $query ) {
586        if ( $this->useTokenCountRouter ) {
587            $tokCount = new TokenCountRouter(
588                // text
589                $queryText,
590                // fallack
591                new \Elastica\Query\MatchNone(),
592                // field
593                null,
594                // analyzer
595                'text_search'
596            );
597            $maxTokens = $this->config->get( 'CirrusSearchMaxPhraseTokens' );
598            if ( $maxTokens ) {
599                $tokCount->addCondition(
600                    TokenCountRouter::GT,
601                    $maxTokens,
602                    new \Elastica\Query\MatchNone()
603                );
604            }
605            $tokCount->addCondition(
606                TokenCountRouter::GT,
607                1,
608                $query
609            );
610            return $tokCount;
611        }
612        return $query;
613    }
614}