rs_hack/
path_resolver.rs

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