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