Skip to main content

components_rs/
fs.rs

1//! Abstract filesystem trait and the default OS-backed implementation.
2//!
3//! All I/O in this crate goes through [`Fs`] rather than `std::fs` / `tokio::fs` directly.
4//! This makes it possible to swap in an in-memory implementation for:
5//!
6//! - **Unit tests** — avoid touching the real disk.
7//! - **WASM / LSP clients** — provide file content from the editor's in-memory document model
8//!   instead of reading from disk, so the server sees unsaved changes immediately.
9//!
10//! The default [`OsFs`] implementation (enabled by the `tokio` feature) delegates to
11//! `tokio::fs` and is suitable for a native language server process.
12//!
13//! All paths are represented as [`url::Url`] with the `file://` scheme.  Directory URLs
14//! **always** end with a trailing `/` so that [`Url::join`] appends correctly.
15
16use async_trait::async_trait;
17use url::Url;
18
19use crate::error::Result;
20
21/// Entry returned by [`Fs::read_dir`].
22///
23/// `path` is a `file://` URL. For directory entries it ends with `/`.
24#[derive(Debug, Clone)]
25pub struct FsDirEntry {
26    pub name: String,
27    pub path: Url,
28    pub is_dir: bool,
29}
30
31/// Abstract filesystem trait. Implement this to provide a custom backend
32/// (e.g., in-memory for WASM, or virtual for testing).
33#[async_trait]
34pub trait Fs: Send + Sync {
35    /// Read the entire contents of a file as a UTF-8 string.
36    async fn read_to_string(&self, url: &Url) -> Result<String>;
37
38    /// List the immediate children of a directory.
39    async fn read_dir(&self, url: &Url) -> Result<Vec<FsDirEntry>>;
40
41    /// Check whether `url` is a file.
42    async fn is_file(&self, url: &Url) -> bool;
43
44    /// Check whether `url` is a directory.
45    async fn is_dir(&self, url: &Url) -> bool;
46
47    /// Return all entries whose paths match `pattern` relative to `base`.
48    ///
49    /// `pattern` uses the same glob syntax as the platform-native glob (e.g.
50    /// `node_modules/*/package.json`, `node_modules/@*/*/package.json`).
51    /// Implementations should delegate to a native glob facility rather than
52    /// hand-rolling their own matching.
53    async fn glob(&self, base: &Url, pattern: &str) -> Result<Vec<FsDirEntry>>;
54}
55
56// ── Convenience helpers built on top of the trait ────────────────────────
57
58/// Check whether a URL exists (file or directory).
59pub async fn exists(fs: &dyn Fs, url: &Url) -> bool {
60    fs.is_file(url).await || fs.is_dir(url).await
61}
62
63/// Recursively walk a directory, returning all file URLs.
64pub async fn walk_dir(fs: &dyn Fs, root: &Url) -> Result<Vec<Url>> {
65    let mut result = Vec::new();
66    let mut stack = vec![root.clone()];
67
68    while let Some(dir) = stack.pop() {
69        if !fs.is_dir(&dir).await {
70            continue;
71        }
72        let entries = fs.read_dir(&dir).await?;
73        for entry in entries {
74            if entry.is_dir {
75                stack.push(entry.path.clone());
76            } else {
77                result.push(entry.path);
78            }
79        }
80    }
81
82    Ok(result)
83}
84
85// ── Default implementation backed by the real OS filesystem ─────────────
86
87/// Standard filesystem implementation using `tokio::fs`.
88///
89/// Available only when the `tokio` feature is enabled.
90#[cfg(feature = "tokio")]
91#[derive(Debug, Clone, Copy, Default)]
92pub struct OsFs;
93
94#[cfg(feature = "tokio")]
95fn url_to_path(url: &Url) -> crate::error::Result<std::path::PathBuf> {
96    url.to_file_path()
97        .map_err(|_| crate::error::ComponentsJsError::InvalidUrl(url.to_string()))
98}
99
100#[cfg(feature = "tokio")]
101#[async_trait]
102impl Fs for OsFs {
103    async fn glob(&self, base: &Url, pattern: &str) -> Result<Vec<FsDirEntry>> {
104        let base_path = url_to_path(base)?;
105        let full_pattern = base_path.join(pattern).to_string_lossy().into_owned();
106
107        let mut entries = Vec::new();
108        let paths = glob::glob(&full_pattern)
109            .map_err(|e| crate::error::ComponentsJsError::General(e.to_string()))?;
110        for path in paths {
111            let path = path.map_err(|e| e.into_error())?;
112            let is_dir = path.is_dir();
113            let name = path
114                .file_name()
115                .map(|n| n.to_string_lossy().into_owned())
116                .unwrap_or_default();
117            let entry_url = if is_dir {
118                Url::from_directory_path(&path).map_err(|_| {
119                    crate::error::ComponentsJsError::InvalidUrl(path.display().to_string())
120                })?
121            } else {
122                Url::from_file_path(&path).map_err(|_| {
123                    crate::error::ComponentsJsError::InvalidUrl(path.display().to_string())
124                })?
125            };
126            entries.push(FsDirEntry {
127                name,
128                path: entry_url,
129                is_dir,
130            });
131        }
132
133        Ok(entries)
134    }
135
136    async fn read_to_string(&self, url: &Url) -> Result<String> {
137        let path = url_to_path(url)?;
138        Ok(tokio::fs::read_to_string(path).await?)
139    }
140
141    async fn read_dir(&self, url: &Url) -> Result<Vec<FsDirEntry>> {
142        let path = url_to_path(url)?;
143        let mut entries = Vec::new();
144        let mut rd = tokio::fs::read_dir(&path).await?;
145        while let Some(entry) = rd.next_entry().await? {
146            let metadata = entry.metadata().await?;
147            let is_dir = metadata.is_dir();
148            let entry_path = entry.path();
149            let entry_url = if is_dir {
150                Url::from_directory_path(&entry_path).map_err(|_| {
151                    crate::error::ComponentsJsError::InvalidUrl(entry_path.display().to_string())
152                })?
153            } else {
154                Url::from_file_path(&entry_path).map_err(|_| {
155                    crate::error::ComponentsJsError::InvalidUrl(entry_path.display().to_string())
156                })?
157            };
158            entries.push(FsDirEntry {
159                name: entry.file_name().to_string_lossy().into_owned(),
160                path: entry_url,
161                is_dir,
162            });
163        }
164        Ok(entries)
165    }
166
167    async fn is_file(&self, url: &Url) -> bool {
168        match url_to_path(url) {
169            Ok(path) => tokio::fs::metadata(path)
170                .await
171                .map(|m| m.is_file())
172                .unwrap_or(false),
173            Err(_) => false,
174        }
175    }
176
177    async fn is_dir(&self, url: &Url) -> bool {
178        match url_to_path(url) {
179            Ok(path) => tokio::fs::metadata(path)
180                .await
181                .map(|m| m.is_dir())
182                .unwrap_or(false),
183            Err(_) => false,
184        }
185    }
186}