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    /// Creates a probe file for go-to-definition with custom dependencies.
86    ///
87    /// # Errors
88    ///
89    /// Returns an [`std::io::Error`] if the underlying project boilerplate, file buffers,
90    /// or custom dependency sections cannot be written.
91    pub fn for_definition_with_deps(
92        type_name: &str,
93        method_name: &str,
94        deps: Option<&str>,
95    ) -> std::io::Result<Self> {
96        Self::create_probe(type_name, Some(method_name), deps)
97    }
98
99    /// Internal probe creation logic shared by all constructors.
100    ///
101    /// # Arguments
102    /// * `type_name` - The Rust type to query
103    /// * `method_name` - If Some, creates a definition probe; if None, creates a completion probe
104    /// * `deps` - Optional TOML dependencies to add to Cargo.toml
105    fn create_probe(
106        type_name: &str,
107        method_name: Option<&str>,
108        deps: Option<&str>,
109    ) -> std::io::Result<Self> {
110        let id = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed);
111        let suffix = method_name.map_or("probe", |_| "probe-def");
112        let dir =
113            std::env::temp_dir().join(format!("rust-meth-{suffix}-{}-{id}", std::process::id()));
114
115        // let dir = std::env::temp_dir().join(format!("rust-meth-{suffix}-{}", std::process::id()));
116        let src_dir = dir.join("src");
117        fs::create_dir_all(&src_dir)?;
118
119        let cargo_toml = deps.map_or_else(|| "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2024\"\n".to_string(), |d| format!(
120                 "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{d}\n"
121             ));
122
123        // Build Cargo.toml with optional dependencies
124
125        fs::write(dir.join("Cargo.toml"), cargo_toml)?;
126
127        // Source file layout (preamble lines + fn main):
128        //
129        //   0..N  preamble use statements
130        //   N+0:  fn main() {
131        //   N+1:      let _x: TYPE = todo!();
132        //   N+2:      _x.         <-- completion trigger after the dot (or _x.METHOD() for definition)
133        //   N+3:  }
134        let preamble_lines =
135            u32::try_from(PREAMBLE.lines().count()).expect("Preamble is too long to fit in u32");
136
137        // Generate source based on whether we're doing completion or definition
138        let source = method_name.map_or_else(|| format!("{PREAMBLE}fn main() {{\n    let _x: {type_name} = todo!();\n    _x.\n}}\n"), |method| format!(
139                      "{PREAMBLE}fn main() {{\n    let _x: {type_name} = todo!();\n    _x.{method}();\n}}\n"
140                 ));
141
142        let src_path = src_dir.join("main.rs");
143        fs::write(&src_path, &source)?;
144
145        // Dot is at preamble_lines + 2, col = len("    _x.")
146        let dot_line = preamble_lines + 2;
147        let dot_col = u32::try_from("    _x.".len()).expect("failed");
148
149        Ok(Self {
150            dir,
151            src_path,
152            dot_line,
153            dot_col,
154        })
155    }
156
157    /// Converts the generated `src/main.rs` file path into a formatted `file://` URI string.
158    ///
159    /// Useful for protocols like LSP that require document paths formatted as URLs.
160    #[must_use]
161    pub fn src_uri(&self) -> String {
162        path_to_uri(&self.src_path)
163    }
164
165    /// Converts the root workspace directory path into a formatted `file://` URI string.
166    #[must_use]
167    pub fn root_uri(&self) -> String {
168        path_to_uri(&self.dir)
169    }
170
171    /// Reads and returns the contents of the source file as a string.
172    ///
173    /// # Errors
174    ///
175    /// This function will return an `Err` if the file cannot be read.
176    /// Common reasons include:
177    /// * The file at `src_path` does not exist.
178    /// * The user lacks permissions to read the file.
179    /// * The file contents are not valid UTF-8.
180    pub fn source(&self) -> std::io::Result<String> {
181        fs::read_to_string(&self.src_path)
182    }
183}
184
185impl Drop for Probe {
186    fn drop(&mut self) {
187        let _ = fs::remove_dir_all(&self.dir);
188    }
189}
190
191fn path_to_uri(path: &Path) -> String {
192    let s = path.to_string_lossy();
193    if s.starts_with('/') {
194        format!("file://{s}")
195    } else {
196        format!("file:///{s}")
197    }
198}
199
200#[cfg(test)]
201#[allow(clippy::unwrap_used)]
202mod tests {
203    use super::*;
204
205    // -- helpers -------------------------------------------------------
206
207    fn preamble_line_count() -> u32 {
208        u32::try_from(PREAMBLE.lines().count()).unwrap()
209    }
210
211    // -- Cargo.toml generation ------------------------------------------
212
213    #[test]
214    fn no_deps_omits_dependencies_section() {
215        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
216        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
217        assert!(
218            !cargo.contains("[dependencies]"),
219            "Cargo.toml should not have a [dependencies] section when deps is None"
220        );
221        assert!(cargo.contains("[package]"));
222        assert!(cargo.contains(r#"name = "probe""#));
223        assert!(cargo.contains(r#"edition = "2024""#));
224    }
225
226    #[test]
227    fn with_deps_injects_dependencies_section() {
228        let p = Probe::new_with_deps("serde_json::Value", Some(r#"serde_json = "1.0""#)).unwrap();
229        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
230        assert!(cargo.contains("[dependencies]"));
231        assert!(cargo.contains(r#"serde_json = "1.0""#));
232    }
233
234    #[test]
235    fn multiple_deps_all_appear_in_cargo_toml() {
236        let deps = "serde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"";
237        let p = Probe::new_with_deps("serde_json::Value", Some(deps)).unwrap();
238        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
239        assert!(cargo.contains("[dependencies]"));
240        assert!(cargo.contains("serde ="));
241        assert!(cargo.contains(r#"serde_json = "1.0""#));
242    }
243
244    // ── source content ───────────────────────────────────────────────────────
245
246    #[test]
247    fn completion_probe_source_has_dot_trigger() {
248        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
249        let src = p.source().unwrap();
250        assert!(
251            src.contains("let _x: Vec<u8> = todo!();"),
252            "source should declare the type"
253        );
254        // ends with `_x.` NOT `_x.something()`
255        assert!(
256            src.contains("    _x.\n"),
257            "completion probe should have bare dot trigger"
258        );
259    }
260
261    #[test]
262    fn definition_probe_source_has_method_call() {
263        let p = Probe::for_definition_with_deps("Vec<u8>", "push", None).unwrap();
264        let src = p.source().unwrap();
265        assert!(src.contains("let _x: Vec<u8> = todo!();"));
266        assert!(
267            src.contains("_x.push();"),
268            "definition probe should contain the method call"
269        );
270    }
271
272    #[test]
273    fn completion_probe_with_deps_type_in_source() {
274        let p = Probe::new_with_deps("serde_json::Value", Some(r#"serde_json = "1.0""#)).unwrap();
275        let src = p.source().unwrap();
276        assert!(src.contains("let _x: serde_json::Value = todo!();"));
277    }
278
279    #[test]
280    fn definition_probe_with_deps_cargo_and_source_correct() {
281        let p = Probe::for_definition_with_deps(
282            "serde_json::Value",
283            "as_str",
284            Some(r#"serde_json = "1.0""#),
285        )
286        .unwrap();
287        let cargo = fs::read_to_string(p.dir.join("Cargo.toml")).unwrap();
288        assert!(cargo.contains("[dependencies]"));
289        let src = p.source().unwrap();
290        assert!(src.contains("serde_json::Value"));
291        assert!(src.contains("_x.as_str();"));
292    }
293
294    // ── dot position ─────────────────────────────────────────────────────────
295
296    #[test]
297    fn dot_col_is_seven() {
298        // "    _x." is always 7 characters
299        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
300        assert_eq!(p.dot_col, 7, r#""    _x." should be 7 chars"#);
301    }
302
303    #[test]
304    fn dot_line_is_preamble_plus_two() {
305        // layout: preamble lines, fn main() {, let _x = …, _x.
306        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
307        assert_eq!(p.dot_line, preamble_line_count() + 2);
308    }
309
310    #[test]
311    fn dot_line_same_for_definition_probe() {
312        let p = Probe::for_definition_with_deps("Vec<u8>", "len", None).unwrap();
313        assert_eq!(p.dot_line, preamble_line_count() + 2);
314    }
315
316    // ── URI helpers ──────────────────────────────────────────────────────────
317
318    #[test]
319    fn src_uri_is_file_uri_ending_in_main_rs() {
320        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
321        let uri = p.src_uri();
322        assert!(
323            uri.starts_with("file://"),
324            "src_uri should be a file:// URI"
325        );
326        assert!(
327            uri.ends_with("/src/main.rs"),
328            "src_uri should end in /src/main.rs"
329        );
330    }
331
332    #[test]
333    fn root_uri_is_file_uri_not_ending_in_main_rs() {
334        let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
335        let uri = p.root_uri();
336        assert!(uri.starts_with("file://"));
337        assert!(!uri.ends_with("main.rs"));
338    }
339
340    // ── cleanup ──────────────────────────────────────────────────────────────
341
342    #[test]
343    fn drop_removes_temp_directory() {
344        let dir = {
345            let p = Probe::new_with_deps("Vec<u8>", None).unwrap();
346            assert!(p.dir.exists(), "dir should exist while probe is alive");
347            p.dir.clone()
348        };
349        assert!(!dir.exists(), "temp dir should be removed after drop");
350    }
351
352    #[test]
353    fn definition_probe_drop_removes_temp_directory() {
354        let dir = {
355            let p = Probe::for_definition_with_deps("Vec<u8>", "len", None).unwrap();
356            p.dir.clone()
357        };
358        assert!(!dir.exists());
359    }
360}