Skip to main content

turbo_tasks_fs/
path.rs

1//! [`FileSystemPath`] and the path-resolution operations built on top of it.
2
3use std::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, trace::TraceRawVcs,
12    turbobail, turbofmt,
13};
14use turbo_unix_path::{get_parent_path, get_relative_path_to, join_path, normalize_path};
15
16use crate::{
17    DirectoryContent, DirectoryEntry, FileContent, FileJsonContent, FileMeta, FileSystem,
18    FileSystemEntryType, LinkContent, LinkType, RawDirectoryContent, RawDirectoryEntry,
19    ReadGlobResult,
20    glob::Glob,
21    read_glob::{read_glob, track_glob},
22};
23
24#[derive(Debug, Clone, Hash)]
25#[turbo_tasks::value(shared, task_input)]
26pub struct FileSystemPath {
27    pub fs: ResolvedVc<Box<dyn FileSystem>>,
28    pub path: RcStr,
29}
30
31impl ValueToStringRef for FileSystemPath {
32    async fn to_string_ref(&self) -> Result<RcStr> {
33        turbofmt!("[{}]/{}", self.fs, self.path).await
34    }
35}
36
37#[turbo_tasks::value_impl]
38impl ValueToString for FileSystemPath {
39    #[turbo_tasks::function]
40    async fn to_string(&self) -> Result<Vc<RcStr>> {
41        Ok(Vc::cell(self.to_string_ref().await?))
42    }
43}
44
45impl FileSystemPath {
46    pub fn is_inside_ref(&self, other: &FileSystemPath) -> bool {
47        if self.fs == other.fs && self.path.starts_with(&*other.path) {
48            if other.path.is_empty() {
49                true
50            } else {
51                self.path.as_bytes().get(other.path.len()) == Some(&b'/')
52            }
53        } else {
54            false
55        }
56    }
57
58    pub fn is_inside_or_equal_ref(&self, other: &FileSystemPath) -> bool {
59        if self.fs == other.fs && self.path.starts_with(&*other.path) {
60            if other.path.is_empty() {
61                true
62            } else {
63                matches!(
64                    self.path.as_bytes().get(other.path.len()),
65                    Some(&b'/') | None
66                )
67            }
68        } else {
69            false
70        }
71    }
72
73    pub fn is_root(&self) -> bool {
74        self.path.is_empty()
75    }
76
77    pub fn is_in_node_modules(&self) -> bool {
78        self.path.starts_with("node_modules/") || self.path.contains("/node_modules/")
79    }
80
81    /// Assumes `self` is a directory. Returns a unix-style relative path of `inner` inside of
82    /// `self`, returns `None` if inner is not inside `self`.
83    ///
84    /// Note: this method always strips the leading `/` from the result.
85    pub fn get_path_to<'a>(&self, inner: &'a FileSystemPath) -> Option<&'a str> {
86        if self.fs != inner.fs {
87            return None;
88        }
89        let path = inner.path.strip_prefix(&*self.path)?;
90        if self.path.is_empty() {
91            Some(path)
92        } else if let Some(stripped) = path.strip_prefix('/') {
93            Some(stripped)
94        } else {
95            None
96        }
97    }
98
99    /// Returns a unix-style path of `other` relative to `self`. Supports traversing upwards (`../`)
100    /// within the filesystem.
101    pub fn get_relative_path_to(&self, other: &FileSystemPath) -> Option<RcStr> {
102        if self.fs != other.fs {
103            return None;
104        }
105
106        Some(get_relative_path_to(&self.path, &other.path).into())
107    }
108
109    /// Returns the final component of the FileSystemPath, or an empty string
110    /// for the root path.
111    pub fn file_name(&self) -> &str {
112        let (_, file_name) = self.split_file_name();
113        file_name
114    }
115
116    /// Returns true if this path has the given extension
117    ///
118    /// slightly faster than `self.extension() == Some(extension)` as we can simply match a
119    /// suffix
120    pub fn has_extension(&self, extension: &str) -> bool {
121        debug_assert!(!extension.contains('/') && extension.starts_with('.'));
122        self.path.ends_with(extension)
123    }
124
125    /// Returns the extension (without a leading `.`)
126    pub fn extension(&self) -> Option<&str> {
127        let (_, extension) = self.split_extension();
128        extension
129    }
130
131    /// Splits the path into two components:
132    /// 1. The path without the extension;
133    /// 2. The extension, if any.
134    fn split_extension(&self) -> (&str, Option<&str>) {
135        if let Some((path_before_extension, extension)) = self.path.rsplit_once('.') {
136            if extension.contains('/') ||
137                // The file name begins with a `.` and has no other `.`s within.
138                path_before_extension.ends_with('/') || path_before_extension.is_empty()
139            {
140                (self.path.as_str(), None)
141            } else {
142                (path_before_extension, Some(extension))
143            }
144        } else {
145            (self.path.as_str(), None)
146        }
147    }
148
149    /// Splits the path into two components:
150    /// 1. The parent directory, if any;
151    /// 2. The file name;
152    fn split_file_name(&self) -> (Option<&str>, &str) {
153        // Since the path is normalized, we know `parent`, if any, must not be empty.
154        if let Some((parent, file_name)) = self.path.rsplit_once('/') {
155            (Some(parent), file_name)
156        } else {
157            (None, self.path.as_str())
158        }
159    }
160
161    /// Splits the path into three components:
162    /// 1. The parent directory, if any;
163    /// 2. The file stem;
164    /// 3. The extension, if any.
165    fn split_file_stem_extension(&self) -> (Option<&str>, &str, Option<&str>) {
166        let (path_before_extension, extension) = self.split_extension();
167
168        if let Some((parent, file_stem)) = path_before_extension.rsplit_once('/') {
169            (Some(parent), file_stem, extension)
170        } else {
171            (None, path_before_extension, extension)
172        }
173    }
174}
175
176#[turbo_tasks::value(transparent)]
177pub struct FileSystemPathOption(Option<FileSystemPath>);
178
179#[turbo_tasks::value_impl]
180impl FileSystemPathOption {
181    #[turbo_tasks::function]
182    pub fn none() -> Vc<Self> {
183        Vc::cell(None)
184    }
185}
186
187impl FileSystemPath {
188    /// Create a new FileSystemPath from a path within a FileSystem. The
189    /// /-separated path is expected to be already normalized (this is asserted
190    /// in dev mode).
191    pub fn new_normalized_unchecked(fs: ResolvedVc<Box<dyn FileSystem>>, path: RcStr) -> Self {
192        // On Windows, the path must be converted to a unix path before creating. But on
193        // Unix, backslashes are a valid char in file names, and the path can be
194        // provided by the user, so we allow it.
195        debug_assert!(
196            MAIN_SEPARATOR != '\\' || !path.contains('\\'),
197            "path {path} must not contain a Windows directory '\\', it must be normalized to Unix \
198             '/'",
199        );
200        debug_assert!(
201            normalize_path(&path).as_deref() == Some(&*path),
202            "path {path} must be normalized",
203        );
204        FileSystemPath { fs, path }
205    }
206
207    /// Adds a subpath to the current path. The /-separated `path` argument might contain ".." or
208    /// "." segments, but it must not leave the root of the filesystem.
209    pub fn join(&self, path: &str) -> Result<Self> {
210        if let Some(path) = join_path(&self.path, path) {
211            Ok(Self::new_normalized_unchecked(self.fs, path.into()))
212        } else {
213            bail!(
214                "FileSystemPath(\"{}\").join(\"{}\") leaves the filesystem root",
215                self.path,
216                path,
217            );
218        }
219    }
220
221    /// Adds a suffix to the filename. `path` must not contain `/`.
222    pub fn append(&self, path: &str) -> Result<Self> {
223        if path.contains('/') {
224            bail!(
225                "FileSystemPath(\"{}\").append(\"{}\") must not append '/'",
226                self.path,
227                path,
228            )
229        }
230        Ok(Self::new_normalized_unchecked(
231            self.fs,
232            format!("{}{}", self.path, path).into(),
233        ))
234    }
235
236    /// Adds a suffix to the basename of the file path. `appending` must not contain `/`. The [file
237    /// extension][FileSystemPath::extension] will stay intact.
238    pub fn append_to_stem(&self, appending: &str) -> Result<Self> {
239        if appending.contains('/') {
240            bail!(
241                "FileSystemPath({:?}).append_to_stem({:?}) must not append '/'",
242                self.path,
243                appending,
244            )
245        }
246        if let (path, Some(ext)) = self.split_extension() {
247            return Ok(Self::new_normalized_unchecked(
248                self.fs,
249                format!("{path}{appending}.{ext}").into(),
250            ));
251        }
252        Ok(Self::new_normalized_unchecked(
253            self.fs,
254            format!("{}{}", self.path, appending).into(),
255        ))
256    }
257
258    /// Similar to [FileSystemPath::join], but returns an [`Option`] that will be [`None`] when the
259    /// joined path would leave the filesystem root.
260    #[allow(clippy::needless_borrow)] // for windows build
261    pub fn try_join(&self, path: &str) -> Option<FileSystemPath> {
262        // TODO(PACK-3279): Remove this once we do not produce invalid paths at the first place.
263        #[cfg(target_os = "windows")]
264        let path = path.replace('\\', "/");
265
266        join_path(&self.path, &path)
267            .map(|p| Self::new_normalized_unchecked(self.fs, RcStr::from(p)))
268    }
269
270    /// Similar to [FileSystemPath::try_join], but returns [`None`] when the new path would leave
271    /// the current path (not just the filesystem root). This is useful for preventing access
272    /// outside of a directory.
273    pub fn try_join_inside(&self, path: &str) -> Option<FileSystemPath> {
274        if let Some(p) = join_path(&self.path, path)
275            && p.starts_with(&*self.path)
276        {
277            return Some(Self::new_normalized_unchecked(self.fs, RcStr::from(p)));
278        }
279        None
280    }
281
282    /// DETERMINISM: Result is in random order. Either sort the result or do not depend on the
283    /// order.
284    pub fn read_glob(&self, glob: Vc<Glob>) -> Vc<ReadGlobResult> {
285        read_glob(self.clone(), glob)
286    }
287
288    // Tracks all files and directories matching the glob using the filesystem watcher. Follows
289    // symlinks as though they were part of the original hierarchy. The returned [`Vc`] will be
290    // invalidated if a file or directory changes.
291    pub fn track_glob(&self, glob: Vc<Glob>, include_dot_files: bool) -> Vc<Completion> {
292        track_glob(self.clone(), glob, include_dot_files)
293    }
294
295    pub fn root(&self) -> Vc<Self> {
296        self.fs().root()
297    }
298}
299
300impl FileSystemPath {
301    pub fn fs(&self) -> Vc<Box<dyn FileSystem>> {
302        *self.fs
303    }
304
305    pub fn is_inside(&self, other: &FileSystemPath) -> bool {
306        self.is_inside_ref(other)
307    }
308
309    pub fn is_inside_or_equal(&self, other: &FileSystemPath) -> bool {
310        self.is_inside_or_equal_ref(other)
311    }
312
313    /// Creates a new [`FileSystemPath`] like `self` but with the given
314    /// extension.
315    pub fn with_extension(&self, extension: &str) -> FileSystemPath {
316        let (path_without_extension, _) = self.split_extension();
317        Self::new_normalized_unchecked(
318            self.fs,
319            // Like `Path::with_extension` and `PathBuf::set_extension`, if the extension is empty,
320            // we remove the extension altogether.
321            match extension.is_empty() {
322                true => path_without_extension.into(),
323                false => format!("{path_without_extension}.{extension}").into(),
324            },
325        )
326    }
327
328    /// Extracts the stem (non-extension) portion of self.file_name.
329    ///
330    /// The stem is:
331    ///
332    /// * [`None`], if there is no file name;
333    /// * The entire file name if there is no embedded `.`;
334    /// * The entire file name if the file name begins with `.` and has no other `.`s within;
335    /// * Otherwise, the portion of the file name before the final `.`
336    pub fn file_stem(&self) -> Option<&str> {
337        let (_, file_stem, _) = self.split_file_stem_extension();
338        if file_stem.is_empty() {
339            return None;
340        }
341        Some(file_stem)
342    }
343}
344
345impl std::fmt::Display for FileSystemPath {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.write_str(&self.path)
348    }
349}
350
351#[turbo_tasks::function]
352pub async fn rebase(
353    fs_path: FileSystemPath,
354    old_base: FileSystemPath,
355    new_base: FileSystemPath,
356) -> Result<Vc<FileSystemPath>> {
357    let new_path;
358    if old_base.path.is_empty() {
359        if new_base.path.is_empty() {
360            new_path = fs_path.path.clone();
361        } else {
362            new_path = [new_base.path.as_str(), "/", &fs_path.path].concat().into();
363        }
364    } else {
365        let base_path = [&old_base.path, "/"].concat();
366        if !fs_path.path.starts_with(&base_path) {
367            turbobail!(
368                "rebasing {fs_path} from {old_base} onto {new_base} doesn't work because it's not \
369                 part of the source path",
370            );
371        }
372        if new_base.path.is_empty() {
373            new_path = [&fs_path.path[base_path.len()..]].concat().into();
374        } else {
375            new_path = [new_base.path.as_str(), &fs_path.path[old_base.path.len()..]]
376                .concat()
377                .into();
378        }
379    }
380    Ok(new_base.fs.root().await?.join(&new_path)?.cell())
381}
382
383// Not turbo-tasks functions, only delegating
384impl FileSystemPath {
385    pub fn read(&self) -> Vc<FileContent> {
386        self.fs().read(self.clone())
387    }
388
389    pub fn read_link(&self) -> Vc<LinkContent> {
390        self.fs().read_link(self.clone())
391    }
392
393    pub fn read_json(&self) -> Vc<FileJsonContent> {
394        self.fs().read(self.clone()).parse_json()
395    }
396
397    pub fn read_json5(&self) -> Vc<FileJsonContent> {
398        self.fs().read(self.clone()).parse_json5()
399    }
400
401    /// Reads content of a directory.
402    ///
403    /// DETERMINISM: Result is in random order. Either sort result or do not
404    /// depend on the order.
405    pub fn raw_read_dir(&self) -> Vc<RawDirectoryContent> {
406        self.fs().raw_read_dir(self.clone())
407    }
408
409    pub fn write(&self, content: Vc<FileContent>) -> Vc<()> {
410        self.fs().write(self.clone(), content)
411    }
412
413    /// Creates a symbolic link to a directory on *nix platforms, or a directory junction point on
414    /// Windows.
415    ///
416    /// [Windows supports symbolic links][windows-symlink], but they [can require elevated
417    /// privileges][windows-privileges] if "developer mode" is not enabled, so we can't safely use
418    /// them. Using junction points [matches the behavior of pnpm][pnpm-windows].
419    ///
420    /// This only supports directories because Windows junction points are incompatible with files.
421    /// To ensure compatibility, this will return an error if the target is a file, even on
422    /// platforms with full symlink support.
423    ///
424    /// **We intentionally do not provide an API for symlinking a file**, as we cannot support that
425    /// on all Windows configurations.
426    ///
427    /// [windows-symlink]: https://blogs.windows.com/windowsdeveloper/2016/12/02/symlinks-windows-10/
428    /// [windows-privileges]: https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/create-symbolic-links
429    /// [pnpm-windows]: https://pnpm.io/faq#does-it-work-on-windows
430    pub fn write_symbolic_link_dir(&self, target: Vc<LinkContent>) -> Vc<()> {
431        self.fs().write_link(self.clone(), target)
432    }
433
434    pub fn metadata(&self) -> Vc<FileMeta> {
435        self.fs().metadata(self.clone())
436    }
437
438    // Returns the realpath to the file, resolving all symlinks and reporting an error if the path
439    // is invalid.
440    pub async fn realpath(&self) -> Result<FileSystemPath> {
441        let result = &(*self.realpath_with_links().await?);
442        match &result.path_result {
443            Ok(path) => Ok(path.clone()),
444            Err(error) => bail!("{}", error.as_error_message(self, result).await?),
445        }
446    }
447
448    pub fn rebase(
449        fs_path: FileSystemPath,
450        old_base: FileSystemPath,
451        new_base: FileSystemPath,
452    ) -> Vc<FileSystemPath> {
453        rebase(fs_path, old_base, new_base)
454    }
455}
456
457impl FileSystemPath {
458    /// Reads content of a directory.
459    ///
460    /// DETERMINISM: Result is in random order. Either sort result or do not
461    /// depend on the order.
462    pub fn read_dir(&self) -> Vc<DirectoryContent> {
463        read_dir(self.clone())
464    }
465
466    pub fn parent(&self) -> FileSystemPath {
467        let path = &self.path;
468        if path.is_empty() {
469            return self.clone();
470        }
471        FileSystemPath::new_normalized_unchecked(self.fs, RcStr::from(get_parent_path(path)))
472    }
473
474    // It is important that get_type uses read_dir and not stat/metadata.
475    // - `get_type` is called very very often during resolving and stat would
476    // make it 1 syscall per call, whereas read_dir would make it 1 syscall per
477    // directory.
478    // - `metadata` allows you to use the "wrong" casing on
479    // case-insensitive filesystems, while read_dir gives you the "correct"
480    // casing. We want to enforce "correct" casing to avoid broken builds on
481    // Vercel deployments (case-sensitive).
482    pub fn get_type(&self) -> Vc<FileSystemEntryType> {
483        get_type(self.clone())
484    }
485
486    pub fn realpath_with_links(&self) -> Vc<RealPathResult> {
487        realpath_with_links(self.clone())
488    }
489}
490
491#[derive(Clone, Debug)]
492#[turbo_tasks::value(shared)]
493pub struct RealPathResult {
494    pub path_result: Result<FileSystemPath, RealPathResultError>,
495    pub symlinks: Vec<FileSystemPath>,
496}
497
498/// Errors that can occur when resolving a path with symlinks.
499/// Many of these can be transient conditions that might happen when package managers are running.
500#[derive(Debug, Clone, Hash, Eq, PartialEq, NonLocalValue, TraceRawVcs, Encode, Decode)]
501pub enum RealPathResultError {
502    TooManySymlinks,
503    CycleDetected,
504    Invalid,
505    NotFound,
506}
507
508impl RealPathResultError {
509    /// Formats the error message
510    pub async fn as_error_message(
511        &self,
512        orig: &FileSystemPath,
513        result: &RealPathResult,
514    ) -> Result<RcStr> {
515        Ok(match self {
516            RealPathResultError::TooManySymlinks => {
517                let len = result.symlinks.len();
518                turbofmt!("Symlink {orig} leads to too many other symlinks ({len} links)").await?
519            }
520            RealPathResultError::CycleDetected => {
521                // symlinks is Vec<FileSystemPath> — format with Debug since
522                // turbofmt can't resolve a whole Vec asynchronously.
523                let symlinks_dbg = format!(
524                    "{:?}",
525                    result.symlinks.iter().map(|s| &s.path).collect::<Vec<_>>()
526                );
527                turbofmt!("Symlink {orig} is in a symlink loop: {symlinks_dbg}").await?
528            }
529            RealPathResultError::Invalid => {
530                turbofmt!("Symlink {orig} is invalid, it points out of the filesystem root").await?
531            }
532            RealPathResultError::NotFound => {
533                turbofmt!("Symlink {orig} is invalid, it points at a file that doesn't exist")
534                    .await?
535            }
536        })
537    }
538}
539
540#[turbo_tasks::function]
541async fn read_dir(path: FileSystemPath) -> Result<Vc<DirectoryContent>> {
542    let fs = path.fs().to_resolved().await?;
543    match &*fs.raw_read_dir(path.clone()).await? {
544        RawDirectoryContent::NotFound => Ok(DirectoryContent::not_found()),
545        RawDirectoryContent::Entries(entries) => {
546            let mut normalized_entries = AutoMap::new();
547            let dir_path = &path.path;
548            for (name, entry) in entries {
549                // Construct the path directly instead of going through `join`.
550                // We do not need to normalize since the `name` is guaranteed to be a simple
551                // path segment.
552                let path = if dir_path.is_empty() {
553                    name.clone()
554                } else {
555                    RcStr::from(format!("{dir_path}/{name}"))
556                };
557
558                let entry_path = FileSystemPath::new_normalized_unchecked(fs, path);
559                let entry = match entry {
560                    RawDirectoryEntry::File => DirectoryEntry::File(entry_path),
561                    RawDirectoryEntry::Directory => DirectoryEntry::Directory(entry_path),
562                    RawDirectoryEntry::Symlink => DirectoryEntry::Symlink(entry_path),
563                    RawDirectoryEntry::Other => DirectoryEntry::Other(entry_path),
564                };
565                normalized_entries.insert(name.clone(), entry);
566            }
567            Ok(DirectoryContent::new(normalized_entries))
568        }
569    }
570}
571
572#[turbo_tasks::function]
573async fn get_type(path: FileSystemPath) -> Result<Vc<FileSystemEntryType>> {
574    if path.is_root() {
575        return Ok(FileSystemEntryType::Directory.cell());
576    }
577    let parent = path.parent();
578    let dir_content = parent.raw_read_dir().await?;
579    match &*dir_content {
580        RawDirectoryContent::NotFound => Ok(FileSystemEntryType::NotFound.cell()),
581        RawDirectoryContent::Entries(entries) => {
582            let (_, file_name) = path.split_file_name();
583            if let Some(entry) = entries.get(file_name) {
584                Ok(FileSystemEntryType::from(entry).cell())
585            } else {
586                Ok(FileSystemEntryType::NotFound.cell())
587            }
588        }
589    }
590}
591
592#[turbo_tasks::function]
593async fn realpath_with_links(path: FileSystemPath) -> Result<Vc<RealPathResult>> {
594    let mut current_path = path;
595    let mut symlinks: IndexSet<FileSystemPath> = IndexSet::new();
596    let mut visited: AutoSet<RcStr> = AutoSet::new();
597    let mut error = RealPathResultError::TooManySymlinks;
598    // Pick some arbitrary symlink depth limit... similar to the ELOOP logic for realpath(3).
599    // SYMLOOP_MAX is 40 for Linux: https://unix.stackexchange.com/q/721724
600    for _i in 0..40 {
601        if current_path.is_root() {
602            // fast path
603            return Ok(RealPathResult {
604                path_result: Ok(current_path),
605                symlinks: symlinks.into_iter().collect(),
606            }
607            .cell());
608        }
609
610        if !visited.insert(current_path.path.clone()) {
611            error = RealPathResultError::CycleDetected;
612            break; // we detected a cycle
613        }
614
615        // see if a parent segment of the path is a symlink and resolve that first
616        let parent = current_path.parent();
617        let parent_result = parent.realpath_with_links().owned().await?;
618        let basename = current_path
619            .path
620            .rsplit_once('/')
621            .map_or(current_path.path.as_str(), |(_, name)| name);
622        symlinks.extend(parent_result.symlinks);
623        let parent_path = match parent_result.path_result {
624            Ok(path) => {
625                if path != parent {
626                    current_path = path.join(basename)?;
627                }
628                path
629            }
630            Err(parent_error) => {
631                error = parent_error;
632                break;
633            }
634        };
635
636        // use `get_type` before trying `read_link`, as there's a good chance of a cache hit on
637        // `get_type`, and `read_link` isn't the common codepath.
638        if !matches!(
639            *current_path.get_type().await?,
640            FileSystemEntryType::Symlink
641        ) {
642            return Ok(RealPathResult {
643                path_result: Ok(current_path),
644                symlinks: symlinks.into_iter().collect(), // convert set to vec
645            }
646            .cell());
647        }
648
649        match &*current_path.read_link().await? {
650            LinkContent::Link { target, link_type } => {
651                symlinks.insert(current_path.clone());
652                current_path = if link_type.contains(LinkType::ABSOLUTE) {
653                    current_path.root().owned().await?
654                } else {
655                    parent_path
656                }
657                .join(target)?;
658            }
659            LinkContent::NotFound => {
660                error = RealPathResultError::NotFound;
661                break;
662            }
663            LinkContent::Invalid => {
664                error = RealPathResultError::Invalid;
665                break;
666            }
667        }
668    }
669
670    // Too many attempts or detected a cycle, we bailed out!
671    //
672    // TODO: There's no proper way to indicate an non-turbo-tasks error here, so just return the
673    // original path and all the symlinks we followed.
674    //
675    // Returning the followed symlinks is still important, even if there is an error! Otherwise
676    // we may never notice if the symlink loop is fixed.
677    Ok(RealPathResult {
678        path_result: Err(error),
679        symlinks: symlinks.into_iter().collect(),
680    }
681    .cell())
682}
683
684#[cfg(test)]
685mod tests {
686    use turbo_rcstr::rcstr;
687    use turbo_tasks::Vc;
688    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
689
690    use super::*;
691    use crate::VirtualFileSystem;
692
693    #[test]
694    fn test_get_relative_path_to() {
695        assert_eq!(get_relative_path_to("a/b/c", "a/b/c").as_str(), ".");
696        assert_eq!(get_relative_path_to("a/c/d", "a/b/c").as_str(), "../../b/c");
697        assert_eq!(get_relative_path_to("", "a/b/c").as_str(), "./a/b/c");
698        assert_eq!(get_relative_path_to("a/b/c", "").as_str(), "../../..");
699        assert_eq!(
700            get_relative_path_to("a/b/c", "c/b/a").as_str(),
701            "../../../c/b/a"
702        );
703        assert_eq!(
704            get_relative_path_to("file:///a/b/c", "file:///c/b/a").as_str(),
705            "../../../c/b/a"
706        );
707    }
708
709    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
710    async fn with_extension() {
711        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
712            BackendOptions::default(),
713            noop_backing_storage(),
714        ));
715        tt.run_once(async move {
716            let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
717                .to_resolved()
718                .await?;
719
720            let path_txt = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar.txt"));
721
722            let path_json = path_txt.with_extension("json");
723            assert_eq!(&*path_json.path, "foo/bar.json");
724
725            let path_no_ext = path_txt.with_extension("");
726            assert_eq!(&*path_no_ext.path, "foo/bar");
727
728            let path_new_ext = path_no_ext.with_extension("json");
729            assert_eq!(&*path_new_ext.path, "foo/bar.json");
730
731            let path_no_slash_txt = FileSystemPath::new_normalized_unchecked(fs, rcstr!("bar.txt"));
732
733            let path_no_slash_json = path_no_slash_txt.with_extension("json");
734            assert_eq!(path_no_slash_json.path.as_str(), "bar.json");
735
736            let path_no_slash_no_ext = path_no_slash_txt.with_extension("");
737            assert_eq!(path_no_slash_no_ext.path.as_str(), "bar");
738
739            let path_no_slash_new_ext = path_no_slash_no_ext.with_extension("json");
740            assert_eq!(path_no_slash_new_ext.path.as_str(), "bar.json");
741
742            anyhow::Ok(())
743        })
744        .await
745        .unwrap()
746    }
747
748    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
749    async fn file_stem() {
750        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
751            BackendOptions::default(),
752            noop_backing_storage(),
753        ));
754        tt.run_once(async move {
755            let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
756                .to_resolved()
757                .await?;
758
759            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!(""));
760            assert_eq!(path.file_stem(), None);
761
762            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar.txt"));
763            assert_eq!(path.file_stem(), Some("bar"));
764
765            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("bar.txt"));
766            assert_eq!(path.file_stem(), Some("bar"));
767
768            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar"));
769            assert_eq!(path.file_stem(), Some("bar"));
770
771            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/.bar"));
772            assert_eq!(path.file_stem(), Some(".bar"));
773
774            anyhow::Ok(())
775        })
776        .await
777        .unwrap()
778    }
779}