src/zotonic_filewatcher_handler.erl

%% @author Arjan Scherpenisse <arjan@miraclethings.nl>
%% @copyright 2014-2017 Arjan Scherpenisse
%%
%% @doc Handle changed files

%% Copyright 2014-2017 Arjan Scherpenisse
%%
%% Licensed 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.

-module(zotonic_filewatcher_handler).
-author("Arjan Scherpenisse <arjan@miraclethings.nl>").

-behaviour(gen_server).

-export([
    file_changed/2,
    re_exclude/0,
    is_file_blocked/1,
    is_file_blocked/2
]).

-export([
    start_link/0,
    init/1,
    handle_call/3,
    handle_cast/2,
    handle_info/2,
    code_change/3,
    terminate/2
]).

-include_lib("zotonic_notifier/include/zotonic_notifier.hrl").
-include_lib("zotonic_filewatcher.hrl").
-include_lib("kernel/include/logger.hrl").

-record(state, {
    events = [] :: map(),  % binary() => verb()
    startline = undefined :: undefined | pos_integer(),
    deadline = undefined :: undefined | pos_integer(),
    is_changed = false :: boolean()
}).


%% Which files do we not consider at all in the file_changed handler
-define(FILENAME_BLOCKLIST_RE,
        "_flymake$"
        "|node_modules"
        "|\\.#"
        "|\\.bea#"
        "|/priv/files/"
        "|/priv/data/"
        "|/\\.git/"
        "|/\\.gitignore"
        "|\\.hg/"
        "|/log/"
        "|\\.log$"
        "|\\.dump$"
        "|/translations/.*\\.mo$"
        "|~$"
        "|\\.bck$"
        "|\\.swp$"
        "|\\.pot$"
        "|/\\."
        "|/mnesia/"
        "|/\\.rebar3"
        "|\\.DS_Store").


-define(TIMER_DELAY, 300).
-define(DEADLINE_DELAY, 250).
-define(MAX_DELAY, 5000).

-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).


%% @doc Called when a file is changed on disk, buffers changes before sending
%%      a batch to the zotonic_filehandler.
-spec file_changed(zotonic_filehandler:verb(), file:filename_all()) -> ok.
file_changed(Verb, F) when is_list(F) ->
    file_changed(Verb, unicode:characters_to_binary(F));
file_changed(Verb, F) when is_binary(F) ->
    ?LOG_DEBUG(#{
        text => <<"Filewatcher event">>,
        in => zotonic_filewatcher,
        verb => Verb,
        filename => F
    }),
    case is_file_blocked(F) of
        true ->
            ok;
        false ->
            gen_server:cast(?MODULE, {file_changed, Verb, F})
    end.

-spec re_exclude() -> string().
re_exclude() ->
    ?FILENAME_BLOCKLIST_RE.

-spec is_file_blocked(binary()|string()) -> boolean().
is_file_blocked(<<".", _/binary>>) ->
    true;
is_file_blocked(<<>>) ->
    true;
is_file_blocked(F) when is_binary(F) ->
    case binary:last(F) of
        $# -> true;
        _ -> re:run(F, re_compiled()) =/= nomatch
    end;
is_file_blocked("." ++ _) ->
    true;
is_file_blocked("") ->
    true;
is_file_blocked(F) when is_list(F) ->
    case lists:last(F) of
        $# -> true;
        _ -> re:run(F, re_compiled()) =/= nomatch
    end.

re_compiled() ->
    case erlang:get(blocklist_re) of
        undefined ->
            {ok, RE} = re:compile(?FILENAME_BLOCKLIST_RE),
            erlang:put(blocklist_re, RE),
            RE;
        RE ->
            RE
    end.

%% @doc Called by zotonic_filewatcher_handler
is_file_blocked("priv", "files") -> true;
is_file_blocked("priv", "mnesia") -> true;
is_file_blocked("priv", "log") -> true;
is_file_blocked(<<"priv">>, <<"files">>) -> true;
is_file_blocked(<<"priv">>, <<"mnesia">>) -> true;
is_file_blocked(<<"priv">>, <<"log">>) -> true;
is_file_blocked(_Dir, "." ++ _) -> true;
is_file_blocked(_Dir, <<".", _/binary>>) -> true;
is_file_blocked(_Dir, File) -> is_file_blocked(File).


%% ------------------------------ gen_server Callbacks -----------------------------

-spec init(term()) -> {ok, #state{}}.
init(_) ->
    {ok, #state{
        events = #{},
        is_changed = false
    }}.

handle_call(Msg, _From, State) ->
    {stop, {unknown_msg, Msg}, State}.

handle_cast({file_changed, Verb, F}, #state{ events = Es } = State) ->
    State1 = State#state{ events = add_event(Es, Verb, F) },
    State2 = set_timer(State1),
    {noreply, State2};

handle_cast(Msg, State) ->
    {stop, {unknown_cast, Msg}, State}.

handle_info(maybe_send, #state{ is_changed = false } = State) ->
    {noreply, State};
handle_info(maybe_send, #state{ deadline = Deadline } = State) ->
    Now = msec(),
    case Deadline > Now of
        true ->
            {noreply, State};
        false ->
            send_changes(State#state.events),
            State1 = State#state{
                events = #{},
                is_changed = false
            },
            {noreply, State1}
    end;
handle_info(_Msg, State) ->
    {noreply, State}.

code_change(_Vsn, State, _Extra) ->
    {ok, State}.

terminate(_Reason, _State) ->
    ok.


%% ------------------------------ Internal Functions -----------------------------

-spec send_changes(map()) -> ok.
send_changes(Es) ->
    Msg = #filewatcher_changes{
        changes = Es
    },
    zotonic_notifier:first(?SYSTEM_NOTIFIER, filewatcher_changes, Msg, undefined).

-spec add_event(map(), zotonic_filehandler:verb(), binary()) -> map().
add_event(Es, Verb, Filename) ->
    OldVerb = maps:get(Filename, Es, undefined),
    Es#{ Filename => select_verb(Verb, OldVerb) }.


%% @doc Select the verb to be passed if there are multiple updates to a file.
-spec select_verb(NewVerb, PrevVerb) -> Verb
    when NewVerb :: zotonic_filehandler:verb(),
         PrevVerb :: zotonic_filehandler:verb() | undefined,
         Verb :: zotonic_filehandler:verb().
select_verb(Verb, undefined) -> Verb;
select_verb(delete, _Verb) -> delete;
select_verb(create, _Verb) -> create;
select_verb(modify, Verb) -> Verb.



set_timer(#state{ is_changed = false } = State) ->
    erlang:send_after(?TIMER_DELAY, self(), maybe_send),
    Now = msec(),
    State#state{
        startline = Now,
        deadline = Now + ?DEADLINE_DELAY,
        is_changed = true
    };
set_timer(#state{ startline = Startline } = State) ->
    erlang:send_after(?TIMER_DELAY, self(), maybe_send),
    Now = msec(),
    CurrentDelay = Now - Startline,
    if
        CurrentDelay > ?MAX_DELAY ->
            State#state{
                is_changed = true
            };
        true ->
            State#state{
                deadline = Now + ?DEADLINE_DELAY,
                is_changed = true
            }
    end.

msec() ->
    {Mega, Secs, Micro} = os:timestamp(),
    ((Mega * 1000000) + Secs) * 1000 + (Micro div 1000).