// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Create a directory at `path`.
///
/// The Unix-style permission of the created directory can be set in `permission`,
/// the default value is `0o755` (anyone can read and traverse, only owner can write).
/// The `permission` parameter is currently ignored on Windows.
///
/// If `recursive=true` (`false` by default), non-existing parent directories
/// of `path` will be recursively created, too.
pub async fn mkdir(
path : StringView,
permission? : Int = 0o755,
recursive? : Bool = false,
) -> Unit {
let context = "@fs.mkdir()"
@event_loop.mkdir(path, mode=permission, context~) catch {
@os_error.OSError(_) as err if recursive && err.is_ENOENT() => {
let last_path_sep_index = match path.rev_find("/") {
Some(index) => index
None if @event_loop.platform is Windows &&
path.rev_find("\\") is Some(index) => index
None => raise err
}
let parent = path[:last_path_sep_index].trim_end(chars="/")
let parent = if @event_loop.platform is Windows {
parent.trim_end(chars="\\")
} else {
parent
}
mkdir(parent, permission~, recursive~)
@event_loop.mkdir(path, mode=permission, context~)
}
err => raise err
}
}
///|
/// Remove a directory at `path`,
/// If `recursive` is `true` (`false` by default),
/// files and directories in `path` will be removed recursively.
/// If `recursive` is `false`,
/// `path` must be an empty directory, otherwise `rmdir` will fail.
pub async fn rmdir(path : StringView, recursive? : Bool = false) -> Unit {
let context = "@fs.rmdir()"
if recursive {
let base = if @event_loop.platform is Windows {
path.trim_end(chars="/\\")
} else {
path.trim_end(chars="/")
}
let dir = opendir_aux(path, context~)
defer dir.close()
while dir.next_aux(include_special=false, include_hidden=true, context~)
is Some(ent) {
let child_path = "\{base}/\{ent.name}"
if ent.is_dir {
rmdir(child_path, recursive=true)
} else {
@event_loop.remove(child_path, context~)
}
}
}
@event_loop.rmdir(path, context~)
}
///|
priv enum DirectoryState {
NoMoreEntry
NeedMoreEntry
HasBufferedEntry
}
///|
priv struct DirectoryBuffer(@c_buffer.Buffer)
///|
/// A directory in file system
struct Directory {
io : @event_loop.IoHandle
buf : DirectoryBuffer
buf_len : Int
// The offset of the next entry in the buffer
mut offset : Int
// The number of entries already consumed
mut count : Int
// the return value of the corresponding job has different semantic of different platforms.
// - Linux: number of bytes read
// - MacOS: number on entries
// - Windows: nonzero unless EOF
mut job_ret : Int
mut state : DirectoryState
}
///|
pub fn Directory::close(self : Directory) -> Unit {
self.io.close()
self.buf.0.free()
}
///|
#cfg(target="native")
extern "C" fn Directory::min_buffer_size() -> Int = "moonbitlang_async_dir_buffer_min_size"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
fn Directory::min_buffer_size() -> Int = "moonbitlang/async" "fs/dir_buffer_min_size"
///|
/// Open the directory at `path`. `path` is encoded UTF8.
/// If `path` is not a directory, an error will be raised
pub async fn opendir(path : StringView) -> Directory {
opendir_aux(path, context="@fs.opendir()")
}
///|
async fn opendir_aux(path : StringView, context~ : String) -> Directory {
let (io, _) = @event_loop.open(
path,
0,
create=0,
append=false,
sync=0,
mode=0,
context~,
)
if !(io.kind() is Directory) {
io.close()
raise @os_error.OSError(
@os_error.errno_ENOTDIR,
context="\{context}: \{path.escape()}",
)
}
Directory::from_io_handle(io)
}
///|
fn Directory::from_io_handle(io : @event_loop.IoHandle) -> Directory {
let buf_len = @cmp.maximum(1024, Directory::min_buffer_size())
{
io,
buf: @c_buffer.new(buf_len),
buf_len,
offset: 0,
count: 0,
job_ret: 0,
state: NeedMoreEntry,
}
}
///|
#cfg(target="native")
extern "C" fn DirectoryBuffer::entry_length(self : Self, offset : Int) -> Int = "moonbitlang_async_dir_entry_length"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
#borrow(self)
fn DirectoryBuffer::entry_length(self : Self, offset : Int) -> Int = "moonbitlang/async" "fs/dir_entry_length"
///|
#cfg(target="native")
extern "C" fn DirectoryBuffer::entry_name_len(self : Self, offset : Int) -> Int = "moonbitlang_async_dir_entry_get_name_len"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
#borrow(self)
fn DirectoryBuffer::entry_name_len(self : Self, offset : Int) -> Int = "moonbitlang/async" "fs/dir_entry_get_name_len"
///|
#cfg(target="native")
extern "C" fn DirectoryBuffer::entry_name_offset(
self : Self,
offset : Int,
) -> Int = "moonbitlang_async_dir_entry_get_name_offset"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
#borrow(self)
fn DirectoryBuffer::entry_name_offset(self : Self, offset : Int) -> Int = "moonbitlang/async" "fs/dir_entry_get_name_offset"
///|
#cfg(target="native")
extern "C" fn DirectoryBuffer::entry_is_dir(self : Self, offset : Int) -> Int = "moonbitlang_async_dir_entry_is_dir"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
#borrow(self)
fn DirectoryBuffer::entry_is_dir(self : Self, offset : Int) -> Int = "moonbitlang/async" "fs/dir_entry_is_dir"
///|
#cfg(target="native")
extern "C" fn DirectoryBuffer::entry_is_hidden(
self : Self,
offset : Int,
) -> Bool = "moonbitlang_async_dir_entry_is_hidden"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
#borrow(self)
fn DirectoryBuffer::entry_is_hidden(self : Self, offset : Int) -> Bool = "moonbitlang/async" "fs/dir_entry_is_hidden"
///|
#cfg(target="native")
extern "C" fn DirectoryBuffer::entry_file_id(
self : Self,
offset : Int,
) -> UInt64 = "moonbitlang_async_dir_entry_get_file_id"
///|
#cfg(target="wasm")
#unsafe_skip_stub_check
#borrow(self)
fn DirectoryBuffer::entry_file_id(self : Self, offset : Int) -> UInt64 = "moonbitlang/async" "fs/dir_entry_get_file_id"
///|
#valtype
pub struct DirectoryEntry {
name : String
/// Indicate whether the directory entry is another directory.
/// Symbolic links are not treated as directories here.
is_dir : Bool
priv file_id : UInt64
}
///|
/// Fetch the next entry in the directory,
/// including its name and some extra information, see `DirectoryEntry`.
/// `None` indicates that the directory has no more files.
///
/// - If `include_special` is `true` (`false` by default),
/// `.` (current directory) and `..` (parent directory) will be included too
///
/// - If `include_hidden` is `true` (`true` by default),
/// hidden files (on Unix: file name starts with `.`, on Windows: has the hidden attribute)
/// will be included
///
/// The file name in the returned directory entry is interpreted as UTF8-encoded on Unix-like systems.
pub async fn Directory::next(
dir : Directory,
include_hidden? : Bool = true,
include_special? : Bool = false,
) -> DirectoryEntry? {
dir.next_aux(
include_special~,
include_hidden~,
context="@fs.Directory::next()",
)
}
///|
async fn Directory::next_aux(
dir : Directory,
include_hidden~ : Bool,
include_special~ : Bool,
restart? : Bool = false,
context~ : String,
) -> DirectoryEntry? {
match dir.state {
NoMoreEntry => return None
HasBufferedEntry => ()
NeedMoreEntry => {
dir.offset = 0
dir.count = 0
dir.job_ret = dir.io.readdir(dir.buf.0, dir.buf_len, restart~, context~)
if dir.job_ret is 0 {
dir.state = NoMoreEntry
return None
} else {
dir.state = HasBufferedEntry
}
}
}
let entry_len = dir.buf.entry_length(dir.offset)
let offset = dir.offset
dir.offset += entry_len
dir.count += 1
let need_more_entry = match @event_loop.platform {
Linux => dir.offset >= dir.job_ret
MacOS => dir.count >= dir.job_ret
Windows => entry_len is 0
}
if need_more_entry {
dir.state = NeedMoreEntry
}
if !include_hidden && dir.buf.entry_is_hidden(offset) {
return dir.next_aux(include_special~, include_hidden~, context~)
}
let name = @os_string.decode(
dir.buf.0,
offset=offset + dir.buf.entry_name_offset(offset),
len=dir.buf.entry_name_len(offset),
)
if !include_special && name is ("." | "..") {
return dir.next_aux(include_special~, include_hidden~, context~)
}
let is_dir = match dir.buf.entry_is_dir(offset) {
0 => false
1..<_ => true
_..<0 => {
let kind = @event_loop.file_kind_by_path(
name,
parent=dir.io.fd(),
follow_symlink=false,
context~,
)
kind is Directory
}
}
let file_id = dir.buf.entry_file_id(offset)
let entry = { name, is_dir, file_id }
// Native file watching consumes this private field. Wasm keeps the same
// entry shape even though watch support is not compiled there yet.
ignore(entry.file_id)
Some(entry)
}
///|
/// Read all entries in a directory.
///
/// - If `include_special` is `true` (`false` by default),
/// `.` (current directory) and `..` (parent directory) will be included too
///
/// - If `include_hidden` is `true` (`true` by default),
/// hidden files (on Unix: file name starts with `.`, on Windows: has the hidden attribute)
/// will be included
///
/// The returned file names are interpreted as UTF8-encoded on Unix-like systems.
pub async fn Directory::read_all(
dir : Directory,
include_hidden? : Bool = true,
include_special? : Bool = false,
) -> Array[String] {
let result = []
let context = "@fs.Directory::read_all()"
while dir.next_aux(include_hidden~, include_special~, context~) is Some(entry) {
result.push(entry.name)
}
result
}
///|
/// Read all entries in the directory located at `path`.
///
/// - If `include_special` is `true` (`false` by default),
/// `.` (current directory) and `..` (parent directory) will be included too
///
/// - If `include_hidden` is `true` (`true` by default),
/// hidden files (on Unix: file name starts with `.`, on Windows: has the hidden attribute)
/// will be included
///
/// - If `sort` is `true` (`false` by default),
/// the result will be sorted using `Array::sort`.
///
/// The returned file names are interpreted as UTF8-encoded on Unix-like systems.
pub async fn readdir(
path : StringView,
include_hidden? : Bool = true,
include_special? : Bool = false,
sort? : Bool = false,
) -> Array[String] {
let context = "@fs.readdir()"
let dir : Directory = opendir_aux(path, context~)
defer dir.close()
let list = []
while dir.next_aux(include_hidden~, include_special~, context~) is Some(entry) {
list.push(entry.name)
}
if sort {
list.sort()
}
list
}
///|
/// `walk(path, f, ..)` recursively walk the directory `path`,
/// and call `f` on every sub-directory in `path`, including `path` itself.
///
/// If `exclude` is present, it will be invoked with the path of every directory
/// before traversing them. If `exclude` returns `true`,
/// the directory and all its children will be skipped.
/// The path passed to `exclude` has the following format:
/// - always start with `path`
/// - does not contain trailing directory separator
///
/// The directories are walked in parallel.
/// Use `max_concurrency` to limit the number of workers spawned in parallel.
/// For example, setting `max_concurrency=1` result in sequential walking.
/// By default `max_concurrency` is set to a large value.
///
/// If `allow_failure` is `true` (`false` by default),
/// error raised by `f` will be silently ignored.
/// Otherwise, failure in `f` will stop the whole `walk`,
/// cancelling the handling of other sub-directories.
pub async fn walk(
path : StringView,
f : async (String, Array[String]) -> Unit,
exclude? : (String) -> Bool = _ => false,
max_concurrency? : Int = 1000,
allow_failure? : Bool = false,
) -> Unit {
let context = "@fs.walk()"
guard! max_concurrency > 0
let sem = @async.Semaphore(max_concurrency)
@async.with_task_group() <| fn(group) {
fn handle_path(path : String) {
guard !exclude(path) else { }
group.spawn_bg(allow_failure~) <| () => {
sem.acquire()
defer sem.release()
let dir = opendir_aux(path, context~)
defer dir.close()
let sub_dirs = []
let files = []
while dir.next_aux(include_hidden=true, include_special=false, context~)
is Some(ent) {
if ent.is_dir {
let file_path = if path is [.., '/'] {
path + ent.name
} else {
"\{path}/\{ent.name}"
}
sub_dirs.push(file_path)
}
files.push(ent.name)
}
for path in sub_dirs {
handle_path(path)
}
f(path, files)
}
}
handle_path(path.to_owned())
}
}