723 lines
16 KiB
Lua
723 lines
16 KiB
Lua
local fs = {}
|
|
|
|
local is_windows = package.config:sub(1,1) == '\\'
|
|
local path_sep = is_windows and '\\' or '/'
|
|
|
|
-- ======================================================================
|
|
-- UTILITY FUNCTIONS
|
|
-- ======================================================================
|
|
|
|
local function shell_escape(str)
|
|
if is_windows then
|
|
-- Windows: escape quotes and wrap in quotes
|
|
return '"' .. str:gsub('"', '""') .. '"'
|
|
else
|
|
-- Unix: escape shell metacharacters
|
|
return "'" .. str:gsub("'", "'\"'\"'") .. "'"
|
|
end
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- FILE OPERATIONS
|
|
-- ======================================================================
|
|
|
|
function fs.exists(path)
|
|
local file = io.open(path, "r")
|
|
if file then
|
|
file:close()
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function fs.size(path)
|
|
local file = io.open(path, "r")
|
|
if not file then return nil end
|
|
|
|
local size = file:seek("end")
|
|
file:close()
|
|
return size
|
|
end
|
|
|
|
function fs.is_dir(path)
|
|
if not fs.exists(path) then return false end
|
|
|
|
local cmd
|
|
if is_windows then
|
|
cmd = 'dir ' .. shell_escape(path) .. ' >nul 2>&1 && echo dir'
|
|
else
|
|
cmd = 'test -d ' .. shell_escape(path) .. ' && echo dir'
|
|
end
|
|
|
|
local handle = io.popen(cmd)
|
|
if handle then
|
|
local result = handle:read("*l")
|
|
handle:close()
|
|
return result == "dir"
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
function fs.is_file(path)
|
|
return fs.exists(path) and not fs.is_dir(path)
|
|
end
|
|
|
|
function fs.read(path)
|
|
local file = io.open(path, "r")
|
|
if not file then
|
|
error("Failed to read file '" .. path .. "': file not found or permission denied")
|
|
end
|
|
|
|
local content = file:read("*all")
|
|
file:close()
|
|
|
|
if not content then
|
|
error("Failed to read file '" .. path .. "': read error")
|
|
end
|
|
|
|
return content
|
|
end
|
|
|
|
function fs.write(path, content)
|
|
if path == "" or path:find("\0") then
|
|
error("Failed to write file '" .. path .. "': invalid path")
|
|
end
|
|
|
|
local file = io.open(path, "w")
|
|
if not file then
|
|
error("Failed to write file '" .. path .. "': permission denied or invalid path")
|
|
end
|
|
|
|
local success = file:write(content)
|
|
file:close()
|
|
|
|
if not success then
|
|
error("Failed to write file '" .. path .. "': write error")
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function fs.append(path, content)
|
|
local file = io.open(path, "a")
|
|
if not file then
|
|
error("Failed to append to file '" .. path .. "': permission denied or invalid path")
|
|
end
|
|
|
|
local success = file:write(content)
|
|
file:close()
|
|
|
|
if not success then
|
|
error("Failed to append to file '" .. path .. "': write error")
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function fs.copy(src, dst)
|
|
local src_file = io.open(src, "rb")
|
|
if not src_file then
|
|
error("Failed to copy '" .. src .. "' to '" .. dst .. "': source file not found")
|
|
end
|
|
|
|
local dst_file = io.open(dst, "wb")
|
|
if not dst_file then
|
|
src_file:close()
|
|
error("Failed to copy '" .. src .. "' to '" .. dst .. "': cannot create destination")
|
|
end
|
|
|
|
local chunk_size = 8192
|
|
while true do
|
|
local chunk = src_file:read(chunk_size)
|
|
if not chunk then break end
|
|
|
|
if not dst_file:write(chunk) then
|
|
src_file:close()
|
|
dst_file:close()
|
|
error("Failed to copy '" .. src .. "' to '" .. dst .. "': write error")
|
|
end
|
|
end
|
|
|
|
src_file:close()
|
|
dst_file:close()
|
|
return true
|
|
end
|
|
|
|
function fs.move(src, dst)
|
|
local success = os.rename(src, dst)
|
|
if success then return true end
|
|
|
|
-- Fallback to copy + delete
|
|
fs.copy(src, dst)
|
|
fs.remove(src)
|
|
return true
|
|
end
|
|
|
|
function fs.remove(path)
|
|
local success = os.remove(path)
|
|
if not success then
|
|
error("Failed to remove '" .. path .. "': file not found or permission denied")
|
|
end
|
|
return true
|
|
end
|
|
|
|
function fs.mtime(path)
|
|
local cmd
|
|
if is_windows then
|
|
cmd = 'forfiles /p . /m ' .. shell_escape(fs.basename(path)) .. ' /c "cmd /c echo @fdate @ftime" 2>nul'
|
|
local handle = io.popen(cmd)
|
|
if handle then
|
|
local result = handle:read("*line")
|
|
handle:close()
|
|
if result then
|
|
-- Basic Windows timestamp parsing - fallback to file existence check
|
|
return fs.exists(path) and os.time() or nil
|
|
end
|
|
end
|
|
else
|
|
cmd = 'stat -c %Y ' .. shell_escape(path) .. ' 2>/dev/null'
|
|
local handle = io.popen(cmd)
|
|
if handle then
|
|
local result = handle:read("*line")
|
|
handle:close()
|
|
if result and result:match("^%d+$") then
|
|
return tonumber(result)
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
function fs.touch(path)
|
|
if fs.exists(path) then
|
|
-- Update timestamp
|
|
local content = fs.read(path)
|
|
fs.write(path, content)
|
|
else
|
|
-- Create empty file
|
|
fs.write(path, "")
|
|
end
|
|
return true
|
|
end
|
|
|
|
function fs.lines(path)
|
|
local lines = {}
|
|
local file = io.open(path, "r")
|
|
if not file then
|
|
error("Failed to read lines from '" .. path .. "': file not found")
|
|
end
|
|
|
|
for line in file:lines() do
|
|
lines[#lines + 1] = line
|
|
end
|
|
file:close()
|
|
|
|
return lines
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- DIRECTORY OPERATIONS
|
|
-- ======================================================================
|
|
|
|
function fs.mkdir(path)
|
|
local cmd
|
|
if is_windows then
|
|
cmd = 'mkdir ' .. shell_escape(path) .. ' >nul 2>&1'
|
|
else
|
|
cmd = 'mkdir -p ' .. shell_escape(path)
|
|
end
|
|
|
|
local result = os.execute(cmd)
|
|
-- Handle different Lua version return values
|
|
local success = (result == 0) or (result == true)
|
|
if not success then
|
|
error("Failed to create directory '" .. path .. "'")
|
|
end
|
|
return true
|
|
end
|
|
|
|
function fs.rmdir(path)
|
|
local cmd
|
|
if is_windows then
|
|
cmd = 'rmdir /s /q ' .. shell_escape(path) .. ' >nul 2>&1'
|
|
else
|
|
cmd = 'rm -rf ' .. shell_escape(path)
|
|
end
|
|
|
|
local result = os.execute(cmd)
|
|
local success = (result == 0) or (result == true)
|
|
if not success then
|
|
error("Failed to remove directory '" .. path .. "'")
|
|
end
|
|
return true
|
|
end
|
|
|
|
function fs.list(path)
|
|
if not fs.exists(path) then
|
|
error("Failed to list directory '" .. path .. "': directory not found")
|
|
end
|
|
|
|
if not fs.is_dir(path) then
|
|
error("Failed to list directory '" .. path .. "': not a directory")
|
|
end
|
|
|
|
local entries = {}
|
|
local cmd
|
|
|
|
if is_windows then
|
|
cmd = 'dir /b ' .. shell_escape(path) .. ' 2>nul'
|
|
else
|
|
cmd = 'ls -1 ' .. shell_escape(path) .. ' 2>/dev/null'
|
|
end
|
|
|
|
local handle = io.popen(cmd)
|
|
if not handle then
|
|
error("Failed to list directory '" .. path .. "': command failed")
|
|
end
|
|
|
|
for name in handle:lines() do
|
|
local full_path = fs.join(path, name)
|
|
entries[#entries + 1] = {
|
|
name = name,
|
|
is_dir = fs.is_dir(full_path),
|
|
size = fs.is_file(full_path) and fs.size(full_path) or 0,
|
|
mtime = fs.mtime(full_path)
|
|
}
|
|
end
|
|
handle:close()
|
|
|
|
return entries
|
|
end
|
|
|
|
function fs.list_files(path)
|
|
local entries = fs.list(path)
|
|
local files = {}
|
|
for _, entry in ipairs(entries) do
|
|
if not entry.is_dir then
|
|
files[#files + 1] = entry
|
|
end
|
|
end
|
|
return files
|
|
end
|
|
|
|
function fs.list_dirs(path)
|
|
local entries = fs.list(path)
|
|
local dirs = {}
|
|
for _, entry in ipairs(entries) do
|
|
if entry.is_dir then
|
|
dirs[#dirs + 1] = entry
|
|
end
|
|
end
|
|
return dirs
|
|
end
|
|
|
|
function fs.list_names(path)
|
|
local entries = fs.list(path)
|
|
local names = {}
|
|
for _, entry in ipairs(entries) do
|
|
names[#names + 1] = entry.name
|
|
end
|
|
return names
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- PATH OPERATIONS
|
|
-- ======================================================================
|
|
|
|
function fs.join(...)
|
|
local parts = {...}
|
|
if #parts == 0 then return "" end
|
|
if #parts == 1 then return parts[1] or "" end
|
|
|
|
local result = parts[1] or ""
|
|
for i = 2, #parts do
|
|
local part = parts[i]
|
|
if part and part ~= "" then
|
|
if result == "" then
|
|
result = part
|
|
elseif result:sub(-1) == path_sep then
|
|
result = result .. part
|
|
else
|
|
result = result .. path_sep .. part
|
|
end
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
function fs.dirname(path)
|
|
if path == "" then return "." end
|
|
|
|
-- Remove trailing separators
|
|
path = path:gsub("[/\\]+$", "")
|
|
|
|
local pos = path:find("[/\\][^/\\]*$")
|
|
if pos then
|
|
local dir = path:sub(1, pos - 1)
|
|
return dir ~= "" and dir or path_sep
|
|
end
|
|
return "."
|
|
end
|
|
|
|
function fs.basename(path)
|
|
if path == "" then return "" end
|
|
|
|
-- Remove trailing separators
|
|
path = path:gsub("[/\\]+$", "")
|
|
|
|
return path:match("[^/\\]*$") or ""
|
|
end
|
|
|
|
function fs.ext(path)
|
|
local base = fs.basename(path)
|
|
local dot_pos = base:find("%.[^%.]*$")
|
|
return dot_pos and base:sub(dot_pos) or ""
|
|
end
|
|
|
|
function fs.abs(path)
|
|
if path == "" then path = "." end
|
|
|
|
if is_windows then
|
|
if path:match("^[A-Za-z]:") or path:match("^\\\\") then
|
|
return fs.clean(path)
|
|
end
|
|
else
|
|
if path:sub(1, 1) == "/" then
|
|
return fs.clean(path)
|
|
end
|
|
end
|
|
|
|
local cwd = fs.getcwd()
|
|
return fs.clean(fs.join(cwd, path))
|
|
end
|
|
|
|
function fs.clean(path)
|
|
if path == "" then return "." end
|
|
|
|
-- Normalize path separators
|
|
path = path:gsub("[/\\]+", path_sep)
|
|
|
|
-- Track if path was absolute
|
|
local is_absolute = false
|
|
if is_windows then
|
|
is_absolute = path:match("^[A-Za-z]:") or path:match("^\\\\")
|
|
else
|
|
is_absolute = path:sub(1, 1) == "/"
|
|
end
|
|
|
|
-- Split into components
|
|
local parts = {}
|
|
for part in path:gmatch("[^" .. path_sep:gsub("\\", "\\\\") .. "]+") do
|
|
if part == ".." then
|
|
if #parts > 0 and parts[#parts] ~= ".." then
|
|
if not is_absolute or #parts > 1 then
|
|
parts[#parts] = nil
|
|
end
|
|
elseif not is_absolute then
|
|
parts[#parts + 1] = ".."
|
|
end
|
|
elseif part ~= "." and part ~= "" then
|
|
parts[#parts + 1] = part
|
|
end
|
|
end
|
|
|
|
local result = table.concat(parts, path_sep)
|
|
|
|
if is_absolute then
|
|
if is_windows and path:match("^[A-Za-z]:") then
|
|
-- Windows drive letter
|
|
local drive = path:match("^[A-Za-z]:")
|
|
result = drive .. path_sep .. result
|
|
elseif is_windows and path:match("^\\\\") then
|
|
-- UNC path
|
|
result = "\\\\" .. result
|
|
else
|
|
-- Unix absolute
|
|
result = path_sep .. result
|
|
end
|
|
end
|
|
|
|
return result ~= "" and result or (is_absolute and path_sep or ".")
|
|
end
|
|
|
|
function fs.split(path)
|
|
local pos = path:find("[/\\][^/\\]*$")
|
|
if pos then
|
|
return path:sub(1, pos), path:sub(pos + 1)
|
|
end
|
|
return "", path
|
|
end
|
|
|
|
function fs.splitext(path)
|
|
local dir = fs.dirname(path)
|
|
local base = fs.basename(path)
|
|
local ext = fs.ext(path)
|
|
local name = base
|
|
|
|
if ext ~= "" then
|
|
name = base:sub(1, -(#ext + 1))
|
|
end
|
|
|
|
return dir, name, ext
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- WORKING DIRECTORY
|
|
-- ======================================================================
|
|
|
|
function fs.getcwd()
|
|
local cwd, err = moonshark.getcwd()
|
|
if not cwd then
|
|
error("Failed to get current directory: " .. (err or "unknown error"))
|
|
end
|
|
return cwd
|
|
end
|
|
|
|
function fs.chdir(path)
|
|
local success, err = moonshark.chdir(path)
|
|
if not success then
|
|
error("Failed to change directory to '" .. path .. "': " .. (err or "unknown error"))
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- TEMPORARY FILES
|
|
-- ======================================================================
|
|
|
|
function fs.tempfile(prefix)
|
|
prefix = prefix or "tmp"
|
|
local temp_name = prefix .. "_" .. os.time() .. "_" .. math.random(10000)
|
|
|
|
local temp_path
|
|
if is_windows then
|
|
local temp_dir = os.getenv("TEMP") or os.getenv("TMP") or "C:\\temp"
|
|
temp_path = fs.join(temp_dir, temp_name)
|
|
else
|
|
temp_path = fs.join("/tmp", temp_name)
|
|
end
|
|
|
|
fs.write(temp_path, "")
|
|
return temp_path
|
|
end
|
|
|
|
function fs.tempdir(prefix)
|
|
prefix = prefix or "tmp"
|
|
local temp_name = prefix .. "_" .. os.time() .. "_" .. math.random(10000)
|
|
|
|
local temp_path
|
|
if is_windows then
|
|
local temp_dir = os.getenv("TEMP") or os.getenv("TMP") or "C:\\temp"
|
|
temp_path = fs.join(temp_dir, temp_name)
|
|
else
|
|
temp_path = fs.join("/tmp", temp_name)
|
|
end
|
|
|
|
fs.mkdir(temp_path)
|
|
return temp_path
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- PATTERN MATCHING
|
|
-- ======================================================================
|
|
|
|
function fs.glob(pattern)
|
|
-- Simple validation to prevent obvious shell injection
|
|
if pattern:find("[;&|`$(){}]") then
|
|
return {}
|
|
end
|
|
|
|
local cmd
|
|
if is_windows then
|
|
cmd = 'for %f in (' .. pattern .. ') do @echo %f 2>nul'
|
|
else
|
|
cmd = 'ls -1d ' .. pattern .. ' 2>/dev/null'
|
|
end
|
|
|
|
local matches = {}
|
|
local handle = io.popen(cmd)
|
|
if handle then
|
|
for match in handle:lines() do
|
|
matches[#matches + 1] = match
|
|
end
|
|
handle:close()
|
|
end
|
|
|
|
return matches
|
|
end
|
|
|
|
function fs.walk(root)
|
|
local files = {}
|
|
|
|
local function walk_recursive(path)
|
|
if not fs.exists(path) then return end
|
|
|
|
files[#files + 1] = path
|
|
|
|
if fs.is_dir(path) then
|
|
local success, entries = pcall(fs.list, path)
|
|
if success then
|
|
for _, entry in ipairs(entries) do
|
|
walk_recursive(fs.join(path, entry.name))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
walk_recursive(root)
|
|
return files
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- UTILITY FUNCTIONS
|
|
-- ======================================================================
|
|
|
|
function fs.extension(path)
|
|
local ext = fs.ext(path)
|
|
return ext:sub(2) -- Remove leading dot
|
|
end
|
|
|
|
function fs.change_ext(path, new_ext)
|
|
local dir, name, _ = fs.splitext(path)
|
|
if not new_ext:match("^%.") then
|
|
new_ext = "." .. new_ext
|
|
end
|
|
if dir == "." then
|
|
return name .. new_ext
|
|
end
|
|
return fs.join(dir, name .. new_ext)
|
|
end
|
|
|
|
function fs.ensure_dir(path)
|
|
if not fs.exists(path) then
|
|
fs.mkdir(path)
|
|
elseif not fs.is_dir(path) then
|
|
error("Path exists but is not a directory: " .. path)
|
|
end
|
|
return true
|
|
end
|
|
|
|
function fs.size_human(path)
|
|
local size = fs.size(path)
|
|
if not size then return nil end
|
|
|
|
local units = {"B", "KB", "MB", "GB", "TB"}
|
|
local unit_index = 1
|
|
local size_float = size
|
|
|
|
while size_float >= 1024 and unit_index < #units do
|
|
size_float = size_float / 1024
|
|
unit_index = unit_index + 1
|
|
end
|
|
|
|
if unit_index == 1 then
|
|
return string.format("%d %s", size_float, units[unit_index])
|
|
else
|
|
return string.format("%.1f %s", size_float, units[unit_index])
|
|
end
|
|
end
|
|
|
|
function fs.is_safe_path(path)
|
|
path = fs.clean(path)
|
|
|
|
if path:match("%.%.") then return false end
|
|
if path:match("^[/\\]") then return false end
|
|
if path:match("^~") then return false end
|
|
|
|
return true
|
|
end
|
|
|
|
-- Aliases for convenience
|
|
fs.makedirs = fs.mkdir
|
|
fs.removedirs = fs.rmdir
|
|
|
|
function fs.copytree(src, dst)
|
|
if not fs.exists(src) then
|
|
error("Source directory does not exist: " .. src)
|
|
end
|
|
|
|
if not fs.is_dir(src) then
|
|
error("Source is not a directory: " .. src)
|
|
end
|
|
|
|
fs.mkdir(dst)
|
|
|
|
local entries = fs.list(src)
|
|
for _, entry in ipairs(entries) do
|
|
local src_path = fs.join(src, entry.name)
|
|
local dst_path = fs.join(dst, entry.name)
|
|
|
|
if entry.is_dir then
|
|
fs.copytree(src_path, dst_path)
|
|
else
|
|
fs.copy(src_path, dst_path)
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function fs.find(root, pattern, recursive)
|
|
recursive = recursive ~= false
|
|
local results = {}
|
|
|
|
local function search(dir)
|
|
local success, entries = pcall(fs.list, dir)
|
|
if not success then return end
|
|
|
|
for _, entry in ipairs(entries) do
|
|
local full_path = fs.join(dir, entry.name)
|
|
|
|
if not entry.is_dir and entry.name:match(pattern) then
|
|
results[#results + 1] = full_path
|
|
elseif entry.is_dir and recursive then
|
|
search(full_path)
|
|
end
|
|
end
|
|
end
|
|
|
|
search(root)
|
|
return results
|
|
end
|
|
|
|
function fs.tree(root, max_depth)
|
|
max_depth = max_depth or 10
|
|
|
|
local function build_tree(path, depth)
|
|
if depth > max_depth then return nil end
|
|
|
|
if not fs.exists(path) then return nil end
|
|
|
|
local node = {
|
|
name = fs.basename(path),
|
|
path = path,
|
|
is_dir = fs.is_dir(path)
|
|
}
|
|
|
|
if node.is_dir then
|
|
node.children = {}
|
|
local success, entries = pcall(fs.list, path)
|
|
if success then
|
|
for _, entry in ipairs(entries) do
|
|
local child_path = fs.join(path, entry.name)
|
|
local child = build_tree(child_path, depth + 1)
|
|
if child then
|
|
node.children[#node.children + 1] = child
|
|
end
|
|
end
|
|
end
|
|
else
|
|
node.size = fs.size(path)
|
|
node.mtime = fs.mtime(path)
|
|
end
|
|
|
|
return node
|
|
end
|
|
|
|
return build_tree(root, 1)
|
|
end
|
|
|
|
return fs |