1use 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
12pub trait EntryReader {
14 fn entry_state(&self) -> EntryState;
16
17 fn main_id(&self) -> Option<FileId> {
19 self.entry_state().main()
20 }
21}
22
23pub trait EntryManager: EntryReader {
25 fn mutate_entry(&mut self, state: EntryState) -> SourceResult<EntryState>;
27}
28
29#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
31pub struct EntryState {
32 root: Option<ImmutPath>,
37 main: Option<FileId>,
41}
42
43pub static DETACHED_ENTRY: LazyLock<FileId> = LazyLock::new(|| {
45 FileId::unique(RootedPath::new(
46 VirtualRoot::Project,
47 VirtualPath::new("/__detached.typ").unwrap(),
48 ))
49});
50
51pub 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 pub fn new_detached() -> Self {
62 Self {
63 root: None,
64 main: None,
65 }
66 }
67
68 pub fn new_workspace(root: ImmutPath) -> Self {
70 Self::new_rooted(root, None)
71 }
72
73 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 pub fn new_rooted_by_id(root: ImmutPath, main: FileId) -> Self {
83 Self::new_rooted(root, Some(main.vpath().clone()))
84 }
85
86 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 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 pub fn main(&self) -> Option<FileId> {
111 self.main
112 }
113
114 pub fn root(&self) -> Option<ImmutPath> {
116 self.root.clone()
117 }
118
119 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 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 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 pub fn is_detached(&self) -> bool {
171 self.root.is_none() && self.main.is_none()
172 }
173
174 pub fn is_inactive(&self) -> bool {
176 self.main.is_none()
177 }
178
179 pub fn is_in_package(&self) -> bool {
181 self.main.is_some_and(WorkspaceResolver::is_package_file)
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
187pub enum EntryOpts {
188 Workspace {
190 root: PathBuf,
193 main: Option<PathBuf>,
195 },
196 RootByParent {
198 entry: PathBuf,
200 },
201 #[default]
203 Detached,
204}
205
206impl EntryOpts {
207 pub fn new_detached() -> Self {
209 Self::Detached
210 }
211
212 pub fn new_workspace(root: PathBuf) -> Self {
214 Self::Workspace { root, main: None }
215 }
216
217 pub fn new_rooted(root: PathBuf, main: Option<PathBuf>) -> Self {
219 Self::Workspace { root, main }
220 }
221
222 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 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}