medl describes one product and the ways its builds differ. This tour walks through the whole language in about ten minutes. The examples are real: each output came from running the command shown.
A .medl file is blocks and assignments. Blocks nest. Values are strings, numbers, booleans, lists, null, or another block. Comments start with #.
atlas.medl
# One product, described once.
app {
name = "Atlas Notes"
version = "2.3.1"
features = ["telemetry", "crash_reporting"]
build {
optimization = "release"
}
}
extends("file") merges another file underneath the current one. Files listed left to right apply in that order, then the current file, so later files win. A file is merged at most once per resolution, however many times it is named.
base.medl
app {
name = "Atlas Notes"
features = ["telemetry", "crash_reporting"]
required sku "every variant must set a SKU"
}
when looks at a subject and picks an arm. It comes in two positions.
Value position
The arm's expression becomes the value. If no arm matches and there is no else, that is an error. Here error() turns an unknown platform into a build failure on purpose.
The arm's statements merge into the enclosing block, exactly like a file merged with extends. A missing else is fine here: a non-matching when simply contributes nothing.
Block-position when runs during the merge pass, before any value exists, so its subject may only read ctx, env, secret, literals, and builtins over those. Value-position when has no such limit.
Tuples
A subject with several parts matches arm by arm. It reads like a matrix.
tuple.medl
app {
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"
}
}
required key says: this key has no value here, and some layer must supply one. It carries an optional message. Holes are checked once the whole chain is merged and before anything resolves, and every unfilled hole is reported together.
base.medl from section 2 declares required sku. broken.medl extends it and forgets to fill the hole:
broken.medl
extends("base.medl")
app {
features += ["enterprise_sso"]
}
$ medl resolve broken.medl --ctx platform=phone
error: 1 required key(s) were never filled: app.sku
--> base.medl:5:3
= note: base.medl:5:3: `app.sku` declared required here: every variant must set a SKU
Several holes are reported at once, each with every line that declared it:
$ medl resolve main.medl
error: 2 required key(s) were never filled: app.sku, app.name
--> main.medl:2:3
= note: main.medl:2:3: `app.sku` declared required here: every variant must set a SKU
= note: main.medl:3:3: `app.name` declared required here
A hole is filled by =, by +=, or by a block of that name with at least one statement. Order does not matter: a required declared after its fill still counts as filled. A required for a key that already has a value is a harmless marker.
${…} interpolates an expression into a string. A string that is exactly one interpolation evaluates to the value itself, so "${app.features}" is the list, not text.
Two operators live inside ${…}:
| supplies a fallback when a ctx, env or secret lookup has nothing.
|> pipes the value into a builtin, left to right. A bare name after |> is a call.
Fallback binds first, so ${env.RELAY_REGION | "eu" |> lower} lowercases whichever side won.
ctx.* is the build being resolved. It only ever comes from the command line, with --ctx KEY=VALUE, one flag per key. A file cannot default it.
env.* reads the process environment.
secret.* comes from --secret KEY=VALUE. In the library it is any lookup you inject.
A missing ctx, env or secret with no fallback is a hard error at the key that asked:
$ medl resolve store.medl
error: ctx has no key `platform`
--> store.medl:6:28
= note: store.medl:6:3: while resolving `app.build_id`
$ medl resolve examples/app.medl --ctx variant=fleet --ctx platform=tablet \
--secret ANALYTICS_FALLBACK_KEY=fallback-key
error: secret `RELAY_APP_ID` is not available and no fallback was given
--> examples/variants/fleet.medl:14:17
= note: examples/variants/fleet.medl:14:5: while resolving `app.session.app_id`
default(a, b) is the general form of |: it works on any expression, including config paths, and is lazy about a missing left side.
Three passes, strictly in order. Nothing in one pass starts before the previous pass has finished for the whole file.
Merge. Follow extends, apply matching block-position when arms, fold =/+=/-= left to right into one flat tree. Then check every required hole against that tree.
Graph. Every path an expression reads becomes an edge from the reader to the value it reads, including when subjects, interpolations and fallbacks.
Resolve. Evaluate keys in dependency order and emit JSON in declaration order.
PASS 1
Merge
then check required
→
PASS 2
Graph
who reads whom; a cycle errors here
→
PASS 3
Resolve
{ }
A cycle is found in the graph pass and reported with its path:
main.medl
a = "${b}"
b = "${a}"
$ medl resolve main.medl
error: dependency cycle: a -> b -> a
--> main.medl:2:8
min_os came from the platform file and overrode the base; timeout_seconds came from a block-position when in app.medl; crash_reporting was removed and session_recording added by the variant; app_id came from a secret and region from a fallback; label, build_id and identifiers were computed with pipes.