Skip to content

Instantly share code, notes, and snippets.

@geekhunger
Last active February 20, 2020 07:33
Show Gist options
  • Save geekhunger/f5ec80010ca25de941f471bb42711996 to your computer and use it in GitHub Desktop.
Save geekhunger/f5ec80010ca25de941f471bb42711996 to your computer and use it in GitHub Desktop.
hot-swap modules required by Lua (bonus: low-level system information and filesystem access via utilities.lua)
-- 2020 (c) [email protected]
-- IMPORTANT NOTE for Lua < 5.1
-- When using this class to hotload other modules, be sure to count tables WITH table.getn and NOT with # !!!
-- Lua 5.1 and prior don't support overriding # via .__len metamethod - so our best bet here is to modify table.getn
-- and use table.getn whenever we have to check the number of entires in numerical tables
local _require = require
local _ipairs = ipairs
local _pairs = pairs
local _type = type
local _getn = table.getn or function(t) return #t end -- Lua > 5.1
local INDEX = function(t, k) return getmetatable(t).__swap.value[k] end
local NEWINDEX = function(t, k, v) getmetatable(t).__swap.value[k] = v end
local CALL = function(t, ...) return getmetatable(t).__swap.value(...) end
local TYPE = function(t) return _type(getmetatable(t).__swap.value) end
local IPAIRS = function(t) return _ipairs(getmetatable(t).__swap.value) end
local PAIRS = function(t) return _pairs(getmetatable(t).__swap.value) end
local LEN = function(t) return _getn(getmetatable(t).__swap.value) end
local utilities = {} -- placeholder, see monkeypatch below
hotload = setmetatable(
{
package_loaded = {};
reload_interval = 3;
run = function(self)
for module, value in pairs(self.package_loaded) do
local proxy = getmetatable(value)
if type(proxy) == "table" then -- is a hot-swappable object
local this = getmetatable(self)
this:__update(proxy)
end
end
end;
onEnterFrame = function(self)
if not self.reload_timeout or self.reload_timeout < os.time() then
self.reload_timeout = os.time() + self.reload_interval
self:run()
end
end
},
{
__call = function(self, module)
assert(
not package.loaded[module],
"module '"..tostring(module).."' can't be registred for hot-reload as it has already been loaded traditionally via require()"
)
if self.package_loaded[module] then
local value = getmetatable(self.package_loaded[module]).__swap.value
if type(value) == "function" or type(value) == "table" then
return self.package_loaded[module] -- via proxy wrapper
end
return value
end
return getmetatable(self).__create(self, module)
end;
__create = function(self, module)
local mname, mpath, mvalue = getmetatable(self):__heap(module)
if not mname or not mpath then
error(string.format(
"module '%s' could neither be loaded nor registred (seems having errors) %s",
module,
type(mvalue) == "nil" and "because it returns nil" or "\n"..tostring(mvalue)
))
return nil
end
self.package_loaded[mname] = setmetatable({}, {
__index = type(mvalue) == "table" and INDEX or nil,
__newindex = type(mvalue) == "table" and NEWINDEX or nil,
__call = (type(mvalue) == "function" or type(mvalue) == "table") and CALL or nil,
__ipairs = type(mvalue) == "table" and IPAIRS or nil,
__pairs = type(mvalue) == "table" and PAIRS or nil,
__len = type(mvalue) == "table" and LEN or nil,
__type = TYPE,
__swap = {
name = mname,
path = mpath,
value = mvalue,
timestamp = utilities.modifiedat(mpath)
}
})
print(string.format("module '%s' has been loaded and registred for hot-reload", mname))
return getmetatable(self).__call(self, module)
end;
__update = function(self, proxy)
local timestamp = utilities.modifiedat(proxy.__swap.path)
if proxy.__swap.timestamp ~= timestamp then
local mname, mpath, mvalue = self:__heap(proxy.__swap.name)
if mname and mpath and mvalue then
proxy.__swap.value = mvalue
proxy.__swap.timestamp = timestamp
print(string.format("module '%s' has been hot-reloaded", mname))
-- TODO? preserve state of hotswappable objects
-- by providing :hotswap class method for transfering state
-- onto the swapped objects?
else
print(string.format("module '%s' could not be hot-re-loaded\n%s", module, mvalue))
end
end
end;
__heap = function(self, module)
local path = self:__path(module)
assert(type(path) == "string", "can't find module '"..module.."'")
local ok, msg = pcall(dofile, path)
if ok == true and msg ~= nil then
return module, path, msg
end
return nil, nil, msg
end;
__path = function(self, resource)
local file_path = resource:gsub("%.", "/")
if file_path:sub(1, 1) == "/" then file_path = "."..file_path end
if file_path:sub(-4) ~= ".lua" then file_path = file_path..".lua" end
if not utilities.isfile(file_path) then
file_path = file_path:sub(1, -4).."init.lua"
if not utilities.isfile(file_path) then
file_path = nil
end
end
return file_path
end
}
)
do
-- monkeypatch to convert utilities module into hot-swappable object
-- 1. load the real, working methods from the utilities module
-- 2. at this point we can actually use hotload() to its full extent
-- 3. update the entire utilities module by aquiring it, which makes it hot-reload-able
utilities = dofile "utilities.lua"
utilities = hotload "utilities"
end
function require(module)
return (type(hotload) == "table" and hotload.package_loaded[module]) and hotload(module) or _require(module)
end
-- IMPORTANT NOTE the fallowing Lua overrides are only needed for Lua <= 5.1 backward compatibility because it doen't support __ipairs, __pairs, __len metamethods. Some of Lua's standard functions use facilities like #, for example in table.insert, when shifting entries.
function type(obj)
local proxy = getmetatable(obj)
if proxy and proxy.__type then
if _type(proxy.__type) == "string" then
return proxy.__type
elseif _type(proxy.__type) == "function" then
return proxy.__type(obj)
end
end
return _type(obj)
end
function ipairs(obj)
local proxy = getmetatable(obj)
if proxy and _type(proxy.__ipairs) == "function" then
return proxy.__ipairs(obj)
end
return _ipairs(obj)
end
function pairs(obj)
local proxy = getmetatable(obj)
if proxy and _type(proxy.__pairs) == "function" then
return proxy.__pairs(obj)
end
return _pairs(obj)
end
function table.getn(obj)
local proxy = getmetatable(obj)
if proxy and _type(proxy.__len) == "function" then
return proxy.__len(obj)
end
return _getn(obj)
end
function table.insert(t, p, v)
local pos, val
if v and type(p) == "number" then
assert(p >= 1 and p <= table.getn(t) + 1, "table.insert position index out of range")
pos = p
val = v
if t[pos] then
for i = table.getn(t) + 1, pos - 1, -1 do
t[i] = t[i - 1]
end
end
else
pos = table.getn(t) + 1
val = p
end
t[pos] = val
end
function table.remove(t, p)
assert(p >= 1 and p <= table.getn(t) + 1, "table.remove position index out of range")
if t[p + 1] then
for i = p, table.getn(t) - 1 do
t[i] = t[i + 1]
end
t[table.getn(t)] = nil
end
t[p] = nil
end
return hotload
-- 2019 (c) [email protected]
-- NOTE useful git command to setup when you want to count all lines of code in the current git repository
-- $ git config --local alias.count "! git ls-files | xargs wc -l"
-- $ git count
-- namespace unix plumbing utilities to access (file)system tools at low-level
local mimetypeguess = require "mimetype"
local filesystem = {shell = shell}
-- @str (string) the string to trim
-- returns (string) with removed whitespaces, tabs and line-break characters from beginning- and ending of the string
-- NOTE also useful to omit additional return values (only keep the first returned value)
local function trim(str)
if type(str) ~= "string" then str = tostring(str or "") end
local mask = "[ \t\r\n]*"
local output = str:gsub("^"..mask, ""):gsub(mask.."$", "")
return output
end
-- @val (any) the value to wrap in qoutes
-- returns (string) value converted to string and wrapped into quotation marks
local function quote(val)
return "\""..tostring(val or "").."\""
end
-- @fragments (table) list of values
-- returns (string) concatenated string of all items similar to table.concat
local function toquery(fragments)
local query = ""
for _, frag in ipairs(fragments) do
local f = tostring(frag)
if (f == "" or f:match("%s+")) and not f:match("^[\"\']+.+[\"\']$") then f = quote(f) end -- but ignore already escaped frags
if not query:match("=$") then f = " "..f end
query = query..f
end
return trim(query)
end
-- @... (any) first argument should be the utility name fallowed by its list of parameters
-- returns (string or nil, boolean) return value of utility call or nil, and its status
local function cmd(...)
local tmpfile = "/tmp/shlua"
local exitcode = "; echo $? >>"..tmpfile
local command = os.execute(toquery{...}.." >>"..tmpfile..exitcode)
local console, report, status = io.open(tmpfile, "r")
if console then
report, status = console:read("*a"):match("(.*)(%d+)[\r\n]*$") -- response, exitcode
report = trim(report)
status = tonumber(status) == 0
console:close()
end
os.remove(tmpfile)
return report ~= "" and report or nil, status
end
-- add api like shell[utility](arguments) or shell.utility(arguments)
local shell = setmetatable({cmd = cmd}, {__index = function(_, utility)
return function(...)
return cmd(utility, ...)
end
end})
-- @platform (string) operating system to check against; returns (boolean) true on match
-- platform regex could be: linux*, windows* darwin*, cygwin*, mingw* (everything else might count as unknown)
-- returns (string) operating system identifier or (boolean) on match with @platform
-- NOTE love.system.getOS() is another way of retreving this, if the love2d framework is used in this context
function filesystem.os(platform)
filesystem.uname = filesystem.uname or shell.uname("-s")
if type(platform) == "string" then
if platform:lower():find("maco?s?") then platform = "darwin" end
return type(filesystem.uname:lower():match("^"..platform:lower())) ~= "nil"
end
return filesystem.uname
end
-- @path (string) relative- or absolute path to a file or folder
-- returns (boolean)
function filesystem.exists(path)
return select(2, shell.test("-e", path))
end
-- @path (string) relative- or absolute path to a file or folder
-- returns (string) mime-type of the resource (file or folder)
-- NOTE for more predictable web-compilant results use the mime.lua module!
function filesystem.filetype(path)
if filesystem.exists(path) then
return trim(shell.file("--mime-type", "-b", path))
end
return nil
end
-- @path (string) relative- or absolute path to a file
-- returns (string) mime-encoding of the resource
function filesystem.filecharset(path)
if filesystem.isfile(path) then
return trim(shell.file("--mime-encoding", "-b", path))
end
return nil
end
-- @path (string) relative- or absolute path to a file
-- returns (string) mime-type; mime-encoding of the resource in one go
function filesystem.filemime(path)
if filesystem.isfile(path) then
if filesystem.os("darwin") then -- MacOS
return trim(shell.file("-bI", path))
elseif filesystem.os("linux") then -- Linux
return trim(shell.file("-bi", path))
end
end
return nil
end
-- @path (string) relative- or absolute path to a file
-- returns (boolean)
function filesystem.isfile(path)
return filesystem.exists(path) and select(2, shell.test("-f", path))
end
-- @path (string) relative- or absolute path to a folder
-- returns (boolean)
function filesystem.isfolder(path)
return filesystem.exists(path) and select(2, shell.test("-d", path))
end
-- returns (string) of the current location you are at
function filesystem.currentfolder()
return trim(shell.echo("$(pwd)"))
end
-- @path (string) relative- or absolute path to the (sub-)folder
-- @filter (string) filename to check against; or regex expression mask, see https://www.cyberciti.biz/faq/grep-regular-expressions
-- returns (boolen or table) nil if @path leads to a file instead of a folder;
-- true on a match with @filter + an array of files that match the @filter criteria;
-- otherwise an array of files inside that folder
function filesystem.infolder(path, filter)
-- TODO? include folders as well but append / to signal that its a folder?
if not filesystem.isfolder(path) then return nil end
local content, status = shell.cmd("ls", path, "|", "grep", filter or "")
local list = {}
for resource in content:gmatch("[^\r\n]*") do
if resource ~= "" then table.insert(list, resource) end
end
if filter then return content ~= "", list end
return list
end
-- @path (string) relative- or absolute path to the file or (sub-)folder
-- returns (string) birthtime of file as epoch/unix date timestamp
function filesystem.createdat(path)
if filesystem.os("darwin") then -- MacOS
return trim(shell.stat("-f", "%B", path))
elseif filesystem.os("linux") then -- Linux
-- NOTE most Linux filesystems do not support this property and return 0 or -
-- see https://unix.stackexchange.com/questions/91197/how-to-find-creation-date-of-file
return trim(shell.stat("-c", "%W", path))
end
end
-- @path (string) relative- or absolute path to the file or (sub-)folder
-- returns (string) epoch/ unix date timestamp
function filesystem.modifiedat(path)
-- NOTE a machine should first of all have the right timezone set in preferences, for Linux see https://askubuntu.com/questions/3375/how-to-change-time-zone-settings-from-the-command-line
-- Linux does always store modification time as UTC and converts these timestamps aleways back into the local timezone of your machine. However, if a device stores time as CET then Linux would assume that timestamp to be UTC and therefor (mistakenly) convert it back into the machines local timezone, see discussion https://unix.stackexchange.com/questions/440765/linux-showing-incorrect-file-modified-time-for-camera-video
-- In any case, you get different results for MacOS vs Linux!
return trim(shell.date("-r", path, "+%s"))
end
-- @path (string) relative- or absolute path to the file
-- returns (string) SHA1 checksum of file contents
function filesystem.checksum(path)
if filesystem.isfile(path) then
if filesystem.os("darwin") then -- MacOS
return trim(shell.cmd("shasum", "-a", 1, path, "|", "awk", "'{print $1}'"))
elseif filesystem.os("linux") then -- Linux
return trim(shell.cmd("sha1sum", path, "|", "awk", "'{print $1}'"))
end
end
return nil
end
-- @path (string) relative- or absolute path to the new, empty file
-- does not override existing file but updates its timestamp
-- returns (boolean) true on success
function filesystem.makefile(path)
if filesystem.isfolder(path) then return false end
return select(2, shell.touch(path))
end
-- @path (string) relative- or absolute path to the file
-- skips non-existing file as well
-- returns (boolean) true on success
function filesystem.deletefile(path)
if filesystem.isfolder(path) then return false end
return select(2, shell.rm("-f", path))
end
-- @path (string) relative- or absolute path to the file
-- returns (string) raw content of a file; or nil on failure
function filesystem.readfile(path, mode)
if type(mode) ~= "string" then mode = "rb" end
local file_pointer
if type(path) == "string" then
if not filesystem.isfile(path) then return nil end
file_pointer = io.open(path, mode)
else
file_pointer = path -- path is already a file handle
end
if not file_pointer then return nil end
local content = file_pointer:read("*a")
file_pointer:close()
return content
end
-- @path (string) relative- or absolute path to the file
-- returns (boolean) true on success, false on fail
function filesystem.writefile(path, data, mode)
if type(mode) ~= "string" then mode = "wb" end
local file_pointer
if type(path) == "string" then
if filesystem.isfolder(path) then return false end
if not filesystem.exists(path) then filesystem.makefile(path) end
file_pointer = io.open(path, mode)
else
file_pointer = path -- path is already a file handle
end
if not file_pointer then return false end
-- TODO? check permissions before write?
file_pointer:write(data)
file_pointer:close()
return true
end
-- @path (string) relative- or absolute path to the new (sub-)folder
-- folder name must not contain special characters, except: spaces, plus- & minus signs and underscores
-- does nothing to existing (sub-)folder or its contents
-- returns (boolean) true on success
function filesystem.makefolder(path)
if filesystem.isfile(path) then return false end
return select(2, shell.mkdir("-p", path))
end
-- @path (string) relative- or absolute path to the (sub-)folder
-- deletes recursevly any sub-folder and its contents
-- skips non-existing folder
-- returns (boolean) true on success
function filesystem.deletefolder(path)
if filesystem.isfile(path) then return false end
return select(2, shell.rm("-rf", path))
end
-- @path (string) relative- or absolute path to the file or (sub-)folder you want to copy
-- @location (string) is the new place of the copied resource, NOTE that this string can also contain a new name for the copied resource!
-- includes nested files and folders
-- returns (boolean) true on success
function filesystem.copy(path, location)
if not filesystem.exists(path) then return false end
return select(2, shell.cp("-a", path, location))
end
-- @path (string) relative- or absolute path to the file or (sub-)folder you want to move to another location
-- @location (string) is the new place of the moved rosource, NOTE that this string can also contain a new name for the copied resource!
-- includes nested files and folders
-- returns (boolean) true on success
function filesystem.move(path, location)
if not filesystem.exists(path) then return false end
return select(2, shell.mv(path, location))
end
-- @path (string) relative- or absolute path to folder or file
-- @rights (string or number) permission level, see http://permissions-calculator.org
-- fs.permissions(path) returns (string) an encoded 4 octal digit representing the permission level
-- fs.permissions(path, right) recursevly sets permission level and returns (boolean) true for successful assignment
function filesystem.permissions(path, right)
local fmt = "%03d"
if type(path) ~= "string" or not filesystem.exists(path) then return nil end
if type(right) == "number" then
-- NOTE seems you can not go below chmod 411 on MacOS
-- as the operating system resets it automatically to the next higher permission level
-- because the User (who created the file) at least holds a read access
-- thus trying to set rights to e.g. 044 would result in 644
-- which means User group automatically gets full rights (7 bits instead of 0)
return select(2, shell.chmod("-R", string.format(fmt, right), path))
end
if filesystem.os("darwin") then -- MacOS
return string.format(fmt, shell.cmd("stat", "-r", path, "|", "awk", "'{print $3}'", "|", "tail", "-c", "4"))
elseif filesystem.os("linux") then -- Linux
return shell.stat("-c", "'%a'", path)
end
return nil
end
-- @path (string) relative- or absolute path to a file or folder
-- returns directory path, filename, file extension and mime-type guessed by the file extension
-- NOTE .filetype is the operating system mime-type of the resource (file or folder),
-- while .mimetype is a web-compilant mime-type of the file judged by its file extension
function filesystem.fileinfo(path)
local t = {}
t.url = path
-- TODO remove mimetypeguess() in favor of .filemime()
t.mimetype, t.path, t.name, t.extension = mimetypeguess(t.url) -- same as .filemime but hardcoded
t.filemime = filesystem.filemime(t.url) -- .filetype + .filecharset
t.filetype = filesystem.filetype(t.url)
t.filecharset = filesystem.filecharset(t.url)
t.exists = filesystem.exists(t.url)
t.isfile = filesystem.isfile(t.url)
t.isfolder = filesystem.isfolder(t.url)
t.created = filesystem.createdat(t.url)
t.modified = filesystem.modifiedat(t.url)
t.checksum = filesystem.checksum(t.url)
t.permissions = filesystem.permissions(t.url)
return t
end
-- returns (string) current content of the system clipboard
function filesystem.readclipboard()
if filesystem.os("darwin") then -- MacOS
-- NOTE we could pass around specific formats
-- and by encode/decode these queries we could copy/paste application specific data
-- just like Adobe can transfer Photos from InDesign to Photoshop and back (or even settings)
return shell.pbpaste() --trim(sh.echo("`pbpaste`"))
elseif filesystem.os("linux") then-- TODO? Linux support via xclip
-- NOTE this makes no sense on a machine without a display, like is a webserver
-- see https://unix.stackexchange.com/questions/211817/copy-the-contents-of-a-file-into-the-clipboard-without-displaying-its-contents
end
return nil
end
-- @data (string) the content to insert into the clipboard
-- returns (boolean) true on success
function filesystem.writeclipboard(query)
if filesystem.os("darwin") then -- MacOS
return select(2, shell.cmd("echo", query, "|", "pbcopy"))
end
-- see NOTE above about Linux support
return false
end
-- @hyperthreading (optional boolean) to check against maximal resources instead of physically available once
-- returns (number) of cores this machine has (optionally counting the maximal utilization potential (@hyperthreading = true))
function filesystem.cores(hyperthreading)
if filesystem.os("darwin") then -- MacOS
local pntr = hyperthreading and "hw.logicalcpu" or "hw.physicalcpu"
return trim(shell.sysctl(pntr, "|", "awk", "'{print $2}'"))
elseif filesystem.os("linux") then -- Linux
return trim(shell.nproc())
end
end
-- returns (number) representing cpu workload in % percent
-- NOTE the workload could be grater than 100% if to much workload or not enough cores to handle it
function filesystem.cpu()
if filesystem.os("darwin") or filesystem.os("linux") then -- MacOS or Linux
-- NOTE @avgcpu can be grater than 100% if machine has multiple cores, e.g. up to 600% at 6 cores
-- it could also be larger than that, because of @hyperthreading (physical vs logical number of cores)
local avgcpu = trim(shell.ps("-A", "-o", "%cpu", "|", "awk", "'{s+=$1} END {print s}'")):gsub(",", ".") --%
local ncores = filesystem.cores()
local used = avgcpu * 100 / (ncores * 100) --%
local free = 100 - used --%
return used, free
end
end
-- returns (number) available ram space in kB
function filesystem.ram()
if filesystem.os("darwin") then -- MacOS
return trim(shell.sysctl("hw.memsize"))
elseif filesystem.os("linux") then -- Linux
return trim(shell.cat("/proc/meminfo", "|", "grep", "-i", "MemTotal", "|", "awk", "'{print $2}'"))
end
end
function filesystem.mem()
if filesystem.os("darwin") or filesystem.os("linux") then -- MacOS or Linux
local avgmem = trim(shell.ps("-A", "-o", "%mem", "|", "awk", "'{s+=$1} END {print s}'")):gsub(",", ".") --%
local rsize = filesystem.ram() --kB
local rfree = avgmem * rsize / 100 --kB
local rused = rsize - rfree --kB
local used = 100 - rused * 100 / rsize --%
local free = 100 - used --%
return used, free
end
end
-- returns (table) various information about the machine
function filesystem.sysinfo()
local t = {cpu = {}, mem = {}}
t.os = filesystem.os()
t.cores = filesystem.cores()
t.cpu.used, t.cpu.free = filesystem.cpu() -- in percent
t.ram = filesystem.ram() -- in kilobytes
t.mem.used, t.mem.free = filesystem.mem() -- in percent
return t
end
return filesystem
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment