sema-config
0.1.0Layered configuration: files, env vars, profiles, and reference expansion
sema-config
Layered configuration: files, env vars, profiles, and reference expansion.
Modeled on Clojure's aero and environ: configuration is plain data, sources are deep-merged left-to-right, and a small set of directives expands environment variables, internal references, and per-environment profiles.
Directive keys use a
%sigil (:%env,:%ref,:%or,:%profiles) because the Sema reader does not accept$in keywords.
Install
sema pkg add sema-config
Quick start
config.sema:
{:db {:host "localhost"
:port {:%env "DB_PORT" :default 5432 :as :int}
:%profiles {:prod {:host "db.internal"}}}
:api {:url "https://${API_HOST:api.example.com}/v1"}}
(import "sema-config")
(define cfg (config/load (list "config.sema"
(config/env-overrides "APP_"))
{:profile :prod}))
(config/get-in cfg [:db :host]) ; => "db.internal" (prod profile)
(config/get-in cfg [:db :port]) ; => 5432 (or DB_PORT, as an int)
(config/get-in cfg [:api :url]) ; => "https://api.example.com/v1"
; APP_DB__HOST=elsewhere would override [:db :host] — later sources win.
Config files
(config/read path) dispatches on extension:
| Extension | Parser | Notes |
|---|---|---|
.sema |
Sema reader | The primary format — a single map literal; keywords, vectors, and directive maps are written natively |
.toml |
toml/decode |
Tables become nested keyword-keyed maps |
.json |
json/decode |
Object keys become keywords |
All formats normalize to keyword-keyed maps, so sources of different formats
merge cleanly. Any other extension, or a missing file, is an error —
{:optional #t} turns a missing file into {}.
In .toml and .json files, directives are only available as ${VAR} strings
(those formats can't express keyword-keyed directive maps); .sema files
support every directive below.
Expansion reference
Profiles resolve per source before merging; directive expansion then runs once, over the fully merged map, so every directive sees the final layered values.
${VAR} string interpolation
Any string value may embed ${VAR} or ${VAR:default} substrings:
{:url "postgres://${DB_USER}:${DB_PASS}@${DB_HOST:localhost}/app"}
Each ${VAR} is replaced with the env var's value; ${VAR:default} falls back
to the literal default text. A missing var with no default is an error naming
the key path and the variable. Results are always strings — use :%env with
:as when you need a typed value.
:%env — typed environment values
{:port {:%env "PORT" :default 3000 :as :int}}
Replaces the map with the env var's value. Options:
:default— returned as-is (already typed) when the var is unset. Without it, an unset var is an error naming the key path and variable.:as— coerce the env string::int,:float,:bool("true"/"1"→#t,"false"/"0"→#f, anything else errors), or:str(no-op). Coercion applies only to the env value, never to:default.
:%ref — internal references
{:db {:host "db.internal"}
:metrics {:endpoint {:%ref [:db :host]}}}
Replaces the map with the value at another key path in the same config. Refs
resolve after merging (they see the winning layered value), the target is
itself expanded (ref-to-ref and refs to ${...} strings work), and reference
cycles are detected and reported with the full chain.
:%or — first non-nil
{:host {:%or [{:%env "HOST" :default nil}
{:%ref [:db :host]}
"localhost"]}}
Expands alternatives left to right and returns the first non-nil result (or
nil if all are). Note that an unset :%env without a default is still an
error — give it :default nil to fall through explicitly.
:%profiles — per-environment sections
{:db {:host "localhost"
:pool 2
:%profiles {:dev {:pool 1}
:prod {:host "db.internal" :pool 10}}}}
The selected profile's map is deep-merged over its parent level, then
:%profiles is removed. Selection: config/load's {:profile :prod} option,
else the SEMA_PROFILE env var, else no profile section is applied (sections
are just dropped). :%profiles may appear at any depth.
API
| Function | Description |
|---|---|
(config/read path opts?) |
Parse one .sema/.toml/.json file into a keyword-keyed map |
(config/load sources opts?) |
Merge sources left-to-right, apply profile, expand directives |
(config/env-overrides prefix) |
Nested map from env vars with a prefix |
(config/get cfg key default?) |
Top-level lookup with default |
(config/get-in cfg path default?) |
Nested lookup with default |
(config/require! cfg paths) |
Error listing all missing key paths |
config/read
(config/read "config.toml")
(config/read "config.local.sema" {:optional #t}) ; => {} when missing
Parses a single file by extension and normalizes keys to keywords. No merging or expansion happens here — use it for one-off reads or to build custom source lists.
config/load
(config/load (list "config.sema" ; base file
"config.local.sema" ; a later file wins...
{:debug #t} ; ...inline maps too
(config/env-overrides "APP_")) ; env vars win last
{:profile :prod})
The entry point. sources is a list of file paths and/or maps (a single path
or map is also accepted), deep-merged left-to-right — later sources win, and
nested maps merge rather than replace. After merging, the selected profile is
applied and all directives are expanded. Options: {:profile :prod} (string
or keyword; falls back to SEMA_PROFILE). Profiles are resolved in each
source before merging, so a base file's :%profiles section never clobbers a
later source's override.
config/env-overrides
;; APP_DB__HOST=x APP_MAX_RETRIES=3
(config/env-overrides "APP_")
; => {:db {:host "x"} :max-retries "3"}
Collects every env var starting with prefix into a nested map: __ (double
underscore) nests, and each segment becomes a lowercase kebab-case keyword
(MAX_RETRIES → :max-retries). Values stay strings — coercion is the
consumer's job (via :%env, or parse at the use site); this keeps overrides
predictable regardless of what they happen to look like.
config/get / config/get-in
(config/get cfg :port 3000)
(config/get-in cfg [:db :host] "localhost")
Thin wrappers over the stdlib get and map/get-in, kept so config access
reads uniformly (config/... end to end). Identical semantics: nil (or the
default) when the key path is absent.
config/require!
(config/require! cfg (list [:db :host] [:db :user] [:port]))
; error: config/require!: missing required config paths: db.user, port — ...
Validates that every key path exists (a present key with a nil value
counts as present). All missing paths are reported in one error, so a broken
deploy shows the complete list at once. Returns cfg for chaining.
Composing with sema-dotenv
sema-dotenv loads .env files
into the process environment; config/load reads that environment. Load the
.env first and every ${VAR}, :%env, and config/env-overrides source
picks it up:
(import "sema-dotenv")
(import "sema-config")
(dotenv/load-if-exists ".env")
(define cfg (config/load (list "config.sema" (config/env-overrides "APP_"))))
Testing
sema pkg add sema-test # once
sema tests.sema
All tests run offline against fixture files and process-local env vars.
License
MIT
| Version | Size | Published |
|---|---|---|
| 0.1.0 | 10 KB | 2026-07-07 21:27:55 |