Skip to main content

ryo_symbol/
file_path.rs

1//! Self-contained workspace file path
2
3use std::ffi::OsStr;
4use std::hash::{Hash, Hasher};
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use serde::de::{DeserializeSeed, Deserializer, MapAccess, Visitor};
10use serde::ser::{Serialize, SerializeStruct, Serializer};
11
12use crate::crate_name::CrateName;
13
14/// Normalized relative path from workspace root (self-contained)
15///
16/// # Design
17///
18/// This type holds the relative path, workspace root, and crate name, making it
19/// completely self-contained. You can get the absolute path and derive symbol
20/// paths without any external context or provider.
21///
22/// - `relative`: Path from workspace_root (used for Hash/Eq/Serialize)
23/// - `workspace_root`: Shared workspace root (Arc for lightweight sharing)
24/// - `crate_name`: The crate this file belongs to (required)
25///
26/// # Creation
27///
28/// **Always create via `WorkspacePathResolver`**. Direct construction
29/// with `new_unchecked()` is for internal/test use only.
30///
31/// ```ignore
32/// let resolver = WorkspacePathResolver::new("/path/to/workspace".into());
33/// let path = resolver.resolve("src/lib.rs")?;
34/// ```
35#[derive(Debug, Clone)]
36pub struct WorkspaceFilePath {
37    relative: PathBuf,
38    workspace_root: Arc<Path>,
39    crate_name: CrateName,
40}
41
42impl WorkspaceFilePath {
43    /// Internal constructor (for normalized paths only)
44    pub(crate) fn new_unchecked(
45        relative: PathBuf,
46        workspace_root: Arc<Path>,
47        crate_name: CrateName,
48    ) -> Self {
49        Self {
50            relative,
51            workspace_root,
52            crate_name,
53        }
54    }
55
56    /// Test utility constructor (available with test-utils feature)
57    #[cfg(any(test, feature = "test-utils"))]
58    pub fn new_for_test(
59        relative: impl Into<PathBuf>,
60        workspace_root: impl Into<PathBuf>,
61        crate_name: impl AsRef<str>,
62    ) -> Self {
63        Self {
64            relative: relative.into(),
65            workspace_root: Arc::from(workspace_root.into()),
66            crate_name: CrateName::new_for_test(crate_name.as_ref()),
67        }
68    }
69
70    /// Get the relative path
71    pub fn as_relative(&self) -> &Path {
72        &self.relative
73    }
74
75    /// Get the workspace root
76    pub fn workspace_root(&self) -> &Path {
77        &self.workspace_root
78    }
79
80    /// Get the crate name
81    pub fn crate_name(&self) -> &CrateName {
82        &self.crate_name
83    }
84
85    /// Get absolute path (no I/O, self-contained)
86    pub fn to_absolute(&self) -> PathBuf {
87        self.workspace_root.join(&self.relative)
88    }
89
90    /// Get canonicalized absolute path (with I/O)
91    pub fn canonicalize(&self) -> io::Result<PathBuf> {
92        std::fs::canonicalize(self.to_absolute())
93    }
94
95    /// Get the file name
96    pub fn file_name(&self) -> Option<&OsStr> {
97        self.relative.file_name()
98    }
99
100    /// Get the file extension
101    pub fn extension(&self) -> Option<&OsStr> {
102        self.relative.extension()
103    }
104
105    /// Get the parent directory
106    pub fn parent(&self) -> Option<&Path> {
107        self.relative.parent()
108    }
109
110    /// Check if this is a Rust source file
111    pub fn is_rust_file(&self) -> bool {
112        self.extension().is_some_and(|ext| ext == "rs")
113    }
114
115    /// Check if this is a binary entry point (main.rs or src/bin/*.rs)
116    ///
117    /// Binary entry points are handled separately from library code because:
118    /// - `main.rs` and `lib.rs` both map to the crate root in module path terms
119    /// - They represent different logical crates (binary vs library)
120    /// - Storing them together causes data overwrite issues
121    ///
122    /// # Returns
123    /// `true` if this file is:
124    /// - `src/main.rs` or `*/src/main.rs`
125    /// - `src/bin/*.rs` or `*/src/bin/*.rs`
126    pub fn is_binary_entry(&self) -> bool {
127        let path_str = self.relative.to_string_lossy();
128
129        // Check for main.rs
130        if path_str.ends_with("/main.rs") || path_str == "main.rs" {
131            return true;
132        }
133
134        // Check for src/bin/*.rs pattern
135        if path_str.contains("/bin/") && path_str.ends_with(".rs") {
136            return true;
137        }
138
139        false
140    }
141
142    /// Create a new WorkspaceFilePath with different context
143    ///
144    /// This is useful after deserialization when both workspace_root and crate_name
145    /// need to be set or updated.
146    pub fn with_context(&self, workspace_root: Arc<Path>, crate_name: CrateName) -> Self {
147        Self {
148            relative: self.relative.clone(),
149            workspace_root,
150            crate_name,
151        }
152    }
153
154    /// Create a new WorkspaceFilePath with different relative path
155    ///
156    /// This is useful for creating sibling files (e.g., creating `src/storage.rs`
157    /// when you have `src/lib.rs`). The workspace_root and crate_name are inherited
158    /// from the original path.
159    ///
160    /// # Example
161    /// ```ignore
162    /// let lib_rs = resolver.resolve("src/lib.rs")?;
163    /// let storage_rs = lib_rs.with_relative("src/storage.rs");
164    /// ```
165    pub fn with_relative(&self, relative: impl Into<PathBuf>) -> Self {
166        Self {
167            relative: relative.into(),
168            workspace_root: self.workspace_root.clone(),
169            crate_name: self.crate_name.clone(),
170        }
171    }
172
173    /// Create a sibling file in the same directory
174    ///
175    /// This is useful for creating module files (e.g., creating `src/storage.rs`
176    /// when you have `src/lib.rs`).
177    ///
178    /// # Example
179    /// ```ignore
180    /// let lib_rs = resolver.resolve("src/lib.rs")?;
181    /// let storage_rs = lib_rs.sibling("storage.rs");
182    /// assert_eq!(storage_rs.as_relative(), Path::new("src/storage.rs"));
183    /// ```
184    pub fn sibling(&self, file_name: &str) -> Self {
185        let parent = self.relative.parent().unwrap_or(Path::new(""));
186        let new_relative = parent.join(file_name);
187        self.with_relative(new_relative)
188    }
189
190    // === File I/O Operations ===
191
192    /// Write content to this file, creating parent directories if needed
193    ///
194    /// This is the preferred way to write files in a workspace context.
195    /// It automatically creates any missing parent directories before writing.
196    ///
197    /// # Example
198    /// ```ignore
199    /// let path = resolver.resolve("src/new_module/lib.rs")?;
200    /// path.write("// New module\n")?;  // Creates src/new_module/ if needed
201    /// ```
202    pub fn write(&self, content: impl AsRef<[u8]>) -> io::Result<()> {
203        write_with_parents(self.to_absolute(), content)
204    }
205
206    /// Read content from this file
207    pub fn read(&self) -> io::Result<String> {
208        std::fs::read_to_string(self.to_absolute())
209    }
210
211    /// Read content as bytes from this file
212    pub fn read_bytes(&self) -> io::Result<Vec<u8>> {
213        std::fs::read(self.to_absolute())
214    }
215
216    /// Check if the file exists
217    pub fn exists(&self) -> bool {
218        self.to_absolute().exists()
219    }
220}
221
222/// Write content to a file, creating parent directories if needed
223///
224/// This is a standalone utility function for cases where you have a raw `PathBuf`
225/// instead of a `WorkspaceFilePath`. Prefer using `WorkspaceFilePath::write()` when possible.
226///
227/// # Example
228/// ```ignore
229/// use ryo_symbol::write_with_parents;
230///
231/// write_with_parents("/path/to/new/file.rs", "content")?;
232/// ```
233pub fn write_with_parents(path: impl AsRef<Path>, content: impl AsRef<[u8]>) -> io::Result<()> {
234    let path = path.as_ref();
235    if let Some(parent) = path.parent() {
236        if !parent.as_os_str().is_empty() && !parent.exists() {
237            std::fs::create_dir_all(parent)?;
238        }
239    }
240    std::fs::write(path, content)
241}
242
243// Hash/Eq based on relative path and crate_name (workspace_root is not included)
244// This allows distinguishing files from different crates with the same relative path
245// (e.g., "src/lib.rs" in crate-a vs crate-b)
246impl PartialEq for WorkspaceFilePath {
247    fn eq(&self, other: &Self) -> bool {
248        self.relative == other.relative && self.crate_name == other.crate_name
249    }
250}
251
252impl Eq for WorkspaceFilePath {}
253
254impl Hash for WorkspaceFilePath {
255    fn hash<H: Hasher>(&self, state: &mut H) {
256        self.relative.hash(state);
257        self.crate_name.hash(state);
258    }
259}
260
261impl AsRef<Path> for WorkspaceFilePath {
262    fn as_ref(&self) -> &Path {
263        &self.relative
264    }
265}
266
267impl std::fmt::Display for WorkspaceFilePath {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(f, "{}", self.relative.display())
270    }
271}
272
273// === Serialization ===
274//
275// Serialize as a struct with "path" (POSIX format) and "crate_name".
276// workspace_root is injected during deserialization via WorkspaceFilePathSeed.
277
278impl Serialize for WorkspaceFilePath {
279    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
280    where
281        S: Serializer,
282    {
283        let mut state = serializer.serialize_struct("WorkspaceFilePath", 2)?;
284        // Convert to POSIX format for cross-platform compatibility
285        let posix_path = self.relative.to_string_lossy().replace('\\', "/");
286        state.serialize_field("path", &posix_path)?;
287        state.serialize_field("crate_name", &self.crate_name)?;
288        state.end()
289    }
290}
291
292// === Deserialization ===
293//
294// WorkspaceFilePath requires workspace_root context during deserialization.
295// Use WorkspaceFilePathSeed with DeserializeSeed to inject the context.
296// This is the responsibility of the Analysis layer, not Symbol layer.
297
298/// Seed for deserializing WorkspaceFilePath with workspace_root injection
299///
300/// Since WorkspaceFilePath requires workspace_root which isn't stored in the
301/// serialized form, use this seed to provide it during deserialization.
302/// crate_name is stored in the serialized form and will be restored.
303///
304/// # Example
305/// ```ignore
306/// use serde::de::DeserializeSeed;
307/// let seed = WorkspaceFilePathSeed::new(resolver.workspace_root_arc());
308/// let path: WorkspaceFilePath = seed.deserialize(&mut deserializer)?;
309/// ```
310#[allow(dead_code)]
311pub struct WorkspaceFilePathSeed {
312    workspace_root: Arc<Path>,
313}
314
315impl WorkspaceFilePathSeed {
316    /// Create a new seed with the workspace root
317    #[allow(dead_code)]
318    pub fn new(workspace_root: Arc<Path>) -> Self {
319        Self { workspace_root }
320    }
321}
322
323impl<'de> DeserializeSeed<'de> for WorkspaceFilePathSeed {
324    type Value = WorkspaceFilePath;
325
326    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
327    where
328        D: Deserializer<'de>,
329    {
330        struct WorkspaceFilePathVisitor {
331            workspace_root: Arc<Path>,
332        }
333
334        impl<'de> Visitor<'de> for WorkspaceFilePathVisitor {
335            type Value = WorkspaceFilePath;
336
337            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
338                formatter.write_str("a struct with 'path' and 'crate_name' fields")
339            }
340
341            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
342            where
343                M: MapAccess<'de>,
344            {
345                let mut path: Option<String> = None;
346                let mut crate_name: Option<CrateName> = None;
347
348                while let Some(key) = map.next_key::<&str>()? {
349                    match key {
350                        "path" => path = Some(map.next_value()?),
351                        "crate_name" => crate_name = Some(map.next_value()?),
352                        _ => {
353                            let _ = map.next_value::<serde::de::IgnoredAny>()?;
354                        }
355                    }
356                }
357
358                let path = path.ok_or_else(|| serde::de::Error::missing_field("path"))?;
359                let crate_name =
360                    crate_name.ok_or_else(|| serde::de::Error::missing_field("crate_name"))?;
361
362                Ok(WorkspaceFilePath::new_unchecked(
363                    PathBuf::from(path),
364                    self.workspace_root,
365                    crate_name,
366                ))
367            }
368        }
369
370        deserializer.deserialize_struct(
371            "WorkspaceFilePath",
372            &["path", "crate_name"],
373            WorkspaceFilePathVisitor {
374                workspace_root: self.workspace_root,
375            },
376        )
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_basic_operations() {
386        let path = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace", "my_crate");
387
388        assert_eq!(path.as_relative(), Path::new("src/lib.rs"));
389        assert_eq!(path.workspace_root(), Path::new("/workspace"));
390        assert_eq!(path.crate_name().as_str(), "my_crate");
391        assert_eq!(path.to_absolute(), PathBuf::from("/workspace/src/lib.rs"));
392        assert_eq!(path.file_name(), Some(OsStr::new("lib.rs")));
393        assert_eq!(path.extension(), Some(OsStr::new("rs")));
394        assert!(path.is_rust_file());
395    }
396
397    #[test]
398    fn test_equality_considers_crate_name() {
399        let path1 = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace1", "crate1");
400        let path2 = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace2", "crate1");
401        let path3 = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace1", "crate2");
402        let path4 = WorkspaceFilePath::new_for_test("src/main.rs", "/workspace1", "crate1");
403
404        // Same relative path + same crate_name = equal (workspace_root ignored)
405        assert_eq!(path1, path2);
406        // Same relative path but different crate_name = not equal
407        assert_ne!(path1, path3);
408        // Different relative path = not equal
409        assert_ne!(path1, path4);
410    }
411
412    #[test]
413    fn test_serialization() {
414        let path = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace", "my_crate");
415        let json = serde_json::to_string(&path).unwrap();
416        assert_eq!(json, r#"{"path":"src/lib.rs","crate_name":"my_crate"}"#);
417    }
418
419    #[test]
420    fn test_deserialization_with_seed() {
421        use serde::de::DeserializeSeed;
422
423        let json = r#"{"path":"src/lib.rs","crate_name":"my_crate"}"#;
424        let workspace_root = Arc::from(Path::new("/workspace"));
425        let seed = WorkspaceFilePathSeed::new(workspace_root);
426
427        let mut de = serde_json::Deserializer::from_str(json);
428        let path = seed.deserialize(&mut de).unwrap();
429
430        assert_eq!(path.as_relative(), Path::new("src/lib.rs"));
431        assert_eq!(path.crate_name().as_str(), "my_crate");
432        assert_eq!(path.workspace_root(), Path::new("/workspace"));
433    }
434
435    #[test]
436    fn test_with_context() {
437        let path = WorkspaceFilePath::new_for_test("src/lib.rs", "/old", "old_crate");
438        let new_path = path.with_context(
439            Arc::from(Path::new("/new")),
440            CrateName::new_for_test("new_crate"),
441        );
442        assert_eq!(new_path.workspace_root(), Path::new("/new"));
443        assert_eq!(new_path.crate_name().as_str(), "new_crate");
444    }
445
446    #[test]
447    fn test_write_creates_parent_directories() {
448        use tempfile::tempdir;
449
450        let temp = tempdir().unwrap();
451        let workspace_root = temp.path();
452
453        // Create a path with nested directories that don't exist
454        let path = WorkspaceFilePath::new_for_test(
455            "src/deep/nested/module/lib.rs",
456            workspace_root.to_str().unwrap(),
457            "test_crate",
458        );
459
460        // Parent directories don't exist yet
461        assert!(!path.to_absolute().parent().unwrap().exists());
462
463        // Write should succeed and create all parent directories
464        path.write("// test content").unwrap();
465
466        // Verify file exists and has correct content
467        assert!(path.exists());
468        assert_eq!(path.read().unwrap(), "// test content");
469    }
470
471    #[test]
472    fn test_write_with_parents_utility() {
473        use tempfile::tempdir;
474
475        let temp = tempdir().unwrap();
476        let file_path = temp.path().join("a/b/c/file.txt");
477
478        // Parent directories don't exist
479        assert!(!file_path.parent().unwrap().exists());
480
481        // write_with_parents should create them
482        write_with_parents(&file_path, "hello").unwrap();
483
484        // Verify
485        assert!(file_path.exists());
486        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello");
487    }
488}