Packages

A module that parses INI-like files. Not unlike the Python configparser package.

Retired package: Deprecated - I don't maintain it anymore

Current section

Files

Jump to
configparser lib configparser.ex
Raw

lib/configparser.ex

defmodule ConfigParser do
alias ConfigParser.ParseState, as: ParseState
@map Application.get_env(:configparser, :map_implementation)
@moduledoc """
The ConfigParser library implements a parser for config files in the style of Windows INI,
as parsed by the Python [configparser](https://docs.python.org/3/library/configparser.html) library.
A note about `Mix.Config`
---------------------------
This library is intended for compatibility in environments that are already
using config files in the format described above. If you are working in a
pure Elixir environment, please consider using `Mix.Config` instead as it is
part of the core library and provides similar functionality.
Basic Usage
-----------
The `ConfigParser` module includes routines that can parse a file, the contents of a string, or from a stream of lines.
To parse the content of a config file call the `parse_file` function and pass the file's path:
{:ok, parse_result} = ConfigParser.parse_file("/path/to/file")
To parse config information out of a string, call the `parse_string` method:
{:ok, parse_result} = ConfigParser.parse_string(\"\"\"
[interesting_config]
config_key = some interesting value
\"\"\")
Given a stream whose elements represent the successive lines of a config file, the library can parse the content of the stream:
fake_stream = ["[section]", "key1 = value2", "key2:value2"]
|> Stream.map(&(&1))
{:ok, parse_result} = ConfigParser.parse_stream(fake_stream)
As shown, the result of doing the parsing is a tuple. If successful, the first element of the tuple is `:ok` and the second element is the parsed result.
If the parser encounters an error, then the first part of the tuple will be the atom `:error` and the second element will be a string describing the error that was encountered:
{:error, "Syntax Error on line 3"}
---
Parser Options
--------------
Starting with Version 3 of the library, it is possible to pass options to the parser:
| Option | Value | Effect |
|----------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `join_continuations` | `:with_newline` | The parser joins the lines of multi-line values with a newline. This is the default and matches the behavior of Python ConfigParser. |
| `join_continuations` | `:with_space` | The parser joins the lines of multi-line values with a space. This is the default behavior of the library prior to version 3. |
You may add options as keyword arguments to the end of the `parse_file`, `parse_string`, or `parse_stream` functions
{:ok, parse_result} = ConfigParser.parse_file("/path/to/file", join_continuations: :with_newline)
"""
@doc """
Accepts `config_file_path`, a file system path to a config file.
Attempts to opens and parses the contents of that file.
"""
def parse_file(config_file_path, parser_options \\ []) do
file_stream = File.stream!(config_file_path, [], :line)
parse_stream(file_stream, parser_options)
end
@doc """
Parse a string as if it was the content of a config file.
"""
def parse_string(config_string, parser_options \\ []) do
{:ok, result} =
StringIO.open(config_string, fn io_device ->
line_stream = IO.stream(io_device, :line)
parse_stream(line_stream, parser_options)
end)
result
end
@doc """
Parses a stream whose elements should be strings representing the
individual lines of a config file.
"""
def parse_stream(line_stream, parser_options \\ []) do
options_map = options_to_map(parser_options)
result =
with {:ok, ^options_map} <- validate_options(options_map) do
%ParseState{result: parse_result} =
Enum.reduce(line_stream, %ParseState{options: options_map}, &parse_line/2)
parse_result
else
error -> error
end
result
end
@doc """
Return a list of sections in the given config parser state
"""
def sections(parser_results) do
@map.keys(parser_results)
end
@doc """
Returns `true` if the named section is found in the config parser results
"""
def has_section?(parser_results, which_section) do
nil != Enum.find(sections(parser_results), &(&1 == which_section))
end
@doc """
Returns a List with the options, the keys, defined in the given section. If the
section is not found, returns an empty List
"""
def options(parser_results, in_section) do
if has_section?(parser_results, in_section) do
@map.keys(parser_results[in_section])
else
nil
end
end
@doc """
Return the value for the configuration option with the given `key`.
You can change the way values are looked up using the `search_options` map.
The following keys are recognized:
* `:raw` - reserved for future enhancements
* `:vars` - a map of keys and values.
* `:fallback` - a value to return if the option given by `key` is not found
The routine searches for a value with the given `key` in the `:vars` map
if provided, then in the given section from the parse result.
If no value is found, and the `options` map has a `:fallback` key, the
value associated with that key will be returned.
If all else fails, the routine returns `nil`
"""
def get(parser_results, section, key, search_options \\ %{}) do
cond do
search_options[:vars] && Map.has_key?(search_options[:vars], key) ->
search_options[:vars][key]
has_option?(parser_results, section, key) ->
parser_results[section][key]
search_options[:fallback] ->
search_options[:fallback]
true ->
nil
end
end
@doc """
This is a convenience routine which calls `ConfigParser.get`
then tries to construct a integer value from the result.
See `ConfigParser.get` for explanations of the options.
"""
def getint(parser_results, section, key, search_options \\ %{}) do
value = get(parser_results, section, key, search_options)
if is_binary(value) do
String.to_integer(value)
else
value
end
end
@doc """
This is a convenience routine which calls `ConfigParser.get`
then tries to construct a float value from the result.
See `ConfigParser.get` for explanations of the options.
"""
def getfloat(parser_results, section, key, search_options \\ %{}) do
value = get(parser_results, section, key, search_options)
if is_binary(value) do
String.to_float(value)
else
value
end
end
@doc """
This is a convenience routine which calls `ConfigParser.get`
then tries to construct a boolean value from the result.
An option value of "true", "1", "yes", or "on" evaluates to true
An options value of "false", "0", "no", or "off" evaluates to false
See `ConfigParser.get` for explanations of the options.
"""
def getboolean(parser_results, section, key, search_options \\ %{}) do
string_value = get(parser_results, section, key, search_options)
case String.downcase(string_value) do
"true" ->
true
"1" ->
true
"yes" ->
true
"on" ->
true
"false" ->
false
"0" ->
false
"no" ->
false
"off" ->
false
_ ->
raise RuntimeError,
message: "ConfigParser.getboolean tried to convert an unexpected value #{string_value}"
end
end
@doc """
returns true if the parse results define the given option in the
section provided
"""
def has_option?(parser_results, section, option) do
potential_options = options(parser_results, section)
if nil != potential_options do
nil != Enum.find(potential_options, &(&1 == option))
else
false
end
end
# If the parse state indicates an error we simply skip over lines and propagate
# the error.
defp parse_line(_line, parse_state = %ParseState{result: {:error, _error_string}}) do
parse_state
end
@section_regex ~r{\[([^\]]+)\]}
@equals_definition_regex ~r{([^=]+)=(.*)}
@colon_definition_regex ~r{([^:]+):(.*)}
@value_like_regex ~r{\s*(\S.*)}
# Parse a line while the parse state indicates we're in a good state
defp parse_line(line, parse_state = %ParseState{result: {:ok, _}, options: options}) do
delimeters = options.delimeters
line = strip_inline_comments(line)
# find out how many whitespace characters are on the front of the line
indent_level = indent_level(line)
if parse_state.continuation? && indent_level > parse_state.last_indent &&
Regex.run(@value_like_regex, line) do
# note that we do not increase the "last indent"
%{
ParseState.append_continuation(parse_state, String.trim(line))
| line_number: parse_state.line_number + 1,
continuation?: true
}
else
cond do
# if we can skip this line (it's empty or a comment) then simply advance the line number
# and note that the next line can't be a continuation
can_skip_line(line) ->
%{
parse_state
| line_number: parse_state.line_number + 1,
continuation?: false,
last_indent: indent_level
}
# match a line that begins a new section like "[new_section]"
match = Regex.run(@section_regex, line) ->
[_, new_section] = match
%{
ParseState.begin_section(parse_state, new_section)
| line_number: parse_state.line_number + 1,
last_indent: indent_level
}
# match a line that defines a value "key = value"
match = :equal in delimeters && Regex.run(@equals_definition_regex, line) ->
[_, key, value] = match
%{
ParseState.define_config(parse_state, key, value)
| line_number: parse_state.line_number + 1,
last_indent: indent_level
}
# match a line that defines a value "key : value"
match = :colon in delimeters && Regex.run(@colon_definition_regex, line) ->
[_, key, value] = match
%{
ParseState.define_config(parse_state, key, value)
| line_number: parse_state.line_number + 1,
last_indent: indent_level
}
# when there's a value-ish line that on a line by itself, but which is not a continuation
# then it actually represents a key that has no associated value (or a value of nil)
match = Regex.run(@value_like_regex, line) ->
[_, key] = match
%{
ParseState.define_config(parse_state, key, nil)
| continuation?: false,
line_number: parse_state.line_number + 1,
last_indent: indent_level
}
# Any non-matching lines result in a syntax error
true ->
%{parse_state | result: {:error, "Syntax Error on line #{parse_state.line_number}"}}
end
# cond
end
# continuation if
end
# Calculate how much whitespace is at the front of the given
# line.
defp indent_level(line) do
[_whole, spaces | _rest] = Regex.run(~r{(\s*).*}, line)
spaces = String.replace(spaces, "\t", " ")
String.length(spaces)
end
# Returns true if the parser can ignore the line passed in.
# this is done if the line is a comment just whitespace
defp can_skip_line(line) do
is_comment(line) || is_empty(line)
end
# Returns true if the line appears to be a comment
@hash_comment_regex ~r{^#.*}
@semicolon_comment_regex ~r{^;.*}
defp is_comment(line) do
String.trim(line) =~ @hash_comment_regex || String.trim(line) =~ @semicolon_comment_regex
end
# returns true if the line contains only whitespace
defp is_empty(line) do
String.trim(line) == ""
end
# semicolons on a line define the start of a comment.
# this removes the semicolon and anything following it.
defp strip_inline_comments(line) do
line_list = String.split(line, ";")
List.first(line_list)
end
defp options_to_map([]), do: ParseState.default_options()
defp options_to_map(options) when is_list(options) do
default_options = ParseState.default_options()
custom_options = Enum.into(options, %{})
Map.merge(default_options, custom_options)
end
defp validate_options(%{} = options_map) do
Enum.reduce(options_map, {:ok, options_map}, &option_reducer/2)
end
# validate an individual option pair when all prior options have been valid
defp option_reducer(pair, {:ok, %{}} = result) do
with {:ok, ^pair} <- validate_option(pair) do
result
else
{:error, option_error} -> {:error, [option_error]}
end
end
# validate an individual option pair when an error has already been encountered
# accumulates a list of errors
defp option_reducer(pair, {:error, errors_list} = result) do
with {:ok, ^pair} <- validate_option(pair) do
result
else
{:error, option_error} -> {:error, [option_error | errors_list]}
end
end
defp validate_option({:join_continuations, value} = pair) do
if value == :with_newline || value == :with_space do
{:ok, pair}
else
{:error,
"The value for the join_continuations option should be :with_newline or :with_space"}
end
end
defp validate_option({:delimeters, value} = pair) when is_list(value) do
permitted_values = [:equal, :colon]
if Enum.all?(value, fn v -> v in permitted_values end) do
{:ok, pair}
else
{:error,
"The value for the delimeters option should be a list consists of :equal and :colon"}
end
end
defp validate_option({option_key, _}) do
{:error, "The ConfigParser library does not recognize the option #{inspect(option_key)}."}
end
end