tinymist_project/
entry.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4use tinymist_l10n::DebugL10n;
5use tinymist_std::ImmutPath;
6use tinymist_std::error::prelude::*;
7use tinymist_std::hash::FxDashMap;
8use tinymist_world::EntryState;
9use typst::syntax::VirtualPath;
10
11/// The kind of project resolution.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
13#[serde(rename_all = "camelCase")]
14pub enum ProjectResolutionKind {
15    /// Manage typst documents like what we did in Markdown. Each single file is
16    /// an individual document and no project resolution is needed.
17    /// This is the default behavior.
18    #[default]
19    SingleFile,
20    /// Manage typst documents like what we did in Rust. For each workspace,
21    /// tinymist tracks your preview and compilation history, and stores the
22    /// information in a lock file. Tinymist will automatically selects the main
23    /// file to use according to the lock file. This also allows other tools
24    /// push preview and export tasks to language server by updating the
25    /// lock file.
26    LockDatabase,
27}
28
29/// Entry resolver
30#[derive(Debug, Default, Clone)]
31pub struct EntryResolver {
32    /// The kind of project resolution.
33    pub project_resolution: ProjectResolutionKind,
34    /// Specifies the root path of the project manually.
35    pub root_path: Option<ImmutPath>,
36    /// The workspace roots from initialization.
37    pub roots: Vec<ImmutPath>,
38    /// Default entry path from the configuration.
39    pub entry: Option<ImmutPath>,
40    /// The path to the typst.toml files.
41    pub typst_toml_cache: Arc<FxDashMap<ImmutPath, Option<ImmutPath>>>,
42}
43
44impl EntryResolver {
45    /// Resolves the root directory for the entry file.
46    pub fn root(&self, entry: Option<&ImmutPath>) -> Option<ImmutPath> {
47        if let Some(root) = &self.root_path {
48            return Some(root.clone());
49        }
50
51        if let Some(entry) = entry {
52            for root in self.roots.iter() {
53                if entry.starts_with(root) {
54                    return Some(root.clone());
55                }
56            }
57
58            if !self.roots.is_empty() {
59                log::warn!("entry is not in any set root directory");
60            }
61
62            let typst_toml_cache = &self.typst_toml_cache;
63
64            match typst_toml_cache.get(entry).map(|r| r.clone()) {
65                // In the case that the file is out of workspace, it is believed to not edited
66                // frequently. When we check the package root of such files and didn't find it
67                // previously, we quickly return None to avoid heavy IO frequently.
68                //
69                // todo: we avoid heavy io for the case when no root is set, but people should
70                // restart the server to refresh the cache
71                Some(None) => return None,
72                Some(Some(cached)) => {
73                    let cached = cached.clone();
74                    if cached.join("typst.toml").exists() {
75                        return Some(cached.clone());
76                    }
77                    typst_toml_cache.remove(entry);
78                }
79                None => {}
80            };
81
82            // cache miss, check the file system
83            // todo: heavy io here?
84            for ancestor in entry.ancestors() {
85                let typst_toml = ancestor.join("typst.toml");
86                if typst_toml.exists() {
87                    let ancestor: ImmutPath = ancestor.into();
88                    typst_toml_cache.insert(entry.clone(), Some(ancestor.clone()));
89                    return Some(ancestor);
90                }
91            }
92            typst_toml_cache.insert(entry.clone(), None);
93
94            if let Some(parent) = entry.parent() {
95                return Some(parent.into());
96            }
97        }
98
99        if !self.roots.is_empty() {
100            return Some(self.roots[0].clone());
101        }
102
103        None
104    }
105
106    /// Resolves the entry state.
107    pub fn resolve(&self, entry: Option<ImmutPath>) -> EntryState {
108        let root_dir = self.root(entry.as_ref());
109        self.resolve_with_root(root_dir, entry)
110    }
111
112    /// Resolves the entry state.
113    pub fn resolve_with_root(
114        &self,
115        root_dir: Option<ImmutPath>,
116        entry: Option<ImmutPath>,
117    ) -> EntryState {
118        // todo: formalize untitled path
119        // let is_untitled = entry.as_ref().is_some_and(|p| p.starts_with("/untitled"));
120        // let root_dir = self.determine_root(if is_untitled { None } else {
121        // entry.as_ref() });
122
123        let entry = match (entry, root_dir) {
124            // (Some(entry), Some(root)) if is_untitled => Some(EntryState::new_rooted(
125            //     root,
126            //     Some(FileId::new(None, VirtualPath::new(entry))),
127            // )),
128            (Some(entry), Some(root)) => match entry.strip_prefix(&root) {
129                Ok(stripped) => Some(EntryState::new_rooted(
130                    root,
131                    Some(VirtualPath::new(stripped)),
132                )),
133                Err(err) => {
134                    log::info!(
135                        "Entry is not in root directory: err {err:?}: entry: {entry:?}, root: {root:?}"
136                    );
137                    EntryState::new_rooted_by_parent(entry)
138                }
139            },
140            (Some(entry), None) => EntryState::new_rooted_by_parent(entry),
141            (None, Some(root)) => Some(EntryState::new_workspace(root)),
142            (None, None) => None,
143        };
144
145        entry.unwrap_or_else(|| match self.root(None) {
146            Some(root) => EntryState::new_workspace(root),
147            None => EntryState::new_detached(),
148        })
149    }
150
151    /// Resolves the directory to store the lock file.
152    pub fn resolve_lock(&self, entry: &EntryState) -> Option<ImmutPath> {
153        match self.project_resolution {
154            ProjectResolutionKind::LockDatabase if entry.is_in_package() => {
155                log::info!("ProjectResolver: no lock for package: {entry:?}");
156                None
157            }
158            ProjectResolutionKind::LockDatabase => {
159                let root = entry.workspace_root();
160                log::info!("ProjectResolver: lock for {entry:?} at {root:?}");
161
162                root
163            }
164            ProjectResolutionKind::SingleFile => None,
165        }
166    }
167
168    /// Resolves the default entry path.
169    pub fn resolve_default(&self) -> Option<ImmutPath> {
170        let entry = self.entry.as_ref();
171        // todo: pre-compute this when updating config
172        if let Some(entry) = entry
173            && entry.is_relative()
174        {
175            let root = self.root(None)?;
176            return Some(root.join(entry).as_path().into());
177        }
178        entry.cloned()
179    }
180
181    /// Validates the configuration.
182    pub fn validate(&self) -> Result<()> {
183        if let Some(root) = &self.root_path
184            && !root.is_absolute()
185        {
186            tinymist_l10n::bail!(
187                "tinymist-project.validate-error.root-path-not-absolute",
188                "rootPath or typstExtraArgs.root must be an absolute path: {root:?}",
189                root = root.debug_l10n()
190            );
191        }
192
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198#[cfg(any(windows, unix, target_os = "macos"))]
199mod entry_tests {
200    use tinymist_world::vfs::WorkspaceResolver;
201
202    use super::*;
203    use std::path::Path;
204
205    const ROOT: &str = if cfg!(windows) {
206        "C:\\dummy-root"
207    } else {
208        "/dummy-root"
209    };
210    const ROOT2: &str = if cfg!(windows) {
211        "C:\\dummy-root2"
212    } else {
213        "/dummy-root2"
214    };
215
216    #[test]
217    fn test_entry_resolution() {
218        let root_path = Path::new(ROOT);
219
220        let entry = EntryResolver {
221            root_path: Some(ImmutPath::from(root_path)),
222            ..Default::default()
223        };
224
225        let entry = entry.resolve(Some(root_path.join("main.typ").into()));
226
227        assert_eq!(entry.root(), Some(ImmutPath::from(root_path)));
228        assert_eq!(
229            entry.main(),
230            Some(WorkspaceResolver::workspace_file(
231                entry.root().as_ref(),
232                VirtualPath::new("main.typ")
233            ))
234        );
235    }
236
237    #[test]
238    fn test_entry_resolution_multi_root() {
239        let root_path = Path::new(ROOT);
240        let root2_path = Path::new(ROOT2);
241
242        let entry = EntryResolver {
243            root_path: Some(ImmutPath::from(root_path)),
244            roots: vec![ImmutPath::from(root_path), ImmutPath::from(root2_path)],
245            ..Default::default()
246        };
247
248        {
249            let entry = entry.resolve(Some(root_path.join("main.typ").into()));
250
251            assert_eq!(entry.root(), Some(ImmutPath::from(root_path)));
252            assert_eq!(
253                entry.main(),
254                Some(WorkspaceResolver::workspace_file(
255                    entry.root().as_ref(),
256                    VirtualPath::new("main.typ")
257                ))
258            );
259        }
260
261        {
262            let entry = entry.resolve(Some(root2_path.join("main.typ").into()));
263
264            assert_eq!(entry.root(), Some(ImmutPath::from(root2_path)));
265            assert_eq!(
266                entry.main(),
267                Some(WorkspaceResolver::workspace_file(
268                    entry.root().as_ref(),
269                    VirtualPath::new("main.typ")
270                ))
271            );
272        }
273    }
274
275    #[test]
276    fn test_entry_resolution_default_multi_root() {
277        let root_path = Path::new(ROOT);
278        let root2_path = Path::new(ROOT2);
279
280        let mut entry = EntryResolver {
281            root_path: Some(ImmutPath::from(root_path)),
282            roots: vec![ImmutPath::from(root_path), ImmutPath::from(root2_path)],
283            ..Default::default()
284        };
285
286        {
287            entry.entry = Some(root_path.join("main.typ").into());
288
289            let default_entry = entry.resolve_default();
290
291            assert_eq!(default_entry, entry.entry);
292        }
293
294        {
295            entry.entry = Some(Path::new("main.typ").into());
296
297            let default_entry = entry.resolve_default();
298
299            assert_eq!(default_entry, Some(root_path.join("main.typ").into()));
300        }
301    }
302}