Skip to main content

rust_meth/
probe.rs

1//! A utility for generating ephemeral, minimal Cargo projects ("probes") used to query
2//! Language Server Protocol (LSP) intelligence like autocompletions or go-to-definitions.
3//!
4//! A `Probe` creates a temporary directory containing a valid Cargo package with a single source file.
5//! The source file declares an isolated variable statement `let _x: TYPE = todo!();` followed by a target
6//! interaction point (such as `_x.` or `_x.method()`).
7//!
8//! When the [`Probe`] instance goes out of scope, its [`Drop`] implementation automatically deletes
9//! the entire temporary directory and its contents from the disk.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14
15/// Global atomic counter ensuring that concurrently generated probe projects
16/// receive unique names within the OS temporary directory.
17static PROBE_COUNTER: AtomicU64 = AtomicU64::new(0);
18
19// Preamble added to every probe file so common std types resolve without
20// the user needing to fully qualify them (e.g. `HashMap` not `std::collections::HashMap`).
21const PREAMBLE: &str = "\
22#![allow(unused_imports)]
23use std::collections::*;
24use std::sync::*;
25use std::cell::*;
26use std::rc::Rc;
27use std::io::{self, Read, Write, BufRead};
28use std::fmt;
29use std::ops::*;
30use std::path::{Path, PathBuf};
31";
32
33/// Represents an ephemeral Cargo project written to disk for LSP interrogation.
34///
35/// Deletes itself automatically when dropped.
36pub struct Probe {
37    /// The absolute path to the root directory of the temporary Cargo project.
38    pub dir: PathBuf,
39    /// The absolute path to the generated `src/main.rs` file.
40    pub src_path: PathBuf,
41    /// The 0-indexed line number in `src/main.rs` pointing to the target interaction point (the dot trigger).
42    pub dot_line: u32,
43    /// The 0-indexed character/column offset pointing exactly after the dot (`_x.`) in `src/main.rs`.
44    pub dot_col: u32,
45}
46
47impl Probe {
48    /// Creates a new probe project without dependencies (for stdlib types).
49    ///
50    /// # Errors
51    ///
52    /// Returns an [`std::io::Error`] if creating the underlying probe project directory
53    /// or writing its files fails.
54    #[allow(dead_code)]
55    pub fn new(type_name: &str) -> std::io::Result<Self> {
56        Self::create_probe(type_name, None, None)
57    }
58
59    /// Creates a new probe project with optional dependencies (for 3rd party crates).
60    ///
61    /// # Arguments
62    /// * `type_name` - The Rust type to query (e.g., "`Vec<u8>`", "`serde_json::Value`")
63    /// * `deps` - Optional TOML dependencies section (e.g., "`serde_json` = \"1.0\"")
64    ///
65    /// # Errors
66    ///
67    /// Returns an [`std::io::Error`] if generating the probe files or writing the dependency
68    /// configuration fails.
69    pub fn new_with_deps(type_name: &str, deps: Option<&str>) -> std::io::Result<Self> {
70        Self::create_probe(type_name, None, deps)
71    }
72
73    /// Creates a probe file with `_x.METHOD_NAME()` for go-to-definition queries.
74    /// The cursor position points at the start of the method name.
75    ///
76    /// # Errors
77    ///
78    /// Returns an [`std::io::Error`] if the workspace initialization or file creation fails
79    /// on disk.
80    #[allow(dead_code)]
81    pub fn for_definition(type_name: &str, method_name: &str) -> std::io::Result<Self> {
82        Self::create_probe(type_name, Some(method_name), None)
83    }
84
85    /// Infers a minimal Cargo dependency string from a type path when no explicit
86    /// `--deps` argument is provided.
87    ///
88    /// Returns `Some("crate_name = \"*\"")` if the leading path segment starts with
89    /// a lowercase letter (indicating a third-party crate), or `None` for stdlib/
90    /// primitive types whose leading segment is uppercase (e.g. `Vec`, `HashMap`).
91    ///
92    /// # Examples
93    /// ```text
94    /// assert_eq!(infer_dep("serde_json::Value"), Some(r#"serde_json = "*""#.into()));
95    /// assert_eq!(infer_dep("Vec<u8>"),           None);
96    /// assert_eq!(infer_dep("HashMap<K, V>"),     None);
97    /// ```
98    fn infer_dep(type_name: &str) -> Option<String> {
99        let crate_name = type_name.split("::").next()?;
100        if crate_name.chars().next()?.is_ascii_lowercase() {
101            Some(format!(r#"{crate_name} = "*""#))
102        } else {
103            None
104        }
105    }
106    /// Creates a probe file for go-to-definition with custom dependencies.
107    ///
108    /// # Errors
109    ///
110    /// Returns an [`std::io::Error`] if the underlying project boilerplate, file buffers,
111    /// or custom dependency sections cannot be written.
112    pub fn for_definition_with_deps(
113        type_name: &str,
114        method_name: &str,
115        deps: Option<&str>,
116    ) -> std::io::Result<Self> {
117        Self::create_probe(type_name, Some(method_name), deps)
118    }
119
120    /// Internal probe creation logic shared by all constructors.
121    ///
122    /// # Arguments
123    /// * `type_name` - The Rust type to query
124    /// * `method_name` - If Some, creates a definition probe; if None, creates a completion probe
125    /// * `deps` - Optional TOML dependencies to add to Cargo.toml
126    fn create_probe(
127        type_name: &str,
128        method_name: Option<&str>,
129        deps: Option<&str>,
130    ) -> std::io::Result<Self> {
131        let id = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed);
132        let suffix = method_name.map_or("probe", |_| "probe-def");
133        let dir =
134            std::env::temp_dir().join(format!("rust-meth-{suffix}-{}-{id}", std::process::id()));
135
136        // let dir = std::env::temp_dir().join(format!("rust-meth-{suffix}-{}", std::process::id()));
137        let src_dir = dir.join("src");
138        fs::create_dir_all(&src_dir)?;
139
140        let effective_deps = deps
141            .map(str::to_owned)
142            .or_else(|| Self::infer_dep(type_name));
143
144        let cargo_toml = effective_deps.map_or_else(
145        || "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2024\"\n".to_string(),
146        |d| format!(
147            "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{d}\n"
148        ),
149    );
150
151        // Build Cargo.toml with optional dependencies
152
153        fs::write(dir.join("Cargo.toml"), cargo_toml)?;
154
155        // Source file layout (preamble lines + fn main):
156        //
157        //   0..N  preamble use statements
158        //   N+0:  fn main() {
159        //   N+1:      let _x: TYPE = todo!();
160        //   N+2:      _x.         <-- completion trigger after the dot (or _x.METHOD() for definition)
161        //   N+3:  }
162        let preamble_lines =
163            u32::try_from(PREAMBLE.lines().count()).expect("Preamble is too long to fit in u32");
164
165        // Generate source based on whether we're doing completion or definition
166        let source = method_name.map_or_else(|| format!("{PREAMBLE}fn main() {{\n    let _x: {type_name} = todo!();\n    _x.\n}}\n"), |method| format!(
167                      "{PREAMBLE}fn main() {{\n    let _x: {type_name} = todo!();\n    _x.{method}();\n}}\n"
168                 ));
169
170        let src_path = src_dir.join("main.rs");
171        fs::write(&src_path, &source)?;
172
173        // Dot is at preamble_lines + 2, col = len("    _x.")
174        let dot_line = preamble_lines + 2;
175        let dot_col = u32::try_from("    _x.".len()).expect("failed");
176
177        Ok(Self {
178            dir,
179            src_path,
180            dot_line,
181            dot_col,
182        })
183    }
184
185    /// Converts the generated `src/main.rs` file path into a formatted `file://` URI string.
186    ///
187    /// Useful for protocols like LSP that require document paths formatted as URLs.
188    #[must_use]
189    pub fn src_uri(&self) -> String {
190        path_to_uri(&self.src_path)
191    }
192
193    /// Converts the root workspace directory path into a formatted `file://` URI string.
194    #[must_use]
195    pub fn root_uri(&self) -> String {
196        path_to_uri(&self.dir)
197    }
198
199    /// Reads and returns the contents of the source file as a string.
200    ///
201    /// # Errors
202    ///
203    /// This function will return an `Err` if the file cannot be read.
204    /// Common reasons include:
205    /// * The file at `src_path` does not exist.
206    /// * The user lacks permissions to read the file.
207    /// * The file contents are not valid UTF-8.
208    pub fn source(&self) -> std::io::Result<String> {
209        fs::read_to_string(&self.src_path)
210    }
211}
212
213impl Drop for Probe {
214    fn drop(&mut self) {
215        let _ = fs::remove_dir_all(&self.dir);
216    }
217}
218
219fn path_to_uri(path: &Path) -> String {
220    let s = path.to_string_lossy();
221    if s.starts_with('/') {
222        format!("file://{s}")
223    } else {
224        format!("file:///{s}")
225    }
226}
227
228#[cfg(test)]
229#[allow(clippy::unwrap_used)]
230mod tests {
231    use super::*;
232
233    // -- helpers -------------------------------------------------------
234
235    fn preamble_line_count() -> u32 {
236        u32::try_from(PREAMBLE.lines().count()).unwrap()
237    }
238
239    // -- Cargo.toml generation ------------------------------------------
240
241    #[test]
242    fn no_deps_omits_dependencies_section() {
243        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
244        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
245        assert!(
246            !cargo.contains("[dependencies]"),
247            "Cargo.toml should not have a [dependencies] section when deps is None"
248        );
249        assert!(cargo.contains("[package]"));
250        assert!(cargo.contains(r#"name = "probe""#));
251        assert!(cargo.contains(r#"edition = "2024""#));
252    }
253
254    #[test]
255    fn with_deps_injects_dependencies_section() {
256        let p = Probe::new_with_deps("serde_json::Value", Some(r#"serde_json = "1.0""#)).unwrap();
257        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
258        assert!(cargo.contains("[dependencies]"));
259        assert!(cargo.contains(r#"serde_json = "1.0""#));
260    }
261
262    #[test]
263    fn multiple_deps_all_appear_in_cargo_toml() {
264        let deps = "serde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"";
265        let p = Probe::new_with_deps("serde_json::Value", Some(deps)).unwrap();
266        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
267        assert!(cargo.contains("[dependencies]"));
268        assert!(cargo.contains("serde ="));
269        assert!(cargo.contains(r#"serde_json = "1.0""#));
270    }
271
272    // ── source content ───────────────────────────────────────────────────────
273
274    #[test]
275    fn completion_probe_source_has_dot_trigger() {
276        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
277        let src = p.source().unwrap();
278        assert!(
279            src.contains("let _x: Vec<u8> = todo!();"),
280            "source should declare the type"
281        );
282        // ends with `_x.` NOT `_x.something()`
283        assert!(
284            src.contains("    _x.\n"),
285            "completion probe should have bare dot trigger"
286        );
287    }
288
289    #[test]
290    fn definition_probe_source_has_method_call() {
291        let p = Probe::for_definition_with_deps("Vec<u8>", "push", None).unwrap();
292        let src = p.source().unwrap();
293        assert!(src.contains("let _x: Vec<u8> = todo!();"));
294        assert!(
295            src.contains("_x.push();"),
296            "definition probe should contain the method call"
297        );
298    }
299
300    #[test]
301    fn completion_probe_with_deps_type_in_source() {
302        let p = Probe::new_with_deps("serde_json::Value", Some(r#"serde_json = "1.0""#)).unwrap();
303        let src = p.source().unwrap();
304        assert!(src.contains("let _x: serde_json::Value = todo!();"));
305    }
306
307    #[test]
308    fn definition_probe_with_deps_cargo_and_source_correct() {
309        let p = Probe::for_definition_with_deps(
310            "serde_json::Value",
311            "as_str",
312            Some(r#"serde_json = "1.0""#),
313        )
314        .unwrap();
315        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
316        assert!(cargo.contains("[dependencies]"));
317        let src = p.source().unwrap();
318        assert!(src.contains("serde_json::Value"));
319        assert!(src.contains("_x.as_str();"));
320    }
321
322    // ── dot position ─────────────────────────────────────────────────────────
323
324    #[test]
325    fn dot_col_is_seven() {
326        // "    _x." is always 7 characters
327        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
328        assert_eq!(p.dot_col, 7, r#""    _x." should be 7 chars"#);
329    }
330
331    #[test]
332    fn dot_line_is_preamble_plus_two() {
333        // layout: preamble lines, fn main() {, let _x = …, _x.
334        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
335        assert_eq!(p.dot_line, preamble_line_count() + 2);
336    }
337
338    #[test]
339    fn dot_line_same_for_definition_probe() {
340        let p = Probe::for_definition_with_deps("Vec<u8>", "len", None).unwrap();
341        assert_eq!(p.dot_line, preamble_line_count() + 2);
342    }
343
344    // ── URI helpers ──────────────────────────────────────────────────────────
345
346    #[test]
347    fn src_uri_is_file_uri_ending_in_main_rs() {
348        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
349        let uri = p.src_uri();
350        assert!(
351            uri.starts_with("file://"),
352            "src_uri should be a file:// URI"
353        );
354        assert!(
355            uri.ends_with("/src/main.rs"),
356            "src_uri should end in /src/main.rs"
357        );
358    }
359
360    #[test]
361    fn root_uri_is_file_uri_not_ending_in_main_rs() {
362        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
363        let uri = p.root_uri();
364        assert!(uri.starts_with("file://"));
365        assert!(!uri.ends_with("main.rs"));
366    }
367
368    // ── cleanup ──────────────────────────────────────────────────────────────
369
370    #[test]
371    fn drop_removes_temp_directory() {
372        let dir = {
373            let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
374            assert!(p.dir.exists(), "dir should exist while probe is alive");
375            p.dir.clone()
376        };
377        assert!(!dir.exists(), "temp dir should be removed after drop");
378    }
379
380    #[test]
381    fn definition_probe_drop_removes_temp_directory() {
382        let dir = {
383            let p = Probe::for_definition_with_deps("Vec<u8>", "len", None).unwrap();
384            p.dir.clone()
385        };
386        assert!(!dir.exists());
387    }
388}