The CFG API for Nim

The CFG reference implementation for the Nim language assumes an Nim version of 1.6.0 or later.

Installation

You can install the library for use by adding config to your list of dependencies in your project.nimble file:

requires "config >= 0.1.1"

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

Exploration

To explore CFG functionality for Nim, we use the inim program (from here), which is a Read-Eval-Print-Loop (REPL). You can install it using

$ nimble install inim

and then just run inim in the shell to start the REPL.

Getting Started with CFG in Nim

A configuration is accessed through the Config type. This has functions 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. In the REPL shell:

nim> import config
var cfg = fromFile("test0.cfg")

Access elements with keys

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

nim> echo cfg["a"]
(kind: StringValue, stringValue: "Hello, ")
nim> echo cfg["b"]
(kind: StringValue, stringValue: "world!")

As Nim is strongly and statically typed, configuration values are returned in a tagged variant of type ConfigValue, with the kind field as the discriminant.

Access elements with paths

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

nim> echo cfg["c.d"]
(kind: StringValue, stringValue: "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:

nim> echo cfg["f.g"]
(kind: StringValue, stringValue: "h")

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 native Nim date/time objects from a configuration, by using an ISO date/time pattern in a backtick-string:

nim> echo cfg["christmas_morning"]
(kind: DateTimeValue, dateTimeValue: (nanosecond: 0, second: 49, minute: 39, hour: 8, monthdayZero: 25, monthZero: 12, year: 2019, weekday: Wednesday, yearday: 358, isDst: false, timezone: ..., utcOffset: 0))

Access to environment variables

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

nim> import os
nim> echo cfg["home"].stringValue == getEnv("HOME")
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.

nim> echo cfg["foo"]
(kind: StringValue, stringValue: "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:

nim> cfg = fromFile("test0a.cfg")
nim> echo cfg["header_time"]
(kind: FloatValue, floatValue: 30.0)
nim> echo cfg["steady_time"]
(kind: FloatValue, floatValue: 50.0)
nim> echo cfg["trailer_time"]
(kind: FloatValue, floatValue: 20.0)
nim> echo cfg["log_file"]
(kind: StringValue, stringValue: "/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:

nim> cfg = fromFile("main.cfg")
nim> echo cfg["logging.layouts.brief.pattern"]
(kind: StringValue, stringValue: "%d [%t] %p %c - %m%n")
nim> echo cfg["redirects.freeotp.url"]
(kind: StringValue, stringValue: "https://freeotp.github.io/")
nim> echo cfg["redirects.freeotp.permanent"]
(kind: BoolValue, boolValue: 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:

nim> echo cfg["logging.appenders.file.append"]
(kind: BoolValue, boolValue: false)
nim> echo cfg["logging.appenders.file.filename"]
(kind: StringValue, stringValue: "run/server.log")
nim> echo cfg["logging.appenders.file.level"]
(kind: StringValue, stringValue: "INFO")
nim> echo cfg["logging.appenders.debug.level"]
(kind: StringValue, stringValue: "DEBUG")
nim> echo cfg["logging.appenders.debug.filename"]
(kind: StringValue, stringValue: "run/server-debug.log")
nim> echo cfg["logging.appenders.debug.append"]
(kind: BoolValue, boolValue: 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.

Types

The main objects used to interface with CFG configurations are Config and ConfigValue. The former represents an entire CFG configuration; the latter represents a value read from it. The ConfigValue is a tagged variant structure using field kind, of type ValueKind, as the discriminant. You may also have to deal with ConfigError, an exception which can be raised due to errors in configurations, and Location, which relates to a location in CFG source.

ValueKind (enum)

This represents the kinds of values that can be returned from a configuration. It has the following members:

Name Significance Corresponding field in ConfigValue
IntegerValue A 64-bit signed integer value intValue
FloatValue A 64-bit floating-point value floatValue
StringValue A string value stringValue
BoolValue A Boolean value boolValue
ComplexValue A 64-bit complex number complexValue
NoneValue A null value  
DateTimeValue A date/time value with timezone offset dateTimeValue
ListValue A list of values listValue
MappingValue A mapping of strings to values mappingValue
NestedConfigValue A nested configuration (sub-configuration) configValue

ConfigValue (object)

This represents values that can be returned from a configuration. It has the following properties (note that kind is always available, and then just one of the others depending on the value of kind):

Name Type Access
kind ValueKind RW
intValue int64 RW
floatValue float64 RW
stringValue string RW
boolValue bool RW
complexValue Complex64 RW
dateTimeValue DateTime RW
listValue seq[ConfigValue] RW
mappingValue Table[string, ConfigValue] RW
configValue Config RW

Location (object)

This represents a location in CFG source. It has the following properties:

Name Type Access
line int RW
column int RW

ConfigError (object)

This represents an error encountered when working with CFG. It has the following properties:

Name Type Access
msg string RW
location Location RW

Config (ref object)

This represents a CFG configuration. It has the following properties:

Name Type Default value Access Purpose
noDuplicates bool true RW Whether duplicate keys are allowed.
strictConversions bool true RW Whether exceptions are raised if a conversion fails.
path string   RW Path from which a configuration was loaded. This is set automatically when a configuration is read from a file, and only needs to be called if initializing the configuration from source and you need to specify a location for included configurations which are in files.
includePath seq[string] @[] RW Directories to search for included configurations (searched after the parent directory of path, or the current directory if that isn’t set.
context Table[string, ConfigValue] (empty) RW A lookup table for any variables to be used.
cached bool false RW If true, values fetched using get() or []() are cached.

Procedures

newConfig*(): Config

Creates a new configuration object, without any actual configuration.

fromSource*(source: string): Config

Creates a new configuration object, with the configuration specified in source.

fromFile*(path: string): Config

Creates a new configuration object, with the configuration read from the file specified in path.

loadFile*(cfg: Config, string: path)

Load or reload the configuration cfg from the specified path.

load*(cfg: Config, stream: Stream)

Load or reload the configuration cfg from the specified stream.

get*(cfg: Config, key: string, default: ConfigValue): ConfigValue

Get a value from the configuration cfg using key, and returning default if not present in the configuration. The key can be a path as well as a simple key.

`[]`*(cfg: Config, key: string): ConfigValue

Get a value from the configuration cfg using the indexing idiom, with key as the index. The key can be a path as well as a simple key.

asDict*(cfg: Config): Table[string, ConfigValue]

Get the configuration cfg as a Table. This will recurse into included configurations.

getSubConfig*(cfg: Config, key: string): Config

Get an included configuration (sub-configuration) from cfg via key. This can be a path as well as a simple key.