Skip to main content

docs_mcp/docsrs/
parser.rs

1use std::collections::{HashMap, HashSet};
2
3use regex::Regex;
4use serde_json::Value;
5
6use super::types::{Item, RustdocJson};
7
8// ─── Type-to-string ───────────────────────────────────────────────────────────
9
10/// Recursively convert a rustdoc JSON `Type` value to a human-readable string.
11///
12/// Handles format v57 type representations.
13pub fn type_to_string(ty: &Value) -> String {
14    if ty.is_null() {
15        return "()".to_string();
16    }
17
18    let obj = match ty.as_object() {
19        Some(o) => o,
20        None => return ty.to_string(),
21    };
22
23    // Primitive
24    if let Some(p) = obj.get("primitive").and_then(|v| v.as_str()) {
25        return p.to_string();
26    }
27
28    // Generic parameter (e.g. "T")
29    if let Some(g) = obj.get("generic").and_then(|v| v.as_str()) {
30        return g.to_string();
31    }
32
33    // Resolved path (e.g. Option<T>, Vec<T>, custom types)
34    if let Some(rp) = obj.get("resolved_path") {
35        let name = rp.get("path")
36            .or_else(|| rp.get("name"))
37            .and_then(|v| v.as_str())
38            .unwrap_or("_");
39        let args = rp.get("args")
40            .and_then(|a| a.get("angle_bracketed"))
41            .and_then(|ab| ab.get("args"))
42            .and_then(|a| a.as_array());
43        if let Some(args) = args {
44            let type_args: Vec<String> = args.iter()
45                .filter_map(|a| a.get("type").map(type_to_string))
46                .collect();
47            if !type_args.is_empty() {
48                return format!("{name}<{}>", type_args.join(", "));
49            }
50        }
51        return name.to_string();
52    }
53
54    // Borrowed reference (&T or &'a T or &'a mut T)
55    if let Some(br) = obj.get("borrowed_ref") {
56        let lifetime = br.get("lifetime").and_then(|v| v.as_str());
57        let mutable = br.get("mutable").and_then(|v| v.as_bool()).unwrap_or(false);
58        let inner = br.get("type").map(type_to_string).unwrap_or_else(|| "_".to_string());
59        let mut_str = if mutable { "mut " } else { "" };
60        return match lifetime {
61            Some(lt) if !lt.is_empty() => {
62                // JSON lifetime may already include the apostrophe (e.g. "'a", "'static")
63                // or may be bare (e.g. "a"). Normalize to avoid "''a".
64                if lt.starts_with('\'') {
65                    format!("&{lt} {mut_str}{inner}")
66                } else {
67                    format!("&'{lt} {mut_str}{inner}")
68                }
69            },
70            _ => format!("&{mut_str}{inner}"),
71        };
72    }
73
74    // Tuple
75    if let Some(tup) = obj.get("tuple").and_then(|v| v.as_array()) {
76        let parts: Vec<String> = tup.iter().map(type_to_string).collect();
77        return format!("({})", parts.join(", "));
78    }
79
80    // Slice [T]
81    if let Some(sl) = obj.get("slice") {
82        return format!("[{}]", type_to_string(sl));
83    }
84
85    // Array [T; N]
86    if let Some(arr) = obj.get("array") {
87        let elem = arr.get("type").map(type_to_string).unwrap_or_else(|| "_".to_string());
88        let len = arr.get("len").and_then(|v| v.as_str()).unwrap_or("_");
89        return format!("[{elem}; {len}]");
90    }
91
92    // Raw pointer (*const T or *mut T)
93    if let Some(rp) = obj.get("raw_pointer") {
94        let mutable = rp.get("mutable").and_then(|v| v.as_bool()).unwrap_or(false);
95        let inner = rp.get("type").map(type_to_string).unwrap_or_else(|| "_".to_string());
96        let mut_str = if mutable { "mut" } else { "const" };
97        return format!("*{mut_str} {inner}");
98    }
99
100    // ImplTrait (impl Trait1 + Trait2)
101    if let Some(bounds) = obj.get("impl_trait").and_then(|v| v.as_array()) {
102        let parts: Vec<String> = bounds.iter()
103            .filter_map(|b| b.get("trait_bound"))
104            .filter_map(|tb| tb.get("trait"))
105            .map(type_to_string)
106            .collect();
107        return format!("impl {}", parts.join(" + "));
108    }
109
110    // DynTrait
111    if let Some(dt) = obj.get("dyn_trait") {
112        let traits = dt.get("traits")
113            .and_then(|v| v.as_array())
114            .map(|ts| {
115                ts.iter()
116                    .filter_map(|t| t.get("trait"))
117                    .map(type_to_string)
118                    .collect::<Vec<_>>()
119                    .join(" + ")
120            })
121            .unwrap_or_default();
122        let lifetime = dt.get("lifetime").and_then(|v| v.as_str());
123        return match lifetime {
124            Some(lt) if !lt.is_empty() => format!("dyn {traits} + {lt}"),
125            _ => format!("dyn {traits}"),
126        };
127    }
128
129    // FunctionPointer
130    if let Some(fp) = obj.get("function_pointer") {
131        let decl = fp.get("sig")
132            .or_else(|| fp.get("decl"));
133        if let Some(decl) = decl {
134            let inputs = decl.get("inputs")
135                .and_then(|v| v.as_array())
136                .map(|inputs| {
137                    inputs.iter()
138                        .filter_map(|i| i.as_array())
139                        .map(|pair| {
140                            let name = pair.first().and_then(|v| v.as_str()).unwrap_or("_");
141                            let ty = pair.get(1).map(type_to_string).unwrap_or_else(|| "_".to_string());
142                            format!("{name}: {ty}")
143                        })
144                        .collect::<Vec<_>>()
145                        .join(", ")
146                })
147                .unwrap_or_default();
148            let output = decl.get("output").map(type_to_string).unwrap_or_default();
149            if output.is_empty() || output == "()" {
150                return format!("fn({inputs})");
151            } else {
152                return format!("fn({inputs}) -> {output}");
153            }
154        }
155    }
156
157    // QualifiedPath (e.g. <T as Trait>::Assoc)
158    if let Some(qp) = obj.get("qualified_path") {
159        let self_type = qp.get("self_type").map(type_to_string).unwrap_or_else(|| "_".to_string());
160        let name = qp.get("name").and_then(|v| v.as_str()).unwrap_or("_");
161        let trait_val = qp.get("trait");
162        let trait_is_absent = trait_val.map(|v| v.is_null()).unwrap_or(true);
163        if trait_is_absent {
164            // No explicit trait disambiguation — emit `T::Name` (shorthand the compiler resolves).
165            return format!("{self_type}::{name}");
166        }
167        let trait_name = trait_val.map(type_to_string).unwrap_or_default();
168        return format!("<{self_type} as {trait_name}>::{name}");
169    }
170
171    // Direct type path (v57 trait bounds / impl for_ / qualified path traits):
172    // {"id": N, "path": "Foo", "args": ...} — no "resolved_path" wrapper
173    if obj.contains_key("id") {
174        if let Some(path_str) = obj.get("path").and_then(|v| v.as_str()) {
175            let name = if path_str.is_empty() { "_" } else { path_str };
176            let args = obj.get("args")
177                .and_then(|a| a.get("angle_bracketed"))
178                .and_then(|ab| ab.get("args"))
179                .and_then(|a| a.as_array());
180            if let Some(args) = args {
181                let type_args: Vec<String> = args.iter()
182                    .filter_map(|a| a.get("type").map(type_to_string))
183                    .collect();
184                if !type_args.is_empty() {
185                    return format!("{name}<{}>", type_args.join(", "));
186                }
187            }
188            return name.to_string();
189        }
190    }
191
192    // Fallback
193    ty.to_string()
194}
195
196// ─── Signature reconstruction ─────────────────────────────────────────────────
197
198/// Reconstruct a function signature from rustdoc JSON format v57.
199pub fn function_signature(item: &Item) -> String {
200    let inner = match item.inner_for("function") {
201        Some(f) => f,
202        None => return String::new(),
203    };
204
205    let header = inner.get("header");
206    let is_async = header.and_then(|h| h.get("is_async")).and_then(|v| v.as_bool()).unwrap_or(false);
207    let is_const = header.and_then(|h| h.get("is_const")).and_then(|v| v.as_bool()).unwrap_or(false);
208    let is_unsafe = header.and_then(|h| h.get("is_unsafe")).and_then(|v| v.as_bool()).unwrap_or(false);
209
210    let sig = match inner.get("sig") {
211        Some(s) => s,
212        None => return String::new(),
213    };
214
215    let name = item.name.as_deref().unwrap_or("_");
216
217    // Build generic params
218    let generics = inner.get("generics");
219    let generic_str = format_generics(generics);
220
221    // Build params
222    let inputs = sig.get("inputs")
223        .and_then(|v| v.as_array())
224        .map(|inputs| {
225            inputs.iter()
226                .filter_map(|i| i.as_array())
227                .map(|pair| {
228                    let param_name = pair.first().and_then(|v| v.as_str()).unwrap_or("_");
229                    let ty = pair.get(1).map(type_to_string).unwrap_or_else(|| "_".to_string());
230                    // Normalize self receiver to idiomatic form
231                    if param_name == "self" {
232                        match ty.as_str() {
233                            "Self" => "self".to_string(),
234                            "&Self" => "&self".to_string(),
235                            "&mut Self" => "&mut self".to_string(),
236                            _ => format!("self: {ty}"),
237                        }
238                    } else {
239                        format!("{param_name}: {ty}")
240                    }
241                })
242                .collect::<Vec<_>>()
243                .join(", ")
244        })
245        .unwrap_or_default();
246
247    let output = sig.get("output")
248        .filter(|v| !v.is_null())
249        .map(type_to_string);
250
251    let where_str = format_where(generics);
252
253    let mut prefix = String::new();
254    if is_const { prefix.push_str("const "); }
255    if is_async { prefix.push_str("async "); }
256    if is_unsafe { prefix.push_str("unsafe "); }
257
258    let output_str = match &output {
259        Some(s) if s != "()" => format!(" -> {s}"),
260        _ => String::new(),
261    };
262
263    format!("{prefix}fn {name}{generic_str}({inputs}){output_str}{where_str}")
264}
265
266/// Reconstruct a struct's signature fields.
267pub fn struct_fields(item: &Item) -> Vec<String> {
268    let inner = match item.inner_for("struct") {
269        Some(s) => s,
270        None => return vec![],
271    };
272
273    let kind = inner.get("kind");
274    if let Some(plain) = kind.and_then(|k| k.get("plain")) {
275        let fields = plain.get("fields")
276            .and_then(|f| f.as_array())
277            .map(|v| v.as_slice()).unwrap_or(&[]);
278        fields.iter()
279            .filter_map(|id| id.as_str())
280            .map(|_id| "/* field */".to_string()) // IDs need resolution from index
281            .collect()
282    } else {
283        vec![]
284    }
285}
286
287/// Extract generic params from the inner block of any item kind (struct/enum/trait/type alias).
288/// Returns a formatted `<T, 'a, const N: usize>` string, or empty string if none.
289pub fn format_generics_for_item(item: &Item, kind: &str) -> String {
290    for k in &[kind, "struct", "enum", "union", "trait", "type_alias", "typedef"] {
291        if let Some(inner) = item.inner_for(k) {
292            if let Some(generics) = inner.get("generics") {
293                let s = format_generics(Some(generics));
294                if !s.is_empty() {
295                    return s;
296                }
297            }
298        }
299    }
300    String::new()
301}
302
303fn format_generics(generics: Option<&Value>) -> String {
304    let generics = match generics {
305        Some(g) => g,
306        None => return String::new(),
307    };
308    let params = match generics.get("params").and_then(|v| v.as_array()) {
309        Some(p) => p,
310        None => return String::new(),
311    };
312    if params.is_empty() {
313        return String::new();
314    }
315    let parts: Vec<String> = params.iter()
316        .filter_map(|p| {
317            let name = p.get("name")?.as_str()?;
318            // Skip synthetic impl Trait params — they appear as `foo: impl Trait`
319            // in the function inputs and shouldn't be re-emitted in <...>
320            if name.starts_with("impl ") {
321                return None;
322            }
323            let kind = p.get("kind");
324            // Const generic param: {"const": {"type": T, "default": ...}} → `const N: type`
325            if let Some(const_info) = kind.and_then(|k| k.get("const")) {
326                let ty_str = const_info.get("type").map(type_to_string).unwrap_or_else(|| "_".to_string());
327                return Some(format!("const {name}: {ty_str}"));
328            }
329            // Type param: may have bounds
330            if let Some(type_bounds) = kind.and_then(|k| k.get("type")).and_then(|t| t.get("bounds")) {
331                let bounds = type_bounds.as_array()
332                    .map(|bs| {
333                        bs.iter()
334                            .filter_map(|b| b.get("trait_bound"))
335                            .filter_map(|tb| tb.get("trait"))
336                            .map(type_to_string)
337                            .collect::<Vec<_>>()
338                            .join(" + ")
339                    })
340                    .unwrap_or_default();
341                if bounds.is_empty() {
342                    Some(name.to_string())
343                } else {
344                    Some(format!("{name}: {bounds}"))
345                }
346            } else {
347                // Lifetime param (kind = {"lifetime": {...}}) or unbounded type param
348                Some(name.to_string())
349            }
350        })
351        .collect();
352    if parts.is_empty() {
353        String::new()
354    } else {
355        format!("<{}>", parts.join(", "))
356    }
357}
358
359fn format_where(generics: Option<&Value>) -> String {
360    let generics = match generics {
361        Some(g) => g,
362        None => return String::new(),
363    };
364    let clauses = match generics.get("where_predicates").and_then(|v| v.as_array()) {
365        Some(c) => c,
366        None => return String::new(),
367    };
368    if clauses.is_empty() {
369        return String::new();
370    }
371    let parts: Vec<String> = clauses.iter()
372        .filter_map(|c| {
373            if let Some(bp) = c.get("bound_predicate") {
374                let ty = bp.get("type").map(type_to_string)?;
375                let bounds = bp.get("bounds")?.as_array()?;
376                let bound_strs: Vec<String> = bounds.iter()
377                    .filter_map(|b| b.get("trait_bound"))
378                    .filter_map(|tb| tb.get("trait"))
379                    .map(type_to_string)
380                    .collect();
381                if bound_strs.is_empty() {
382                    None
383                } else {
384                    Some(format!("{ty}: {}", bound_strs.join(" + ")))
385                }
386            } else {
387                None
388            }
389        })
390        .collect();
391    if parts.is_empty() {
392        String::new()
393    } else {
394        format!("\nwhere\n    {}", parts.join(",\n    "))
395    }
396}
397
398// ─── Feature flag extraction ──────────────────────────────────────────────────
399
400/// Extract feature requirements from rustdoc JSON item attributes.
401///
402/// Uses the correct v57 attr format: `name: "feature", value: Some("auth")`
403/// NOT the broken `#[cfg(feature = "...")]` pattern.
404///
405/// Cross-references against the set of declared features from the sparse index.
406pub fn extract_feature_requirements(
407    attrs: &[String],
408    declared_features: &HashSet<String>,
409) -> Vec<String> {
410    // Lazy static would be cleaner, but we create the regex once per call
411    // (attrs are small, so this is acceptable)
412    let Ok(re) = Regex::new(r#"name: "feature", value: Some\("([^"]+)"\)"#) else {
413        return vec![];
414    };
415
416    let mut features: Vec<String> = attrs
417        .iter()
418        .flat_map(|attr| {
419            re.captures_iter(attr)
420                .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
421                .collect::<Vec<_>>()
422        })
423        .collect();
424
425    // Cross-reference against declared features (filter out non-feature cfgs)
426    if !declared_features.is_empty() {
427        features.retain(|f| declared_features.contains(f));
428    }
429
430    features.sort();
431    features.dedup();
432    features
433}
434
435// ─── Module tree building ─────────────────────────────────────────────────────
436
437/// A non-module item directly inside a module (used for include_items output).
438#[derive(Debug, Clone)]
439pub struct ItemSummary {
440    pub kind: String,
441    pub name: String,
442    pub doc_summary: String,
443}
444
445#[derive(Debug, Clone)]
446pub struct ModuleNode {
447    pub path: String,
448    pub doc_summary: String,
449    /// Count of each item kind directly inside this module (excludes "use"/"import" noise).
450    pub item_counts: HashMap<String, usize>,
451    /// Direct non-module items (structs, fns, traits, etc.) — populated for include_items.
452    pub items: Vec<ItemSummary>,
453    pub children: Vec<ModuleNode>,
454}
455
456pub fn build_module_tree(doc: &RustdocJson) -> Vec<ModuleNode> {
457    // Find the root module
458    let root_id = doc.root_id();
459    let root_item = doc.index.get(&root_id);
460    if root_item.is_none() {
461        return vec![];
462    }
463
464    // Build children of root
465    if let Some(root) = root_item {
466        if let Some(module) = root.inner_for("module") {
467            let item_ids = module.get("items")
468                .and_then(|v| v.as_array())
469                .cloned()
470                .unwrap_or_default();
471
472            return build_children(&item_ids, doc, 0);
473        }
474    }
475    vec![]
476}
477
478fn id_val_to_string(id_val: &Value) -> Option<String> {
479    match id_val {
480        Value::String(s) => Some(s.clone()),
481        Value::Number(n) => Some(n.to_string()),
482        _ => None,
483    }
484}
485
486fn build_children(item_ids: &[Value], doc: &RustdocJson, depth: usize) -> Vec<ModuleNode> {
487    if depth > 5 {
488        return vec![];
489    }
490
491    let mut modules = vec![];
492    let mut other_counts: HashMap<String, usize> = HashMap::new();
493
494    for id_val in item_ids {
495        // v57 IDs are integers in JSON; the index HashMap has string keys
496        let id = match id_val_to_string(id_val) {
497            Some(s) => s,
498            None => continue,
499        };
500
501        let item = match doc.index.get(&id) {
502            Some(i) => i,
503            None => continue,
504        };
505
506        let kind = item.kind().unwrap_or("unknown");
507
508        if kind == "module" {
509            let path = doc.paths.get(&id)
510                .map(|p| p.full_path())
511                .or_else(|| item.name.clone())
512                .unwrap_or_else(|| id.clone());
513
514            let doc_summary = item.doc_summary();
515
516            let sub_items = item.inner_for("module")
517                .and_then(|m| m.get("items"))
518                .and_then(|v| v.as_array())
519                .cloned()
520                .unwrap_or_default();
521
522            let mut item_counts = HashMap::new();
523            let mut direct_items = vec![];
524            for sub_id_val in &sub_items {
525                if let Some(sub_id) = id_val_to_string(sub_id_val) {
526                    if let Some(sub_item) = doc.index.get(&sub_id) {
527                        if let Some(k) = sub_item.kind() {
528                            // Skip "use"/"import" re-exports from counts — they're noise
529                            // (re-exported items already appear under their canonical path).
530                            if k == "use" || k == "import" { continue; }
531                            *item_counts.entry(k.to_string()).or_insert(0) += 1;
532                            // Collect non-module items for include_items
533                            if k != "module" {
534                                direct_items.push(ItemSummary {
535                                    kind: k.to_string(),
536                                    name: sub_item.name.clone().unwrap_or_default(),
537                                    doc_summary: sub_item.doc_summary(),
538                                });
539                            }
540                        }
541                    }
542                }
543            }
544
545            let children = build_children(&sub_items, doc, depth + 1);
546
547            modules.push(ModuleNode {
548                path,
549                doc_summary,
550                item_counts,
551                items: direct_items,
552                children,
553            });
554        } else {
555            *other_counts.entry(kind.to_string()).or_insert(0) += 1;
556        }
557    }
558
559    modules
560}
561
562// ─── Method parent map ───────────────────────────────────────────────────────
563
564/// Returns the item ID embedded in a rustdoc JSON type node (`resolved_path` or direct id+path).
565fn type_item_id(val: &Value) -> Option<String> {
566    if let Some(rp) = val.get("resolved_path") {
567        return match rp.get("id") {
568            Some(Value::Number(n)) => Some(n.to_string()),
569            Some(Value::String(s)) => Some(s.clone()),
570            _ => None,
571        };
572    }
573    match (val.get("id"), val.get("path")) {
574        (Some(Value::Number(n)), Some(_)) => Some(n.to_string()),
575        (Some(Value::String(s)), Some(_)) => Some(s.clone()),
576        _ => None,
577    }
578}
579
580/// Build a map from method/associated item ID → parent type's full qualified path.
581///
582/// Covers inherent impl blocks. Trait-impl method IDs are intentionally excluded
583/// because they are covered by looking up the implementing type directly.
584fn build_method_parent_map(doc: &RustdocJson) -> HashMap<String, String> {
585    let mut map: HashMap<String, String> = HashMap::new();
586
587    for item in doc.index.values() {
588        if item.kind() != Some("impl") { continue; }
589        let Some(impl_inner) = item.inner_for("impl") else { continue };
590
591        // Inherent impls only (trait field is null/absent)
592        let trait_is_null = impl_inner.get("trait").map(|t| t.is_null()).unwrap_or(true);
593        if !trait_is_null { continue; }
594
595        let Some(for_val) = impl_inner.get("for") else { continue };
596
597        // Resolve the parent type path: try doc.paths first (gives full qualified path),
598        // fall back to type_to_string (gives just the type name).
599        let parent_path = type_item_id(for_val)
600            .and_then(|id| doc.paths.get(&id))
601            .map(|p| p.full_path())
602            .unwrap_or_else(|| type_to_string(for_val));
603
604        if parent_path.is_empty() { continue; }
605
606        let method_ids = impl_inner.get("items")
607            .and_then(|v| v.as_array())
608            .cloned()
609            .unwrap_or_default();
610
611        for method_id_val in &method_ids {
612            if let Some(mid) = id_val_to_string(method_id_val) {
613                map.insert(mid, parent_path.clone());
614            }
615        }
616    }
617
618    map
619}
620
621// ─── Item search ──────────────────────────────────────────────────────────────
622
623pub struct SearchResult {
624    pub path: String,
625    pub kind: String,
626    pub signature: String,
627    pub doc_summary: String,
628    pub feature_requirements: Vec<String>,
629    pub score: f32,
630}
631
632/// Search for items in the rustdoc JSON by name or concept.
633pub fn search_items(
634    doc: &RustdocJson,
635    query: &str,
636    kind_filter: Option<&str>,
637    module_prefix: Option<&str>,
638    limit: usize,
639    declared_features: &HashSet<String>,
640) -> Vec<SearchResult> {
641    let query_lower = query.to_lowercase();
642    let mut results: Vec<SearchResult> = vec![];
643
644    for (id, item) in &doc.index {
645        let path_entry = match doc.paths.get(id) {
646            Some(p) => p,
647            None => continue,
648        };
649
650        let full_path = path_entry.full_path();
651        let name = item.name.as_deref().unwrap_or("");
652        let item_kind = path_entry.kind_name();
653
654        // Kind filter — normalize user-friendly aliases to rustdoc kind names
655        if let Some(kf) = kind_filter {
656            let normalized = match kf {
657                "fn" => "function",
658                "mod" => "module",
659                "type" => "type_alias",
660                other => other,
661            };
662            if item_kind != normalized {
663                continue;
664            }
665        }
666
667        // Module prefix filter
668        if let Some(prefix) = module_prefix {
669            if !full_path.starts_with(prefix) {
670                continue;
671            }
672        }
673
674        // Skip auto-generated or unnamed items
675        if name.is_empty() {
676            continue;
677        }
678
679        let name_lower = name.to_lowercase();
680        let doc_summary = item.doc_summary();
681        let doc_lower = doc_summary.to_lowercase();
682
683        // Score calculation
684        let score = if name_lower == query_lower {
685            1.0f32
686        } else if name_lower.starts_with(&query_lower) {
687            0.9
688        } else if name_lower.contains(&query_lower) {
689            0.7
690        } else if doc_lower.contains(&query_lower) {
691            0.2
692        } else {
693            continue; // no match
694        };
695
696        let signature = match item.kind().unwrap_or("") {
697            "function" => function_signature(item),
698            _ => format!("{} {}", item_kind, name),
699        };
700
701        let feature_requirements = extract_feature_requirements(&item.attr_strings(), declared_features);
702
703        results.push(SearchResult {
704            path: full_path,
705            kind: item_kind.to_string(),
706            signature,
707            doc_summary,
708            feature_requirements,
709            score,
710        });
711    }
712
713    // Second pass: search methods (function items in doc.index but absent from doc.paths).
714    // These are inherent methods on structs/enums, not top-level free functions.
715    // kind="fn"/"function" specifically targets free functions; methods have kind="method".
716    let want_methods = kind_filter.is_none() || kind_filter == Some("method");
717
718    if want_methods {
719        let method_parent_map = build_method_parent_map(doc);
720
721        for (id, item) in &doc.index {
722            if doc.paths.contains_key(id) { continue; } // already searched above
723            if item.kind() != Some("function") { continue; }
724
725            let Some(parent_path) = method_parent_map.get(id) else { continue };
726            let name = item.name.as_deref().unwrap_or("");
727            if name.is_empty() { continue; }
728
729            // Module prefix filter: parent type path must start with the prefix
730            if let Some(prefix) = module_prefix {
731                if !parent_path.starts_with(prefix) { continue; }
732            }
733
734            let name_lower = name.to_lowercase();
735            let parent_lower = parent_path.to_lowercase();
736            let doc_summary = item.doc_summary();
737            let doc_lower = doc_summary.to_lowercase();
738
739            let score = if name_lower == query_lower {
740                1.0f32
741            } else if name_lower.starts_with(&query_lower) {
742                0.9
743            } else if name_lower.contains(&query_lower) {
744                0.7
745            } else if parent_lower.contains(&query_lower) {
746                0.6 // query matches parent type name, e.g. "TokioChildProcess" → all its methods
747            } else if doc_lower.contains(&query_lower) {
748                0.4
749            } else {
750                continue;
751            };
752
753            let full_path = format!("{parent_path}::{name}");
754            let signature = function_signature(item);
755            let feature_requirements = extract_feature_requirements(&item.attr_strings(), declared_features);
756
757            results.push(SearchResult {
758                path: full_path,
759                kind: "method".to_string(),
760                signature,
761                doc_summary,
762                feature_requirements,
763                score,
764            });
765        }
766    }
767
768    // Sort by score descending
769    results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
770    results.truncate(limit);
771    results
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    #[test]
779    fn test_type_to_string_primitive() {
780        let ty = serde_json::json!({"primitive": "str"});
781        assert_eq!(type_to_string(&ty), "str");
782    }
783
784    #[test]
785    fn test_type_to_string_generic() {
786        let ty = serde_json::json!({"generic": "T"});
787        assert_eq!(type_to_string(&ty), "T");
788    }
789
790    #[test]
791    fn test_type_to_string_ref() {
792        let ty = serde_json::json!({
793            "borrowed_ref": {
794                "lifetime": null,
795                "mutable": false,
796                "type": {"primitive": "str"}
797            }
798        });
799        assert_eq!(type_to_string(&ty), "&str");
800    }
801
802    #[test]
803    fn test_type_to_string_mut_ref_with_lifetime() {
804        let ty = serde_json::json!({
805            "borrowed_ref": {
806                "lifetime": "a",
807                "mutable": true,
808                "type": {"generic": "T"}
809            }
810        });
811        assert_eq!(type_to_string(&ty), "&'a mut T");
812    }
813
814    #[test]
815    fn test_type_to_string_tuple() {
816        let ty = serde_json::json!({
817            "tuple": [
818                {"primitive": "i32"},
819                {"primitive": "bool"}
820            ]
821        });
822        assert_eq!(type_to_string(&ty), "(i32, bool)");
823    }
824
825    #[test]
826    fn test_type_to_string_slice() {
827        let ty = serde_json::json!({"slice": {"primitive": "u8"}});
828        assert_eq!(type_to_string(&ty), "[u8]");
829    }
830
831    #[test]
832    fn test_type_to_string_option() {
833        let ty = serde_json::json!({
834            "resolved_path": {
835                "path": "Option",
836                "args": {
837                    "angle_bracketed": {
838                        "args": [
839                            {"type": {"primitive": "i32"}}
840                        ]
841                    }
842                }
843            }
844        });
845        assert_eq!(type_to_string(&ty), "Option<i32>");
846    }
847
848    #[test]
849    fn test_feature_regex_correct_pattern() {
850        let attr = r#"#[attr = CfgTrace([NameValue { name: "feature", value: Some("auth"), span: None }])]"#;
851        let features = extract_feature_requirements(
852            &[attr.to_string()],
853            &HashSet::from(["auth".to_string()]),
854        );
855        assert_eq!(features, vec!["auth"]);
856    }
857
858    #[test]
859    fn test_feature_regex_old_pattern_fails() {
860        // The old broken pattern #[cfg(feature = "...")] would NOT match this format
861        let attr = r#"#[attr = CfgTrace([NameValue { name: "feature", value: Some("auth"), span: None }])]"#;
862        // Old pattern wouldn't extract "auth" from this attr format
863        let old_re = regex::Regex::new(r#"#\[cfg\(feature\s*=\s*"([^"]+)"\)\]"#).unwrap();
864        let matches: Vec<&str> = old_re.captures_iter(attr)
865            .filter_map(|c| c.get(1).map(|m| m.as_str()))
866            .collect();
867        assert!(matches.is_empty(), "Old pattern should NOT match v57 attr format");
868    }
869
870    #[test]
871    fn test_feature_cross_reference() {
872        let attr = r#"#[attr = CfgTrace([NameValue { name: "feature", value: Some("undeclared"), span: None }])]"#;
873        let declared = HashSet::from(["auth".to_string(), "tls".to_string()]);
874        let features = extract_feature_requirements(&[attr.to_string()], &declared);
875        // "undeclared" should be filtered out
876        assert!(features.is_empty());
877    }
878}