encoding/envfile
Reading and writing .env files, with variable substitution.
import encoding.envfile
A .env file holds a program's settings and secrets as one KEY=value per line, the format used by Docker Compose, most twelve-factor apps and tools such as dotenv. This package reads such a file into a Doc, an ordered list of keys and string values, substitutes ${NAME} references, and writes a Doc back as text.
It works on text and never touches the process environment. If you only want a .env file's variables available to the program, env.Load(path) in env puts them into the environment in one call (and env.Overload lets the file win over variables already set). config.LoadEnv in config reads one into a Config with typed getters such as GetInt and GetBool. Use this package when you need the file itself: its order, its references, a strict check, or to write it back. For nested settings with numbers and lists, encoding/toml or encoding/yaml fit better.
What a line may contain
- Blank lines and lines starting with
#are skipped, and a UTF-8 byte order mark at the start is ignored.exportbefore a key is allowed, so a file a shell cansourcealso parses. - Keys are letters, digits,
_,.and-. Spaces around the=are allowed. - An unquoted value runs to the end of the line, minus trailing spaces. A
#starts a comment only after a space, sohttps://x/#topkeeps its#. - A double-quoted value understands
\n,\t,\"and\\, may span several lines, and takes${NAME}substitution. A single-quoted value is taken literally, with no escapes and no substitution. - A key that appears twice keeps its first position and its last value.
What trips people up
- Every value is a string.
PORT=8080gives"8080"; convert it yourself, and remember that"abc".to_int()is 0, not an error. - References only look upward.
${NAME}is resolved from lines above it (then from thebasedocument, if you pass one). A name defined further down, or not at all, silently becomes an empty string. UseRefNameson aParseRawresult to see what a file expects. Parseskips bad lines without a word. A line likeSECRET KEY 8f2cjust disappears. Load files withParseStrictat startup.Formatdrops comments and blank lines; it writes only keys and values.
Guide: shared defaults and local overrides
Most projects commit a .env with safe defaults and let each developer keep a .env.local with their own ports and passwords. Parse both and MergeInto the local one: its keys replace the shared ones, and keys that only exist in the shared file stay. GetOr covers settings neither file has.
import encoding.envfile
func main:
shared := `# committed to the repository
APP_NAME=shop
PORT=8080
LOG_LEVEL=info
DB_URL=postgres://localhost/shop
`
local := `# .env.local, kept out of git
PORT=9000
DB_URL=postgres://sara:secret@localhost/shop_dev
`
mut cfg := envfile.Parse(shared)
mut over := envfile.Parse(local)
envfile.MergeInto(cfg, over)
port := envfile.Get(cfg, "PORT").to_int()
println envfile.Get(cfg, "APP_NAME"), "on port", port
println "log level:", envfile.GetOr(cfg, "LOG_LEVEL", "warn")
println "workers:", envfile.GetOr(cfg, "WORKERS", "2").to_int()
println "keys in order:", envfile.Keys(cfg)
envfile.Free(over)
envfile.Free(cfg)
endEvery value is a string; convert numbers with to_int(). A merged key keeps its original place, so Keys still lists them in the order of the shared file. In a real program the two texts come from file.ReadAll in fs/file.
Guide: comments, quotes and multi-line values
Real .env files mix comments, URLs with # in them, PEM keys pasted over several lines and prices with a $ in them. The program below reads one of each, so you can see where each rule from the list above applies.
import encoding.envfile
func main:
text := `export APP_NAME=shop # trailing comment
HOME_URL=https://shop.example/#top
MOTD="Welcome!\nOrders ship daily."
TLS_KEY="-----BEGIN KEY-----
MIIEvQIBADANBg
-----END KEY-----"
PRICE_NOTE='$5 off, literally'
EARLY=${LATER}
LATER=set below
PORT=8080
PORT=9090
`
mut d := envfile.Parse(text)
println "APP_NAME:", envfile.Get(d, "APP_NAME")
println "HOME_URL:", envfile.Get(d, "HOME_URL")
println "MOTD:", envfile.Get(d, "MOTD")
key := envfile.Get(d, "TLS_KEY")
println "TLS_KEY lines:", key.split("\n").len()
println "PRICE_NOTE:", envfile.Get(d, "PRICE_NOTE")
println "EARLY: [" + envfile.Get(d, "EARLY") + "]"
println "PORT:", envfile.Get(d, "PORT"), "keys:", envfile.Len(d)
envfile.Free(d)
endThe trailing comment after shop is dropped because a space comes before the #; the one in HOME_URL stays. The double-quoted MOTD turns \n into a real line break, and TLS_KEY keeps its three lines. EARLY is empty because LATER is defined after it, and PORT holds the second value but is counted once.
Guide: references between variables
Values can refer to other keys with ${NAME} or $NAME, and ${NAME:-default} falls back when the key is not set. Parse resolves references within the file. To fill some of them from elsewhere, for example secrets that only the server knows, read the file with ParseRaw and resolve it with ExpandDoc and a second Doc of base values.
import encoding.envfile
func main:
text := `DB_HOST=localhost
DB_PORT=5432
DB_NAME=shop
DATABASE_URL=postgres://${DB_USER}@${DB_HOST}:${DB_PORT}/${DB_NAME}
CACHE_URL=redis://${REDIS_HOST:-127.0.0.1}:6379
GREETING='costs $5, not expanded'
`
mut raw := envfile.ParseRaw(text)
println "references:", envfile.RefNames(raw)
mut base := envfile.New()
envfile.Set(base, "DB_USER", "app")
mut env := envfile.ExpandDoc(raw, base)
println envfile.Get(env, "DATABASE_URL")
println envfile.Get(env, "CACHE_URL")
println envfile.Get(env, "GREETING")
println "template:", envfile.Expand("${DB_NAME}_test", env, base)
envfile.Free(env)
envfile.Free(base)
envfile.Free(raw)
endSingle quotes turn expansion off, as in a shell, so $5 stays as written; in a double-quoted or bare value write \$ for a literal dollar sign. ${NAME:-x} uses x when the name is missing or empty, ${NAME-x} only when it is missing. RefNames lists every name a file refers to, which is a quick way to tell an operator which variables a deployment needs. To use the process's own environment as the base, copy the variables you need into it with env.Get from env.
Guide: fail early on a broken file
Parse is forgiving and skips lines it cannot read. At startup it is better to stop with a clear message: ParseStrict fills an EncodingError with the reason and the byte where the bad line starts. After that, check the keys the program cannot run without.
import encoding.envfile
import encoding.encerr
func load(name: str, text: str):
mut e := encerr.NoError()
mut d := envfile.ParseStrict(text, e)
if encerr.IsError(e):
println name, "error at byte", e.position, "->", e.message
envfile.Free(d)
ret
end
required := ["DB_URL", "SECRET_KEY"]
mut missing := 0
each k in required:
if not envfile.Has(d, k) or envfile.Get(d, k) == "":
println name, "missing", k
missing += 1
end
end
if missing == 0:
println name, "ok with", envfile.Len(d), "keys"
end
envfile.Free(d)
end
func main:
load("prod:", "DB_URL=postgres://db/shop\nSECRET_KEY=8f2c\n")
load("dev: ", "DB_URL=postgres://localhost/shop\nSECRET_KEY=\n")
load("typo:", "DB_URL=postgres://db/shop\nSECRET KEY 8f2c\n")
load("open:", "DB_URL=\"postgres://db/shop\n")
endTreat an empty value like a missing one for required keys, because SECRET_KEY= is a common leftover from a template.
Guide: change a file and write it back
Tools that edit settings, such as an installer, change a Doc with Set and Remove and write it with Format. Values are quoted only when they need it, and quotes inside are escaped, so Parse reads the text back to the same values. FormatExport puts export before each line, for a file a shell can source.
import encoding.envfile
func main:
mut d := envfile.Parse("APP_NAME=shop\nPORT=8080\n")
envfile.Set(d, "PORT", "9090")
envfile.Set(d, "WELCOME", "Hello, \"friend\"")
envfile.Set(d, "SEARCH_URL", "http://127.0.0.1:7700")
println "removed APP_NAME:", envfile.Remove(d, "APP_NAME")
print envfile.Format(d)
println "---"
print envfile.FormatExport(d)
println "---"
println envfile.Quote("no-spaces"), envfile.Quote("two words")
mut again := envfile.Parse(envfile.Format(d))
println "round trip:", envfile.Get(again, "WELCOME")
envfile.Free(again)
envfile.Free(d)
endFormat writes keys in order and drops comments, so use it for files your program owns. For a file people maintain by hand, prefer telling them which line to change.
Types
Doc
struct Doc| Name | Type | Description |
|---|---|---|
m | collections.OrderedMap | The keys and values, in order. |
flags | Vector | For each key, whether its value takes substitution; single quotes turn it off. |
import encoding.envfile
import encoding.encerr
func main:
text := "# settings\nHOST=localhost\nPORT=8080\nexport NAME=\"hello world\"\nURL=http://${HOST}:${PORT}/\n"
mut doc := envfile.Parse(text)
println envfile.Len(doc), envfile.Keys(doc)
println envfile.Get(doc, "NAME"), envfile.Has(doc, "DEBUG"), envfile.GetOr(doc, "DEBUG", "false")
println envfile.KeyAt(doc, 0), envfile.ValueAt(doc, 1)
println envfile.Get(doc, "URL")
raw := envfile.ParseRaw(text)
println envfile.Get(raw, "URL"), envfile.RefNames(raw)
mut base := envfile.New()
expanded := envfile.ExpandDoc(raw, base)
println envfile.Get(expanded, "URL")
println envfile.Expand("${HOST}/api", doc, base), envfile.RefNamesIn("$A and ${B}")
envfile.Set(doc, "DEBUG", "true")
println envfile.Remove(doc, "URL")
println envfile.Format(doc)
println envfile.FormatExport(base) == "", envfile.Quote("a b")
mut other := envfile.Parse("PORT=9090\nMODE=prod\n")
envfile.MergeInto(doc, other)
println envfile.Get(doc, "PORT"), envfile.Get(doc, "MODE")
mut e := encerr.NoError()
_ := envfile.ParseStrict("=no key\n", e)
println encerr.IsError(e), envfile.Valid("A=1\n")
println envfile.ParseScalar(`"a\tb"`), envfile.ParseScalarEx("x ; comment", true)
envfile.Free(doc)
envfile.Free(raw)
envfile.Free(base)
envfile.Free(expanded)
envfile.Free(other)
endFunctions
ExpandDoc
func ExpandDoc(d &: Doc, base &: Doc): Docbase; a name found in neither becomes an empty string. The result is fully expanded, so RefNames finds nothing in it.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
Expand
func Expand(raw: str, d &: Doc, base &: Doc): strbase.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
RefNames
func RefNames(d &: Doc): Vector<str>ParseRaw result; after Parse the references are already gone.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
RefNamesIn
func RefNamesIn(raw: str): Vector<str>$A and the ${A} form.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
Quote
func Quote(v: str): strThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
Format
func Format(d &: Doc): strKEY=value per line, quoting and escaping values that need it. Comments are not kept.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
FormatExport
func FormatExport(d &: Doc): strFormat, with export before each line, for a shell.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
ParseRaw
func ParseRaw(text: str): DocThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
Parse
func Parse(text: str): DocThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
ParseStrict
func ParseStrict(text: str, err &: encerr.EncodingError): DocParse, but the first malformed line or unclosed quote stops reading and fills err, with position at the start of that line. The keys read before it are returned.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
Valid
func Valid(text: str): boolThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
ParseScalar
func ParseScalar(raw: str): str=: it strips the quotes and the escapes.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
ParseScalarEx
func ParseScalarEx(raw: str, semi: bool): strParseScalar; when semi is true, ; starts a comment as well as #.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
New
func New(): DocThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
Len
func Len(d &: Doc): intThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
Has
func Has(d &: Doc, key: str): boolThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
Get
func Get(d &: Doc, key: str): strThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
GetOr
func GetOr(d &: Doc, key: str, def: str): strThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
Set
func Set(d &: Doc, key: str, val: str)The example for this item is shown with the ones above: encoding-envfile-Parse.salam
Remove
func Remove(d &: Doc, key: str): boolThe example for this item is shown with the ones above: encoding-envfile-Parse.salam
KeyAt
func KeyAt(d &: Doc, i: int): stri.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
ValueAt
func ValueAt(d &: Doc, i: int): stri.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
Keys
func Keys(d &: Doc): Vector<str>The example for this item is shown with the ones above: encoding-envfile-Parse.salam
MergeInto
func MergeInto(dst &: Doc, src &: Doc)src into dst, replacing any that are already there.The example for this item is shown with the ones above: encoding-envfile-Parse.salam
Free
func Free(d &: Doc)The example for this item is shown with the ones above: encoding-envfile-Parse.salam