tinymist_world/
entry.rs

1use std::path::{Path, PathBuf};
2use std::sync::LazyLock;
3
4use serde::{Deserialize, Serialize};
5use tinymist_std::{error::prelude::*, ImmutPath};
6use tinymist_vfs::{WorkspaceResolution, WorkspaceResolver};
7use typst::diag::SourceResult;
8use typst::syntax::{FileId, VirtualPath};
9
10pub trait EntryReader {
11    fn entry_state(&self) -> EntryState;
12
13    fn main_id(&self) -> Option<FileId> {
14        self.entry_state().main()
15    }
16}
17
18pub trait EntryManager: EntryReader {
19    fn mutate_entry(&mut self, state: EntryState) -> SourceResult<EntryState>;
20}
21
22#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
23pub struct EntryState {
24    /// Path to the root directory of compilation.
25    /// The world forbids direct access to files outside this directory.
26    root: Option<ImmutPath>,
27    /// Identifier of the main file in the workspace
28    main: Option<FileId>,
29}
30
31pub static DETACHED_ENTRY: LazyLock<FileId> =
32    LazyLock::new(|| FileId::new(None, VirtualPath::new(Path::new("/__detached.typ"))));
33
34pub static MEMORY_MAIN_ENTRY: LazyLock<FileId> =
35    LazyLock::new(|| FileId::new(None, VirtualPath::new(Path::new("/__main__.typ"))));
36
37impl EntryState {
38    /// Create an entry state with no workspace root and no main file.
39    pub fn new_detached() -> Self {
40        Self {
41            root: None,
42            main: None,
43        }
44    }
45
46    /// Create an entry state with a workspace root and no main file.
47    pub fn new_workspace(root: ImmutPath) -> Self {
48        Self::new_rooted(root, None)
49    }
50
51    /// Create an entry state without permission to access the file system.
52    pub fn new_rootless(main: VirtualPath) -> Self {
53        Self {
54            root: None,
55            main: Some(FileId::new(None, main)),
56        }
57    }
58
59    /// Create an entry state with a workspace root and an main file.
60    pub fn new_rooted_by_id(root: ImmutPath, main: FileId) -> Self {
61        Self::new_rooted(root, Some(main.vpath().clone()))
62    }
63
64    /// Create an entry state with a workspace root and an optional main file.
65    pub fn new_rooted(root: ImmutPath, main: Option<VirtualPath>) -> Self {
66        let main = main.map(|main| WorkspaceResolver::workspace_file(Some(&root), main));
67        Self {
68            root: Some(root),
69            main,
70        }
71    }
72
73    /// Create an entry state with only a main file given.
74    pub fn new_rooted_by_parent(entry: ImmutPath) -> Option<Self> {
75        let root = entry.parent().map(ImmutPath::from);
76        let main =
77            WorkspaceResolver::workspace_file(root.as_ref(), VirtualPath::new(entry.file_name()?));
78
79        Some(Self {
80            root,
81            main: Some(main),
82        })
83    }
84
85    pub fn main(&self) -> Option<FileId> {
86        self.main
87    }
88
89    pub fn root(&self) -> Option<ImmutPath> {
90        self.root.clone()
91    }
92
93    pub fn workspace_root(&self) -> Option<ImmutPath> {
94        if let Some(main) = self.main {
95            match WorkspaceResolver::resolve(main).ok()? {
96                WorkspaceResolution::Workspace(id) | WorkspaceResolution::UntitledRooted(id) => {
97                    Some(id.path().clone())
98                }
99                WorkspaceResolution::Rootless => None,
100                WorkspaceResolution::Package => self.root.clone(),
101            }
102        } else {
103            self.root.clone()
104        }
105    }
106
107    pub fn select_in_workspace(&self, path: &Path) -> EntryState {
108        let id = WorkspaceResolver::workspace_file(self.root.as_ref(), VirtualPath::new(path));
109
110        Self {
111            root: self.root.clone(),
112            main: Some(id),
113        }
114    }
115
116    pub fn try_select_path_in_workspace(&self, path: &Path) -> Result<Option<EntryState>> {
117        Ok(match self.workspace_root() {
118            Some(root) => match path.strip_prefix(&root) {
119                Ok(path) => Some(EntryState::new_rooted(
120                    root.clone(),
121                    Some(VirtualPath::new(path)),
122                )),
123                Err(err) => {
124                    return Err(
125                        error_once!("entry file is not in workspace", err: err, entry: path.display(), root: root.display()),
126                    )
127                }
128            },
129            None => EntryState::new_rooted_by_parent(path.into()),
130        })
131    }
132
133    pub fn is_detached(&self) -> bool {
134        self.root.is_none() && self.main.is_none()
135    }
136
137    pub fn is_inactive(&self) -> bool {
138        self.main.is_none()
139    }
140
141    pub fn is_in_package(&self) -> bool {
142        self.main.is_some_and(WorkspaceResolver::is_package_file)
143    }
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub enum EntryOpts {
148    Workspace {
149        /// Path to the root directory of compilation.
150        /// The world forbids direct access to files outside this directory.
151        root: PathBuf,
152        /// Relative path to the main file in the workspace.
153        main: Option<PathBuf>,
154    },
155    RootByParent {
156        /// Path to the entry file of compilation.
157        entry: PathBuf,
158    },
159    Detached,
160}
161
162impl Default for EntryOpts {
163    fn default() -> Self {
164        Self::Detached
165    }
166}
167
168impl EntryOpts {
169    pub fn new_detached() -> Self {
170        Self::Detached
171    }
172
173    pub fn new_workspace(root: PathBuf) -> Self {
174        Self::Workspace { root, main: None }
175    }
176
177    pub fn new_rooted(root: PathBuf, main: Option<PathBuf>) -> Self {
178        Self::Workspace { root, main }
179    }
180
181    pub fn new_rootless(entry: PathBuf) -> Option<Self> {
182        if entry.is_relative() {
183            return None;
184        }
185
186        Some(Self::RootByParent {
187            entry: entry.clone(),
188        })
189    }
190}
191
192impl TryFrom<EntryOpts> for EntryState {
193    type Error = tinymist_std::Error;
194
195    fn try_from(value: EntryOpts) -> Result<Self, Self::Error> {
196        match value {
197            EntryOpts::Workspace { root, main: entry } => Ok(EntryState::new_rooted(
198                root.as_path().into(),
199                entry.map(VirtualPath::new),
200            )),
201            EntryOpts::RootByParent { entry } => {
202                if entry.is_relative() {
203                    return Err(error_once!("entry path must be absolute", path: entry.display()));
204                }
205
206                // todo: is there path that has no parent?
207                EntryState::new_rooted_by_parent(entry.as_path().into())
208                    .ok_or_else(|| error_once!("entry path is invalid", path: entry.display()))
209            }
210            EntryOpts::Detached => Ok(EntryState::new_detached()),
211        }
212    }
213}