glava-ridged/glava-config/config.lua
VetheonGames 5499f9f4b9 Init
2023-09-07 16:16:06 -06:00

321 lines
9.7 KiB
Lua

local lfs = require "lfs"
local mappings = require "glava-config.mappings"
local config = {
Profile = { mt = {} },
PROFILES_DIR = "profiles"
}
config.Profile.__index = config.Profile
setmetatable(config.Profile, config.Profile.mt)
-- Split path into entries, such that `table.concat` can be used to
-- reconstruct the path. Prepends the result with an empty string so
-- root (absolute) paths are preserved
local function path_split(str, sep)
local sep, fields = sep or ":", (str:sub(1, sep:len()) == sep and {""} or {})
local pattern = string.format("([^%s]+)", sep)
str:gsub(pattern, function(c) fields[#fields + 1] = c end)
return fields
end
-- Concatenates paths such that duplicate path separators are removed.
-- Can be used on non-split arguments, and resolves `..` syntax
local function path_concat(...)
local ret = {}
for _, v in ipairs({...}) do
for _, e in ipairs(path_split(v, "/")) do
if e ~= "" or #ret == 0 then
if e == ".." and #ret >= 1 then
ret[#ret] = nil
else
ret[#ret + 1] = e
end
end
end
end
return table.concat(ret, "/")
end
-- Wrap table such that it can be called to index and call its members,
-- useful for switch-style syntax
local function switch(tbl)
local mt = { __call = function(self, i) return rawget(self, i)() end }
return setmetatable(tbl, mt)
end
-- To parse data from GLSL configs we use some complex pattern matching.
--
-- Because Lua's patterns operate on a per-character basis and do not offer
-- any read-ahead functionality, we use a pattern 'replacement' functionality
-- such that the match of an input pattern is passed to a function to produce
-- an output pattern.
--
-- This effectively means we have some fairly powerful parsing which allows us
-- to handle things like quoted strings with escaped characters.
local function unquote(match)
local ret = {}
local escaped = false
for c in match:gmatch(".") do
if c == "\"" then
if escaped then ret[#ret + 1] = c end
elseif c ~= "\\" then ret[#ret + 1] = c end
if c == "\\" then
if escaped then ret[#ret + 1] = c end
escaped = not escaped
else escaped = false end
end
return table.concat(ret, "")
end
local function none(...) return ... end
local MATCH_ENTRY_PATTERN = "^%s*%#(%a+)%s+(%a+)"
local MATCH_DATA_PREFIX = "^%s*%#%a+%s+%a+"
local MATCH_TYPES = {
["float"] = { pattern = "(%d+.?%d*)" },
["int"] = { pattern = "(%d+)" },
["color-expr"] = { pattern = "(.+)" },
["expr"] = { pattern = "(.+)" },
["ident"] = { pattern = "(%a%w*)" },
["string"] = {
pattern = "(.+)",
cast = unquote,
-- Read-ahead function to generate a fixed-width pattern
-- to match the next (possibly quoted) string
transform = function(match)
local quoted = false
local start = true
local escaped = false
local count = 0
local skip = 0
for c in match:gmatch(".") do
count = count + 1
if c == "\"" then
if start then
start = false
quoted = true
elseif not escaped then
if quoted then
-- End-quote; end of string
break
else
-- Formatting error: non-escaped quote after string start: `foo"bar`
-- We attempt to resolve this by halting parsing and skipping the
-- out-of-context quotation
count = count - 1
skip = skip + 1
break
end
end
elseif c == " " then
if not start and not quoted then
-- Un-escaped space; end of string
-- skip the space itself
count = count - 1
break
end
else start = false end
if c == "\\" then
escaped = not escaped
else escaped = false end
end
-- Strings without an ending quote will simply take up the remainder of
-- the request, causing the following arguments to be overwritten. This
-- is intended to ensure we can save valid options after stripping out
-- the errornous quotes and using defaults for the subsequent arguments.
local ret = { "(" }
for t = 1, count do
ret[1 + t] = "."
end
ret[2 + count] = ")"
for t = 1, skip do
ret[2 + count + t] = "."
end
return table.concat(ret, "")
end,
serialize = function(x)
return string.format("\"%s\"", x)
end
}
}
config.path_concat = path_concat
config.path_split = path_split
local function create_pf(arr, mode, silent)
local parts = {}
local function errfmt(err)
return string.format("Failed to create '%s' in '%s': %s",
path_concat(parts, "/"), path_concat(arr, "/"), err)
end
for i, v in ipairs(arr) do
parts[#parts + 1] = v
local failret = false
if silent then failret = #parts == #arr end
local path = path_concat(parts, "/")
local m = (i == #arr and mode or "directory")
local attr, err = lfs.attributes(path, "mode")
if attr == nil then
local ret, err = switch {
file = function()
local ret, err = lfs.touch(path)
if not ret then return false, errfmt(err) end
end,
directory = function()
local ret, err = lfs.mkdir(path)
if not ret then return false, errfmt(err) end
end,
}(m)
if ret == false then return ret, err end
elseif attr ~= m then
if not (silent and #parts == #arr) then
return false, string.format("'%s' is not a %s", path, m)
else
return true
end
end
end
return true
end
local function create_p(path, ...) create_pf(path_split(path, "/"), ...) end
local function unwrap(ret, err)
if ret == nil or ret == false then
glava.fail(err)
else return ret end
end
function config.Profile:__call(args)
local self = { name = args.name or ".." }
self:rebuild()
return setmetatable(self, config.Profile)
end
function config.Profile:rename(new)
error("not implemented")
end
function config.Profile:get_path()
return path_concat(glava.config_path, config.PROFILES_DIR, self.name)
end
function config.Profile:rebuild()
self.store = {}
self.path = path_concat(glava.config_path, config.PROFILES_DIR, self.name)
unwrap(create_p(self.path, "directory", true))
local unbuilt = {}
for k, _ in pairs(mappings) do
unbuilt[k] = true
end
for file in lfs.dir(self.path) do
if file ~= "." and file ~= ".." and mappings[file] ~= nil then
self:rebuild_file(file, path_concat(path, file))
unbuilt[file] = nil
end
end
for file, _ in pairs(unbuilt) do
self:rebuild_file(file, path_concat(path, file), true)
end
end
function config.Profile:rebuild_file(file, path, phony)
local fstore = {}
local fmap = mappings[file]
self.store[file] = fstore
for k, _ in pairs(fmap) do
if type(k) == "string" and k ~= "name" then
unbuilt[k] = true
end
end
function parse_line(line, idx, key, default)
local map = fmap[key]
if map == nil then return end
local tt = type(map.field_type) == "table" and map.field_type or { map.field_type }
local _,e = string.find(line, MATCH_DATA_PREFIX)
local at = string.sub(line, 1, e)
if default == nil or fstore[key] == nil then
fstore[key] = {}
end
if default == nil then fstore[key].line = idx end
for t, v in ipairs(tt) do
local r, i, match = string.find(at, "%s*" .. MATCH_TYPES[v].pattern)
if r ~= nil then
-- Handle read-ahead pattern transforms
if MATCH_TYPES[v].transform ~= nil then
_, i, match = string.find(at, "%s*" .. MATCH_TYPES[v].transform(match))
end
if default == nil or fstore[key][t] == nil then
fstore[key][t] = MATCH_TYPES[v].cast and MATCH_TYPES[v].cast(match) or match
end
at = string.sub(at, 1, i)
else break end
end
end
local idx = 1
if phony ~= true then
for line in io.lines(path) do
local mtype, arg = string.match(line, MATCH_ENTRY_PATTERN)
if mtype ~= nil then
parse_line(line, idx, string.format("%s:%s", mtype, arg))
end
idx = idx + 1
end
end
idx = 1
for line in io.lines(path_concat(glava.system_shader_path, file)) do
local mtype, arg = string.match(line, MATCH_ENTRY_PATTERN)
if mtype ~= nil then
parse_line(line, idx, string.format("%s:%s", mtype, arg), true)
end
idx = idx + 1
end
end
-- Sync all
function config.Profile:sync()
for k, v in pairs(self.store) do self:sync_file(k) end
end
-- Sync filename relative to profile root
function config.Profile:sync_file(fname)
local fstore = self.store[fname]
local fmap = mappings[file]
local fpath = path_concat(self.path, fname)
local buf = {}
local extra = {}
local idx = 1
for k, v in fstore do
local parts = { string.format("#%s", string.gsub(k, ":", " ")) }
local field = fmap[k].field_type
for i, e in ipairs(type(field) == "table" and field or { field }) do
parts[#parts + 1] = MATCH_TYPES[e].serialize and MATCH_TYPES[e].serialize(v[i]) or v[i]
end
local serialized = table.concat(parts, " ")
if v.line then buf[line] = serialized
else extra[#extra + 1] = serialized end
end
if lfs.attributes(fpath, "mode") == "file" then
for line in io.lines(path) do
if not buf[idx] then
buf[idx] = line
end
idx = idx + 1
end
for _, v in ipairs(extra) do
buf[#buf + 1] = v
end
end
local handle, err = io.open(fpath, "w+")
if handle then
handle:write(table.concat(buf, "\n"))
handle:close()
else
glava.fail(string.format("Could not open file handle to \"%s\": %s", handle, err))
end
end
return config