All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.4.0] - 2026-10-01
Added
An import path may now interpolate
${NAME}or${NAME:default}:import "env/${MIX_ENV:dev}.casc"which is how one document selects among several without the selection living in the consuming application's code.
Only
${...}is permitted. An import is resolved while the document is parsed, so a reference needing the finished tree (%{...}) cannot exist yet and remains a load-time error. The environment is available at that point, which is what a${?NAME}guard already reads.Unset and empty are treated alike, as everywhere else. An unset variable without a default is an error rather than an empty segment: a path that silently became
env/.cascwould import the wrong file, or none.!module("Name"), a built-in tag naming a module of the host language:client_module = !module("ASCO.Redis.TestClient") formatter = !module("${LOG_FORMATTER}")§6.4's atoms are bare identifiers, so a dotted module name cannot be written as a literal — and a module is often deployment-selected, arriving through
${...}as a string. There was no way to express either.The tag is the same in every Cooper implementation; the shape it accepts is not. What counts as a module name belongs to the language an implementation targets, so a document naming a module stays readable across ports even where the convention differs. This implementation accepts dot-separated identifiers, mapping an upper-case initial to an Elixir module (
Foo.Bar→Elixir.Foo.Bar) and anything else to an Erlang module (crypto→:crypto), and rejects anything longer than 512 bytes.Like a bare atom literal, this creates an atom, with the same caveat: fine for a fixed, trusted set of configuration files, not for untrusted input.
A reference's name may now be built by interpolation, given as a double-quoted string:
@which = "HOST" host = ${"APP_@{which}"} # reads APP_HOSTwhich, with a loop, is how a document follows a deployment convention of one variable per tenant — something it previously could not express at all, since CASC reads named variables and cannot enumerate the environment:
@supervisor_ids = ["1", "2", "3"] for @id in @{supervisor_ids} as tokens { "@{id}" = ${"TOKEN_@{id}"} }The same applies to
@{"..."}and to a%{...}path segment. A built${...}/@{...}name must resolve to an identifier; a%{...}key may be any single segment; neither may be built from a secret, since names reach error messages unredacted. Only the bare form may be built — a reference nested inside a larger string keeps a plain name, the same scope trim that position's:defaultgrammar already has.This is interpolation pointed at the name, not a new expression form. There is still deliberately no concatenation operator.
Fixed
An interpolated
%{...}path segment (%{tokens."supervisor-@{id}"}) parsed but never resolved: the unresolved struct reachedEnum.join/2and raisedProtocol.UndefinedErrorinstead of either working or failing cleanly. It now resolves.A
forloop's bindings did not substitute into a body key, only into values. An interpolated key stayed an unresolvedCooper.Interp.Textused as a map key, so every iteration collapsed onto that single struct key and only the last survived:for @id in @{ids} as out { "@{id}" = "value-@{id}" # previously produced one entry, not one per id }Private (
@*name) variables were not file-local. Visibility was applied to the declaration environment while@{...}references were resolved later, against the single flattened result of the whole import tree — by which point the file a reference had been written in was no longer known. Three consequences, all now fixed:- A private variable declared in an importing file was visible inside the files it imported.
- A private variable declared in an imported file could not be used by that
file's own values at all, failing with
undefined reference— the declaration was filtered out before resolution ever ran. - A private name was therefore global in one direction and unusable in the other, the opposite of what CASC.md §5.2 specifies.
Every
@{...}reference is now attributed to the file that wrote it (seeCooper.Scope), and each file's private declarations are resolved only for references carrying that file's scope.Existing documents that use only public
@namevariables are unaffected. Documents relying on a private variable leaking into an imported file will now see it as undefined, which is the specified behaviour.
Changed
CASC.md §5.2 now states the visibility rules in full. Public variables are visible in both directions — to a file's importers and to the files it imports — which is what makes an entry document able to declare values its shared includes consume. This is a documentation fix: it is what the implementation has always done for public names, and it was previously described as flowing only towards importers.
Cooper.Ref.Varcarries a newscopefield, andCooper.Grammar.run_tree/2now returns its variable environment as{public, private_by_scope}.Cooper.Resolverstill accepts a plain%{name => value}map for:vars, so callers resolving a hand-built tree need no change.
[0.3.0] - 2026-08-19
Added
Reference filters:
${NAME | trim},| downcase,| upcase,| trim_prefix: "..."and| trim_suffix: "...". Filters are one rule shared by@{...},${...}and%{...}, exactly like the existing suffix grammar, and apply after the suffix has settled so a filter always sees the value that will actually be used — including one that came from a default. They chain left to right.This is for the case where a deployment supplies a value in a spelling you did not choose:
${SCHEME | trim_suffix: "://"}accepts bothhttpsandhttps://without the composing code having to normalize what it reads back.Arguments may be single- or double-quoted. Single quotes are what make a filter usable inside an interpolated string, where a double-quoted argument has nowhere to nest:
"${SCHEME | trim_suffix: '://'}://%{host}".Filters normalize; they do not convert. A non-string value, an unknown filter name, a missing argument, and an argument given to a filter that takes none are all load-time errors rather than silent coercions.
!trim,!downcaseand!upcasetagged values, which do for a whole value what the matching filter does for one reference.
Changed
BREAKING (behaviour):
.envfiles no longer outrank the real environment. The layering is now.env→.env.<env>→.env.local→System.get_env/0→:env, where it wasSystem.get_env/0→ the files →:env.A deployment sets variables in the environment it controls, and a file in the working directory silently beating them is a debugging trap rather than a feature. It also matches what dotenv implementations in other ecosystems do by default — Ruby's and Node's both decline to overwrite an already-set variable.
Nothing errors when this changes which value wins, so check any setup that relies on a
.envshadowing an exported variable. Pass the newdotenv_override: trueoption toCooper.load_file/2,load_string/2, orCooper.Dotenv.env/1to restore the previous ordering.mix.exs'sdocs/0now setssource_urlandhomepage_url(https://github.com/joetjen/cooperandhttps://joetjen.github.io/cooper), andpackage/0'slinksgained a"Docs"entry alongside"GitHub", so generated docs (both HexDocs and the GitHub Pages copy.github/workflows/docs.ymldeploys) link back to the right places instead of leaving ExDoc to guess. README.md and CONTRIBUTING.md now link to the published GitHub Pages docs site too.
[0.2.2] - 2026-08-03
Changed
- Bumped
ichor_runtimeto~> 0.2(from~> 0.1.0) and the dev/test-onlyichorto~> 0.3(from~> 0.2.1, the minimum that depends onichor_runtime ~> 0.2in turn), and dropped the patch component from both requirements (~> 0.2/~> 0.3rather than pinning a specific patch).ichor_runtime0.2.0's breaking change is internal to the parse pipeline: raw capture data (Ichor.Capture.node_t/0's:rulevariant) is now an ordered[{name, value}]list instead of a plain map, fixing sibling-capture evaluation order depending on a map's own (cross-OTP-version-unstable) iteration order rather than true first-occurrence source order.lib/cooper/native_grammar/native.exandlib/cooper/native_interp_grammar/native.ex(bothmix ichor.genoutput forpriv/grammar/casc.aether/casc_interp.aether) were regenerated to match;lib/cooper/native_grammar/capture_shapes.ex(scripts/gen_capture_shapes.exs) was regenerated too but came out unchanged, since which captures are repeatable is orthogonal to this fix. No hand-written code needed updating:Cooper.Actions/Cooper.InterpActionsonly ever see the already-evaluatedcapturesmaphandle_rule/3callbacks receive (unaffected by the change), and both modules implement an exhaustive catch-allhandle_rule/3clause, soIchor.Actions' own default fallback -- the only place the old buggy ordering could actually surface -- was never reachable from Cooper's own grammars in the first place. No observable behavior change for anything calling intoCooper's public API.
[0.2.1] - 2026-07-31
Fixed
test/fixtures/dotenv/empty/, used byCooper.DotenvTest's "missing files are never a load-time error" and "System.get_env/0 is always the floor" cases, was a genuinely empty directory -- Git doesn't track empty directories, so it was never actually committed despite existing locally, and any fresh checkout (CI included) was missing it entirely, failing both tests with aFile.ErroronFile.cd!/1. No code behavior changed; added a.gitkeepplaceholder so the directory itself is tracked.
[0.2.0] - 2026-07-31
Added
.envfile support:Cooper.load_file/2/load_string/2now layer.env,.env.<env>, and.env.local(project root, later winning) into${...}resolution, on by default, via the new optional:dotenvydependency andCooper.Dotenvmodule. A missing file is never an error.:dotenv: falsedisables just the.envfile layers;:dotenv_envpicks the per-environment file, defaulting to liveMix.env/0when Mix is loaded, elseApplication.compile_env(:cooper, :dotenv_env)(opt in from a host release withconfig :cooper, dotenv_env: config_env()in its ownconfig/config.exs-- deliberately not a bareMix.env/0read from inside Cooper's own source, which Mix always compiles under:prodregardless of the host app's real build env, confirmed empirically againstmix help deps's documented dependency-env isolation);:dotenv_filesfully replaces the default four-file list.Cooper.Cache: caches everything up to the final${...}-resolution step, keyed by mtime across the entry file and every transitively bare-imported file (a GenServer-owned,:publicETS table with stampede protection — concurrent misses on the same file coalesce into one load).${...}resolution itself is never cached — it re-runs against a freshly-computed:envon every call, hit or miss, so an ordinary value read is always current.Cooper.Cache.invalidate/1andCooper.Cache.clear/0bust a specific entry or everything. One documented limitation: a${?NAME}guard's decision is baked in at populate time and only refreshes when the cache entry itself invalidates, not on every access. (${...}inside animport "..."path is unrelated -- CASC.md §5.1 doesn't support it at all, so it's always an unconditional load-time error, never something cached.)- Cooper is now a proper OTP application (
mix.exsgains amod:entry): a new internal application module starts a small supervision tree owningCooper.Cache's process. Idle (no background work) until the firstload_file/2call, and no polling timer until the first call whose file references the environment (seewatch_envbelow). Cooper.Cacheemits:telemetry(new, non-optional dependency) events:[:cooper, :cache, :file_changed], always, when a previously-cached entry's fingerprint no longer matches disk (never on first load);[:cooper, :cache, :env_changed], viaload_file/2's newwatch_envoption (defaults totruefor a file that references${...}at all -- an ordinary value, a${?NAME}guard, or both --falseotherwise; pass it explicitly to override), when a watched${NAME}changes -- a realSystem.put_env/2or a.envfile edit, either one, checked againstCooper.Dotenv.env/1on a poll timer (Application.get_env(:cooper, :env_poll_interval, 5_000)), since neither has an OS-level push notification to hook into. A genuine external OS environment change (outside the running app entirely) is never observable by anything, Cooper included -- documented, not a gap to close. A detected change also invalidates the cache entry, so a${?NAME}guard depending on a watched name refreshes within one poll interval instead of only on a file change.Cooper.Resolvergainedresolve_with_env_names/2(the ordinary-value-read tracking this relies on, alongsideCooper.Actions'/Cooper.Loader's new guard-name tracking) next to the unchangedresolve/2.
Changed
- Breaking:
Cooper.load_file/2now caches by default (seeCooper.Cache, above) -- previously it always did a full fresh parse+merge+resolve on every call. For an unchanged file, the result is identical, just faster on repeat calls; the one real behavior difference is that a${?NAME}guard's decision is now sticky until the cache entry invalidates, rather than re-evaluated on every single call (mitigated bywatch_env's own default -- see above). Passcache: falseto restore the old always-fresh behavior for a specific call.Cooper.load_string/2is unaffected -- it never caches, since there is no file to key a cache on. - Breaking:
:envis now an override layer, not the sole source of${...}resolution. The full precedence chain, later winning, isSystem.get_env/0<.env<.env.<dotenv_env><.env.local<:env. Previously, passing:envfully replacedSystem.get_env/0and nothing else was consulted; nowSystem.get_env/0(and any.envfile) is always in the mix, and:envguarantees a value only for the names it explicitly defines — a${...}reference to any other name still falls through to the real environment/.envfiles, exactly as if:envweren't passed. Code (including tests) that relied on:envfully isolating resolution from the real environment needs to either give every relevant name its own explicit:enventry, or passdotenv: falseif only the.envfile layers (notSystem.get_env/0) need ruling out. Per SemVer's pre-1.0 rules, this will ship as a0.xbump, not1.0.0.
[0.1.0] - 2026-07-31
Added
Cooper.load_file/2andCooper.load_string/2, loading a CASC config file (or source string) into native Elixir terms end to end: lex+parse, loop expansion, import resolution, merge, reference resolution, and secret-wrapping.- A complete CASC grammar (
priv/grammar/casc.aether, built on Ichor, a sibling grammar-compiler library): version headers, comments, disabled statements, key paths and nested blocks, dotted and quoted key segments, secret keys (*key), and every literal form CASC.md §6 defines — nil, booleans, integers (decimal/hex/octal/binary), floats, atoms, dates and times, durations, byte sizes, strings (double/single/triple-quoted), lists, and tuples (kept as real Elixir tuples, never coerced to lists). Cooper.IPv4/Cooper.IPv6(CASC.md §6.7): dedicated, validated types for IP address literals (127.0.0.1,::1/128) — an out-of-range octet, malformed address, or CIDR prefix outside0..32/0..128fails at load time with a named error, never a crash. Both support CIDR containment (contains?/2) and network math (network/1,netmask/1,first_host/1,last_host/1, plusbroadcast/1onCooper.IPv4).- Variables (
@name/@*name, public/private) and all five interpolation/reference forms (CASC.md §7):@{}variables,${}environment reads (with:default,:+alt,:?"required", list, and indexed forms),%{}lazy config references (resolved against the final merged tree, with cycle detection),!{resolver:payload}extensible dispatch, and!Name(arg)tagged values — five built-in tags (int/float/bool/duration/bytes) plus consumer-registered ones via the:tagsoption. - The merge model (CASC.md §8): deep-merge by default,
~key { ... }whole-subtree replace,+key/-keylist append/remove,-key.pathdelete, and a hard error (never a silent coercion) merging into a tuple. forloops (CASC.md §5.5): index and element bindings, parallel (zipped) multi-binding iteration, interpolated destination paths, andfrom <template>(a lazy base resolved against the final tree, with the loop body layered on top as overrides).importstatements (CASC.md §5.1): bare paths (with{a,b}brace and**glob expansion),scheme://dispatch via the:import_schemesoption, import-cycle detection, and public-variable propagation across import boundaries.Cooper.Secret, wrapping every*key-prefixed value —inspect/1andto_string/1redact unconditionally;Cooper.Secret.reveal/1is the only way to the real value. Wrapping happens at merge time and travels with the value through any later%{...}reference orfor ... fromtemplate copy, rather than staying pinned to the value's original declared path. A secret embedded inside a larger interpolated string redacts only its own portion, not the whole string, and independently for each of several secrets interpolated into the same string.- Two interchangeable backends per grammar (
Grammar.Nativeby default,Grammar.VMkept for parity/benchmarking) — seebench/native_vs_vm.exsandtest/cooper/backend_parity_test.exs. test/SPEC_COVERAGE.md, a traceability table mapping every section of CASC.md to the test(s) covering it, plus one documented gap: backslash-continued strings (CASC.md §6.5's fifth string form) are not implemented, since the spec gives no worked example to validate an implementation against.guides/EXAMPLES.md,guides/casc/TUTORIAL.md,guides/casc/CASC_EXAMPLES.md, andguides/casc/CASC_CHEATSHEET.md— every example in all four is verified against a realCooper.load_string/2call, not just written by hand.mix precommit(an alias inmix.exs):format,compile --warnings-as-errors,credo --strict,sobelow,test,dialyzer, in that fast-to-slow order, all underMIX_ENV=testso Credo/Dialyzer seetest/support/too. Backing dev/test dependencies:credo,dialyxir(PLT built with:mix/:ichoradded explicitly, sinceichor'sruntime: falsehides it from dialyxir's automatic OTP-app discovery even thoughtest/support/vm_parity.exgenuinely calls into it),sobelow,excoveralls, plusmox/faker/stream_dataavailable for tests that need them (none do yet)..credo.exsand.dialyzer_ignore.exs(the latter narrowly scoped to two known-benign Dialyzer findings aroundMapSet's opaque type in generated code, not a blanket suppression) are new at the repo root. SeeCONTRIBUTING.md's "Making a change" §4.
Changed
- Ichor split into
ichor(the Aether front-end,Grammar.Analysis, both codegen backends -- dev-time-only) andichor_runtime(the small support library generated code actually calls at runtime):priv/grammar/casc.aether/casc_interp.aetherare now compiled ahead of time bymix ichor.geninto checked-in modules (lib/cooper/native_grammar/native.ex,lib/cooper/native_interp_grammar/native.ex) instead of at Cooper's own compile time viause Ichor, grammar:, actions:, andlib/cooper/native_grammar/capture_shapes.ex(scripts/gen_capture_shapes.exs) does the same for the one other pieceCooper.Grammar'srun_with_context/3needed fromichorproper at runtime.mix.exsnow depends onichor_runtimeas an ordinary runtime dependency andichoritselfonly: [:dev, :test], runtime: false-- confirmed viaMIX_ENV=prod mix deps/mix compilethat a release build now touches onlyichor_runtime. TheGrammar.VMparity backend (bench/native_vs_vm.exs,test/cooper/backend_parity_test.exs) moved out oflib/intotest/support/vm_parity.exaccordingly, since it still needsichorproper andlib/compiles in every environment including:prod. ichor/ichor_runtimenow depend on their Hex-published releases (ichor ~> 0.2.1,ichor_runtime ~> 0.1.0) instead of Ichor's pre-mergefeature/runtimegit branch --ichor_runtimeis its own independently-published, independently-versioned package now (not a subdirectory ofichor's own repo), so nooverride:/sparse:wiring is needed the way there was while both lived behind onegit:spec.lib/cooper/native_grammar/native.ex,lib/cooper/native_interp_grammar/native.ex, andlib/cooper/native_grammar/capture_shapes.exregenerated against 0.2.1'smix ichor.gen; behavior unchanged (0.2.x's own changes were theichor/ichor_runtimesplit and docs -- see Ichor's own CHANGELOG.md).RESOLVER_REF_RAW(!{resolver:payload}'s token, in bothcasc.aetherandcasc_interp.aether) now uses Ichor 0.1.1's@native(...)token-position escape hatch (new module:Cooper.Native.ResolverRef) instead of a fixed one-level-of-nested-braces combinator form — a payload may now brace-nest to any depth, matching CASC.md §7.4's own "brace-balanced" wording exactly, rather than the previous implementation's slightly narrower approximation of it.- The
Grammar.VMparity backend (nowCooper.Test.VMParity.run_with_context_vm/3, see above) updated for Ichor 0.1.1'sGrammar.VM.Lexer→Grammar.VM.Tokenizerrename and its newcustom_lexemes/@keywords/@refine-reclassification pipeline stages.
Fixed
Cooper.Loader's per-importsub_ctxnow carries:envforward — previously, a${?NAME}conditional statement (CASC.md §7.2) inside an imported file crashed withKeyError: key :env not foundinstead of reading the importer's own environment, since the imported file's own parse-time context silently dropped that key.