tinymist_world/
entry.rs

1//! The entry state of the world.
2
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5
6use serde::{Deserialize, Serialize};
7use tinymist_std::{ImmutPath, error::prelude::*};
8use tinymist_vfs::{WorkspaceResolution, WorkspaceResolver};
9use typst::diag::SourceResult;
10use typst::syntax::{FileId, RootedPath, VirtualPath, VirtualRoot};
11
12/// A trait to read the entry state.
13pub trait EntryReader {
14    /// Gets the entry state.
15    fn entry_state(&self) -> EntryState;
16
17    /// Gets the main file id.
18    fn main_id(&self) -> Option<FileId> {
19        self.entry_state().main()
20    }
21}
22
23/// A trait to manage the entry state.
24pub trait EntryManager: EntryReader {
25    /// Mutates the entry state.
26    fn mutate_entry(&mut self, state: EntryState) -> SourceResult<EntryState>;
27}
28
29/// The state of the entry.
30#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
31pub struct EntryState {
32    /// The path to the root directory of compilation.
33    /// The world forbids direct access to files outside this directory.
34    ///
35    /// If the root is `None`, the world cannot access the file system.
36    root: Option<ImmutPath>,
37    /// The identifier of the main file in the workspace.
38    ///
39    /// If the main is `None`, the world is inactive.
40    main: Option<FileId>,
41}
42
43/// The detached entry.
44pub static DETACHED_ENTRY: LazyLock<FileId> = LazyLock::new(|| {
45    FileId::unique(RootedPath::new(
46        VirtualRoot::Project,
47        VirtualPath::new("/__detached.typ").unwrap(),
48    ))
49});
50
51/// The memory main entry.
52pub static MEMORY_MAIN_ENTRY: LazyLock<FileId> = LazyLock::new(|| {
53    FileId::unique(RootedPath::new(
54        VirtualRoot::Project,
55        VirtualPath::new("/__main__.typ").unwrap(),
56    ))
57});
58
59impl EntryState {
60    /// Creates an entry state with no workspace root and no main file.
61    pub fn new_detached() -> Self {
62        Self {
63            root: None,
64            main: None,
65        }
66    }
67
68    /// Creates an entry state with a workspace root and no main file.
69    pub fn new_workspace(root: ImmutPath) -> Self {
70        Self::new_rooted(root, None)
71    }
72
73    /// Creates an entry state without permission to access the file system.
74    pub fn new_rootless(main: VirtualPath) -> Self {
75        Self {
76            root: None,
77            main: Some(FileId::unique(RootedPath::new(VirtualRoot::Project, main))),
78        }
79    }
80
81    /// Creates an entry state with a workspace root and an main file.
82    pub fn new_rooted_by_id(root: ImmutPath, main: FileId) -> Self {
83        Self::new_rooted(root, Some(main.vpath().clone()))
84    }
85
86    /// Creates an entry state with a workspace root and an optional main file.
87    pub fn new_rooted(root: ImmutPath, main: Option<VirtualPath>) -> Self {
88        let main = main.map(|main| WorkspaceResolver::workspace_file(Some(&root), main));
89        Self {
90            root: Some(root),
91            main,
92        }
93    }
94
95    /// Creates an entry state with only a main file given.
96    pub fn new_rooted_by_parent(entry: ImmutPath) -> Option<Self> {
97        let root = entry.parent().map(ImmutPath::from);
98        let main = WorkspaceResolver::workspace_file(
99            root.as_ref(),
100            VirtualPath::new(entry.file_name()?.to_str()?).ok()?,
101        );
102
103        Some(Self {
104            root,
105            main: Some(main),
106        })
107    }
108
109    /// Gets the main file id.
110    pub fn main(&self) -> Option<FileId> {
111        self.main
112    }
113
114    /// Gets the specified root directory.
115    pub fn root(&self) -> Option<ImmutPath> {
116        self.root.clone()
117    }
118
119    /// Gets the root directory of the main file.
120    pub fn workspace_root(&self) -> Option<ImmutPath> {
121        if let Some(main) = self.main {
122            match WorkspaceResolver::resolve(main).ok()? {
123                WorkspaceResolution::Workspace(id) | WorkspaceResolution::UntitledRooted(id) => {
124                    Some(id.path().clone())
125                }
126                WorkspaceResolution::Rootless => None,
127                WorkspaceResolution::Package => self.root.clone(),
128            }
129        } else {
130            self.root.clone()
131        }
132    }
133
134    /// Selects an entry in the workspace.
135    pub fn select_in_workspace(&self, path: &Path) -> EntryState {
136        let id = WorkspaceResolver::workspace_file(
137            self.root.as_ref(),
138            VirtualPath::new(path.to_str().expect("virtual path must be utf-8")).unwrap(),
139        );
140
141        Self {
142            root: self.root.clone(),
143            main: Some(id),
144        }
145    }
146
147    /// Tries to select an entry in the workspace.
148    ///
149    /// If a workspace root is set, the path must be inside that root and the
150    /// selected entry keeps the same root. Selecting a path outside the root is
151    /// rejected because that has undefined workspace semantics.
152    ///
153    /// If no workspace root is set, selection falls back to rooting the entry by
154    /// its parent directory. This matches typst-cli behavior with and without
155    /// an explicit `--root`.
156    pub fn try_select_path_in_workspace(&self, path: &Path) -> Result<Option<EntryState>> {
157        match self.workspace_root() {
158            Some(root) => {
159                let path = VirtualPath::virtualize(&root, path).map_err(|err| {
160                    error_once!("entry file is not in workspace", err: err, entry: path.display(), root: root.display())
161                })?;
162
163                Ok(Some(EntryState::new_rooted(root.clone(), Some(path))))
164            }
165            None => Ok(EntryState::new_rooted_by_parent(path.into())),
166        }
167    }
168
169    /// Checks if the world is detached.
170    pub fn is_detached(&self) -> bool {
171        self.root.is_none() && self.main.is_none()
172    }
173
174    /// Checks if the world is inactive.
175    pub fn is_inactive(&self) -> bool {
176        self.main.is_none()
177    }
178
179    /// Checks if the world is in a package.
180    pub fn is_in_package(&self) -> bool {
181        self.main.is_some_and(WorkspaceResolver::is_package_file)
182    }
183}
184
185/// The options to create the entry
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
187pub enum EntryOpts {
188    /// Creates the entry with a specified root directory and a main file.
189    Workspace {
190        /// Path to the root directory of compilation.
191        /// The world forbids direct access to files outside this directory.
192        root: PathBuf,
193        /// Relative path to the main file in the workspace.
194        main: Option<PathBuf>,
195    },
196    /// Creates the entry with a main file and a parent directory as the root.
197    RootByParent {
198        /// Path to the entry file of compilation.
199        entry: PathBuf,
200    },
201    /// Creates the entry with no root and no main file.
202    #[default]
203    Detached,
204}
205
206impl EntryOpts {
207    /// Creates the entry with no root and no main file.
208    pub fn new_detached() -> Self {
209        Self::Detached
210    }
211
212    /// Creates the entry with a specified root directory and no main file.
213    pub fn new_workspace(root: PathBuf) -> Self {
214        Self::Workspace { root, main: None }
215    }
216
217    /// Creates the entry with a specified root directory and a main file.
218    pub fn new_rooted(root: PathBuf, main: Option<PathBuf>) -> Self {
219        Self::Workspace { root, main }
220    }
221
222    /// Creates the entry with a main file and a parent directory as the root.
223    pub fn new_rootless(entry: PathBuf) -> Option<Self> {
224        if entry.is_relative() {
225            return None;
226        }
227
228        Some(Self::RootByParent {
229            entry: entry.clone(),
230        })
231    }
232}
233
234impl TryFrom<EntryOpts> for EntryState {
235    type Error = tinymist_std::Error;
236
237    fn try_from(value: EntryOpts) -> Result<Self, Self::Error> {
238        match value {
239            EntryOpts::Workspace { root, main: entry } => Ok(EntryState::new_rooted(
240                root.as_path().into(),
241                entry.map(|entry| {
242                    VirtualPath::new(entry.to_string_lossy())
243                        .expect("entry path must be a valid virtual path")
244                }),
245            )),
246            EntryOpts::RootByParent { entry } => {
247                if entry.is_relative() {
248                    return Err(error_once!("entry path must be absolute", path: entry.display()));
249                }
250
251                // todo: is there path that has no parent?
252                EntryState::new_rooted_by_parent(entry.as_path().into())
253                    .ok_or_else(|| error_once!("entry path is invalid", path: entry.display()))
254            }
255            EntryOpts::Detached => Ok(EntryState::new_detached()),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use tinymist_vfs::WorkspaceResolution;
264
265    #[cfg(windows)]
266    const ROOT: &str = r"C:\workspace";
267    #[cfg(not(windows))]
268    const ROOT: &str = "/workspace";
269
270    fn assert_workspace_entry(entry: &EntryState, root: &ImmutPath, vpath: &str) {
271        assert_eq!(entry.root(), Some(root.clone()));
272        assert_eq!(entry.main().unwrap().vpath().get_with_slash(), vpath);
273
274        let resolution = WorkspaceResolver::resolve(entry.main().unwrap()).unwrap();
275        assert!(matches!(resolution, WorkspaceResolution::Workspace(id) if id.path() == *root));
276    }
277
278    #[test]
279    fn try_select_path_in_workspace_selects_absolute_workspace_path() {
280        let root = ImmutPath::from(Path::new(ROOT));
281        let entry = EntryState::new_workspace(root.clone());
282        let selected = entry
283            .try_select_path_in_workspace(&root.join("main.typ"))
284            .unwrap()
285            .unwrap();
286
287        assert_workspace_entry(&selected, &root, "/main.typ");
288    }
289
290    #[test]
291    fn try_select_path_in_workspace_selects_nested_absolute_workspace_path() {
292        let root = ImmutPath::from(Path::new(ROOT));
293        let entry = EntryState::new_workspace(root.clone());
294        let selected = entry
295            .try_select_path_in_workspace(&root.join("chapters").join("main.typ"))
296            .unwrap()
297            .unwrap();
298
299        assert_workspace_entry(&selected, &root, "/chapters/main.typ");
300    }
301
302    #[test]
303    fn try_select_path_in_workspace_rejects_relative_path_with_workspace_root() {
304        let root = ImmutPath::from(Path::new(ROOT));
305        let entry = EntryState::new_workspace(root);
306
307        assert!(
308            entry
309                .try_select_path_in_workspace(Path::new("main.typ"))
310                .is_err()
311        );
312    }
313
314    #[test]
315    fn try_select_path_in_workspace_rejects_path_outside_workspace() {
316        let root = ImmutPath::from(Path::new(ROOT));
317        let entry = EntryState::new_workspace(root);
318
319        assert!(
320            entry
321                .try_select_path_in_workspace(Path::new("/outside/main.typ"))
322                .is_err()
323        );
324    }
325
326    #[test]
327    fn select_in_workspace_accepts_virtual_path_without_leading_slash() {
328        let root = ImmutPath::from(Path::new(ROOT));
329        let entry = EntryState::new_workspace(root.clone());
330        let selected = entry.select_in_workspace(Path::new("main.typ"));
331
332        assert_workspace_entry(&selected, &root, "/main.typ");
333    }
334
335    #[test]
336    fn try_select_path_in_workspace_uses_parent_root_without_workspace_root() {
337        let root = ImmutPath::from(Path::new(ROOT));
338        let entry = EntryState::new_rootless(VirtualPath::new("/old.typ").unwrap());
339        let selected = entry
340            .try_select_path_in_workspace(&root.join("main.typ"))
341            .unwrap()
342            .unwrap();
343
344        assert_workspace_entry(&selected, &root, "/main.typ");
345    }
346
347    #[test]
348    fn try_select_path_in_workspace_keeps_detached_parent_root_fallback() {
349        let root = ImmutPath::from(Path::new(ROOT));
350        let selected = EntryState::new_detached()
351            .try_select_path_in_workspace(&root.join("main.typ"))
352            .unwrap()
353            .unwrap();
354
355        assert_workspace_entry(&selected, &root, "/main.typ");
356    }
357}