Language tour

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.

Contents
1 · A file 2 · Layering with extends 3 · Editing lists 4 · Branching with when 5 · Declaring holes with required 6 · Strings, fallbacks and pipes 7 · Context, environment and secrets 8 · How resolution works 9 · Strict mode 10 · The full example 11 · Next

01A file#

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"
  }
}
$ medl resolve atlas.medl
{
  "app": {
    "name": "Atlas Notes",
    "version": "2.3.1",
    "features": [
      "telemetry",
      "crash_reporting"
    ],
    "build": {
      "optimization": "release"
    }
  }
}

Keys come out in the order they were declared. That holds across every file in a merge chain.

02Layering with extends#

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"
}
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"
  }
}

Note that extends is a statement, so it can also sit inside a when arm. Section 4 shows that.

03Editing lists#

Three operators. = replaces whatever was there. += appends to an inherited list. -= removes elements by exact value. A layer says only what changes.

listops.medl
app {
  features = ["telemetry", "crash_reporting"]
  features -= ["crash_reporting"]
  features += ["session_recording"]
}
$ medl resolve listops.medl
{
  "app": {
    "features": [
      "telemetry",
      "session_recording"
    ]
  }
}

+= and -= only work on lists. Using them on anything else is a hard error. += on a key that does not exist yet behaves like =; -= on one is a no-op.

Op Key not yet defined Key defined, same type Key defined, other type
=setsreplacesreplaces
+=setsappendserror
-=no-opremoves matcheserror

04Branching with when#

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.

when_value.medl
app {
  min_os = when ctx.platform {
    "phone" => "8.0"
    "tablet" => "12.0"
    else  => error("unsupported platform: ${ctx.platform}")
  }
}
$ medl resolve when_value.medl --ctx platform=phone
{
  "app": {
    "min_os": "8.0"
  }
}
$ medl resolve when_value.medl --ctx platform=android
error: unsupported platform: android
  --> when_value.medl:5:14
  = note: when_value.medl:2:3: while resolving `app.min_os`

Block position

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.

when_block.medl
app {
  networking {
    timeout_seconds = 30
  }

  when ctx.variant {
    "fleet" => {
      networking {
        timeout_seconds = 15
      }
    }
  }
}
$ medl resolve when_block.medl --ctx variant=store
{
  "app": {
    "networking": {
      "timeout_seconds": 30
    }
  }
}
$ medl resolve when_block.medl --ctx variant=fleet
{
  "app": {
    "networking": {
      "timeout_seconds": 15
    }
  }
}

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"
  }
}
$ medl resolve tuple.medl --ctx variant=fleet --ctx platform=phone
{
  "app": {
    "display_name": "Atlas Notes Fleet"
  }
}

Conditional layering

Because extends and required are statements, they can live inside an arm. The file is only merged, and the hole only exists, when that arm matches.

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

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

05Declaring holes with required#

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.

06Strings, fallbacks and pipes#

${…} 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.

interp.medl
app {
  variant_name = "Atlas Notes Store"
  version = "2.3.1"

  log_tag  = "${app.variant_name |> snake_case}"
  class    = "${app.variant_name |> pascal_case}Session"
  build_id = "${app.version |> replace(".", "_")}"
  region   = "${env.RELAY_REGION | "eu" |> lower}"
}

No RELAY_REGION in the environment:

$ medl resolve interp.medl
{
  "app": {
    "variant_name": "Atlas Notes Store",
    "version": "2.3.1",
    "log_tag": "atlas_notes_store",
    "class": "AtlasNotesStoreSession",
    "build_id": "2_3_1",
    "region": "eu"
  }
}

With it set:

$ RELAY_REGION=US medl resolve interp.medl
{
  "app": {
    "variant_name": "Atlas Notes Store",
    "version": "2.3.1",
    "log_tag": "atlas_notes_store",
    "class": "AtlasNotesStoreSession",
    "build_id": "2_3_1",
    "region": "us"
  }
}

Paths in expressions are always absolute from the root, even inside a nested block. Inside app { session { … } } you still write app.session.provider.

Strings Lists Any
upper lower trim capitalize join len contains default
replace split slice first last error
snake_case kebab_case camel_case pascal_case

Full signatures are in the spec, §5.1.

07Context, environment and secrets#

Three reserved roots that no file can assign:

  • 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.

analytics_key = default(env.ANALYTICS_KEY, secret.ANALYTICS_FALLBACK_KEY)

08How resolution works#

Three passes, strictly in order. Nothing in one pass starts before the previous pass has finished for the whole file.

  1. 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.
  2. Graph. Every path an expression reads becomes an edge from the reader to the value it reads, including when subjects, interpolations and fallbacks.
  3. 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

09Strict mode#

--strict adds lint warnings on stderr. They never change the output or the exit code. Three lints exist in v0.1:

  • a -= that removed nothing, which is usually a typo;
  • a required next to an unconditional fill of the same key in the same file, a dead marker;
  • a | fallback whose left side is not a ctx, env or secret lookup, dead code.
main.medl
f = ["a"]
f -= ["zzz"]
$ medl resolve main.medl --strict
warning: `-=` on `f` removed nothing: none of the listed values were present
  --> main.medl:2:1
{
  "f": [
    "a"
  ]
}

The JSON is still the successful output — a warning never fails the build.

10The full example#

The repository ships a worked configuration: one product, two variants (store, fleet), two platforms (phone, tablet), four builds. Six files.

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
base.medl
app {
  name = "Atlas Notes"
  bundle_id = "com.fieldkit.atlas"
  version = "2.3.1"

  required sku "every variant must set a SKU"
  required variant_name

  features = ["telemetry", "crash_reporting"]

  build {
    min_os = "8.0"
    optimization = "release"
  }

  networking {
    timeout_seconds = 30
    retry_count = 3
  }
}
variants/fleet.medl
app {
  variant_name = "Atlas Notes Fleet"
  sku = "AN-FLEET"

  features -= ["crash_reporting"]
  features += ["session_recording"]

  enterprise {
    enabled = false
  }

  session {
    provider = "relay"
    app_id = "${secret.RELAY_APP_ID}"
    region = "${env.RELAY_REGION | "eu" |> lower}"
    max_participants = 40
  }
}
platforms/tablet.medl
app {
  build {
    min_os = "12.0"
  }

  platform_features = ["split_view", "stylus_input"]

  session {
    live_cursors = true
  }
}
app.medl (excerpt) — expand · full file ↗
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(".", "_")}"
}
base.medl variants/fleet.medl platforms/tablet.medl app.medl
$ medl resolve examples/app.medl --ctx variant=fleet --ctx platform=tablet \
    --secret RELAY_APP_ID=relay-123 --secret ANALYTICS_FALLBACK_KEY=fallback-key
{
  "app": {
    "name": "Atlas Notes",
    "bundle_id": "com.fieldkit.atlas",
    "version": "2.3.1",
    "features": [
      "telemetry",
      "session_recording"
    ],
    "build": {
      "min_os": "12.0",
      "optimization": "release"
    },
    "networking": {
      "timeout_seconds": 15,
      "retry_count": 3
    },
    "variant_name": "Atlas Notes Fleet",
    "sku": "AN-FLEET",
    "enterprise": {
      "enabled": false
    },
    "session": {
      "provider": "relay",
      "app_id": "relay-123",
      "region": "eu",
      "max_participants": 40,
      "live_cursors": true,
      "label": "RELAY"
    },
    "platform_features": [
      "split_view",
      "stylus_input"
    ],
    "platform_supported": true,
    "build_id": "AN-FLEET-tablet-2_3_1",
    "display_name": "Atlas Notes Fleet for Tablet",
    "compliance_profile": "consumer",
    "identifiers": {
      "module_class": "AtlasNotesFleetSession",
      "log_tag": "atlas_notes_fleet"
    },
    "debug_summary": "features: telemetry, session_recording (2 total)",
    "analytics_key": "fallback-key"
  }
}

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.

11Next#

The spec
Grammar, the operator table, when rules, every builtin's signature, and the list of v0.1 decisions.
The code
Reference implementation, the example above, and one test directory per behavior.