Five builds, five copies of the same file.
A product that ships to more than one platform or in more than one variant ends up with a config file per build. They start identical. Then someone changes a timeout in the phone file and forgets the tablet file, and nobody notices until it ships. Templating engines fix the copying and add a language nobody asked for. medl is the small language that only does this job.
A base, a layer, a command.
The base declares what is shared and leaves a hole. The layer fills the hole and adds to a list. The command supplies the one thing files never know about themselves: which build this is.
Delete the sku = "AN-STORE" line from store.medl and the build does not ship:
Small language, five ideas.
Layer files with extends
Later files win. Files listed left to right, then the current file's own statements in order; a when arm merges at the point where it appears.
extends("base.medl")
Edit lists instead of replacing them
= replaces, += appends, -= removes by value. A layer changes only what it needs to.
features -= ["crash_reporting"] features += ["session_recording"]
Branch on context with when
Block position merges statements in. Value position picks a value. Tuples read like a matrix.
display_name = when (ctx.variant, ctx.platform) {
("store", "phone") => "Atlas Notes Store"
("store", "tablet") => "Atlas Notes Store for Tablet"
("fleet", "phone") => "Atlas Notes Fleet"
("fleet", "tablet") => "Atlas Notes Fleet for Tablet"
}
Declare holes with required
A base says what every build must provide. An unfilled hole is a hard error before anything resolves, with every declaring line listed.
required sku "every variant must set a SKU"
Interpolate, fall back, pipe
${…} anywhere in a string. | falls back when an env, secret or ctx lookup is missing. |> pipes through a fixed set of builtins.
log_tag = "${app.variant_name |> snake_case}"
region = "${env.RELAY_REGION | "eu" |> lower}"
Three passes, in order, every time.
- Merge. Follow every
extends, applywhenarms that match the context, fold list operators left to right. Then check everyrequiredhole against the flat result. - Graph. Record which key reads which other key. That includes
whensubjects, interpolations and fallbacks. - Resolve. Evaluate in dependency order. A cycle is an error with the path spelled out. A missing env or secret with no fallback is an error at the key that asked for it.
Two variants × two platforms, one tree of files.
The repository ships a complete example. app.medl extends a base, then pulls in a variant file and a platform file depending on the context it is given. Four builds come out of six files, and each build's output is a golden test.
app.medl
base.medl
variants/ ×2
platforms/ ×2
Condensed by hand from the verified pretty-printed outputs; the values are exact, the line breaks are not.
A binary and a crate.
One subcommand. Context comes in with --ctx, secrets with --secret, and --strict turns on lint warnings. Exit code 0 resolved, 1 any language error, 2 a usage error.
The same resolver is a Rust library. Swap the file loader to resolve from memory, swap the lookups to control where env and secret come from.
Adapted from the README; illustrative, not compiled as shown.
Deliberately small.
- No loops, no user-defined functions, no recursion. If a config needs iteration, generate several
.medlfiles upstream instead. - A fixed set of builtins. String case conversion,
split,join,len,contains,default,errorand a few more. Nothing that reaches outside the file. - Context is never declared inside a file.
ctx.platformalways comes from whoever runs the tool. A file cannot quietly default itself into the wrong build. - No schema, no types. Validating the resolved JSON against a shape is another tool's job.
- One output format. JSON, keys in declaration order, or an error. Nothing in between.