187 lines
6.1 KiB
Elixir
187 lines
6.1 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 quoted 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 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*"(.*)(?!\\)"\s*(?:#.*)?$/
|
||
@squoted_val_re ~R/^\s*'(.*)(?!\\)'\s*(?:#.*)?$/
|
||
@hex_re ~R/^[0-9a-f]+$/i
|
||
|
||
@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
|
||
|
||
@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
|
||
data
|
||
|> String.split(@linefeed_re)
|
||
|> Enum.map(&String.trim/1)
|
||
|> Enum.reject(&is_comment?/1)
|
||
|> Enum.reject(&is_blank?/1)
|
||
|> Enum.map(&parse_line/1)
|
||
end
|
||
|
||
@doc """
|
||
Parse given single line and return a variable–value tuple.
|
||
|
||
If line cannot be parsed, an error is raised.
|
||
"""
|
||
@spec parse_line(String.t()) :: value_pair()
|
||
def parse_line(line) 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 ", "")
|
||
val = String.trim(val)
|
||
|
||
with {:dquoted, nil} <- {:dquoted, Regex.run(@dquoted_val_re, val)},
|
||
{:squoted, nil} <- {:squoted, Regex.run(@squoted_val_re, val)} do
|
||
# Value is plain value
|
||
{var, val |> remove_comment() |> String.trim()}
|
||
else
|
||
{:dquoted, [_, inner_val]} -> {var, stripslashes(inner_val)}
|
||
{:squoted, [_, inner_val]} -> {var, inner_val}
|
||
end
|
||
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 <> "\\")
|
||
end
|