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