1use 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 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 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 pub fn file_name(&self) -> &str {
112 let (_, file_name) = self.split_file_name();
113 file_name
114 }
115
116 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 pub fn extension(&self) -> Option<&str> {
127 let (_, extension) = self.split_extension();
128 extension
129 }
130
131 fn split_extension(&self) -> (&str, Option<&str>) {
135 if let Some((path_before_extension, extension)) = self.path.rsplit_once('.') {
136 if extension.contains('/') ||
137 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 fn split_file_name(&self) -> (Option<&str>, &str) {
153 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 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 pub fn new_normalized_unchecked(fs: ResolvedVc<Box<dyn FileSystem>>, path: RcStr) -> Self {
192 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 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 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 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 #[allow(clippy::needless_borrow)] pub fn try_join(&self, path: &str) -> Option<FileSystemPath> {
262 #[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 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 pub fn read_glob(&self, glob: Vc<Glob>) -> Vc<ReadGlobResult> {
285 read_glob(self.clone(), glob)
286 }
287
288 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 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 match extension.is_empty() {
322 true => path_without_extension.into(),
323 false => format!("{path_without_extension}.{extension}").into(),
324 },
325 )
326 }
327
328 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
383impl 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 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 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 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 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 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#[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 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 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 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 for _i in 0..40 {
601 if current_path.is_root() {
602 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; }
614
615 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 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(), }
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 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}