Skip to main content

rs_hack/
path_resolver.rs

1//! Path resolution for safely matching qualified paths in Rust code.
2//! Tracks use statements and validates whether a path refers to a specific target.
3///
4/// Example: When looking for `crate::compiler::types::IRValue::Variant`, this resolver
5/// will match:
6/// - `IRValue::Variant` (if `use crate::compiler::types::IRValue;` exists)
7/// - `types::IRValue::Variant` (if `use crate::compiler;` exists)
8/// - `crate::compiler::types::IRValue::Variant` (fully qualified)
9///
10/// But will NOT match:
11/// - `OtherEnum::Variant` (different enum entirely)
12/// - `IRValue::Variant` (if no appropriate use statement exists)
13use std::collections::HashMap;
14
15use syn::visit::Visit;
16use syn::{File, ItemUse, Path, UseTree};
17
18/// Tracks use statements and validates whether paths refer to a specific target.
19///
20/// This is generic enough to work for enums, structs, functions, traits, etc.
21#[derive(Debug, Clone)]
22pub struct PathResolver {
23    /// The canonical fully-qualified path we're looking for
24    /// e.g., ["crate", "compiler", "types", "IRValue"]
25    target_canonical_segments: Vec<String>,
26
27    /// Maps local names/aliases to their canonical path segments
28    /// e.g., "IRValue" -> ["crate", "compiler", "types", "IRValue"]
29    /// e.g., "types" -> ["crate", "compiler", "types"]
30    /// e.g., "IV" -> ["crate", "compiler", "types", "IRValue"] (aliased)
31    local_aliases: HashMap<String, Vec<String>>,
32
33    /// Tracks if we found a glob import that might include our target
34    /// e.g., `use crate::compiler::types::*;`
35    has_potential_glob_import: bool,
36}
37
38impl PathResolver {
39    /// Create a new path resolver for a specific canonical path.
40    ///
41    /// # Arguments
42    /// * `canonical_path` - The fully qualified path (e.g., "crate::compiler::types::IRValue")
43    ///
44    /// # Returns
45    /// A new PathResolver, or None if the path is invalid
46    ///
47    /// # Example
48    /// ```
49    /// use rs_hack::path_resolver::PathResolver;
50    /// let resolver = PathResolver::new("crate::compiler::types::IRValue");
51    /// ```
52    pub fn new(canonical_path: &str) -> Option<Self> {
53        if canonical_path.is_empty() {
54            return None;
55        }
56
57        let segments: Vec<String> = canonical_path.split("::").map(String::from).collect();
58
59        if segments.is_empty() {
60            return None;
61        }
62
63        Some(Self {
64            target_canonical_segments: segments,
65            local_aliases: HashMap::new(),
66            has_potential_glob_import: false,
67        })
68    }
69
70    /// Create a resolver that only matches exact simple paths (backward compatible mode).
71    ///
72    /// This matches the old behavior where only `EnumName::Variant` is matched,
73    /// without any use statement tracking.
74    #[allow(dead_code)]
75    pub fn simple(name: &str) -> Self {
76        Self {
77            target_canonical_segments: vec![name.to_string()],
78            local_aliases: HashMap::new(),
79            has_potential_glob_import: false,
80        }
81    }
82
83    /// Scan a file to build the local alias map from use statements.
84    ///
85    /// This should be called once per file before using `matches_target()`.
86    pub fn scan_file(&mut self, file: &File) {
87        let mut scanner = UseStatementScanner {
88            target_canonical_segments: &self.target_canonical_segments,
89            local_aliases: &mut self.local_aliases,
90            has_potential_glob_import: &mut self.has_potential_glob_import,
91        };
92        scanner.visit_file(file);
93    }
94
95    /// Check if a path definitely refers to our target.
96    ///
97    /// This uses conservative matching - only returns true if we're certain
98    /// the path refers to our target based on:
99    /// 1. Exact canonical path match
100    /// 2. Import alias resolution
101    /// 3. Module path resolution
102    ///
103    /// # Arguments
104    /// * `path` - The syn::Path to check
105    ///
106    /// # Returns
107    /// true if the path definitely refers to our target
108    pub fn matches_target(&self, path: &Path) -> bool {
109        if path.segments.is_empty() {
110            return false;
111        }
112
113        let path_segments: Vec<String> = path
114            .segments
115            .iter()
116            .map(|seg| seg.ident.to_string())
117            .collect();
118
119        // Case 1: Exact canonical path match
120        // e.g., `crate::compiler::types::IRValue` matches exactly
121        if path_segments == self.target_canonical_segments {
122            return true;
123        }
124
125        // Case 2: Check if any prefix is an alias we know about
126        // e.g., if `use crate::compiler::types;` exists,
127        // then `types::IRValue` should match
128        for i in 1..=path_segments.len() {
129            let prefix = &path_segments[0..i];
130            let prefix_str = prefix.join("::");
131
132            if let Some(canonical_prefix) = self.local_aliases.get(&prefix_str) {
133                // Rebuild the full path using the canonical prefix
134                let mut full_path = canonical_prefix.clone();
135                full_path.extend_from_slice(&path_segments[i..]);
136
137                if full_path == self.target_canonical_segments {
138                    return true;
139                }
140            }
141        }
142
143        // Case 3: Simple import case
144        // e.g., if `use crate::compiler::types::IRValue;` exists,
145        // then just `IRValue` should match
146        if path_segments.len() == 1
147            && let Some(canonical) = self.local_aliases.get(&path_segments[0])
148        {
149            return canonical == &self.target_canonical_segments;
150        }
151
152        false
153    }
154
155    /// Check if a path ends with the preceding segment (e.g., enum name).
156    ///
157    /// This is useful for matching patterns like `EnumName::VariantName`
158    /// regardless of what the variant name is, when combined with path validation.
159    ///
160    /// # Arguments
161    /// * `path` - The path to check
162    /// * `preceding_segment` - The segment before the variant (e.g., "IRValue" for enum variants)
163    ///
164    /// # Returns
165    /// true if the path has at least 2 segments and the second-to-last matches preceding_segment
166    #[cfg_attr(not(test), allow(dead_code))]
167    pub fn path_ends_with(&self, path: &Path, preceding_segment: &str) -> bool {
168        let segments: Vec<_> = path.segments.iter().collect();
169        let len = segments.len();
170
171        if len >= 2 {
172            segments[len - 2].ident == preceding_segment
173        } else {
174            false
175        }
176    }
177
178    /// Get the simple name of the target.
179    #[cfg_attr(not(test), allow(dead_code))]
180    pub fn target_name(&self) -> &str {
181        self.target_canonical_segments
182            .last()
183            .map(std::string::String::as_str)
184            .expect("canonical path should have at least one segment")
185    }
186
187    /// Check if a path could potentially match via glob import.
188    ///
189    /// Returns true if:
190    /// - We found a glob import that could include our target
191    /// - The path's simple name matches our target
192    #[cfg_attr(not(test), allow(dead_code))]
193    pub fn might_match_via_glob(&self, path: &Path) -> bool {
194        if !self.has_potential_glob_import {
195            return false;
196        }
197
198        // Check if the last segment matches our target name
199        path.segments
200            .last()
201            .map(|seg| seg.ident == self.target_name())
202            .unwrap_or(false)
203    }
204}
205
206/// Visitor that scans use statements to build the alias map.
207struct UseStatementScanner<'a> {
208    target_canonical_segments: &'a [String],
209    local_aliases: &'a mut HashMap<String, Vec<String>>,
210    has_potential_glob_import: &'a mut bool,
211}
212
213impl<'a> UseStatementScanner<'a> {
214    /// Process a use tree and extract aliases.
215    fn process_use_tree(&mut self, tree: &UseTree, prefix: Vec<String>) {
216        match tree {
217            UseTree::Path(path) => {
218                let mut new_prefix = prefix;
219                new_prefix.push(path.ident.to_string());
220                self.process_use_tree(&path.tree, new_prefix);
221            }
222            UseTree::Name(name) => {
223                // Simple import: `use crate::foo::Bar;`
224                let mut full_path = prefix.clone();
225                full_path.push(name.ident.to_string());
226
227                // Map the simple name to the full path
228                let local_name = name.ident.to_string();
229                self.local_aliases.insert(local_name, full_path.clone());
230
231                // Also map intermediate paths
232                // e.g., `use crate::compiler::types;` maps "types" to ["crate", "compiler",
233                // "types"]
234                if !prefix.is_empty() {
235                    let prefix_str = prefix.join("::");
236                    self.local_aliases.insert(prefix_str, prefix);
237                }
238            }
239            UseTree::Rename(rename) => {
240                // Aliased import: `use crate::foo::Bar as Baz;`
241                let mut full_path = prefix;
242                full_path.push(rename.ident.to_string());
243
244                let local_name = rename.rename.to_string();
245                self.local_aliases.insert(local_name, full_path);
246            }
247            UseTree::Glob(_glob) => {
248                // Glob import: `use crate::foo::*;`
249                // Check if this glob could import our target
250                if self.is_potential_glob_for_target(&prefix) {
251                    *self.has_potential_glob_import = true;
252                }
253            }
254            UseTree::Group(group) => {
255                // Grouped imports: `use crate::foo::{Bar, Baz};`
256                for tree in &group.items {
257                    self.process_use_tree(tree, prefix.clone());
258                }
259            }
260        }
261    }
262
263    /// Check if a glob import could potentially import our target.
264    fn is_potential_glob_for_target(&self, glob_prefix: &[String]) -> bool {
265        // Check if our target starts with this prefix
266        if self.target_canonical_segments.len() <= glob_prefix.len() {
267            return false;
268        }
269
270        // Check if the glob prefix matches the start of our target
271        for (i, segment) in glob_prefix.iter().enumerate() {
272            if i >= self.target_canonical_segments.len() {
273                return false;
274            }
275            if segment != &self.target_canonical_segments[i] {
276                return false;
277            }
278        }
279
280        // The glob is one level above our target
281        self.target_canonical_segments.len() == glob_prefix.len() + 1
282    }
283}
284
285impl<'ast, 'a> Visit<'ast> for UseStatementScanner<'a> {
286    fn visit_item_use(&mut self, node: &'ast ItemUse) {
287        self.process_use_tree(&node.tree, Vec::new());
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use syn::parse_quote;
294
295    use super::*;
296
297    #[test]
298    fn test_exact_canonical_path_match() {
299        let resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
300        let path: Path = parse_quote!(crate::compiler::types::IRValue);
301        assert!(resolver.matches_target(&path));
302    }
303
304    #[test]
305    fn test_simple_import() {
306        let mut resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
307        let file: File = parse_quote! {
308            use crate::compiler::types::IRValue;
309
310            fn foo() {}
311        };
312        resolver.scan_file(&file);
313
314        let path: Path = parse_quote!(IRValue);
315        assert!(resolver.matches_target(&path));
316    }
317
318    #[test]
319    fn test_module_import() {
320        let mut resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
321        let file: File = parse_quote! {
322            use crate::compiler::types;
323
324            fn foo() {}
325        };
326        resolver.scan_file(&file);
327
328        let path: Path = parse_quote!(types::IRValue);
329        assert!(resolver.matches_target(&path));
330    }
331
332    #[test]
333    fn test_aliased_import() {
334        let mut resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
335        let file: File = parse_quote! {
336            use crate::compiler::types::IRValue as IV;
337
338            fn foo() {}
339        };
340        resolver.scan_file(&file);
341
342        let path: Path = parse_quote!(IV);
343        assert!(resolver.matches_target(&path));
344    }
345
346    #[test]
347    fn test_does_not_match_different_path() {
348        let resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
349        let path: Path = parse_quote!(crate::other::types::IRValue);
350        assert!(!resolver.matches_target(&path));
351    }
352
353    #[test]
354    fn test_does_not_match_without_import() {
355        let mut resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
356        let file: File = parse_quote! {
357            // No imports
358            fn foo() {}
359        };
360        resolver.scan_file(&file);
361
362        let path: Path = parse_quote!(IRValue);
363        assert!(!resolver.matches_target(&path));
364    }
365
366    #[test]
367    fn test_glob_import_detection() {
368        let mut resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
369        let file: File = parse_quote! {
370            use crate::compiler::types::*;
371
372            fn foo() {}
373        };
374        resolver.scan_file(&file);
375
376        let path: Path = parse_quote!(IRValue);
377        assert!(resolver.might_match_via_glob(&path));
378    }
379
380    #[test]
381    fn test_path_ends_with() {
382        let resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
383
384        let path1: Path = parse_quote!(IRValue::HashMap);
385        assert!(resolver.path_ends_with(&path1, "IRValue"));
386
387        let path2: Path = parse_quote!(crate::compiler::types::IRValue::HashMap);
388        assert!(resolver.path_ends_with(&path2, "IRValue"));
389
390        let path3: Path = parse_quote!(OtherEnum::HashMap);
391        assert!(!resolver.path_ends_with(&path3, "IRValue"));
392    }
393
394    #[test]
395    fn test_grouped_imports() {
396        let mut resolver = PathResolver::new("crate::compiler::types::IRValue").unwrap();
397        let file: File = parse_quote! {
398            use crate::compiler::types::{IRValue, Frame};
399
400            fn foo() {}
401        };
402        resolver.scan_file(&file);
403
404        let path: Path = parse_quote!(IRValue);
405        assert!(resolver.matches_target(&path));
406    }
407}