dotenv-parser/lib/dotenv_parser.ex

324 lines
10 KiB
Elixir
Raw Normal View History

2021-01-20 19:57:23 +00:00
defmodule DotenvParser do
@moduledoc """
Simple parser for dotenv style files.
Supports simple variablevalue 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`.
2022-01-28 16:40:19 +00:00
Single quote or double quote value to prevent trimming of whitespace and allow usage of `#` in value, i.e. `FOO=' bar # not comment ' # comment`.
2021-01-20 19:57:23 +00:00
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)
2022-01-28 16:40:19 +00:00
* 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."
```
2021-01-20 19:57:23 +00:00
2021-01-21 05:10:39 +00:00
A line can start with `export ` for easier interoperation with regular shell scripts. These lines are treated the
same as any others.
2021-01-20 19:57:23 +00:00
## 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")
"""
2024-04-26 14:35:55 +00:00
@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
2021-01-20 19:57:23 +00:00
2022-01-28 16:40:19 +00:00
@quote_chars ~w(" ')
2021-01-20 19:57:23 +00:00
@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
2022-01-28 16:40:19 +00:00
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
2021-01-20 19:57:23 +00:00
@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 variablevalue 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 variablevalue tuples.
If a line cannot be parsed, an error is raised.
"""
@spec parse_data(String.t()) :: [value_pair()]
def parse_data(data) do
2022-01-28 16:40:19 +00:00
{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)
2021-01-20 19:57:23 +00:00
end
@doc """
2022-01-28 16:40:19 +00:00
Parse given single line and return a variablevalue tuple, or a continuation value if the line
started or continued a multiline value.
2021-01-20 19:57:23 +00:00
If line cannot be parsed, an error is raised.
2022-01-28 16:40:19 +00:00
The second argument needs to be `nil` or a continuation value returned from parsing the previous
line.
2021-01-20 19:57:23 +00:00
"""
2022-01-28 16:40:19 +00:00
@spec parse_line(String.t(), Continuation.t() | nil) :: value_pair() | Continuation.t()
def parse_line(line, state)
def parse_line(line, nil) do
2021-01-20 19:57:23 +00:00
if not Regex.match?(@line_re, line) do
raise ParseError, "Malformed line cannot be parsed: #{line}"
else
[var, val] = String.split(line, "=", parts: 2)
2021-01-21 05:10:39 +00:00
var = var |> String.trim() |> String.replace_leading("export ", "")
2022-01-28 16:40:19 +00:00
trimmed = String.trim(val)
2021-01-20 19:57:23 +00:00
2022-01-28 16:40:19 +00:00
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
2021-01-20 19:57:23 +00:00
# Value is plain value
2022-01-28 16:40:19 +00:00
{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)
2021-01-20 19:57:23 +00:00
else
2022-01-28 16:40:19 +00:00
Regex.run(@squoted_multiline_end, trimmed)
2021-01-20 19:57:23 +00:00
end
2022-01-28 16:40:19 +00:00
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}
2021-01-20 19:57:23 +00:00
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 <> "\\")
2022-01-28 16:40:19 +00:00
@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"
2021-01-20 19:57:23 +00:00
end