joetjen.net
EN DE
Configuration language

File structure

The header, comments, keys and blocks — and what happens when the same key is written more than once.

Format 1.0

Whitespace and comments

Statements are separated by whitespace; one per line is the convention. Inside a block, commas may separate statements as well. A # followed by a space starts a comment that runs to the end of the line. There are no block comments.

#@version = 1.0
# A comment runs to the end of the line.
name = "api"   # so does a trailing one
limits { soft = 10, hard = 20 }
%{"name" => "api", "limits" => %{"soft" => 10, "hard" => 20}}

Keys

A bare key starts with a letter or _ and continues with letters, digits, _, + and -; it may end in ? or !. So logger, handle-otp-reports, enabled?, ready! and __sentry__ are all keys. The same rule names variables and atoms.

Reserved everywhere: nil, true, false, inf, +inf, -inf. The words for, in, from, as and import only mean something where a loop or an import expects them, and are ordinary keys elsewhere.

Quoted keys

Anything else — spaces, dots, a reserved word — goes in quotes. Double quotes allow escapes, single quotes are literal. A quoted key can be one segment of a dotted path.

headers { "Content-Type" = "application/json" }
foo."bar baz".dronf = "fnord"
"true" = 1
%{"headers" => %{"Content-Type" => "application/json"},
  "foo" => %{"bar baz" => %{"dronf" => "fnord"}},
  "true" => 1}

A double-quoted key may interpolate variables and the environment. It must come out as a non-empty string without a . — a dot that arrives through interpolation would silently split the path — and may not be built from a secret. %{…}, resolvers and tags are refused in a key: keys are built before the tree they would need exists.

with REGION=eu-west
@name = "primary"
db."@{name}".host = "x"
"${REGION}" = 1
%{"db" => %{"primary" => %{"host" => "x"}}, "eu-west" => 1}

Secret keys

A * in front of any key segment marks the value as secret. The value itself is unchanged; an implementation wraps it so that it is redacted when printed or logged. Secrecy follows the value: a string that interpolates a secret is itself secret, and prints with that part redacted.

*password = "hunter2"
db.*token = "t0k3n"
url = "postgres://app:%{password}@db.internal"
%{"password" => [~~REDACTED~~],
  "db" => %{"token" => [~~REDACTED~~]},
  "url" => "postgres://app:[~~REDACTED~~]@db.internal"}

Assignment

A statement assigns a value to a key: key = value. The = is optional — port 8080 is the same statement — but recommended for readability. A colon is never an assignment.

port 8080
name "api"
%{"port" => 8080, "name" => "api"}

Paths and blocks

A block { … } groups statements under a key; a dotted path reaches into nested blocks directly. Both build the same tree, and they mix freely. These four lines produce the identical result:

foo { bar { baz = "dronf" } }
foo { bar.baz = "dronf" }
foo = { bar = { baz = "dronf" } }
foo.bar.baz = "dronf"
%{"foo" => %{"bar" => %{"baz" => "dronf"}}}

Blocks are CASC’s only map type. A block stands on the right-hand side of a statement or as an element of a list or tuple (see lists). An empty block is an empty map; written over a map that already exists, it merges in nothing and leaves it as it is.

logger {}
cache = {}
db { host = "x" }
db {}
%{"logger" => %{}, "cache" => %{}, "db" => %{"host" => "x"}}

Disabled statements

A # written directly against a statement switches it off, however many lines it spans. The key is absent from the result — not nil. It is the quick way to try a configuration without a setting and keep it at hand.

section {
  #ignored = 1
  #sub { remove = yes }
  kept = true
}
%{"section" => %{"kept" => true}}

A disabled statement is never evaluated: a disabled #import reads no file, a disabled #@name declares no variable, and a bad literal inside one is no error.

Merging and overrides

A key may be written any number of times, in one file or across imports. Statements apply in order:

  • Blocks deep-merge, at every depth.
  • Scalars, lists and tuples are replaced: the last write wins.
  • A change of type replaces too: a block after a scalar is a block.

When the default is not what you want, a sigil in front of the key says so:

SigilEffect
~key { … }Replace the block instead of merging into it
+key = [ … ]Append to a list (a plain assignment if the key is new)
-key = [ … ]Remove these elements from a list, by equality
-keyDelete the key; deleting a missing key does nothing

A list operand stands for its elements; anything else, nil included, is one element, so +tags = "d" appends "d". Removal compares strictly: 1 and 1.0 are different elements. A sigil inside a block applies to its own key there.

Tuples are never merged: + or - against a tuple is an error. When several prefixes combine, the order is #, then the sigil, then *, as in #~*database { … }.

Statements do not end at a newline, so a bare -key followed by another complete statement is always the delete, never a removal whose value is the next line’s key. Write the removal with its = (-tags = b) when its value is a bare word.

base.casc, then override.casc
# base.casc
server { host = "0.0.0.0", port = 8080 }
tags = ["a", "b", "c"]
feature.legacy_mode = true

# override.casc
import "base.casc"
~server { port = 9090 }
+tags = ["d"]
-tags = ["b"]
-feature.legacy_mode
%{"server" => %{"port" => 9090},
  "tags" => ["a", "c", "d"],
  "feature" => %{}}