1use std::{borrow::Cow, error::Error, fmt, path::MAIN_SEPARATOR};
4
5use anyhow::{Result, bail};
6use auto_hash_map::{AutoMap, AutoSet};
7use bincode::{Decode, Encode};
8use indexmap::IndexSet;
9use turbo_rcstr::RcStr;
10use turbo_tasks::{
11 Completion, NonLocalValue, ResolvedVc, ValueToString, ValueToStringRef, Vc, turbobail, turbofmt,
12};
13use turbo_unix_path::{
14 get_parent_path, get_relative_path_to, get_relative_request_to, join_path, normalize_path,
15};
16
17use crate::{
18 DirectoryContent, DirectoryEntry, FileContent, FileJsonContent, FileMeta, FileSystem,
19 FileSystemEntryType, LinkContent, RawDirectoryContent, RawDirectoryEntry, ReadGlobResult,
20 WriteLinkContent,
21 glob::Glob,
22 read_glob::{read_glob, track_glob},
23};
24
25#[derive(Debug, Clone, Hash)]
26#[turbo_tasks::value(shared, task_input)]
27pub struct FileSystemPath {
28 pub fs: ResolvedVc<Box<dyn FileSystem>>,
29 pub path: RcStr,
30}
31
32impl ValueToStringRef for FileSystemPath {
33 async fn to_string_ref(&self) -> Result<RcStr> {
34 turbofmt!("[{}]/{}", self.fs, self.path).await
35 }
36}
37
38#[turbo_tasks::value_impl]
39impl ValueToString for FileSystemPath {
40 #[turbo_tasks::function]
41 async fn to_string(&self) -> Result<Vc<RcStr>> {
42 Ok(Vc::cell(self.to_string_ref().await?))
43 }
44}
45
46impl FileSystemPath {
47 pub fn is_inside_ref(&self, other: &FileSystemPath) -> bool {
48 if self.fs == other.fs && self.path.starts_with(&*other.path) {
49 if other.path.is_empty() {
50 true
51 } else {
52 self.path.as_bytes().get(other.path.len()) == Some(&b'/')
53 }
54 } else {
55 false
56 }
57 }
58
59 pub fn is_inside_or_equal_ref(&self, other: &FileSystemPath) -> bool {
60 if self.fs == other.fs && self.path.starts_with(&*other.path) {
61 if other.path.is_empty() {
62 true
63 } else {
64 matches!(
65 self.path.as_bytes().get(other.path.len()),
66 Some(&b'/') | None
67 )
68 }
69 } else {
70 false
71 }
72 }
73
74 pub fn is_root(&self) -> bool {
75 self.path.is_empty()
76 }
77
78 pub fn is_in_node_modules(&self) -> bool {
79 self.path.starts_with("node_modules/") || self.path.contains("/node_modules/")
80 }
81
82 pub fn get_path_to<'a>(&self, inner: &'a FileSystemPath) -> Option<&'a str> {
87 if self.fs != inner.fs {
88 return None;
89 }
90 let path = inner.path.strip_prefix(&*self.path)?;
91 if self.path.is_empty() {
92 Some(path)
93 } else if let Some(stripped) = path.strip_prefix('/') {
94 Some(stripped)
95 } else {
96 None
97 }
98 }
99
100 pub fn get_relative_path_to(&self, other: &FileSystemPath) -> Option<RcStr> {
108 if self.fs != other.fs {
109 return None;
110 }
111
112 Some(match get_relative_path_to(&self.path, &other.path) {
113 Cow::Borrowed(path) if std::ptr::eq(path, other.path.as_str()) => other.path.clone(),
114 Cow::Borrowed(path) => path.into(),
115 Cow::Owned(path) => path.into(),
116 })
117 }
118
119 pub fn get_relative_request_to(&self, other: &FileSystemPath) -> Option<RcStr> {
128 if self.fs != other.fs {
129 return None;
130 }
131
132 Some(get_relative_request_to(&self.path, &other.path).into())
133 }
134
135 pub fn file_name(&self) -> &str {
138 let (_, file_name) = self.split_file_name();
139 file_name
140 }
141
142 pub fn has_extension(&self, extension: &str) -> bool {
147 debug_assert!(!extension.contains('/') && extension.starts_with('.'));
148 self.path.ends_with(extension)
149 }
150
151 pub fn extension(&self) -> Option<&str> {
153 let (_, extension) = self.split_extension();
154 extension
155 }
156
157 fn split_extension(&self) -> (&str, Option<&str>) {
161 if let Some((path_before_extension, extension)) = self.path.rsplit_once('.') {
162 if extension.contains('/') ||
163 path_before_extension.ends_with('/') || path_before_extension.is_empty()
165 {
166 (self.path.as_str(), None)
167 } else {
168 (path_before_extension, Some(extension))
169 }
170 } else {
171 (self.path.as_str(), None)
172 }
173 }
174
175 fn split_file_name(&self) -> (Option<&str>, &str) {
179 if let Some((parent, file_name)) = self.path.rsplit_once('/') {
181 (Some(parent), file_name)
182 } else {
183 (None, self.path.as_str())
184 }
185 }
186
187 fn split_file_stem_extension(&self) -> (Option<&str>, &str, Option<&str>) {
192 let (path_before_extension, extension) = self.split_extension();
193
194 if let Some((parent, file_stem)) = path_before_extension.rsplit_once('/') {
195 (Some(parent), file_stem, extension)
196 } else {
197 (None, path_before_extension, extension)
198 }
199 }
200}
201
202#[turbo_tasks::value(transparent)]
203pub struct FileSystemPathOption(Option<FileSystemPath>);
204
205#[turbo_tasks::value_impl]
206impl FileSystemPathOption {
207 #[turbo_tasks::function]
208 pub fn none() -> Vc<Self> {
209 Vc::cell(None)
210 }
211}
212
213impl FileSystemPath {
214 pub fn new_normalized_unchecked(fs: ResolvedVc<Box<dyn FileSystem>>, path: RcStr) -> Self {
218 debug_assert!(
222 MAIN_SEPARATOR != '\\' || !path.contains('\\'),
223 "path {path} must not contain a Windows directory '\\', it must be normalized to Unix \
224 '/'",
225 );
226 debug_assert!(
227 normalize_path(&path).as_deref() == Some(&*path),
228 "path {path} must be normalized",
229 );
230 FileSystemPath { fs, path }
231 }
232
233 pub fn join(&self, path: &str) -> Result<Self> {
236 if let Some(path) = join_path(&self.path, path) {
237 Ok(Self::new_normalized_unchecked(self.fs, path.into()))
238 } else {
239 bail!(
240 "FileSystemPath(\"{}\").join(\"{}\") leaves the filesystem root",
241 self.path,
242 path,
243 );
244 }
245 }
246
247 pub fn append(&self, path: &str) -> Result<Self> {
249 if path.contains('/') {
250 bail!(
251 "FileSystemPath(\"{}\").append(\"{}\") must not append '/'",
252 self.path,
253 path,
254 )
255 }
256 Ok(Self::new_normalized_unchecked(
257 self.fs,
258 format!("{}{}", self.path, path).into(),
259 ))
260 }
261
262 pub fn append_to_stem(&self, appending: &str) -> Result<Self> {
265 if appending.contains('/') {
266 bail!(
267 "FileSystemPath({:?}).append_to_stem({:?}) must not append '/'",
268 self.path,
269 appending,
270 )
271 }
272 if let (path, Some(ext)) = self.split_extension() {
273 return Ok(Self::new_normalized_unchecked(
274 self.fs,
275 format!("{path}{appending}.{ext}").into(),
276 ));
277 }
278 Ok(Self::new_normalized_unchecked(
279 self.fs,
280 format!("{}{}", self.path, appending).into(),
281 ))
282 }
283
284 #[allow(clippy::needless_borrow)] pub fn try_join(&self, path: &str) -> Option<FileSystemPath> {
288 #[cfg(target_os = "windows")]
290 let path = path.replace('\\', "/");
291
292 join_path(&self.path, &path)
293 .map(|p| Self::new_normalized_unchecked(self.fs, RcStr::from(p)))
294 }
295
296 pub fn try_join_inside(&self, path: &str) -> Option<FileSystemPath> {
300 if let Some(p) = join_path(&self.path, path)
301 && p.starts_with(&*self.path)
302 {
303 return Some(Self::new_normalized_unchecked(self.fs, RcStr::from(p)));
304 }
305 None
306 }
307
308 pub fn read_glob(&self, glob: Vc<Glob>) -> Vc<ReadGlobResult> {
311 read_glob(self.clone(), glob)
312 }
313
314 pub fn track_glob(&self, glob: Vc<Glob>, include_dot_files: bool) -> Vc<Completion> {
318 track_glob(self.clone(), glob, include_dot_files)
319 }
320
321 pub fn root(&self) -> Vc<Self> {
322 self.fs().root()
323 }
324}
325
326impl FileSystemPath {
327 pub fn fs(&self) -> Vc<Box<dyn FileSystem>> {
328 *self.fs
329 }
330
331 pub fn is_inside(&self, other: &FileSystemPath) -> bool {
332 self.is_inside_ref(other)
333 }
334
335 pub fn is_inside_or_equal(&self, other: &FileSystemPath) -> bool {
336 self.is_inside_or_equal_ref(other)
337 }
338
339 pub fn with_extension(&self, extension: &str) -> FileSystemPath {
342 let (path_without_extension, _) = self.split_extension();
343 Self::new_normalized_unchecked(
344 self.fs,
345 match extension.is_empty() {
348 true => path_without_extension.into(),
349 false => format!("{path_without_extension}.{extension}").into(),
350 },
351 )
352 }
353
354 pub fn file_stem(&self) -> Option<&str> {
363 let (_, file_stem, _) = self.split_file_stem_extension();
364 if file_stem.is_empty() {
365 return None;
366 }
367 Some(file_stem)
368 }
369}
370
371impl std::fmt::Display for FileSystemPath {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 f.write_str(&self.path)
374 }
375}
376
377#[turbo_tasks::function]
378pub async fn rebase(
379 fs_path: FileSystemPath,
380 old_base: FileSystemPath,
381 new_base: FileSystemPath,
382) -> Result<Vc<FileSystemPath>> {
383 let new_path;
384 if old_base.path.is_empty() {
385 if new_base.path.is_empty() {
386 new_path = fs_path.path.clone();
387 } else {
388 new_path = [new_base.path.as_str(), "/", &fs_path.path].concat().into();
389 }
390 } else {
391 let base_path = [&old_base.path, "/"].concat();
392 if !fs_path.path.starts_with(&base_path) {
393 turbobail!(
394 "rebasing {fs_path} from {old_base} onto {new_base} doesn't work because it's not \
395 part of the source path",
396 );
397 }
398 if new_base.path.is_empty() {
399 new_path = [&fs_path.path[base_path.len()..]].concat().into();
400 } else {
401 new_path = [new_base.path.as_str(), &fs_path.path[old_base.path.len()..]]
402 .concat()
403 .into();
404 }
405 }
406 Ok(new_base.fs.root().await?.join(&new_path)?.cell())
407}
408
409impl FileSystemPath {
411 pub fn read(&self) -> Vc<FileContent> {
412 self.fs().read(self.clone())
413 }
414
415 pub fn read_link(&self) -> Vc<LinkContent> {
416 self.fs().read_link(self.clone())
417 }
418
419 pub fn is_junction_point(&self) -> Vc<bool> {
420 self.fs().is_junction_point(self.clone())
421 }
422
423 pub fn read_json(&self) -> Vc<FileJsonContent> {
424 self.fs().read(self.clone()).parse_json()
425 }
426
427 pub fn read_json5(&self) -> Vc<FileJsonContent> {
428 self.fs().read(self.clone()).parse_json5()
429 }
430
431 pub fn raw_read_dir(&self) -> Vc<RawDirectoryContent> {
436 self.fs().raw_read_dir(self.clone())
437 }
438
439 pub fn write(&self, content: Vc<FileContent>) -> Vc<()> {
440 self.fs().write(self.clone(), content)
441 }
442
443 pub fn write_link(&self, target: Vc<WriteLinkContent>) -> Vc<()> {
457 self.fs().write_link(self.clone(), target)
458 }
459
460 pub fn metadata(&self) -> Vc<FileMeta> {
461 self.fs().metadata(self.clone())
462 }
463
464 pub async fn realpath(&self) -> Result<Result<FileSystemPath, RealPathError>> {
469 Ok(self.realpath_with_links().await?.path_result.clone())
470 }
471
472 pub fn rebase(
473 fs_path: FileSystemPath,
474 old_base: FileSystemPath,
475 new_base: FileSystemPath,
476 ) -> Vc<FileSystemPath> {
477 rebase(fs_path, old_base, new_base)
478 }
479}
480
481impl FileSystemPath {
482 pub fn read_dir(&self) -> Vc<DirectoryContent> {
487 read_dir(self.clone())
488 }
489
490 pub fn parent(&self) -> FileSystemPath {
491 let path = &self.path;
492 if path.is_empty() {
493 return self.clone();
494 }
495 FileSystemPath::new_normalized_unchecked(self.fs, RcStr::from(get_parent_path(path)))
496 }
497
498 pub fn get_type(&self) -> Vc<FileSystemEntryType> {
507 get_type(self.clone())
508 }
509
510 pub fn realpath_with_links(&self) -> Vc<RealPathWithLinksResult> {
511 realpath_with_links(self.clone())
512 }
513}
514
515#[derive(Clone, Debug)]
516#[turbo_tasks::value(shared)]
517pub struct RealPathWithLinksResult {
518 pub path_result: Result<FileSystemPath, RealPathError>,
519 pub symlinks: Box<[FileSystemPath]>,
520}
521
522#[derive(Debug, Clone, Hash, Eq, PartialEq, NonLocalValue, Encode, Decode)]
525pub struct RealPathError {
526 original_path: FileSystemPath,
527 kind: RealPathErrorType,
528}
529
530#[derive(Debug, Clone, Hash, Eq, PartialEq, NonLocalValue, Encode, Decode)]
531pub enum RealPathErrorType {
532 TooManySymlinks {
533 symlinks: Box<[FileSystemPath]>,
534 },
535 CycleDetected {
536 symlinks: Box<[FileSystemPath]>,
537 },
538 NotFound,
540 Invalid {
542 reason: RcStr,
543 },
544}
545
546impl RealPathError {
547 pub fn kind(&self) -> &RealPathErrorType {
548 &self.kind
549 }
550}
551
552impl fmt::Display for RealPathError {
553 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554 match &self.kind {
555 RealPathErrorType::TooManySymlinks { symlinks } => write!(
556 f,
557 "Symlink {} leads to too many other symlinks ({} links)",
558 self.original_path,
559 symlinks.len()
560 ),
561 RealPathErrorType::CycleDetected { symlinks } => write!(
562 f,
563 "Symlink {} is in a symlink loop: {:?}",
564 self.original_path,
565 symlinks
566 .iter()
567 .map(|symlink| &symlink.path)
568 .collect::<Vec<_>>()
569 ),
570 RealPathErrorType::Invalid { reason } => write!(
571 f,
572 "Symlink {} could not be resolved: {reason}",
573 self.original_path
574 ),
575 RealPathErrorType::NotFound => write!(
576 f,
577 "Path {} could not be resolved because a component does not exist",
578 self.original_path
579 ),
580 }
581 }
582}
583
584impl Error for RealPathError {}
585
586#[turbo_tasks::function]
587async fn read_dir(path: FileSystemPath) -> Result<Vc<DirectoryContent>> {
588 let fs = path.fs().to_resolved().await?;
589 match &*fs.raw_read_dir(path.clone()).await? {
590 RawDirectoryContent::NotFound => Ok(DirectoryContent::not_found()),
591 RawDirectoryContent::Entries(entries) => {
592 let mut normalized_entries = AutoMap::new();
593 let dir_path = &path.path;
594 for (name, entry) in entries {
595 let path = if dir_path.is_empty() {
599 name.clone()
600 } else {
601 RcStr::from(format!("{dir_path}/{name}"))
602 };
603
604 let entry_path = FileSystemPath::new_normalized_unchecked(fs, path);
605 let entry = match entry {
606 RawDirectoryEntry::File => DirectoryEntry::File(entry_path),
607 RawDirectoryEntry::Directory => DirectoryEntry::Directory(entry_path),
608 RawDirectoryEntry::Symlink => DirectoryEntry::Symlink(entry_path),
609 RawDirectoryEntry::Other => DirectoryEntry::Other(entry_path),
610 };
611 normalized_entries.insert(name.clone(), entry);
612 }
613 Ok(DirectoryContent::new(normalized_entries))
614 }
615 }
616}
617
618#[turbo_tasks::function]
619async fn get_type(path: FileSystemPath) -> Result<Vc<FileSystemEntryType>> {
620 if path.is_root() {
621 return Ok(FileSystemEntryType::Directory.cell());
622 }
623 let parent = path.parent();
624 let dir_content = parent.raw_read_dir().await?;
625 match &*dir_content {
626 RawDirectoryContent::NotFound => Ok(FileSystemEntryType::NotFound.cell()),
627 RawDirectoryContent::Entries(entries) => {
628 let (_, file_name) = path.split_file_name();
629 if let Some(entry) = entries.get(file_name) {
630 Ok(FileSystemEntryType::from(entry).cell())
631 } else {
632 Ok(FileSystemEntryType::NotFound.cell())
633 }
634 }
635 }
636}
637
638#[turbo_tasks::function]
639async fn realpath_with_links(path: FileSystemPath) -> Result<Vc<RealPathWithLinksResult>> {
640 let error_result = |original_path, kind, symlinks| {
641 RealPathWithLinksResult {
642 path_result: Err(RealPathError {
643 original_path,
644 kind,
645 }),
646 symlinks,
647 }
648 .cell()
649 };
650
651 let original_path = path.clone();
652 let mut current_path = path;
653 let mut symlinks: IndexSet<FileSystemPath> = IndexSet::new();
654 let mut visited: AutoSet<FileSystemPath> = AutoSet::new();
655 for _i in 0..40 {
658 if current_path.is_root() {
659 return Ok(RealPathWithLinksResult {
661 path_result: Ok(current_path),
662 symlinks: symlinks.into_iter().collect(),
663 }
664 .cell());
665 }
666
667 if !visited.insert(current_path.clone()) {
668 let symlinks: Box<[_]> = symlinks.into_iter().collect();
669 return Ok(error_result(
670 original_path,
671 RealPathErrorType::CycleDetected {
672 symlinks: symlinks.clone(),
673 },
674 symlinks,
675 ));
676 }
677
678 let parent = current_path.parent();
680 let parent_result = parent.realpath_with_links().owned().await?;
681 let basename = current_path
682 .path
683 .rsplit_once('/')
684 .map_or(current_path.path.as_str(), |(_, name)| name);
685 symlinks.extend(parent_result.symlinks);
686 match parent_result.path_result {
687 Ok(path) => {
688 if path != parent {
689 current_path = path.join(basename)?;
690 }
691 }
692 Err(parent_error) => {
693 let symlinks: Box<[_]> = symlinks.into_iter().collect();
694 return Ok(error_result(original_path, parent_error.kind, symlinks));
695 }
696 }
697
698 let entry_type = *current_path.get_type().await?;
701 if !matches!(entry_type, FileSystemEntryType::Symlink) {
702 if matches!(entry_type, FileSystemEntryType::NotFound) {
703 return Ok(error_result(
704 original_path,
705 RealPathErrorType::NotFound,
706 symlinks.into_iter().collect(),
707 ));
708 }
709 return Ok(RealPathWithLinksResult {
710 path_result: Ok(current_path),
711 symlinks: symlinks.into_iter().collect(),
712 }
713 .cell());
714 }
715
716 let link_content = current_path.read_link().await?;
717 match &*link_content {
718 LinkContent::Link { target } => {
719 let target_path = target.file_system_path().clone();
720 symlinks.insert(current_path);
721 current_path = target_path;
722 }
723 LinkContent::NotFound => {
724 return Ok(error_result(
725 original_path,
726 RealPathErrorType::NotFound,
727 symlinks.into_iter().collect(),
728 ));
729 }
730 LinkContent::Invalid { reason } => {
731 return Ok(error_result(
732 original_path,
733 RealPathErrorType::Invalid {
734 reason: reason.clone(),
735 },
736 symlinks.into_iter().collect(),
737 ));
738 }
739 }
740 }
741
742 let symlinks: Box<[_]> = symlinks.into_iter().collect();
746 Ok(error_result(
747 original_path,
748 RealPathErrorType::TooManySymlinks {
749 symlinks: symlinks.clone(),
750 },
751 symlinks,
752 ))
753}
754
755#[cfg(test)]
756mod tests {
757 use turbo_rcstr::rcstr;
758 use turbo_tasks::Vc;
759 use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
760
761 use super::*;
762 use crate::VirtualFileSystem;
763
764 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
768 async fn get_relative_to() {
769 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
770 BackendOptions::default(),
771 noop_backing_storage(),
772 ));
773 tt.run_once(async move {
774 let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
775 .to_resolved()
776 .await?;
777 let dir = FileSystemPath::new_normalized_unchecked(fs, rcstr!("a/b"));
778 let file = FileSystemPath::new_normalized_unchecked(fs, rcstr!("a/b/c.js"));
779
780 assert_eq!(dir.get_relative_path_to(&file).as_deref(), Some("c.js"));
781 assert_eq!(
782 dir.get_relative_request_to(&file).as_deref(),
783 Some("./c.js")
784 );
785
786 let other_fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
787 .to_resolved()
788 .await?;
789 let elsewhere = FileSystemPath::new_normalized_unchecked(other_fs, rcstr!("a/b/c.js"));
790 assert_eq!(dir.get_relative_path_to(&elsewhere), None);
791 assert_eq!(dir.get_relative_request_to(&elsewhere), None);
792
793 anyhow::Ok(())
794 })
795 .await
796 .unwrap();
797 }
798
799 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
800 async fn with_extension() {
801 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
802 BackendOptions::default(),
803 noop_backing_storage(),
804 ));
805 tt.run_once(async move {
806 let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
807 .to_resolved()
808 .await?;
809
810 let path_txt = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar.txt"));
811
812 let path_json = path_txt.with_extension("json");
813 assert_eq!(&*path_json.path, "foo/bar.json");
814
815 let path_no_ext = path_txt.with_extension("");
816 assert_eq!(&*path_no_ext.path, "foo/bar");
817
818 let path_new_ext = path_no_ext.with_extension("json");
819 assert_eq!(&*path_new_ext.path, "foo/bar.json");
820
821 let path_no_slash_txt = FileSystemPath::new_normalized_unchecked(fs, rcstr!("bar.txt"));
822
823 let path_no_slash_json = path_no_slash_txt.with_extension("json");
824 assert_eq!(path_no_slash_json.path.as_str(), "bar.json");
825
826 let path_no_slash_no_ext = path_no_slash_txt.with_extension("");
827 assert_eq!(path_no_slash_no_ext.path.as_str(), "bar");
828
829 let path_no_slash_new_ext = path_no_slash_no_ext.with_extension("json");
830 assert_eq!(path_no_slash_new_ext.path.as_str(), "bar.json");
831
832 anyhow::Ok(())
833 })
834 .await
835 .unwrap()
836 }
837
838 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
839 async fn file_stem() {
840 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
841 BackendOptions::default(),
842 noop_backing_storage(),
843 ));
844 tt.run_once(async move {
845 let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
846 .to_resolved()
847 .await?;
848
849 let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!(""));
850 assert_eq!(path.file_stem(), None);
851
852 let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar.txt"));
853 assert_eq!(path.file_stem(), Some("bar"));
854
855 let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("bar.txt"));
856 assert_eq!(path.file_stem(), Some("bar"));
857
858 let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar"));
859 assert_eq!(path.file_stem(), Some("bar"));
860
861 let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/.bar"));
862 assert_eq!(path.file_stem(), Some(".bar"));
863
864 anyhow::Ok(())
865 })
866 .await
867 .unwrap()
868 }
869}