1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7/**
8 * This module exports a provider that offers search engine suggestions.
9 */
10
11var EXPORTED_SYMBOLS = ["UrlbarProviderSearchSuggestions"];
12
13const { XPCOMUtils } = ChromeUtils.import(
14  "resource://gre/modules/XPCOMUtils.jsm"
15);
16XPCOMUtils.defineLazyModuleGetters(this, {
17  SearchSuggestionController:
18    "resource://gre/modules/SearchSuggestionController.jsm",
19  Services: "resource://gre/modules/Services.jsm",
20  SkippableTimer: "resource:///modules/UrlbarUtils.jsm",
21  UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
22  UrlbarProvider: "resource:///modules/UrlbarUtils.jsm",
23  UrlbarResult: "resource:///modules/UrlbarResult.jsm",
24  UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm",
25  UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
26  UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
27});
28
29/**
30 * Returns whether the passed in string looks like a url.
31 * @param {string} str
32 * @param {boolean} [ignoreAlphanumericHosts]
33 * @returns {boolean}
34 *   True if the query looks like a URL.
35 */
36function looksLikeUrl(str, ignoreAlphanumericHosts = false) {
37  // Single word including special chars.
38  return (
39    !UrlbarTokenizer.REGEXP_SPACES.test(str) &&
40    (["/", "@", ":", "["].some(c => str.includes(c)) ||
41      (ignoreAlphanumericHosts
42        ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str)
43        : str.includes(".")))
44  );
45}
46
47/**
48 * Class used to create the provider.
49 */
50class ProviderSearchSuggestions extends UrlbarProvider {
51  constructor() {
52    super();
53  }
54
55  /**
56   * Returns the name of this provider.
57   * @returns {string} the name of this provider.
58   */
59  get name() {
60    return "SearchSuggestions";
61  }
62
63  /**
64   * Returns the type of this provider.
65   * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.*
66   */
67  get type() {
68    return UrlbarUtils.PROVIDER_TYPE.NETWORK;
69  }
70
71  /**
72   * Whether this provider should be invoked for the given context.
73   * If this method returns false, the providers manager won't start a query
74   * with this provider, to save on resources.
75   * @param {UrlbarQueryContext} queryContext The query context object
76   * @returns {boolean} Whether this provider should be invoked for the search.
77   */
78  isActive(queryContext) {
79    // If the sources don't include search or the user used a restriction
80    // character other than search, don't allow any suggestions.
81    if (
82      !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) ||
83      (queryContext.restrictSource &&
84        queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH)
85    ) {
86      return false;
87    }
88
89    // No suggestions for empty search strings, unless we are restricting to
90    // search.
91    if (
92      !queryContext.trimmedSearchString &&
93      !this._isTokenOrRestrictionPresent(queryContext)
94    ) {
95      return false;
96    }
97
98    if (!this._allowSuggestions(queryContext)) {
99      return false;
100    }
101
102    let wantsLocalSuggestions =
103      UrlbarPrefs.get("maxHistoricalSearchSuggestions") &&
104      (queryContext.trimmedSearchString ||
105        UrlbarPrefs.get("update2.emptySearchBehavior") != 0);
106
107    return wantsLocalSuggestions || this._allowRemoteSuggestions(queryContext);
108  }
109
110  /**
111   * Returns whether the user typed a token alias or restriction token, or is in
112   * search mode. We use this value to override the pref to disable search
113   * suggestions in the Urlbar.
114   * @param {UrlbarQueryContext} queryContext  The query context object.
115   * @returns {boolean} True if the user typed a token alias or search
116   *   restriction token.
117   */
118  _isTokenOrRestrictionPresent(queryContext) {
119    return (
120      queryContext.searchString.startsWith("@") ||
121      (queryContext.restrictSource &&
122        queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) ||
123      queryContext.tokens.some(
124        t => t.type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH
125      ) ||
126      (queryContext.searchMode &&
127        queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH))
128    );
129  }
130
131  /**
132   * Returns whether suggestions in general are allowed for a given query
133   * context.  If this returns false, then we shouldn't fetch either form
134   * history or remote suggestions.
135   *
136   * @param {object} queryContext The query context object
137   * @returns {boolean} True if suggestions in general are allowed and false if
138   *   not.
139   */
140  _allowSuggestions(queryContext) {
141    if (
142      // If the user typed a restriction token or token alias, we ignore the
143      // pref to disable suggestions in the Urlbar.
144      (!UrlbarPrefs.get("suggest.searches") &&
145        !this._isTokenOrRestrictionPresent(queryContext)) ||
146      !UrlbarPrefs.get("browser.search.suggest.enabled") ||
147      (queryContext.isPrivate &&
148        !UrlbarPrefs.get("browser.search.suggest.enabled.private"))
149    ) {
150      return false;
151    }
152    return true;
153  }
154
155  /**
156   * Returns whether remote suggestions are allowed for a given query context.
157   *
158   * @param {object} queryContext The query context object
159   * @param {string} [searchString] The effective search string without
160   *        restriction tokens or aliases. Defaults to the context searchString.
161   * @returns {boolean} True if remote suggestions are allowed and false if not.
162   */
163  _allowRemoteSuggestions(
164    queryContext,
165    searchString = queryContext.searchString
166  ) {
167    // This is checked by `queryContext.allowRemoteResults` below, but we can
168    // short-circuit that call with the `_isTokenOrRestrictionPresent` block
169    // before that. Make sure we don't allow remote suggestions if this is set.
170    if (queryContext.prohibitRemoteResults) {
171      return false;
172    }
173
174    // TODO (Bug 1626964): Support zero prefix suggestions.
175    if (!searchString.trim()) {
176      return false;
177    }
178
179    // Skip all remaining checks and allow remote suggestions at this point if
180    // the user used a token alias or restriction token. We want "@engine query"
181    // to return suggestions from the engine. We'll return early from startQuery
182    // if the query doesn't match an alias.
183    if (this._isTokenOrRestrictionPresent(queryContext)) {
184      return true;
185    }
186
187    // If the user is just adding on to a query that previously didn't return
188    // many remote suggestions, we are unlikely to get any more results.
189    if (
190      !!this._lastLowResultsSearchSuggestion &&
191      searchString.length > this._lastLowResultsSearchSuggestion.length &&
192      searchString.startsWith(this._lastLowResultsSearchSuggestion)
193    ) {
194      return false;
195    }
196
197    return queryContext.allowRemoteResults(searchString);
198  }
199
200  /**
201   * Starts querying.
202   * @param {object} queryContext The query context object
203   * @param {function} addCallback Callback invoked by the provider to add a new
204   *        result.
205   * @returns {Promise} resolved when the query stops.
206   */
207  async startQuery(queryContext, addCallback) {
208    let instance = this.queryInstance;
209
210    let aliasEngine = await this._maybeGetAlias(queryContext);
211    if (!aliasEngine) {
212      // Autofill matches queries starting with "@" to token alias engines.
213      // If the string starts with "@", but an alias engine is not yet
214      // matched, then autofill might still be filtering token alias
215      // engine results. We don't want to mix search suggestions with those
216      // engine results, so we return early. See bug 1551049 comment 1 for
217      // discussion on how to improve this behavior.
218      if (queryContext.searchString.startsWith("@")) {
219        return;
220      }
221    }
222
223    let query = aliasEngine
224      ? aliasEngine.query
225      : UrlbarUtils.substringAt(
226          queryContext.searchString,
227          queryContext.tokens[0]?.value || ""
228        ).trim();
229
230    let leadingRestrictionToken = null;
231    if (
232      UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) &&
233      (queryContext.tokens.length > 1 ||
234        queryContext.tokens[0].type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH)
235    ) {
236      leadingRestrictionToken = queryContext.tokens[0].value;
237    }
238
239    // Strip a leading search restriction char, because we prepend it to text
240    // when the search shortcut is used and it's not user typed. Don't strip
241    // other restriction chars, so that it's possible to search for things
242    // including one of those (e.g. "c#").
243    if (leadingRestrictionToken === UrlbarTokenizer.RESTRICT.SEARCH) {
244      query = UrlbarUtils.substringAfter(query, leadingRestrictionToken).trim();
245    }
246
247    // Find our search engine. It may have already been set with an alias.
248    let engine;
249    if (aliasEngine) {
250      engine = aliasEngine.engine;
251    } else if (queryContext.searchMode?.engineName) {
252      engine = Services.search.getEngineByName(
253        queryContext.searchMode.engineName
254      );
255    } else {
256      engine = UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate);
257    }
258
259    if (!engine) {
260      return;
261    }
262
263    let alias = (aliasEngine && aliasEngine.alias) || "";
264    let results = await this._fetchSearchSuggestions(
265      queryContext,
266      engine,
267      query,
268      alias
269    );
270
271    if (!results || instance != this.queryInstance) {
272      return;
273    }
274
275    for (let result of results) {
276      addCallback(this, result);
277    }
278  }
279
280  /**
281   * Gets the provider's priority.
282   * @param {UrlbarQueryContext} queryContext The query context object
283   * @returns {number} The provider's priority for the given query.
284   */
285  getPriority(queryContext) {
286    return 0;
287  }
288
289  /**
290   * Cancels a running query.
291   * @param {object} queryContext The query context object
292   */
293  cancelQuery(queryContext) {
294    if (this._suggestionsController) {
295      this._suggestionsController.stop();
296      this._suggestionsController = null;
297    }
298  }
299
300  async _fetchSearchSuggestions(queryContext, engine, searchString, alias) {
301    if (!engine) {
302      return null;
303    }
304
305    this._suggestionsController = new SearchSuggestionController();
306    this._suggestionsController.formHistoryParam = queryContext.formHistoryName;
307
308    // If there's a form history entry that equals the search string, the search
309    // suggestions controller will include it, and we'll make a result for it.
310    // If the heuristic result ends up being a search result, the muxer will
311    // discard the form history result since it dupes the heuristic, and the
312    // final list of results would be left with `count` - 1 form history results
313    // instead of `count`.  Therefore we request `count` + 1 entries.  The muxer
314    // will dedupe and limit the final form history count as appropriate.
315    this._suggestionsController.maxLocalResults = queryContext.maxResults + 1;
316
317    // Request maxResults + 1 remote suggestions for the same reason we request
318    // maxResults + 1 form history entries.
319    let allowRemote = this._allowRemoteSuggestions(queryContext, searchString);
320    this._suggestionsController.maxRemoteResults = allowRemote
321      ? queryContext.maxResults + 1
322      : 0;
323
324    this._suggestionsFetchCompletePromise = this._suggestionsController.fetch(
325      searchString,
326      queryContext.isPrivate,
327      engine,
328      queryContext.userContextId,
329      this._isTokenOrRestrictionPresent(queryContext),
330      false
331    );
332
333    // See `SearchSuggestionsController.fetch` documentation for a description
334    // of `fetchData`.
335    let fetchData = await this._suggestionsFetchCompletePromise;
336    // The fetch was canceled.
337    if (!fetchData) {
338      return null;
339    }
340
341    let results = [];
342
343    // maxHistoricalSearchSuggestions used to determine the initial number of
344    // form history results, with the special case where zero means to never
345    // show form history at all.  With the introduction of flexed result
346    // groups, we now use it only as a boolean: Zero means don't show form
347    // history at all (as before), non-zero means show it.
348    if (UrlbarPrefs.get("maxHistoricalSearchSuggestions")) {
349      for (let entry of fetchData.local) {
350        results.push(makeFormHistoryResult(queryContext, engine, entry));
351      }
352    }
353
354    // If we don't return many results, then keep track of the query. If the
355    // user just adds on to the query, we won't fetch more suggestions if the
356    // query is very long since we are unlikely to get any.
357    if (
358      allowRemote &&
359      !fetchData.remote.length &&
360      searchString.length > UrlbarPrefs.get("maxCharsForSearchSuggestions")
361    ) {
362      this._lastLowResultsSearchSuggestion = searchString;
363    }
364
365    // If we have only tail suggestions, we only show them if we have no other
366    // results. We need to wait for other results to arrive to avoid flickering.
367    // We will wait for this timer unless we have suggestions that don't have a
368    // tail.
369    let tailTimer = new SkippableTimer({
370      name: "ProviderSearchSuggestions",
371      time: 100,
372      logger: this.logger,
373    });
374
375    for (let entry of fetchData.remote) {
376      if (looksLikeUrl(entry.value)) {
377        continue;
378      }
379
380      if (entry.tail && entry.tailOffsetIndex < 0) {
381        this.logger.error(
382          `Error in tail suggestion parsing. Value: ${entry.value}, tail: ${entry.tail}.`
383        );
384        continue;
385      }
386
387      let tail = entry.tail;
388      let tailPrefix = entry.matchPrefix;
389
390      // Skip tail suggestions if the pref is disabled.
391      if (tail && !UrlbarPrefs.get("richSuggestions.tail")) {
392        continue;
393      }
394
395      if (!tail) {
396        await tailTimer.fire().catch(ex => this.logger.error(ex));
397      }
398
399      try {
400        results.push(
401          new UrlbarResult(
402            UrlbarUtils.RESULT_TYPE.SEARCH,
403            UrlbarUtils.RESULT_SOURCE.SEARCH,
404            ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
405              engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED],
406              suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED],
407              lowerCaseSuggestion: entry.value.toLocaleLowerCase(),
408              tailPrefix,
409              tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED],
410              tailOffsetIndex: tail ? entry.tailOffsetIndex : undefined,
411              keyword: [alias ? alias : undefined, UrlbarUtils.HIGHLIGHT.TYPED],
412              query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE],
413              icon: !entry.value ? engine.iconURI?.spec : undefined,
414            })
415          )
416        );
417      } catch (err) {
418        this.logger.error(err);
419        continue;
420      }
421    }
422
423    await tailTimer.promise;
424    return results;
425  }
426
427  /**
428   * Searches for an engine alias given the queryContext.
429   * @param {UrlbarQueryContext} queryContext
430   * @returns {object} aliasEngine
431   *   A representation of the aliased engine. Null if there's no match.
432   * @returns {nsISearchEngine} aliasEngine.engine
433   * @returns {string} aliasEngine.alias
434   * @returns {string} aliasEngine.query
435   * @returns {object} { engine, alias, query }
436   *
437   */
438  async _maybeGetAlias(queryContext) {
439    if (queryContext.searchMode) {
440      // If we're in search mode, don't try to parse an alias at all.
441      return null;
442    }
443
444    let possibleAlias = queryContext.tokens[0]?.value;
445    // "@" on its own is handled by UrlbarProviderTokenAliasEngines and returns
446    // a list of every available token alias.
447    if (!possibleAlias || possibleAlias == "@") {
448      return null;
449    }
450
451    let query = UrlbarUtils.substringAfter(
452      queryContext.searchString,
453      possibleAlias
454    );
455
456    // Match an alias only when it has a space after it.  If there's no trailing
457    // space, then continue to treat it as part of the search string.
458    if (!UrlbarTokenizer.REGEXP_SPACES_START.test(query)) {
459      return null;
460    }
461
462    // Check if the user entered an engine alias directly.
463    let engineMatch = await UrlbarSearchUtils.engineForAlias(possibleAlias);
464    if (engineMatch) {
465      return {
466        engine: engineMatch,
467        alias: possibleAlias,
468        query: query.trim(),
469      };
470    }
471
472    return null;
473  }
474}
475
476function makeFormHistoryResult(queryContext, engine, entry) {
477  return new UrlbarResult(
478    UrlbarUtils.RESULT_TYPE.SEARCH,
479    UrlbarUtils.RESULT_SOURCE.HISTORY,
480    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
481      engine: engine.name,
482      suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED],
483      lowerCaseSuggestion: entry.value.toLocaleLowerCase(),
484    })
485  );
486}
487
488var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions();
489