The CFG API for Lua

The CFG reference implementation for the Lua language requires a Lua version of 5.3 or later. This documentation assumes that you will be using the LuaRocks package manager to work with packages such as this (using the name cfg-lib).

Installation

You can install the library for use by adding cfg_lib to your list of dependencies in your LuaRocks rockspec file:

dependencies = {
    'lua >= 5.3',
    'cfg-lib >= 0.1.0'
}

There’s a minimal example of a program that uses CFG here.

Exploration

You can use the Lua interpreter’s Read-Eval-Print-Loop (REPL) functionality, invoked by running the interpreter with no arguments. You can run luarocks install cfg-lib to install the latest version of this package for your exploration.

Getting Started with CFG in Lua

A configuration is accessed through the Config class. This has methods which can be passed a filename or a string which contains the text for the configuration. The text is read in, parsed and converted to an object that you can then query. A simple example:

test0.cfg
a: 'Hello, '
b: 'world!'
c: {
  d: 'e'
}
'f.g': 'h'
christmas_morning: `2019-12-25 08:39:49`
home: `$HOME`
foo: `$FOO|bar`

Loading a configuration

The configuration above can be loaded as shown below:

config = require('config')  -- this line will be inferred in the snippets below
cfg = config.Config:from_file('test0.cfg')

The configuration is now available through the object referenced by cfg.

Access elements with keys

Accessing elements of the configuration with a simple key is just like using a Lua table:

print(string.format('a   is "%s"', cfg.a))
print(string.format('b   is "%s"', cfg.b))

which prints:

a   is "Hello, "
b   is "world!"

As an alternative to using attribute notation such as cfg.a, you can also use indexing notation such as cfg['a']. The attribute notation can only be used if the key is a valid identifier.

Access elements with paths

As well as simple keys, elements can also be accessed using path strings:

print(string.format('c.d is "%s"', cfg['c.d']))

which prints:

c.d is "e"

Here, the desired value is obtained in a single step, by (under the hood) walking the path c.d – first getting the mapping at key c, and then the value at d in the resulting mapping.

Note that you can have simple keys which look like paths:

print(string.format('f.g is "%s"', cfg['f.g']))

which prints:

f.g is "h"

Note that in the first case, you could access the value "e" using cfg.c.d, but you can’t access the value "h"``using attribute notation ``cfg.f.g – you must use the indexing notation. That’s because cfg.f isn’t a thing in the configuration.

If a key is given that exists in the configuration, it is used as such, and if it is not present in the configuration, an attempt is made to interpret it as a path. Thus, f.g is present and accessed via key, whereas c.d is not an existing key, so is interpreted as a path.

Access to date/time objects

You can also get date/time objects from a configuration, by using an ISO date/time pattern in a backtick-string:

print(string.format('Christmas morning is %s', cfg.christmas_morning))

which prints:

Christmas morning is 2019-12-25 08:39:49

The returned date is a Lua table with the following keys:

  • year (integer)

  • month (integer)

  • day (integer)

  • hour (integer)

  • minute (integer)

  • second (integer or float)

  • offset_sign (defaults to "+")

  • offset_hour (integer)

  • offset_minute (integer)

Access to environment variables

To access an environment variable, use a backtick-string of the form `$VARNAME`:

print(cfg['home'] == os.getenv('HOME'))

which prints:

true

You can specify a default value to be used if an environment variable isn’t present using the `$VARNAME|default-value` form. Whatever string follows the pipe character (including the empty string) is returned if VARNAME is not a variable in the environment.

print(string.format('foo is "%s"', cfg.foo))

which prints:

foo is "bar"

Access to computed values

Sometimes, it’s useful to have values computed declaratively in the configuration, rather than imperatively in the code that processes the configuration. For example, an overall time period may be specified and other configuration values are fractions thereof. It may also be desirable to perform other simple calculations declaratively, e.g. concatenation of numerous file names to a base directory to get a final pathname.

test0a.cfg
total_period : 100
header_time: 0.3 * ${total_period}
steady_time: 0.5 * ${total_period}
trailer_time: 0.2 * ${total_period}
base_prefix: '/my/app/'
log_file: ${base_prefix} + 'test.log'

When this file is read in, the computed values can be accessed directly.

cfg = config.Config:from_file('test0a.cfg')

for _, key in ipairs({'header_time', 'steady_time', 'trailer_time', 'log_file'}) do
    print(string.format('%s is %s', key, cfg[key]))
end

which prints:

header_time is 30.0
steady_time is 50.0
trailer_time is 20.0
log_file is /my/app/test.log

Including one configuration inside another

There are times when it’s useful to include one configuration inside another. For example, consider the following configuration files:

logging.cfg
layouts: {
  brief: {
    pattern: '%d [%t] %p %c - %m%n'
  }
},
appenders: {
  file: {
    level: 'INFO',
    layout: 'brief',
    filename: 'run/server.log',
    append: false,
    charset: 'UTF-8'
  },
  error: {
    level: 'ERROR',
    layout: 'brief',
    filename: 'run/server-errors.log',
    append: false,
    charset: 'UTF-8'
  },
  debug: {
    level: 'DEBUG',
    layout: 'brief',
    filename: 'run/server-debug.log',
    append: false,
    charset: 'UTF-8'
  }
},
loggers: {
  mylib: {
    level: 'INFO'
  }
  'mylib.detail': {
    level: 'DEBUG'
  }
},
root: {
  handlers: ['file', 'error', 'debug'],
  level: 'WARNING'
}
redirects.cfg
cookies: {
  url: 'http://www.allaboutcookies.org/',
  permanent: false
},
freeotp: {
  url: 'https://freeotp.github.io/',
  permanent: false
},
'google-auth': {
  url: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2',
  permanent: false
}
main.cfg
secret: 'some random application secret',
port: 8000,
sitename: 'My Test Site',
default_access: 'public',
ignore_trailing_slashes: true,
site_options: {
  want_ipinfo: false,
  show_form: true,
  cookie_bar: true
},
debug: true,
captcha_length: 4,
captcha_timeout: 5,
session_timeout: 7 * 24 * 60 * 60,  # 7 days in seconds
redirects: @'redirects.cfg',
email: {
  sender: 'no-reply@my-domain.com',
  host: 'smtp.my-domain.com:587',
  user: 'smtp-user',
  password: 'smtp-pwd'
}
logging: @'logging.cfg'

The main.cfg contents have been kept to the highest-level values, within logging and redirection configuration relegated to other files logging.cfg and redirects.cfg which are then included in main.cfg. This allows the high-level configuration to be more readable at a glance, and even allows the separate configuration files to be e.g. maintained by different people.

The contents of the sub-configurations are easily accessible from the main configuration just as if they had been defined in the same file:

cfg = config.Config:from_file('main.cfg')

for _, key in ipairs({'logging.layouts.brief.pattern', 'redirects.freeotp.url', 'redirects.freeotp.permanent'}) do
    print(string.format('%s is %s', key, cfg[key]))
end

which prints:

logging.layouts.brief.pattern is %d [%t] %p %c - %m%n
redirects.freeotp.url is https://freeotp.github.io/
redirects.freeotp.permanent is false

Avoiding unnecessary repetition

Don’t Repeat Yourself (DRY) is a useful principle to follow. CFG can help with this. You may have noticed that the logging.cfg file above has some repetitive elements:

logging.cfg (partial)
appenders: {
  file: {
    level: 'INFO',
    layout: 'brief',
    filename: 'run/server.log',
    append: true,
    charset: 'UTF-8'
  },
  error: {
    level: 'ERROR',
    layout: 'brief',
    filename: 'run/server-errors.log',
    append: false,
    charset: 'UTF-8'
  },
  debug: {
    level: 'DEBUG',
    layout: 'brief',
    filename: 'run/server-debug.log',
    append: false,
    charset: 'UTF-8'
  }
},

This portion could be rewritten as:

logging.cfg (partial)
defs: {
  base_appender: {
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
  }
},
appenders: {
  file: ${defs.base_appender} + {
    level: 'INFO',
    filename: 'run/server.log',
    append: true,
  },
  error: ${defs.base_appender} + {
    level: 'ERROR',
    filename: 'run/server-errors.log',
  },
  debug: ${defs.base_appender} + {
    level: 'DEBUG',
    filename: 'run/server-debug.log',
  }
},

where the common elements are separated out and just referenced where they are needed. We find it useful to put all things which will be reused like this in one place in the condiguration, so we always know where to go to make changes. The key used is conventionally defs or base, though it can be anything you like.

Access is just as before, and provides the same results:

cfg = config.Config:from_file('main.cfg')

for _, key in ipairs({'logging.appenders.file.append', 'logging.appenders.file.filename', 'logging.appenders.file.level',
                      'logging.appenders.debug.level', 'logging.appenders.debug.filename',
                      'logging.appenders.debug.append'}) do
    print(string.format('%s is %s', key, cfg[key]))
end

which prints:

logging.appenders.file.append is false
logging.appenders.file.filename is run/server.log
logging.appenders.file.level is INFO
logging.appenders.debug.level is DEBUG
logging.appenders.debug.filename is run/server-debug.log
logging.appenders.debug.append is false

The definition of logging.appenders.file as ${defs.base_appender} + { level: 'INFO', filename: 'run/server.log', append: true } has resulted in an evaluation which first fetches the defs.base_appender value, which is a mapping, and “adds” to that the literal mapping which defines the level, filename and append keys. The + operation for mappings is implemented as a copy of the left-hand side merged with the right-hand side. Note that the append value for logging.appenders.file is overridden by the right-hand side to true, whereas that for e.g. logging.appenders.error is unchanged as false.

We could do some further refinement by factoring out the common location for the log files:

logging.cfg (partial)
defs: {
  base_appender: {
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
  }
  log_prefix: 'run/',
},
appenders: {
  file: ${defs.base_appender} + {
    level: 'INFO',
    filename: ${defs.log_prefix} + 'server.log',
    append: true,
  },
  error: ${defs.base_appender} + {
    level: 'ERROR',
    filename: ${defs.log_prefix} + 'server-errors.log',
  },
  debug: ${defs.base_appender} + {
    level: 'DEBUG',
    filename: ${defs.log_prefix} + 'server-debug.log',
  }
}

with the same result as before. It is slightly more verbose than before, but the location of all files can be changed in just one place now, as opposed to three, as it was before.

Classes

Classes are just Lua tables with metatables that determine their behaviour.

The Location class

This represents a source location and has two integer attributes, line and column. The line must be positive and the column non-negative (newlines have an ending column of zero, as column 1 would have the first character of the next line).

The ConfigError class

This has an error message in the message attribute and a Location in the pos attribute indicating the position in the source of the error, where appropriate. (Some errors don’t have a location, and in such cases the location attribute of the instance will be set to nil.)

The Config class

This class implements access to a CFG configuration. You’ll generally interface to CFG files using this class. When you pass in a string or file path to a constructor the CFG source in the string or file is parsed and converted into an internal form which can then be accessed in a manner analogous to a standard Lua table.

class Config

Attributes

path

The path to the file from which the configuration has been read. You won’t usually need to set this, unless you want to load a configuration from text that references included files. In that case, set the path to a value whose parent directory contains the included files, or specify relevant directories using include_path. When a configuration is loaded from a file, this attribute is automatically set.

include_path

A list of directories which is searched for included sub-configurations if the parent directory of path (or the current directory, if path isn’t set) doesn’t contain them.

context

A mapping of strings to objects which is used whenever an identifier is encountered when evaluating an expression in a configuration file.

no_duplicates = true

Whether keys are allowed to be duplicated in mappings. Usually, a ConfigError is raised if a duplicate key is seen. If false, then if a duplicate key is seen, its value silently replaces the value associated with the earlier appearance of the same key.

strict_conversions = true

If true, a ConfigEerror is raised if a string can’t be converted. It’s intended to help catch typos in backtick-strings.

Methods

new()

Returns a new instance.

from_source(source)

Returns a new instance initialized with a configuration provided in source.

from_file(filepath)

Returns a new instance initialized with a configuration provided in the file specified by filepath.

load_file(filepath)

Overwrite any configuration with a configuration provided in the file specified by filepath.

as_dict()

Return’s this instance’s data as a standard Lua table. Amy expressions in the configuration are evaluated, so that the contents are standard Lua values.

get(key, default_value)

Retrieve a value from the instance whose key or path is represented by key. If the key or path is absent from the configuration and default_value is provided, it is returned. Otherwise, a ConfigError is raised.

operator [](key)

Retrieve a value from the instance whose key or path is represented by key, using notation like cfg[key]. If the key or path is absent from the configuration, a ConfigError is raised.