tinymist_vfs/
path_mapper.rs

1//! Maps paths to compact integer ids. We don't care about clearings paths which
2//! no longer exist -- the assumption is total size of paths we ever look at is
3//! not too big.
4
5use core::fmt;
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10
11use parking_lot::RwLock;
12use tinymist_std::ImmutPath;
13use tinymist_std::path::PathClean;
14use typst::diag::{EcoString, FileError, FileResult, eco_format};
15use typst::syntax::VirtualPath;
16use typst::syntax::package::{PackageSpec, PackageVersion};
17
18use super::FileId;
19
20/// Represents the resolution of a path to either a physical filesystem path or a virtual path.
21#[derive(Debug)]
22pub enum PathResolution {
23    /// A path that has been resolved to a physical filesystem path.
24    Resolved(PathBuf),
25    /// A path that exists without a physical root, represented as a virtual path.
26    Rootless(Cow<'static, VirtualPath>),
27}
28
29impl PathResolution {
30    /// Converts the path resolution to a file result, returning an error for rootless paths.
31    pub fn to_err(self) -> FileResult<PathBuf> {
32        match self {
33            PathResolution::Resolved(path) => Ok(path),
34            PathResolution::Rootless(_) => Err(FileError::AccessDenied),
35        }
36    }
37
38    /// Returns a reference to the path as a `Path`.
39    pub fn as_path(&self) -> &Path {
40        match self {
41            PathResolution::Resolved(path) => path.as_path(),
42            PathResolution::Rootless(path) => path.as_rooted_path(),
43        }
44    }
45
46    /// Joins the current path with a relative path string.
47    pub fn join(&self, path: &str) -> FileResult<PathResolution> {
48        match self {
49            PathResolution::Resolved(root) => Ok(PathResolution::Resolved(root.join(path))),
50            PathResolution::Rootless(root) => {
51                Ok(PathResolution::Rootless(Cow::Owned(root.join(path))))
52            }
53        }
54    }
55
56    /// Resolves a virtual path relative to this path resolution.
57    pub fn resolve_to(&self, path: &VirtualPath) -> Option<PathResolution> {
58        match self {
59            PathResolution::Resolved(root) => Some(PathResolution::Resolved(path.resolve(root)?)),
60            PathResolution::Rootless(root) => Some(PathResolution::Rootless(Cow::Owned(
61                VirtualPath::new(path.resolve(root.as_ref().as_rooted_path())?),
62            ))),
63        }
64    }
65}
66
67/// Trait for resolving file paths and roots for different types of files.
68pub trait RootResolver {
69    /// Resolves a file ID to its corresponding path resolution.
70    fn path_for_id(&self, file_id: FileId) -> FileResult<PathResolution> {
71        use WorkspaceResolution::*;
72        let root = match WorkspaceResolver::resolve(file_id)? {
73            Workspace(id) => id.path().clone(),
74            Package => {
75                self.resolve_package_root(file_id.package().expect("not a file in package"))?
76            }
77            UntitledRooted(..) | Rootless => {
78                return Ok(PathResolution::Rootless(Cow::Borrowed(file_id.vpath())));
79            }
80        };
81
82        file_id
83            .vpath()
84            .resolve(&root)
85            .map(PathResolution::Resolved)
86            .ok_or_else(|| FileError::AccessDenied)
87    }
88
89    /// Resolves the root path for a given file ID.
90    fn resolve_root(&self, file_id: FileId) -> FileResult<Option<ImmutPath>> {
91        use WorkspaceResolution::*;
92        match WorkspaceResolver::resolve(file_id)? {
93            Workspace(id) | UntitledRooted(id) => Ok(Some(id.path().clone())),
94            Rootless => Ok(None),
95            Package => self
96                .resolve_package_root(file_id.package().expect("not a file in package"))
97                .map(Some),
98        }
99    }
100
101    /// Resolves the root path for a given package specification.
102    fn resolve_package_root(&self, pkg: &PackageSpec) -> FileResult<ImmutPath>;
103}
104
105/// A unique identifier for a workspace, represented as a 16-bit integer.
106#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
107pub struct WorkspaceId(u16);
108
109const NO_VERSION: PackageVersion = PackageVersion {
110    major: 0,
111    minor: 0,
112    patch: 0,
113};
114
115const UNTITLED_ROOT: PackageVersion = PackageVersion {
116    major: 0,
117    minor: 0,
118    patch: 1,
119};
120
121impl WorkspaceId {
122    fn package(&self) -> PackageSpec {
123        PackageSpec {
124            namespace: WorkspaceResolver::WORKSPACE_NS.clone(),
125            name: eco_format!("p{}", self.0),
126            version: NO_VERSION,
127        }
128    }
129
130    fn untitled_root(&self) -> PackageSpec {
131        PackageSpec {
132            namespace: WorkspaceResolver::WORKSPACE_NS.clone(),
133            name: eco_format!("p{}", self.0),
134            version: UNTITLED_ROOT,
135        }
136    }
137
138    /// Returns the filesystem path associated with this workspace ID.
139    pub fn path(&self) -> ImmutPath {
140        let interner = INTERNER.read();
141        interner
142            .from_id
143            .get(self.0 as usize)
144            .expect("invalid workspace id")
145            .clone()
146    }
147
148    fn from_package_name(name: &str) -> Option<WorkspaceId> {
149        if !name.starts_with("p") {
150            return None;
151        }
152
153        let num = name[1..].parse().ok()?;
154        Some(WorkspaceId(num))
155    }
156}
157
158/// The global package-path interner.
159static INTERNER: LazyLock<RwLock<Interner>> = LazyLock::new(|| {
160    RwLock::new(Interner {
161        to_id: HashMap::new(),
162        from_id: Vec::new(),
163    })
164});
165
166/// Represents the different types of workspace resolution for a file.
167pub enum WorkspaceResolution {
168    /// A file that belongs to a workspace with a specific workspace ID.
169    Workspace(WorkspaceId),
170    /// A file that is rooted in a workspace but untitled.
171    UntitledRooted(WorkspaceId),
172    /// A file that has no root and exists without workspace context.
173    Rootless,
174    /// A file that belongs to a package.
175    Package,
176}
177
178/// A package-path interner.
179struct Interner {
180    to_id: HashMap<ImmutPath, WorkspaceId>,
181    from_id: Vec<ImmutPath>,
182}
183
184/// Resolver for handling workspace-related path operations and file ID management.
185#[derive(Default)]
186pub struct WorkspaceResolver {}
187
188impl WorkspaceResolver {
189    /// Namespace identifier for workspace files.
190    pub const WORKSPACE_NS: EcoString = EcoString::inline("ws");
191
192    /// Checks if a file ID represents a workspace file.
193    pub fn is_workspace_file(fid: FileId) -> bool {
194        fid.package()
195            .is_some_and(|p| p.namespace == WorkspaceResolver::WORKSPACE_NS)
196    }
197
198    /// Checks if a file ID represents a package file.
199    pub fn is_package_file(fid: FileId) -> bool {
200        fid.package()
201            .is_some_and(|p| p.namespace != WorkspaceResolver::WORKSPACE_NS)
202    }
203
204    /// Gets or creates a workspace ID for the given root path.
205    pub fn workspace_id(root: &ImmutPath) -> WorkspaceId {
206        // Try to find an existing entry that we can reuse.
207        //
208        // We could check with just a read lock, but if the pair is not yet
209        // present, we would then need to recheck after acquiring a write lock,
210        // which is probably not worth it.
211        let mut interner = INTERNER.write();
212        if let Some(&id) = interner.to_id.get(root) {
213            return id;
214        }
215
216        let root = ImmutPath::from(root.clean());
217
218        // Create a new entry forever by leaking the pair. We can't leak more
219        // than 2^16 pair (and typically will leak a lot less), so its not a
220        // big deal.
221        let num = interner.from_id.len().try_into().expect("out of file ids");
222        let id = WorkspaceId(num);
223        interner.to_id.insert(root.clone(), id);
224        interner.from_id.push(root.clone());
225        id
226    }
227
228    /// Creates a file id for a rootless file.
229    pub fn rootless_file(path: VirtualPath) -> FileId {
230        FileId::new(None, path)
231    }
232
233    /// Creates a file ID for a file with its parent directory as the root.
234    pub fn file_with_parent_root(path: &Path) -> Option<FileId> {
235        if !path.is_absolute() {
236            return None;
237        }
238        let parent = path.parent()?;
239        let parent = ImmutPath::from(parent);
240        let path = VirtualPath::new(path.file_name()?);
241        Some(Self::workspace_file(Some(&parent), path))
242    }
243
244    /// Creates a file ID for a file in a workspace. The `root` is the root
245    /// directory of the workspace. If `root` is `None`, the source code at the
246    /// `path` will not be able to access physical files.
247    pub fn workspace_file(root: Option<&ImmutPath>, path: VirtualPath) -> FileId {
248        let workspace = root.map(Self::workspace_id);
249        FileId::new(workspace.as_ref().map(WorkspaceId::package), path)
250    }
251
252    /// Mounts an untitled file to a workspace. The `root` is the
253    /// root directory of the workspace. If `root` is `None`, the source
254    /// code at the `path` will not be able to access physical files.
255    pub fn rooted_untitled(root: Option<&ImmutPath>, path: VirtualPath) -> FileId {
256        let workspace = root.map(Self::workspace_id);
257        FileId::new(workspace.as_ref().map(WorkspaceId::untitled_root), path)
258    }
259
260    /// Resolves a file ID to its corresponding workspace resolution.
261    pub fn resolve(fid: FileId) -> FileResult<WorkspaceResolution> {
262        let Some(package) = fid.package() else {
263            return Ok(WorkspaceResolution::Rootless);
264        };
265
266        match package.namespace.as_str() {
267            "ws" => {
268                let id = WorkspaceId::from_package_name(&package.name).ok_or_else(|| {
269                    FileError::Other(Some(eco_format!("bad workspace id: {fid:?}")))
270                })?;
271
272                Ok(if package.version == UNTITLED_ROOT {
273                    WorkspaceResolution::UntitledRooted(id)
274                } else {
275                    WorkspaceResolution::Workspace(id)
276                })
277            }
278            _ => Ok(WorkspaceResolution::Package),
279        }
280    }
281
282    /// Creates a display wrapper for a file ID that can be formatted for output.
283    pub fn display(id: Option<FileId>) -> Resolving {
284        Resolving { id }
285    }
286}
287
288/// A wrapper for displaying file IDs in a human-readable format.
289pub struct Resolving {
290    id: Option<FileId>,
291}
292
293impl fmt::Debug for Resolving {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        use WorkspaceResolution::*;
296        let Some(id) = self.id else {
297            return write!(f, "unresolved-path");
298        };
299
300        let path = match WorkspaceResolver::resolve(id) {
301            Ok(Workspace(workspace)) => id.vpath().resolve(&workspace.path()),
302            Ok(UntitledRooted(..)) => Some(id.vpath().as_rootless_path().to_owned()),
303            Ok(Rootless | Package) | Err(_) => None,
304        };
305
306        if let Some(path) = path {
307            write!(f, "{}", path.display())
308        } else {
309            write!(f, "{:?}", self.id)
310        }
311    }
312}
313
314impl fmt::Display for Resolving {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        use WorkspaceResolution::*;
317        let Some(id) = self.id else {
318            return write!(f, "unresolved-path");
319        };
320
321        let path = match WorkspaceResolver::resolve(id) {
322            Ok(Workspace(workspace)) => id.vpath().resolve(&workspace.path()),
323            Ok(UntitledRooted(..)) => Some(id.vpath().as_rootless_path().to_owned()),
324            Ok(Rootless | Package) | Err(_) => None,
325        };
326
327        if let Some(path) = path {
328            write!(f, "{}", path.display())
329        } else {
330            let pkg = id.package();
331            match pkg {
332                Some(pkg) => {
333                    write!(f, "{pkg}{}", id.vpath().as_rooted_path().display())
334                }
335                None => write!(f, "{}", id.vpath().as_rooted_path().display()),
336            }
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343
344    #[test]
345    fn test_interner_untitled() {}
346}