Skip to main content

ryo_symbol/
file_resolver.rs

1//! File path resolver for converting symbol paths to file paths
2//!
3//! Centralizes the logic for resolving SymbolPath to WorkspaceFilePath.
4//! This is the reverse of SymbolPathResolver.
5
6use std::path::PathBuf;
7
8use crate::crate_name::CrateName;
9use crate::error::ResolutionError;
10use crate::file_path::WorkspaceFilePath;
11use crate::metadata::{CrateInfo, TargetKind};
12use crate::path::SymbolPath;
13use crate::registry::SymbolRegistry;
14use crate::resolver::WorkspacePathResolver;
15
16/// Resolves symbol paths to file paths
17///
18/// # Design
19///
20/// This resolver centralizes the conversion from Rust symbol paths to
21/// file system paths (WorkspaceFilePath). It provides two resolution strategies:
22///
23/// 1. **Registry-based** (preferred): Uses SymbolRegistry span info
24/// 2. **Inference-based** (fallback): Infers file path from module structure
25///
26/// # Example
27///
28/// ```ignore
29/// let resolver = FilePathResolver::new(workspace_root);
30///
31/// // With registry (preferred - uses span info)
32/// let path = resolver.resolve_with_registry(&symbol_path, &registry)?;
33///
34/// // Without registry (inference-based)
35/// let path = resolver.resolve_by_inference(&symbol_path)?;
36/// ```
37#[derive(Debug, Clone)]
38pub struct FilePathResolver {
39    workspace_resolver: WorkspacePathResolver,
40}
41
42impl FilePathResolver {
43    /// Create a new resolver with the given workspace root
44    pub fn new(workspace_root: PathBuf) -> Self {
45        Self {
46            workspace_resolver: WorkspacePathResolver::new(workspace_root),
47        }
48    }
49
50    /// Create from an existing WorkspacePathResolver
51    pub fn from_workspace_resolver(workspace_resolver: WorkspacePathResolver) -> Self {
52        Self { workspace_resolver }
53    }
54
55    /// Get the workspace root
56    pub fn workspace_root(&self) -> &std::path::Path {
57        self.workspace_resolver.workspace_root()
58    }
59
60    /// Resolve SymbolPath to WorkspaceFilePath using SymbolRegistry
61    ///
62    /// This is the preferred method as it uses span information from the registry.
63    ///
64    /// # Returns
65    ///
66    /// - `Ok(WorkspaceFilePath)` - The file containing the symbol
67    /// - `Err(ResolutionError::SymbolNotFound)` - Symbol not in registry
68    /// - `Err(ResolutionError::NoSpanInfo)` - Symbol found but has no span
69    pub fn resolve_with_registry(
70        &self,
71        path: &SymbolPath,
72        registry: &SymbolRegistry,
73    ) -> Result<WorkspaceFilePath, ResolutionError> {
74        let symbol_id = registry
75            .lookup(path)
76            .ok_or_else(|| ResolutionError::SymbolNotFound(path.to_string()))?;
77
78        let span = registry
79            .span(symbol_id)
80            .ok_or_else(|| ResolutionError::NoSpanInfo(path.to_string()))?;
81
82        Ok(span.file.clone())
83    }
84
85    /// Resolve SymbolPath to WorkspaceFilePath, falling back to inference
86    ///
87    /// Tries registry first, then falls back to inference if:
88    /// - Symbol not found in registry
89    /// - Symbol has no span info
90    ///
91    /// # Arguments
92    ///
93    /// - `path` - The symbol path to resolve
94    /// - `registry` - Optional registry for span-based resolution
95    pub fn resolve(
96        &self,
97        path: &SymbolPath,
98        registry: Option<&SymbolRegistry>,
99    ) -> Result<WorkspaceFilePath, ResolutionError> {
100        // Try registry-based resolution first
101        if let Some(reg) = registry {
102            if let Ok(file_path) = self.resolve_with_registry(path, reg) {
103                return Ok(file_path);
104            }
105        }
106
107        // Fallback to inference
108        self.resolve_by_inference(path)
109    }
110
111    /// Resolve SymbolPath to WorkspaceFilePath by inferring from module structure
112    ///
113    /// Follows Rust's module conventions:
114    /// - `crate_name` → `src/lib.rs` or `src/main.rs`
115    /// - `crate_name::foo` → `src/foo.rs` or `src/foo/mod.rs`
116    /// - `crate_name::foo::bar` → `src/foo/bar.rs` or `src/foo/bar/mod.rs`
117    /// - `crate_name::foo::bar::Item` → same as `crate_name::foo::bar`
118    ///
119    /// # Note
120    ///
121    /// This method cannot distinguish between:
122    /// - A module file (`foo.rs` defines module `foo`)
123    /// - An item in a parent module (`foo` is an item in `lib.rs`)
124    ///
125    /// Use `resolve_with_registry` when accuracy is critical.
126    pub fn resolve_by_inference(
127        &self,
128        path: &SymbolPath,
129    ) -> Result<WorkspaceFilePath, ResolutionError> {
130        // Get crate name from symbol path (first segment)
131        let crate_name = CrateName::new_unchecked(path.crate_name());
132
133        // Skip crate name, get remaining segments
134        let segments: Vec<&str> = path.segments().skip(1).collect();
135
136        if segments.is_empty() {
137            // Crate root → src/lib.rs
138            return Ok(self
139                .workspace_resolver
140                .resolve_relative_with_crate("src/lib.rs", crate_name));
141        }
142
143        // Try progressively shorter paths (item might be in parent module)
144        // e.g., crate::foo::bar::Baz → try foo/bar.rs, then foo.rs, then lib.rs
145        for depth in (0..=segments.len()).rev() {
146            let module_segments = &segments[..depth];
147
148            if module_segments.is_empty() {
149                // Check lib.rs
150                return Ok(self
151                    .workspace_resolver
152                    .resolve_relative_with_crate("src/lib.rs", crate_name));
153            }
154
155            // Build file path: src/foo/bar.rs
156            let mut path_buf = PathBuf::from("src");
157            for seg in module_segments {
158                path_buf.push(seg);
159            }
160            path_buf.set_extension("rs");
161
162            let file_path = self
163                .workspace_resolver
164                .resolve_relative_with_crate(&path_buf, crate_name.clone());
165
166            // We return the first candidate (deepest module path)
167            // Caller should verify file exists if needed
168            if depth == segments.len() || depth == segments.len() - 1 {
169                return Ok(file_path);
170            }
171        }
172
173        // Should not reach here, but return lib.rs as ultimate fallback
174        Ok(self
175            .workspace_resolver
176            .resolve_relative_with_crate("src/lib.rs", crate_name))
177    }
178
179    /// Resolve with mod.rs fallback
180    ///
181    /// First tries `src/foo/bar.rs`, then `src/foo/bar/mod.rs`.
182    /// Returns both candidates for caller to check existence.
183    pub fn resolve_candidates(&self, path: &SymbolPath) -> Vec<WorkspaceFilePath> {
184        // Get crate name from symbol path (first segment)
185        let crate_name = CrateName::new_unchecked(path.crate_name());
186
187        let segments: Vec<&str> = path.segments().skip(1).collect();
188
189        if segments.is_empty() {
190            return vec![
191                self.workspace_resolver
192                    .resolve_relative_with_crate("src/lib.rs", crate_name.clone()),
193                self.workspace_resolver
194                    .resolve_relative_with_crate("src/main.rs", crate_name),
195            ];
196        }
197
198        // For module path, try both file.rs and dir/mod.rs patterns
199        let mut candidates = Vec::with_capacity(2);
200
201        // Build module path
202        let mut path_buf = PathBuf::from("src");
203        for seg in &segments {
204            path_buf.push(seg);
205        }
206
207        // Candidate 1: src/foo/bar.rs
208        let mut file_path = path_buf.clone();
209        file_path.set_extension("rs");
210        candidates.push(
211            self.workspace_resolver
212                .resolve_relative_with_crate(&file_path, crate_name.clone()),
213        );
214
215        // Candidate 2: src/foo/bar/mod.rs
216        let mut mod_path = path_buf;
217        mod_path.push("mod.rs");
218        candidates.push(
219            self.workspace_resolver
220                .resolve_relative_with_crate(&mod_path, crate_name),
221        );
222
223        candidates
224    }
225
226    /// Resolve SymbolPath to WorkspaceFilePath candidates using Cargo metadata.
227    ///
228    /// This is the accurate, metadata-driven file resolution method that correctly handles:
229    /// - Bin-only crates (no lib.rs, only main.rs)
230    /// - Mixed crates (both lib.rs and main.rs)
231    /// - Library-only crates
232    /// - Workspace crates in subdirectories
233    ///
234    /// # Main Symbol Handling
235    ///
236    /// This method handles the `main::` prefix correctly by skipping both "main" and
237    /// the crate name when processing segments:
238    ///
239    /// ```ignore
240    /// // Library symbol:
241    /// "my_crate::models::User"
242    /// segments: skip(1) → ["models", "User"]
243    ///
244    /// // Binary symbol:
245    /// "main::my_crate::models::User"
246    /// segments: skip(2) → ["models", "User"]  // Skip both "main" and "my_crate"
247    /// ```
248    ///
249    /// # Crate Root Resolution
250    ///
251    /// For crate root symbols (0 or 1 segments after crate name):
252    ///
253    /// ```ignore
254    /// // Library crate:
255    /// "my_crate" or "my_crate::Item" → ["src/lib.rs"]
256    ///
257    /// // Bin-only crate:
258    /// "main::my_app" or "main::my_app::Item" → ["src/main.rs"]
259    ///
260    /// // Mixed crate (both lib and bin):
261    /// "my_crate::Item" → ["src/lib.rs", "src/main.rs"]
262    /// "main::my_crate::Item" → ["src/main.rs", "src/lib.rs"]
263    /// ```
264    ///
265    /// The `resolve_crate_root_candidates()` method consults `crate_info.entry_points`
266    /// to determine which files to include.
267    ///
268    /// # Sub-Module Resolution
269    ///
270    /// For nested modules (2+ segments after crate name):
271    ///
272    /// ```ignore
273    /// "my_crate::models::User" → ["src/models.rs", "src/models/mod.rs"]
274    /// "main::my_app::cli::Args" → ["src/cli.rs", "src/cli/mod.rs"]
275    /// ```
276    ///
277    /// # Arguments
278    ///
279    /// * `path` - The symbol path to resolve (may have `main::` prefix)
280    /// * `crate_info` - Cargo metadata for the target crate
281    ///
282    /// # Returns
283    ///
284    /// A vector of candidate file paths, ordered by preference:
285    /// 1. For crate root: entry point files (lib.rs, main.rs, or both)
286    /// 2. For modules: file.rs first, then file/mod.rs
287    ///
288    /// # Example
289    ///
290    /// ```ignore
291    /// // Bin-only crate (only main.rs):
292    /// let path = SymbolPath::parse("main::my_app::Status")?;
293    /// let candidates = resolver.resolve_candidates_with_crate_info(&path, &crate_info);
294    /// // → ["src/main.rs"] (crate root, bin-only)
295    ///
296    /// // Library crate with module:
297    /// let path = SymbolPath::parse("my_lib::models::User")?;
298    /// let candidates = resolver.resolve_candidates_with_crate_info(&path, &crate_info);
299    /// // → ["src/models.rs", "src/models/mod.rs"]
300    /// ```
301    ///
302    /// # See Also
303    ///
304    /// - [`SymbolPath::module_path_str()`] - Inverse operation (file → symbol path)
305    /// - [`SymbolPath::is_main_symbol()`] - Checks for `main::` prefix
306    /// - `resolve_crate_root_candidates()` - Handles entry point resolution
307    pub fn resolve_candidates_with_crate_info(
308        &self,
309        path: &SymbolPath,
310        crate_info: &CrateInfo,
311    ) -> Vec<WorkspaceFilePath> {
312        let crate_name = CrateName::new_unchecked(&crate_info.module_name);
313
314        // For main symbols (main::my_crate::Item), skip both "main" and crate name
315        // For library symbols (my_crate::Item), skip crate name only
316        let skip_count = if path.is_main_symbol() { 2 } else { 1 };
317        let segments: Vec<&str> = path.segments().skip(skip_count).collect();
318
319        // Get crate's source directory relative to workspace root
320        // e.g., "crates/core/src" or "src"
321        let crate_src_path = &crate_info.src_path;
322
323        if segments.is_empty() || segments.len() == 1 {
324            // Crate root or crate root item (e.g., crate or crate::Item)
325            // Both should resolve to the crate's entry point (lib.rs or main.rs)
326            return self.resolve_crate_root_candidates(crate_info, &crate_name);
327        }
328
329        // For sub-modules (2+ segments after crate), use crate's src_path as base
330        let mut candidates = Vec::with_capacity(2);
331
332        // Build module path relative to crate's src directory
333        let mut path_buf = PathBuf::from(crate_src_path.as_str());
334        for seg in &segments {
335            path_buf.push(seg);
336        }
337
338        // Candidate 1: crates/xxx/src/foo/bar.rs
339        let mut file_path = path_buf.clone();
340        file_path.set_extension("rs");
341        candidates.push(
342            self.workspace_resolver
343                .resolve_relative_with_crate(&file_path, crate_name.clone()),
344        );
345
346        // Candidate 2: crates/xxx/src/foo/bar/mod.rs
347        let mut mod_path = path_buf;
348        mod_path.push("mod.rs");
349        candidates.push(
350            self.workspace_resolver
351                .resolve_relative_with_crate(&mod_path, crate_name),
352        );
353
354        candidates
355    }
356
357    /// Resolve crate root candidates using entry_points from CrateInfo
358    fn resolve_crate_root_candidates(
359        &self,
360        crate_info: &CrateInfo,
361        crate_name: &CrateName,
362    ) -> Vec<WorkspaceFilePath> {
363        let mut candidates = Vec::new();
364
365        // Check for lib target first (preferred)
366        let has_lib = crate_info
367            .entry_points
368            .iter()
369            .any(|t| t.kind == TargetKind::Lib);
370
371        // Check for bin target
372        let bin_target = crate_info
373            .entry_points
374            .iter()
375            .find(|t| t.kind == TargetKind::Bin);
376
377        if has_lib {
378            // Lib target exists - add lib.rs
379            let lib_path = crate_info.src_path.join("lib.rs");
380            candidates.push(
381                self.workspace_resolver
382                    .resolve_relative_with_crate(lib_path.as_str(), crate_name.clone()),
383            );
384        }
385
386        if let Some(bin) = bin_target {
387            // Bin target exists - add its path (usually main.rs but could be custom)
388            // Use the actual path from entry_points
389            candidates.push(
390                self.workspace_resolver
391                    .resolve_relative_with_crate(bin.src_path.as_str(), crate_name.clone()),
392            );
393        }
394
395        // Fallback: if no targets found, use default paths
396        if candidates.is_empty() {
397            let src_path = &crate_info.src_path;
398            candidates.push(
399                self.workspace_resolver.resolve_relative_with_crate(
400                    format!("{}/lib.rs", src_path),
401                    crate_name.clone(),
402                ),
403            );
404            candidates.push(
405                self.workspace_resolver.resolve_relative_with_crate(
406                    format!("{}/main.rs", src_path),
407                    crate_name.clone(),
408                ),
409            );
410        }
411
412        candidates
413    }
414
415    /// Resolve using CrateInfo, checking file existence
416    ///
417    /// Returns the first candidate that exists in the provided file set.
418    /// This is the recommended method for production use.
419    ///
420    /// # Arguments
421    ///
422    /// * `path` - The symbol path to resolve
423    /// * `crate_info` - Cargo metadata for the crate
424    /// * `existing_files` - Set of files that exist (for existence checking)
425    pub fn resolve_with_crate_info<F>(
426        &self,
427        path: &SymbolPath,
428        crate_info: &CrateInfo,
429        file_exists: F,
430    ) -> Result<WorkspaceFilePath, ResolutionError>
431    where
432        F: Fn(&WorkspaceFilePath) -> bool,
433    {
434        let candidates = self.resolve_candidates_with_crate_info(path, crate_info);
435
436        for candidate in candidates {
437            if file_exists(&candidate) {
438                return Ok(candidate);
439            }
440        }
441
442        Err(ResolutionError::SymbolNotFound(format!(
443            "No file found for symbol '{}' in crate '{}'",
444            path, crate_info.name
445        )))
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::kind::SymbolKind;
453    use crate::span::FileSpan;
454
455    fn make_path(s: &str) -> SymbolPath {
456        SymbolPath::parse(s).unwrap()
457    }
458
459    fn make_resolver() -> FilePathResolver {
460        FilePathResolver::new(PathBuf::from("/workspace"))
461    }
462
463    #[test]
464    fn test_resolve_crate_root() {
465        let resolver = make_resolver();
466        let path = make_path("my_crate");
467
468        let result = resolver.resolve_by_inference(&path).unwrap();
469        assert_eq!(result.as_relative(), std::path::Path::new("src/lib.rs"));
470    }
471
472    #[test]
473    fn test_resolve_simple_module() {
474        let resolver = make_resolver();
475        let path = make_path("my_crate::foo");
476
477        let result = resolver.resolve_by_inference(&path).unwrap();
478        assert_eq!(result.as_relative(), std::path::Path::new("src/foo.rs"));
479    }
480
481    #[test]
482    fn test_resolve_nested_module() {
483        let resolver = make_resolver();
484        let path = make_path("my_crate::foo::bar");
485
486        let result = resolver.resolve_by_inference(&path).unwrap();
487        assert_eq!(result.as_relative(), std::path::Path::new("src/foo/bar.rs"));
488    }
489
490    #[test]
491    fn test_resolve_item_in_module() {
492        let resolver = make_resolver();
493        // Item "Baz" in module foo::bar
494        let path = make_path("my_crate::foo::bar::Baz");
495
496        let result = resolver.resolve_by_inference(&path).unwrap();
497        // Should resolve to the module file
498        assert_eq!(
499            result.as_relative(),
500            std::path::Path::new("src/foo/bar/Baz.rs")
501        );
502    }
503
504    #[test]
505    fn test_resolve_candidates() {
506        let resolver = make_resolver();
507        let path = make_path("my_crate::foo::bar");
508
509        let candidates = resolver.resolve_candidates(&path);
510        assert_eq!(candidates.len(), 2);
511        assert_eq!(
512            candidates[0].as_relative(),
513            std::path::Path::new("src/foo/bar.rs")
514        );
515        assert_eq!(
516            candidates[1].as_relative(),
517            std::path::Path::new("src/foo/bar/mod.rs")
518        );
519    }
520
521    #[test]
522    fn test_resolve_with_registry() {
523        let resolver = make_resolver();
524        let mut registry = SymbolRegistry::new();
525
526        let symbol_path = make_path("my_crate::MyStruct");
527        let file = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace", "my_crate");
528        let span = FileSpan::new(file.clone(), 100, 150);
529
530        let id = registry
531            .register(symbol_path.clone(), SymbolKind::Struct)
532            .unwrap();
533        registry.set_span(id, span).unwrap();
534
535        let result = resolver
536            .resolve_with_registry(&symbol_path, &registry)
537            .unwrap();
538        assert_eq!(result, file);
539    }
540
541    #[test]
542    fn test_resolve_fallback_to_inference() {
543        let resolver = make_resolver();
544        let registry = SymbolRegistry::new(); // Empty registry
545
546        let path = make_path("my_crate::foo");
547
548        // Symbol not in registry, should fall back to inference
549        let result = resolver.resolve(&path, Some(&registry)).unwrap();
550        assert_eq!(result.as_relative(), std::path::Path::new("src/foo.rs"));
551    }
552}