%% -------------------------------------------------------------------
%%
%% conf_parse: for all your .conf parsing needs.
%%
%% Copyright (c) 2013 Basho Technologies, Inc.  All Rights Reserved.
%% Copyright (c) 2019 Pivotal Software, Inc.  All rights reserved.
%% Copyright (c) 2020 VMware, Inc. or its affiliates.  All rights reserved.
%%
%% This file is provided to you under the Apache License,
%% Version 2.0 (the "License"); you may not use this file
%% except in compliance with the License.  You may obtain
%% a copy of the License at
%%
%%   http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing,
%% software distributed under the License is distributed on an
%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
%% KIND, either express or implied.  See the License for the
%% specific language governing permissions and limitations
%% under the License.
%%
%% -------------------------------------------------------------------

% -------------------------------------------------------------------
% NOTE: IMPORTANT
%
% After using neotoma to re-generate conf_parse.erl, you MUST
% edit that file to change the exported file/1 function to this code:
%
% -spec file(file:name()) -> any().
% file(Filename) ->
%     AbsFilename = filename:absname(Filename),
%     case erl_prim_loader:get_file(AbsFilename) of
%         {ok, Bin, _} -> parse(Bin);
%         error -> {error, undefined}
%     end.
%
% The reason is that the above code allows for reading cuttlefish
% schemas from .ez archives
% -------------------------------------------------------------------

%% A configuration file may have zero-or-more lines.
config <- line* %{
    [ L || L <- Node, is_setting(L) ]
%};

%% Lines are actual settings, includes, comments, or horizontal whitespace,
%% terminated by an end-of-line or end-of-file.
line <- ((setting / include / comment / ws+) (crlf / eof)) / crlf %{
    case Node of
        [ Line, _EOL ] -> Line;
        Line -> Line
    end
%};

%% A setting is a key and a value, joined by =, with surrounding
%% whitespace ignored.
setting <- ws* key ws* "=" ws* (multiline_value / escaped_value / unescaped_value) ws* comment? %{
    [ _, Key, _, _Eq, _, Value, _, _ ] = Node,
    {Key, try_unicode_characters_to_list(Value, Idx)}
%};

%% A key is a series of dot-separated identifiers.
key <- head:word tail:("." word)* %{
    [{head, H}, {tail, T}] = Node,
    [try_unicode_characters_to_list(H, Idx)| [try_unicode_characters_to_list(W, Idx) || [_, W] <- T]]
%};

%% A multiline value is any character between two pairs of triple single quotes, spanning lines
multiline_value <- "'''" ((!"'''" .)* / crlf) "'''" %{
    Node0 = string:trim(Node, both, [$',$\n,[$\r,$\n]]),
    try_unicode_characters_to_list(Node0 Idx)
%};

%% An escaped value is any character between single quotes except for EOF
escaped_value <- "'" (!"'" .)* "'" %{
    Stripped = string:trim(Node, both, [$']),
    try_unicode_characters_to_list(Stripped, Idx)
%};

%% A value is any character, with trailing whitespace stripped.
unescaped_value <- (!((ws* crlf) / comment) .)+ %{
    try_unicode_characters_to_list(Node, Idx)
%};

%% A comment is any line that begins with a # sign, leading whitespace
%% allowed.
comment <- ws* "#" (!crlf .)* `comment`;

%% An include is a line that begins with 'include' and something.
include <- ws* "include" ws+ included_file_or_dir comment? %{
    [_, _Include, _, Included, _] = Node,
    {include, Included}
%};

included_file_or_dir <- [A-Za-z0-9-\_\.\*\/]+ %{
    try_unicode_characters_to_binary(Node, Idx)
%};

%% A word is one or more of letters, numbers and dashes or
%% underscores.
word <- ("\\." / [A-Za-z0-9_-])+ %{
    unescape_dots(try_unicode_characters_to_list(Node, Idx))
%};

%% An end-of-line is signified by a line-feed with an optional
%% preceding carriage-return.
crlf <- "\r"? "\n" `ws`;

%% The end-of-file is where no character matches.
eof <- !. `ws`;

%% Whitespace is either spaces or tabs.
ws <- [ \t]+ `ws`;

% Erlang code
%{

%% -------------------------------------------------------------------
%%
%% conf_parse: for all your .conf parsing needs.
%%
%% Copyright (c) 2013 Basho Technologies, Inc.  All Rights Reserved.
%% Copyright (c) 2019 Pivotal Software, Inc.  All rights reserved.
%% Copyright (c) 2020 VMware, Inc. or its affiliates.  All rights reserved.
%%
%% This file is provided to you under the Apache License,
%% Version 2.0 (the "License"); you may not use this file
%% except in compliance with the License.  You may obtain
%% a copy of the License at
%%
%%   http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing,
%% software distributed under the License is distributed on an
%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
%% KIND, either express or implied.  See the License for the
%% specific language governing permissions and limitations
%% under the License.
%%
%% -------------------------------------------------------------------

%% This module implements the parser for a sysctl-style
%% configuration format. Example:
%%
%% ```
%% riak.local.node = riak@127.0.0.1
%% riak.local.http = 127.0.0.1:8098
%% riak.local.pb = 127.0.0.1:8087
%% riak.local.storage.backend = bitcask'''
%%
%% This would parse into the following flat proplist:
%%
%% ```
%% [{<<"riak.local.node">>,<<"riak@127.0.0.1">>},
%% {<<"riak.local.http">>,<<"127.0.0.1:8098">>},
%% {<<"riak.local.pb">>,<<"127.0.0.1:8087">>},
%% {<<"riak.local.storage.backend">>,<<"bitcask">>}]'''
%%
%% Other modules in this application interpret and validate the
%% result of a successful parse.
%% @end
-define(line, true).

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.

%% @doc Only let through lines that are not comments or whitespace.
is_setting(ws) -> false;
is_setting([ws]) -> false;
is_setting(comment) -> false;
is_setting(_) -> true.

%% @doc Removes escaped dots from keys
unescape_dots([$\\,$.|Rest]) ->
    [$.|unescape_dots(Rest)];
unescape_dots([]) -> [];
unescape_dots([C|Rest]) ->
    [C|unescape_dots(Rest)].

try_unicode_characters_to_binary(Node, Idx) ->
    case unicode:characters_to_binary(Node) of
        {incomplete, _List, _RestBin} ->
            throw({error, {conf_to_unicode, line(Idx)}});
        {error, _List, _RestData} ->
            throw({error, {conf_to_unicode, line(Idx)}});
        Chardata when is_binary(Chardata)->
            Chardata
    end.

try_unicode_characters_to_list(Node, Idx) ->
    case unicode:characters_to_list(Node) of
        {incomplete, _List, _RestBin} ->
            throw({error, {conf_to_unicode, line(Idx)}});
        {error, _List, _RestData} ->
            throw({error, {conf_to_unicode, line(Idx)}});
        Chardata when is_list(Chardata)->
            Chardata
    end.

-ifdef(TEST).
file_test() ->
    Conf = conf_parse:file("test/riak.conf"),
    ?assertEqual([
            {["ring_size"],"32"},
            {["anti_entropy"],"debug"},
            {["log","error","file"],"/var/log/error.log"},
            {["log","console","file"],"/var/log/console.log"},
            {["log","syslog"],"on"},
            {["listener","http","internal"],"127.0.0.1:8098"},
            {["listener","http","external"],"10.0.0.1:80"}
        ], Conf),
    ok.

included_file_test() ->
    Conf = conf_parse:file("test/include_file.conf"),
    ?assertEqual([
            {include,<<"riak.conf">>}
        ], Conf),
    ok.

included_dir_test() ->
    Conf = conf_parse:file("test/include_dir.conf"),
    ?assertEqual([
            {include,<<"conf.d/*.conf">>}
        ], Conf),
    ok.

escaped_dots_are_removed_test() ->
    Conf = conf_parse:parse("#comment\nsetting\\.0 = thing0\n"),
    ?assertEqual([
            {["setting.0"],"thing0"}
        ], Conf),
    ok.

utf8_test() ->
    Expected = [{["setting"], [116,104,105,110,103,338]}],
    Actual = conf_parse:parse("setting = thing" ++ [338] ++ "\n"),
    ?assertMatch(Expected, Actual),
    ok.

invalid_included_file_test() ->
    Conf = conf_parse:file("test/invalid_include_file.conf"),
    ?assertMatch({[], _PathWithNewLineAndCarriage, {{line,_}, {column, _}}}, Conf),
    ok.

invalid_included_dir_test() ->
    Conf = conf_parse:file("test/invalid_include_dir.conf"),
    ?assertMatch({[], _PathWithNewLineAndCarriage, {{line, _},{column, _}}}, Conf),
    ok.

escaped_string_test() ->
    Expected = [{["setting"],"e9238-7_49%#sod7"}],
    Actual = conf_parse:parse("setting = 'e9238-7_49%#sod7'" ++ "\n"),
    ?assertMatch(Expected, Actual),
    ok.

gh_1_two_tab_test() ->
    Conf = conf_parse:parse("setting0 = thing0\n\t\t\nsetting1 = thing1\n"),
    ?assertEqual([
            {["setting0"],"thing0"},
            {["setting1"],"thing1"}
        ], Conf),
    ok.

gh_1_three_tab_test() ->
    Conf = conf_parse:parse("setting0 = thing0\n\t\t\t\nsetting1 = thing1\n"),
    ?assertEqual([
            {["setting0"],"thing0"},
            {["setting1"],"thing1"}
        ], Conf),
    ok.

-endif.
%}
