Skip to main content

rust_meth/
analyzer.rs

1// Orchestrates the full LSP session:
2//   1. Spawn rust-analyzer
3//   2. initialize / initialized handshake
4//   3. textDocument/didOpen
5//   4. Wait for indexing to complete
6//   5. textDocument/completion (with retry)
7//   6. Extract Method items from the response
8//   7. shutdown / exit
9
10use std::path::PathBuf;
11use std::process::{Command, Stdio};
12use std::sync::OnceLock;
13
14use serde_json::Value;
15
16use crate::lsp::LspTransport;
17use crate::probe::Probe;
18
19/// LSP `CompletionItemKind` value corresponding to a Method.
20const KIND_METHOD: u64 = 2;
21
22static RA_PATH_CACHE: OnceLock<PathBuf> = OnceLock::new();
23
24/// Represents a method extracted from a `rust-analyzer` completion list.
25#[derive(serde::Serialize)]
26pub struct Method {
27    /// The plain name of the method (e.g., `"len"`).
28    pub name: String,
29    /// The full method signature hint provided by the LSP server (e.g., `"pub const fn len(&self) -> usize"`).
30    pub detail: Option<String>,
31    /// Markdown or plaintext documentation extracted from the item.
32    pub documentation: Option<String>,
33}
34
35fn rustup_rust_analyzer() -> Option<PathBuf> {
36    let out = Command::new("rustup")
37        .args(["which", "rust-analyzer"])
38        .output()
39        .ok()?;
40
41    if !out.status.success() {
42        return None;
43    }
44
45    let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
46    (!path.is_empty()).then(|| path.into())
47}
48
49/// Locates the `rust-analyzer` binary.
50///
51/// It first searches the system `PATH` env variable using the system `which` utility.
52/// If missing, it attempts to fall back to the active toolchain's binary directory
53/// using `rustup which rust-analyzer`.
54///
55/// # Errors
56///
57/// Returns an error if `rust-analyzer` cannot be found via either mechanism,
58/// providing user-friendly instructions on how to install it.
59///
60pub fn find_rust_analyzer() -> anyhow::Result<PathBuf> {
61    if let Some(path) = RA_PATH_CACHE.get() {
62        return Ok(path.clone());
63    }
64    let path = if let Ok(path) = which("rust-analyzer") {
65        path
66    } else if let Some(path) = rustup_rust_analyzer() {
67        path
68    } else {
69        anyhow::bail!(
70            "rust-analyzer not found.\n\
71             Install it with: rustup component add rust-analyzer\n\
72             or ensure it is on your PATH."
73        )
74    };
75    Ok(RA_PATH_CACHE.get_or_init(|| path).clone())
76}
77
78#[cfg(unix)]
79fn which(name: &str) -> anyhow::Result<std::path::PathBuf> {
80    let out = Command::new("which").arg(name).output()?;
81    anyhow::ensure!(out.status.success(), "not found");
82    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
83    Ok(s.into())
84}
85
86/// Queries `rust-analyzer` for all available methods on a given type type expression.
87///
88/// This spins up an ephemeral LSP session, generates a mock workspace via a [`Probe`],
89/// triggers a completion request at the appropriate line/column location, and parses the results.
90///
91/// # Environment Variables
92///
93/// * `RUST_METH_DEBUG` - If set, logs raw LSP method lifecycle events to standard error.
94///
95/// # Errors
96///
97/// Returns an error if:
98/// * Spawning the `rust-analyzer` subprocess fails.
99/// * The LSP server communication channels break.
100/// * The server returns an unexpectedly structured or malformed JSON payload.
101// pub fn query_methods(type_name: &str, ra_path: &std::path::Path) -> anyhow::Result<Vec<Method>> {
102//     let probe = Probe::new(type_name)?;
103pub fn query_methods(
104    type_name: &str,
105    ra_path: &std::path::Path,
106    deps: Option<&str>,
107) -> anyhow::Result<Vec<Method>> {
108    let probe = Probe::new_with_deps(type_name, deps)?;
109    let mut child = Command::new(ra_path)
110        .stdin(Stdio::piped())
111        .stdout(Stdio::piped())
112        .stderr(Stdio::null())
113        .spawn()?;
114
115    let mut lsp = LspTransport::new(&mut child);
116    let pid = std::process::id();
117
118    // ── 1. initialize ────────────────────────────────────────────────────────
119    lsp.send(&LspTransport::initialize(pid, &probe.root_uri()))?;
120    lsp.recv_until(20, |msg| {
121        (msg["id"] == 1 && msg["result"].is_object()).then_some(())
122    })?;
123
124    // ── 2. initialized notification ──────────────────────────────────────────
125    lsp.send(&LspTransport::initialized())?;
126
127    // ── 3. didOpen ───────────────────────────────────────────────────────────
128    lsp.send(&LspTransport::did_open(&probe.src_uri(), &probe.source()?))?;
129
130    // ── 4. Wait for RA to finish indexing ────────────────────────────────────
131    wait_for_indexing(&mut lsp)?;
132
133    // ── 5. completion — retry until RA returns items ──────────────────────────
134    // RA may return isIncomplete+empty if it isn't fully ready yet.
135    let completion_response = {
136        let mut response = Value::Null;
137        for attempt in 1..=10u64 {
138            let req_id = attempt + 2;
139            lsp.send(&LspTransport::completion(
140                req_id,
141                &probe.src_uri(),
142                probe.dot_line,
143                probe.dot_col,
144            ))?;
145
146            let msg = lsp.recv_until(50, |msg| (msg["id"] == req_id).then(|| msg.clone()))?;
147
148            let has_items = msg["result"]["items"]
149                .as_array()
150                .is_some_and(|a| !a.is_empty());
151
152            if has_items {
153                response = msg;
154                break;
155            }
156
157            if attempt < 10 {
158                let delay = match attempt {
159                    1 => 50,  // 50ms - RA might be ready immediately
160                    2 => 100, // 100ms
161                    3 => 200, // 200ms
162                    _ => 300, // 300ms for later attempts
163                };
164                std::thread::sleep(std::time::Duration::from_millis(delay));
165            }
166            // if attempt < 10 {
167            //     std::thread::sleep(std::time::Duration::from_millis(500));
168            // }
169        }
170        response
171    };
172
173    // ── 6. shutdown / exit ────────────────────────────────────────────────────
174    lsp.send(&LspTransport::shutdown(13))?;
175    let _ = lsp.recv_until(10, |msg| (msg["id"] == 13).then_some(()));
176    lsp.send(&LspTransport::exit())?;
177    let _ = child.wait();
178
179    // ── 7. Parse completion items ─────────────────────────────────────────────
180    parse_methods(&completion_response)
181}
182
183/// Wait until rust-analyzer is ready to serve completions.
184///
185/// RA doesn't always send $/progress. On fast/warm projects it skips straight
186/// to publishing diagnostics. We treat any of these as "ready":
187///   - $/progress with value.kind == "end"
188///   - experimental/serverStatus with quiescent == true
189///   - workspace/diagnostic/refresh
190///   - textDocument/publishDiagnostics
191fn wait_for_indexing(lsp: &mut LspTransport) -> anyhow::Result<()> {
192    let debug = std::env::var("RUST_METH_DEBUG").is_ok();
193    let start = std::time::Instant::now();
194    let timeout = std::time::Duration::from_secs(10); // Hard timeout
195
196    lsp.recv_until(200, |msg| {
197        // Timeout escape hatch
198        if start.elapsed() > timeout {
199            return Some(()); // Give up and try anyway
200        }
201
202        let method = msg["method"].as_str().unwrap_or("");
203        if debug {
204            eprintln!("[debug] {method}");
205        }
206
207        match method {
208            "$/progress" => {
209                if msg["params"]["value"]["kind"] == "end" {
210                    Some(())
211                } else {
212                    None
213                }
214            }
215            "experimental/serverStatus" => {
216                if msg["params"]["quiescent"] == true {
217                    Some(())
218                } else {
219                    None
220                }
221            }
222            // These are strong signals that indexing is done
223            "textDocument/publishDiagnostics" | "workspace/diagnostic/refresh" => Some(()),
224            _ => None,
225        }
226    })
227    .or(Ok(()))
228}
229
230/// Filters, sanitizes, and deduplicates the raw JSON arrays returned by the LSP completion query.
231///
232/// # Errors
233///
234/// Returns an error if the provided JSON response does not conform to the expected LSP
235/// completion shape (missing both a top-level `result` array and an `items` sub-array).
236pub fn parse_methods(response: &Value) -> anyhow::Result<Vec<Method>> {
237    let result = &response["result"];
238    let items: &[Value] = match result {
239        Value::Array(arr) => arr.as_slice(),
240        obj if obj["items"].is_array() => obj["items"].as_array().map_or(&[], Vec::as_slice),
241        _ => anyhow::bail!("Unexpected completion response shape: {response}"),
242    };
243
244    let mut methods: Vec<Method> = Vec::with_capacity(items.len() / 2);
245
246    for item in items {
247        if item["kind"].as_u64() != Some(KIND_METHOD) {
248            continue;
249        }
250        let name = item["label"]
251            .as_str()
252            .unwrap_or("")
253            .split('(')
254            .next()
255            .unwrap_or("")
256            .trim()
257            .to_string();
258        if name.is_empty() {
259            continue;
260        }
261        methods.push(Method {
262            name,
263            detail: item["detail"].as_str().map(str::to_string),
264            documentation: item["documentation"]["value"].as_str().map(str::to_string),
265        });
266    }
267
268    methods.sort_unstable_by(|a, b| a.name.cmp(&b.name));
269    methods.dedup_by(|a, b| a.name == b.name);
270    Ok(methods)
271}
272
273/// Contains source definition location mappings returned by an LSP `textDocument/definition` call.
274#[must_use]
275pub struct Definition {
276    /// A shortened path string tailored for display terminals (e.g., `"library/core/src/num/uint_macros.rs"`).
277    pub path: String,
278    /// The unadulterated, absolute path prefix on the local filesystem.
279    pub full_path: String,
280    /// 0-indexed line number where the source item is declared.
281    pub line: u32,
282}
283
284/// Queries `rust-analyzer` for the precise upstream source file declaration layout of a specific method.
285///
286/// Under the hood, this sets up a mock environment containing an isolated invocation of your method,
287/// queries `textDocument/definition`, and intercepts the target file location coordinates.
288///
289/// # Errors
290///
291/// Returns an error if the underlying LSP runtime breaks, or if `rust-analyzer` encounters structural errors.
292/// If a method exists but has no discoverable source code location definitions, it evaluates cleanly into `Ok(None)`.
293pub fn query_definition(
294    type_name: &str,
295    method_name: &str,
296    ra_path: &std::path::Path,
297    deps: Option<&str>,
298) -> anyhow::Result<Option<Definition>> {
299    let probe = Probe::for_definition_with_deps(type_name, method_name, deps)?;
300
301    let mut child = Command::new(ra_path)
302        .stdin(Stdio::piped())
303        .stdout(Stdio::piped())
304        .stderr(Stdio::null())
305        .spawn()?;
306
307    let mut lsp = LspTransport::new(&mut child);
308    let pid = std::process::id();
309
310    // Send didOpen immediately after initialized, don't wait
311    lsp.send(&LspTransport::initialize(pid, &probe.root_uri()))?;
312    lsp.recv_until(20, |msg| {
313        (msg["id"] == 1 && msg["result"].is_object()).then_some(())
314    })?;
315
316    // Send both notifications back-to-back (no wait needed)
317    lsp.send(&LspTransport::initialized())?;
318    lsp.send(&LspTransport::did_open(&probe.src_uri(), &probe.source()?))?;
319
320    // Now wait for indexing
321    wait_for_indexing(&mut lsp)?;
322
323    // Retry on "content modified" - RA rejects requests while it's still
324    // processing the file. Same pattern as the completion retry loop.
325    let response = {
326        let mut result = Value::Null;
327        for attempt in 1..=10u64 {
328            let req_id = attempt + 2;
329            lsp.send(&LspTransport::definition(
330                req_id,
331                &probe.src_uri(),
332                probe.dot_line,
333                probe.dot_col,
334            ))?;
335
336            let msg = lsp.recv_until(50, |msg| (msg["id"] == req_id).then(|| msg.clone()))?;
337
338            // -32801 = content modified, -32800 = request cancelled. Both mean retry.
339            let is_error = msg["error"]["code"].as_i64().is_some();
340            let is_null = msg["result"].is_null();
341
342            if !is_error && !is_null {
343                result = msg;
344                break;
345            }
346
347            if attempt < 10 {
348                if std::env::var("RUST_METH_DEBUG").is_ok() {
349                    eprintln!("(attempt {attempt}: not ready, retrying…)");
350                }
351                std::thread::sleep(std::time::Duration::from_millis(500));
352            }
353        }
354        result
355    };
356
357    lsp.send(&LspTransport::shutdown(13))?;
358    let _ = lsp.recv_until(10, |msg| (msg["id"] == 13).then_some(()));
359    lsp.send(&LspTransport::exit())?;
360    let _ = child.wait();
361
362    Ok(parse_definition(&response))
363}
364
365/// Normalizes the location array or object mapping payload returned by the LSP server into a [`Definition`].
366///
367/// # Panics
368///
369/// Panics if the line position value returned by the LSP protocol fails to map cleanly into a `u32`.
370#[must_use]
371pub fn parse_definition(response: &Value) -> Option<Definition> {
372    let result = &response["result"];
373    let location: &Value = match result {
374        Value::Array(arr) if !arr.is_empty() => &arr[0],
375        single if single.is_object() => single,
376        _ => return None,
377    };
378
379    let uri = location["uri"].as_str().unwrap_or("");
380    if uri.is_empty() {
381        return None;
382    }
383
384    let line = u32::try_from(location["range"]["start"]["line"].as_u64().unwrap_or(0))
385        .expect("LSP definition line should fit in u32");
386
387    let full_path_str = uri.strip_prefix("file://").unwrap_or(uri);
388
389    let path = full_path_str
390        .find("/library/")
391        .or_else(|| full_path_str.find("/src/"))
392        .map_or_else(
393            || full_path_str.to_string(),
394            |idx| full_path_str[idx + 1..].to_string(),
395        );
396
397    let full_path = full_path_str.to_string();
398
399    Some(Definition {
400        path,
401        full_path,
402        line,
403    })
404}
405
406#[cfg(test)]
407#[allow(clippy::unwrap_used)]
408mod tests {
409    use super::*;
410    use serde_json::json;
411
412    // ── parse_methods ────────────────────────────────────────────────────────
413
414    #[test]
415    fn parse_methods_empty_items_returns_empty_vec() {
416        let resp = json!({ "result": { "items": [], "isIncomplete": false } });
417        let methods = parse_methods(&resp).unwrap();
418        assert!(methods.is_empty());
419    }
420
421    #[test]
422    fn parse_methods_filters_non_method_kinds() {
423        // kind 2 = Method, kind 5 = Field, kind 9 = Module
424        let resp = json!({
425            "result": {
426                "items": [
427                    { "kind": 2, "label": "len(…)" },
428                    { "kind": 5, "label": "capacity" },
429                    { "kind": 9, "label": "Clone" }
430                ]
431            }
432        });
433        let methods = parse_methods(&resp).unwrap();
434        assert_eq!(methods.len(), 1);
435        assert_eq!(methods[0].name, "len");
436    }
437
438    #[test]
439    fn parse_methods_deduplicates_same_name() {
440        let resp = json!({
441            "result": {
442                "items": [
443                    { "kind": 2, "label": "clone(…)" },
444                    { "kind": 2, "label": "clone(…)" }
445                ]
446            }
447        });
448        let methods = parse_methods(&resp).unwrap();
449        assert_eq!(methods.len(), 1);
450        assert_eq!(methods[0].name, "clone");
451    }
452
453    #[test]
454    fn parse_methods_returns_sorted_names() {
455        let resp = json!({
456            "result": {
457                "items": [
458                    { "kind": 2, "label": "zip(…)" },
459                    { "kind": 2, "label": "map(…)" },
460                    { "kind": 2, "label": "filter(…)" }
461                ]
462            }
463        });
464        let methods = parse_methods(&resp).unwrap();
465        let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect();
466        assert_eq!(names, ["filter", "map", "zip"]);
467    }
468
469    #[test]
470    fn parse_methods_preserves_detail_and_documentation() {
471        let resp = json!({
472            "result": {
473                "items": [{
474                    "kind": 2,
475                    "label": "len(…)",
476                    "detail": "pub fn len(&self) -> usize",
477                    "documentation": { "value": "Returns the number of elements." }
478                }]
479            }
480        });
481        let methods = parse_methods(&resp).unwrap();
482        assert_eq!(methods.len(), 1);
483        assert_eq!(
484            methods[0].detail.as_deref(),
485            Some("pub fn len(&self) -> usize")
486        );
487        assert_eq!(
488            methods[0].documentation.as_deref(),
489            Some("Returns the number of elements.")
490        );
491    }
492
493    #[test]
494    fn parse_methods_no_detail_or_docs_is_none() {
495        let resp = json!({
496            "result": { "items": [{ "kind": 2, "label": "len(…)" }] }
497        });
498        let methods = parse_methods(&resp).unwrap();
499        assert!(methods[0].detail.is_none());
500        assert!(methods[0].documentation.is_none());
501    }
502
503    #[test]
504    fn parse_methods_array_result_form() {
505        // Some LSP servers return `result` as a plain array
506        let resp = json!({
507            "result": [
508                { "kind": 2, "label": "len(…)" },
509                { "kind": 2, "label": "is_empty(…)" }
510            ]
511        });
512        let methods = parse_methods(&resp).unwrap();
513        assert_eq!(methods.len(), 2);
514    }
515
516    #[test]
517    fn parse_methods_skips_empty_label() {
518        let resp = json!({
519            "result": {
520                "items": [
521                    { "kind": 2, "label": "" },
522                    { "kind": 2, "label": "len(…)" }
523                ]
524            }
525        });
526        let methods = parse_methods(&resp).unwrap();
527        assert_eq!(methods.len(), 1);
528        assert_eq!(methods[0].name, "len");
529    }
530
531    #[test]
532    fn parse_methods_unexpected_shape_returns_error() {
533        let resp = json!({ "result": "this_is_not_valid" });
534        assert!(parse_methods(&resp).is_err());
535    }
536
537    // These simulate what rust-analyzer returns for third-party crate types:
538    // the label contains the full signature e.g. `"as_str(…)"`.
539
540    #[test]
541    fn parse_methods_third_party_label_stripped_at_paren() {
542        let resp = json!({
543            "result": {
544                "items": [
545                    { "kind": 2, "label": "as_str(…)", "detail": "pub fn as_str(&self) -> &str" },
546                    { "kind": 2, "label": "as_object(…)" }
547                ]
548            }
549        });
550        let methods = parse_methods(&resp).unwrap();
551        let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect();
552        assert!(names.contains(&"as_str"));
553        assert!(names.contains(&"as_object"));
554    }
555
556    // ── parse_definition ─────────────────────────────────────────────────────
557
558    #[test]
559    fn parse_definition_array_form() {
560        let resp = json!({
561            "result": [{
562                "uri": "file:///home/user/.rustup/toolchains/stable/library/core/src/num/mod.rs",
563                "range": {
564                    "start": { "line": 42, "character": 0 },
565                    "end":   { "line": 42, "character": 10 }
566                }
567            }]
568        });
569        let def = parse_definition(&resp).unwrap();
570        assert_eq!(def.line, 42);
571        assert!(def.path.starts_with("library/"));
572        assert!(!def.full_path.starts_with("file://"));
573    }
574
575    #[test]
576    fn parse_definition_object_form() {
577        let resp = json!({
578            "result": {
579                "uri": "file:///home/user/.rustup/toolchains/stable/library/core/src/str/mod.rs",
580                "range": {
581                    "start": { "line": 99, "character": 4 },
582                    "end":   { "line": 99, "character": 20 }
583                }
584            }
585        });
586        let def = parse_definition(&resp).unwrap();
587        assert_eq!(def.line, 99);
588        assert!(def.path.starts_with("library/"));
589    }
590
591    #[test]
592    fn parse_definition_null_result_returns_none() {
593        let resp = json!({ "result": null });
594        assert!(parse_definition(&resp).is_none());
595    }
596
597    #[test]
598    fn parse_definition_empty_array_returns_none() {
599        let resp = json!({ "result": [] });
600        assert!(parse_definition(&resp).is_none());
601    }
602
603    #[test]
604    fn parse_definition_empty_uri_returns_none() {
605        let resp = json!({
606            "result": [{
607                "uri": "",
608                "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }
609            }]
610        });
611        assert!(parse_definition(&resp).is_none());
612    }
613
614    #[test]
615    fn parse_definition_strips_library_prefix_from_path() {
616        let resp = json!({
617            "result": [{
618                "uri": "file:///home/user/.rustup/toolchains/stable/library/core/src/num/mod.rs",
619                "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }
620            }]
621        });
622        let def = parse_definition(&resp).unwrap();
623        // path should start at "library/" not "/"
624        assert!(def.path.starts_with("library/"));
625        assert!(!def.path.starts_with('/'));
626    }
627
628    #[test]
629    fn parse_definition_src_path_fallback() {
630        // A third-party crate source — has /src/ but no /library/
631        let resp = json!({
632            "result": [{
633                "uri": "file:///home/user/myproject/src/main.rs",
634                "range": { "start": { "line": 5, "character": 0 }, "end": { "line": 5, "character": 0 } }
635            }]
636        });
637        let def = parse_definition(&resp).unwrap();
638        assert!(def.path.starts_with("src/"));
639        assert_eq!(def.line, 5);
640    }
641
642    #[test]
643    fn parse_definition_full_path_does_not_start_with_file_scheme() {
644        let resp = json!({
645            "result": [{
646                "uri": "file:///home/user/project/src/lib.rs",
647                "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 0 } }
648            }]
649        });
650        let def = parse_definition(&resp).unwrap();
651        assert!(!def.full_path.starts_with("file://"));
652        assert!(def.full_path.starts_with('/'));
653    }
654}