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, ®istry)?;
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, ®istry)
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(®istry)).unwrap();
550 assert_eq!(result.as_relative(), std::path::Path::new("src/foo.rs"));
551 }
552}