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