biome_fs/fs/
os.rs

1//! Implementation of the [FileSystem] and related traits for the underlying OS filesystem
2use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic};
3use crate::fs::OpenOptions;
4use crate::{
5    fs::{TraversalContext, TraversalScope},
6    BiomePath, FileSystem,
7};
8use biome_diagnostics::{adapters::IoError, DiagnosticExt, Error, Severity};
9use oxc_resolver::{Resolution, ResolveError, ResolveOptions, Resolver};
10use rayon::{scope, Scope};
11use std::ffi::OsStr;
12use std::fs::{DirEntry, FileType};
13use std::panic::AssertUnwindSafe;
14use std::process::Command;
15use std::{
16    env, fs,
17    io::{self, ErrorKind as IoErrorKind, Read, Seek, Write},
18    mem,
19    path::{Path, PathBuf},
20};
21
22const MAX_SYMLINK_DEPTH: u8 = 3;
23
24/// Implementation of [FileSystem] that directly calls through to the underlying OS
25pub struct OsFileSystem {
26    pub working_directory: Option<PathBuf>,
27    pub configuration_resolver: AssertUnwindSafe<Resolver>,
28}
29
30impl OsFileSystem {
31    pub fn new(working_directory: PathBuf) -> Self {
32        Self {
33            working_directory: Some(working_directory),
34            configuration_resolver: AssertUnwindSafe(Resolver::new(ResolveOptions {
35                condition_names: vec!["node".to_string(), "import".to_string()],
36                extensions: vec!["*.json".to_string()],
37                ..ResolveOptions::default()
38            })),
39        }
40    }
41}
42
43impl Default for OsFileSystem {
44    fn default() -> Self {
45        Self {
46            working_directory: env::current_dir().ok(),
47            configuration_resolver: AssertUnwindSafe(Resolver::new(ResolveOptions {
48                condition_names: vec!["node".to_string(), "import".to_string()],
49                extensions: vec!["*.json".to_string(), "*.jsonc".to_string()],
50                ..ResolveOptions::default()
51            })),
52        }
53    }
54}
55
56impl FileSystem for OsFileSystem {
57    fn open_with_options(&self, path: &Path, options: OpenOptions) -> io::Result<Box<dyn File>> {
58        tracing::debug_span!("OsFileSystem::open_with_options", path = ?path, options = ?options)
59            .in_scope(move || -> io::Result<Box<dyn File>> {
60                let mut fs_options = fs::File::options();
61                Ok(Box::new(OsFile {
62                    inner: options.into_fs_options(&mut fs_options).open(path)?,
63                    version: 0,
64                }))
65            })
66    }
67
68    fn traversal(&self, func: BoxedTraversal) {
69        OsTraversalScope::with(move |scope| {
70            func(scope);
71        })
72    }
73
74    fn working_directory(&self) -> Option<PathBuf> {
75        self.working_directory.clone()
76    }
77
78    fn path_exists(&self, path: &Path) -> bool {
79        path.exists()
80    }
81
82    fn path_is_file(&self, path: &Path) -> bool {
83        path.is_file()
84    }
85
86    fn resolve_configuration(&self, specifier: &str) -> Result<Resolution, ResolveError> {
87        self.configuration_resolver
88            .resolve(self.working_directory().unwrap(), specifier)
89    }
90
91    fn get_changed_files(&self, base: &str) -> io::Result<Vec<String>> {
92        let output = Command::new("git")
93            .arg("diff")
94            .arg("--name-only")
95            // A: added
96            // C: copied
97            // M: modified
98            // R: renamed
99            // Source: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203
100            .arg("--diff-filter=ACMR")
101            .arg(format!("{}...HEAD", base))
102            .output()?;
103
104        Ok(String::from_utf8_lossy(&output.stdout)
105            .lines()
106            .map(|l| l.to_string())
107            .collect())
108    }
109}
110
111struct OsFile {
112    inner: fs::File,
113    version: i32,
114}
115
116impl File for OsFile {
117    fn read_to_string(&mut self, buffer: &mut String) -> io::Result<()> {
118        tracing::debug_span!("OsFile::read_to_string").in_scope(move || {
119            // Reset the cursor to the starting position
120            self.inner.rewind()?;
121            // Read the file content
122            self.inner.read_to_string(buffer)?;
123            Ok(())
124        })
125    }
126
127    fn set_content(&mut self, content: &[u8]) -> io::Result<()> {
128        tracing::trace_span!("OsFile::set_content").in_scope(move || {
129            // Truncate the file
130            self.inner.set_len(0)?;
131            // Reset the cursor to the starting position
132            self.inner.rewind()?;
133            // Write the byte slice
134            self.inner.write_all(content)?;
135            // new version stored
136            self.version += 1;
137            Ok(())
138        })
139    }
140
141    fn file_version(&self) -> i32 {
142        self.version
143    }
144}
145
146#[repr(transparent)]
147pub struct OsTraversalScope<'scope> {
148    scope: Scope<'scope>,
149}
150
151impl<'scope> OsTraversalScope<'scope> {
152    pub(crate) fn with<F>(func: F)
153    where
154        F: FnOnce(&Self) + Send,
155    {
156        scope(move |scope| func(Self::from_rayon(scope)))
157    }
158
159    fn from_rayon<'a>(scope: &'a Scope<'scope>) -> &'a Self {
160        // SAFETY: transmuting from Scope to OsTraversalScope is safe since
161        // OsTraversalScope has the `repr(transparent)` attribute that
162        // guarantees its layout is the same as Scope
163        unsafe { mem::transmute(scope) }
164    }
165}
166
167impl<'scope> TraversalScope<'scope> for OsTraversalScope<'scope> {
168    fn spawn(&self, ctx: &'scope dyn TraversalContext, path: PathBuf) {
169        let file_type = match path.metadata() {
170            Ok(meta) => meta.file_type(),
171            Err(err) => {
172                ctx.push_diagnostic(
173                    IoError::from(err).with_file_path(path.to_string_lossy().to_string()),
174                );
175                return;
176            }
177        };
178        handle_any_file(&self.scope, ctx, path, file_type, None);
179    }
180}
181
182// TODO: remove in Biome 2.0, and directly use `.gitignore`
183/// Default list of ignored directories, in the future will be supplanted by
184/// detecting and parsing .ignore files
185const DEFAULT_IGNORE: &[&str; 5] = &[".git", ".svn", ".hg", ".yarn", "node_modules"];
186
187/// Traverse a single directory
188fn handle_dir<'scope>(
189    scope: &Scope<'scope>,
190    ctx: &'scope dyn TraversalContext,
191    path: &Path,
192    // The unresolved origin path in case the directory is behind a symbolic link
193    origin_path: Option<PathBuf>,
194) {
195    if let Some(file_name) = path.file_name().and_then(OsStr::to_str) {
196        if DEFAULT_IGNORE.contains(&file_name) {
197            return;
198        }
199    }
200    let iter = match fs::read_dir(path) {
201        Ok(iter) => iter,
202        Err(err) => {
203            ctx.push_diagnostic(IoError::from(err).with_file_path(path.display().to_string()));
204            return;
205        }
206    };
207
208    for entry in iter {
209        match entry {
210            Ok(entry) => handle_dir_entry(scope, ctx, entry, origin_path.clone()),
211            Err(err) => {
212                ctx.push_diagnostic(IoError::from(err).with_file_path(path.display().to_string()));
213            }
214        }
215    }
216}
217
218/// Traverse a single directory entry, scheduling any file to execute the context
219/// handler and sub-directories for subsequent traversal
220fn handle_dir_entry<'scope>(
221    scope: &Scope<'scope>,
222    ctx: &'scope dyn TraversalContext,
223    entry: DirEntry,
224    // The unresolved origin path in case the directory is behind a symbolic link
225    origin_path: Option<PathBuf>,
226) {
227    let path = entry.path();
228    let file_type = match entry.file_type() {
229        Ok(file_type) => file_type,
230        Err(err) => {
231            ctx.push_diagnostic(
232                IoError::from(err).with_file_path(path.to_string_lossy().to_string()),
233            );
234            return;
235        }
236    };
237    handle_any_file(scope, ctx, path, file_type, origin_path);
238}
239
240fn handle_any_file<'scope>(
241    scope: &Scope<'scope>,
242    ctx: &'scope dyn TraversalContext,
243    mut path: PathBuf,
244    mut file_type: FileType,
245    // The unresolved origin path in case the directory is behind a symbolic link
246    mut origin_path: Option<PathBuf>,
247) {
248    if !ctx.interner().intern_path(path.clone()) {
249        // If the path was already inserted, it could have been pointed at by
250        // multiple symlinks. No need to traverse again.
251        return;
252    }
253
254    if file_type.is_symlink() {
255        if !ctx.can_handle(&BiomePath::new(path.clone())) {
256            return;
257        }
258        let Ok((target_path, target_file_type)) = expand_symbolic_link(path.clone(), ctx) else {
259            return;
260        };
261
262        if !ctx.interner().intern_path(target_path.clone()) {
263            // If the path was already inserted, it could have been pointed at by
264            // multiple symlinks. No need to traverse again.
265            return;
266        }
267
268        if target_file_type.is_dir() {
269            scope.spawn(move |scope| {
270                handle_dir(scope, ctx, &target_path, Some(path));
271            });
272            return;
273        }
274
275        path = target_path;
276        file_type = target_file_type;
277    }
278
279    // In case the file is inside a directory that is behind a symbolic link,
280    // the unresolved origin path is used to construct a new path.
281    // This is required to support ignore patterns to symbolic links.
282    let biome_path = if let Some(old_origin_path) = &origin_path {
283        if let Some(file_name) = path.file_name() {
284            let new_origin_path = old_origin_path.join(file_name);
285            origin_path = Some(new_origin_path.clone());
286            BiomePath::new(new_origin_path)
287        } else {
288            ctx.push_diagnostic(Error::from(FileSystemDiagnostic {
289                path: path.to_string_lossy().to_string(),
290                error_kind: ErrorKind::UnknownFileType,
291                severity: Severity::Warning,
292            }));
293            return;
294        }
295    } else {
296        BiomePath::new(&path)
297    };
298
299    // Performing this check here let's us skip unsupported
300    // files entirely, as well as silently ignore unsupported files when
301    // doing a directory traversal, but printing an error message if the
302    // user explicitly requests an unsupported file to be handled.
303    // This check also works for symbolic links.
304    if !ctx.can_handle(&biome_path) {
305        return;
306    }
307
308    if file_type.is_dir() {
309        scope.spawn(move |scope| {
310            handle_dir(scope, ctx, &path, origin_path);
311        });
312        return;
313    }
314
315    if file_type.is_file() {
316        scope.spawn(move |_| {
317            ctx.handle_file(&path);
318        });
319        return;
320    }
321
322    ctx.push_diagnostic(Error::from(FileSystemDiagnostic {
323        path: path.to_string_lossy().to_string(),
324        error_kind: ErrorKind::from(file_type),
325        severity: Severity::Warning,
326    }));
327}
328
329/// Indicates a symbolic link could not be expanded.
330///
331/// Has no fields, since the diagnostics are already generated inside
332/// [follow_symbolic_link()] and the caller doesn't need to do anything except
333/// an early return.
334struct SymlinkExpansionError;
335
336/// Expands symlinks by recursively following them up to [MAX_SYMLINK_DEPTH].
337///
338/// ## Returns
339///
340/// Returns a tuple where the first argument is the target path being pointed to
341/// and the second argument is the target file type.
342fn expand_symbolic_link(
343    mut path: PathBuf,
344    ctx: &dyn TraversalContext,
345) -> Result<(PathBuf, FileType), SymlinkExpansionError> {
346    let mut symlink_depth = 0;
347    loop {
348        symlink_depth += 1;
349        if symlink_depth > MAX_SYMLINK_DEPTH {
350            let path = path.to_string_lossy().to_string();
351            ctx.push_diagnostic(Error::from(FileSystemDiagnostic {
352                path: path.clone(),
353                error_kind: ErrorKind::DeeplyNestedSymlinkExpansion(path),
354                severity: Severity::Warning,
355            }));
356            return Err(SymlinkExpansionError);
357        }
358
359        let (target_path, target_file_type) = follow_symlink(&path, ctx)?;
360
361        if target_file_type.is_symlink() {
362            path = target_path;
363            continue;
364        }
365
366        return Ok((target_path, target_file_type));
367    }
368}
369
370fn follow_symlink(
371    path: &Path,
372    ctx: &dyn TraversalContext,
373) -> Result<(PathBuf, FileType), SymlinkExpansionError> {
374    tracing::info!("Translating symlink: {path:?}");
375
376    let target_path = fs::read_link(path).map_err(|err| {
377        ctx.push_diagnostic(IoError::from(err).with_file_path(path.to_string_lossy().to_string()));
378        SymlinkExpansionError
379    })?;
380
381    // Make sure relative symlinks are resolved:
382    let target_path = path
383        .parent()
384        .map(|parent_dir| parent_dir.join(&target_path))
385        .unwrap_or(target_path);
386
387    let target_file_type = match fs::symlink_metadata(&target_path) {
388        Ok(meta) => meta.file_type(),
389        Err(err) => {
390            if err.kind() == IoErrorKind::NotFound {
391                let path = path.to_string_lossy().to_string();
392                ctx.push_diagnostic(Error::from(FileSystemDiagnostic {
393                    path: path.clone(),
394                    error_kind: ErrorKind::DereferencedSymlink(path),
395                    severity: Severity::Warning,
396                }));
397            } else {
398                ctx.push_diagnostic(
399                    IoError::from(err).with_file_path(path.to_string_lossy().to_string()),
400                );
401            }
402            return Err(SymlinkExpansionError);
403        }
404    };
405
406    Ok((target_path, target_file_type))
407}
408
409impl From<FileType> for ErrorKind {
410    fn from(_: FileType) -> Self {
411        Self::UnknownFileType
412    }
413}