tinymist_vfs/
path_mapper.rs1use 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#[derive(Debug)]
22pub enum PathResolution {
23 Resolved(PathBuf),
25 Rootless(Cow<'static, VirtualPath>),
27}
28
29impl PathResolution {
30 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 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 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 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
67pub trait RootResolver {
69 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 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 fn resolve_package_root(&self, pkg: &PackageSpec) -> FileResult<ImmutPath>;
103}
104
105#[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 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
158static INTERNER: LazyLock<RwLock<Interner>> = LazyLock::new(|| {
160 RwLock::new(Interner {
161 to_id: HashMap::new(),
162 from_id: Vec::new(),
163 })
164});
165
166pub enum WorkspaceResolution {
168 Workspace(WorkspaceId),
170 UntitledRooted(WorkspaceId),
172 Rootless,
174 Package,
176}
177
178struct Interner {
180 to_id: HashMap<ImmutPath, WorkspaceId>,
181 from_id: Vec<ImmutPath>,
182}
183
184#[derive(Default)]
186pub struct WorkspaceResolver {}
187
188impl WorkspaceResolver {
189 pub const WORKSPACE_NS: EcoString = EcoString::inline("ws");
191
192 pub fn is_workspace_file(fid: FileId) -> bool {
194 fid.package()
195 .is_some_and(|p| p.namespace == WorkspaceResolver::WORKSPACE_NS)
196 }
197
198 pub fn is_package_file(fid: FileId) -> bool {
200 fid.package()
201 .is_some_and(|p| p.namespace != WorkspaceResolver::WORKSPACE_NS)
202 }
203
204 pub fn workspace_id(root: &ImmutPath) -> WorkspaceId {
206 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 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 pub fn rootless_file(path: VirtualPath) -> FileId {
230 FileId::new(None, path)
231 }
232
233 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 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 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 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 pub fn display(id: Option<FileId>) -> Resolving {
284 Resolving { id }
285 }
286}
287
288pub 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}