1use 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#[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#[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#[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 pub fn new(root: impl Into<PathBuf>) -> Self {
80 Self {
81 root: root.into(),
82 files: Arc::default(),
83 }
84 }
85
86 pub fn builder(root: impl Into<PathBuf>) -> MockWorkspaceBuilder {
88 MockWorkspaceBuilder::new(root)
89 }
90
91 pub fn default_builder() -> MockWorkspaceBuilder {
93 MockWorkspaceBuilder::new(default_mock_root())
94 }
95
96 pub fn root(&self) -> &Path {
98 &self.root
99 }
100
101 pub fn root_path(&self) -> ImmutPath {
103 immut_path(self.root.clone())
104 }
105
106 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 pub fn immut_path(&self, path: impl AsRef<Path>) -> ImmutPath {
118 immut_path(self.path(path))
119 }
120
121 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 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 pub fn access_model(&self) -> MockPathAccess {
137 MockPathAccess {
138 files: self.files.clone(),
139 }
140 }
141
142 pub fn vfs(&self) -> Vfs<MockPathAccess> {
144 Vfs::new(Arc::new(MockRootResolver), self.access_model())
145 }
146
147 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 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 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 pub fn create_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
173 self.write_source(path, source)
174 }
175
176 pub fn update_source(&self, path: impl AsRef<Path>, source: impl Into<String>) -> MockChange {
178 self.write_source(path, source)
179 }
180
181 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 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 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 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 pub fn sync_filesystem_event(&self) -> FilesystemEvent {
247 FilesystemEvent::Update(self.sync_changeset(), true)
248 }
249
250 pub fn sync_memory_event(&self) -> MemoryEvent {
252 MemoryEvent::Sync(self.sync_changeset())
253 }
254}
255
256#[derive(Debug)]
258pub struct MockWorkspaceBuilder {
259 workspace: MockWorkspace,
260}
261
262impl MockWorkspaceBuilder {
263 pub fn new(root: impl Into<PathBuf>) -> Self {
265 Self {
266 workspace: MockWorkspace::new(root),
267 }
268 }
269
270 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 pub fn bytes(self, path: impl AsRef<Path>, bytes: Bytes) -> Self {
278 self.workspace.write_bytes(path, bytes);
279 self
280 }
281
282 pub fn build(self) -> MockWorkspace {
284 self.workspace
285 }
286}
287
288#[derive(Debug, Clone)]
290pub struct MockChange {
291 changeset: FileChangeSet,
292}
293
294impl MockChange {
295 pub fn new(changeset: FileChangeSet) -> Self {
297 Self { changeset }
298 }
299
300 pub fn changeset(&self) -> &FileChangeSet {
302 &self.changeset
303 }
304
305 pub fn into_changeset(self) -> FileChangeSet {
307 self.changeset
308 }
309
310 pub fn filesystem_event(&self, is_sync: bool) -> FilesystemEvent {
312 FilesystemEvent::Update(self.changeset.clone(), is_sync)
313 }
314
315 pub fn into_filesystem_event(self, is_sync: bool) -> FilesystemEvent {
317 FilesystemEvent::Update(self.changeset, is_sync)
318 }
319
320 pub fn memory_event(&self) -> MemoryEvent {
322 MemoryEvent::Update(self.changeset.clone())
323 }
324
325 pub fn memory_sync_event(&self) -> MemoryEvent {
327 MemoryEvent::Sync(self.changeset.clone())
328 }
329
330 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
339pub 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}