Quick syntax lookup. See CASC.md for full explanations, or the tutorial for a walkthrough.

File skeleton

#@version = 1.0   ; required, must be the first non-blank/non-comment statement

# a line comment, runs to end of line

key = value        ; "=" is always optional -- "key value" works too

Statements

SyntaxMeaning
key = value / key valueassignment (§5.3)
key { ... }block (sugar for key = { ... }, §5.4)
a.b.c = valuedotted path (sugar for nested blocks, §5.4)
@name = valuepublic variable declaration (§5.2)
@*name = valueprivate variable declaration -- file-local, never exported (§5.2)
import "path"filesystem import, glob/brace patterns expand (§5.1)
import "scheme://..."consumer-registered import loader (§5.1, §9.3)
for ... as ... { ... }loop (§5.5)
#statementdisabled statement -- produces nothing (§5.6)

Sigil ordering (#, then ~/+/-, then *, then the key)

SigilPositionMeaning
#keydisablestatement produces nothing (§5.6)
~key { ... }merge controlreplace the whole subtree, not merge (§5.7/§8.4)
+key = [...]merge controlappend to an existing list (§5.7/§8.4)
-key = [...]merge controlremove matching elements from an existing list (§5.7/§8.4)
-key.pathmerge controldelete the path entirely, any type, no value (§5.7/§8.4)
*keysecrecymark this key (and everything under it) secret (§4.3)
#~*database { host = "db.internal", password = "hunter2" }

# disabled, ~ replace, * secret, in that order, no separators.

Values (§6)

SyntaxResult
nilnil
true / falsetrue / false
-17, 0, +99, 0xDEAD_BEEF, 0o755, 0b1101, 3.14, 5e+22numbers, _ allowed as a digit separator
inf, +inf, -infinfinities
info, :infoatom :info (leading : optional except to reach a reserved word: :true, :nil, ...)
"double quoted"string -- escapes + interpolation (§7)
'single quoted'string -- literal, no escapes/interpolation
"""triple\nquoted"""multi-line string, common leading whitespace stripped
\ at end of linebackslash continuation -- joins onto the next line
1979-05-27T07:32:00Z, 1979-05-27, 07:32:00date/time/datetime (§6.6)
127.0.0.1, 127.0.0.1/32, ::1/128%Cooper.IPv4{} / %Cooper.IPv6{} (§6.7)
500ms, 1h30m{:duration, nanoseconds} -- units: ns us µs ms s m h d, descending, no repeats (§6.8)
512MiB, 10GB{:bytes, count} -- Ki/Mi/Gi/Ti/Pi = ×1024, no i = ×1000 (§6.9)
["a", "b", "c"]list -- comma/newline/whitespace-separated
(52.52, 13.405)tuple -- fixed arity, never merged, only replaced wholesale (§6.11)

Interpolation and references (§7)

SigilMeaningTiming
@{name}variable reference (§7.1)eager, per-file
${NAME}environment variable, always a string (§7.2)eager
%{path}reference into the final merged tree (§7.3)lazy, after merge
!{resolver:payload}consumer-registered resolver dispatch (§7.4/§9.2)resolve time
!Name(arg)tagged value -- built-in or consumer-registered (§7.5/§9.1)resolve time

Shared suffix grammar across @{}/${}/%{}:

SuffixMeaning
:defaultfall back to default if undefined/unset
:+altsubstitute alt if defined/set, else empty string
:?"message"fail loading with message if undefined/unset
[i]index into a list-valued variable/config path

${...}-only extras:

SyntaxMeaning
${?NAME}guards the one statement immediately following -- skipped entirely if NAME unset/empty
${NAME[]:[...]}parse NAME as a comma/semicolon-separated list, with a default
${NAME[i]:default}index into the split list

Imports (§5.1)

import "path.casc" -- relative to the current file; brace/glob patterns expand against the filesystem. A path may interpolate ${NAME}/${NAME:default} to select one:

import "env/${MIX_ENV:dev}.casc"

Only ${...}; %{...} needs the finished tree, which does not exist while the document is still being parsed. An unset variable with no default is an error, not an empty segment.

The common shape is an entry document importing shared concerns and then its environment's overlay, last so it overrides them:

import "inc/database.casc"
import "env/${MIX_ENV:dev}.casc"

Every selectable file must exist -- a missing import is a load error, so keep an env/prod.casc even when it overrides nothing. Default to the environment whose tooling does not set the variable, which is usually dev: deployed environments set it explicitly.

Filters (§7.2)

FilterEffect
| trimstrip leading and trailing whitespace
| downcase, | upcasechange case
| trim_prefix: "...", | trim_suffix: "..."strip an affix if present

One rule shared by @{...}, ${...} and %{...}, exactly like the suffix grammar. Applied after the suffix settles, left to right, so a filter always sees the value that will actually be used:

scheme = ${SCHEME | trim_suffix: "://" | downcase}
region = ${REGION:"  us-east-1  " | trim}

Single-quoted arguments exist so a filter stays usable inside an interpolated string: "${S | trim_suffix: '://'}://%{host}". Filters normalize, they never convert -- a non-string is an error, not a coercion. Use a tagged value to change a type.

Built-in tagged values

!int(arg) !float(arg) !bool(arg) !duration(arg) !bytes(arg) !trim(arg) !downcase(arg) !upcase(arg) !module(arg) -- always available, no registration needed. !duration/!bytes produce the same {:duration, ns} / {:bytes, n} shape as the bare literal forms (§6.8/§6.9); !trim/!downcase/!upcase normalize a whole value the way the matching filter normalizes one reference; !module("Foo.Bar") names a module, which §6.4's bare-identifier atoms cannot express.

Loops (§5.5)

for <binding> [, <binding>]* [from <template-path>] as <destination-path> {
  <body>
}
BindingMeaning
@nameindex binding -- current iteration number, at most one per loop, no in
@name in <iterable>element binding -- current element of an @{}/%{}-resolved list
  • Multiple element bindings iterate in parallel (zipped by index), never a cartesian product -- all bound lists must share the same length.
  • from <template-path> copies that path's value as each iteration's base, resolved lazily against the final tree, same timing as %{...}.
  • <destination-path> may use interpolated segments built from the loop's own bindings.
  • Loop-bound variables are scoped to the body only.

Merge model (§8)

TypeDefaultOverride
Blocks/mapsdeep-merge~key { ... } replaces the whole subtree
Listsreplace wholesale+key/-key append/remove elements
Tuplesreplace wholesalenone -- ~/+/- against a tuple is a load-time error

Later statements (including a later import) override earlier ones at the same path. %{...} always resolves against the final, fully merged tree, regardless of where in the source the reference itself was written.

Identifiers (§4.1)

/^[a-zA-Z][a-zA-Z0-9_+-]*[?!]?$/ -- mixed case, _/-/+, digits, optional trailing ?/!. nil/true/false/inf/+inf/-inf are reserved everywhere (§6.4); for/in/from/as are reserved only inside a for statement (§5.5), ordinary identifiers everywhere else. A key segment that doesn't fit this rule (spaces, punctuation) must be quoted: "Content-Type" = "application/json".

Common gotchas

  • #TODO (no space) is a disabled-statement attempt, not a comment -- it fails to parse unless TODO happens to be valid statement syntax. # TODO (space) is unambiguously a comment (§3.2).
  • ${...} always resolves to a plain string -- wrap it in !int(...)/ !bool(...)/etc. to get a typed value; CASC never guesses a type from what the string looks like (§7.2).
  • A compound duration (1h30m) requires strictly descending, non-repeated, integer-only components -- 30m1h and 1.5h30m are both invalid (§6.8).
  • +/- against a tuple is a load-time error, not a silent no-op -- only lists support append/remove (§6.11/§8.3).
  • %{...}/@{...} cycles are a load-time error naming the full cycle path, never an infinite loop or a silently partial value (§7.1/§7.3).
  • An unregistered !Name(...) tag, !{resolver:...} resolver, or scheme:// import source is always a load-time error naming the offender -- never silently ignored (§9.4).