Changelog
Copy MarkdownAll 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.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. SeeCONTRIBUTION.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.