Initial commit

This commit is contained in:
Mikko Ahlroth 2021-01-20 21:57:23 +02:00
commit 82fd4fc4ab
12 changed files with 374 additions and 0 deletions

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
dotenv_parser-*.tar
# Temporary files for e.g. tests
/tmp

21
README.md Normal file
View file

@ -0,0 +1,21 @@
# DotenvParser
Simple dotenv style file parser that can parse environment data from file and load it.
See documentation: [https://hexdocs.pm/dotenv_parser](https://hexdocs.pm/dotenv_parser)
## Installation
The package can be installed by adding `dotenv_parser` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:dotenv_parser, "~> 1.0.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/dotenv_parser](https://hexdocs.pm/dotenv_parser).

184
lib/dotenv_parser.ex Normal file
View file

@ -0,0 +1,184 @@
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`.
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)
## 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*[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 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
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 variablevalue 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 = String.trim(var)
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

30
mix.exs Normal file
View file

@ -0,0 +1,30 @@
defmodule DotenvParser.MixProject do
use Mix.Project
def project do
[
app: :dotenv_parser,
version: "1.0.0",
elixir: "~> 1.11",
start_permanent: Mix.env() == :prod,
deps: deps(),
docs: [
main: DotenvParser
]
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ex_doc, "~> 0.23.0", only: :dev}
]
end
end

7
mix.lock Normal file
View file

@ -0,0 +1,7 @@
%{
"earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"},
"ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"},
"makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
"makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"},
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
}

31
test/data/.env Normal file
View file

@ -0,0 +1,31 @@
BASIC=basic
# previous line intentionally left blank
# Indented comment
AFTER_LINE=after_line
EMPTY=
SINGLE_QUOTES='single_quotes'
SINGLE_QUOTES_SPACED=' single quotes '
DOUBLE_QUOTES="double_quotes"
DOUBLE_QUOTES_SPACED=" double quotes "
EXPAND_NEWLINES="expand\nnew\nlines"
EXPAND_MANY_SLASHES="expand\\\\nmany \tslashes"
EXPAND_UNICODE="expand \u1234"
INVALID_UNICODE="not valid \uarf1"
DONT_EXPAND_UNQUOTED=dontexpand\nnewlines\u1234
DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
# COMMENTS=work
EQUAL_SIGNS=equals==
RETAIN_INNER_QUOTES={"foo": "bar"}
RETAIN_LEADING_DQUOTE="retained
RETAIN_LEADING_SQUOTE='retained
RETAIN_TRAILING_DQUOTE=retained"
RETAIN_TRAILING_SQUOTE=retained'
RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
TRIM_SPACE_FROM_UNQUOTED= some spaced out string
USERNAME=therealnerdybeast@example.tld
SPACED_KEY = parsed
INLINE_COMMENT="foo#bar" # Bark Bark
INLINE_COMMENT_PLAIN=foo bar # Bark Bark
END_BACKSLASH="something\" # Comment
lowercased_var=foo

4
test/data/.invalid_env Normal file
View file

@ -0,0 +1,4 @@
FOO=bar
# This line will not parse
ARGH

View file

@ -0,0 +1,2 @@
# Var name contains invalid chars
FOO-BAR=baz

View file

@ -0,0 +1,2 @@
# Var name starting with number is not allowed
0FOO=bar

View file

@ -0,0 +1,61 @@
defmodule DotenvParserTest do
use ExUnit.Case
doctest DotenvParser
test "parses correctly" do
assert DotenvParser.parse_file("test/data/.env") == [
{"BASIC", "basic"},
{"AFTER_LINE", "after_line"},
{"EMPTY", ""},
{"SINGLE_QUOTES", "single_quotes"},
{"SINGLE_QUOTES_SPACED", " single quotes "},
{"DOUBLE_QUOTES", "double_quotes"},
{"DOUBLE_QUOTES_SPACED", " double quotes "},
{"EXPAND_NEWLINES", "expand\nnew\nlines"},
{"EXPAND_MANY_SLASHES", "expand\\\\nmany \tslashes"},
{"EXPAND_UNICODE", "expand ሴ"},
{"INVALID_UNICODE", "not valid \\uarf1"},
{"DONT_EXPAND_UNQUOTED", "dontexpand\\nnewlines\\u1234"},
{"DONT_EXPAND_SQUOTED", "dontexpand\\nnewlines"},
{"EQUAL_SIGNS", "equals=="},
{"RETAIN_INNER_QUOTES", "{\"foo\": \"bar\"}"},
{"RETAIN_LEADING_DQUOTE", "\"retained"},
{"RETAIN_LEADING_SQUOTE", "'retained"},
{"RETAIN_TRAILING_DQUOTE", "retained\""},
{"RETAIN_TRAILING_SQUOTE", "retained'"},
{"RETAIN_INNER_QUOTES_AS_STRING", "{\"foo\": \"bar\"}"},
{"TRIM_SPACE_FROM_UNQUOTED", "some spaced out string"},
{"USERNAME", "therealnerdybeast@example.tld"},
{"SPACED_KEY", "parsed"},
{"INLINE_COMMENT", "foo#bar"},
{"INLINE_COMMENT_PLAIN", "foo bar"},
{"END_BACKSLASH", "something\\"},
{"lowercased_var", "foo"}
]
end
test "loads env" do
DotenvParser.load_file("test/data/.env")
assert System.get_env("INLINE_COMMENT") == "foo#bar"
assert System.get_env("lowercased_var") == "foo"
end
test "fails with invalid env and no env is loaded" do
assert_raise(DotenvParser.ParseError, fn ->
DotenvParser.load_file("test/data/.invalid_env")
end)
assert is_nil(System.get_env("FOO"))
end
test "fails with invalid variable names" do
assert_raise(DotenvParser.ParseError, fn ->
DotenvParser.parse_file("test/data/.invalid_var_name_start")
end)
assert_raise(DotenvParser.ParseError, fn ->
DotenvParser.parse_file("test/data/.invalid_var_name_chars")
end)
end
end

1
test/test_helper.exs Normal file
View file

@ -0,0 +1 @@
ExUnit.start()