├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── ConfigParser │ └── ParseState.ex └── configparser.ex ├── mix.exs └── test ├── configparser_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | configparser_ex-*.tar 24 | 25 | # Temporary files for e.g. tests. 26 | /tmp/ 27 | 28 | # Misc. 29 | mix.lock 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.8 4 | - 1.7 5 | otp_release: 6 | - '21.2' 7 | notifications: 8 | email: 9 | - easco@mac.com -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.0.0 4 | 5 | Changes the parser to allow for values that include square brackets. 6 | 7 | ## 4.0.0 8 | 9 | Changed the way that strings get parsed to use new functionality 10 | (added in Elixir 1.7) where `StringIO.open` can accept a function for working 11 | with the device that is automatically closed when the function completes. Since 12 | this changes the minimum Elixir version this is a major version release. 13 | 14 | ## 3.0.1 15 | 16 | Fixed compiler warnings for Elixir 1.7.1 and later 17 | 18 | ## 3.0.0 19 | 20 | This represents a significant change for multi-line values. Prior to 21 | version 3, the parser would join multi-line values using a single space. The 22 | Python ConfigParser library, in contrast, joins them with a newline. This 23 | version joins the lines of a multi-line value with a newline like Python does. 24 | It also adds parser options, in particular the `join_continuations` option, 25 | which should allow users to continue using a space if desired. 26 | 27 | ## 2.0.1 28 | 29 | When parsing from a string, the library opened a `StringIO` device which 30 | it never closed. This release fixes the problem. Thanks to @vietkungfu on 31 | GitHub. 32 | 33 | ## 2.0.0 34 | 35 | Replaced calls to deprecated String.strip with String.trim. Makes 36 | minimum Elixir Version 1.3. If you need to run on versions prior to 1.3 you 37 | can use the 1.0.0 version. Bumped the major version as this may be a breaking 38 | change for some folks. 39 | 40 | ## 1.0.0 41 | 42 | Changed the way comments were parsed to make it more compatible with 43 | other libraries 44 | 45 | ## 0.2.1 46 | 47 | Small code changes to address a compiler warning from Elixir 1.2.3 48 | 49 | ## 0.2.0 50 | 51 | Identical releases caused by author's inexperience with Hex 52 | 53 | ## 0.1.0 54 | 55 | Initial release 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2025, R. Scott Thompson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the configparser_ex hex package nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL R. Scott Thompson BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ConfigParser for Elixir 2 | 3 | [![BuildStatus](https://travis-ci.org/easco/configparser_ex.svg?branch=master)](https://travis-ci.org/easco/configparser_ex) 4 | [![Module Version](https://img.shields.io/hexpm/v/configparser_ex.svg)](https://hex.pm/packages/configparser_ex) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/configparser_ex/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/configparser_ex.svg)](https://hex.pm/packages/configparser_ex) 7 | [![License](https://img.shields.io/hexpm/l/configparser_ex.svg)](https://github.com/easco/configparser_ex/blob/master/LICENSE) 8 | [![Last Updated](https://img.shields.io/github/last-commit/easco/configparser_ex.svg)](https://github.com/easco/configparser_ex/commits/master) 9 | 10 | This library implements a parser for config files in the style of Windows INI, 11 | as parsed by the Python 12 | [configparser](https://docs.python.org/3/library/configparser.html) library. A 13 | goal of this library is to replicate that module though not all of its features 14 | are re-implemented here. 15 | 16 | ### A note about Elixir's Config 17 | 18 | This library is intended for compatibility in environments that are already 19 | using files in the `configparser` format. For most uses in Elixir, consider 20 | using the built in `Config` module instead as it provides similar functionality. 21 | 22 | > **Release Notes** 23 | > 24 | > Starting with Version 3.0, the way the library handles multi-line values has 25 | > changed! Prior versions of the library would join multi-line values with a 26 | > single space. Now it joins them with a newline character. This change 27 | > replicates the behavior of the Python ConfigParser library. 28 | > The release now includes parser options and the `join_continuations` option 29 | > with the value `:with_space` will revert the library to its prior behavior. 30 | 31 | Basic config files look like this: 32 | 33 | ```ini 34 | # Comments can be placed in the file on lines with a hash 35 | [config section] 36 | 37 | first_key = value 38 | second_key = another_value 39 | ``` 40 | The file shown in this sample defines a section called `config section` and then defines two config settings in key-value form. The result of parsing this file would be: 41 | 42 | ```elixir 43 | {:ok, 44 | %{"config section" => %{"first_key" => "value", 45 | "second_key" => "another_value"}}} 46 | ``` 47 | 48 | The `:ok` atom in the first part of the tuple indicates that parsing was successful. The map in the second part of the tuple has keys that are the sections created in the file and the values are themselves value maps. The value maps reflect the keys and values defined within that section. 49 | 50 | ## Config Definitions 51 | 52 | A section definition is simply the name of the section enclosed in square brackets `[like this]`. Section names can contain spaces. 53 | 54 | Within a section, configuration definitions are key value pairs. On a definition line, the key and value are separated by either a colon (:) or an equal sign (=): 55 | 56 | ```ini 57 | [key-value samples] 58 | key_defined = with_an_equal_sign 59 | another_key_defined : with_a_colon 60 | keys can have spaces : true 61 | values = can have spaces too 62 | ``` 63 | The value of a particular key can extend over more than one line. The follow-on lines must be indented farther than the first line. 64 | 65 | ```ini 66 | [multi-line sample] 67 | this key's value : continues on more than one line 68 | but the follow on lines must be indented 69 | farther than the original one. 70 | ``` 71 | 72 | It is possible to define keys with values that are either null or the empty string: 73 | 74 | ```ini 75 | [empty-ish values] 76 | this_key_has_a_nil_value 77 | this_key_has_the_empty_string_as_a_value = 78 | ``` 79 | 80 | ## Comments 81 | 82 | The config file can contain comments: 83 | 84 | ```ini 85 | # comments can begin with a hash or number sign a the beginning 86 | ; or a comment line can begin with a semicolon 87 | 88 | [comment section] 89 | when defining a key = this is the value ; a comment starting with a semicolon 90 | ``` 91 | 92 | ## Using the Parser 93 | 94 | The `ConfigParser` module includes routines that can parse a file, the contents of a string, or from a stream of lines. 95 | 96 | To parse the content of a config file call the `parse_file` function and pass the file's path: 97 | 98 | ```elixir 99 | {:ok, parse_result} = ConfigParser.parse_file("/path/to/file") 100 | ``` 101 | 102 | To parse config information out of a string, call the `parse_string` method: 103 | 104 | ```elixir 105 | {:ok, parse_result} = ConfigParser.parse_string(""" 106 | [interesting_config] 107 | config_key = some interesting value 108 | """) 109 | ``` 110 | 111 | Given a stream whose elements represent the successive lines of a config file, the library can parse the content of the stream: 112 | 113 | ``` 114 | fake_stream = ["[section]", "key1 = value2", "key2:value2"] |> Stream.map(&(&1)) 115 | {:ok, parse_result} = ConfigParser.parse_stream(fake_stream) 116 | ``` 117 | 118 | As mentioned previously 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. 119 | 120 | 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: 121 | 122 | ```elixir 123 | {:error, "Syntax Error on line 3"} 124 | ``` 125 | 126 | ## Parser Options 127 | 128 | Starting with Version 3 of the library, it is possible to pass options to the parser: 129 | 130 | | Option | Value | Effect | 131 | |----------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------| 132 | | `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. | 133 | | `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. | 134 | 135 | You may add options as keyword arguments to the end of the `parse_file`, `parse_string`, or `parse_stream` functions 136 | 137 | ```elixir 138 | {:ok, parse_result} = ConfigParser.parse_file("/path/to/file", join_continuations: :with_newline) 139 | ``` 140 | 141 | ## Not Implemented 142 | 143 | This library is primarily intended to provide backward-compatibility in environments that already use config files. It does not handle creating, manipulating, or writing config files. It treats config files as read-only entities. 144 | 145 | This library currently returns the parsed result as a raw data structure. 146 | 147 | It does not support the value interpolation in the Python library and does not implement the DEFAULT section as described in the Python documentation. 148 | 149 | This library does not support the Python ConfigParser's customization features. 150 | 151 | ## Copyright and License 152 | 153 | Copyright (c) 2015-2025 R. Scott Thompson 154 | 155 | Released under the BSD License, which can be found in the repository in [`LICENSE`](https://github.com/easco/configparser_ex). 156 | -------------------------------------------------------------------------------- /lib/ConfigParser/ParseState.ex: -------------------------------------------------------------------------------- 1 | defmodule ConfigParser.ParseState do 2 | @moduledoc false 3 | 4 | @default_options %{ 5 | join_continuations: :with_newline 6 | } 7 | 8 | # What line of the "file" are we parsing 9 | defstruct line_number: 1, 10 | # Section that definitions go into 11 | current_section: nil, 12 | # The amount of whitespace on the last line 13 | last_indent: 0, 14 | # Could the line being parsed be a continuation 15 | continuation?: false, 16 | # If this is a continuation, which key would it continue 17 | last_key: nil, 18 | # The result as it is being built. 19 | result: {:ok, %{}}, 20 | # options used when parsing the config 21 | options: @default_options 22 | 23 | alias __MODULE__ 24 | 25 | def default_options, do: @default_options 26 | 27 | def begin_section(parse_state, new_section) do 28 | # Create a new result, based on the old, with the new section added 29 | {:ok, section_map} = parse_state.result 30 | 31 | # Only add a new section if it's not already there 32 | section_key = String.trim(new_section) 33 | 34 | new_result = 35 | if Map.has_key?(section_map, section_key) do 36 | # don't change the result if they section already exists 37 | parse_state.result 38 | else 39 | # add the section as an empty map if it doesn't exist 40 | {:ok, Map.put(section_map, section_key, %{})} 41 | end 42 | 43 | # next line cannot be a continuation 44 | %{ 45 | parse_state 46 | | current_section: section_key, 47 | result: new_result, 48 | continuation?: false, 49 | last_key: nil 50 | } 51 | end 52 | 53 | def define_config(parse_state, key, value) do 54 | {:ok, section_map} = parse_state.result 55 | 56 | if parse_state.current_section != nil do 57 | # pull the values out for the section that's currently being built 58 | value_map = section_map[parse_state.current_section] 59 | 60 | # create a new set of values by adding the key/value pair passed in 61 | new_values = 62 | if value == nil do 63 | Map.put(value_map, String.trim(key), nil) 64 | else 65 | Map.put(value_map, String.trim(key), String.trim(value)) 66 | end 67 | 68 | # create a new result replacing the current section with thenew values 69 | new_result = {:ok, Map.put(section_map, parse_state.current_section, new_values)} 70 | 71 | # The next line could be a continuation of this value so set continuation to true 72 | # and store the key that we're defining now. 73 | %{parse_state | result: new_result, continuation?: true, last_key: String.trim(key)} 74 | else 75 | new_result = 76 | {:error, 77 | "A configuration section must be defined before defining configuration values in line #{parse_state.line_number}"} 78 | 79 | %{parse_state | result: new_result} 80 | end 81 | end 82 | 83 | def append_continuation(%ParseState{options: options} = parse_state, continuation_value) do 84 | {:ok, section_map} = parse_state.result 85 | 86 | # pull the values out for the section that's currently being built 87 | value_map = section_map[parse_state.current_section] 88 | 89 | # create a new set of values by adding the key/value pair passed in 90 | new_value = append_continuation(options, value_map[parse_state.last_key], continuation_value) 91 | 92 | define_config(parse_state, parse_state.last_key, new_value) 93 | end 94 | 95 | defp append_continuation(%{join_continuations: :with_newline}, value, continuation) do 96 | "#{value}\n#{continuation}" 97 | end 98 | 99 | defp append_continuation(%{join_continuations: :with_space}, value, continuation) do 100 | "#{value} #{continuation}" 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/configparser.ex: -------------------------------------------------------------------------------- 1 | defmodule ConfigParser do 2 | alias ConfigParser.ParseState, as: ParseState 3 | 4 | @moduledoc """ 5 | The ConfigParser library implements a parser for config files in the style of Windows INI, 6 | as parsed by the Python [configparser](https://docs.python.org/3/library/configparser.html) library. 7 | 8 | A note about `Config` 9 | --------------------------- 10 | 11 | This library is intended for compatibility in environments that are already 12 | using ini-style files in the format described above. If you are working in a 13 | pure Elixir environment, please consider using Elixir's built-in `Config` 14 | instead as it is part of the core library and provides similar functionality. 15 | 16 | Basic Usage 17 | ----------- 18 | 19 | The `ConfigParser` module includes routines that can parse a file, the contents of a string, or from a stream of lines. 20 | 21 | To parse the content of a config file call the `parse_file` function and pass the file's path: 22 | 23 | {:ok, parse_result} = ConfigParser.parse_file("/path/to/file") 24 | 25 | To parse config information out of a string, call the `parse_string` method: 26 | 27 | {:ok, parse_result} = ConfigParser.parse_string(\"\"\" 28 | [interesting_config] 29 | config_key = some interesting value 30 | \"\"\") 31 | 32 | Given a stream whose elements represent the successive lines of a config file, the library can parse the content of the stream: 33 | 34 | fake_stream = ["[section]", "key1 = value2", "key2:value2"] 35 | |> Stream.map(&(&1)) 36 | 37 | {:ok, parse_result} = ConfigParser.parse_stream(fake_stream) 38 | 39 | 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. 40 | 41 | 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: 42 | 43 | {:error, "Syntax Error on line 3"} 44 | 45 | --- 46 | 47 | Parser Options 48 | -------------- 49 | 50 | Starting with Version 3 of the library, it is possible to pass options to the parser: 51 | 52 | | Option | Value | Effect | 53 | |----------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------| 54 | | `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. | 55 | | `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. | 56 | 57 | You may add options as keyword arguments to the end of the `parse_file`, `parse_string`, or `parse_stream` functions 58 | 59 | {:ok, parse_result} = ConfigParser.parse_file("/path/to/file", join_continuations: :with_newline) 60 | """ 61 | 62 | @doc """ 63 | Accepts `config_file_path`, a file system path to a config file. 64 | Attempts to opens and parses the contents of that file. 65 | """ 66 | def parse_file(config_file_path, parser_options \\ []) do 67 | file_stream = File.stream!(config_file_path, [], :line) 68 | parse_stream(file_stream, parser_options) 69 | end 70 | 71 | @doc """ 72 | Parse a string as if it was the content of a config file. 73 | """ 74 | def parse_string(config_string, parser_options \\ []) do 75 | {:ok, result} = 76 | StringIO.open(config_string, fn io_device -> 77 | line_stream = IO.stream(io_device, :line) 78 | parse_stream(line_stream, parser_options) 79 | end) 80 | 81 | result 82 | end 83 | 84 | @doc """ 85 | Parses a stream whose elements should be strings representing the 86 | individual lines of a config file. 87 | """ 88 | def parse_stream(line_stream, parser_options \\ []) do 89 | options_map = options_to_map(parser_options) 90 | 91 | result = 92 | with {:ok, ^options_map} <- validate_options(options_map) do 93 | %ParseState{result: parse_result} = 94 | Enum.reduce(line_stream, %ParseState{options: options_map}, &parse_line/2) 95 | 96 | parse_result 97 | else 98 | error -> error 99 | end 100 | 101 | result 102 | end 103 | 104 | @doc """ 105 | Return a list of sections in the given config parser state 106 | """ 107 | def sections(parser_results) do 108 | Map.keys(parser_results) 109 | end 110 | 111 | @doc """ 112 | Returns `true` if the named section is found in the config parser results 113 | """ 114 | def has_section?(parser_results, which_section) do 115 | nil != Enum.find(sections(parser_results), &(&1 == which_section)) 116 | end 117 | 118 | @doc """ 119 | Returns a List with the options, the keys, defined in the given section. If the 120 | section is not found, returns an empty List 121 | """ 122 | def options(parser_results, in_section) do 123 | if has_section?(parser_results, in_section) do 124 | Map.keys(parser_results[in_section]) 125 | else 126 | nil 127 | end 128 | end 129 | 130 | @doc """ 131 | Return the value for the configuration option with the given `key`. 132 | 133 | You can change the way values are looked up using the `search_options` map. 134 | The following keys are recognized: 135 | 136 | * `:raw` - reserved for future enhancements 137 | * `:vars` - a map of keys and values. 138 | * `:fallback` - a value to return if the option given by `key` is not found 139 | 140 | The routine searches for a value with the given `key` in the `:vars` map 141 | if provided, then in the given section from the parse result. 142 | 143 | If no value is found, and the `options` map has a `:fallback` key, the 144 | value associated with that key will be returned. 145 | 146 | If all else fails, the routine returns `nil` 147 | """ 148 | def get(parser_results, section, key, search_options \\ %{}) do 149 | cond do 150 | search_options[:vars] && Map.has_key?(search_options[:vars], key) -> 151 | search_options[:vars][key] 152 | 153 | has_option?(parser_results, section, key) -> 154 | parser_results[section][key] 155 | 156 | search_options[:fallback] -> 157 | search_options[:fallback] 158 | 159 | true -> 160 | nil 161 | end 162 | end 163 | 164 | @doc """ 165 | This is a convenience routine which calls `ConfigParser.get` 166 | then tries to construct a integer value from the result. 167 | 168 | See `ConfigParser.get` for explanations of the options. 169 | """ 170 | def getint(parser_results, section, key, search_options \\ %{}) do 171 | value = get(parser_results, section, key, search_options) 172 | 173 | if is_binary(value) do 174 | String.to_integer(value) 175 | else 176 | value 177 | end 178 | end 179 | 180 | @doc """ 181 | This is a convenience routine which calls `ConfigParser.get` 182 | then tries to construct a float value from the result. 183 | 184 | See `ConfigParser.get` for explanations of the options. 185 | """ 186 | def getfloat(parser_results, section, key, search_options \\ %{}) do 187 | value = get(parser_results, section, key, search_options) 188 | 189 | if is_binary(value) do 190 | String.to_float(value) 191 | else 192 | value 193 | end 194 | end 195 | 196 | @doc """ 197 | This is a convenience routine which calls `ConfigParser.get` 198 | then tries to construct a boolean value from the result. 199 | 200 | An option value of "true", "1", "yes", or "on" evaluates to true 201 | An options value of "false", "0", "no", or "off" evaluates to false 202 | 203 | See `ConfigParser.get` for explanations of the options. 204 | """ 205 | def getboolean(parser_results, section, key, search_options \\ %{}) do 206 | string_value = get(parser_results, section, key, search_options) 207 | 208 | case String.downcase(string_value) do 209 | "true" -> 210 | true 211 | 212 | "1" -> 213 | true 214 | 215 | "yes" -> 216 | true 217 | 218 | "on" -> 219 | true 220 | 221 | "false" -> 222 | false 223 | 224 | "0" -> 225 | false 226 | 227 | "no" -> 228 | false 229 | 230 | "off" -> 231 | false 232 | 233 | _ -> 234 | raise RuntimeError, 235 | message: "ConfigParser.getboolean tried to convert an unexpected value #{string_value}" 236 | end 237 | end 238 | 239 | @doc """ 240 | returns true if the parse results define the given option in the 241 | section provided 242 | """ 243 | def has_option?(parser_results, section, option) do 244 | potential_options = options(parser_results, section) 245 | 246 | if nil != potential_options do 247 | nil != Enum.find(potential_options, &(&1 == option)) 248 | else 249 | false 250 | end 251 | end 252 | 253 | # If the parse state indicates an error we simply skip over lines and propagate 254 | # the error. 255 | defp parse_line(_line, parse_state = %ParseState{result: {:error, _error_string}}) do 256 | parse_state 257 | end 258 | 259 | # Regex matching a line containing a section definition "[section]" 260 | @section_regex ~r{^\s*\[([^\]]+)\]\s*$} 261 | 262 | # Regex matching key-value pairs separated by equals "key = value" 263 | @equals_definition_regex ~r{^\s*([^=]+?)\s*=\s*(.*)$} 264 | 265 | # Regex matching key-value pairs separated by colon "key: value" 266 | @colon_definition_regex ~r{^\s*([^:]+?)\s*:\s*(.*)$} 267 | 268 | # Regex matching a standalone value line (no delimiter), interpreted as a key with nil value 269 | @value_like_regex ~r{^\s*(\S.*)$} 270 | 271 | # Parse a line while the parse state indicates we're in a good state 272 | defp parse_line(line, parse_state = %ParseState{result: {:ok, _}}) do 273 | line = strip_inline_comments(line) 274 | 275 | # find out how many whitespace characters are on the front of the line 276 | indent_level = indent_level(line) 277 | 278 | cond do 279 | parse_state.continuation? && indent_level > parse_state.last_indent && 280 | Regex.run(@value_like_regex, line) -> 281 | # note that we do not increase the "last indent" 282 | %{ 283 | ParseState.append_continuation(parse_state, String.trim(line)) 284 | | line_number: parse_state.line_number + 1, 285 | continuation?: true 286 | } 287 | 288 | # if we can skip this line (it's empty or a comment) then simply advance the line number 289 | # and note that the next line can't be a continuation 290 | can_skip_line(line) -> 291 | %{ 292 | parse_state 293 | | line_number: parse_state.line_number + 1, 294 | continuation?: false, 295 | last_indent: indent_level 296 | } 297 | 298 | # match a line that begins a new section like "[new_section]" 299 | match = Regex.run(@section_regex, line) -> 300 | [_, new_section] = match 301 | 302 | %{ 303 | ParseState.begin_section(parse_state, new_section) 304 | | line_number: parse_state.line_number + 1, 305 | last_indent: indent_level 306 | } 307 | 308 | # match a line that defines a value "key = value" 309 | match = Regex.run(@equals_definition_regex, line) -> 310 | [_, key, value] = match 311 | 312 | %{ 313 | ParseState.define_config(parse_state, key, value) 314 | | line_number: parse_state.line_number + 1, 315 | last_indent: indent_level 316 | } 317 | 318 | # match a line that defines a value "key : value" 319 | match = Regex.run(@colon_definition_regex, line) -> 320 | [_, key, value] = match 321 | 322 | %{ 323 | ParseState.define_config(parse_state, key, value) 324 | | line_number: parse_state.line_number + 1, 325 | last_indent: indent_level 326 | } 327 | 328 | # when there's a value-ish line that on a line by itself, but which is not a continuation 329 | # then it actually represents a key that has no associated value (or a value of nil) 330 | match = Regex.run(@value_like_regex, line) -> 331 | [_, key] = match 332 | 333 | %{ 334 | ParseState.define_config(parse_state, key, nil) 335 | | continuation?: false, 336 | line_number: parse_state.line_number + 1, 337 | last_indent: indent_level 338 | } 339 | 340 | # Any non-matching lines result in a syntax error 341 | true -> 342 | %{parse_state | result: {:error, "Syntax Error on line #{parse_state.line_number}"}} 343 | end 344 | end 345 | 346 | # Calculate how much whitespace is at the front of the given 347 | # line. 348 | defp indent_level(line) do 349 | [_whole, spaces | _rest] = Regex.run(~r{(\s*).*}, line) 350 | spaces = String.replace(spaces, "\t", " ") 351 | String.length(spaces) 352 | end 353 | 354 | # Returns true if the parser can ignore the line passed in. 355 | # this is done if the line is a comment just whitespace 356 | defp can_skip_line(line) do 357 | is_comment(line) || is_empty(line) 358 | end 359 | 360 | # Returns true if the line appears to be a comment 361 | @hash_comment_regex ~r{^#.*} 362 | @semicolon_comment_regex ~r{^;.*} 363 | 364 | defp is_comment(line) do 365 | String.trim(line) =~ @hash_comment_regex || String.trim(line) =~ @semicolon_comment_regex 366 | end 367 | 368 | # returns true if the line contains only whitespace 369 | defp is_empty(line) do 370 | String.trim(line) == "" 371 | end 372 | 373 | # semicolons on a line define the start of a comment. 374 | # this removes the semicolon and anything following it. 375 | defp strip_inline_comments(line) do 376 | line_list = String.split(line, ";") 377 | List.first(line_list) 378 | end 379 | 380 | defp options_to_map([]), do: ParseState.default_options() 381 | defp options_to_map(options) when is_list(options), do: Enum.into(options, %{}) 382 | 383 | defp validate_options(%{} = options_map) do 384 | Enum.reduce(options_map, {:ok, options_map}, &option_reducer/2) 385 | end 386 | 387 | # validate an individual option pair when all prior options have been valid 388 | defp option_reducer(pair, {:ok, %{}} = result) do 389 | with {:ok, ^pair} <- validate_option(pair) do 390 | result 391 | else 392 | {:error, option_error} -> {:error, [option_error]} 393 | end 394 | end 395 | 396 | # validate an individual option pair when an error has already been encountered 397 | # accumulates a list of errors 398 | defp option_reducer(pair, {:error, errors_list} = result) do 399 | with {:ok, ^pair} <- validate_option(pair) do 400 | result 401 | else 402 | {:error, option_error} -> {:error, [option_error | errors_list]} 403 | end 404 | end 405 | 406 | defp validate_option({:join_continuations, value} = pair) do 407 | if value == :with_newline || value == :with_space do 408 | {:ok, pair} 409 | else 410 | {:error, 411 | "The value for the join_continuations option should be :with_newline or :with_space"} 412 | end 413 | end 414 | 415 | defp validate_option({option_key, _}) do 416 | {:error, "The ConfigParser library does not recognize the option #{inspect(option_key)}."} 417 | end 418 | end 419 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ConfigParser.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/easco/configparser_ex" 5 | 6 | def project do 7 | [ 8 | app: :configparser_ex, 9 | version: "5.0.0", 10 | name: "ConfigParser for Elixir", 11 | source_url: @source_url, 12 | elixir: ">= 1.7.0", 13 | package: package(), 14 | deps: deps(), 15 | docs: docs() 16 | ] 17 | end 18 | 19 | def application do 20 | [] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 26 | ] 27 | end 28 | 29 | defp package do 30 | [ 31 | description: """ 32 | A module that parses INI-like files. Not unlike the Python configparser 33 | package. 34 | """, 35 | maintainers: ["Scott Thompson"], 36 | files: ["mix.exs", "lib", "LICENSE*", "README*", "CHANGELOG*"], 37 | licenses: ["BSD-3-Clause"], 38 | links: %{ 39 | "Changelog" => "https://hexdocs.pm/configparser_ex/changelog.html", 40 | "GitHub" => @source_url 41 | } 42 | ] 43 | end 44 | 45 | defp docs do 46 | [ 47 | extras: ["CHANGELOG.md", {:"README.md", [title: "Overview"]}], 48 | main: "readme", 49 | source_url: @source_url, 50 | formatters: ["html"] 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/configparser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConfigParserTest do 2 | use ExUnit.Case 3 | 4 | def check_string(string, against_value) do 5 | assert against_value == ConfigParser.parse_string(string) 6 | end 7 | 8 | test "parses an empty file" do 9 | check_string("", {:ok, %{}}) 10 | end 11 | 12 | test "parses an comment only file" do 13 | check_string("#this is a useless file\n", {:ok, %{}}) 14 | end 15 | 16 | test "parses comments and empty lines" do 17 | check_string( 18 | """ 19 | #this is a useless file 20 | 21 | # filled with comments and empty lines 22 | """, 23 | {:ok, %{}} 24 | ) 25 | end 26 | 27 | test "parses a single section" do 28 | check_string("[section]\n", {:ok, %{"section" => %{}}}) 29 | end 30 | 31 | test "parses a section name with a space" do 32 | check_string("[section with space]\n", {:ok, %{"section with space" => %{}}}) 33 | end 34 | 35 | test "parses a config option into a section" do 36 | check_string( 37 | """ 38 | [section] 39 | # this is an interesting key value pair 40 | key = value 41 | """, 42 | {:ok, %{"section" => %{"key" => "value"}}} 43 | ) 44 | end 45 | 46 | test "allows spaces in the keys" do 47 | check_string( 48 | """ 49 | [section] 50 | # this is an interesting key value pair 51 | spaces in keys=allowed 52 | """, 53 | {:ok, %{"section" => %{"spaces in keys" => "allowed"}}} 54 | ) 55 | end 56 | 57 | test "allows spaces in the values" do 58 | check_string( 59 | """ 60 | [section] 61 | # this is an interesting key value pair 62 | spaces in values=allowed as well 63 | """, 64 | {:ok, %{"section" => %{"spaces in values" => "allowed as well"}}} 65 | ) 66 | end 67 | 68 | test "allows spaces around the delimiter" do 69 | check_string( 70 | """ 71 | [section] 72 | # this is an interesting key value pair 73 | spaces around the delimiter = obviously 74 | """, 75 | {:ok, %{"section" => %{"spaces around the delimiter" => "obviously"}}} 76 | ) 77 | end 78 | 79 | test "allows a colon as the delimiter" do 80 | check_string( 81 | """ 82 | [section] 83 | # this is an interesting key value pair 84 | you can also use : to delimit keys from values 85 | """, 86 | {:ok, %{"section" => %{"you can also use" => "to delimit keys from values"}}} 87 | ) 88 | end 89 | 90 | test "allows a continuation line" do 91 | check_string( 92 | """ 93 | [section] 94 | you can also use : to delimit keys from values 95 | and add a continuation 96 | """, 97 | {:ok, 98 | %{ 99 | "section" => %{ 100 | "you can also use" => "to delimit keys from values\nand add a continuation" 101 | } 102 | }} 103 | ) 104 | end 105 | 106 | test "allows a multi-line continuation" do 107 | check_string( 108 | """ 109 | [section] 110 | you can also use : to delimit keys 111 | from values 112 | and add a continuation 113 | """, 114 | {:ok, 115 | %{ 116 | "section" => %{ 117 | "you can also use" => "to delimit keys\nfrom values\nand add a continuation" 118 | } 119 | }} 120 | ) 121 | end 122 | 123 | test "allows empty string thing" do 124 | check_string( 125 | """ 126 | [No Values] 127 | empty string value here = 128 | """, 129 | {:ok, %{"No Values" => %{"empty string value here" => ""}}} 130 | ) 131 | end 132 | 133 | test "allows lone keys" do 134 | check_string( 135 | """ 136 | [No Values] 137 | key_without_value 138 | """, 139 | {:ok, %{"No Values" => %{"key_without_value" => nil}}} 140 | ) 141 | end 142 | 143 | test "parses a config option with an inline comment" do 144 | check_string( 145 | """ 146 | [section] 147 | # this is an interesting key value pair 148 | key = value ; With a comment 149 | """, 150 | {:ok, %{"section" => %{"key" => "value"}}} 151 | ) 152 | end 153 | 154 | test "close StringIO after read" do 155 | process_count_before = Process.list() |> length 156 | ConfigParser.parse_string("test") 157 | process_count_after = Process.list() |> length 158 | assert process_count_before == process_count_after 159 | end 160 | 161 | test "extracts a list of sections from parsed config data" do 162 | {:ok, parse_result} = 163 | ConfigParser.parse_string(""" 164 | [first_section] 165 | boring = value 166 | [second_section] 167 | someother_key = and_value 168 | """) 169 | 170 | sorted_sections = Enum.sort(ConfigParser.sections(parse_result), &(&1 < &2)) 171 | assert sorted_sections == ["first_section", "second_section"] 172 | end 173 | 174 | test "determines if a particular section is found in the parsed results" do 175 | {:ok, parse_result} = 176 | ConfigParser.parse_string(""" 177 | [first_section] 178 | boring = value 179 | [second_section] 180 | someother_key = and_value 181 | """) 182 | 183 | assert true == ConfigParser.has_section?(parse_result, "first_section") 184 | assert false == ConfigParser.has_section?(parse_result, "snarfblat") 185 | end 186 | 187 | test "returns a list of the options defined in a particular section" do 188 | {:ok, parse_result} = 189 | ConfigParser.parse_string(""" 190 | [section] 191 | one = for the money 192 | two = for the show 193 | three = to get ready 194 | """) 195 | 196 | sorted_options = Enum.sort(ConfigParser.options(parse_result, "section"), &(&1 < &2)) 197 | assert sorted_options == ["one", "three", "two"] 198 | 199 | assert nil == ConfigParser.options(parse_result, "non-existent section") 200 | end 201 | 202 | test "determines if a particuliar option is available in a section" do 203 | {:ok, parse_result} = 204 | ConfigParser.parse_string(""" 205 | [section] 206 | one = for the money 207 | two = for the show 208 | three = to get ready 209 | """) 210 | 211 | assert ConfigParser.has_option?(parse_result, "section", "one") == true 212 | assert ConfigParser.has_option?(parse_result, "section", "florp") == false 213 | end 214 | 215 | test "returns nil if asked for a value in a section that doesn't exist" do 216 | {:ok, parse_result} = 217 | ConfigParser.parse_string(""" 218 | [section] 219 | one = for the money 220 | """) 221 | 222 | assert ConfigParser.get(parse_result, "non-existent", "one") == nil 223 | end 224 | 225 | test "returns the value of a particular option when no fancy options are provided" do 226 | {:ok, parse_result} = 227 | ConfigParser.parse_string(""" 228 | [section] 229 | one = for the money 230 | """) 231 | 232 | assert ConfigParser.get(parse_result, "section", "one") == "for the money" 233 | end 234 | 235 | test "allows the :vars option to override definitions from the parse data" do 236 | {:ok, parse_result} = 237 | ConfigParser.parse_string(""" 238 | [section] 239 | one = for the money 240 | """) 241 | 242 | assert ConfigParser.get(parse_result, "section", "one", 243 | vars: %{ 244 | "one" => "is the loneliest number" 245 | } 246 | ) == "is the loneliest number" 247 | 248 | assert ConfigParser.get(parse_result, "section", "one", 249 | vars: %{ 250 | "one" => nil 251 | } 252 | ) == nil 253 | end 254 | 255 | test "returns the fallback value if the value can't be found otherwise" do 256 | {:ok, parse_result} = 257 | ConfigParser.parse_string(""" 258 | [section] 259 | one = for the money 260 | """) 261 | 262 | assert ConfigParser.get(parse_result, "non-existent", "_", fallback: "None") == "None" 263 | assert ConfigParser.get(parse_result, "section", "non-existent", fallback: "None") == "None" 264 | end 265 | 266 | test "correctly handles a value with brackets [ ]" do 267 | {:ok, parse_result} = 268 | ConfigParser.parse_string(""" 269 | [brackets_value] 270 | paired_spaces = [ value ] 271 | odd_spaces = [ yes] 272 | no_spaces = [without spaces] 273 | """) 274 | 275 | assert ConfigParser.get(parse_result, "brackets_value", "paired_spaces") == "[ value ]" 276 | assert ConfigParser.get(parse_result, "brackets_value", "odd_spaces") == "[ yes]" 277 | assert ConfigParser.get(parse_result, "brackets_value", "no_spaces") == "[without spaces]" 278 | end 279 | 280 | test "correctly handles the case where a section is repeated or reopened" do 281 | {:ok, parse_result} = 282 | ConfigParser.parse_string(""" 283 | [Simple Values] 284 | key=value 285 | spaces in keys=allowed 286 | 287 | [Simple Values] 288 | spaces in values=allowed as well 289 | spaces around the delimiter = obviously 290 | you can also use : to delimit keys from values 291 | """) 292 | 293 | assert ConfigParser.get(parse_result, "Simple Values", "key") == "value" 294 | 295 | assert ConfigParser.get(parse_result, "Simple Values", "spaces in values") == 296 | "allowed as well" 297 | end 298 | 299 | describe "parsing options" do 300 | test "errors out with unrecognized options" do 301 | assert {:error, _} = 302 | ConfigParser.parse_string( 303 | """ 304 | [does not matter] 305 | this = is_bogus 306 | """, 307 | unrecognized_option: "whatever" 308 | ) 309 | end 310 | 311 | test "parses multiline_values when asked for spaces" do 312 | {:ok, parse_result} = 313 | ConfigParser.parse_string( 314 | """ 315 | [section] 316 | you can also use : to delimit keys from values 317 | and add a continuation 318 | """, 319 | join_continuations: :with_space 320 | ) 321 | 322 | assert parse_result == %{ 323 | "section" => %{ 324 | "you can also use" => "to delimit keys from values and add a continuation" 325 | } 326 | } 327 | end 328 | end 329 | 330 | test "parses extended example from python page" do 331 | check_string( 332 | """ 333 | [Simple Values] 334 | key=value 335 | spaces in keys=allowed 336 | spaces in values=allowed as well 337 | spaces around the delimiter = obviously 338 | you can also use : to delimit keys from values 339 | 340 | [All Values Are Strings] 341 | values like this: 1000000 342 | or this: 3.14159265359 343 | are they treated as numbers? : no 344 | integers, floats and booleans are held as: strings 345 | can use the API to get converted values directly: true 346 | 347 | [Multiline Values] 348 | chorus: I'm a lumberjack, and I'm okay, 349 | I sleep all night and I work all day 350 | 351 | [No Values] 352 | key_without_value 353 | empty string value here = 354 | 355 | [You can use comments] 356 | # like this 357 | ; or this 358 | # and also this 359 | but not # like this 360 | and also ; not this 361 | 362 | # By default only in an empty line. 363 | # Inline comments can be harmful because they prevent users 364 | # from using the delimiting characters as parts of values. 365 | # That being said, this can be customized. 366 | 367 | [Sections Can Be Indented] 368 | can_values_be_as_well = True 369 | does_that_mean_anything_special = False 370 | purpose = formatting for readability 371 | multiline_values = are 372 | handled just fine as 373 | long as they are indented 374 | deeper than the first line 375 | of a value 376 | # Did I mention we can indent comments, too? 377 | """, 378 | {:ok, 379 | %{ 380 | "All Values Are Strings" => %{ 381 | "are they treated as numbers?" => "no", 382 | "can use the API to get converted values directly" => "true", 383 | "integers, floats and booleans are held as" => "strings", 384 | "or this" => "3.14159265359", 385 | "values like this" => "1000000" 386 | }, 387 | "Multiline Values" => %{ 388 | "chorus" => "I'm a lumberjack, and I'm okay,\nI sleep all night and I work all day" 389 | }, 390 | "No Values" => %{"empty string value here" => "", "key_without_value" => nil}, 391 | "Sections Can Be Indented" => %{ 392 | "can_values_be_as_well" => "True", 393 | "does_that_mean_anything_special" => "False", 394 | "multiline_values" => 395 | "are\nhandled just fine as\nlong as they are indented\ndeeper than the first line\nof a value", 396 | "purpose" => "formatting for readability" 397 | }, 398 | "Simple Values" => %{ 399 | "key" => "value", 400 | "spaces around the delimiter" => "obviously", 401 | "spaces in keys" => "allowed", 402 | "spaces in values" => "allowed as well", 403 | "you can also use" => "to delimit keys from values" 404 | }, 405 | "You can use comments" => %{ 406 | "but not # like this" => nil, 407 | "and also" => nil 408 | } 409 | }} 410 | ) 411 | end 412 | end 413 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------