sema-config

0.1.0

Layered configuration: files, env vars, profiles, and reference expansion

$ sema pkg install sema-config
Readme Versions 1 Dependencies 0

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

VersionSizePublished
0.1.0 10 KB 2026-07-07 21:27:55
No dependencies