laburnum 1.17.1

An LSP framework for building language servers and compilers, powered by an incremental query tree with content-addressed storage, task-based dataflow, and parallel queries.
Documentation
// Copyright Two Neutron Stars Incorporated and contributors
// SPDX-License-Identifier: BlueOak-1.0.0

use {
  crate::fs::errors::{
    FsError,
    Result,
  },
  error_stack::ResultExt,
  std::{
    io::{
      Read,
      Write,
    },
    sync::Arc,
    time::SystemTime,
  },
};

/// Laburnum file system trait
///
/// This trait provides an abstraction for over physical and virtual file
/// systems, optimised for the purpose of compiling, running, and testing source
/// code.
///
/// These file systems are designed to provide scoped access to a file system,
/// for the purpose of being a good citizen on a system. They are not designed
/// to completely block access outside of the scope, but should provide enough
/// safety to prevent accidental damage.
///
/// In general a file system would be created for analysing source code, with
/// the root of the workspace as the root of the file system. From there, all
/// operations are relative to the workspace root.
pub trait FileSystem: std::fmt::Debug + Send + Sync {
  /// The URI of the file system
  fn uri(&self) -> crate::Uri;

  /// Open a file with specific options
  fn open(
    &self,
    path: &crate::Uri,
    options: OpenOptions,
  ) -> Result<Box<dyn FileHandle>>;

  // --- READ ------------------------------------------------------------------

  /// Read entire file contents
  fn read(&self, path: &crate::Uri) -> Result<Vec<u8>>;

  /// Read the file into a new `ropey::Rope`
  fn read_to_new_rope(&self, path: &crate::Uri) -> Result<ropey::Rope>;

  /// Read entire file contents as a UTF-8 string
  fn read_to_string(&self, path: &crate::Uri) -> Result<String> {
    let bytes = self.read(path)?;
    String::from_utf8(bytes).map_err(|e| {
      error_stack::Report::new(FsError::Io(format!("Invalid UTF-8 data: {e}")))
    })
  }

  /// Returns an iterator over all files in the filesystem
  /// Each item is (uri, content_bytes)
  fn iter_files(
    &self,
  ) -> Result<Box<dyn Iterator<Item = (crate::Uri, Vec<u8>)> + '_>>;

  // --- WRITE -----------------------------------------------------------------

  /// Write entire file contents to a file at the path
  fn write(&self, path: &crate::Uri, data: &[u8]) -> Result<()>;

  fn write_str(&self, path: &crate::Uri, data: &str) -> Result<()>;

  /// Append data to the end of an existing file at the path, creating a new
  /// file if it does not exist.
  fn append(&self, path: &crate::Uri, data: &[u8]) -> Result<()>;

  // --- DELETE ----------------------------------------------------------------

  /// Delete a file at the path
  fn delete(&self, path: &crate::Uri) -> Result<()>;

  // --- METADATA --------------------------------------------------------------

  /// Get file metadata
  fn metadata(&self, path: &crate::Uri) -> Result<Metadata>;

  // --- DIRECTORY -------------------------------------------------------------

  /// Get the root directory
  fn root(&self) -> Result<Box<dyn DirEntry>>;

  /// Ensure a directory exists at the path, and create it if it does not.
  ///
  /// Returns a DirEntry you can use to add files and directories to the
  /// directory.
  fn dir(&self, path: &crate::Uri) -> Result<Box<dyn DirEntry>>;

  /// Find files and directories recursively, starting from `path` and matching
  /// the globs in `options.glob`.
  ///
  /// This respects `.gitignore` and `.ignore` files.
  fn find(
    &self,
    path: &crate::Uri,
    glob: &[String],
  ) -> Result<im::Vector<Arc<dyn DirEntry>>>;

  fn add_tree_to_snapshot(
    &self,
    title: &str,
    snapshot: &mut ferrotype::Ferrotype,
  ) {
    match self.iter_files() {
      | Ok(files) => {
        let files_vec: Vec<_> = files.collect();

        if files_vec.is_empty() {
          snapshot.add(title, "(empty filesystem)".to_string());
        } else {
          // Add a summary section first
          snapshot.add(title, format!("Found {} files:", files_vec.len()));

          // Create a separate section for each file
          for (url, file_content) in files_vec {
            let filename = url
              .path_segments()
              .and_then(|mut segments| segments.next_back())
              .unwrap_or("unknown");

            // Create title with metadata: "filename (path, size)"
            let file_title = format!(
              "{} ({}, {} bytes)",
              filename,
              url.path(),
              file_content.len()
            );

            // Determine content based on file type
            let file_body = if is_likely_text(&file_content) {
              match std::str::from_utf8(&file_content) {
                | Ok(text) => {
                  if text.is_empty() {
                    "(empty file)".to_string()
                  } else {
                    text.to_string()
                  }
                },
                | Err(_) => "<invalid UTF-8>".to_string(),
              }
            } else {
              "<binary data>".to_string()
            };

            snapshot.add(&file_title, file_body);
          }
        }
      },
      | Err(err) => {
        let error_content = format!("Error reading filesystem: {err}");
        snapshot.add(title, error_content);
      },
    }
  }
}

/// Helper method to determine if file content is likely text
fn is_likely_text(content: &[u8]) -> bool {
  // Empty files are considered text
  if content.is_empty() {
    return true;
  }

  // Check for null bytes or too many non-printable characters
  let null_bytes = content.iter().filter(|&&b| b == 0).count();
  if null_bytes > 0 {
    return false;
  }

  let non_printable = content
    .iter()
    .filter(|&&b| b < 32 && b != b'\n' && b != b'\r' && b != b'\t')
    .count();

  // If more than 10% non-printable, consider binary
  non_printable < content.len() / 10
}

/// Represents file metadata
#[derive(Debug, Clone)]
pub struct Metadata {
  pub is_dir:   bool,
  pub len:      u64,
  pub modified: SystemTime,
  pub created:  SystemTime,
  pub readonly: bool,
}

/// File system permissions
#[derive(Debug, Clone, Copy)]
pub struct Permissions {
  pub readonly: bool,
}

/// Directory entry
pub trait DirEntry: std::fmt::Debug {
  /// Get the path of the entry
  fn path(&self) -> crate::Uri;

  /// Get the file type of the entry
  fn file_type(&self) -> FileType;

  /// Check if the entry is a directory
  fn is_dir(&self) -> bool {
    self.file_type() == FileType::Directory
  }

  /// Check if the entry is a file
  fn is_file(&self) -> bool {
    self.file_type() == FileType::File
  }

  /// Get the metadata of the entry
  fn metadata(&self) -> Result<Metadata>;

  fn entry(&mut self, entry: Box<dyn DirEntry>);

  /// Add a file or directory to the directory
  fn write(&mut self, path: &crate::Uri, data: &[u8]) -> Result<()>;
}

/// File type (file or directory)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
  File,
  Directory,
}

/// Read/Write options for opening files
#[derive(Debug, Clone, Copy)]
pub struct OpenOptions {
  pub read:     bool,
  pub write:    bool,
  pub create:   bool,
  pub append:   bool,
  pub truncate: bool,
}

impl Default for OpenOptions {
  fn default() -> Self {
    OpenOptions {
      read:     true,
      write:    false,
      create:   false,
      append:   false,
      truncate: false,
    }
  }
}

/// File handle trait
pub trait FileHandle: Read + Write + Send + Sync {
  fn metadata(&self) -> Result<Metadata>;
}

/// Helper function to build a GlobSet from a slice of glob patterns
pub(crate) fn build_glob_set(patterns: &[String]) -> Result<globset::GlobSet> {
  let mut builder = globset::GlobSetBuilder::new();
  for pattern in patterns {
    builder.add(
      globset::GlobBuilder::new(pattern)
        .literal_separator(true)
        .build()
        .change_context(FsError::InvalidGlob(
          "Invalid glob pattern".to_string(),
        ))
        .attach_printable_lazy(|| format!("Pattern: {pattern}"))?,
    );
  }

  builder.build().change_context(FsError::InvalidGlob(
    "Failed to build glob pattern".to_string(),
  ))
}