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 VirtualPath::virtualize(&root, &entry) {
129                Ok(vpath) => Some(EntryState::new_rooted(root, Some(vpath))),
130                Err(err) => {
131                    log::info!(
132                        "Entry is not in root directory: err {err:?}: entry: {entry:?}, root: {root:?}"
133                    );
134                    EntryState::new_rooted_by_parent(entry)
135                }
136            },
137            (Some(entry), None) => EntryState::new_rooted_by_parent(entry),
138            (None, Some(root)) => Some(EntryState::new_workspace(root)),
139            (None, None) => None,
140        };
141
142        entry.unwrap_or_else(|| match self.root(None) {
143            Some(root) => EntryState::new_workspace(root),
144            None => EntryState::new_detached(),
145        })
146    }
147
148    /// Resolves the directory to store the lock file.
149    pub fn resolve_lock(&self, entry: &EntryState) -> Option<ImmutPath> {
150        match self.project_resolution {
151            ProjectResolutionKind::LockDatabase if entry.is_in_package() => {
152                log::info!("ProjectResolver: no lock for package: {entry:?}");
153                None
154            }
155            ProjectResolutionKind::LockDatabase => {
156                let root = entry.workspace_root();
157                log::info!("ProjectResolver: lock for {entry:?} at {root:?}");
158
159                root
160            }
161            ProjectResolutionKind::SingleFile => None,
162        }
163    }
164
165    /// Resolves the default entry path.
166    pub fn resolve_default(&self) -> Option<ImmutPath> {
167        let entry = self.entry.as_ref();
168        // todo: pre-compute this when updating config
169        if let Some(entry) = entry
170            && entry.is_relative()
171        {
172            let root = self.root(None)?;
173            return Some(root.join(entry).as_path().into());
174        }
175        entry.cloned()
176    }
177
178    /// Validates the configuration.
179    pub fn validate(&self) -> Result<()> {
180        if let Some(root) = &self.root_path
181            && !root.is_absolute()
182        {
183            tinymist_l10n::bail!(
184                "tinymist-project.validate-error.root-path-not-absolute",
185                "rootPath or typstExtraArgs.root must be an absolute path: {root:?}",
186                root = root.debug_l10n()
187            );
188        }
189
190        Ok(())
191    }
192}
193
194#[cfg(test)]
195#[cfg(any(windows, unix, target_os = "macos"))]
196mod entry_tests {
197    use tinymist_world::vfs::WorkspaceResolver;
198
199    use super::*;
200    use std::path::Path;
201
202    const ROOT: &str = if cfg!(windows) {
203        "C:\\dummy-root"
204    } else {
205        "/dummy-root"
206    };
207    const ROOT2: &str = if cfg!(windows) {
208        "C:\\dummy-root2"
209    } else {
210        "/dummy-root2"
211    };
212
213    #[test]
214    fn test_entry_resolution() {
215        let root_path = Path::new(ROOT);
216
217        let entry = EntryResolver {
218            root_path: Some(ImmutPath::from(root_path)),
219            ..Default::default()
220        };
221
222        let entry = entry.resolve(Some(root_path.join("main.typ").into()));
223
224        assert_eq!(entry.root(), Some(ImmutPath::from(root_path)));
225        assert_eq!(
226            entry.main(),
227            Some(WorkspaceResolver::workspace_file(
228                entry.root().as_ref(),
229                VirtualPath::new("main.typ").unwrap()
230            ))
231        );
232    }
233
234    #[test]
235    fn test_entry_resolution_multi_root() {
236        let root_path = Path::new(ROOT);
237        let root2_path = Path::new(ROOT2);
238
239        let entry = EntryResolver {
240            root_path: Some(ImmutPath::from(root_path)),
241            roots: vec![ImmutPath::from(root_path), ImmutPath::from(root2_path)],
242            ..Default::default()
243        };
244
245        {
246            let entry = entry.resolve(Some(root_path.join("main.typ").into()));
247
248            assert_eq!(entry.root(), Some(ImmutPath::from(root_path)));
249            assert_eq!(
250                entry.main(),
251                Some(WorkspaceResolver::workspace_file(
252                    entry.root().as_ref(),
253                    VirtualPath::new("main.typ").unwrap()
254                ))
255            );
256        }
257
258        {
259            let entry = entry.resolve(Some(root2_path.join("main.typ").into()));
260
261            assert_eq!(entry.root(), Some(ImmutPath::from(root2_path)));
262            assert_eq!(
263                entry.main(),
264                Some(WorkspaceResolver::workspace_file(
265                    entry.root().as_ref(),
266                    VirtualPath::new("main.typ").unwrap()
267                ))
268            );
269        }
270    }
271
272    #[test]
273    fn test_entry_resolution_default_multi_root() {
274        let root_path = Path::new(ROOT);
275        let root2_path = Path::new(ROOT2);
276
277        let mut entry = EntryResolver {
278            root_path: Some(ImmutPath::from(root_path)),
279            roots: vec![ImmutPath::from(root_path), ImmutPath::from(root2_path)],
280            ..Default::default()
281        };
282
283        {
284            entry.entry = Some(root_path.join("main.typ").into());
285
286            let default_entry = entry.resolve_default();
287
288            assert_eq!(default_entry, entry.entry);
289        }
290
291        {
292            entry.entry = Some(Path::new("main.typ").into());
293
294            let default_entry = entry.resolve_default();
295
296            assert_eq!(default_entry, Some(root_path.join("main.typ").into()));
297        }
298    }
299}