Skip to content

Instantly share code, notes, and snippets.

@mejedi
Last active October 7, 2015 18:37
Show Gist options
  • Save mejedi/a61c4e2a0140bf1f0401 to your computer and use it in GitHub Desktop.
Save mejedi/a61c4e2a0140bf1f0401 to your computer and use it in GitHub Desktop.
RFC: Cfg module
cfg = require('config').new()
cfg.reg('foo', 42)
cfg.reg('bar', 'example.org')
cfg.reg('bazz')
cfg.load{}
print('Adding listener (monitors foo and bar)')
cfg.add_listener(function (cfg_data)
print('<...> foo: ' .. tostring(cfg_data.foo) .. ', bar: ' .. tostring(cfg_data.bar))
end)
print('Updating foo')
cfg.update{foo = 0}
print('Touching foo, value not changed')
cfg.update{foo = 0}
print('Updating bar')
cfg.update{bar = 'example.org:8080'}
print('Touching bar, value not changed')
cfg.update{bar = 'example.org:8080'}
print('Updating unrelated key')
cfg.update{bazz = 123456}
print('Loading default config')
cfg.load{}
Adding listener (monitors foo and bar)
<...> foo: 42, bar: example.org
Updating foo
<...> foo: 0, bar: example.org
Touching foo, value not changed
Updating bar
<...> foo: 0, bar: example.org:8080
Touching bar, value not changed
Updating unrelated key
Loading default config
<...> foo: 42, bar: example.org
cfg = require('config').new()
cfg.reg('listen', nil, 'string, number')
cfg.reg('work_dir', '.', 'string')
cfg.reg('sophia.memory_limit', 0, 'number')
print('Adding listener. It runs now for the first time')
cfg.add_listener(function (cfg_data)
print('<...> listen: ' .. tostring(cfg_data.listen))
end)
print('Loading default config')
cfg.load()
print('Current sophia.memory_limit: ' .. tostring(cfg.data.sophia.memory_limit))
print('Current work_dir: ' .. tostring(cfg.get('work_dir')))
print('Updating work dir (/tmp)')
cfg.update{work_dir = '/tmp'}
print('Updated work_dir: ' .. tostring(cfg.get('work_dir')))
print('Notice: no message about listen changing')
print('Now updating listen (example.org). This will trigger the listener')
cfg.update{listen = 'example.org'}
print('Setting listen to the same value (example.org)')
cfg.update{listen = 'example.org'}
print('Notice: no message about listen changing')
Adding listener. It runs now for the first time
<...> listen: nil
Loading default config
Current sophia.memory_limit: 0
Current work_dir: .
Updating work dir (/tmp)
Updated work_dir: /tmp
Notice: no message about listen changing
Now updating listen (example.org). This will trigger the listener
<...> listen: example.org
Setting listen to the same value (example.org)
Notice: no message about listen changing
local config_module = {}
-- '<opt_tag>a.b.c.d' -> '<opt_tag>', 'a.b.c.d'
local function extract_tag(path)
local tag = nil
path = string.gsub(path, '^(<[^>]+>)',
function (match) tag = match; return '' end)
return tag, path
end
-- create_schema_node() ->
-- true, node if a new node was created
-- false, node if returning existing node
local function create_schema_node(schema, path)
local node = nil
local did_create = false
local ns = schema
local part
for part in string.gmatch(path, '[^.]+') do
if ns then
node = ns[part]
else
-- create 'nested' in parent
ns = {}
node.nested = ns
node = nil
end
if node then
ns = node.nested
else
did_create = true
node = {}
ns[part] = node
ns = nil -- defer 'nested' creation
end
end
return did_create, node
end
-- equality check (builtin == performs shallow compare)
local function deep_equal(a, b)
if type(a) ~= 'table' then
return a == b
elseif type(b) ~= 'table' then
return false
else
local k, v, _
for k, v in pairs(a) do
if not deep_equal(v, b[k]) then
return false
end
end
for k, _ in pairs(b) do
if not a[k] then
return false
end
end
return true
end
end
-- Validates new_data according to the schema;
-- augments the missing keys from old_data and defaults;
-- complains if a frozen node changes;
-- if a changed node has listeners adds them to scheduled_listeners (set);
-- returns
-- true, result if the produced result is different from ref_data
-- false, result otherwise
local function render_updated_data(node, frozen_tags,
new_data, old_data, ref_data,
scheduled_listeners)
local did_change = false
local result = old_data or node.default
if not new_data and result then
-- fixme: this won't augment old_data with default keys,
-- can happen if new keys with defaults were registered
-- since last load
if result ~= ref_data then
did_change = not deep_equal(result, ref_data)
end
else
local nested = node.nested
if not nested or new_data and type(new_data) ~= 'table' then
-- unable to perform a merge reasonably
result = new_data
did_change = (new_data ~= ref_data and
not deep_equal(new_data, ref_data))
else
-- check keys and merge
result = {}
new_data = new_data or {}
local safe_old_data = (
type(old_data) == 'table' and old_data or {})
local safe_ref_data = (
type(ref_data) == 'table' and ref_data or {})
local k, nested_node, _
for k, nested_node in pairs(nested) do
local nested_did_change
nested_did_change, result[k] = render_updated_data(
nested_node, frozen_tags,
new_data[k], safe_old_data[k], safe_ref_data[k],
scheduled_listeners)
did_change = did_change or nested_did_change
end
for k, _ in pairs(new_data) do
if not nested[k] then
error('Validation failed: Unknown key: ' .. k)
end
end
end
end
local custom_filter = node.custom_filter
if custom_filter then
local custom_filter_res = custom_filter(result, old_data)
if custom_filter_res and custom_filter_res ~= result then
-- arbitrary changes could've happened
did_change = not deep_equal(result, old_data)
end
end
local valid_types = node.valid_types
if valid_types and result and not valid_types[type(result)] then
error('Validation failed: Wrong type')
end
if did_change then
if (node.frozen or frozen_tags[node.tag]) then
error('Validation failed: Attempted to change a frozen key')
end
local listeners = node.listeners
if listeners then
local listener, _
for listener, _ in pairs(listeners) do
scheduled_listeners[listener] = true
end
end
end
return did_change, result
end
-- Invokes a listener;
-- builds a set of nodes accessed during execution (subscriptions);
-- updates the schema removing the listener from nodes that are no longer relevant
-- and installing it in relevant ones;
-- returns the updated subscriptions set.
local function invoke_listener(listener, schema_root, data, cur_subscriptions)
local upd_subscriptions = {}
local function wrap(schema_node, data)
local m = {}
function m.__index(_, key)
local v = data[key]
local ns = schema_node.nested
local next_schema_node = ns and ns[key]
if type(v) == 'table' and next_schema_node then
return wrap(next_schema_node, v)
end
upd_subscriptions[next_schema_node or schema_node] = true
return v
end
function m.__tostring(_)
upd_subscriptions[schema_node] = true
return tostring(data)
end
function m.__pairs(_)
upd_subscriptions[schema_node] = true
return pairs(data)
end
function m.__ipairs(_)
upd_subscriptions[schema_node] = true
return ipairs(data)
end
function m.__len()
upd_subscriptions[schema_node] = true
return #data
end
-- todo: other metamethods?
local o = {}
setmetatable(o, m)
return o
end
pcall(listener, type(data) == 'table' and wrap(schema_root, data) or data)
local node, _
for node, _ in pairs(cur_subscriptions) do
if not upd_subscriptions[node] then
-- remove listener
local listeners = node.listeners
if listeners then
listeners[listener] = nil
end
end
end
for node, _ in pairs(upd_subscriptions) do
if not cur_subscriptions[node] then
-- install listener
local listeners = node.listeners
if not listeners then
listeners = {}
node.listeners = listeners
end
listeners[listener] = true
end
end
return upd_subscriptions
end
function config_module.new()
local cfg = {}
local data = {}
local schema = {}
local schema_root = {nested = schema}
local frozen_tags = {}
-- listener -> list of affecting schema nodes
local subscriptions = {}
cfg.data = data
function cfg._schema()
return schema
end
function cfg.get(name)
return data[name]
end
function cfg.reg(name, default, valid_types, custom_filter)
local tag, path = extract_tag(name)
if path == '' then
error('Bad name: ' .. name)
end
local did_create, node = create_schema_node(schema, path)
if not did_create and node.is_explicit then
error('Name already defined: ' .. name)
end
node.is_explicit = true
if tag then
node.tag = tag
end
if default then
node.default = default
end
if custom_filter then
node.custom_filter = custom_filter
end
if valid_types then
local types = {}
local type_name
valid_types = string.gsub(valid_types, ' ', '')
for type_name in string.gmatch(valid_types, '[^,]+') do
types[type_name] = true
end
if #types and not types.any then
node.valid_types = types
end
end
end
local function do_load_or_update(new_data, old_data)
local scheduled_listeners = {}
local _, result = render_updated_data(
schema_root, frozen_tags, new_data, old_data, data,
scheduled_listeners)
data = result
cfg.data = data
local listener, _
for listener, _ in pairs(scheduled_listeners) do
subscriptions[listener] = invoke_listener(
listener, schema_root, data, subscriptions[listener])
end
end
function cfg.load_file(path)
error('not implemented')
end
function cfg.load(lua_table)
do_load_or_update(lua_table, nil)
end
function cfg.update(lua_table)
do_load_or_update(lua_table, data)
end
function cfg.freeze(name)
local tag, path = extract_tag(name)
if tag then
if path ~= '' then
error('Both a tag and a name provided: ' .. name)
end
frozen_tags[tag] = true
end
if path == '' then
error('Expecting a tag or a name')
end
local _, node = create_schema_node(schema, path)
node.frozen = true
end
function cfg.add_listener(listener)
local exists = subscriptions[listener]
if exists then
return
end
subscriptions[listener] = invoke_listener(
listener, schema_root, data, {})
end
return cfg
end
return config_module
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment