diff --git a/Cargo.lock b/Cargo.lock index b976fba..d757106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1572,8 +1572,11 @@ dependencies = [ name = "lune-std-fs" version = "0.1.0" dependencies = [ + "bstr", + "lune-std-datetime", "lune-utils", "mlua", + "tokio", ] [[package]] diff --git a/crates/lune-std-datetime/src/lib.rs b/crates/lune-std-datetime/src/lib.rs index 83af413..f53ddf3 100644 --- a/crates/lune-std-datetime/src/lib.rs +++ b/crates/lune-std-datetime/src/lib.rs @@ -8,7 +8,7 @@ mod date_time; mod result; mod values; -use self::date_time::DateTime; +pub use self::date_time::DateTime; /** Creates the `datetime` standard library module. diff --git a/crates/lune-std-fs/Cargo.toml b/crates/lune-std-fs/Cargo.toml index 253d96f..a3bc219 100644 --- a/crates/lune-std-fs/Cargo.toml +++ b/crates/lune-std-fs/Cargo.toml @@ -13,4 +13,9 @@ workspace = true [dependencies] mlua = { version = "0.9.7", features = ["luau"] } +bstr = "1.9" + +tokio = { version = "1", default-features = false, features = ["fs"] } + lune-utils = { version = "0.1.0", path = "../lune-utils" } +lune-std-datetime = { version = "0.1.0", path = "../lune-std-datetime" } diff --git a/crates/lune-std-fs/src/copy.rs b/crates/lune-std-fs/src/copy.rs new file mode 100644 index 0000000..4fa3287 --- /dev/null +++ b/crates/lune-std-fs/src/copy.rs @@ -0,0 +1,166 @@ +use std::collections::VecDeque; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use mlua::prelude::*; +use tokio::fs; + +use super::options::FsWriteOptions; + +pub struct CopyContents { + // Vec<(relative depth, path)> + pub dirs: Vec<(usize, PathBuf)>, + pub files: Vec<(usize, PathBuf)>, +} + +async fn get_contents_at(root: PathBuf, _: FsWriteOptions) -> LuaResult { + let mut dirs = Vec::new(); + let mut files = Vec::new(); + + let mut queue = VecDeque::new(); + + let normalized_root = fs::canonicalize(&root).await.map_err(|e| { + LuaError::RuntimeError(format!("Failed to canonicalize root directory path\n{e}")) + })?; + + // Push initial children of the root path into the queue + let mut entries = fs::read_dir(&normalized_root).await?; + while let Some(entry) = entries.next_entry().await? { + queue.push_back((1, entry.path())); + } + + // Go through the current queue, pushing to it + // when we find any new descendant directories + // FUTURE: Try to do async reading here concurrently to speed it up a bit + while let Some((current_depth, current_path)) = queue.pop_front() { + let meta = fs::metadata(¤t_path).await?; + if meta.is_symlink() { + return Err(LuaError::RuntimeError(format!( + "Symlinks are not yet supported, encountered at path '{}'", + current_path.display() + ))); + } else if meta.is_dir() { + // FUTURE: Add an option in FsWriteOptions for max depth and limit it here + let mut entries = fs::read_dir(¤t_path).await?; + while let Some(entry) = entries.next_entry().await? { + queue.push_back((current_depth + 1, entry.path())); + } + dirs.push((current_depth, current_path)); + } else { + files.push((current_depth, current_path)); + } + } + + // Ensure that all directory and file paths are relative to the root path + // SAFETY: Since we only ever push dirs and files relative to the root, unwrap is safe + for (_, dir) in &mut dirs { + *dir = dir.strip_prefix(&normalized_root).unwrap().to_path_buf(); + } + for (_, file) in &mut files { + *file = file.strip_prefix(&normalized_root).unwrap().to_path_buf(); + } + + // FUTURE: Deduplicate paths such that these directories: + // - foo/ + // - foo/bar/ + // - foo/bar/baz/ + // turn into a single foo/bar/baz/ and let create_dir_all do the heavy lifting + + Ok(CopyContents { dirs, files }) +} + +async fn ensure_no_dir_exists(path: impl AsRef) -> LuaResult<()> { + let path = path.as_ref(); + match fs::metadata(&path).await { + Ok(meta) if meta.is_dir() => Err(LuaError::RuntimeError(format!( + "A directory already exists at the path '{}'", + path.display() + ))), + _ => Ok(()), + } +} + +async fn ensure_no_file_exists(path: impl AsRef) -> LuaResult<()> { + let path = path.as_ref(); + match fs::metadata(&path).await { + Ok(meta) if meta.is_file() => Err(LuaError::RuntimeError(format!( + "A file already exists at the path '{}'", + path.display() + ))), + _ => Ok(()), + } +} + +pub async fn copy( + source: impl AsRef, + target: impl AsRef, + options: FsWriteOptions, +) -> LuaResult<()> { + let source = source.as_ref(); + let target = target.as_ref(); + + // Check if we got a file or directory - we will handle them differently below + let (is_dir, is_file) = match fs::metadata(&source).await { + Ok(meta) => (meta.is_dir(), meta.is_file()), + Err(e) if e.kind() == ErrorKind::NotFound => { + return Err(LuaError::RuntimeError(format!( + "No file or directory exists at the path '{}'", + source.display() + ))) + } + Err(e) => return Err(e.into()), + }; + if !is_file && !is_dir { + return Err(LuaError::RuntimeError(format!( + "The given path '{}' is not a file or a directory", + source.display() + ))); + } + + // Perform copying: + // + // 1. If we are not allowed to overwrite, make sure nothing exists at the target path + // 2. If we are allowed to overwrite, remove any previous entry at the path + // 3. Write all directories first + // 4. Write all files + + if !options.overwrite { + if is_file { + ensure_no_file_exists(target).await?; + } else if is_dir { + ensure_no_dir_exists(target).await?; + } + } + + if is_file { + fs::copy(source, target).await?; + } else if is_dir { + let contents = get_contents_at(source.to_path_buf(), options).await?; + + if options.overwrite { + let (is_dir, is_file) = match fs::metadata(&target).await { + Ok(meta) => (meta.is_dir(), meta.is_file()), + Err(e) if e.kind() == ErrorKind::NotFound => (false, false), + Err(e) => return Err(e.into()), + }; + if is_dir { + fs::remove_dir_all(target).await?; + } else if is_file { + fs::remove_file(target).await?; + } + } + + fs::create_dir_all(target).await?; + + // FUTURE: Write dirs / files concurrently + // to potentially speed these operations up + for (_, dir) in &contents.dirs { + fs::create_dir_all(target.join(dir)).await?; + } + for (_, file) in &contents.files { + fs::copy(source.join(file), target.join(file)).await?; + } + } + + Ok(()) +} diff --git a/crates/lune-std-fs/src/lib.rs b/crates/lune-std-fs/src/lib.rs index 81a1507..954472a 100644 --- a/crates/lune-std-fs/src/lib.rs +++ b/crates/lune-std-fs/src/lib.rs @@ -1,9 +1,22 @@ #![allow(clippy::cargo_common_metadata)] +use std::io::ErrorKind as IoErrorKind; +use std::path::{PathBuf, MAIN_SEPARATOR}; + +use bstr::{BString, ByteSlice}; use mlua::prelude::*; +use tokio::fs; use lune_utils::TableBuilder; +mod copy; +mod metadata; +mod options; + +use self::copy::copy; +use self::metadata::FsMetadata; +use self::options::FsWriteOptions; + /** Creates the `fs` standard library module. @@ -12,5 +25,115 @@ use lune_utils::TableBuilder; Errors when out of memory. */ pub fn module(lua: &Lua) -> LuaResult { - TableBuilder::new(lua)?.build_readonly() + TableBuilder::new(lua)? + .with_async_function("readFile", fs_read_file)? + .with_async_function("readDir", fs_read_dir)? + .with_async_function("writeFile", fs_write_file)? + .with_async_function("writeDir", fs_write_dir)? + .with_async_function("removeFile", fs_remove_file)? + .with_async_function("removeDir", fs_remove_dir)? + .with_async_function("metadata", fs_metadata)? + .with_async_function("isFile", fs_is_file)? + .with_async_function("isDir", fs_is_dir)? + .with_async_function("move", fs_move)? + .with_async_function("copy", fs_copy)? + .build_readonly() +} + +async fn fs_read_file(lua: &Lua, path: String) -> LuaResult { + let bytes = fs::read(&path).await.into_lua_err()?; + + lua.create_string(bytes) +} + +async fn fs_read_dir(_: &Lua, path: String) -> LuaResult> { + let mut dir_strings = Vec::new(); + let mut dir = fs::read_dir(&path).await.into_lua_err()?; + while let Some(dir_entry) = dir.next_entry().await.into_lua_err()? { + if let Some(dir_path_str) = dir_entry.path().to_str() { + dir_strings.push(dir_path_str.to_owned()); + } else { + return Err(LuaError::RuntimeError(format!( + "File path could not be converted into a string: '{}'", + dir_entry.path().display() + ))); + } + } + let mut dir_string_prefix = path; + if !dir_string_prefix.ends_with(MAIN_SEPARATOR) { + dir_string_prefix.push(MAIN_SEPARATOR); + } + let dir_strings_no_prefix = dir_strings + .iter() + .map(|inner_path| { + inner_path + .trim() + .trim_start_matches(&dir_string_prefix) + .to_owned() + }) + .collect::>(); + Ok(dir_strings_no_prefix) +} + +async fn fs_write_file(_: &Lua, (path, contents): (String, BString)) -> LuaResult<()> { + fs::write(&path, contents.as_bytes()).await.into_lua_err() +} + +async fn fs_write_dir(_: &Lua, path: String) -> LuaResult<()> { + fs::create_dir_all(&path).await.into_lua_err() +} + +async fn fs_remove_file(_: &Lua, path: String) -> LuaResult<()> { + fs::remove_file(&path).await.into_lua_err() +} + +async fn fs_remove_dir(_: &Lua, path: String) -> LuaResult<()> { + fs::remove_dir_all(&path).await.into_lua_err() +} + +async fn fs_metadata(_: &Lua, path: String) -> LuaResult { + match fs::metadata(path).await { + Err(e) if e.kind() == IoErrorKind::NotFound => Ok(FsMetadata::not_found()), + Ok(meta) => Ok(FsMetadata::from(meta)), + Err(e) => Err(e.into()), + } +} + +async fn fs_is_file(_: &Lua, path: String) -> LuaResult { + match fs::metadata(path).await { + Err(e) if e.kind() == IoErrorKind::NotFound => Ok(false), + Ok(meta) => Ok(meta.is_file()), + Err(e) => Err(e.into()), + } +} + +async fn fs_is_dir(_: &Lua, path: String) -> LuaResult { + match fs::metadata(path).await { + Err(e) if e.kind() == IoErrorKind::NotFound => Ok(false), + Ok(meta) => Ok(meta.is_dir()), + Err(e) => Err(e.into()), + } +} + +async fn fs_move(_: &Lua, (from, to, options): (String, String, FsWriteOptions)) -> LuaResult<()> { + let path_from = PathBuf::from(from); + if !path_from.exists() { + return Err(LuaError::RuntimeError(format!( + "No file or directory exists at the path '{}'", + path_from.display() + ))); + } + let path_to = PathBuf::from(to); + if !options.overwrite && path_to.exists() { + return Err(LuaError::RuntimeError(format!( + "A file or directory already exists at the path '{}'", + path_to.display() + ))); + } + fs::rename(path_from, path_to).await.into_lua_err()?; + Ok(()) +} + +async fn fs_copy(_: &Lua, (from, to, options): (String, String, FsWriteOptions)) -> LuaResult<()> { + copy(from, to, options).await } diff --git a/crates/lune-std-fs/src/metadata.rs b/crates/lune-std-fs/src/metadata.rs new file mode 100644 index 0000000..2cf01c8 --- /dev/null +++ b/crates/lune-std-fs/src/metadata.rs @@ -0,0 +1,154 @@ +use std::{ + fmt, + fs::{FileType as StdFileType, Metadata as StdMetadata, Permissions as StdPermissions}, + io::Result as IoResult, + str::FromStr, + time::SystemTime, +}; + +use mlua::prelude::*; + +use lune_std_datetime::DateTime; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FsMetadataKind { + None, + File, + Dir, + Symlink, +} + +impl fmt::Display for FsMetadataKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::None => "none", + Self::File => "file", + Self::Dir => "dir", + Self::Symlink => "symlink", + } + ) + } +} + +impl FromStr for FsMetadataKind { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_ref() { + "none" => Ok(Self::None), + "file" => Ok(Self::File), + "dir" => Ok(Self::Dir), + "symlink" => Ok(Self::Symlink), + _ => Err("Invalid metadata kind"), + } + } +} + +impl From for FsMetadataKind { + fn from(value: StdFileType) -> Self { + if value.is_file() { + Self::File + } else if value.is_dir() { + Self::Dir + } else if value.is_symlink() { + Self::Symlink + } else { + panic!("Encountered unknown filesystem filetype") + } + } +} + +impl<'lua> IntoLua<'lua> for FsMetadataKind { + fn into_lua(self, lua: &'lua Lua) -> LuaResult> { + if self == Self::None { + Ok(LuaValue::Nil) + } else { + self.to_string().into_lua(lua) + } + } +} + +#[derive(Debug, Clone)] +pub struct FsPermissions { + pub(crate) read_only: bool, +} + +impl From for FsPermissions { + fn from(value: StdPermissions) -> Self { + Self { + read_only: value.readonly(), + } + } +} + +impl<'lua> IntoLua<'lua> for FsPermissions { + fn into_lua(self, lua: &'lua Lua) -> LuaResult> { + let tab = lua.create_table_with_capacity(0, 1)?; + tab.set("readOnly", self.read_only)?; + tab.set_readonly(true); + Ok(LuaValue::Table(tab)) + } +} + +#[derive(Debug, Clone)] +pub struct FsMetadata { + pub(crate) kind: FsMetadataKind, + pub(crate) exists: bool, + pub(crate) created_at: Option, + pub(crate) modified_at: Option, + pub(crate) accessed_at: Option, + pub(crate) permissions: Option, +} + +impl FsMetadata { + pub fn not_found() -> Self { + Self { + kind: FsMetadataKind::None, + exists: false, + created_at: None, + modified_at: None, + accessed_at: None, + permissions: None, + } + } +} + +impl<'lua> IntoLua<'lua> for FsMetadata { + fn into_lua(self, lua: &'lua Lua) -> LuaResult> { + let tab = lua.create_table_with_capacity(0, 6)?; + tab.set("kind", self.kind)?; + tab.set("exists", self.exists)?; + tab.set("createdAt", self.created_at)?; + tab.set("modifiedAt", self.modified_at)?; + tab.set("accessedAt", self.accessed_at)?; + tab.set("permissions", self.permissions)?; + tab.set_readonly(true); + Ok(LuaValue::Table(tab)) + } +} + +impl From for FsMetadata { + fn from(value: StdMetadata) -> Self { + Self { + kind: value.file_type().into(), + exists: true, + created_at: system_time_to_timestamp(value.created()), + modified_at: system_time_to_timestamp(value.modified()), + accessed_at: system_time_to_timestamp(value.accessed()), + permissions: Some(FsPermissions::from(value.permissions())), + } + } +} + +fn system_time_to_timestamp(res: IoResult) -> Option { + match res { + Ok(t) => match t.duration_since(SystemTime::UNIX_EPOCH) { + Ok(d) => DateTime::from_unix_timestamp_float(d.as_secs_f64()).ok(), + Err(_) => None, + }, + Err(_) => None, + } +} diff --git a/crates/lune-std-fs/src/options.rs b/crates/lune-std-fs/src/options.rs new file mode 100644 index 0000000..d33c8f4 --- /dev/null +++ b/crates/lune-std-fs/src/options.rs @@ -0,0 +1,31 @@ +use mlua::prelude::*; + +#[derive(Debug, Clone, Copy)] +pub struct FsWriteOptions { + pub(crate) overwrite: bool, +} + +impl<'lua> FromLua<'lua> for FsWriteOptions { + fn from_lua(value: LuaValue<'lua>, _: &'lua Lua) -> LuaResult { + Ok(match value { + LuaValue::Nil => Self { overwrite: false }, + LuaValue::Boolean(b) => Self { overwrite: b }, + LuaValue::Table(t) => { + let overwrite: Option = t.get("overwrite")?; + Self { + overwrite: overwrite.unwrap_or(false), + } + } + _ => { + return Err(LuaError::FromLuaConversionError { + from: value.type_name(), + to: "FsWriteOptions", + message: Some(format!( + "Invalid write options - expected boolean or table, got {}", + value.type_name() + )), + }) + } + }) + } +}