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, VirtualPath};
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> =
45    LazyLock::new(|| FileId::new(None, VirtualPath::new(Path::new("/__detached.typ"))));
46
47/// The memory main entry.
48pub static MEMORY_MAIN_ENTRY: LazyLock<FileId> =
49    LazyLock::new(|| FileId::new(None, VirtualPath::new(Path::new("/__main__.typ"))));
50
51impl EntryState {
52    /// Creates an entry state with no workspace root and no main file.
53    pub fn new_detached() -> Self {
54        Self {
55            root: None,
56            main: None,
57        }
58    }
59
60    /// Creates an entry state with a workspace root and no main file.
61    pub fn new_workspace(root: ImmutPath) -> Self {
62        Self::new_rooted(root, None)
63    }
64
65    /// Creates an entry state without permission to access the file system.
66    pub fn new_rootless(main: VirtualPath) -> Self {
67        Self {
68            root: None,
69            main: Some(FileId::new(None, main)),
70        }
71    }
72
73    /// Creates an entry state with a workspace root and an main file.
74    pub fn new_rooted_by_id(root: ImmutPath, main: FileId) -> Self {
75        Self::new_rooted(root, Some(main.vpath().clone()))
76    }
77
78    /// Creates an entry state with a workspace root and an optional main file.
79    pub fn new_rooted(root: ImmutPath, main: Option<VirtualPath>) -> Self {
80        let main = main.map(|main| WorkspaceResolver::workspace_file(Some(&root), main));
81        Self {
82            root: Some(root),
83            main,
84        }
85    }
86
87    /// Creates an entry state with only a main file given.
88    pub fn new_rooted_by_parent(entry: ImmutPath) -> Option<Self> {
89        let root = entry.parent().map(ImmutPath::from);
90        let main =
91            WorkspaceResolver::workspace_file(root.as_ref(), VirtualPath::new(entry.file_name()?));
92
93        Some(Self {
94            root,
95            main: Some(main),
96        })
97    }
98
99    /// Gets the main file id.
100    pub fn main(&self) -> Option<FileId> {
101        self.main
102    }
103
104    /// Gets the specified root directory.
105    pub fn root(&self) -> Option<ImmutPath> {
106        self.root.clone()
107    }
108
109    /// Gets the root directory of the main file.
110    pub fn workspace_root(&self) -> Option<ImmutPath> {
111        if let Some(main) = self.main {
112            match WorkspaceResolver::resolve(main).ok()? {
113                WorkspaceResolution::Workspace(id) | WorkspaceResolution::UntitledRooted(id) => {
114                    Some(id.path().clone())
115                }
116                WorkspaceResolution::Rootless => None,
117                WorkspaceResolution::Package => self.root.clone(),
118            }
119        } else {
120            self.root.clone()
121        }
122    }
123
124    /// Selects an entry in the workspace.
125    pub fn select_in_workspace(&self, path: &Path) -> EntryState {
126        let id = WorkspaceResolver::workspace_file(self.root.as_ref(), VirtualPath::new(path));
127
128        Self {
129            root: self.root.clone(),
130            main: Some(id),
131        }
132    }
133
134    /// Tries to select an entry in the workspace.
135    pub fn try_select_path_in_workspace(&self, path: &Path) -> Result<Option<EntryState>> {
136        Ok(match self.workspace_root() {
137            Some(root) => match path.strip_prefix(&root) {
138                Ok(path) => Some(EntryState::new_rooted(
139                    root.clone(),
140                    Some(VirtualPath::new(path)),
141                )),
142                Err(err) => {
143                    return Err(
144                        error_once!("entry file is not in workspace", err: err, entry: path.display(), root: root.display()),
145                    );
146                }
147            },
148            None => EntryState::new_rooted_by_parent(path.into()),
149        })
150    }
151
152    /// Checks if the world is detached.
153    pub fn is_detached(&self) -> bool {
154        self.root.is_none() && self.main.is_none()
155    }
156
157    /// Checks if the world is inactive.
158    pub fn is_inactive(&self) -> bool {
159        self.main.is_none()
160    }
161
162    /// Checks if the world is in a package.
163    pub fn is_in_package(&self) -> bool {
164        self.main.is_some_and(WorkspaceResolver::is_package_file)
165    }
166}
167
168/// The options to create the entry
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub enum EntryOpts {
171    /// Creates the entry with a specified root directory and a main file.
172    Workspace {
173        /// Path to the root directory of compilation.
174        /// The world forbids direct access to files outside this directory.
175        root: PathBuf,
176        /// Relative path to the main file in the workspace.
177        main: Option<PathBuf>,
178    },
179    /// Creates the entry with a main file and a parent directory as the root.
180    RootByParent {
181        /// Path to the entry file of compilation.
182        entry: PathBuf,
183    },
184    /// Creates the entry with no root and no main file.
185    Detached,
186}
187
188impl Default for EntryOpts {
189    fn default() -> Self {
190        Self::Detached
191    }
192}
193
194impl EntryOpts {
195    /// Creates the entry with no root and no main file.
196    pub fn new_detached() -> Self {
197        Self::Detached
198    }
199
200    /// Creates the entry with a specified root directory and no main file.
201    pub fn new_workspace(root: PathBuf) -> Self {
202        Self::Workspace { root, main: None }
203    }
204
205    /// Creates the entry with a specified root directory and a main file.
206    pub fn new_rooted(root: PathBuf, main: Option<PathBuf>) -> Self {
207        Self::Workspace { root, main }
208    }
209
210    /// Creates the entry with a main file and a parent directory as the root.
211    pub fn new_rootless(entry: PathBuf) -> Option<Self> {
212        if entry.is_relative() {
213            return None;
214        }
215
216        Some(Self::RootByParent {
217            entry: entry.clone(),
218        })
219    }
220}
221
222impl TryFrom<EntryOpts> for EntryState {
223    type Error = tinymist_std::Error;
224
225    fn try_from(value: EntryOpts) -> Result<Self, Self::Error> {
226        match value {
227            EntryOpts::Workspace { root, main: entry } => Ok(EntryState::new_rooted(
228                root.as_path().into(),
229                entry.map(VirtualPath::new),
230            )),
231            EntryOpts::RootByParent { entry } => {
232                if entry.is_relative() {
233                    return Err(error_once!("entry path must be absolute", path: entry.display()));
234                }
235
236                // todo: is there path that has no parent?
237                EntryState::new_rooted_by_parent(entry.as_path().into())
238                    .ok_or_else(|| error_once!("entry path is invalid", path: entry.display()))
239            }
240            EntryOpts::Detached => Ok(EntryState::new_detached()),
241        }
242    }
243}