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
62fn 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 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 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 if is_method_search {
458 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}