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.
$ 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.
let tree = @moonconf.of_json(
"{\"db\":{\"host\":\"localhost\"}}",
source="defaults",
)
println(tree.need_text("db.host")) // localhost
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.
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
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.
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
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.
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
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.
let env = @dotenv.load("HOST=db.internal\nDSN=postgres://${HOST}/app\n")
println(env.get("DSN").unwrap()) // postgres://db.internal/app
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.
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
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.
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
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.
let from_json = @moonconf.of_json("{\"a\":1}")
println(from_json.need_int("a")) // 1
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.
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))
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.
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")
}
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.
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
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.
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"))
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.
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")
}
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.
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())
{"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.
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
}
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.
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
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.
$ 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.
| Package | Switch | Values |
|---|---|---|
ini | delimiters | The characters that separate a name from a value. |
comment · inline_comment | What starts a comment, whole-line and after a value. | |
case | Lower, Preserve, Upper. | |
strict · allow_no_value · empty_lines_in_values | Whether a duplicate, a bare name, or a blank line is allowed. | |
interp | None_, Basic (%(name)s), Extended (${section:name}). | |
expand | unset | Fail, Empty, Keep. |
| presets | strict, lenient, deferred. | |
dotenv | interpolate | Whether `${…}` is resolved; presets interpolated and verbatim. |
hocon | read · fallback · depth | Where an include comes from, where an unknown name is looked up, how deep nesting may go. |
merge | order · clash · strict | The tier order; Ignore, Warn, Panic or Handle for a disagreement; or an error that names both positions. |
bind | extra | Allow or Refuse a member the model does not describe. |
@ini.standard,
@expand.lenient and @moonconf.layered are
records you can copy and change one field of:
{ ..@ini.standard, delimiters: "=" }.