--luapower package reflection library. --Written by Cosmin Apreutesei. Public Domain. local luapower = setmetatable({}, {__index = _G}) setfenv(1, luapower) local lfs = require'lfs' local glue = require'glue' local ffi = require'ffi' --config ------------------------------------------------------------------------------ local cfg = { luapower_dir = '.', --the location of the luapower tree to inspect on mgit_dir = '.mgit', --relative to luapower_dir oses = {mingw = true, linux = true, osx = true}, --supported OSes os_platforms = { mingw = {mingw32 = true, mingw64 = true}, linux = {linux32 = true, linux64 = true}, osx = {osx32 = true, osx64 = true}, }, platforms = { --supported platforms mingw32 = true, mingw64 = true, linux32 = true, linux64 = true, osx32 = true, osx64 = true, }, servers = {}, --{platform = {'ip|host', port}} auto_update_db = true, --update the db automatically when info is missing allow_update_db_locally = true, default_license = 'PD', --public domain } --get or set a config value function config(var, val) if val ~= nil then glue.assert(cfg[var] ~= nil, 'unknown config var: %s', var) cfg[var] = val else return cfg[var] end end local function plusfile(file) return file and '/'..file or '' end --make a path given a luapower_dir-relative path function powerpath(file) return config'luapower_dir'..plusfile(file) end --make an abs path given a mgit-dir relative path function mgitpath(file) return config'mgit_dir'..plusfile(file) end --memoize pattern with total and partial cache invalidation ------------------------------------------------------------------------------ --memoize function that cannot have its cache cleared. local memoize_permanent = glue.memoize --memoize of 1 and 2 arg functions, where the first arg is always a package name. --cache on those functions can be cleared for individual packages. local pkg_caches = {} --{func = {pkg = val}} local function memoize_package(func) local info = debug.getinfo(func) assert(not info.isvararg and info.nparams >= 1 and info.nparams <= 2) return glue.memoize(func, glue.attr(pkg_caches, func)) end --generic memoize that can only have its entire cache cleared. local rememoizers = {} local function memoize(func) local memfunc local function rememoize() memfunc = glue.memoize(func) end rememoize() rememoizers[func] = rememoize return function(...) return memfunc(...) end end --clear memoization caches for a specific package or for all packages. function clear_cache(pkg) if pkg then for _, cache in pairs(pkg_caches) do cache[pkg] = nil end else for _, cache in pairs(pkg_caches) do for pkg in pairs(cache) do cache[pkg] = nil end end end for _, rememoize in pairs(rememoizers) do rememoize() end collectgarbage() --unload modules from the tracking Lua state end --other helpers --filter a table with a filter function(key, value) -> truthy | falsy local function filter(t, f) local dt = {} for k,v in pairs(t) do if f(k,v) then dt[k] = v end end return dt end --data acquisition: readers and parsers --============================================================================ --detect current platform local platos = {Windows = 'mingw', Linux = 'linux', OSX = 'osx'} current_platform = memoize_permanent(function() return platos[ffi.os]..(ffi.abi'32bit' and '32' or '64') end) --validate a platform if given, or return current platform if not given. function check_platform(platform) if not platform then return current_platform() end glue.assert(config('platforms')[platform], 'unknown platform "%s"', platform) return platform end --find dependencies of a module by tracing the `require` and `ffi.load` calls. ------------------------------------------------------------------------------ --modules that we won't track because require'ing them --is not necessary in any Lua version so there's no point "discovering" --'luajit' as the package that sources these modules. builtin_modules = { string = true, table = true, coroutine = true, package = true, io = true, math = true, os = true, _G = true, debug = true, } --install `require` and `ffi.load` trackers -- to be run in a new Lua state. local function install_trackers(builtin_modules, filter) --find Lua dependencies of a module by tracing its `require` calls. local parents = {} --{module1, ...} local dt = {} --{module = dep_table} local lua_require = require function require(m) --create the module's tracking table dt[m] = dt[m] or {} --require the module directly if it doesn't need tracking. if builtin_modules[m] then return lua_require(m) end --register the module as a dependency for the parent at the top of the stack local parent = parents[#parents] if parent then dt[parent].mdeps = dt[parent].mdeps or {} dt[parent].mdeps[m] = true end --push the module into the parents stack table.insert(parents, m) --check the error cache before loading the module local ok, ret local err = dt[m].loaderr if err then ok, ret = nil, err else ok, ret = pcall(lua_require, m) if not ok then --cache the error for future calls. local err = ret:gsub(':?%s*[\n\r].*', '') --remove source info for platform and arch load errors if err:find'platform not ' and not err:find'arch not ' then err = err:gsub('[^:]*:%d+: ', '') end dt[m] = {loaderr = err} end end --pop the module from the parents stack table.remove(parents) if not ok then error(ret, 2) end --copy the module's autoload table if it has one --TODO: dive into the keys of module and check autoload on the keys too! --eg. bitmap: 'colortypes.rgbaf' -> 'bitmap_rgbaf' local mt = getmetatable(ret) local auto = mt and rawget(mt, '__autoload') if auto then dt[m].autoloads = filter(auto, function(key, mod) return type(key) == 'string' and type(mod) == 'string' end) end return ret end --find C dependencies of a module by tracing the `ffi.load` calls. local ffi = lua_require'ffi' local ffi_load = ffi.load function ffi.load(clib, ...) local ok, ret = xpcall(ffi_load, debug.traceback, clib, ...) local m = parents[#parents] local t = dt[m] t.ffi_deps = t.ffi_deps or {} t.ffi_deps[clib] = ok if not ok then error(ret, 2) else return ret end end --track a module, tracing its require and ffi.load calls. --loader_m is an optional "loader" module that needs to be loaded --before m can be loaded (eg. for loading .dasl files, see dynasm). function track_module(m, loader_m) if loader_m then local ok, err = pcall(require, loader_m) if not ok then --put the error on the account of mod dt[m] = {loaderr = err} --clear deps return dt[m] end end pcall(require, m) return dt[m] end end --make a Lua state for loading modules in a clean environment for tracking. --the tracker function is installed in the state as the global 'track_module'. local tracking_state = memoize(function() local luastate = require'luastate' local state = luastate.open() state:openlibs() state:push(install_trackers) state:call(builtin_modules, filter) return state end) --track a module in the tracking Lua state (which is created on the first call). local function track_module(m, loader_m) assert(m, 'module required') local state = tracking_state() state:getglobal'track_module' return state:call(m, loader_m) end --dependency tracking based on parsing ------------------------------------------------------------------------------ --luajit built-in modules that don't have source code to parse for require calls. luajit_builtin_modules = { ffi = true, bit = true, jit = true, ['jit.util'] = true, ['jit.profile'] = true, } module_requires_parsed = memoize(function(m) --direct dependencies local t = {} if builtin_modules[m] or luajit_builtin_modules[m] then return t end local path = --search for .lua files in standard path package.searchpath(m, package.path) --search for .dasl files in the same path as .lua files or package.searchpath(m, package.path:gsub('%.lua', '.dasl')) if not path then return t end local s = assert(glue.readfile(path)) --delete long comments s = s:gsub('%-%-%[(=*)%[.*%]%1%]', '') --delete long strings s = s:gsub('%-%-%[%[.*%]%]', '') --delete short comments s = s:gsub('%-%-[^\n\r]*', '') --delete the demo section s = s:gsub('[\r\n]if not %.%.%. then.*', '') --require'xxx' for m in s:gmatch'require%s*(%b\'\')' do t[m:sub(2,-2)] = true end --require"xxx" for m in s:gmatch'require%s*(%b"")' do t[m:sub(2,-2)] = true end --require("xxx") or require('xxx') for m in s:gmatch'require%s*(%b())' do m = glue.trim(m:sub(2,-2)) if m:find'^%b\'\'$' or m:find'^%b""$' then m = m:sub(2,-2) if m:find'^[a-z0-9%.]+$' then t[m] = true end end end return t end) --module header parsing ------------------------------------------------------------------------------ function parse_module_header(file) local t = {} local f = io.open(file, 'r') --TODO: check if the module is a .lua file first (what else can it be?). --TODO: parse "Author: XXX" --TODO: parse "License: XXX" --TODO: parse all comment lines before a non-comment line starts. --TODO: parse long comments too. if f then local s1 = f:read'*l' if s1 and s1:find'^%s*$' then --sometimes the first line is empty s1 = f:read'*l' end local s2 = f:read'*l' f:close() if s1 then t.name, t.descr = s1:match'^%-%-%s*([^%:]+)%:%s*(.*)' if not t.name then t.descr = s1:match'^%-%-%s*(.*)' end end if s2 then t.author, t.license = s2:match'^%-%-[Ww]ritten [Bb]y%:? ([^%.]+)%.%s*([^%.]+)%.' if t.license and t.license:lower() == 'public domain' then t.license = 'PD' end end end return t end --comparison function for table.sort() for modules: sorts built-ins first. ------------------------------------------------------------------------------ function module_name_cmp(a, b) if builtin_modules[a] == builtin_modules[b] or luajit_builtin_modules[a] == luajit_builtin_modules[b] then --if a and be are in the same class, compare their names return a < b else --compare classes (std. vs non-std. module) return not (builtin_modules[b] or luajit_builtin_modules[b]) end end --filesystem reader ------------------------------------------------------------------------------ --recursive lfs.dir() -> iter() -> filename, path, mode local function dir(p0, recurse) assert(p0) local t = {} local function rec(p) local dp = p0 .. (p and '/' .. p or '') for f in lfs.dir(dp) do if f ~= '.' and f ~= '..' then local mode = lfs.attributes(dp .. '/' .. f, 'mode') table.insert(t, {f, p, mode}) if recurse and mode == 'directory' then rec((p and p .. '/' .. f or f)) end end end end rec() local i = 0 return function() i = i + 1 if not t[i] then return end return unpack(t[i], 1, 3) end end --path/dir/file -> path/dir, file local function split_path(path) local filename = path:match'([^/]*)$' local n = #path - #filename - 1 if n > 1 then n = n - 1 end --remove trailing '/' if the path is not '/' return path:sub(1, n), filename end --open a file and return a give-me-the-next-line function and a close function. function more(filename) local f, err = io.open(filename, 'r') if not f then return nil, err end local function more() local s = f:read'*l' if not s then f:close(); f = nil end return s end local function close() if f then f:close() end end return more, close end --git command output readers ------------------------------------------------------------------------------ --read a cmd output to a line iterator local function pipe_lines(cmd) if ffi.os == 'Windows' then cmd = cmd .. ' 2> nul' else cmd = cmd .. ' 2> /dev/null' end local t = {} glue.fcall(function(finally) local f = assert(io.popen(cmd, 'r')) finally(function() f:close() end) f:setvbuf'full' for line in f:lines() do t[#t+1] = line end end) local i = 0 return function() i = i + 1 return t[i] end end --read a cmd output to a string local function read_pipe(cmd) local t = {} for line in pipe_lines(cmd) do t[#t+1] = line end return table.concat(t, '\n') end local function git_dir(package) return mgitpath(package..'/.git') end --git command string for a package repo local function gitp(package, args) local git = ffi.os == 'Windows' and 'git.exe' or 'git' return git..' --git-dir="'..git_dir(package)..'" '..args end local function in_dir(dir, func, ...) local pwd = assert(lfs.currentdir()) assert(lfs.chdir(dir)) local function pass(ok, ...) lfs.chdir(pwd) assert(ok, ...) return ... end return pass(glue.pcall(func, ...)) end function git(package, cmd) return in_dir(powerpath(), read_pipe, gitp(package, cmd)) end function gitlines(package, cmd) return in_dir(powerpath(), pipe_lines, gitp(package, cmd)) end --module finders ------------------------------------------------------------------------------ --path/*.lua -> Lua module name local function lua_module_name(path) if path:find'^bin/[^/]+/lua/' then --platform-dependent module path = path:gsub('^bin/[^/]+/lua/', '') end return path:gsub('/', '.'):match('(.-)%.lua$') end --path/*.dasl -> dasl module name local function dasl_module_name(path) return path:gsub('/', '.'):match('(.-)%.dasl$') end --path/*.dll|.so -> C module name local function c_module_name(path) local ext = package.cpath:match'%?%.([^;]+)' --dll, so local name = path:match('bin/[^/]+/clib/(.-)%.'..ext..'$') return name and name:gsub('/', '.') end --check if a file is a module and if it is, return the module name local function module_name(path) return lua_module_name(path) or dasl_module_name(path) or c_module_name(path) end --'module_submodule' -> 'module' --'module.submodule' -> 'module' local function parent_module_name(mod) local parent = mod:match'(.-)[_%.][^_%.]+$' if not parent or parent == '' then return end return parent end --tree builder and tree walker patterns ------------------------------------------------------------------------------ --tree builder based on a function that produces names and a function that --resolves the parent name of a name. --returns a tree of form: {name = true, children = {name = NAME, children = ...}} local function build_tree(get_names, get_parent) local parents = {} for name in get_names() do parents[name] = get_parent(name) or true end local root = {name = true} local function add_children(pnode) for name, parent in pairs(parents) do if parent == pnode.name then local node = {name = name} pnode.children = pnode.children or {} table.insert(pnode.children, node) add_children(node) end end end add_children(root) return root end --tree walker: calls f(node, level, parent_node, node_index) for each node. --depth-first traversal. function walk_tree(t, f) local function walk_children(pnode, level) if type(pnode) ~= 'table' then return end if not pnode.children then return end for i,node in ipairs(pnode.children) do f(node, level, pnode, i) walk_children(node, level + 1) end end walk_children(t, 0) end --WHAT file parser ------------------------------------------------------------------------------ --WHAT file -> {realname=, version=, url=, license=, dependencies={d1,...}} local function parse_what_file(what_file) local t = {} local more, close = assert(more(what_file)) --parse the first line which has the format: -- ' from ()' local s = assert(more(), 'invalid WHAT file '.. what_file) t.realname, t.version, t.url, t.license = s:match('^%s*(.-)%s+(.-)%s+from%s+(.-)%s+%((.*)%)') if not t.realname then error('invalid WHAT file '.. what_file) end t.license = t.license and t.license:match('^(.-)%s+'..glue.escape('license', '*i')..'$') or t.license t.license = t.license:match('^'..glue.escape('public domain', '*i')..'$') and 'PD' or t.license --parse the second line which has the format: -- 'requires: , ( ...), ...' t.dependencies = {} -- {platform = {dep = true}} local s = more() s = s and s:match'^[^:]*:(.*)' if s then for s in glue.gsplit(s, ',') do s = glue.trim(s) if s ~= '' then local s1, ps = s:match'^([^%(]+)%s*%(%s*([^%)]+)%s*%)' --'pkg (platform1 ...)' if ps then s = glue.trim(s1) for platform in glue.gsplit(ps, '%s+') do glue.attr(t.dependencies, platform)[s] = true end else for platform in pairs(config'platforms') do glue.attr(t.dependencies, platform)[s] = true end end end end end close() return t end --markdown yaml header parser ------------------------------------------------------------------------------ --"key value" -> key, value local function split_kv(s, sep) sep = glue.escape(sep) local k,v = s:match('^([^'..sep..']*)'..sep..'(.*)$') k = k and glue.trim(k) if not k then return end v = glue.trim(v) if v == '' then v = true end --values default to true in pandoc return k,v end --parse the yaml header of a pandoc .md file, enclosed between '---\n' local function parse_md_file(md_file) local docname = md_file:match'([^/\\]+)%.md$' local t = {} local more, close = more(md_file) if not more or not more():find '^---' then t.title = docname close() return t end for s in more do if s:find'^---' then break end local k,v = split_kv(s, ':') if not k then error('invalid tag '..s) elseif t[k] then error('duplicate tag '..k) else t[k] = v end end t.title = t.title or docname --set default title close() return t end --category file parser from the luapower-repos package ------------------------------------------------------------------------------ --parse the table of contents file into a list of categories and docs. cats = memoize_package(function(package) local more, close = assert(more(powerpath(mgitpath'luapower-cat.md'))) local cats = {} local lastcat local misc local pkgs = filter(installed_packages(), function(pkg) return known_packages()[pkg] end) local uncat = glue.update({}, pkgs) for s in more do local pkg = s:match'^%s*%*%s*%[([^%]]+)%]%s*$' -- " * [name]" if pkg then if pkgs[pkg] then table.insert(lastcat.packages, pkg) uncat[pkg] = nil end else local cat = s:match'^%s*%*%s*(.-)%s*$' -- " * name" if cat then lastcat = {name = cat, packages = {}} table.insert(cats, lastcat) if cat == 'Misc' then misc = lastcat end end end end if not misc and next(uncat) then table.insert(cats, {name = 'Misc', packages = glue.keys(uncat, true)}) end close() return cats end) --data acquisition: logic and collection --============================================================================ --packages and their files ------------------------------------------------------------------------------ --.mgit/.origin -> {name = true} known_packages = memoize(function() local t = {} for f in dir(powerpath(mgitpath())) do local s = f:match'^(.-)%.origin$' if s then t[s] = true end end return t end) --.mgit// -> {name = true} installed_packages = memoize(function() local t = {} for f, _, mode in dir(powerpath(mgitpath())) do if mode == 'directory' and lfs.attributes(powerpath(git_dir(f)), 'mode') == 'directory' then t[f] = true end end return t end) --(known - installed) -> not installed not_installed_packages = memoize(function() local installed = installed_packages() return filter(known_packages(), function(pkg) return not installed[pkg] end) end) --wrapper for any function(package, ...) that returns a table with keys that --are unique accross all packages. it makes the package argument optional --so that if not given, function(package) is called repeatedly for each --installed package and the results are accumulated into a single table. local function opt_package(func) return function(package, ...) if package then return func(package, ...) end local t = {} for package in glue.sortedpairs(installed_packages()) do glue.update(t, func(package, ...)) end return t end end --memoize for functions where the first arg, package, is optional. local function memoize_opt_package(func) return memoize(opt_package(memoize_package(func))) end --git ls-files -> {path = package} tracked_files = memoize_opt_package(function(package) local t = {} for path in gitlines(package, 'ls-files') do t[path] = package end return t end) --tracked files breakdown: modules, scripts, docs ------------------------------------------------------------------------------ --check if a path is valid for containing modules. local function is_module_path(p, platform) platform = platform and check_platform(platform) or '[^/]+' return not p or not ( (p:find'^bin/' --can't have modules in bin, except... and not p:find('^bin/'..platform..'/clib/') --Lua/C modules and not p:find('^bin/'..platform..'/lua/')) --platform Lua modules or p:find'^csrc/' --can't have modules in csrc or p:find'^media/' --can't have modules in media or p:find'^.mgit/' --can't have modules in .mgit ) end --check if a path is valid for containing docs. --docs can be anywhere except in a few "reserved" places. local function is_doc_path(p) return not p or not ( p:find'^bin/' or p:find'^csrc/' or p:find'^media/' ) end --check if a name is a loadable module as opposed to a script or app. --*_demo, *_test, *_benchmark and *_app modules are excluded from tracking. local function is_module(mod) return not ( mod:find'_test$' or mod:find'_demo$' or mod:find'_demo_.*$' --"demo_" or mod:find'_benchmark$' or mod:find'_app$' ) end --tracked .md -> {doc = path} docs = opt_package(memoize_package(function(package) local t = {} for path in pairs(tracked_files(package)) do if is_doc_path(path) then local name = path:gsub('/', '.'):match'^(.-)%.md$' if name then t[name] = path end end end return t end)) --TODO: current platform is assumed for Lua/C module paths. local function modules_(package, should_be_module) local t = {} for path in pairs(tracked_files(package)) do if is_module_path(path, current_platform()) then local mod = module_name(path) if mod and is_module(mod) == should_be_module then t[mod] = path end end end --add the built-ins to the list of modules for the 'luajit' package if should_be_module and package == 'luajit' then glue.update(t, builtin_modules, luajit_builtin_modules) end return t end --tracked .lua -> {module = path} modules = memoize_opt_package(function(package) return modules_(package, true) end) --tracked