diff --git a/src/lune/builtins/fs/copy.rs b/src/lune/builtins/fs/copy.rs new file mode 100644 index 0000000..677d0b2 --- /dev/null +++ b/src/lune/builtins/fs/copy.rs @@ -0,0 +1,155 @@ +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, _options: 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 dirs.iter_mut() { + *dir = dir.strip_prefix(&normalized_root).unwrap().to_path_buf() + } + for (_, file) in files.iter_mut() { + *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 { + fs::remove_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/src/lune/builtins/fs/metadata.rs b/src/lune/builtins/fs/metadata.rs new file mode 100644 index 0000000..a5e1ddf --- /dev/null +++ b/src/lune/builtins/fs/metadata.rs @@ -0,0 +1,153 @@ +use std::{ + fmt, + fs::{FileType as StdFileType, Metadata as StdMetadata, Permissions as StdPermissions}, + io::Result as IoResult, + str::FromStr, + time::SystemTime, +}; + +use mlua::prelude::*; + +#[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, 5)?; + 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, + // FUTURE: Turn these into DateTime structs instead when that's implemented + 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) => Some(d.as_secs_f64()), + Err(_) => None, + }, + Err(_) => None, + } +} diff --git a/src/lune/builtins/fs/mod.rs b/src/lune/builtins/fs/mod.rs new file mode 100644 index 0000000..210608d --- /dev/null +++ b/src/lune/builtins/fs/mod.rs @@ -0,0 +1,128 @@ +use std::io::ErrorKind as IoErrorKind; +use std::path::{PathBuf, MAIN_SEPARATOR}; + +use mlua::prelude::*; +use tokio::fs; + +use crate::lune::util::TableBuilder; + +mod copy; +mod metadata; +mod options; + +use copy::copy; +use metadata::FsMetadata; +use options::FsWriteOptions; + +pub fn create(lua: &'static Lua) -> LuaResult { + 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, LuaString<'_>)) -> 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/src/lune/builtins/fs/options.rs b/src/lune/builtins/fs/options.rs new file mode 100644 index 0000000..d33c8f4 --- /dev/null +++ b/src/lune/builtins/fs/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() + )), + }) + } + }) + } +} diff --git a/src/lune/builtins/mod.rs b/src/lune/builtins/mod.rs index a0548d1..b44dd86 100644 --- a/src/lune/builtins/mod.rs +++ b/src/lune/builtins/mod.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use mlua::prelude::*; +mod fs; mod luau; mod serde; mod stdio; @@ -9,6 +10,7 @@ mod task; #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum LuneBuiltin { + Fs, Luau, Task, Serde, @@ -21,6 +23,7 @@ where { pub fn name(&self) -> &'static str { match self { + Self::Fs => "fs", Self::Luau => "luau", Self::Task => "task", Self::Serde => "serde", @@ -30,6 +33,7 @@ where pub fn create(&self, lua: &'lua Lua) -> LuaResult> { let res = match self { + Self::Fs => fs::create(lua), Self::Luau => luau::create(lua), Self::Task => task::create(lua), Self::Serde => serde::create(lua), @@ -49,6 +53,7 @@ impl FromStr for LuneBuiltin { type Err = String; fn from_str(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { + "fs" => Ok(Self::Fs), "luau" => Ok(Self::Luau), "task" => Ok(Self::Task), "serde" => Ok(Self::Serde),