How to modify a config file in Erlang

I was tasked this week with writing an escript to modify an Erlang config file. All I needed was to read it in, make a couple updates, and write it back out. Should be easy, right? Please make it easy Erlang. No? Of course not... this is Erlang after all.

How to modify a config file in Erlang

It bothers me when a language doesn't offer opposite and equal functions. If there's a function that adds to a list, there should be a function that removes from a list. If there's a function to read a file, there should be a function to write a file. And they should be named sensibly like file_read and file_write. Not file_looky and file_stuffit. Not file_consider and file_okimgoodnow. 🙄

And so this brings us to the murky waters of Erlang, and my task this week to write an escript that modifies a config file. All I needed was to read it in, make a couple updates, and write it back out. Should be easy, right? Please make it easy Erlang. (NO! %@$ you!! says Erlang.)

Here's a sample of what the config file looks like, without resembling actual production code of course. The point is, it's nothing special - just a list of configuration parameters for a system, laid out in a nested proplist format.

[{application_1,[{log_options,[{log_path,"C:/Program Files/Acme/Logs"}]}]},
 {application_2,[{log_options,[{log_path,"C:/Program Files/Acme/Logs"}]},
                 {app_options,[{max_attempts,4},{attempt_delay_ms,5000}]},
                 {dependencies,[[{name,writer},
                                 {exe,"C:/Program Files/Acme/Writer.exe"}],
                                [{name,logger},
                                 {exe,"C:/Program Files/Acme/Logger.exe"}],
                                [{name,server},
                                 {exe,"C:/Program Files/Acme/Server.exe"}]]}]},
 {application_3,[{app_options,[{allowed_groups,[admin,manager]}]}]}].

Reading in Erlang terms from a file

My first thought was to just open the file and use the proplists module to parse it, but whenever I opened it I got a binary string with the contents of the file. Okay, maybe I was reading it wrong. I started looking at the file module for different ways to read a file. Aaaaand..... I skipped right over the function I needed - file:consult/1. If your file has nothing but legit Erlang code in it, then file:consult() can read it into memory.

In my defense, the name, description, and example are all awful... "Reads Erlang terms, separated by '.'" That's all we get. (But then, the Erlang doc site is not exactly a bastion of documentation greatness.) And the name!! What does consulting a file have to do with reading in Erlang terms? And of course, there's no.. um... what would be the opposite? Unconsult? Deconsult? And hows about a module that makes parsing and modifying these config files easier? Hmm? Bueller?

Modifying a config file

And so I present...... (drum-roll please)..... a-less-stupidly-named-config-file-modifier-module, which I'll just call config_parser. You can grab it below or find it on GitHub, and modify it to your heart's content. It reads and writes (thank you) config files, and can also get and set nested terms so you can more easily modify them.

% Author: Grant Winney
% License: MIT

-module(config_parser).

-export([read_terms/1, get_nested_terms/2, set_nested_terms/3, write_terms/2]).

read_terms(FileName) ->
    case file:consult(FileName) of
        {ok, [Terms]} ->
            {ok, Terms};
        {error, {_Line, _Mod, _Term} = Reason} ->
            {error, file:format_error(Reason)};
        {error, Reason} ->
            {error, error_message(Reason, FileName)}
    end.

write_terms(FileName, Terms) ->
    Format = fun(Term) -> io_lib:format("~tp.~n", [Term]) end,
    file:write_file(FileName, lists:map(Format, [Terms])).

get_nested_terms(Keys, Terms) ->
    lists:foldl(fun(Key, InnerTerms) -> proplists:get_value(Key, InnerTerms) end, Terms, Keys).

set_nested_terms([Key], ReplacementTerms, Terms) ->
    lists:keyreplace(Key, 1, Terms, {Key, ReplacementTerms});
set_nested_terms([Key|NestedKeys], ReplacementTerms, Terms) ->
    InnerValue = set_nested_terms(NestedKeys, ReplacementTerms, proplists:get_value(Key, Terms)),
    lists:keyreplace(Key, 1, Terms, {Key, InnerValue}).


error_message(enoent, FileName) ->
    io_lib:format("The file does not exist: ~p", [FileName]);
error_message(eaccess, FileName) ->
    io_lib:format("Missing permission for reading the file, or for searching one of the parent directories: ~p", [FileName]);
error_message(eisdir, FileName) ->
    io_lib:format("The named file is a directory: ~p", [FileName]);
error_message(enotdir, FileName) ->
    io_lib:format("A component of the filename is not a directory: ~p", [FileName]);
error_message(enomem, _FileName) ->
    io_lib:format("There is not enough memory for the contents of the file.");
error_message(Error, FileName) ->
    io_lib:format("~p error: ~p", [Error, FileName]).

Usage

There's a couple other files in the repo so you can try it out. Just leave them in the same directory, compile the Erlang module, and run the two functions to see how it updates the config file. You should see a new dependency added to application_2, and a new group added to application_3.

[{application_1,[{log_options,[{log_path,"C:/Program Files/Acme/Logs"}]}]},
 {application_2,[{log_options,[{log_path,"C:/Program Files/Acme/Logs"}]},
                 {app_options,[{max_attempts,4},{attempt_delay_ms,5000}]},
                 {dependencies,[[{name,writer},
                                 {exe,"C:/Program Files/Acme/Writer.exe"}],
                                [{name,logger},
                                 {exe,"C:/Program Files/Acme/Logger.exe"}],
                                [{name,server},
                                 {exe,"C:/Program Files/Acme/Server.exe"}],
                                [{name,consumer},
                                 {exe,"C:/Program Files/Acme/Consumer.exe"}]]}]},
 {application_3,[{app_options,[{allowed_groups,[admin,manager,owner]}]}]}].

Issues

If you have a fix, you could be awesome and submit a PR. At the very least, give me a heads up if you run into problems and I'll see what I can do!

I haven't added any specs or EUnit tests around it, but if you do and you'd like to share them, I'd like to include them. 😉  Good luck, hope you find this useful.