medl — braces and three faders
a config language

Describe the product once. Ship every build.

One base file holds what every build shares. Platform, variant and tier files layer on top. medl merges them into one JSON document, or one error that names the file, line and key.

Heavy on structure. Light on ceremony.

Take the tour Read the spec
atlas.medl
# One product, described once.
app {
  name = "Atlas Notes"
  version = "2.3.1"
  features = ["telemetry", "crash_reporting"]

  build {
    optimization = "release"
  }
}

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.

base.medl
app {
  name = "Atlas Notes"
  features = ["telemetry", "crash_reporting"]

  required sku "every variant must set a SKU"
}
store.medl
extends("base.medl")

app {
  sku = "AN-STORE"
  features += ["enterprise_sso"]
  build_id = "${app.sku}-${ctx.platform}"
}
$ medl resolve store.medl --ctx platform=phone
{
  "app": {
    "name": "Atlas Notes",
    "features": [
      "telemetry",
      "crash_reporting",
      "enterprise_sso"
    ],
    "sku": "AN-STORE",
    "build_id": "AN-STORE-phone"
  }
}

Delete the sku = "AN-STORE" line from store.medl and the build does not ship:

$ medl resolve store.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

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.

  1. Merge. Follow every extends, apply when arms that match the context, fold list operators left to right. Then check every required hole against the flat result.
  2. Graph. Record which key reads which other key. That includes when subjects, interpolations and fallbacks.
  3. 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.
PASS 1
Merge
extends chains, matching when arms, list ops folded left to right
then check required
PASS 2
Graph
a b c
PASS 3
Resolve
{ }
evaluate in dependency order, emit JSON
main.medl
a = "${b}"
b = "${a}"
$ medl resolve main.medl
error: dependency cycle: a -> b -> a
  --> main.medl:2:8

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.

examples/
├── app.medl              entry: extends base, branches on ctx
├── base.medl             shared values, two required holes
├── variants/
│   ├── store.medl
│   └── fleet.medl
└── platforms/
    ├── phone.medl
    └── tablet.medl
Merge order — Fleet on Tablet
base.medl
variants/fleet.medl← ctx.variant
platforms/tablet.medl← ctx.platform
app.medlits own statements
▼ later wins ▼
resolved JSON
app.medl (excerpt)
extends("base.medl")

app {
  when ctx.variant {
    "store" => {
      extends("variants/store.medl")
    }
    "fleet" => {
      extends("variants/fleet.medl")

      required session.app_id "fleet builds need a relay app id"
    }
  }

  when ctx.platform {
    "phone" => {
      extends("platforms/phone.medl")
    }
    "tablet" => {
      extends("platforms/tablet.medl")
    }
  }

  build_id = "${app.sku}-${ctx.platform}-${app.version |> replace(".", "_")}"
}
The files never change. Only the context does.
examples/
  app.medl
  base.medl
  variants/ ×2
  platforms/ ×2
phone
tablet
store
AN-STORE-phone-2_3_1
AN-STORE-tablet-2_3_1
fleet
AN-FLEET-phone-2_3_1
AN-FLEET-tablet-2_3_1
store · phone
"features": ["telemetry", "crash_reporting", "enterprise_sso", "device_management"],
"build": {"min_os": "8.0", "optimization": "release"},
"networking": {"timeout_seconds": 30, "retry_count": 3},
"session": {"provider": "mesh", "max_participants": 8, "label": "MESH"},
"build_id": "AN-STORE-phone-2_3_1",
"display_name": "Atlas Notes Store"
fleet · tablet
"features": ["telemetry", "session_recording"],
"build": {"min_os": "12.0", "optimization": "release"},
"networking": {"timeout_seconds": 15, "retry_count": 3},
"session": {"provider": "relay", "app_id": "relay-123", "region": "eu", "max_participants": 40, "live_cursors": true, "label": "RELAY"},
"build_id": "AN-FLEET-tablet-2_3_1",
"display_name": "Atlas Notes Fleet for Tablet"

Condensed by hand from the verified pretty-printed outputs; the values are exact, the line breaks are not.

Walk through the whole example →

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.

$ medl resolve examples/app.medl \
    --ctx variant=fleet --ctx platform=tablet \
    --secret RELAY_APP_ID=relay-123 \
    --secret ANALYTICS_FALLBACK_KEY=fallback-key

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.

let inputs = Inputs::new()
    .with_ctx("platform", "phone")
    .with_ctx("variant", "store")
    .with_env(ProcessEnv);

let mut medl = Medl::new(Box::new(FsLoader));
let output = medl.resolve(Path::new("examples/app.medl"), &inputs)?;
println!("{}", output.value.to_json());

Adapted from the README; illustrative, not compiled as shown.

Deliberately small.

Next.

Take the tour
Every feature, one section each, with real output after every snippet.
Read the spec
The normative description: grammar, passes, operator table, builtins, and the decisions made for v0.1.
Get the code
The reference implementation in Rust, with the worked example and a test case per behavior. MIT licensed.
install
$ cargo install --git https://github.com/medl-config/medl medl