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:

  1. Lex + parseCooper.Grammar and Cooper.Actions turn 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.
  2. Loop expansionCooper.Loop expands each for statement (CASC.md §5.5) into the ops it generates.
  3. Import resolutionCooper.Loader recursively loads each import statement (CASC.md §5.1), splicing the imported file's own ops into the importer's list and propagating its public variables.
  4. Merge and secret-wrappingCooper.Merge folds the flattened ops into a tree, honoring the merge-control sigils (~/+/-, CASC.md §5.7/§8), and wraps every *key-prefixed value in Cooper.Secret so it can't leak through an accidental inspect/log call.
  5. Reference resolutionCooper.Resolver resolves 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

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.