reflexo_world/
entry.rs

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