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.

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.1.0"}
  ]
end

Where to go next

  • Tutorial — loading config, secrets, error handling, test-time env/import injection, 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.