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