tinymist_vfs/
mock.rs

1//! Mock VFS support for Tinymist tests.
2//!
3//! This module intentionally lives in `tinymist-vfs` so VFS tests can use it
4//! without depending on higher-level crates. Enable the `mock` feature from
5//! downstream test-support crates when this module is needed as a dependency.
6
7use std::{
8    collections::BTreeMap,
9    path::{Path, PathBuf},
10    sync::{Arc, RwLock},
11};
12
13use typst::{
14    diag::{FileError, FileResult},
15    foundations::Bytes,
16    syntax::VirtualPath,
17};
18
19use crate::{
20    FileChangeSet, FileId, FileSnapshot, FilesystemEvent, ImmutPath, MemoryEvent, PathAccessModel,
21    RootResolver, Vfs, WorkspaceResolver,
22};
23
24type SharedFiles = Arc<RwLock<BTreeMap<PathBuf, FileSnapshot>>>;
25
26/// Path access over a shared in-memory workspace.
27///
28/// Clones of this type read the same backing map, so tests can mutate a
29/// [`MockWorkspace`] and then deliver the corresponding change event to an
30/// existing VFS.
31#[derive(Debug, Clone)]
32pub struct MockPathAccess {
33    files: SharedFiles,
34}
35
36impl PathAccessModel for MockPathAccess {
37    fn content(&self, src: &Path) -> FileResult<Bytes> {
38        self.files
39            .read()
40            .expect("mock workspace lock poisoned")
41            .get(src)
42            .ok_or_else(|| FileError::NotFound(src.into()))
43            .and_then(|snapshot| snapshot.content().cloned())
44    }
45}
46
47/// A root resolver for mock VFS tests.
48#[derive(Debug, Default)]
49pub struct MockRootResolver;
50
51impl RootResolver for MockRootResolver {
52    fn resolve_package_root(
53        &self,
54        _pkg: &typst::syntax::package::PackageSpec,
55    ) -> FileResult<ImmutPath> {
56        Err(FileError::AccessDenied)
57    }
58}
59
60/// A deterministic in-memory workspace for VFS and runtime tests.
61///
62/// Paths accepted by this type may be relative to the workspace root or already
63/// absolute. File writes are upserts because Tinymist's runtime-facing
64/// [`FileChangeSet`] insert side also represents both creates and updates.
65#[derive(Debug, Clone)]
66pub struct MockWorkspace {
67    root: PathBuf,
68    files: SharedFiles,
69}
70
71impl Default for MockWorkspace {
72    fn default() -> Self {
73        Self::new(default_mock_root())
74    }
75}
76
77impl MockWorkspace {
78    /// Creates an empty mock workspace at the given root.
79    pub fn new(root: impl Into<PathBuf>) -> Self {
80        Self {
81            root: root.into(),
82            files: Arc::default(),
83        }
84    }
85
86    /// Creates a builder for a mock workspace at the given root.
87    pub fn builder(root: impl Into<PathBuf>) -> MockWorkspaceBuilder {
88        MockWorkspaceBuilder::new(root)
89    }
90
91    /// Creates a builder for a mock workspace at the default test root.
92    pub fn default_builder() -> MockWorkspaceBuilder {
93        MockWorkspaceBuilder::new(default_mock_root())
94    }
95
96    /// Returns the workspace root.
97    pub fn root(&self) -> &Path {
98        &self.root
99    }
100
101    /// Returns the workspace root as an immutable path.
102    pub fn root_path(&self) -> ImmutPath {
103        immut_path(self.root.clone())
104    }
105
106    /// Resolves a test path against the workspace root.
107    pub fn path(&self, path: impl AsRef<Path>) -> PathBuf {
108        let path = path.as_ref();
109        if path.is_absolute() {
110            path.to_owned()
111        } else {
112            self.root.join(path)
113        }
114    }
115
116    /// Resolves a test path against the workspace root as an immutable path.
117    pub fn immut_path(&self, path: impl AsRef<Path>) -> ImmutPath {
118        immut_path(self.path(path))
119    }
120
121    /// Resolves a test path as a Typst virtual path inside the workspace.
122    pub fn virtual_path(&self, path: impl AsRef<Path>) -> FileResult<VirtualPath> {
123        let path = self.path(path);
124        let relative = path
125            .strip_prefix(&self.root)
126            .map_err(|_| FileError::AccessDenied)?;
127        Ok(VirtualPath::new(relative))
128    }
129
130    /// Resolves a test path as a workspace [`FileId`].
131    pub fn file_id(&self, path: impl AsRef<Path>) -> FileResult<FileId> {
132        Ok(WorkspaceResolver::workspace_file(
133            Some(&self.root_path()),
134            self.virtual_path(path)?,
135        ))
136    }
137
138    /// Creates path access for a Tinymist VFS.
139    pub fn access_model(&self) -> MockPathAccess {
140        MockPathAccess {
141            files: self.files.clone(),
142        }
143    }
144
145    /// Creates a VFS backed by this workspace.
146    pub fn vfs(&self) -> Vfs<MockPathAccess> {
147        Vfs::new(Arc::new(MockRootResolver), self.access_model())
148    }
149
150    /// Reads bytes from the in-memory workspace.
151    pub fn read(&self, path: impl AsRef<Path>) -> FileResult<Bytes> {
152        let path = self.path(path);
153        self.files
154            .read()
155            .expect("mock workspace lock poisoned")
156            .get(&path)
157            .ok_or_else(|| FileError::NotFound(path.clone()))
158            .and_then(|snapshot| snapshot.content().cloned())
159    }
160
161    /// Returns whether a file exists in the in-memory workspace.
162    pub fn contains(&self, path: impl AsRef<Path>) -> bool {
163        self.files
164            .read()
165            .expect("mock workspace lock poisoned")
166            .contains_key(&self.path(path))
167    }
168
169    /// Creates or updates a Typst source file.
170    pub fn write_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
171        self.write_bytes(path, Bytes::from_string(source.into()))
172    }
173
174    /// Creates a Typst source file.
175    pub fn create_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
176        self.write_source(path, source)
177    }
178
179    /// Updates a Typst source file.
180    pub fn update_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
181        self.write_source(path, source)
182    }
183
184    /// Creates or updates a file with arbitrary bytes.
185    pub fn write_bytes(&self, path: impl AsRef<Path>, bytes: Bytes) -> MockChange {
186        let path = self.path(path);
187        let snapshot = snapshot(bytes);
188
189        self.files
190            .write()
191            .expect("mock workspace lock poisoned")
192            .insert(path.clone(), snapshot.clone());
193
194        MockChange::new(FileChangeSet::new_inserts(vec![(
195            immut_path(path),
196            snapshot,
197        )]))
198    }
199
200    /// Removes a file from the in-memory workspace.
201    pub fn remove(&self, path: impl AsRef<Path>) -> FileResult<MockChange> {
202        let path = self.path(path);
203
204        let removed = self
205            .files
206            .write()
207            .expect("mock workspace lock poisoned")
208            .remove(&path);
209
210        match removed {
211            Some(_) => Ok(MockChange::new(FileChangeSet::new_removes(vec![
212                immut_path(path),
213            ]))),
214            None => Err(FileError::NotFound(path)),
215        }
216    }
217
218    /// Renames a file inside the in-memory workspace.
219    pub fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) -> FileResult<MockChange> {
220        let from = self.path(from);
221        let to = self.path(to);
222
223        let mut files = self.files.write().expect("mock workspace lock poisoned");
224        let snapshot = files
225            .remove(&from)
226            .ok_or_else(|| FileError::NotFound(from.clone()))?;
227        files.insert(to.clone(), snapshot.clone());
228
229        Ok(MockChange::new(FileChangeSet {
230            removes: vec![immut_path(from)],
231            inserts: vec![(immut_path(to), snapshot)],
232        }))
233    }
234
235    /// Returns a changeset that syncs the current workspace files.
236    pub fn sync_changeset(&self) -> FileChangeSet {
237        let inserts = self
238            .files
239            .read()
240            .expect("mock workspace lock poisoned")
241            .iter()
242            .map(|(path, snapshot)| (immut_path(path.clone()), snapshot.clone()))
243            .collect();
244
245        FileChangeSet::new_inserts(inserts)
246    }
247
248    /// Returns a filesystem event that syncs the current workspace files.
249    pub fn sync_filesystem_event(&self) -> FilesystemEvent {
250        FilesystemEvent::Update(self.sync_changeset(), true)
251    }
252
253    /// Returns a memory event that syncs the current workspace files.
254    pub fn sync_memory_event(&self) -> MemoryEvent {
255        MemoryEvent::Sync(self.sync_changeset())
256    }
257}
258
259/// Builder for [`MockWorkspace`].
260#[derive(Debug)]
261pub struct MockWorkspaceBuilder {
262    workspace: MockWorkspace,
263}
264
265impl MockWorkspaceBuilder {
266    /// Creates a mock workspace builder at the given root.
267    pub fn new(root: impl Into<PathBuf>) -> Self {
268        Self {
269            workspace: MockWorkspace::new(root),
270        }
271    }
272
273    /// Adds a Typst source file to the workspace.
274    pub fn file(self, path: impl AsRef<Path>, source: impl Into<String>) -> Self {
275        self.workspace.write_source(path, source);
276        self
277    }
278
279    /// Adds an arbitrary byte file to the workspace.
280    pub fn bytes(self, path: impl AsRef<Path>, bytes: Bytes) -> Self {
281        self.workspace.write_bytes(path, bytes);
282        self
283    }
284
285    /// Finishes the builder.
286    pub fn build(self) -> MockWorkspace {
287        self.workspace
288    }
289}
290
291/// A workspace mutation and its runtime-facing changeset.
292#[derive(Debug, Clone)]
293pub struct MockChange {
294    changeset: FileChangeSet,
295}
296
297impl MockChange {
298    /// Creates a mock change from a changeset.
299    pub fn new(changeset: FileChangeSet) -> Self {
300        Self { changeset }
301    }
302
303    /// Returns the changeset.
304    pub fn changeset(&self) -> &FileChangeSet {
305        &self.changeset
306    }
307
308    /// Consumes this change and returns the changeset.
309    pub fn into_changeset(self) -> FileChangeSet {
310        self.changeset
311    }
312
313    /// Returns this change as a filesystem event.
314    pub fn filesystem_event(&self, is_sync: bool) -> FilesystemEvent {
315        FilesystemEvent::Update(self.changeset.clone(), is_sync)
316    }
317
318    /// Consumes this change and returns it as a filesystem event.
319    pub fn into_filesystem_event(self, is_sync: bool) -> FilesystemEvent {
320        FilesystemEvent::Update(self.changeset, is_sync)
321    }
322
323    /// Returns this change as a memory update event.
324    pub fn memory_event(&self) -> MemoryEvent {
325        MemoryEvent::Update(self.changeset.clone())
326    }
327
328    /// Returns this change as a memory sync event.
329    pub fn memory_sync_event(&self) -> MemoryEvent {
330        MemoryEvent::Sync(self.changeset.clone())
331    }
332
333    /// Applies this change to a VFS through `notify_fs_changes`.
334    pub fn apply_to_vfs<M>(&self, vfs: &mut Vfs<M>)
335    where
336        M: PathAccessModel,
337    {
338        vfs.revise().notify_fs_changes(self.changeset.clone());
339    }
340}
341
342/// Returns the default root used by mock workspaces.
343pub fn default_mock_root() -> PathBuf {
344    if cfg!(windows) {
345        PathBuf::from(r"C:\tinymist-mock")
346    } else {
347        PathBuf::from("/tinymist-mock")
348    }
349}
350
351fn snapshot(bytes: Bytes) -> FileSnapshot {
352    Ok(bytes).into()
353}
354
355fn immut_path(path: PathBuf) -> ImmutPath {
356    Arc::from(path.into_boxed_path())
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn mock_workspace_drives_vfs_updates() {
365        let workspace = MockWorkspace::default_builder()
366            .file("main.typ", "#let value = [before]\n#value")
367            .build();
368        let mut vfs = workspace.vfs();
369        let main_id = workspace.file_id("main.typ").unwrap();
370
371        assert_eq!(
372            vfs.source(main_id).unwrap().text(),
373            "#let value = [before]\n#value"
374        );
375
376        workspace
377            .update_source("main.typ", "#let value = [after]\n#value")
378            .apply_to_vfs(&mut vfs);
379
380        assert_eq!(
381            vfs.source(main_id).unwrap().text(),
382            "#let value = [after]\n#value"
383        );
384    }
385}