Skip to main content

module_path_extractor/
lib.rs

1extern crate proc_macro;
2
3mod cache;
4mod parser;
5mod pathing;
6
7#[cfg(doctest)]
8#[doc = include_str!("../README.md")]
9mod readme_doctests {}
10
11use std::path::Path;
12
13use crate::cache::{
14    clear_line_cache_for_file, file_fingerprint, get_or_parse_file_modules, store_line_result,
15};
16use parser::{parse_file_modules, resolve_module_path_from_lines};
17use pathing::normalize_file_path;
18pub use pathing::{
19    module_path_from_file, module_path_from_file_with_root, module_path_to_file,
20    module_root_from_file,
21};
22
23/// Extracts the file path and line number where the macro was invoked.
24pub fn get_source_info() -> Option<(String, usize)> {
25    // `proc_macro` APIs panic when used outside a proc-macro context.
26    // Return `None` instead of panicking so callers can degrade gracefully.
27    let span = std::panic::catch_unwind(proc_macro::Span::call_site).ok()?;
28    let line_number = span.start().line();
29
30    if let Some(local_file) = span.local_file() {
31        return Some((local_file.to_string_lossy().into_owned(), line_number));
32    }
33
34    let file_path = span.file();
35    if file_path.is_empty() {
36        None
37    } else {
38        Some((file_path, line_number))
39    }
40}
41
42/// Reads the file and extracts the module path at the given line.
43pub fn find_module_path(file_path: &str, line_number: usize) -> Option<String> {
44    let normalized_file_path = normalize_file_path(file_path);
45    let fingerprint = file_fingerprint(&normalized_file_path)?;
46
47    match cache::cached_line_result(&normalized_file_path, line_number, fingerprint) {
48        cache::CacheLookup::Fresh(module_path) => return module_path,
49        // Cached line/module mappings are only valid as a set for one file fingerprint.
50        // Once the file changes, drop every cached line for that file before reparsing.
51        cache::CacheLookup::Stale => clear_line_cache_for_file(&normalized_file_path),
52        cache::CacheLookup::Missing => {}
53    }
54
55    let parsed_file = get_or_parse_file_modules(&normalized_file_path, fingerprint)?;
56    let resolved = resolve_module_path_from_lines(
57        &parsed_file.base_module,
58        &parsed_file.line_modules,
59        line_number,
60    );
61
62    store_line_result(
63        &normalized_file_path,
64        line_number,
65        fingerprint,
66        resolved.clone(),
67    );
68
69    resolved
70}
71
72/// Reads the file and extracts the module path at the given line, using a known module root.
73pub fn find_module_path_in_file(
74    file_path: &str,
75    line_number: usize,
76    module_root: &Path,
77) -> Option<String> {
78    let normalized_file_path = normalize_file_path(file_path);
79    let (base_module, line_modules) = parse_file_modules(&normalized_file_path, module_root)?;
80    resolve_module_path_from_lines(&base_module, &line_modules, line_number)
81}
82
83/// Gets the full pseudo-absolute module path of the current macro invocation.
84pub fn get_pseudo_module_path() -> String {
85    get_source_info()
86        .and_then(|(file, line)| find_module_path(&file, line))
87        .unwrap_or_else(|| "unknown".to_string())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::fs;
94    use std::path::{Path, PathBuf};
95    use std::thread;
96    use std::time::Duration;
97    use std::time::{SystemTime, UNIX_EPOCH};
98
99    fn unique_temp_dir(label: &str) -> PathBuf {
100        let nanos = SystemTime::now()
101            .duration_since(UNIX_EPOCH)
102            .expect("clock")
103            .as_nanos();
104        let dir = std::env::temp_dir().join(format!("statum_module_path_{label}_{nanos}"));
105        fs::create_dir_all(&dir).expect("create temp dir");
106        dir
107    }
108
109    fn write_file(path: &Path, contents: &str) {
110        if let Some(parent) = path.parent() {
111            fs::create_dir_all(parent).expect("create parent");
112        }
113        fs::write(path, contents).expect("write file");
114    }
115
116    #[test]
117    fn module_path_from_file_handles_lib_mod_and_nested_paths() {
118        assert_eq!(module_path_from_file("/tmp/project/src/lib.rs"), "crate");
119        assert_eq!(module_path_from_file("/tmp/project/src/main.rs"), "crate");
120        assert_eq!(
121            module_path_from_file("/tmp/project/src/foo/bar.rs"),
122            "foo::bar"
123        );
124        assert_eq!(module_path_from_file("/tmp/project/src/foo/mod.rs"), "foo");
125    }
126
127    #[test]
128    fn module_path_to_file_resolves_crate_rs_and_mod_rs() {
129        let crate_dir = unique_temp_dir("to_file");
130        let src = crate_dir.join("src");
131        let lib = src.join("lib.rs");
132        let workflow = src.join("workflow.rs");
133        let worker_mod = src.join("worker").join("mod.rs");
134
135        write_file(&lib, "pub mod workflow; pub mod worker;");
136        write_file(&workflow, "pub fn run() {}");
137        write_file(&worker_mod, "pub fn spawn() {}");
138
139        let current = workflow.to_string_lossy().into_owned();
140        let module_root = src;
141
142        assert_eq!(
143            module_path_to_file("crate", &current, &module_root),
144            Some(lib.clone())
145        );
146        assert_eq!(
147            module_path_to_file("crate::workflow", &current, &module_root),
148            Some(workflow.clone())
149        );
150        assert_eq!(
151            module_path_to_file("crate::worker", &current, &module_root),
152            Some(worker_mod.clone())
153        );
154
155        let _ = fs::remove_dir_all(crate_dir);
156    }
157
158    #[test]
159    fn find_module_path_in_file_resolves_nested_inline_modules() {
160        let crate_dir = unique_temp_dir("nested_mods");
161        let src = crate_dir.join("src");
162        let lib = src.join("lib.rs");
163
164        write_file(
165            &lib,
166            "mod outer {\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
167        );
168
169        let found = find_module_path_in_file(&lib.to_string_lossy(), 3, &src);
170        assert_eq!(found.as_deref(), Some("outer::inner"));
171
172        let _ = fs::remove_dir_all(crate_dir);
173    }
174
175    #[test]
176    fn find_module_path_in_file_handles_raw_identifier_modules() {
177        let crate_dir = unique_temp_dir("raw_ident_mods");
178        let src = crate_dir.join("src");
179        let lib = src.join("lib.rs");
180
181        write_file(
182            &lib,
183            "#[cfg(any())]\npub(crate) mod r#async {\n    pub mod r#type {\n        pub fn marker() {}\n    }\n}\n",
184        );
185
186        let found = find_module_path_in_file(&lib.to_string_lossy(), 4, &src);
187        assert_eq!(found.as_deref(), Some("r#async::r#type"));
188
189        let _ = fs::remove_dir_all(crate_dir);
190    }
191
192    #[test]
193    fn find_module_path_in_file_separates_sibling_modules_with_similar_shapes() {
194        let crate_dir = unique_temp_dir("sibling_modules");
195        let src = crate_dir.join("src");
196        let lib = src.join("lib.rs");
197
198        write_file(
199            &lib,
200            "mod alpha {\n    mod support {\n        pub struct Text;\n    }\n\n    pub enum WorkflowState {\n        Draft,\n    }\n\n    pub struct Row {\n        pub status: &'static str,\n    }\n}\n\nmod beta {\n    mod support {\n        pub struct Text;\n    }\n\n    pub enum WorkflowState {\n        Draft,\n    }\n\n    pub struct Row {\n        pub status: &'static str,\n    }\n}\n",
201        );
202
203        assert_eq!(
204            find_module_path_in_file(&lib.to_string_lossy(), 6, &src).as_deref(),
205            Some("alpha")
206        );
207        assert_eq!(
208            find_module_path_in_file(&lib.to_string_lossy(), 20, &src).as_deref(),
209            Some("beta")
210        );
211        assert_eq!(
212            find_module_path_in_file(&lib.to_string_lossy(), 19, &src).as_deref(),
213            Some("beta")
214        );
215
216        let _ = fs::remove_dir_all(crate_dir);
217    }
218
219    #[test]
220    fn find_module_path_in_file_ignores_mod_tokens_in_comments_and_raw_strings() {
221        let crate_dir = unique_temp_dir("comments_and_raw_strings");
222        let src = crate_dir.join("src");
223        let lib = src.join("lib.rs");
224
225        write_file(
226            &lib,
227            "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",
228        );
229
230        let found = find_module_path_in_file(&lib.to_string_lossy(), 14, &src);
231        assert_eq!(found.as_deref(), Some("outer::inner"));
232
233        let _ = fs::remove_dir_all(crate_dir);
234    }
235
236    #[test]
237    fn find_module_path_in_file_ignores_modules_inside_macro_rules_bodies() {
238        let crate_dir = unique_temp_dir("macro_rules_body");
239        let src = crate_dir.join("src");
240        let lib = src.join("lib.rs");
241
242        write_file(
243            &lib,
244            "mod outer {\n    macro_rules! generated {\n        () => {\n            mod fake {\n                pub fn hidden() {}\n            }\n        };\n    }\n}\n",
245        );
246
247        assert_eq!(
248            find_module_path_in_file(&lib.to_string_lossy(), 5, &src).as_deref(),
249            Some("outer")
250        );
251
252        let _ = fs::remove_dir_all(crate_dir);
253    }
254
255    #[test]
256    fn find_module_path_in_file_ignores_modules_inside_macro_invocation_bodies() {
257        let crate_dir = unique_temp_dir("macro_invocation_body");
258        let src = crate_dir.join("src");
259        let lib = src.join("lib.rs");
260
261        write_file(
262            &lib,
263            "mod outer {\n    generated! {\n        mod fake {\n            pub fn hidden() {}\n        }\n    }\n\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
264        );
265
266        assert_eq!(
267            find_module_path_in_file(&lib.to_string_lossy(), 4, &src).as_deref(),
268            Some("outer")
269        );
270        assert_eq!(
271            find_module_path_in_file(&lib.to_string_lossy(), 8, &src).as_deref(),
272            Some("outer::inner")
273        );
274
275        let _ = fs::remove_dir_all(crate_dir);
276    }
277
278    #[test]
279    fn find_module_path_in_file_ignores_modules_inside_macro_invocations_for_all_delimiters() {
280        let crate_dir = unique_temp_dir("macro_invocation_delimiters");
281        let src = crate_dir.join("src");
282        let lib = src.join("lib.rs");
283
284        for (label, open, close) in [
285            ("brace", "{", "}"),
286            ("paren", "(", ")"),
287            ("bracket", "[", "]"),
288        ] {
289            write_file(
290                &lib,
291                &format!(
292                    "mod outer {{\n    generated!{open}\n        mod fake {{\n            pub fn hidden() {{}}\n        }}\n    {close};\n\n    mod inner {{\n        pub fn marker() {{}}\n    }}\n}}\n"
293                ),
294            );
295
296            assert_eq!(
297                find_module_path_in_file(&lib.to_string_lossy(), 4, &src).as_deref(),
298                Some("outer"),
299                "fake module should stay opaque for {label} delimiter"
300            );
301            assert_eq!(
302                find_module_path_in_file(&lib.to_string_lossy(), 9, &src).as_deref(),
303                Some("outer::inner"),
304                "real nested module should resolve for {label} delimiter"
305            );
306        }
307
308        let _ = fs::remove_dir_all(crate_dir);
309    }
310
311    #[test]
312    fn find_module_path_invalidates_stale_line_cache_when_file_changes() {
313        let crate_dir = unique_temp_dir("invalidate_cache");
314        let src = crate_dir.join("src");
315        let lib = src.join("lib.rs");
316
317        write_file(
318            &lib,
319            "mod outer {\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
320        );
321
322        let lib_path = lib.to_string_lossy().to_string();
323        let first = find_module_path(&lib_path, 3);
324        assert_eq!(first.as_deref(), Some("outer::inner"));
325
326        // Ensure the file metadata timestamp has a chance to advance on coarse filesystems.
327        thread::sleep(Duration::from_millis(2));
328        write_file(
329            &lib,
330            "mod changed {\n    mod deeper {\n        pub fn marker() {}\n    }\n}\n",
331        );
332
333        let second = find_module_path(&lib_path, 3);
334        assert_eq!(second.as_deref(), Some("changed::deeper"));
335
336        let _ = fs::remove_dir_all(crate_dir);
337    }
338
339    #[test]
340    fn stale_line_entries_are_replaced_after_file_change() {
341        let crate_dir = unique_temp_dir("stale_line_entries");
342        let src = crate_dir.join("src");
343        let lib = src.join("lib.rs");
344
345        write_file(
346            &lib,
347            "mod outer {\n    mod inner {\n        pub fn marker() {}\n    }\n}\n",
348        );
349
350        let lib_path = lib.to_string_lossy().to_string();
351        let _ = find_module_path(&lib_path, 2);
352        let _ = find_module_path(&lib_path, 3);
353        assert_eq!(cache::line_cache_entries_for(&lib_path), 2);
354
355        // Ensure the file metadata timestamp has a chance to advance on coarse filesystems.
356        thread::sleep(Duration::from_millis(2));
357        write_file(
358            &lib,
359            "mod changed {\n    mod deeper {\n        pub fn marker() {}\n    }\n}\n",
360        );
361
362        let refreshed = find_module_path(&lib_path, 3);
363        assert_eq!(refreshed.as_deref(), Some("changed::deeper"));
364        assert_eq!(cache::line_cache_entries_for(&lib_path), 1);
365
366        let second_line = find_module_path(&lib_path, 2);
367        assert_eq!(second_line.as_deref(), Some("changed::deeper"));
368        assert_eq!(cache::line_cache_entries_for(&lib_path), 2);
369
370        let _ = fs::remove_dir_all(crate_dir);
371    }
372
373    #[test]
374    fn find_module_path_handles_non_src_fixture_files_with_nested_modules() {
375        let crate_dir = unique_temp_dir("non_src_fixture");
376        let tests_ui = crate_dir.join("tests").join("ui");
377        let fixture = tests_ui.join("fixture.rs");
378
379        write_file(
380            &fixture,
381            "pub mod public_flow {\n    #[allow(dead_code)]\n    pub struct Machine;\n}\n",
382        );
383
384        assert_eq!(
385            find_module_path(&fixture.to_string_lossy(), 3).as_deref(),
386            Some("fixture::public_flow")
387        );
388
389        let _ = fs::remove_dir_all(crate_dir);
390    }
391
392    #[test]
393    fn find_module_path_handles_nested_trybuild_style_fixture() {
394        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
395            .join("../statum-macros/tests/ui/valid_helper_trait_visibility.rs");
396
397        assert_eq!(
398            find_module_path(&fixture.to_string_lossy(), 30).as_deref(),
399            Some("valid_helper_trait_visibility::public_flow")
400        );
401        assert_eq!(
402            find_module_path(&fixture.to_string_lossy(), 39).as_deref(),
403            Some("valid_helper_trait_visibility::public_flow")
404        );
405        assert_eq!(
406            find_module_path(&fixture.to_string_lossy(), 103).as_deref(),
407            Some("valid_helper_trait_visibility::crate_flow")
408        );
409    }
410
411    #[test]
412    fn find_module_path_handles_sibling_trybuild_modules() {
413        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
414            .join("../statum-macros/tests/ui/valid_matrix.rs");
415
416        assert_eq!(
417            find_module_path(&fixture.to_string_lossy(), 18).as_deref(),
418            Some("valid_matrix::simple")
419        );
420        assert_eq!(
421            find_module_path(&fixture.to_string_lossy(), 47).as_deref(),
422            Some("valid_matrix::data_state")
423        );
424        assert_eq!(
425            find_module_path(&fixture.to_string_lossy(), 69).as_deref(),
426            Some("valid_matrix::wrappers_option")
427        );
428        assert_eq!(
429            find_module_path(&fixture.to_string_lossy(), 113).as_deref(),
430            Some("valid_matrix::validators_sync")
431        );
432    }
433}