Skip to main content

searchfox_lib/
search.rs

1use crate::client::SearchfoxClient;
2use crate::types::{File, SearchfoxResponse};
3use anyhow::Result;
4use log::{debug, warn};
5use reqwest::Url;
6
7fn is_constructor_pattern(symbol: &str) -> bool {
8    if let Some(colon_pos) = symbol.rfind("::") {
9        let class_part = &symbol[..colon_pos];
10        let method_part = &symbol[colon_pos + 2..];
11        let class_name = class_part.split("::").last().unwrap_or(class_part);
12        class_name == method_part
13    } else {
14        false
15    }
16}
17
18fn extract_class_name_from_constructor(symbol: &str) -> String {
19    if let Some(colon_pos) = symbol.rfind("::") {
20        symbol[..colon_pos].to_string()
21    } else {
22        symbol.to_string()
23    }
24}
25
26fn category_matches_symbol(category_name: &str, symbol: &str, case_sensitive: bool) -> bool {
27    let Some(symbol_start) = category_name.find('(') else {
28        return false;
29    };
30    let Some(symbol_end) = category_name.rfind(')') else {
31        return false;
32    };
33
34    let category_symbol = &category_name[symbol_start + 1..symbol_end];
35    let (category_symbol, symbol) = if case_sensitive {
36        (category_symbol.to_string(), symbol.to_string())
37    } else {
38        (category_symbol.to_lowercase(), symbol.to_lowercase())
39    };
40
41    category_symbol == symbol || category_symbol.ends_with(&format!("::{symbol}"))
42}
43
44fn category_matches_constructor(category_name: &str, symbol: &str, case_sensitive: bool) -> bool {
45    let Some(symbol_start) = category_name.find('(') else {
46        return false;
47    };
48    let Some(symbol_end) = category_name.rfind(')') else {
49        return false;
50    };
51
52    let category_symbol = &category_name[symbol_start + 1..symbol_end];
53    let (category_symbol, symbol) = if case_sensitive {
54        (category_symbol.to_string(), symbol.to_string())
55    } else {
56        (category_symbol.to_lowercase(), symbol.to_lowercase())
57    };
58
59    category_symbol == symbol || category_symbol.starts_with(&format!("{symbol}<"))
60}
61
62/// Filters search-result categories to matching source locations.
63///
64/// Categories must contain `search_type` and identify `symbol`; locations that
65/// do not pass `options`' language filter are omitted. Returns `None` when no
66/// matching locations remain.
67fn filter_category_locations(
68    json: &SearchfoxResponse,
69    search_type: &str,
70    symbol: &str,
71    case_sensitive: bool,
72    options: &SearchOptions,
73) -> Option<Vec<(String, usize)>> {
74    let mut locations = Vec::new();
75
76    for (key, value) in json {
77        if key.starts_with('*') {
78            continue;
79        }
80
81        let Some(categories) = value.as_object() else {
82            continue;
83        };
84
85        for (category_name, category_value) in categories {
86            if !category_name.contains(search_type)
87                || !category_matches_symbol(category_name, symbol, case_sensitive)
88            {
89                continue;
90            }
91
92            let Some(files_array) = category_value.as_array() else {
93                continue;
94            };
95
96            for file in files_array {
97                let Ok(file) = serde_json::from_value::<File>(file.clone()) else {
98                    continue;
99                };
100
101                if !options.matches_language_filter(&file.path) {
102                    continue;
103                }
104
105                for line in file.lines {
106                    locations.push((file.path.clone(), line.lno));
107                }
108            }
109        }
110    }
111
112    (!locations.is_empty()).then_some(locations)
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Lang {
117    Cpp,
118    C,
119    Js,
120    WebIdl,
121    Java,
122    Kotlin,
123    Rust,
124    Python,
125    Html,
126    Css,
127}
128
129impl Lang {
130    pub fn matches(&self, path: &str) -> bool {
131        let p = path.to_lowercase();
132        match self {
133            Lang::Cpp => {
134                p.ends_with(".cc")
135                    || p.ends_with(".cpp")
136                    || p.ends_with(".h")
137                    || p.ends_with(".hh")
138                    || p.ends_with(".hpp")
139            }
140            Lang::C => p.ends_with(".c") || p.ends_with(".h"),
141            Lang::Js => {
142                p.ends_with(".js")
143                    || p.ends_with(".mjs")
144                    || p.ends_with(".ts")
145                    || p.ends_with(".cjs")
146                    || p.ends_with(".jsx")
147                    || p.ends_with(".tsx")
148            }
149            Lang::WebIdl => p.ends_with(".webidl"),
150            Lang::Java | Lang::Kotlin => p.ends_with(".java") || p.ends_with(".kt"),
151            Lang::Rust => p.ends_with(".rs"),
152            Lang::Python => p.ends_with(".py"),
153            Lang::Html => p.ends_with(".html") || p.ends_with(".xhtml") || p.ends_with(".htm"),
154            Lang::Css => p.ends_with(".css"),
155        }
156    }
157
158    pub fn parse(s: &str) -> Option<Self> {
159        // "c" is an alias for Cpp (same extensions in Mozilla's codebase).
160        // "kotlin"/"kt" are aliases for Java (same filter: .java and .kt files).
161        match s.to_lowercase().as_str() {
162            "cpp" | "c++" | "c" => Some(Lang::Cpp),
163            "js" | "javascript" | "typescript" | "ts" => Some(Lang::Js),
164            "webidl" => Some(Lang::WebIdl),
165            "java" | "kotlin" | "kt" => Some(Lang::Java),
166            "rust" | "rs" => Some(Lang::Rust),
167            "python" | "py" => Some(Lang::Python),
168            "html" => Some(Lang::Html),
169            "css" => Some(Lang::Css),
170            _ => None,
171        }
172    }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum CategoryFilter {
177    All,
178    ExcludeTests,
179    ExcludeGenerated,
180    ExcludeTestsAndGenerated,
181    OnlyTests,
182    OnlyGenerated,
183    OnlyNormal,
184}
185
186impl CategoryFilter {
187    pub fn should_include(&self, category: &str) -> bool {
188        match self {
189            CategoryFilter::All => true,
190            CategoryFilter::ExcludeTests => category != "test",
191            CategoryFilter::ExcludeGenerated => category != "generated",
192            CategoryFilter::ExcludeTestsAndGenerated => {
193                category != "test" && category != "generated"
194            }
195            CategoryFilter::OnlyTests => category == "test",
196            CategoryFilter::OnlyGenerated => category == "generated",
197            CategoryFilter::OnlyNormal => category == "normal",
198        }
199    }
200}
201
202#[derive(Debug, Clone)]
203pub struct SearchOptions {
204    pub query: Option<String>,
205    pub path: Option<String>,
206    pub case: bool,
207    pub regexp: bool,
208    pub limit: usize,
209    pub context: Option<usize>,
210    pub symbol: Option<String>,
211    pub id: Option<String>,
212    pub lang: Vec<Lang>,
213    pub category_filter: CategoryFilter,
214}
215
216impl Default for SearchOptions {
217    fn default() -> Self {
218        Self {
219            query: None,
220            path: None,
221            case: false,
222            regexp: false,
223            limit: 50,
224            context: None,
225            symbol: None,
226            id: None,
227            lang: Vec::new(),
228            category_filter: CategoryFilter::All,
229        }
230    }
231}
232
233impl SearchOptions {
234    pub fn matches_language_filter(&self, path: &str) -> bool {
235        if self.lang.is_empty() {
236            return true;
237        }
238        self.lang.iter().any(|lang| lang.matches(path))
239    }
240
241    pub fn build_query(&self) -> String {
242        if let Some(symbol) = &self.symbol {
243            format!("symbol:{symbol}")
244        } else if let Some(id) = &self.id {
245            format!("id:{id}")
246        } else if let Some(q) = &self.query {
247            let has_prefix = q.contains("path:")
248                || q.contains("pathre:")
249                || q.contains("symbol:")
250                || q.contains("id:")
251                || q.contains("text:")
252                || q.contains("re:");
253            if let Some(context) = self.context {
254                if has_prefix {
255                    format!("context:{context} {q}")
256                } else {
257                    format!("context:{context} text:{q}")
258                }
259            } else {
260                q.clone()
261            }
262        } else {
263            String::new()
264        }
265    }
266}
267
268pub struct SearchResult {
269    pub path: String,
270    pub line_number: usize,
271    pub line: String,
272    pub context_before: Vec<String>,
273    pub context_after: Vec<String>,
274}
275
276impl SearchfoxClient {
277    pub async fn search(&self, options: &SearchOptions) -> Result<Vec<SearchResult>> {
278        let query = options.build_query();
279
280        let mut url = Url::parse(&format!("{}/{}/search", self.base_url, self.repo))?;
281        url.query_pairs_mut()
282            .append_pair("q", &query)
283            .append_pair("case", if options.case { "true" } else { "false" })
284            .append_pair("regexp", if options.regexp { "true" } else { "false" });
285        if let Some(path) = &options.path {
286            url.query_pairs_mut().append_pair("path", path);
287        }
288
289        let response = self.get(url).await?;
290
291        if !response.status().is_success() {
292            anyhow::bail!("Request failed: {}", response.status());
293        }
294
295        let response_text = response.text().await?;
296        let json: SearchfoxResponse = serde_json::from_str(&response_text)?;
297
298        let mut results = Vec::new();
299        let mut count = 0;
300
301        for (key, value) in &json {
302            if key.starts_with('*') {
303                continue;
304            }
305
306            if !options.category_filter.should_include(key) {
307                continue;
308            }
309
310            if let Some(files_array) = value.as_array() {
311                for file in files_array {
312                    let file: File = match serde_json::from_value(file.clone()) {
313                        Ok(f) => f,
314                        Err(e) => {
315                            warn!("Failed to parse file JSON: {e}");
316                            continue;
317                        }
318                    };
319
320                    if !options.matches_language_filter(&file.path) {
321                        continue;
322                    }
323
324                    if options.path.is_some()
325                        && options.query.is_none()
326                        && options.symbol.is_none()
327                        && options.id.is_none()
328                    {
329                        if count >= options.limit {
330                            break;
331                        }
332                        results.push(SearchResult {
333                            path: file.path.clone(),
334                            line_number: 0,
335                            line: String::new(),
336                            context_before: vec![],
337                            context_after: vec![],
338                        });
339                        count += 1;
340                    } else {
341                        for line in file.lines {
342                            if count >= options.limit {
343                                break;
344                            }
345                            results.push(SearchResult {
346                                path: file.path.clone(),
347                                line_number: line.lno,
348                                line: line.line.trim_end().to_string(),
349                                context_before: line.context_before.unwrap_or_default(),
350                                context_after: line.context_after.unwrap_or_default(),
351                            });
352                            count += 1;
353                        }
354                    }
355                }
356            } else if let Some(obj) = value.as_object() {
357                for (_category, file_list) in obj {
358                    if let Some(files) = file_list.as_array() {
359                        for file in files {
360                            let file: File = match serde_json::from_value(file.clone()) {
361                                Ok(f) => f,
362                                Err(_) => continue,
363                            };
364
365                            if !options.matches_language_filter(&file.path) {
366                                continue;
367                            }
368
369                            if options.path.is_some()
370                                && options.query.is_none()
371                                && options.symbol.is_none()
372                                && options.id.is_none()
373                            {
374                                if count >= options.limit {
375                                    break;
376                                }
377                                results.push(SearchResult {
378                                    path: file.path.clone(),
379                                    line_number: 0,
380                                    line: String::new(),
381                                    context_before: vec![],
382                                    context_after: vec![],
383                                });
384                                count += 1;
385                            } else {
386                                for line in file.lines {
387                                    if count >= options.limit {
388                                        break;
389                                    }
390                                    results.push(SearchResult {
391                                        path: file.path.clone(),
392                                        line_number: line.lno,
393                                        line: line.line.trim_end().to_string(),
394                                        context_before: line.context_before.unwrap_or_default(),
395                                        context_after: line.context_after.unwrap_or_default(),
396                                    });
397                                    count += 1;
398                                }
399                            }
400                        }
401                    }
402                }
403            }
404
405            if count >= options.limit {
406                break;
407            }
408        }
409
410        Ok(results)
411    }
412
413    pub async fn find_symbol_locations(
414        &self,
415        symbol: &str,
416        path_filter: Option<&str>,
417        options: &SearchOptions,
418    ) -> Result<Vec<(String, usize)>> {
419        let is_ctor = is_constructor_pattern(symbol);
420        let search_symbol = if is_ctor {
421            extract_class_name_from_constructor(symbol)
422        } else {
423            symbol.to_string()
424        };
425        // Exact identifier queries omit templated constructor categories such
426        // as `nsTArray::nsTArray<E>`, so constructors use the full query.
427        let query = if is_ctor {
428            symbol.to_string()
429        } else {
430            format!("id:{search_symbol}")
431        };
432        let mut url = Url::parse(&format!("{}/{}/search", self.base_url, self.repo))?;
433        url.query_pairs_mut().append_pair("q", &query);
434        if let Some(path) = path_filter {
435            url.query_pairs_mut().append_pair("path", path);
436        }
437
438        let response = self.get(url).await?;
439
440        if !response.status().is_success() {
441            anyhow::bail!("Request failed: {}", response.status());
442        }
443
444        let response_text = response.text().await?;
445        let json: SearchfoxResponse = serde_json::from_str(&response_text)?;
446        let mut file_locations = Vec::new();
447
448        debug!("Analyzing search results...");
449
450        let symbol_name = symbol.strip_prefix("id:").unwrap_or(symbol);
451        let is_method_search = symbol_name.contains("::") && !is_ctor;
452
453        // Searchfox returns categories from several top-level buckets (normal,
454        // test, thirdparty, ...). Search all of them before falling back to a
455        // case-insensitive category, otherwise a fallback from a later bucket
456        // can replace an exact match found in an earlier one.
457        if is_method_search {
458            // mozsearch maps WebIDL methods to bare JS #property symbols, which
459            // can make unrelated JavaScript definitions look like this method.
460            // For virtual methods, the concrete implementation is in
461            // "Overridden By" while "Definitions" may only be the declaration.
462            for search_type in ["Overridden By", "Definitions", "Declarations"] {
463                for case_sensitive in [true, false] {
464                    if let Some(locations) = filter_category_locations(
465                        &json,
466                        search_type,
467                        symbol_name,
468                        case_sensitive,
469                        options,
470                    ) {
471                        return Ok(locations);
472                    }
473                }
474            }
475        }
476
477        for (key, value) in &json {
478            if key.starts_with('*') {
479                continue;
480            }
481
482            if let Some(files_array) = value.as_array() {
483                debug!("Found {} files in array for key {}", files_array.len(), key);
484                for file in files_array {
485                    match serde_json::from_value::<File>(file.clone()) {
486                        Ok(file) => {
487                            if !options.matches_language_filter(&file.path) {
488                                continue;
489                            }
490
491                            debug!(
492                                "Processing file: {} with {} lines",
493                                file.path,
494                                file.lines.len()
495                            );
496                            for line in file.lines {
497                                if crate::utils::is_potential_definition(&line, symbol) {
498                                    debug!(
499                                        "Found potential definition: {}:{} - {}",
500                                        file.path,
501                                        line.lno,
502                                        line.line.trim()
503                                    );
504                                    file_locations.push((file.path.clone(), line.lno));
505                                }
506                            }
507                        }
508                        Err(e) => {
509                            warn!("Failed to parse file JSON: {e}");
510                        }
511                    }
512                }
513            } else if let Some(categories) = value.as_object() {
514                if !is_method_search && !is_ctor {
515                    for (category_name, category_value) in categories {
516                        let is_class_def_category = category_name.starts_with("Definitions (")
517                            && (category_name.ends_with(&format!("::{symbol_name})"))
518                                || category_name.ends_with(&format!("({symbol_name})")));
519                        let is_not_constructor =
520                            !category_name.contains(&format!("::{symbol_name}::{symbol_name})"));
521
522                        if !is_class_def_category || !is_not_constructor {
523                            continue;
524                        }
525
526                        debug!("Found class definition category: {}", category_name);
527                        let Some(files_array) = category_value.as_array() else {
528                            continue;
529                        };
530
531                        for file in files_array {
532                            let Ok(file) = serde_json::from_value::<File>(file.clone()) else {
533                                continue;
534                            };
535
536                            if !options.matches_language_filter(&file.path) {
537                                continue;
538                            }
539
540                            let mut class_lines = Vec::new();
541                            for line in file.lines {
542                                if line.line.contains("class ") || line.line.contains("struct ") {
543                                    debug!(
544                                        "Found class/struct definition: {}:{} - {}",
545                                        file.path,
546                                        line.lno,
547                                        line.line.trim()
548                                    );
549                                    class_lines.push((
550                                        file.path.clone(),
551                                        line.lno,
552                                        line.line.clone(),
553                                    ));
554                                }
555                            }
556
557                            if class_lines.is_empty() {
558                                continue;
559                            }
560
561                            for (path, lno, line_text) in &class_lines {
562                                if !line_text.contains("{}") {
563                                    return Ok(vec![(path.clone(), *lno)]);
564                                }
565                            }
566                            let (path, lno, _) = &class_lines[0];
567                            return Ok(vec![(path.clone(), *lno)]);
568                        }
569                    }
570                }
571
572                if is_ctor {
573                    let mut all_ctor_lines = Vec::new();
574                    for (category_name, category_value) in categories {
575                        if category_name.contains("Definitions")
576                            && category_matches_constructor(category_name, symbol, false)
577                        {
578                            debug!("Found constructor category: {}", category_name);
579                            if let Some(files_array) = category_value.as_array() {
580                                for file in files_array {
581                                    match serde_json::from_value::<File>(file.clone()) {
582                                        Ok(file) => {
583                                            if !options.matches_language_filter(&file.path) {
584                                                continue;
585                                            }
586
587                                            for line in file.lines {
588                                                debug!(
589                                                    "Found constructor definition: {}:{} - {}",
590                                                    file.path,
591                                                    line.lno,
592                                                    line.line.trim()
593                                                );
594                                                all_ctor_lines.push((file.path.clone(), line.lno));
595                                            }
596                                        }
597                                        Err(_) => continue,
598                                    }
599                                }
600                            }
601                        }
602                    }
603
604                    if !all_ctor_lines.is_empty() {
605                        return Ok(all_ctor_lines);
606                    }
607                }
608
609                let search_order = if is_method_search || is_ctor {
610                    vec!["Overridden By", "Definitions", "Declarations"]
611                } else {
612                    vec!["Declarations", "Definitions"]
613                };
614
615                for search_type in search_order {
616                    for (category_name, category_value) in categories {
617                        if category_name.contains(search_type)
618                            && category_matches_symbol(category_name, symbol_name, false)
619                        {
620                            if let Some(files_array) = category_value.as_array() {
621                                for file in files_array {
622                                    match serde_json::from_value::<File>(file.clone()) {
623                                        Ok(file) => {
624                                            if !options.matches_language_filter(&file.path) {
625                                                continue;
626                                            }
627
628                                            for line in file.lines {
629                                                if let Some(upsearch) = &line.upsearch {
630                                                    if upsearch.starts_with("symbol:_Z") {
631                                                        return Ok(vec![(
632                                                            file.path.clone(),
633                                                            line.lno,
634                                                        )]);
635                                                    }
636                                                }
637                                                file_locations.push((file.path.clone(), line.lno));
638                                            }
639                                        }
640                                        Err(_) => continue,
641                                    }
642                                }
643                            }
644                        }
645                    }
646
647                    if !file_locations.is_empty() {
648                        break;
649                    }
650                }
651            }
652        }
653
654        Ok(file_locations)
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use serde_json::json;
662    use wiremock::matchers::{method, path, query_param};
663    use wiremock::{Mock, MockServer, ResponseTemplate};
664
665    #[test]
666    fn category_matches_exact_symbol_with_namespace() {
667        assert!(category_matches_symbol(
668            "Definitions (mozilla::dom::IDBCursor::Continue)",
669            "IDBCursor::Continue",
670            true,
671        ));
672        assert!(!category_matches_symbol(
673            "Definitions (IDBCursor::continue)",
674            "IDBCursor::Continue",
675            true,
676        ));
677        assert!(category_matches_symbol(
678            "Definitions (IDBCursor::continue)",
679            "IDBCursor::Continue",
680            false,
681        ));
682        assert!(!category_matches_symbol(
683            "Definitions (IDBCursor::ContinuePrimaryKey)",
684            "IDBCursor::Continue",
685            false,
686        ));
687    }
688
689    #[test]
690    fn category_matches_templated_constructor() {
691        assert!(category_matches_constructor(
692            "Definitions (nsTArray::nsTArray<E>)",
693            "nsTArray::nsTArray",
694            false,
695        ));
696        assert!(!category_matches_constructor(
697            "Definitions (nsTArray::nsTArrayExtra<E>)",
698            "nsTArray::nsTArray",
699            false,
700        ));
701    }
702
703    #[tokio::test]
704    async fn templated_constructor_search_uses_full_symbol_query() {
705        let server = MockServer::start().await;
706        let response = json!({
707            "normal": {
708                "Definitions (nsTArray::nsTArray<E>)": [{
709                    "path": "xpcom/ds/nsTArray.h",
710                    "lines": [{
711                        "lno": 2636,
712                        "line": "explicit nsTArray(size_type aCapacity) {}"
713                    }]
714                }],
715                "Definitions (nsTArray::nsTArray<T>)": [{
716                    "path": "xpcom/ds/nsTArray.h",
717                    "lines": [{
718                        "lno": 2635,
719                        "line": "constexpr nsTArray() = default;"
720                    }]
721                }]
722            }
723        });
724
725        Mock::given(method("GET"))
726            .and(path("/mozilla-central/search"))
727            .and(query_param("q", "nsTArray::nsTArray"))
728            .respond_with(ResponseTemplate::new(200).set_body_json(response))
729            .mount(&server)
730            .await;
731
732        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
733        let mut locations = client
734            .find_symbol_locations("nsTArray::nsTArray", None, &SearchOptions::default())
735            .await
736            .unwrap();
737        locations.sort();
738
739        assert_eq!(
740            locations,
741            vec![
742                ("xpcom/ds/nsTArray.h".to_string(), 2635),
743                ("xpcom/ds/nsTArray.h".to_string(), 2636),
744            ]
745        );
746    }
747
748    #[tokio::test]
749    async fn implementation_category_precedes_declaration_and_case_insensitive_match() {
750        let server = MockServer::start().await;
751        let response = json!({
752            "normal": {
753                "Definitions (IDBCursor::continue)": [{
754                    "path": "devtools/client/shared/sourceeditor/codemirror/codemirror.bundle.js",
755                    "lines": [{
756                        "lno": 1,
757                        "line": "var CodeMirror = function() { continue: true; }"
758                    }]
759                }],
760                "Definitions (mozilla::dom::IDBCursor::Continue)": [{
761                    "path": "dom/indexedDB/IDBCursor.h",
762                    "lines": [{
763                        "lno": 127,
764                        "line": "virtual void Continue(JSContext* aCx, JS::Handle<JS::Value> aKey,"
765                    }]
766                }],
767                "Overridden By (mozilla::dom::IDBCursor::Continue)": [{
768                    "path": "dom/indexedDB/IDBCursor.cpp",
769                    "lines": [{
770                        "lno": 336,
771                        "line": "void IDBTypedCursor<CursorType>::Continue(JSContext* const aCx,",
772                        "upsearch": "symbol:_ZN7mozilla3dom14IDBTypedCursor8ContinueEP9JSContext"
773                    }]
774                }, {
775                    "path": "dom/indexedDB/IDBAnotherCursor.cpp",
776                    "lines": [{
777                        "lno": 400,
778                        "line": "void IDBAnotherCursor::Continue(JSContext* const aCx,",
779                        "upsearch": "symbol:_ZN7mozilla3dom16IDBAnotherCursor8ContinueEP9JSContext"
780                    }]
781                }]
782            },
783            "thirdparty": {
784                "Definitions (IDBCursor::continue)": [{
785                    "path": "devtools/client/shared/sourceeditor/codemirror/mode/javascript/javascript.js",
786                    "lines": [{
787                        "lno": 31,
788                        "line": "\"continue\": kw(\"continue\")"
789                    }]
790                }]
791            }
792        });
793
794        Mock::given(method("GET"))
795            .and(path("/mozilla-central/search"))
796            .and(query_param("q", "id:IDBCursor::Continue"))
797            .respond_with(ResponseTemplate::new(200).set_body_json(response))
798            .mount(&server)
799            .await;
800
801        let client = SearchfoxClient::new_for_test("mozilla-central".into(), server.uri()).unwrap();
802        let locations = client
803            .find_symbol_locations("IDBCursor::Continue", None, &SearchOptions::default())
804            .await
805            .unwrap();
806
807        assert_eq!(
808            locations,
809            vec![
810                ("dom/indexedDB/IDBCursor.cpp".to_string(), 336),
811                ("dom/indexedDB/IDBAnotherCursor.cpp".to_string(), 400),
812            ]
813        );
814    }
815}