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