moonbitstack · Apache-2.0

Four sources.
One reading.

INI, Java properties, .env, HOCON and HCL, read into a single tree. Layer it by tier, look a value up by path, bind the result to a model. Every value keeps the file and line it came from, so the answer to “where did this come from?” is in the answer itself.

$ moon add moonbitstack/moonconf
5 readers
4 tiers
3 clash policies
0 files opened
Assembly bench live · compiled to JavaScript
on a clash
click a layer to drop it
Hover a value to see where it was written.
Fig. 01 — the line

Read. Merge. Look up. Bind.

one pass
bytes → record

Each plate of settings turns into the same tree, the tree is merged once, and everything after that — a lookup, a check, a bound record — reads the merged result rather than the files.

Four tiers meshed into one reading Defaults, the file, the environment and an override, each drawn as a gear, driving one shaft labelled one tree. DEFAULT defaults.conf FILE app.ini ENV env OVERRIDE flags OUTPUT One tree Node · Value · Table four tiers, meshed weakest strongest
01 — read

Read

A format package turns text into a tree and records where each value sits. No file is opened: the caller has the bytes already.

@ini.parse(text).tree(source="app.ini")

02 — merge

Merge

Tiers decide which of two values wins. Mappings merge member by member, so a file that sets one key keeps the rest.

@moonconf.merge(sources)

03 — look up

Look up

A dotted path walks the tree. Missing, mistyped and undescribed are three different refusals, not one null.

merged.need("db.port")

04 — bind

Bind

A model says what a valid section is. Binding fills the defaults, reads text as the type it spells, and names the field it refused.

@moonconf.bind(section, model)

Fig. 02 — what it holds

The cabinet, plate by plate.

seven packages
one tree
01

Five readers

INI with its dialects, Java properties with its escapes, .env with its quotes, HOCON with its references, HCL with its expressions. JSON, YAML and TOML arrive through of_json, of_yaml and of_toml.

@ini · @props · @dotenv · @hocon · @hcl

02

Four tiers

Defaults, the file, the environment, an explicit override. The field wins orders them and can be reversed; the field clash says what a disagreement means.

Knobs { wins, clash }

03

Provenance

Each node carries the name of the source it was read from and the line it sat on. The bench on this page colours every value by the layer that won it.

node.source · node.at.line

04

Refusals, not nulls

Missing, Type, Unknown, Clash, Path, Format: each says the path it was about and where that path was written.

db.port (env@1:1): want integer, got text

05

Bound to a model

A Model from moonmodel describes the section: required fields, defaults, nesting. validate reports every problem; bind stops at the first.

bind(section, model) · validate(…)

06

Switches with names

Delimiters, comment marks, case folding, strictness, two interpolation modes, three policies for an unset variable, four expansion modes. Every switch is a named field or a preset.

@ini.Knobs · @expand.Unset

Fig. 03 — a whole run

One deployment, four sources, one record.

src/examples/tour
moon run
moonbitapp/main.mbt
// What the program carries.
let defaults = @moonconf.Node::map([
  ("db", @moonconf.Node::map([
    ("host", @moonconf.Node::str("localhost", "defaults")),
    ("port", @moonconf.Node::num(5432, "defaults")),
  ], "defaults")),
], "defaults")

// What the deployment shipped, and what the machine says.
let file = @ini.parse(
  "[db]\nhost = db.internal\npool = 8\n",
).tree(source="app.ini")

let env = @moonconf.Node::map([
  ("db", @moonconf.Node::map([
    ("port", @moonconf.Node::str("6432", "env")),
  ], "env")),
], "env")

let merged = @moonconf.merge([
  @moonconf.Source::new(@moonconf.Env, "env", env),
  @moonconf.Source::new(@moonconf.Default, "defaults", defaults),
  @moonconf.Source::new(@moonconf.File, "app.ini", file),
])

println(merged.need_text("db.host"))   // db.internal
println(merged.need_text("db.port"))   // 6432

// A .env file is the same idea with fewer brackets.
let env_file = @dotenv.load("HOST=db.internal\nDSN=postgres://${HOST}/app\n")
println(env_file.get("DSN").unwrap())  // postgres://db.internal/app

// Text expansion stands on its own, for a value written as a shell writes it.
let vars = @expand.Vars::of_map(Map([("PORT", "8080")]))
println(@expand.expand("http://localhost:${PORT:-80}/", vars))

// A model turns the section into a record, with types and defaults.
let db = @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)),
])
println(@moonconf.bind(merged.need("db"), db).stringify())
outputnative
$ 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 verbs
CallAnswers
parseThe document as written: entries, sections, lines.
treeThe same document as the shared tree, with positions.
dumpsThe document back as text, in the file's own shape.
of_json · of_yaml · of_tomlA tree from a serialisation, for a caller that already has one.
mergeOne tree from a stack of sources, ordered by tier.
find · needA value at a dotted path, present or required.
bind · validateA record as the model describes it, or every reason it is not.
A refusal, read aloud
PartWhat it says
db.portThe path a person greps for.
env@1:1The source it was read from, and where in it.
want integerThe shape the caller asked for.
got textThe shape that was there instead.
missing · unknownNothing was written, or nothing describes what was.
clashTwo layers wrote it: both places are named.
Fig. 04 — the stack

The order, written down.

weakest first
strongest last
Tiers
TierHoldsComes from
Override Flags, a fixture, a deliberate last word. The caller, last.
Env The process environment, nested by the caller. get_env_var, read by the caller.
File INI, properties, .env, HOCON, HCL. One of the readers.
Default What the program carries with it. The caller, first.
On a clash
PolicyWhat happens
Last The stronger layer wins, silently. The default.
First The weaker layer wins — for a patch layered under a document.
Refuse Two layers naming one value is a Clash that names both places.
wins The tier order itself, so the whole stack can be reversed.
What each package reads
PackageReadsWritesWorth knowing
moonconf/iniINI, configparser's dialectsyesSections, continuation lines, two interpolation modes, eight switches.
moonconf/propsJava propertiesyesload takes bytes as ISO-8859-1; \uXXXX and continuations.
moonconf/dotenv.envyesTwo kinds of quote, references, and a list of lines that were not assignments.
moonconf/hoconHOCON—Merging, ${?name}, includes, a value that builds on itself.
moonconf/hclHCL 2, native and JSON—Templates, heredocs, for expressions, splats, a function table.
moonconf/expandPOSIX parameter expansion—${A:-b} and the rest, nested, with three policies for unset.
moonconfNothing; it holds the tree—Merge, lookups, binding, and the refusals that name a path.