defmodule ConfigParser do @moduledoc """ This 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. 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 mentioned previously the result of doing the parsing is a tuple. If successful, the first element of the tupe 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"} """ defmodule ParseState do defstruct line_number: 1, # What line of the "file" are we parsing current_section: nil, # Section that definitions go into last_indent: 0, # The amount of whitespace on the last line continuation?: false, # Could the line being parsed be a coninuation last_key: nil, # If this is a continuation, which key would it continue result: {:ok, %{}} # The result as it is being built. def begin_section(parse_state, new_section) do # Create a new result, based on the old, with the new section added {:ok, section_map} = parse_state.result # Only add a new section if it's not already there section_key = String.strip(new_section) unless Map.has_key?(section_map, section_key) do new_result = {:ok, Map.put(section_map, section_key, %{}) } end # next line cannot be a continuation %{parse_state | current_section: section_key, result: new_result, continuation?: false, last_key: nil} end def define_config(parse_state, key, value) do {:ok, section_map} = parse_state.result if parse_state.current_section != nil do # pull the values out for the section that's currently being built value_map = section_map[parse_state.current_section] # create a new set of values by adding the key/value pair passed in if value == nil do new_values = Map.put(value_map, String.strip(key), nil) else new_values = Map.put(value_map, String.strip(key), String.strip(value)) end # create a new result replacing the current section with thenew values new_result = {:ok, Map.put(section_map, parse_state.current_section, new_values)} # The next line could be a continuation of this value so set continuation to true # and store the key that we're defining now. %{parse_state | result: new_result, continuation?: true, last_key: String.strip(key)} else new_result = {:error, "A configuration section must be defined before defining configuration values in line #{parse_state.line_number}"} %{parse_state | result: new_result} end end def append_continuation(parse_state, continuation_value) do {:ok, section_map} = parse_state.result # pull the values out for the section that's currently being built value_map = section_map[parse_state.current_section] # create a new set of values by adding the key/value pair passed in new_value = "#{value_map[parse_state.last_key]} #{continuation_value}" define_config(parse_state, parse_state.last_key, new_value) end end @section_regex ~r{\[([^\]]+)\]} @equals_definition_regex ~r{([^=]+)=(.*)} @colon_definition_regex ~r{([^:]+):(.*)} @value_like_regex ~r{\s*(\S.*)} # If the parse state indicates an error we simply skip over lines and propogate # the error. defp parse_line(_line, parse_state = %ParseState{result: {:error, _error_string}}) do parse_state end # Parse a line while the parse state indicates we're in a good state defp parse_line(line, parse_state = %ParseState{result: {:ok, _}}) do 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 && (match = Regex.run(@value_like_regex, line)) do # note that we do not increase the "last indent" %{ParseState.append_continuation(parse_state, String.strip(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 = 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 = 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 @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) do file_stream = File.stream!(config_file_path, [], :line) parse_stream(file_stream) end @doc """ Parse a string as if it was the content of a config file. """ def parse_string(config_string) do {:ok, pid} = StringIO.open(config_string) line_stream = IO.stream(pid, :line) parse_stream(line_stream) end @doc """ Parses a stream whose elements should be strings representing the individual lines of a config file. """ def parse_stream(line_stream) do %ParseState{result: result} = Enum.reduce(line_stream, %ParseState{}, &parse_line/2) result end # Calulate 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.strip(line) =~ @hash_comment_regex || String.strip(line) =~ @semicolon_comment_regex end # returns true if the line contains only whitespace defp is_empty(line) do String.strip(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 end