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
5#![allow(missing_docs)]
6
7use core::fmt;
8use std::borrow::Cow;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::LazyLock;
12
13use parking_lot::RwLock;
14use tinymist_std::path::PathClean;
15use tinymist_std::ImmutPath;
16use typst::diag::{eco_format, EcoString, FileError, FileResult};
17use typst::syntax::package::{PackageSpec, PackageVersion};
18use typst::syntax::VirtualPath;
19
20use super::FileId;
21
22#[derive(Debug)]
23pub enum PathResolution {
24    Resolved(PathBuf),
25    Rootless(Cow<'static, VirtualPath>),
26}
27
28impl PathResolution {
29    pub fn to_err(self) -> FileResult<PathBuf> {
30        match self {
31            PathResolution::Resolved(path) => Ok(path),
32            PathResolution::Rootless(_) => Err(FileError::AccessDenied),
33        }
34    }
35
36    pub fn as_path(&self) -> &Path {
37        match self {
38            PathResolution::Resolved(path) => path.as_path(),
39            PathResolution::Rootless(path) => path.as_rooted_path(),
40        }
41    }
42
43    pub fn join(&self, path: &str) -> FileResult<PathResolution> {
44        match self {
45            PathResolution::Resolved(root) => Ok(PathResolution::Resolved(root.join(path))),
46            PathResolution::Rootless(root) => {
47                Ok(PathResolution::Rootless(Cow::Owned(root.join(path))))
48            }
49        }
50    }
51}
52
53pub trait RootResolver {
54    fn path_for_id(&self, file_id: FileId) -> FileResult<PathResolution> {
55        use WorkspaceResolution::*;
56        let root = match WorkspaceResolver::resolve(file_id)? {
57            Workspace(id) => id.path().clone(),
58            Package => {
59                self.resolve_package_root(file_id.package().expect("not a file in package"))?
60            }
61            UntitledRooted(..) | Rootless => {
62                return Ok(PathResolution::Rootless(Cow::Borrowed(file_id.vpath())))
63            }
64        };
65
66        file_id
67            .vpath()
68            .resolve(&root)
69            .map(PathResolution::Resolved)
70            .ok_or_else(|| FileError::AccessDenied)
71    }
72
73    fn resolve_root(&self, file_id: FileId) -> FileResult<Option<ImmutPath>> {
74        use WorkspaceResolution::*;
75        match WorkspaceResolver::resolve(file_id)? {
76            Workspace(id) | UntitledRooted(id) => Ok(Some(id.path().clone())),
77            Rootless => Ok(None),
78            Package => self
79                .resolve_package_root(file_id.package().expect("not a file in package"))
80                .map(Some),
81        }
82    }
83
84    fn resolve_package_root(&self, pkg: &PackageSpec) -> FileResult<ImmutPath>;
85}
86
87#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
88pub struct WorkspaceId(u16);
89
90const NO_VERSION: PackageVersion = PackageVersion {
91    major: 0,
92    minor: 0,
93    patch: 0,
94};
95
96const UNTITLED_ROOT: PackageVersion = PackageVersion {
97    major: 0,
98    minor: 0,
99    patch: 1,
100};
101
102impl WorkspaceId {
103    fn package(&self) -> PackageSpec {
104        PackageSpec {
105            namespace: WorkspaceResolver::WORKSPACE_NS.clone(),
106            name: eco_format!("p{}", self.0),
107            version: NO_VERSION,
108        }
109    }
110
111    fn untitled_root(&self) -> PackageSpec {
112        PackageSpec {
113            namespace: WorkspaceResolver::WORKSPACE_NS.clone(),
114            name: eco_format!("p{}", self.0),
115            version: UNTITLED_ROOT,
116        }
117    }
118
119    pub fn path(&self) -> ImmutPath {
120        let interner = INTERNER.read();
121        interner
122            .from_id
123            .get(self.0 as usize)
124            .expect("invalid workspace id")
125            .clone()
126    }
127
128    fn from_package_name(name: &str) -> Option<WorkspaceId> {
129        if !name.starts_with("p") {
130            return None;
131        }
132
133        let num = name[1..].parse().ok()?;
134        Some(WorkspaceId(num))
135    }
136}
137
138/// The global package-path interner.
139static INTERNER: LazyLock<RwLock<Interner>> = LazyLock::new(|| {
140    RwLock::new(Interner {
141        to_id: HashMap::new(),
142        from_id: Vec::new(),
143    })
144});
145
146pub enum WorkspaceResolution {
147    Workspace(WorkspaceId),
148    UntitledRooted(WorkspaceId),
149    Rootless,
150    Package,
151}
152
153/// A package-path interner.
154struct Interner {
155    to_id: HashMap<ImmutPath, WorkspaceId>,
156    from_id: Vec<ImmutPath>,
157}
158
159#[derive(Default)]
160pub struct WorkspaceResolver {}
161
162impl WorkspaceResolver {
163    pub const WORKSPACE_NS: EcoString = EcoString::inline("ws");
164
165    pub fn is_workspace_file(fid: FileId) -> bool {
166        fid.package()
167            .is_some_and(|p| p.namespace == WorkspaceResolver::WORKSPACE_NS)
168    }
169
170    pub fn is_package_file(fid: FileId) -> bool {
171        fid.package()
172            .is_some_and(|p| p.namespace != WorkspaceResolver::WORKSPACE_NS)
173    }
174
175    /// Id of the given path if it exists in the `Vfs` and is not deleted.
176    pub fn workspace_id(root: &ImmutPath) -> WorkspaceId {
177        // Try to find an existing entry that we can reuse.
178        //
179        // We could check with just a read lock, but if the pair is not yet
180        // present, we would then need to recheck after acquiring a write lock,
181        // which is probably not worth it.
182        let mut interner = INTERNER.write();
183        if let Some(&id) = interner.to_id.get(root) {
184            return id;
185        }
186
187        let root = ImmutPath::from(root.clean());
188
189        // Create a new entry forever by leaking the pair. We can't leak more
190        // than 2^16 pair (and typically will leak a lot less), so its not a
191        // big deal.
192        let num = interner.from_id.len().try_into().expect("out of file ids");
193        let id = WorkspaceId(num);
194        interner.to_id.insert(root.clone(), id);
195        interner.from_id.push(root.clone());
196        id
197    }
198
199    /// Creates a file id for a rootless file.
200    pub fn rootless_file(path: VirtualPath) -> FileId {
201        FileId::new(None, path)
202    }
203
204    /// Creates a file id for a rootless file.
205    pub fn file_with_parent_root(path: &Path) -> Option<FileId> {
206        if !path.is_absolute() {
207            return None;
208        }
209        let parent = path.parent()?;
210        let parent = ImmutPath::from(parent);
211        let path = VirtualPath::new(path.file_name()?);
212        Some(Self::workspace_file(Some(&parent), path))
213    }
214
215    /// Creates a file id for a file in some workspace. The `root` is the root
216    /// directory of the workspace. If `root` is `None`, the source code at the
217    /// `path` will not be able to access physical files.
218    pub fn workspace_file(root: Option<&ImmutPath>, path: VirtualPath) -> FileId {
219        let workspace = root.map(Self::workspace_id);
220        FileId::new(workspace.as_ref().map(WorkspaceId::package), path)
221    }
222
223    /// Mounts an untiled file to some workspace. The `root` is the
224    /// root directory of the workspace. If `root` is `None`, the source
225    /// code at the `path` will not be able to access physical files.
226    pub fn rooted_untitled(root: Option<&ImmutPath>, path: VirtualPath) -> FileId {
227        let workspace = root.map(Self::workspace_id);
228        FileId::new(workspace.as_ref().map(WorkspaceId::untitled_root), path)
229    }
230
231    /// File path corresponding to the given `fid`.
232    pub fn resolve(fid: FileId) -> FileResult<WorkspaceResolution> {
233        let Some(package) = fid.package() else {
234            return Ok(WorkspaceResolution::Rootless);
235        };
236
237        match package.namespace.as_str() {
238            "ws" => {
239                let id = WorkspaceId::from_package_name(&package.name).ok_or_else(|| {
240                    FileError::Other(Some(eco_format!("bad workspace id: {fid:?}")))
241                })?;
242
243                Ok(if package.version == UNTITLED_ROOT {
244                    WorkspaceResolution::UntitledRooted(id)
245                } else {
246                    WorkspaceResolution::Workspace(id)
247                })
248            }
249            _ => Ok(WorkspaceResolution::Package),
250        }
251    }
252
253    /// File path corresponding to the given `fid`.
254    pub fn display(id: Option<FileId>) -> Resolving {
255        Resolving { id }
256    }
257}
258
259pub struct Resolving {
260    id: Option<FileId>,
261}
262
263impl fmt::Debug for Resolving {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        use WorkspaceResolution::*;
266        let Some(id) = self.id else {
267            return write!(f, "unresolved-path");
268        };
269
270        let path = match WorkspaceResolver::resolve(id) {
271            Ok(Workspace(workspace)) => id.vpath().resolve(&workspace.path()),
272            Ok(UntitledRooted(..)) => Some(id.vpath().as_rootless_path().to_owned()),
273            Ok(Rootless | Package) | Err(_) => None,
274        };
275
276        if let Some(path) = path {
277            write!(f, "{}", path.display())
278        } else {
279            write!(f, "{:?}", self.id)
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286
287    #[test]
288    fn test_interner_untitled() {}
289}