Skip to main content

module_path_extractor/
lib.rs

1extern crate proc_macro;
2
3mod cache;
4mod parser;
5mod pathing;
6
7use std::path::Path;
8
9use cache::{
10    clear_line_cache_for_file, file_fingerprint, get_or_parse_file_modules, store_line_result,
11    CacheLookup,
12};
13use parser::{parse_file_modules, resolve_module_path_from_lines};
14use pathing::normalize_file_path;
15pub use pathing::{
16    module_path_from_file, module_path_from_file_with_root, module_path_to_file,
17    module_root_from_file,
18};
19
20/// Extracts the file path and line number where the macro was invoked.
21pub fn get_source_info() -> Option<(String, usize)> {
22    // `proc_macro` APIs panic when used outside a proc-macro context.
23    // Return `None` instead of panicking so callers can degrade gracefully.
24    let span = std::panic::catch_unwind(proc_macro::Span::call_site).ok()?;
25    let line_number = span.start().line();
26
27    if let Some(local_file) = span.local_file() {
28        return Some((local_file.to_string_lossy().into_owned(), line_number));
29    }
30
31    let file_path = span.file();
32    if file_path.is_empty() {
33        None
34    } else {
35        Some((file_path, line_number))
36    }
37}
38
39/// Reads the file and extracts the module path at the given line.
40pub fn find_module_path(file_path: &str, line_number: usize) -> Option<String> {
41    let normalized_file_path = normalize_file_path(file_path);
42    let fingerprint = file_fingerprint(&normalized_file_path)?;
43
44    match cache::cached_line_result(&normalized_file_path, line_number, fingerprint) {
45        CacheLookup::Fresh(module_path) => return module_path,
46        // Cached line/module mappings are only valid as a set for one file fingerprint.
47        // Once the file changes, drop every cached line for that file before reparsing.
48        CacheLookup::Stale => clear_line_cache_for_file(&normalized_file_path),
49        CacheLookup::Missing => {}
50    }
51
52    let parsed_file = get_or_parse_file_modules(&normalized_file_path, fingerprint)?;
53    let resolved = resolve_module_path_from_lines(
54        &parsed_file.base_module,
55        &parsed_file.line_modules,
56        line_number,
57    );
58
59    store_line_result(
60        &normalized_file_path,
61        line_number,
62        fingerprint,
63        resolved.clone(),
64    );
65
66    resolved
67}
68
69/// Reads the file and extracts the module path at the given line, using a known module root.
70pub fn find_module_path_in_file(
71    file_path: &str,
72    line_number: usize,
73    module_root: &Path,
74) -> Option<String> {
75    let normalized_file_path = normalize_file_path(file_path);
76    let (base_module, line_modules) = parse_file_modules(&normalized_file_path, module_root)?;
77    resolve_module_path_from_lines(&base_module, &line_modules, line_number)
78}
79
80/// Gets the full pseudo-absolute module path of the current macro invocation.
81pub fn get_pseudo_module_path() -> String {
82    get_source_info()
83        .and_then(|(file, line)| find_module_path(&file, line))
84        .unwrap_or_else(|| "unknown".to_string())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::fs;
91    use std::path::{Path, PathBuf};
92    use std::thread;
93    use std::time::Duration;
94    use std::time::{SystemTime, UNIX_EPOCH};
95
96    fn unique_temp_dir(label: &str) -> PathBuf {
97        let nanos = SystemTime::now()
98            .duration_since(UNIX_EPOCH)
99            .expect("clock")
100            .as_nanos();
101        let dir = std::env::temp_dir().join(format!("statum_module_path_{label}_{nanos}"));
102        fs::create_dir_all(&dir).expect("create temp dir");
103        dir
104    }
105
106    fn write_file(path: &Path, contents: &str) {
107        if let Some(parent) = path.parent() {
108            fs::create_dir_all(parent).expect("create parent");
109        }
110        fs::write(path, contents).expect("write file");
111    }
112
113    #[test]
114    fn module_path_from_file_handles_lib_mod_and_nested_paths() {
115        assert_eq!(module_path_from_file("/tmp/project/src/lib.rs"), "crate");
116        assert_eq!(module_path_from_file("/tmp/project/src/main.rs"), "crate");
117        assert_eq!(
118            module_path_from_file("/tmp/project/src/foo/bar.rs"),
119            "foo::bar"
120        );
121        assert_eq!(module_path_from_file("/tmp/project/src/foo/mod.rs"), "foo");
122    }
123
124    #[test]
125    fn module_path_to_file_resolves_crate_rs_and_mod_rs() {
126        let crate_dir = unique_temp_dir("to_file");
127        let src = crate_dir.join("src");
128        let lib = src.join("lib.rs");
129        let workflow = src.join("workflow.rs");
130        let worker_mod = src.join("worker").join("mod.rs");
131
132        write_file(&lib, "pub mod workflow; pub mod worker;");
133        write_file(&workflow, "pub fn run() {}");
134        write_file(&worker_mod, "pub fn spawn() {}");
135
136        let current = workflow.to_string_lossy().into_owned();
137        let module_root = src;
138
139        assert_eq!(
140            module_path_to_file("crate", &current, &module_root),
141            Some(lib.clone())
142        );
143        assert_eq!(
144            module_path_to_file("crate::workflow", &current, &module_root),
145            Some(workflow.clone())
146        );
147        assert_eq!(
148            module_path_to_file("crate::worker", &current, &module_root),
149            Some(worker_mod.clone())
150        );
151
152        let _ = fs::remove_dir_all(crate_dir);
153    }
154
155    #[test]
156    fn find_module_path_in_file_resolves_nested_inline_modules() {
157        let crate_dir = unique_temp_dir("nested_mods");
158        let src = crate_dir.join("src");
159        let lib = src.join("lib.rs");
160
161        write_file(
162            &lib,
163            "mod outer {\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
164        );
165
166        let found = find_module_path_in_file(&lib.to_string_lossy(), 3, &src);
167        assert_eq!(found.as_deref(), Some("outer::inner"));
168
169        let _ = fs::remove_dir_all(crate_dir);
170    }
171
172    #[test]
173    fn find_module_path_in_file_handles_raw_identifier_modules() {
174        let crate_dir = unique_temp_dir("raw_ident_mods");
175        let src = crate_dir.join("src");
176        let lib = src.join("lib.rs");
177
178        write_file(
179            &lib,
180            "#[cfg(any())]\npub(crate) mod r#async {\n    pub mod r#type {\n        pub fn marker() {}\n    }\n}\n",
181        );
182
183        let found = find_module_path_in_file(&lib.to_string_lossy(), 4, &src);
184        assert_eq!(found.as_deref(), Some("r#async::r#type"));
185
186        let _ = fs::remove_dir_all(crate_dir);
187    }
188
189    #[test]
190    fn find_module_path_in_file_ignores_mod_tokens_in_comments_and_raw_strings() {
191        let crate_dir = unique_temp_dir("comments_and_raw_strings");
192        let src = crate_dir.join("src");
193        let lib = src.join("lib.rs");
194
195        write_file(
196            &lib,
197            "const TEMPLATE: &str = r#\"\nmod fake {\n    mod nested {}\n}\n\"#;\n\n/* mod ignored {\n    mod deeper {}\n} */\n\nmod outer {\n    // mod hidden { mod nope {} }\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
198        );
199
200        let found = find_module_path_in_file(&lib.to_string_lossy(), 14, &src);
201        assert_eq!(found.as_deref(), Some("outer::inner"));
202
203        let _ = fs::remove_dir_all(crate_dir);
204    }
205
206    #[test]
207    fn find_module_path_invalidates_stale_line_cache_when_file_changes() {
208        let crate_dir = unique_temp_dir("invalidate_cache");
209        let src = crate_dir.join("src");
210        let lib = src.join("lib.rs");
211
212        write_file(
213            &lib,
214            "mod outer {\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
215        );
216
217        let lib_path = lib.to_string_lossy().to_string();
218        let first = find_module_path(&lib_path, 3);
219        assert_eq!(first.as_deref(), Some("outer::inner"));
220
221        // Ensure the file metadata timestamp has a chance to advance on coarse filesystems.
222        thread::sleep(Duration::from_millis(2));
223        write_file(
224            &lib,
225            "mod changed {\n    mod deeper {\n        pub fn marker() {}\n    }\n}\n",
226        );
227
228        let second = find_module_path(&lib_path, 3);
229        assert_eq!(second.as_deref(), Some("changed::deeper"));
230
231        let _ = fs::remove_dir_all(crate_dir);
232    }
233
234    #[test]
235    fn stale_line_entries_are_replaced_after_file_change() {
236        let crate_dir = unique_temp_dir("stale_line_entries");
237        let src = crate_dir.join("src");
238        let lib = src.join("lib.rs");
239
240        write_file(
241            &lib,
242            "mod outer {\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
243        );
244
245        let lib_path = lib.to_string_lossy().to_string();
246        let _ = find_module_path(&lib_path, 2);
247        let _ = find_module_path(&lib_path, 3);
248        assert_eq!(cache::line_cache_entries_for(&lib_path), 2);
249
250        // Ensure the file metadata timestamp has a chance to advance on coarse filesystems.
251        thread::sleep(Duration::from_millis(2));
252        write_file(
253            &lib,
254            "mod changed {\n    mod deeper {\n        pub fn marker() {}\n    }\n}\n",
255        );
256
257        let refreshed = find_module_path(&lib_path, 3);
258        assert_eq!(refreshed.as_deref(), Some("changed::deeper"));
259        assert_eq!(cache::line_cache_entries_for(&lib_path), 1);
260
261        let second_line = find_module_path(&lib_path, 2);
262        assert_eq!(second_line.as_deref(), Some("changed::deeper"));
263        assert_eq!(cache::line_cache_entries_for(&lib_path), 2);
264
265        let _ = fs::remove_dir_all(crate_dir);
266    }
267}