Cooper (Cooper v0.2.0)
Copy MarkdownParses and loads CASC config files (see CASC.md) into native Elixir
terms: maps (string keys), atoms, real tuples (never lists --
CASC.md §6.11 is explicit that this is a correctness requirement, not
an implementation detail), and Cooper.Secret-wrapped values for any
*key-prefixed secret.
load_file/2/load_string/2 run the whole pipeline end to end:
lex+parse (Cooper.Grammar, Cooper.Actions) → loop expansion
(Cooper.Loop) → import resolution (Cooper.Loader) → merge and
secret-wrapping (Cooper.Merge) → reference resolution
(Cooper.Resolver, which re-wraps a Cooper.Secret's own inner
value as it resolves, so the wrapping travels through a %{...}
reference or a for ... from template copy rather than staying
pinned to the value's original declared path). load_file/2 caches
everything before that final reference-resolution step, on by
default, keyed by file mtime -- see Cooper.Cache. load_string/2
has no file to key a cache on, so it never caches.
Options
Both functions take the same opts:
:env--%{String.t() => String.t()}, an override layer for${...}(CASC.md §7.2) resolution -- not a replacement for the real environment.System.get_env/0is always the floor (layered under.env/.env.local, in turn under:env, seeCooper.Dotenv); a name given here always wins, but a name not given here still falls through to a real OS/.envvalue if one is set. There is currently no option that fully isolates resolution from the real environment -- give every name a test needs a deterministic value under:envexplicitly, rather than relying on it being otherwise unset.:dotenv/:dotenv_env/:dotenv_files-- layer.envfile(s) from the project root betweenSystem.get_env/0and:env, via the optional:dotenvydependency, on by default. SeeCooper.Dotenvfor the full layering rules, environment detection, and how to disable or reconfigure it.:root-- filesystem root a bare (non-scheme://)importresolves relative to (§5.1).load_file/2derives this frompath's own directory automatically;load_string/2defaults toFile.cwd!/0.:resolvers--%{String.t() => (payload :: String.t() -> {:ok, term()} | {:error, term()})}, one function per!{resolver:...}name your application registers (§7.4/§9.2). Unregistered use of any resolver not in this map is a load-time error naming it.:tags-- same shape, for!Name(arg)beyond the five built-ins (int/float/bool/duration/bytes, §7.5/§9.1). Unregistered use is a load-time error naming it, same as:resolvers.:import_schemes--%{String.t() => (rest :: String.t() -> {:ok, String.t()} | {:error, term()})}, one loader perimport "scheme://..."scheme your application registers (§5.1/§9.3), returning the imported CASC source text. Unregistered use of any scheme not in this map is a load-time error naming it.:cache--load_file/2only (load_string/2never caches -- there's no file to key one on).boolean(), defaults totrue.falsebypasses the cache entirely for that one call.:watch_env--load_file/2only.boolean(), defaults to whether this call references the environment at all (an ordinary${NAME}value, a${?NAME}guard, or both) --truefor a file that does,falsefor one that doesn't. Either way, polls every referenced name for changes, firing[:cooper, :cache, :env_changed](seeCooper.Cache) if any of them do; pass it explicitly to override the default in either direction.
Errors
Every stage returns {:error, Ichor.Error.t() | [Ichor.Error.t()]} on
failure, reusing Ichor.Error directly rather than inventing a
parallel error type -- lexer/parser/analysis errors come from Ichor's
own stages unchanged; Cooper's own stages (:dotenv, :loop,
:import, :merge, :resolve) extend the same %Ichor.Error{stage: ...} shape with new atoms rather than a new struct, so every error
looks the same regardless of which stage raised it.
A note on atoms
CASC atoms (level = info, CASC.md §6.4) become Elixir atoms via
String.to_atom/1, for the ergonomics of pattern-matching against a
config value the ordinary Elixir way. Elixir's atom table is bounded
and never garbage-collected -- fine for config sourced from a fixed,
trusted set of files (typical CASC usage), but worth flagging
explicitly if Cooper is ever pointed at config sources that aren't
trusted/fixed at deploy time (e.g. user-uploaded config): an attacker
who can supply arbitrary atom-shaped text can exhaust the atom table.
Not a concern for the common case; a real one to know about outside it.
Summary
Functions
Reads and loads the CASC file at path. :root (see the moduledoc)
defaults to path's own directory, and import-cycle detection is
seeded with path itself (Cooper.Grammar.run_file/2's own :file
option), so a cycle that loops back to the entry file is caught on the
first repeat.
Parses, expands loops, resolves imports, merges, and fully resolves
source (a complete CASC file's own text, own version header
included) -- the same pipeline load_file/2 runs, for a source that
isn't (yet, or ever) sitting in a real file. Imports using a bare
(non-scheme://) path still need a real :root unless registered
via :import_schemes.
Types
@type opts() :: [ env: %{optional(String.t()) => String.t()}, dotenv: boolean(), dotenv_env: atom() | nil, dotenv_files: [String.t()], cache: boolean(), watch_env: boolean(), root: String.t(), resolvers: %{ optional(String.t()) => (String.t() -> {:ok, term()} | {:error, term()}) }, tags: %{optional(String.t()) => (term() -> {:ok, term()} | {:error, term()})}, import_schemes: %{ optional(String.t()) => (String.t() -> {:ok, String.t()} | {:error, term()}) } ]
Functions
@spec load_file(String.t(), opts()) :: {:ok, term()} | {:error, Ichor.Error.t() | [Ichor.Error.t()]}
Reads and loads the CASC file at path. :root (see the moduledoc)
defaults to path's own directory, and import-cycle detection is
seeded with path itself (Cooper.Grammar.run_file/2's own :file
option), so a cycle that loops back to the entry file is caught on the
first repeat.
Caches the pre-resolve tree Cooper.Grammar produces, on by
default, and reuses it across calls as long as every file that
contributed to it -- this one, plus every transitively bare-imported
file -- still has the mtime it had when the cache entry was populated
(see Cooper.Cache). ${...} resolution is never part of what's
cached: it re-runs against a freshly-computed Cooper.Dotenv.env/1 on
every call, hit or miss, so an ordinary ${NAME} value read is
always current regardless of caching.
Pass cache: false to bypass the cache entirely for one call -- for
forcing a guaranteed-fresh read, or in tests.
One real limitation, not a bug: a ${?NAME} guard's decision is
baked in at the moment the cache entry is populated, and only
refreshes when that entry itself invalidates -- not on every access
the way an ordinary value read is. Without watch_env: true (below),
the only thing that invalidates it is a fingerprinted file changing,
or an explicit Cooper.Cache.invalidate/1/clear/0. See
Cooper.Cache's moduledoc for why that's a deliberate scope
boundary. cache: false sidesteps it entirely, at the cost of a full
reparse on every call. (${...} inside an import "..." path is a
different matter entirely -- CASC.md §5.1 doesn't support it, so it's
always an unconditional load-time error, never something to keep
fresh.)
watch_env polls every ${NAME} this call reads for changes -- an
ordinary value, a guard, or both -- and defaults to true for a file
that references the environment at all, false for one that doesn't
(pass it explicitly either way to override). Checked on a timer
rather than pushed live: a real System.put_env/2 elsewhere in this
app and a .env file edit both count, since neither has an OS-level
notification to hook into. A detected change fires [:cooper, :cache, :env_changed] and invalidates the entry, so the guard limitation
above narrows to "up to one poll interval stale" for a watched name,
instead of "stale until the file changes." Cooper.Cache also fires
[:cooper, :cache, :file_changed] whenever a cached entry's own
fingerprint changes, unconditionally -- no option needed for that
one. See Cooper.Cache.
@spec load_string(String.t(), opts()) :: {:ok, term()} | {:error, Ichor.Error.t() | [Ichor.Error.t()]}
Parses, expands loops, resolves imports, merges, and fully resolves
source (a complete CASC file's own text, own version header
included) -- the same pipeline load_file/2 runs, for a source that
isn't (yet, or ever) sitting in a real file. Imports using a bare
(non-scheme://) path still need a real :root unless registered
via :import_schemes.