Cooper
Cooper loads CASC config files — a hierarchical, extensible config language with imports, variables, string interpolation, environment/config references, loops, and consumer-registered resolvers and tags — into native Elixir terms.
# config.casc
#@version = 1.0
@region = "eu-west"
server {
host = "0.0.0.0"
port = 8080
}
database {
*password = ${DB_PASSWORD}
host = "db.@{region}.internal"
pool_size = 10
timeout = 500ms
}
iex> Cooper.load_file("config.casc", env: %{"DB_PASSWORD" => "hunter2"})
{:ok,
%{
"server" => %{"host" => "0.0.0.0", "port" => 8080},
"database" => %{
"password" => [~~REDACTED~~],
"host" => "db.eu-west.internal",
"pool_size" => 10,
"timeout" => {:duration, 500_000_000}
}
}}
Maps come back with string keys, durations/byte sizes as tagged
{:duration, nanoseconds}/{:bytes, count} tuples, tuples as real
Elixir tuples (never coerced to lists, CASC.md §6.11), and any
*key-prefixed value wrapped in Cooper.Secret — redacted by
inspect/1 and to_string/1 by default; call
Cooper.Secret.reveal/1 to get the real value back.
Why
Most config libraries either parse a fixed format (JSON/YAML/TOML) or
hand you a bespoke DSL with no formal grammar behind it. CASC has a
real specification (guides/casc/CASC.md), and
Cooper implements it via Ichor, a sibling grammar-compiler library,
rather than hand-rolled string parsing — so the interpreter's behavior
tracks the grammar, not the other way around.
How it fits together
Loading a file runs one pipeline, each stage handing off to the next:
- Lex + parse —
Cooper.GrammarandCooper.Actionsturn CASC source into a flat list of assignments and variable declarations, with every literal value (numbers, durations, dates, strings, ...) already converted to its native Elixir form. - Loop expansion —
Cooper.Loopexpands eachforstatement (CASC.md §5.5) into the ops it generates. - Import resolution —
Cooper.Loaderrecursively loads eachimportstatement (CASC.md §5.1), splicing the imported file's own ops into the importer's list and propagating its public variables. - Merge and secret-wrapping —
Cooper.Mergefolds the flattened ops into a tree, honoring the merge-control sigils (~/+/-, CASC.md §5.7/§8), and wraps every*key-prefixed value inCooper.Secretso it can't leak through an accidentalinspect/log call. - Reference resolution —
Cooper.Resolverresolves everything left unresolved:@{}variables,${}environment reads,%{}config references,!{resolver:...}dispatch, and!Name(...)tagged values (CASC.md §7) — including re-wrapping a secret that gets copied elsewhere by one of those references, so the redaction travels with the value, not just its original declared path.
See the tutorial for a full walkthrough of both the library and the CASC syntax it parses, or the cheatsheet for a terse API reference.
.env files
${...} reads pick up .env/.env.<env>/.env.local from the
project root automatically — on by default, no option needed — via the
optional dotenvy dependency, layered
between the real OS environment and an explicit :env option (which
always wins, but only for the names it defines — anything else still
falls through). See the tutorial's
§11 for the full layering rules.
Caching
Cooper.load_file/2 caches everything up to the final ${...}-resolution
step by default, keyed by file mtime (imports included) — a repeat call
for an unchanged file skips re-parsing entirely, while ${...} values
themselves are always re-resolved fresh, hit or miss. Pass cache: false to opt out for one call. It emits
:telemetry events too —
[:cooper, :cache, :file_changed] whenever a cached file's fingerprint
changes, and [:cooper, :cache, :env_changed] (on by default for any
file that references ${...} at all, via watch_env) when an
actually-referenced ${NAME} changes, a real System.put_env/2 or a
.env file edit either one. See the tutorial's
§12 for the invalidation rules and
Cooper.Cache for the module backing it.
Installation
Add cooper to your list of dependencies in mix.exs. ichor comes
along as its own dependency automatically — no separate line needed:
def deps do
[
{:cooper, "~> 0.2.0"}
]
end
Add {:dotenvy, "~> 1.1"} too if you want .env file support (see
above) — it's optional, so nothing pulls it in for you.
Where to go next
- Tutorial — loading config, secrets, guards,
error handling, test-time env/import injection,
.envfiles, caching, and enough CASC syntax to follow along. - Examples — layered per-environment config, a real secrets manager, IP allowlisting, generating per-shard config from a template, testing config-loading code without touching disk.
- Cheatsheet — quick reference for
Cooper's public API. - CASC tutorial and CASC reference — everything about the language itself, independent of the surrounding library.
Development
mix deps.get
mix precommit
mix precommit runs everything a change needs to pass before review —
formatting, --warnings-as-errors compilation, Credo, Sobelow, the
test suite, and Dialyzer, in that order. See
CONTRIBUTION.md for how to propose changes, and
CHANGELOG.md for release history.
License
MIT — see LICENSE.