-- lib/clod.lua
--
-- Configuration Language Organised (by) Dots
--
-- Copyright 2012 Daniel Silverstone <dsilvers@digital-scurf.org>
--

---
-- 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,
}
