-- lib/clod.lua -- -- Configuration Language Organised (by) Dots -- -- Copyright 2012 Daniel Silverstone -- --- -- Clod - Configuration Language Organised (by) Dots -- -- Clod is a simple key/value configuration language (where the keys form a -- heirarchy based on dotted names) whose implementation aims to provide a way -- to have a program alter a configuration file in a way identical to how a -- human might have done so. This then allows configuration files to be kept -- in revision control and have the diffs make sense. -- -- @module clod --- -- Parse clod configuration file -- -- Parsing a clod file constructs a `clodfile` instance which represents -- the content provided. All the arguments to this function only have use -- during the parse phase. If `migrate_lists` is used then any real table -- values encountered during the parse will be converted into clod list -- assignments. Otherwise an error will be raised. -- -- @function parse -- @tparam string conf The configuration file content -- @tparam string confname The name of the configuration file -- @tparam boolean migrate_lists Whether to migrate list format during parse. -- @treturn clodfile or nil -- @treturn string Error message if failed to parse provided source. local type = type local getinfo = debug.getinfo local pcall = pcall local loadstring = loadstring local tconcat = table.concat local setmetatable = setmetatable local setfenv = setfenv local pairs = pairs -- metatable for clod config operations local clod_mt = {} -- metadata for clod config instances local metadata = setmetatable({}, {__mode = "k"}) -- metatable for settings objects local settings_mt = {} -- Helper routines local function gen_settings(tab, prefix) local meta = metadata[tab] local clodconf = meta.conf or tab local newmeta = { conf = clodconf, prefix = prefix } local ret = setmetatable({}, settings_mt) metadata[ret] = newmeta return ret end --- -- Settings (for a clod file) -- -- The `settings` class encapsulates the actual values represented by a -- `clodfile` instance. Instances can have a prefix, in which case they -- represent a subset of the content of the `clodfile`. -- -- @type settings --- -- Read configuration settings out of a `clodfile` -- -- If the given key is a prefix of other keys then a new `clodfile` instance is -- returned which works with that subset of the keys in the `clodfile` indexed. -- -- @function __index -- @tparam string subkey The (sub)key to retrieve. -- @treturn string-or-number-or-boolean The value of that key (or nil if not found) function settings_mt:__index(subkey) local meta = metadata[self] local confmeta = metadata[meta.conf] local key = subkey if meta.prefix then key = meta.prefix .. "." .. subkey end if confmeta.settings[key] then return confmeta.settings[key].value end for k in pairs(confmeta.settings) do if k:sub(1,#key) == key then return gen_settings(self, key) end end end local function insert_after(entry, new_entry) -- Interject new_entry after entry if entry.next then entry.next.prev = new_entry end new_entry.next = entry.next new_entry.prev = entry entry.next = new_entry -- Now sort the line numbers out local lineno = entry.lineno while entry do entry.lineno = lineno lineno = lineno + 1 entry = entry.next end end local function delete_entry(entry) if entry.prev then entry.prev.next = entry.next end if entry.next then entry.next.prev = entry.prev end -- Shuffle all lines from here on down away entry = entry.next while entry do entry.lineno = entry.lineno - 1 entry = entry.next end end local function has_key(confmeta, key) return confmeta.settings[key] ~= nil end local function calculate_wild_key(confmeta, prefix) local keyno = 1 local keystr = ("%si_%s"):format(prefix, keyno) while has_key(confmeta, keystr) do keyno = keyno + 1 keystr = ("%si_%s"):format(prefix, keyno) end return keystr end --- -- Set / Change / Unset an entry in a `clodfile` -- -- This function sets, changes or removes an entry from a `clodfile`. in doing -- this, the `clodfile` attempts to make edits which a human might match. It -- attempts to place new values near similarly named old values and when -- removing values it tries to clean up whitespace etc appropriately. -- -- @function __newindex -- @tparam string subkey The (sub)key to set to the given value -- @tparam string-or-number-or-boolean value The value to set function settings_mt:__newindex(subkey, value) local meta = metadata[self] local confmeta = metadata[meta.conf] local key = subkey if type(value) == "table" or type(value) == "function" then error("Clod does not support " .. type(value) .. "s as values") end if meta.prefix then key = meta.prefix .. "." .. subkey end local wild_prefix, last_key_element = key:match("^(.-)([^.]+)$") if last_key_element == "*" then -- Wild insert, so calculate a unique key to use key = calculate_wild_key(confmeta, wild_prefix) end if value == nil then -- removing an entry... if confmeta.settings[key] then -- Need to remove *this* entry local entry = confmeta.settings[key] local prev = entry.prev local next = entry.next delete_entry(entry) if prev and next then -- Also delete 'next' if prev is also blank if not prev.key and not next.key then delete_entry(next) end elseif prev and not next then -- Also delete prev, if it's not the zeroth sentinel -- and it's blank, since we've removed the last line if not prev.key and prev.lineno > 0 then delete_entry(prev) end end end elseif confmeta.settings[key] then -- Replacing extant entry confmeta.settings[key].value = value else -- Inventing a new entry, let's try and find a good -- spot for it. -- -- Search the list, looking for the longest common prefix. -- Place the new element at the end of any section of that -- longest common prefix, or else at the end. -- If placing at the end, insert a blank line if necessary -- to separate it from something without a common prefix. local longest_prefix = 0 local longest_prefix_found_at = nil local entry = confmeta.entries while entry do if entry.key then local maxpos = 0 for i = (#key < #entry.key and #key or #entry.key), 1, -1 do if key:sub(1,i) == entry.key:sub(1,i) then if key:sub(1,i):find("%.") then maxpos = i break end end end if maxpos > longest_prefix then longest_prefix = maxpos longest_prefix_found_at = entry end end entry = entry.next end local insert_blank = false if longest_prefix == 0 then local last = confmeta.entries while last.next do last = last.next end longest_prefix_found_at = last if last.key then insert_blank = true end else -- Starting at longest_prefix_found_at, iterate -- until it no longer matches the prefix local entry = longest_prefix_found_at while entry.next and (entry.next.key and (entry.next.key:sub(1, longest_prefix) == longest_prefix_found_at.key:sub(1, longest_prefix))) do entry = entry.next end longest_prefix_found_at = entry end local before = longest_prefix_found_at if insert_blank then insert_after(before, {}) before = before.next end insert_after(before, { key = key, value = value }) confmeta.settings[key] = before.next end end --- -- Clod File - Settings representation and re-serialisation -- -- These objects can be queried for values and also have values added, changed, -- and removed. Where clod gets interesting is that the `clodfile` instance -- can also be used to re-serialise the configuration file in a way which -- hopefully resembles human edits. -- -- @type clodfile -- Methods for clod instances local methods = {} --- -- Serialise a `clodfile` into a string -- -- In the reverse of `clod.parse` this method re-serialises a `clodfile` into -- a string which represents the input clod source with the various changes -- made to it. In an ideal world, the output of serialising an unmodified -- `clodfile` should be byte-for-byte the same as the input source. -- -- @function serialise -- @treturn string The serialised `clodfile` function methods:serialise() local entries = metadata[self].entries local retstr = {} local function serialise_entry(entry) local key, value, line = entry.key, entry.value, "" if key then local wild_prefix = key:match("^(.-)%.i_[0-9]+$") if wild_prefix then key = wild_prefix .. '["*"]' end local vtype = type(value) assert((vtype == "string" or vtype == "number" or vtype == "boolean"), "Unexpected " .. vtype .. " in key: " .. key) if vtype == "string" then line = ("%s %q"):format(key, value) elseif vtype == "number" then line = ("%s = %d"):format(key, value) elseif vtype == "boolean" then line = ("%s = %s"):format(key, value and "true" or "false") end end retstr[#retstr+1] = line end while entries do if entries.lineno ~= 0 then serialise_entry(entries) end entries = entries.next end serialise_entry({}) return tconcat(retstr, "\n") end --- -- Iterate the `clodfile` key/value pairs -- -- Use the `:each` method to iterate a `clodfile`. The usage form is: -- -- for key, value in clodfile:each(pfx) do -- -- Do something with key and value -- end -- -- @tparam[opt] string prefix The optional prefix to restrict iteration to. -- @treturn function The iterator function -- @treturn state The iterator function's state -- @treturn nil The nil context needed to start iteration function methods:each(prefix) if prefix then prefix = "^" .. prefix:gsub("%.", "%%.") end local function iterator(confmeta, prev_key) local next_key, next_value = next(confmeta.settings, prev_key) if prefix then while next_key and not next_key:match(prefix) do next_key, next_value = next(confmeta.settings, next_key) end end if next_key and next_value then return next_key, next_value.value end end return iterator, metadata[self], nil end --- -- Retrieve a list of values in a prefix -- -- Since clod can store lists, this function can be used to retrieve a named -- list. The values are returned in the order in which they are set into -- the list by the source which was parsed to create the `clodfile` instance. -- -- @function get_list -- @tparam string prefix The prefix (name) of the key to retrieve. -- @treturn table The ordered set of values (ordered by input file ordering) function methods:get_list(prefix) local ret = {} local map = {} for k, v in self:each(prefix) do ret[#ret+1] = k map[k] = v end table.sort(ret) for i = 1, #ret do ret[i] = map[ret[i]] end return ret end --- -- Set a list of values into a `clodfile` instance. -- -- This is a helper method for applications which manipulate lists of settings -- using clod. Writing a list this way will correctly add, change, and remove -- entries as needed. Note, that reordering a list will result in slightly -- odd deltas when reserialised. -- -- @function set_list -- @tparam string prefix The key prefix for the list -- @tparam table list The list of values to set. function methods:set_list(prefix, list) -- This algorithm isn't perfect, but it'll do in the face of -- lazy apps devs who don't look after keys/value pairs themselves local old_list = self:get_list(prefix) -- Step one is to update all extant entries for i = 1, #list do local key = ("%s.i_%d"):format(prefix, i) self.settings[key] = list[i] end -- If the new list is shorter, delete trailing entries if #list < #old_list then for i = #list + 1, #old_list do local key = ("%s.i_%d"):format(prefix, i) self.settings[key] = nil end end end --- -- Get the location of a given setting -- -- Find where a given key is defined in the clod source. Note that if -- you have added a new key, or removed an old key, since loading then -- you may not be able to retrieve data for those entries. -- -- Also, note that this function will always return the original line number -- of an entry, which is not necessarily where the entry would be in a new -- serialisation if changes have been made. -- -- @function locate -- @tparam string key The name of the key to locate -- @treturn number-or-false The line number that key is defined on, or nil. -- @treturn[opt] string The reason for the nil return function methods:locate(key) local meta = metadata[self].settings if meta[key] then if meta[key].original_lineno then return meta[key].original_lineno end return nil, "Key is new, rather than from the input" end for k in pairs(meta) do if k:sub(1,#key) == key then return nil, "Ambiguous key prefix" end end return nil, "Not found" end --- -- The settings in this `clodfile` instance. -- -- @field settings -- Metamethods for clod instances function clod_mt:__index(key) if key == "settings" then return gen_settings(self) elseif methods[key] then return methods[key] end end local function parse_config(conf, confname, migrate_lists) local ret = {} local settings = {} local last_entry = {lineno = 0} local front_entry = last_entry local keys = {} local parse_mt = {} local function gen_hook(key) local ret = setmetatable({}, parse_mt) keys[ret] = key return ret end function parse_mt:__index(key) local prefix = keys[self] if not prefix then -- This is a global indexing, so return a fresh entry return gen_hook(key) end -- A 'local' indexing, so combine with the key return gen_hook(("%s.%s"):format(prefix, key)) end function parse_mt:__newindex(key, value) -- This is the equivalent of 'foo = "bar"' instead of 'foo "bar"' if migrate_lists and type(value) == "table" then for i = 1, #value do self[key .. ".*"](value[i],1) end return end if type(value) == "table" or type(value) == "function" then error("Clod does not support " .. type(value) .. "s as values") end return self[key](value, 1) end function parse_mt:__call(value, offset) local key = assert(keys[self]) if migrate_lists and type(value) == "table" then for i = 1, #value do self["*"](value[i],2) end return end if type(value) == "table" or type(value) == "function" then error("Clod does not support " .. type(value) .. "s as values") end local wild_prefix, last_key_element = key:match("^(.-)([^.]+)$") if last_key_element == "*" then -- Wild insert, so calculate a unique key to use key = calculate_wild_key({settings=settings}, wild_prefix) end local curline = getinfo(2 + (offset or 0), "Snlf").currentline local entry = { key = key, value = value, lineno = curline, original_lineno = curline } while last_entry.lineno < (curline - 1) do local empty = { lineno = last_entry.lineno + 1, prev = last_entry } last_entry.next = empty last_entry = empty end last_entry.next = entry entry.prev = last_entry last_entry = entry settings[key] = entry end local func, msg local sourcename = ("@%s"):format(confname or "clod-config") local globs = setmetatable({}, parse_mt) if setfenv == nil then func, msg = load(conf, sourcename, "t", globs) else func, msg = loadstring(conf, sourcename) if not func then return nil, msg end setfenv(func, globs) end local ok, err = pcall(func) if not ok then return nil, err end -- Successfully loaded the settings, they're in settings and front_entry -- points to line zero which we keep, for cleanliness -- Construct a return object ready for magic local ret = setmetatable({}, clod_mt) metadata[ret] = { settings = settings, entries = front_entry, } return ret end return { parse = parse_config, }