Guide

From one key to a deployment.

This walks the library in the order a program uses it: text becomes a tree, trees are layered, values are looked up, and a section becomes a record. Every block below was compiled and run; the output under each one is what it printed.

1. Install

Add the module, then import the package that reads the format you have. The root package holds the tree, the merge and the binding; the rest are readers.

console
$ moon add moonbitstack/moonconf

Nothing here opens a file. A caller reads bytes — from disk, a URL, the environment — and hands the text in, which is why every part of this runs on every target.

moonbitmain.mbt
let tree = @moonconf.of_json(
  "{\"db\":{\"host\":\"localhost\"}}",
  source="defaults",
)
println(tree.need_text("db.host"))   // localhost
output
localhost

2. The tree

Every reader produces the same shape. A Node is a value, the name of the source it came from, and the position in that source. A value is null, a boolean, an integer, a real, text, a list, or a mapping. A mapping keeps the order its members were written in.

A path is the member names joined by dots. find answers nothing when the path is absent; need refuses.

moonbit
let defaults = @moonconf.Node::map([
  ("db", @moonconf.Node::map([
    ("host", @moonconf.Node::str("localhost", "defaults")),
    ("port", @moonconf.Node::num(5432, "defaults")),
  ], "defaults")),
], "defaults")

println(defaults.need_text("db.host"))   // localhost
println(defaults.need_int("db.port"))    // 5432
println(defaults.has("db.tls"))          // false
output
localhost
5432
false

Builders exist for each value: Node::str, Node::num, Node::of_real, Node::flag, Node::seq, Node::map. A node built by hand names its source too, so a refusal about a default still says where the default came from.

3. Reading a file

Each reader has a parse that answers the document as written, and most have a tree that answers the shared tree. Use parse when the file's own shape matters — the section order of an INI file, the lines of a .env — and tree when it is one layer of a configuration.

INI

Sections in brackets, = or : between a name and a value, continuation lines indented under the line they continue, and a DEFAULT section that every other section inherits. The reader follows the dialect everybody compares against, and every part of that dialect is a field of Knobs — see the switches.

moonbit
let doc = @ini.parse("[db]\nhost = db.internal\npool = 8\n")
let tree = doc.tree(source="app.ini")

println(tree.need_text("db.host"))      // db.internal
println(doc.get("db", "pool"))          // 8
println(tree.need("db.host").at.line)   // 2
output
db.internal
8
2

The tree carries the line each value was written on, which is what a refusal about that value will name.

Properties

The Java properties grammar: =, : and whitespace all separate, a backslash continues a line or escapes a character, and anything outside printable ASCII is written \uXXXX. load takes bytes as ISO-8859-1, one byte to one character, the way the original reader does; parse takes text.

moonbit
let doc = @props.parse("db.host=db.internal\ngreeting=hello\n")
println(doc.get("greeting").unwrap())          // hello

let bytes = @props.load(b"greeting=hello\n")   // ISO-8859-1
println(bytes.get("greeting").unwrap())        // hello
output
hello
hello

.env

An export prefix, comments, two kinds of quote, values that span lines, and references to earlier entries. A line that is not an assignment is kept in Doc::skipped rather than failing the file: one bad line should not cost a deployment its configuration.

moonbit
let env = @dotenv.load("HOST=db.internal\nDSN=postgres://${HOST}/app\n")
println(env.get("DSN").unwrap())   // postgres://db.internal/app
output
postgres://db.internal/app

HOCON

JSON with the punctuation relaxed, plus the three things that make it a configuration language: a repeated name merges its values, a ${path} is the value at that path, and include "file" asks the caller for more text. A name the document does not define is looked up in the process environment unless the caller says otherwise with plain.

moonbit
let doc = @hocon.parse(
  "db { host = localhost }\n" +
  "db.port = 5432\n" +
  "url = \"${db.host}:${db.port}\"\n",
)
println(doc.need_text("url"))   // localhost:5432
output
localhost:5432

HCL

A body of attributes and blocks, and an expression language with references, calls and operators. Expressions are evaluated as they are read, against a context the caller fills with names and functions; standard() has the string and list functions a configuration file uses. Templates, heredocs, for expressions and splats are part of the reader.

moonbit
let body = @hcl.parse(
  "server \"web\" {\n  port = 8080\n}\nname = upper(\"web\")\n",
  context=@hcl.standard(),
)
println(body.blocks[0].body.get("port").unwrap().int())   // 8080
println(body.get("name").unwrap().text())                 // WEB
output
8080
WEB

Body::tree turns a body into the shared tree: attributes under their names, blocks under theirs, so a document of blocks merges like any other.

JSON, YAML, TOML

These arrive as trees directly, from the readers of the same organisation. Use them when a source is already a serialisation rather than a configuration dialect.

moonbit
let from_json = @moonconf.of_json("{\"a\":1}")
println(from_json.need_int("a"))   // 1
output
1

4. Expansion

A .env file and an INI value use the shell's parameter expansion. It also stands alone, for values written as a shell would write them: ${A}, ${A:-b}, ${A-b}, ${A:=b}, ${A:?msg}, ${A:+b}, ${#A}, nested, with escapes.

moonbit
let vars = @expand.Vars::of_map(Map([("PORT", "8080")]))
println(@expand.expand("http://localhost:${PORT:-80}/", vars))
println(@expand.expand("${MISSING:-fallback}", vars))
println(@expand.expand("${#PORT}", vars))
output
http://localhost:8080/
fallback
4

What an unset variable means is a policy, named: strict refuses, lenient reads it as the empty string, deferred leaves the reference as written for a later pass. The default is to refuse, because a value that quietly becomes empty is a value nobody notices is missing.

moonbit
let vars = @expand.Vars::of_map(Map([("PORT", "8080")]))
match (try Ok(@expand.expand("${MISSING}", vars)) catch {
  e => Err(e)
}) {
  Err(e) => println(e)   // line 1: MISSING is not set
  Ok(_) => println("expanded")
}
output
line 1: MISSING is not set

5. Merging

A source is a tree, the tier it belongs to, and a name for error messages. Merge them and the tier decides which of two values wins: defaults, then the file, then the environment, then an explicit override. Mappings merge member by member. A list replaces: a list in a configuration is one decision — which hosts, which plugins — and appending two decisions gives a value nobody wrote.

moonbit
let defaults = @hocon.parse(
  "db {\n  host = localhost\n  port = 5432\n}\n",
  source="defaults.conf",
)
let file = @ini.parse("[db]\nhost = db.internal\n").tree(source="app.ini")
let env = @moonconf.Node::map([
  ("db", @moonconf.Node::map([
    ("port", @moonconf.Node::str("6432", "env")),
  ], "env")),
], "env")

let sources = [
  @moonconf.Source::new(@moonconf.File, "app.ini", file),
  @moonconf.Source::new(@moonconf.Env, "env", env),
  @moonconf.Source::new(@moonconf.Default, "defaults.conf", defaults),
]
let merged = @moonconf.merge(sources)
println(merged.need_text("db.host"))   // db.internal
println(merged.need_text("db.port"))   // 6432
output
db.internal
6432

The sources were given in any order; merge sorted them by tier. strict makes a disagreement an error, clash otherwise says what it means, and order is the tier order itself, so reversing the stack is a change of knobs rather than of code.

moonbit
let defaults = @hocon.parse(
  "db {\n  host = localhost\n  port = 5432\n}\n",
  source="defaults.conf",
)
let env = @moonconf.Node::map([
  ("db", @moonconf.Node::map([
    ("host", @moonconf.Node::str("db.internal", "env")),
  ], "env")),
], "env")
let sources = [
  @moonconf.Source::new(@moonconf.Default, "defaults.conf", defaults),
  @moonconf.Source::new(@moonconf.Env, "env", env),
]

let strict : @moonconf.Knobs = { ..@moonconf.layered, strict: true }
match (try Ok(@moonconf.merge(sources, knobs=strict)) catch { e => Err(e) }) {
  Err(e) => println(e)   // names both places
  Ok(_) => println("merged")
}

let reversed : @moonconf.Knobs = {
  ..@moonconf.layered,
  order: [@moonconf.Env, @moonconf.Default],
}
println(@moonconf.merge(sources, knobs=reversed).need_text("db.host"))
output
db.host: written twice, at defaults.conf@2:10 and env@1:1
localhost

6. Paths and refusals

A lookup has three ways to fail, and they are three different sentences: nothing is at the path, something is there but not in the shape asked for, or the value exists and the field is not described. Each sentence begins with the path, then the source and position.

moonbit
let odd = @moonconf.of_json("{\"db\":{\"port\":\"6432\"}}", source="env")

match (try Ok(odd.need_int("db.port")) catch { e => Err(e) }) {
  Err(e) => println(e)   // want integer, got text
  Ok(_) => println("read")
}
match (try Ok(odd.need("db.host")) catch { e => Err(e) }) {
  Err(e) => println(e)   // nothing at the path
  Ok(_) => println("found")
}
output
db.port (env@1:1): want integer, got text
db.host: want a value, got nothing

7. Binding to a model

A model describes a section: the fields, which are required, what each one is, and what it is when it was not written. Binding reads the section through that description, and text is read as the type it spells — which is what makes an environment variable, always text, usable as a port number.

moonbit
let model = @moonmodel.Model::new("Db", [
  @moonmodel.Field::new("host", @moonmodel.Type::Text, required=true),
  @moonmodel.Field::new("port", @moonmodel.Type::Int, required=true),
  @moonmodel.Field::new("pool", @moonmodel.Type::Int, default=Json::number(4.0)),
])
let section = @moonconf.of_json(
  "{\"host\":\"db.internal\",\"port\":\"6432\"}",
  source="env",
)
println(@moonconf.bind(section, model).stringify())
output
{"host":"db.internal","port":6432,"pool":4}

bind stops at the first problem. validate collects all of them, which is what a program wants when it reports a bad configuration to whoever wrote it.

moonbit
let model = @moonmodel.Model::new("Db", [
  @moonmodel.Field::new("host", @moonmodel.Type::Text, required=true),
  @moonmodel.Field::new("port", @moonmodel.Type::Int, required=true),
])
let broken = @moonconf.Node::map([
  ("host", @moonconf.Node::str("h", "fixture")),
  ("port", @moonconf.Node::str("http", "fixture")),
], "fixture")

for problem in @moonconf.validate(broken, model) {
  println(problem)   // port (fixture@1:1): want integer, got text
}
output
port (fixture@1:1): want integer, got text

8. Provenance

The merged tree is not a fresh value: each leaf is still the node a reader produced, with its source name and position. After a merge, ask a value where it came from.

moonbit
let file = @ini.parse("[db]\nport = 5432\n").tree(source="app.ini")
let env = @moonconf.of_json("{\"db\":{\"port\":6432}}", source="env")
let merged = @moonconf.merge([
  @moonconf.Source::new(@moonconf.File, "app.ini", file),
  @moonconf.Source::new(@moonconf.Env, "env", env),
])

println(merged.need("db.port").source)    // env
println(merged.need("db.port").at.line)   // 1
output
env
1

A program that prints its own configuration at startup can therefore print where each value came from, which turns “why is this port 6432?” into a line of output.

9. A whole deployment

The tour example does the whole line in one file: defaults, an INI file, an environment layer, a merge, a binding, and a .env read with a reference in it.

terminal
$ moon run src/examples/tour
host  -> db.internal
port  -> 6432
pool  -> 8
bound -> {"host":"db.internal","port":6432,"pool":8}
dsn   -> postgres://db.internal/app
url   -> http://localhost:8080/

The file is src/examples/tour/main.mbt. Two more examples cover HOCON and HCL on their own: src/examples/typesafe and src/examples/terraform.

10. The switches

A format is a set of decisions, and each decision here is a named field or a preset. The presets are the usual answers; a field is for the file that disagrees with them.

What can be changed
PackageSwitchValues
inidelimitersThe characters that separate a name from a value.
comment · inline_commentWhat starts a comment, whole-line and after a value.
caseLower, Preserve, Upper.
strict · allow_no_value · empty_lines_in_valuesWhether a duplicate, a bare name, or a blank line is allowed.
interpNone_, Basic (%(name)s), Extended (${section:name}).
expandunsetFail, Empty, Keep.
presetsstrict, lenient, deferred.
dotenvinterpolateWhether `${…}` is resolved; presets interpolated and verbatim.
hoconread · fallback · depthWhere an include comes from, where an unknown name is looked up, how deep nesting may go.
mergeorder · clash · strictThe tier order; Ignore, Warn, Panic or Handle for a disagreement; or an error that names both positions.
bindextraAllow or Refuse a member the model does not describe.
Presets are values, not shortcuts. @ini.standard, @expand.lenient and @moonconf.layered are records you can copy and change one field of: { ..@ini.standard, delimiters: "=" }.