Packages

Type-safe environment variables for Gleam. Zero dependencies.

Current section

Files

Jump to
envie src envie parse.gleam
Raw

src/envie/parse.gleam

/// Minimal `.env` parser.
///
/// Supports comments (`#`), blank lines, `export` prefix, quoted values,
/// and inline comments. Returns all parse errors together for easier reporting.
import envie/error
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
/// Parse a string in .env format into a list of `#(key, value)` pairs.
///
/// Lines that are empty or start with `#` are silently skipped.
/// Lines that do not contain an `=` produce a `ParseError`.
pub fn parse_dotenv(
content: String,
) -> Result(List(#(String, String)), List(error.ParseError)) {
content
|> string.split("\n")
|> list.index_map(fn(line, idx) { #(idx + 1, line) })
|> parse_lines([], [])
}
fn parse_lines(
lines: List(#(Int, String)),
vars: List(#(String, String)),
errors: List(error.ParseError),
) -> Result(List(#(String, String)), List(error.ParseError)) {
case lines {
[] -> {
case errors {
[] -> Ok(list.reverse(vars))
_ -> Error(list.reverse(errors))
}
}
[#(line_num, line), ..rest] -> {
case parse_line(line_num, line) {
Ok(None) -> parse_lines(rest, vars, errors)
Ok(Some(pair)) -> parse_lines(rest, [pair, ..vars], errors)
Error(err) -> parse_lines(rest, vars, [err, ..errors])
}
}
}
}
fn parse_line(
line_num: Int,
line: String,
) -> Result(Option(#(String, String)), error.ParseError) {
let trimmed = string.trim(line)
case trimmed {
"" -> Ok(None)
_ -> {
case string.starts_with(trimmed, "#") {
True -> Ok(None)
False -> parse_assignment(line_num, trimmed)
}
}
}
}
fn parse_assignment(
line_num: Int,
line: String,
) -> Result(Option(#(String, String)), error.ParseError) {
// Strip optional "export " prefix
let effective = case string.starts_with(line, "export ") {
True -> string.slice(line, 7, string.length(line) - 7)
False -> line
}
case string.split_once(effective, "=") {
Ok(#(raw_key, raw_value)) -> {
let key = string.trim(raw_key)
case key {
"" ->
Error(error.InvalidSyntax(
line: line_num,
content: line,
reason: "Empty variable name",
))
_ -> {
let value = unquote(string.trim(raw_value))
Ok(Some(#(key, value)))
}
}
}
Error(_) ->
Error(error.InvalidSyntax(
line: line_num,
content: line,
reason: "Expected KEY=value format",
))
}
}
fn unquote(s: String) -> String {
case string.to_graphemes(s) {
["\"", ..rest] -> parse_quoted(rest, "\"", "", True)
["'", ..rest] -> parse_quoted(rest, "'", "", False)
_ -> strip_inline_comment(s)
}
}
fn parse_quoted(
chars: List(String),
quote: String,
acc: String,
process: Bool,
) -> String {
case chars {
[] -> acc
[q, ..] if q == quote -> {
case process {
True -> process_escapes(acc)
False -> acc
}
}
["\\", c, ..rest] if process -> {
// Keep the escape for process_escapes to handle
parse_quoted(rest, quote, acc <> "\\" <> c, process)
}
[c, ..rest] -> parse_quoted(rest, quote, acc <> c, process)
}
}
/// Process escape sequences inside double-quoted values.
///
/// Supported: `\\n` → newline, `\\t` → tab, `\\\\` → backslash,
/// `\\"` → double-quote. Unknown sequences are kept as-is.
fn process_escapes(s: String) -> String {
do_process_escapes(string.to_graphemes(s), "")
}
fn do_process_escapes(chars: List(String), acc: String) -> String {
case chars {
[] -> acc
["\\", "n", ..rest] -> do_process_escapes(rest, acc <> "\n")
["\\", "t", ..rest] -> do_process_escapes(rest, acc <> "\t")
["\\", "\\", ..rest] -> do_process_escapes(rest, acc <> "\\")
["\\", "\"", ..rest] -> do_process_escapes(rest, acc <> "\"")
["\\", c, ..rest] -> do_process_escapes(rest, acc <> "\\" <> c)
[c, ..rest] -> do_process_escapes(rest, acc <> c)
}
}
/// Remove an inline comment from an unquoted value.
///
/// `8080 # default port``8080`
fn strip_inline_comment(s: String) -> String {
case string.split_once(s, " #") {
Ok(#(before, _)) -> string.trim_end(before)
Error(_) -> s
}
}