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        VirtualPath::virtualize(&self.root, &path).map_err(|_| FileError::AccessDenied)
125    }
126
127    /// Resolves a test path as a workspace [`FileId`].
128    pub fn file_id(&self, path: impl AsRef<Path>) -> FileResult<FileId> {
129        Ok(WorkspaceResolver::workspace_file(
130            Some(&self.root_path()),
131            self.virtual_path(path)?,
132        ))
133    }
134
135    /// Creates path access for a Tinymist VFS.
136    pub fn access_model(&self) -> MockPathAccess {
137        MockPathAccess {
138            files: self.files.clone(),
139        }
140    }
141
142    /// Creates a VFS backed by this workspace.
143    pub fn vfs(&self) -> Vfs<MockPathAccess> {
144        Vfs::new(Arc::new(MockRootResolver), self.access_model())
145    }
146
147    /// Reads bytes from the in-memory workspace.
148    pub fn read(&self, path: impl AsRef<Path>) -> FileResult<Bytes> {
149        let path = self.path(path);
150        self.files
151            .read()
152            .expect("mock workspace lock poisoned")
153            .get(&path)
154            .ok_or_else(|| FileError::NotFound(path.clone()))
155            .and_then(|snapshot| snapshot.content().cloned())
156    }
157
158    /// Returns whether a file exists in the in-memory workspace.
159    pub fn contains(&self, path: impl AsRef<Path>) -> bool {
160        self.files
161            .read()
162            .expect("mock workspace lock poisoned")
163            .contains_key(&self.path(path))
164    }
165
166    /// Creates or updates a Typst source file.
167    pub fn write_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
168        self.write_bytes(path, Bytes::from_string(source.into()))
169    }
170
171    /// Creates a Typst source file.
172    pub fn create_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
173        self.write_source(path, source)
174    }
175
176    /// Updates a Typst source file.
177    pub fn update_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
178        self.write_source(path, source)
179    }
180
181    /// Creates or updates a file with arbitrary bytes.
182    pub fn write_bytes(&self, path: impl AsRef<Path>, bytes: Bytes) -> MockChange {
183        let path = self.path(path);
184        let snapshot = snapshot(bytes);
185
186        self.files
187            .write()
188            .expect("mock workspace lock poisoned")
189            .insert(path.clone(), snapshot.clone());
190
191        MockChange::new(FileChangeSet::new_inserts(vec![(
192            immut_path(path),
193            snapshot,
194        )]))
195    }
196
197    /// Removes a file from the in-memory workspace.
198    pub fn remove(&self, path: impl AsRef<Path>) -> FileResult<MockChange> {
199        let path = self.path(path);
200
201        let removed = self
202            .files
203            .write()
204            .expect("mock workspace lock poisoned")
205            .remove(&path);
206
207        match removed {
208            Some(_) => Ok(MockChange::new(FileChangeSet::new_removes(vec![
209                immut_path(path),
210            ]))),
211            None => Err(FileError::NotFound(path)),
212        }
213    }
214
215    /// Renames a file inside the in-memory workspace.
216    pub fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) -> FileResult<MockChange> {
217        let from = self.path(from);
218        let to = self.path(to);
219
220        let mut files = self.files.write().expect("mock workspace lock poisoned");
221        let snapshot = files
222            .remove(&from)
223            .ok_or_else(|| FileError::NotFound(from.clone()))?;
224        files.insert(to.clone(), snapshot.clone());
225
226        Ok(MockChange::new(FileChangeSet {
227            removes: vec![immut_path(from)],
228            inserts: vec![(immut_path(to), snapshot)],
229        }))
230    }
231
232    /// Returns a changeset that syncs the current workspace files.
233    pub fn sync_changeset(&self) -> FileChangeSet {
234        let inserts = self
235            .files
236            .read()
237            .expect("mock workspace lock poisoned")
238            .iter()
239            .map(|(path, snapshot)| (immut_path(path.clone()), snapshot.clone()))
240            .collect();
241
242        FileChangeSet::new_inserts(inserts)
243    }
244
245    /// Returns a filesystem event that syncs the current workspace files.
246    pub fn sync_filesystem_event(&self) -> FilesystemEvent {
247        FilesystemEvent::Update(self.sync_changeset(), true)
248    }
249
250    /// Returns a memory event that syncs the current workspace files.
251    pub fn sync_memory_event(&self) -> MemoryEvent {
252        MemoryEvent::Sync(self.sync_changeset())
253    }
254}
255
256/// Builder for [`MockWorkspace`].
257#[derive(Debug)]
258pub struct MockWorkspaceBuilder {
259    workspace: MockWorkspace,
260}
261
262impl MockWorkspaceBuilder {
263    /// Creates a mock workspace builder at the given root.
264    pub fn new(root: impl Into<PathBuf>) -> Self {
265        Self {
266            workspace: MockWorkspace::new(root),
267        }
268    }
269
270    /// Adds a Typst source file to the workspace.
271    pub fn file(self, path: impl AsRef<Path>, source: impl Into<String>) -> Self {
272        self.workspace.write_source(path, source);
273        self
274    }
275
276    /// Adds an arbitrary byte file to the workspace.
277    pub fn bytes(self, path: impl AsRef<Path>, bytes: Bytes) -> Self {
278        self.workspace.write_bytes(path, bytes);
279        self
280    }
281
282    /// Finishes the builder.
283    pub fn build(self) -> MockWorkspace {
284        self.workspace
285    }
286}
287
288/// A workspace mutation and its runtime-facing changeset.
289#[derive(Debug, Clone)]
290pub struct MockChange {
291    changeset: FileChangeSet,
292}
293
294impl MockChange {
295    /// Creates a mock change from a changeset.
296    pub fn new(changeset: FileChangeSet) -> Self {
297        Self { changeset }
298    }
299
300    /// Returns the changeset.
301    pub fn changeset(&self) -> &FileChangeSet {
302        &self.changeset
303    }
304
305    /// Consumes this change and returns the changeset.
306    pub fn into_changeset(self) -> FileChangeSet {
307        self.changeset
308    }
309
310    /// Returns this change as a filesystem event.
311    pub fn filesystem_event(&self, is_sync: bool) -> FilesystemEvent {
312        FilesystemEvent::Update(self.changeset.clone(), is_sync)
313    }
314
315    /// Consumes this change and returns it as a filesystem event.
316    pub fn into_filesystem_event(self, is_sync: bool) -> FilesystemEvent {
317        FilesystemEvent::Update(self.changeset, is_sync)
318    }
319
320    /// Returns this change as a memory update event.
321    pub fn memory_event(&self) -> MemoryEvent {
322        MemoryEvent::Update(self.changeset.clone())
323    }
324
325    /// Returns this change as a memory sync event.
326    pub fn memory_sync_event(&self) -> MemoryEvent {
327        MemoryEvent::Sync(self.changeset.clone())
328    }
329
330    /// Applies this change to a VFS through `notify_fs_changes`.
331    pub fn apply_to_vfs<M>(&self, vfs: &mut Vfs<M>)
332    where
333        M: PathAccessModel,
334    {
335        vfs.revise().notify_fs_changes(self.changeset.clone());
336    }
337}
338
339/// Returns the default root used by mock workspaces.
340pub fn default_mock_root() -> PathBuf {
341    if cfg!(windows) {
342        PathBuf::from(r"C:\tinymist-mock")
343    } else {
344        PathBuf::from("/tinymist-mock")
345    }
346}
347
348fn snapshot(bytes: Bytes) -> FileSnapshot {
349    Ok(bytes).into()
350}
351
352fn immut_path(path: PathBuf) -> ImmutPath {
353    Arc::from(path.into_boxed_path())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    const ENTRY: &str = "main.typ";
361    const DEP: &str = "dep.typ";
362    const RENAMED_DEP: &str = "renamed.typ";
363    const UNRELATED: &str = "notes.typ";
364    const ASSET: &str = "image.svg";
365
366    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
367    enum OperationId {
368        O01,
369        O02,
370        O03,
371        O04,
372        O05,
373        O06,
374        O07,
375        O08,
376        O09,
377        O10,
378        O11,
379        O12,
380        O13,
381        O14,
382        O15,
383        O16,
384        O17,
385        O18,
386        O19,
387        O20,
388    }
389
390    impl OperationId {
391        fn label(self) -> &'static str {
392            match self {
393                OperationId::O01 => "O01",
394                OperationId::O02 => "O02",
395                OperationId::O03 => "O03",
396                OperationId::O04 => "O04",
397                OperationId::O05 => "O05",
398                OperationId::O06 => "O06",
399                OperationId::O07 => "O07",
400                OperationId::O08 => "O08",
401                OperationId::O09 => "O09",
402                OperationId::O10 => "O10",
403                OperationId::O11 => "O11",
404                OperationId::O12 => "O12",
405                OperationId::O13 => "O13",
406                OperationId::O14 => "O14",
407                OperationId::O15 => "O15",
408                OperationId::O16 => "O16",
409                OperationId::O17 => "O17",
410                OperationId::O18 => "O18",
411                OperationId::O19 => "O19",
412                OperationId::O20 => "O20",
413            }
414        }
415    }
416
417    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
418    enum RelationVariant {
419        Entry,
420        ActiveDependency,
421        MissingDependency,
422        RetainedInactiveDependency,
423        AssetDependency,
424        ShadowOpenPath,
425        UnrelatedPath,
426    }
427
428    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
429    enum CachePostcondition {
430        InsertRefreshesCurrentSource,
431        SamePathReadErrorReplacesSource,
432        RemoveRetiresPath,
433        RemoveThenInsertRefreshesPath,
434        RenameRetiresOldPath,
435        PrefixRewriteRetiresOldPaths,
436        RootBoundaryRetiresOldPath,
437        NoDirectVfsChange,
438        ShadowOverlayOrdersWithFilesystem,
439        MixedBatchFinalState,
440    }
441
442    impl CachePostcondition {
443        fn grouping_note(self) -> &'static str {
444            match self {
445                CachePostcondition::InsertRefreshesCurrentSource => {
446                    "same-path create/update-like rows must refresh cached bytes and parsed Source"
447                }
448                CachePostcondition::SamePathReadErrorReplacesSource => {
449                    "read-error rows must replace the old readable snapshot until recovery"
450                }
451                CachePostcondition::RemoveRetiresPath => {
452                    "remove-like rows must stop serving cached source for the old path"
453                }
454                CachePostcondition::RemoveThenInsertRefreshesPath => {
455                    "delete/recreate rows must observe missing then fresh contents"
456                }
457                CachePostcondition::RenameRetiresOldPath => {
458                    "rename rows must retire the old path and make the new path independently readable"
459                }
460                CachePostcondition::PrefixRewriteRetiresOldPaths => {
461                    "directory-prefix rows must retire every known old child path"
462                }
463                CachePostcondition::RootBoundaryRetiresOldPath => {
464                    "root-boundary rows must retire paths that leave the addressable workspace"
465                }
466                CachePostcondition::NoDirectVfsChange => {
467                    "dependency membership rows with no file delta must not dirty VFS state by themselves"
468                }
469                CachePostcondition::ShadowOverlayOrdersWithFilesystem => {
470                    "shadow-open rows must keep memory content active until the shadow is removed"
471                }
472                CachePostcondition::MixedBatchFinalState => {
473                    "mixed batches are asserted by final observable VFS state, independent of atom order"
474                }
475            }
476        }
477    }
478
479    #[derive(Debug, Clone, Copy)]
480    struct VfsCacheMatrixRow {
481        id: OperationId,
482        postcondition: CachePostcondition,
483        relations: &'static [RelationVariant],
484    }
485
486    const VFS_CACHE_FILE_OPERATION_MATRIX: &[VfsCacheMatrixRow] = &[
487        VfsCacheMatrixRow {
488            id: OperationId::O01,
489            postcondition: CachePostcondition::InsertRefreshesCurrentSource,
490            relations: &[
491                RelationVariant::MissingDependency,
492                RelationVariant::UnrelatedPath,
493                RelationVariant::Entry,
494            ],
495        },
496        VfsCacheMatrixRow {
497            id: OperationId::O02,
498            postcondition: CachePostcondition::InsertRefreshesCurrentSource,
499            relations: &[
500                RelationVariant::Entry,
501                RelationVariant::ActiveDependency,
502                RelationVariant::AssetDependency,
503                RelationVariant::UnrelatedPath,
504            ],
505        },
506        VfsCacheMatrixRow {
507            id: OperationId::O03,
508            postcondition: CachePostcondition::InsertRefreshesCurrentSource,
509            relations: &[
510                RelationVariant::Entry,
511                RelationVariant::ActiveDependency,
512                RelationVariant::UnrelatedPath,
513            ],
514        },
515        VfsCacheMatrixRow {
516            id: OperationId::O04,
517            postcondition: CachePostcondition::SamePathReadErrorReplacesSource,
518            relations: &[
519                RelationVariant::Entry,
520                RelationVariant::ActiveDependency,
521                RelationVariant::AssetDependency,
522            ],
523        },
524        VfsCacheMatrixRow {
525            id: OperationId::O05,
526            postcondition: CachePostcondition::RemoveRetiresPath,
527            relations: &[
528                RelationVariant::Entry,
529                RelationVariant::ActiveDependency,
530                RelationVariant::RetainedInactiveDependency,
531                RelationVariant::UnrelatedPath,
532            ],
533        },
534        VfsCacheMatrixRow {
535            id: OperationId::O06,
536            postcondition: CachePostcondition::RemoveThenInsertRefreshesPath,
537            relations: &[
538                RelationVariant::ActiveDependency,
539                RelationVariant::MissingDependency,
540                RelationVariant::Entry,
541            ],
542        },
543        VfsCacheMatrixRow {
544            id: OperationId::O07,
545            postcondition: CachePostcondition::InsertRefreshesCurrentSource,
546            relations: &[
547                RelationVariant::Entry,
548                RelationVariant::ActiveDependency,
549                RelationVariant::AssetDependency,
550            ],
551        },
552        VfsCacheMatrixRow {
553            id: OperationId::O08,
554            postcondition: CachePostcondition::RenameRetiresOldPath,
555            relations: &[
556                RelationVariant::ActiveDependency,
557                RelationVariant::UnrelatedPath,
558            ],
559        },
560        VfsCacheMatrixRow {
561            id: OperationId::O09,
562            postcondition: CachePostcondition::RenameRetiresOldPath,
563            relations: &[RelationVariant::ActiveDependency, RelationVariant::Entry],
564        },
565        VfsCacheMatrixRow {
566            id: OperationId::O10,
567            postcondition: CachePostcondition::RenameRetiresOldPath,
568            relations: &[RelationVariant::Entry, RelationVariant::ActiveDependency],
569        },
570        VfsCacheMatrixRow {
571            id: OperationId::O11,
572            postcondition: CachePostcondition::RootBoundaryRetiresOldPath,
573            relations: &[
574                RelationVariant::ActiveDependency,
575                RelationVariant::MissingDependency,
576                RelationVariant::UnrelatedPath,
577            ],
578        },
579        VfsCacheMatrixRow {
580            id: OperationId::O12,
581            postcondition: CachePostcondition::PrefixRewriteRetiresOldPaths,
582            relations: &[
583                RelationVariant::ActiveDependency,
584                RelationVariant::UnrelatedPath,
585            ],
586        },
587        VfsCacheMatrixRow {
588            id: OperationId::O13,
589            postcondition: CachePostcondition::PrefixRewriteRetiresOldPaths,
590            relations: &[RelationVariant::ActiveDependency, RelationVariant::Entry],
591        },
592        VfsCacheMatrixRow {
593            id: OperationId::O14,
594            postcondition: CachePostcondition::PrefixRewriteRetiresOldPaths,
595            relations: &[
596                RelationVariant::ActiveDependency,
597                RelationVariant::UnrelatedPath,
598                RelationVariant::Entry,
599            ],
600        },
601        VfsCacheMatrixRow {
602            id: OperationId::O15,
603            postcondition: CachePostcondition::RootBoundaryRetiresOldPath,
604            relations: &[
605                RelationVariant::ActiveDependency,
606                RelationVariant::UnrelatedPath,
607            ],
608        },
609        VfsCacheMatrixRow {
610            id: OperationId::O16,
611            postcondition: CachePostcondition::NoDirectVfsChange,
612            relations: &[RelationVariant::RetainedInactiveDependency],
613        },
614        VfsCacheMatrixRow {
615            id: OperationId::O17,
616            postcondition: CachePostcondition::InsertRefreshesCurrentSource,
617            relations: &[
618                RelationVariant::RetainedInactiveDependency,
619                RelationVariant::ActiveDependency,
620            ],
621        },
622        VfsCacheMatrixRow {
623            id: OperationId::O18,
624            postcondition: CachePostcondition::ShadowOverlayOrdersWithFilesystem,
625            relations: &[
626                RelationVariant::ShadowOpenPath,
627                RelationVariant::Entry,
628                RelationVariant::ActiveDependency,
629            ],
630        },
631        VfsCacheMatrixRow {
632            id: OperationId::O19,
633            postcondition: CachePostcondition::InsertRefreshesCurrentSource,
634            relations: &[
635                RelationVariant::ActiveDependency,
636                RelationVariant::AssetDependency,
637                RelationVariant::UnrelatedPath,
638            ],
639        },
640        VfsCacheMatrixRow {
641            id: OperationId::O20,
642            postcondition: CachePostcondition::MixedBatchFinalState,
643            relations: &[
644                RelationVariant::Entry,
645                RelationVariant::ActiveDependency,
646                RelationVariant::UnrelatedPath,
647            ],
648        },
649    ];
650
651    #[test]
652    fn mock_workspace_drives_vfs_updates() {
653        let workspace = MockWorkspace::default_builder()
654            .file("main.typ", "#let value = [before]\n#value")
655            .build();
656        let mut vfs = workspace.vfs();
657        let main_id = workspace.file_id("main.typ").unwrap();
658
659        assert_eq!(
660            vfs.source(main_id).unwrap().text(),
661            "#let value = [before]\n#value"
662        );
663
664        workspace
665            .update_source("main.typ", "#let value = [after]\n#value")
666            .apply_to_vfs(&mut vfs);
667
668        assert_eq!(
669            vfs.source(main_id).unwrap().text(),
670            "#let value = [after]\n#value"
671        );
672    }
673
674    #[test]
675    fn vfs_cache_file_operation_matrix_is_explicit() {
676        for id in [
677            OperationId::O01,
678            OperationId::O02,
679            OperationId::O03,
680            OperationId::O04,
681            OperationId::O05,
682            OperationId::O06,
683            OperationId::O07,
684            OperationId::O08,
685            OperationId::O09,
686            OperationId::O10,
687            OperationId::O11,
688            OperationId::O12,
689            OperationId::O13,
690            OperationId::O14,
691            OperationId::O15,
692            OperationId::O16,
693            OperationId::O17,
694            OperationId::O18,
695            OperationId::O19,
696            OperationId::O20,
697        ] {
698            assert_matrix_contains(id, |row| row.id == id);
699        }
700
701        for relation in [
702            RelationVariant::Entry,
703            RelationVariant::ActiveDependency,
704            RelationVariant::MissingDependency,
705            RelationVariant::RetainedInactiveDependency,
706            RelationVariant::AssetDependency,
707            RelationVariant::ShadowOpenPath,
708            RelationVariant::UnrelatedPath,
709        ] {
710            assert_matrix_contains(relation, |row| row.relations.contains(&relation));
711        }
712
713        for postcondition in [
714            CachePostcondition::InsertRefreshesCurrentSource,
715            CachePostcondition::SamePathReadErrorReplacesSource,
716            CachePostcondition::RemoveRetiresPath,
717            CachePostcondition::RemoveThenInsertRefreshesPath,
718            CachePostcondition::RenameRetiresOldPath,
719            CachePostcondition::PrefixRewriteRetiresOldPaths,
720            CachePostcondition::RootBoundaryRetiresOldPath,
721            CachePostcondition::NoDirectVfsChange,
722            CachePostcondition::ShadowOverlayOrdersWithFilesystem,
723            CachePostcondition::MixedBatchFinalState,
724        ] {
725            assert_matrix_contains(postcondition, |row| row.postcondition == postcondition);
726            assert!(
727                !postcondition.grouping_note().is_empty(),
728                "postcondition {postcondition:?} must document why grouped rows share behavior"
729            );
730        }
731    }
732
733    #[test]
734    fn vfs_cache_file_operation_matrix_rows_execute_expected_state() {
735        for row in VFS_CACHE_FILE_OPERATION_MATRIX {
736            run_vfs_cache_matrix_row(*row);
737        }
738    }
739
740    fn run_vfs_cache_matrix_row(row: VfsCacheMatrixRow) {
741        match row.id {
742            OperationId::O01 => assert_create_row(row),
743            OperationId::O02 => assert_content_update_row(row),
744            OperationId::O03 => assert_transient_empty_row(row),
745            OperationId::O04 => assert_read_error_row(row),
746            OperationId::O05 => assert_remove_file_row(row),
747            OperationId::O06 => assert_delete_then_recreate_row(row),
748            OperationId::O07 => assert_atomic_replace_row(row),
749            OperationId::O08 => assert_rename_stale_row(row),
750            OperationId::O09 => assert_rename_updated_row(row),
751            OperationId::O10 => assert_case_only_rename_row(row),
752            OperationId::O11 => assert_move_file_root_boundary_row(row),
753            OperationId::O12 => assert_rename_directory_stale_row(row),
754            OperationId::O13 => assert_rename_directory_updated_row(row),
755            OperationId::O14 => assert_delete_directory_row(row),
756            OperationId::O15 => assert_move_directory_root_boundary_row(row),
757            OperationId::O16 => assert_membership_remove_row(row),
758            OperationId::O17 => assert_membership_add_row(row),
759            OperationId::O18 => assert_shadow_filesystem_race_row(row),
760            OperationId::O19 => assert_symlink_like_observable_change_row(row),
761            OperationId::O20 => assert_mixed_batch_row(row),
762        }
763    }
764
765    fn assert_create_row(row: VfsCacheMatrixRow) {
766        assert_eq!(
767            row.postcondition,
768            CachePostcondition::InsertRefreshesCurrentSource
769        );
770
771        let workspace = base_workspace();
772        let mut vfs = workspace.vfs();
773        let id = workspace.file_id("new.typ").unwrap();
774        assert!(vfs.source(id).is_err(), "{} precondition", row.id.label());
775        let revision = vfs.revision().get();
776
777        workspace
778            .create_source("new.typ", "#let value = [created]")
779            .apply_to_vfs(&mut vfs);
780
781        assert_source(
782            row.id,
783            &vfs,
784            &workspace,
785            "new.typ",
786            "#let value = [created]",
787        );
788        assert_dirty_since(row.id, &vfs, revision, id, "new.typ");
789    }
790
791    fn assert_content_update_row(row: VfsCacheMatrixRow) {
792        assert_eq!(
793            row.postcondition,
794            CachePostcondition::InsertRefreshesCurrentSource
795        );
796
797        let workspace = base_workspace();
798        let mut vfs = workspace.vfs();
799        let dep_id = workspace.file_id(DEP).unwrap();
800        let asset_id = workspace.file_id(ASSET).unwrap();
801        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
802        assert_eq!(
803            vfs.read(asset_id).unwrap(),
804            Bytes::from_string("asset-before".to_owned())
805        );
806        let revision = vfs.revision().get();
807
808        workspace
809            .update_source(DEP, "#let value = [after]")
810            .apply_to_vfs(&mut vfs);
811        workspace
812            .write_bytes(ASSET, Bytes::from_string("asset-after".to_owned()))
813            .apply_to_vfs(&mut vfs);
814
815        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [after]");
816        assert_eq!(
817            vfs.read(asset_id).unwrap(),
818            Bytes::from_string("asset-after".to_owned()),
819            "{} asset bytes should refresh",
820            row.id.label()
821        );
822        assert_dirty_since(row.id, &vfs, revision, dep_id, DEP);
823    }
824
825    fn assert_transient_empty_row(row: VfsCacheMatrixRow) {
826        assert_eq!(
827            row.postcondition,
828            CachePostcondition::InsertRefreshesCurrentSource
829        );
830
831        let workspace = base_workspace();
832        let mut vfs = workspace.vfs();
833        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
834
835        workspace.update_source(DEP, "").apply_to_vfs(&mut vfs);
836        assert_source(row.id, &vfs, &workspace, DEP, "");
837
838        workspace
839            .update_source(DEP, "#let value = [after empty]")
840            .apply_to_vfs(&mut vfs);
841        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [after empty]");
842    }
843
844    fn assert_read_error_row(row: VfsCacheMatrixRow) {
845        assert_eq!(
846            row.postcondition,
847            CachePostcondition::SamePathReadErrorReplacesSource
848        );
849
850        let workspace = base_workspace();
851        let mut vfs = workspace.vfs();
852        let dep_id = workspace.file_id(DEP).unwrap();
853        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
854        let revision = vfs.revision().get();
855
856        read_error_change(&workspace, DEP).apply_to_vfs(&mut vfs);
857
858        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
859        assert_dirty_since(row.id, &vfs, revision, dep_id, DEP);
860
861        workspace
862            .update_source(DEP, "#let value = [recovered]")
863            .apply_to_vfs(&mut vfs);
864        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [recovered]");
865    }
866
867    fn assert_remove_file_row(row: VfsCacheMatrixRow) {
868        assert_eq!(row.postcondition, CachePostcondition::RemoveRetiresPath);
869
870        let workspace = base_workspace();
871        let mut vfs = workspace.vfs();
872        let dep_id = workspace.file_id(DEP).unwrap();
873        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
874        let revision = vfs.revision().get();
875
876        workspace.remove(DEP).unwrap().apply_to_vfs(&mut vfs);
877
878        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
879        assert_dirty_since(row.id, &vfs, revision, dep_id, DEP);
880    }
881
882    fn assert_delete_then_recreate_row(row: VfsCacheMatrixRow) {
883        assert_eq!(
884            row.postcondition,
885            CachePostcondition::RemoveThenInsertRefreshesPath
886        );
887
888        let workspace = base_workspace();
889        let mut vfs = workspace.vfs();
890        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
891
892        workspace.remove(DEP).unwrap().apply_to_vfs(&mut vfs);
893        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
894
895        workspace
896            .create_source(DEP, "#let value = [recreated]")
897            .apply_to_vfs(&mut vfs);
898        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [recreated]");
899    }
900
901    fn assert_atomic_replace_row(row: VfsCacheMatrixRow) {
902        assert_eq!(
903            row.postcondition,
904            CachePostcondition::InsertRefreshesCurrentSource
905        );
906
907        let workspace = base_workspace();
908        let mut vfs = workspace.vfs();
909        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
910
911        let replace = replace_source_change(&workspace, DEP, "#let value = [replaced]");
912        replace.apply_to_vfs(&mut vfs);
913
914        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [replaced]");
915    }
916
917    fn assert_rename_stale_row(row: VfsCacheMatrixRow) {
918        assert_eq!(row.postcondition, CachePostcondition::RenameRetiresOldPath);
919
920        let workspace = base_workspace();
921        let mut vfs = workspace.vfs();
922        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
923
924        workspace
925            .rename(DEP, RENAMED_DEP)
926            .unwrap()
927            .apply_to_vfs(&mut vfs);
928
929        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
930        assert_source(
931            row.id,
932            &vfs,
933            &workspace,
934            RENAMED_DEP,
935            "#let value = [before]",
936        );
937    }
938
939    fn assert_rename_updated_row(row: VfsCacheMatrixRow) {
940        assert_eq!(row.postcondition, CachePostcondition::RenameRetiresOldPath);
941
942        let workspace = base_workspace();
943        let mut vfs = workspace.vfs();
944        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
945
946        let rename = workspace.rename(DEP, RENAMED_DEP).unwrap();
947        let entry = workspace.update_source(ENTRY, "#import \"renamed.typ\": value\n#value");
948        combine_changes(&[rename, entry]).apply_to_vfs(&mut vfs);
949
950        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
951        assert_source(
952            row.id,
953            &vfs,
954            &workspace,
955            RENAMED_DEP,
956            "#let value = [before]",
957        );
958        assert_source(
959            row.id,
960            &vfs,
961            &workspace,
962            ENTRY,
963            "#import \"renamed.typ\": value\n#value",
964        );
965    }
966
967    fn assert_case_only_rename_row(row: VfsCacheMatrixRow) {
968        assert_eq!(row.postcondition, CachePostcondition::RenameRetiresOldPath);
969
970        let workspace = MockWorkspace::default_builder()
971            .file("case.typ", "#let value = [case]")
972            .build();
973        let mut vfs = workspace.vfs();
974        assert_source(row.id, &vfs, &workspace, "case.typ", "#let value = [case]");
975
976        workspace
977            .rename("case.typ", "Case.typ")
978            .unwrap()
979            .apply_to_vfs(&mut vfs);
980
981        assert_source_unavailable(row.id, &vfs, &workspace, "case.typ");
982        assert_source(row.id, &vfs, &workspace, "Case.typ", "#let value = [case]");
983    }
984
985    fn assert_move_file_root_boundary_row(row: VfsCacheMatrixRow) {
986        assert_eq!(
987            row.postcondition,
988            CachePostcondition::RootBoundaryRetiresOldPath
989        );
990
991        let workspace = base_workspace();
992        let mut vfs = workspace.vfs();
993        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
994
995        let removed = workspace.remove(DEP).unwrap();
996        let moved_out = workspace.write_source("/outside-root/dep.typ", "#let value = [outside]");
997        combine_changes(&[removed, moved_out]).apply_to_vfs(&mut vfs);
998
999        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
1000    }
1001
1002    fn assert_rename_directory_stale_row(row: VfsCacheMatrixRow) {
1003        assert_eq!(
1004            row.postcondition,
1005            CachePostcondition::PrefixRewriteRetiresOldPaths
1006        );
1007
1008        let workspace = directory_workspace();
1009        let mut vfs = workspace.vfs();
1010        assert_source(
1011            row.id,
1012            &vfs,
1013            &workspace,
1014            "chapters/dep.typ",
1015            "#let value = [chapter]",
1016        );
1017
1018        let remove_dep = workspace.remove("chapters/dep.typ").unwrap();
1019        let create_dep = workspace.create_source("renamed/dep.typ", "#let value = [chapter]");
1020        combine_changes(&[remove_dep, create_dep]).apply_to_vfs(&mut vfs);
1021
1022        assert_source_unavailable(row.id, &vfs, &workspace, "chapters/dep.typ");
1023        assert_source(
1024            row.id,
1025            &vfs,
1026            &workspace,
1027            "renamed/dep.typ",
1028            "#let value = [chapter]",
1029        );
1030    }
1031
1032    fn assert_rename_directory_updated_row(row: VfsCacheMatrixRow) {
1033        assert_eq!(
1034            row.postcondition,
1035            CachePostcondition::PrefixRewriteRetiresOldPaths
1036        );
1037
1038        let workspace = directory_workspace();
1039        let mut vfs = workspace.vfs();
1040        assert_source(
1041            row.id,
1042            &vfs,
1043            &workspace,
1044            "chapters/dep.typ",
1045            "#let value = [chapter]",
1046        );
1047
1048        let remove_dep = workspace.remove("chapters/dep.typ").unwrap();
1049        let create_dep = workspace.create_source("renamed/dep.typ", "#let value = [chapter]");
1050        let entry = workspace.update_source(ENTRY, "#import \"renamed/dep.typ\": value\n#value");
1051        combine_changes(&[remove_dep, create_dep, entry]).apply_to_vfs(&mut vfs);
1052
1053        assert_source_unavailable(row.id, &vfs, &workspace, "chapters/dep.typ");
1054        assert_source(
1055            row.id,
1056            &vfs,
1057            &workspace,
1058            "renamed/dep.typ",
1059            "#let value = [chapter]",
1060        );
1061        assert_source(
1062            row.id,
1063            &vfs,
1064            &workspace,
1065            ENTRY,
1066            "#import \"renamed/dep.typ\": value\n#value",
1067        );
1068    }
1069
1070    fn assert_delete_directory_row(row: VfsCacheMatrixRow) {
1071        assert_eq!(
1072            row.postcondition,
1073            CachePostcondition::PrefixRewriteRetiresOldPaths
1074        );
1075
1076        let workspace = MockWorkspace::default_builder()
1077            .file(
1078                ENTRY,
1079                "#import \"chapters/a.typ\": a\n#import \"chapters/b.typ\": b\n#a\n#b",
1080            )
1081            .file("chapters/a.typ", "#let a = [a]")
1082            .file("chapters/b.typ", "#let b = [b]")
1083            .file("chapters/note.typ", "#let note = [unused]")
1084            .build();
1085        let mut vfs = workspace.vfs();
1086        assert_source(row.id, &vfs, &workspace, "chapters/a.typ", "#let a = [a]");
1087        assert_source(row.id, &vfs, &workspace, "chapters/b.typ", "#let b = [b]");
1088
1089        let remove_a = workspace.remove("chapters/a.typ").unwrap();
1090        let remove_b = workspace.remove("chapters/b.typ").unwrap();
1091        let remove_note = workspace.remove("chapters/note.typ").unwrap();
1092        combine_changes(&[remove_a, remove_b, remove_note]).apply_to_vfs(&mut vfs);
1093
1094        assert_source_unavailable(row.id, &vfs, &workspace, "chapters/a.typ");
1095        assert_source_unavailable(row.id, &vfs, &workspace, "chapters/b.typ");
1096    }
1097
1098    fn assert_move_directory_root_boundary_row(row: VfsCacheMatrixRow) {
1099        assert_eq!(
1100            row.postcondition,
1101            CachePostcondition::RootBoundaryRetiresOldPath
1102        );
1103
1104        let workspace = directory_workspace();
1105        let mut vfs = workspace.vfs();
1106        assert_source(
1107            row.id,
1108            &vfs,
1109            &workspace,
1110            "chapters/dep.typ",
1111            "#let value = [chapter]",
1112        );
1113
1114        let remove_dep = workspace.remove("chapters/dep.typ").unwrap();
1115        let moved_out =
1116            workspace.write_source("/outside-root/chapters/dep.typ", "#let value = [chapter]");
1117        combine_changes(&[remove_dep, moved_out]).apply_to_vfs(&mut vfs);
1118
1119        assert_source_unavailable(row.id, &vfs, &workspace, "chapters/dep.typ");
1120    }
1121
1122    fn assert_membership_remove_row(row: VfsCacheMatrixRow) {
1123        assert_eq!(row.postcondition, CachePostcondition::NoDirectVfsChange);
1124
1125        let workspace = base_workspace();
1126        let mut vfs = workspace.vfs();
1127        let dep_id = workspace.file_id(DEP).unwrap();
1128        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
1129        let revision = vfs.revision().get();
1130
1131        empty_change().apply_to_vfs(&mut vfs);
1132
1133        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
1134        assert!(
1135            vfs.is_clean_compile(revision, &[dep_id]),
1136            "{} no direct VFS change should keep {DEP:?} clean",
1137            row.id.label()
1138        );
1139    }
1140
1141    fn assert_membership_add_row(row: VfsCacheMatrixRow) {
1142        assert_eq!(
1143            row.postcondition,
1144            CachePostcondition::InsertRefreshesCurrentSource
1145        );
1146
1147        let workspace = base_workspace();
1148        let mut vfs = workspace.vfs();
1149        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
1150
1151        workspace
1152            .update_source(DEP, "#let value = [changed while inactive]")
1153            .apply_to_vfs(&mut vfs);
1154
1155        assert_source(
1156            row.id,
1157            &vfs,
1158            &workspace,
1159            DEP,
1160            "#let value = [changed while inactive]",
1161        );
1162    }
1163
1164    fn assert_shadow_filesystem_race_row(row: VfsCacheMatrixRow) {
1165        assert_eq!(
1166            row.postcondition,
1167            CachePostcondition::ShadowOverlayOrdersWithFilesystem
1168        );
1169
1170        let workspace = base_workspace();
1171        let mut vfs = workspace.vfs();
1172        let entry_path = workspace.path(ENTRY);
1173        assert_source(
1174            row.id,
1175            &vfs,
1176            &workspace,
1177            ENTRY,
1178            "#import \"dep.typ\": value\n#value",
1179        );
1180
1181        vfs.revise()
1182            .map_shadow(
1183                &entry_path,
1184                snapshot(Bytes::from_string(
1185                    "#let value = [memory]\n#value".to_owned(),
1186                )),
1187            )
1188            .unwrap();
1189        assert_source(
1190            row.id,
1191            &vfs,
1192            &workspace,
1193            ENTRY,
1194            "#let value = [memory]\n#value",
1195        );
1196
1197        workspace
1198            .update_source(ENTRY, "#let value = [filesystem]\n#value")
1199            .apply_to_vfs(&mut vfs);
1200        assert_source(
1201            row.id,
1202            &vfs,
1203            &workspace,
1204            ENTRY,
1205            "#let value = [memory]\n#value",
1206        );
1207
1208        vfs.revise().unmap_shadow(&entry_path).unwrap();
1209        assert_source(
1210            row.id,
1211            &vfs,
1212            &workspace,
1213            ENTRY,
1214            "#let value = [filesystem]\n#value",
1215        );
1216    }
1217
1218    fn assert_symlink_like_observable_change_row(row: VfsCacheMatrixRow) {
1219        assert_eq!(
1220            row.postcondition,
1221            CachePostcondition::InsertRefreshesCurrentSource
1222        );
1223
1224        let workspace = MockWorkspace::default_builder()
1225            .file("linked.typ", "#let value = [target-a]")
1226            .build();
1227        let mut vfs = workspace.vfs();
1228        assert_source(
1229            row.id,
1230            &vfs,
1231            &workspace,
1232            "linked.typ",
1233            "#let value = [target-a]",
1234        );
1235
1236        workspace
1237            .update_source("linked.typ", "#let value = [target-b]")
1238            .apply_to_vfs(&mut vfs);
1239
1240        assert_source(
1241            row.id,
1242            &vfs,
1243            &workspace,
1244            "linked.typ",
1245            "#let value = [target-b]",
1246        );
1247    }
1248
1249    fn assert_mixed_batch_row(row: VfsCacheMatrixRow) {
1250        assert_eq!(row.postcondition, CachePostcondition::MixedBatchFinalState);
1251
1252        let workspace = base_workspace();
1253        let mut vfs = workspace.vfs();
1254        assert_source(
1255            row.id,
1256            &vfs,
1257            &workspace,
1258            ENTRY,
1259            "#import \"dep.typ\": value\n#value",
1260        );
1261        assert_source(row.id, &vfs, &workspace, DEP, "#let value = [before]");
1262
1263        let rename = workspace.rename(DEP, RENAMED_DEP).unwrap();
1264        let entry = workspace.update_source(ENTRY, "#import \"renamed.typ\": value\n#value");
1265        let unrelated = workspace.update_source(UNRELATED, "#let note = [changed]");
1266        let created = workspace.create_source("created.typ", "#let created = [created]");
1267        combine_changes(&[rename, entry, unrelated, created]).apply_to_vfs(&mut vfs);
1268
1269        assert_source_unavailable(row.id, &vfs, &workspace, DEP);
1270        assert_source(
1271            row.id,
1272            &vfs,
1273            &workspace,
1274            RENAMED_DEP,
1275            "#let value = [before]",
1276        );
1277        assert_source(
1278            row.id,
1279            &vfs,
1280            &workspace,
1281            ENTRY,
1282            "#import \"renamed.typ\": value\n#value",
1283        );
1284        assert_source(row.id, &vfs, &workspace, UNRELATED, "#let note = [changed]");
1285        assert_source(
1286            row.id,
1287            &vfs,
1288            &workspace,
1289            "created.typ",
1290            "#let created = [created]",
1291        );
1292    }
1293
1294    fn base_workspace() -> MockWorkspace {
1295        MockWorkspace::default_builder()
1296            .file(ENTRY, "#import \"dep.typ\": value\n#value")
1297            .file(DEP, "#let value = [before]")
1298            .file(UNRELATED, "#let note = [unchanged]")
1299            .bytes(ASSET, Bytes::from_string("asset-before".to_owned()))
1300            .build()
1301    }
1302
1303    fn directory_workspace() -> MockWorkspace {
1304        MockWorkspace::default_builder()
1305            .file(ENTRY, "#import \"chapters/dep.typ\": value\n#value")
1306            .file("chapters/dep.typ", "#let value = [chapter]")
1307            .file("chapters/unrelated.typ", "#let note = [unused]")
1308            .build()
1309    }
1310
1311    fn read_error_change(workspace: &MockWorkspace, path: &str) -> MockChange {
1312        let snapshot = FileResult::Err(FileError::NotFound(workspace.path(path))).into();
1313        MockChange::new(FileChangeSet::new_inserts(vec![(
1314            workspace.immut_path(path),
1315            snapshot,
1316        )]))
1317    }
1318
1319    fn replace_source_change(workspace: &MockWorkspace, path: &str, source: &str) -> MockChange {
1320        let removed = workspace.remove(path).unwrap();
1321        let created = workspace.create_source(path, source);
1322        combine_changes(&[removed, created])
1323    }
1324
1325    fn empty_change() -> MockChange {
1326        MockChange::new(FileChangeSet::default())
1327    }
1328
1329    fn combine_changes(changes: &[MockChange]) -> MockChange {
1330        let mut changeset = FileChangeSet::default();
1331        for change in changes {
1332            changeset.removes.extend(change.changeset().removes.clone());
1333            changeset.inserts.extend(change.changeset().inserts.clone());
1334        }
1335
1336        MockChange::new(changeset)
1337    }
1338
1339    fn assert_matrix_contains<T: std::fmt::Debug>(
1340        missing: T,
1341        predicate: impl Fn(&VfsCacheMatrixRow) -> bool,
1342    ) {
1343        assert!(
1344            VFS_CACHE_FILE_OPERATION_MATRIX.iter().any(predicate),
1345            "VFS/cache file-operation matrix missing {missing:?}"
1346        );
1347    }
1348
1349    fn assert_source(
1350        id: OperationId,
1351        vfs: &Vfs<MockPathAccess>,
1352        workspace: &MockWorkspace,
1353        path: &str,
1354        expected: &str,
1355    ) {
1356        let file_id = workspace.file_id(path).unwrap_or_else(|err| {
1357            panic!(
1358                "{} failed to resolve file id for {path:?}: {err:?}",
1359                id.label()
1360            )
1361        });
1362        let source = vfs.source(file_id).unwrap_or_else(|err| {
1363            panic!(
1364                "{} expected source for {path:?}, got error: {err:?}",
1365                id.label()
1366            )
1367        });
1368        assert_eq!(
1369            source.text(),
1370            expected,
1371            "{} source mismatch for {path:?}",
1372            id.label()
1373        );
1374    }
1375
1376    fn assert_source_unavailable(
1377        id: OperationId,
1378        vfs: &Vfs<MockPathAccess>,
1379        workspace: &MockWorkspace,
1380        path: &str,
1381    ) {
1382        let file_id = workspace.file_id(path).unwrap_or_else(|err| {
1383            panic!(
1384                "{} failed to resolve file id for {path:?}: {err:?}",
1385                id.label()
1386            )
1387        });
1388        if let Ok(source) = vfs.source(file_id) {
1389            panic!(
1390                "{} expected {path:?} to be unavailable, got {:?}",
1391                id.label(),
1392                source.text()
1393            );
1394        }
1395    }
1396
1397    fn assert_dirty_since(
1398        id: OperationId,
1399        vfs: &Vfs<MockPathAccess>,
1400        revision: usize,
1401        file_id: FileId,
1402        path: &str,
1403    ) {
1404        assert!(
1405            !vfs.is_clean_compile(revision, &[file_id]),
1406            "{} expected {path:?} to be dirty since revision {revision}",
1407            id.label()
1408        );
1409    }
1410}