Standard library · Encoding and data

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. export before a key is allowed, so a file a shell can source also 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, so https://x/#top keeps 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=8080 gives "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 the base document, if you pass one). A name defined further down, or not at all, silently becomes an empty string. Use RefNames on a ParseRaw result to see what a file expects.
  • Parse skips bad lines without a word. A line like SECRET KEY 8f2c just disappears. Load files with ParseStrict at startup.
  • Format drops 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.

encoding-envfile-guide-layers.salam
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)
end
Outputshop on port 9000 log level: info workers: 2 keys in order: ["APP_NAME", "PORT", "LOG_LEVEL", "DB_URL"]

Every 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.

encoding-envfile-guide-syntax.salam
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)
end
OutputAPP_NAME: shop HOME_URL: https://shop.example/#top MOTD: Welcome! Orders ship daily. TLS_KEY lines: 3 PRICE_NOTE: $5 off, literally EARLY: [] PORT: 9090 keys: 8

The 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.

encoding-envfile-guide-expand.salam
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)
end
Outputreferences: ["DB_USER", "DB_HOST", "DB_PORT", "DB_NAME", "REDIS_HOST"] postgres://app@localhost:5432/shop redis://127.0.0.1:6379 costs $5, not expanded template: shop_test

Single 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.

encoding-envfile-guide-validate.salam
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")
end
Outputprod: ok with 2 keys dev: missing SECRET_KEY typo: error at byte 26 -> envfile: expected KEY=VALUE open: error at byte 0 -> envfile: unterminated quoted value

Treat 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.

encoding-envfile-guide-write.salam
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)
end
Outputremoved APP_NAME: true PORT=9090 WELCOME="Hello, \"friend\"" SEARCH_URL=http://127.0.0.1:7700 --- export PORT=9090 export WELCOME="Hello, \"friend\"" export SEARCH_URL=http://127.0.0.1:7700 --- no-spaces "two words" round trip: Hello, "friend"

Format 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
The contents of an env file.
NameTypeDescription
mcollections.OrderedMapThe keys and values, in order.
flagsVectorFor each key, whether its value takes substitution; single quotes turn it off.
encoding-envfile-Parse.salam
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)
end
Output4 ["HOST", "PORT", "NAME", "URL"] hello world false false HOST 8080 http://localhost:8080/ http://${HOST}:${PORT}/ ["HOST", "PORT"] http://localhost:8080/ localhost/api ["A", "B"] true HOST=localhost PORT=8080 NAME="hello world" DEBUG=true true "a b" 9090 prod true true a b x

Functions

ExpandDoc

func ExpandDoc(d &: Doc, base &: Doc): Doc
Builds a new document with the references substituted. A name is looked up among the keys above it in the document, then in base; 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): str
Substitutes the references in a string from the document and base.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

RefNames

func RefNames(d &: Doc): Vector<str>
The names of the variables the document's values refer to, in order of first use. Call it on a 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>
The names of the variables referred to in a string, in both the $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): str
Puts a value in double quotes when it needs them.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

Format

func Format(d &: Doc): str
Turns the document back into env file text, one KEY=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): str
Like Format, 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): Doc
Reads a file without substituting the references.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

Parse

func Parse(text: str): Doc
Reads a file and substitutes the references. Lines it cannot read are skipped silently.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

ParseStrict

func ParseStrict(text: str, err &: encerr.EncodingError): Doc
Like Parse, 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): bool
True when every line is well formed.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

ParseScalar

func ParseScalar(raw: str): str
Reads a single value, as on the right of an =: 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): str
Like ParseScalar; 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

Has

func Has(d &: Doc, key: str): bool
True when the key is there.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

Get

func Get(d &: Doc, key: str): str
A key's value, or an empty string when it is absent.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

GetOr

func GetOr(d &: Doc, key: str, def: str): str
A key's value, or the default.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

Set

func Set(d &: Doc, key: str, val: str)
Sets a key's value; a new key goes on the end. The value is stored literally and is never expanded.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

Remove

func Remove(d &: Doc, key: str): bool
Removes a key and says whether it was there.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

ValueAt

func ValueAt(d &: Doc, i: int): str
Value number i.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam

MergeInto

func MergeInto(dst &: Doc, src &: Doc)
Pours the keys of src into dst, replacing any that are already there.

The example for this item is shown with the ones above: encoding-envfile-Parse.salam