323 lines
10 KiB
Elixir
323 lines
10 KiB
Elixir
defmodule DotenvParser do
|
||
@moduledoc """
|
||
Simple parser for dotenv style files.
|
||
|
||
Supports simple variable–value pairs with upper and lower cased variables. Values are trimmed of extra whitespace.
|
||
|
||
Blank lines and lines starting with `#` are ignored. Additionally inline comments can be added after values with a
|
||
`#`, i.e. `FOO=bar # comment`.
|
||
|
||
Single quote or double quote value to prevent trimming of whitespace and allow usage of `#` in value, i.e. `FOO=' bar # not comment ' # comment`.
|
||
|
||
Single quoted values don't do any unescaping. Double quoted values will unescape the following:
|
||
|
||
* `\\n` - Linefeed
|
||
* `\\r` - Carriage return
|
||
* `\\t` - Tab
|
||
* `\\f` - Form feed
|
||
* `\\b` - Backspace
|
||
* `\\"` and `\\'` - Quotes
|
||
* `\\\\` - Backslash
|
||
* `\\uFFFF` - Unicode escape (4 hex characters to denote the codepoint)
|
||
* A backslash at the end of the line in a multiline value will remove the linefeed.
|
||
|
||
Values can span multiple lines when single or double quoted:
|
||
|
||
```sh
|
||
MULTILINE="This is a
|
||
multiline value."
|
||
```
|
||
|
||
This will result in the following:
|
||
|
||
```elixir
|
||
System.fetch_env!("MULTILINE") == "This is a\\nmultiline value."
|
||
```
|
||
|
||
A line can start with `export ` for easier interoperation with regular shell scripts. These lines are treated the
|
||
same as any others.
|
||
|
||
## Serving suggestion
|
||
|
||
If you load lots of environment variables in `config/runtime.exs`, you can easily configure them for development by
|
||
having an `.env` file in your development environment and using DotenvParser at the start of the file:
|
||
|
||
```elixir
|
||
import Config
|
||
|
||
if Config.config_env() == :dev do
|
||
DotenvParser.load_file(".env")
|
||
end
|
||
|
||
# Now variables from `.env` are loaded into system env
|
||
config :your_project,
|
||
database_url: System.fetch_env!("DB_URL")
|
||
"""
|
||
|
||
@linefeed_re ~R/\r?\n/
|
||
@line_re ~R/^(?:\s*export)?\s*[a-z_][a-z_0-9]*\s*=/i
|
||
@dquoted_val_re ~R/^"([^"\\]*(?:\\.[^"\\]*)*)"\s*(?:#.*)?$/
|
||
@squoted_val_re ~R/^\s*'(.*)'\s*(?:#.*)?$/
|
||
@dquoted_multiline_end ~R/^([^"\\]*(?:\\.[^"\\]*)*)"\s*(?:#.*)?$/
|
||
@squoted_multiline_end ~R/^(.*)'\s*(?:#.*)?$/
|
||
@hex_re ~R/^[0-9a-f]+$/i
|
||
|
||
@quote_chars ~w(" ')
|
||
|
||
@typedoc "Pair of variable name, variable value."
|
||
@type value_pair :: {String.t(), String.t()}
|
||
|
||
defmodule ParseError do
|
||
@moduledoc "Error raised when a line cannot be parsed."
|
||
defexception [:message]
|
||
end
|
||
|
||
defmodule Continuation do
|
||
@typedoc """
|
||
A multiline value continuation. When a function returns this, it means that a multiline value
|
||
was started and more needs to be parsed to get the rest of the value.
|
||
"""
|
||
@type t :: %__MODULE__{
|
||
name: String.t(),
|
||
value: String.t(),
|
||
start_quote: String.t()
|
||
}
|
||
@enforce_keys [:name, :value, :start_quote]
|
||
defstruct [:name, :value, :start_quote]
|
||
end
|
||
|
||
@doc """
|
||
Parse given file and load the variables to the environment.
|
||
|
||
If a line cannot be parsed or the file cannot be read, an error is raised and no values are loaded to the
|
||
environment.
|
||
"""
|
||
@spec load_file(String.t()) :: :ok
|
||
def load_file(file) do
|
||
file
|
||
|> File.read!()
|
||
|> load_data()
|
||
end
|
||
|
||
@doc """
|
||
Parse given data and load the variables to the environment.
|
||
|
||
If a line cannot be parsed, an error is raised and no values are loaded to the environment.
|
||
"""
|
||
@spec load_data(String.t()) :: :ok
|
||
def load_data(data) do
|
||
data
|
||
|> parse_data()
|
||
|> Enum.each(fn {var, val} -> System.put_env(var, val) end)
|
||
end
|
||
|
||
@doc """
|
||
Parse given file and return a list of variable–value tuples.
|
||
|
||
If a line cannot be parsed or the file cannot be read, an error is raised.
|
||
"""
|
||
@spec parse_file(String.t()) :: [value_pair()]
|
||
def parse_file(file) do
|
||
file
|
||
|> File.read!()
|
||
|> parse_data()
|
||
end
|
||
|
||
@doc """
|
||
Parse given data and return a list of variable–value tuples.
|
||
|
||
If a line cannot be parsed, an error is raised.
|
||
"""
|
||
@spec parse_data(String.t()) :: [value_pair()]
|
||
def parse_data(data) do
|
||
{value_pairs, continuation} =
|
||
data
|
||
|> String.split(@linefeed_re)
|
||
|> Enum.reduce({[], nil}, fn
|
||
line, {ret, nil} ->
|
||
trimmed = String.trim(line)
|
||
|
||
if not is_comment?(trimmed) and not is_blank?(trimmed) do
|
||
reduce_line(ret, line, nil)
|
||
else
|
||
{ret, nil}
|
||
end
|
||
|
||
line, {ret, continuation} ->
|
||
reduce_line(ret, line, continuation)
|
||
end)
|
||
|
||
if not is_nil(continuation) do
|
||
raise ParseError,
|
||
"Could not find end for quote #{continuation.start_quote} in variable #{continuation.name}"
|
||
end
|
||
|
||
Enum.reverse(value_pairs)
|
||
end
|
||
|
||
@doc """
|
||
Parse given single line and return a variable–value tuple, or a continuation value if the line
|
||
started or continued a multiline value.
|
||
|
||
If line cannot be parsed, an error is raised.
|
||
|
||
The second argument needs to be `nil` or a continuation value returned from parsing the previous
|
||
line.
|
||
"""
|
||
@spec parse_line(String.t(), Continuation.t() | nil) :: value_pair() | Continuation.t()
|
||
def parse_line(line, state)
|
||
|
||
def parse_line(line, nil) do
|
||
if not Regex.match?(@line_re, line) do
|
||
raise ParseError, "Malformed line cannot be parsed: #{line}"
|
||
else
|
||
[var, val] = String.split(line, "=", parts: 2)
|
||
var = var |> String.trim() |> String.replace_leading("export ", "")
|
||
trimmed = String.trim(val)
|
||
|
||
with {:dquoted, nil} <- {:dquoted, Regex.run(@dquoted_val_re, trimmed)},
|
||
{:squoted, nil} <- {:squoted, Regex.run(@squoted_val_re, trimmed)},
|
||
trimmed_leading = String.trim_leading(val),
|
||
{:quoted_start, false} <-
|
||
{:quoted_start, String.starts_with?(trimmed_leading, @quote_chars)} do
|
||
# Value is plain value
|
||
{var, trimmed |> remove_comment() |> String.trim()}
|
||
else
|
||
{:dquoted, [_, inner_val]} ->
|
||
{var, stripslashes(inner_val)}
|
||
|
||
{:squoted, [_, inner_val]} ->
|
||
{var, inner_val}
|
||
|
||
{:quoted_start, _} ->
|
||
parse_multiline_start(var, val)
|
||
end
|
||
end
|
||
end
|
||
|
||
def parse_line(line, %Continuation{} = continuation) do
|
||
trimmed = String.trim_trailing(line)
|
||
|
||
end_match =
|
||
if continuation.start_quote == "\"" do
|
||
Regex.run(@dquoted_multiline_end, trimmed)
|
||
else
|
||
Regex.run(@squoted_multiline_end, trimmed)
|
||
end
|
||
|
||
with [_, line_content] <- end_match do
|
||
ret = maybe_stripslashes(continuation, line_content)
|
||
{continuation.name, continuation.value <> ret}
|
||
else
|
||
_ ->
|
||
next_line = maybe_stripslashes(continuation, line)
|
||
next_line = maybe_linefeed(continuation, next_line)
|
||
|
||
%Continuation{
|
||
continuation
|
||
| value: continuation.value <> next_line
|
||
}
|
||
end
|
||
end
|
||
|
||
@spec parse_multiline_start(String.t(), String.t()) :: Continuation.t()
|
||
defp parse_multiline_start(name, input) do
|
||
{start_quote, rest} = input |> String.trim_leading() |> String.split_at(1)
|
||
|
||
continuation = %Continuation{
|
||
name: name,
|
||
value: "",
|
||
start_quote: start_quote
|
||
}
|
||
|
||
value = maybe_stripslashes(continuation, rest)
|
||
value = maybe_linefeed(continuation, value)
|
||
|
||
%Continuation{continuation | value: value}
|
||
end
|
||
|
||
@spec reduce_line([value_pair()], String.t(), Continuation.t() | nil) ::
|
||
{[value_pair()], Continuation.t() | nil}
|
||
defp reduce_line(ret, line, continuation) do
|
||
case parse_line(line, continuation) do
|
||
%Continuation{} = new_continuation ->
|
||
{ret, new_continuation}
|
||
|
||
result ->
|
||
{[result | ret], nil}
|
||
end
|
||
end
|
||
|
||
@spec remove_comment(String.t()) :: String.t()
|
||
defp remove_comment(val) do
|
||
case String.split(val, "#", parts: 2) do
|
||
[true_val, _comment] -> true_val
|
||
[true_val] -> true_val
|
||
end
|
||
end
|
||
|
||
@spec is_comment?(String.t()) :: boolean()
|
||
defp is_comment?(line)
|
||
defp is_comment?("#" <> _rest), do: true
|
||
defp is_comment?(_line), do: false
|
||
|
||
@spec is_blank?(String.t()) :: boolean()
|
||
defp is_blank?(line)
|
||
defp is_blank?(""), do: true
|
||
defp is_blank?(_line), do: false
|
||
|
||
@spec stripslashes(String.t(), :slash | :no_slash, String.t()) :: String.t()
|
||
defp stripslashes(input, mode \\ :no_slash, acc \\ "")
|
||
|
||
defp stripslashes("\\" <> rest, :no_slash, acc) do
|
||
stripslashes(rest, :slash, acc)
|
||
end
|
||
|
||
defp stripslashes("", :no_slash, acc), do: acc
|
||
|
||
defp stripslashes(input, :no_slash, acc) do
|
||
case String.split(input, "\\", parts: 2) do
|
||
[all] -> acc <> all
|
||
[head, tail] -> stripslashes(tail, :slash, acc <> head)
|
||
end
|
||
end
|
||
|
||
defp stripslashes("n" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\n")
|
||
defp stripslashes("r" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\r")
|
||
defp stripslashes("t" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\t")
|
||
defp stripslashes("f" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\f")
|
||
defp stripslashes("b" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\b")
|
||
defp stripslashes("\"" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\"")
|
||
defp stripslashes("'" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "'")
|
||
defp stripslashes("\\" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\\")
|
||
|
||
defp stripslashes(<<"u", hex::binary-size(4), rest::binary>>, :slash, acc) do
|
||
with true <- Regex.match?(@hex_re, hex),
|
||
{int, _rest} <- Integer.parse(hex, 16) do
|
||
stripslashes(rest, :no_slash, acc <> <<int::utf8>>)
|
||
else
|
||
_ -> stripslashes(rest, :no_slash, acc <> "\\u" <> hex)
|
||
end
|
||
end
|
||
|
||
defp stripslashes(input, :slash, acc), do: stripslashes(input, :no_slash, acc <> "\\")
|
||
|
||
@spec maybe_stripslashes(Continuation.t(), String.t()) :: String.t()
|
||
defp maybe_stripslashes(continuation, input)
|
||
|
||
defp maybe_stripslashes(%Continuation{start_quote: "\""}, input), do: stripslashes(input)
|
||
defp maybe_stripslashes(_, input), do: input
|
||
|
||
@spec maybe_linefeed(Continuation.t(), String.t()) :: String.t()
|
||
defp maybe_linefeed(continuation, input)
|
||
|
||
defp maybe_linefeed(%Continuation{start_quote: "\""}, input) do
|
||
if String.ends_with?(input, "\\") do
|
||
String.slice(input, 0..-2)
|
||
else
|
||
input <> "\n"
|
||
end
|
||
end
|
||
|
||
defp maybe_linefeed(_, input), do: input <> "\n"
|
||
end
|