src/zotonic_filewatcher_fswatch.erl

%% @author Arjan Scherpenisse <arjan@scherpenisse.net>
%% @copyright 2014-2018 Arjan Scherpenisse <arjan@scherpenisse.net>

%% @doc Watch for changed files using fswatch (MacOS X; brew install fswatch).
%%      https://github.com/emcrisostomo/fswatch

%% Copyright 2014-2018 Arjan Scherpenisse
%% Copyright 2015-2018 Marc Worrell
%%
%% 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_fswatch).
-author("Arjan Scherpenisse <arjan@scherpenisse.net>").

-behaviour(gen_server).

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

-record(state, {
    pid :: pid() | undefined,
    port :: integer() | undefined,
    data :: binary(),
    executable :: string()
}).

%% interface functions
-export([
    is_installed/0,
    restart/0
]).


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

%%====================================================================
%% API
%%====================================================================
%% @doc Starts the server
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
    case os:find_executable("fswatch") of
        false ->
            {error, "fswatch not found"};
        Executable ->
            gen_server:start_link({local, ?MODULE}, ?MODULE, [Executable], [])
    end.

-spec is_installed() -> boolean().
is_installed() ->
    os:find_executable("fswatch") =/= false.

-spec restart() -> ok.
restart() ->
    gen_server:cast(?MODULE, restart).

%%====================================================================
%% gen_server callbacks
%%====================================================================

%% @spec init(Args) -> {ok, State} |
%%                     {ok, State, Timeout} |
%%                     ignore               |
%%                     {stop, Reason}
%% @doc Initiates the server.
init([Executable]) ->
    process_flag(trap_exit, true),
    State = #state{
        executable = Executable,
        port = undefined,
        pid = undefined,
        data = <<>>
    },
    timer:send_after(100, start),
    {ok, State}.

%% @doc Trap unknown calls
handle_call(Message, _From, State) ->
    {stop, {unknown_call, Message}, State}.

%% @spec handle_cast(Msg, State) -> {noreply, State} |
%%                                  {noreply, State, Timeout} |
%%                                  {stop, Reason, State}
handle_cast(restart, #state{ pid = undefined } = State) ->
    {noreply, State};
handle_cast(restart, #state{ pid = Pid } = State) when is_pid(Pid) ->
    ?LOG_INFO("[inotify] Stopping fswatch file monitor."),
    catch exec:stop(Pid),
    {noreply, start_fswatch(State#state{ port = undefined })};

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

%% @doc Reading a line from the fswatch program.
handle_info({stdout, _Port, FilenameFlags}, #state{ data = Data } = State) ->
    {FVs, Rest} = extract_filename_verb( <<Data/binary, FilenameFlags/binary>>),
    lists:map(
        fun({Filename, Verb}) ->
            zotonic_filewatcher_handler:file_changed(Verb, Filename)
        end,
        FVs),
    {noreply, State#state{ data = Rest }};

handle_info({'DOWN', _Port, process, Pid, Reason}, #state{pid = Pid} = State) ->
    ?LOG_ERROR(#{
        text => <<"[fswatch] fswatch port closed, restarting in 5 seconds.">>,
        in => zotonic_filewatcher,
        result => error,
        reason => Reason
    }),
    State1 = State#state{
        pid = undefined,
        port = undefined
    },
    timer:send_after(5000, start),
    {noreply, State1};

handle_info({'EXIT', _Pid, _Reason}, State) ->
    {noreply, State};

handle_info(start, #state{port = undefined} = State) ->
    {noreply, start_fswatch(State)};
handle_info(start, State) ->
    {noreply, State};

handle_info(_Info, State) ->
    {noreply, State}.

%% @spec terminate(Reason, State) -> void()
%% @doc This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any necessary
%% cleaning up. When it returns, the gen_server terminates with Reason.
%% The return value is ignored.
terminate(_Reason, #state{pid = undefined}) ->
    ok;
terminate(_Reason, #state{pid = Pid}) ->
    catch exec:stop(Pid),
    ok.

%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState}
%% @doc Convert process state when code is changed
code_change(_OldVsn, State, _Extra) ->
    {ok, State}.


%%====================================================================
%% support functions
%%====================================================================

start_fswatch(State=#state{executable = Executable, port = undefined}) ->
    ?LOG_INFO("[fswatch] Starting fswatch file monitor."),
    REs = lists:foldl(
        fun(RE, Acc) ->
            [ "-e", RE | Acc ]
        end,
        [],
        string:tokens(zotonic_filewatcher_handler:re_exclude(), "|")),
    Args = [ Executable, "-0", "-x", "-Lr" ]
        ++ REs
        ++ zotonic_filewatcher_sup:watch_dirs_expanded(),
    {ok, Pid, Port} = exec:run_link(Args, [stdout, monitor]),
    State#state{
        port = Port,
        pid = Pid,
        data = <<>>
    }.

extract_filename_verb(Line) ->
    [ Rest | Lines ] = lists:reverse( binary:split(Line, <<0>>, [global]) ),
    FVs = lists:foldl(fun split_line/2, [], Lines),
    {FVs, Rest}.

split_line(<<>>, Acc) ->
    Acc;
split_line(Line, Acc) ->
	% get a file path that may include spaces
	Filepath = get_filepath(Line),
	% extract a verb from the line, while ignoring strings that are not verbs
	[_|Flags] = binary:split(Line, <<" ">>, [global]),
    Verb = case extract_verb(Flags) of
        create ->
            % Deletes and renames are sometimes seen as a create
            case filelib:is_file(Filepath) of
                true -> create;
                false -> delete
            end;
        V ->
            V
    end,
    [{Filepath, Verb} | Acc].

%% Remove verbs from line, preserve spaces
get_filepath(Line) ->
	Space = <<" ">>,
	Parts = lists:foldl(fun(Part, Acc) ->
		case extract_filepath(Part) of
			<<>> -> Acc;
			P -> <<Acc/binary, P/binary, Space/binary>>
		end
	end, <<>>, binary:split(Line, Space, [global])),
	string:strip(unicode:characters_to_list(Parts), both, $ ).

% Remove all known verbs; the remainder must be the file path
% of course this breaks when new event names are added to fswatch
extract_filepath(<<>>) -> <<>>;
extract_filepath(<<"PlatformSpecific">>) -> <<>>;
extract_filepath(<<"AttributeModified">>) -> <<>>;
extract_filepath(<<"Created">>) -> <<>>;
extract_filepath(<<"Updated">>) -> <<>>;
extract_filepath(<<"Removed">>) -> <<>>;
extract_filepath(<<"Renamed">>) -> <<>>;
extract_filepath(<<"OwnerModified">>) -> <<>>;
extract_filepath(<<"MovedFrom">>) -> <<>>;
extract_filepath(<<"MovedTo">>) -> <<>>;
extract_filepath(<<"IsFile">>) -> <<>>;
extract_filepath(<<"IsDir">>) -> <<>>;
extract_filepath(<<"IsSymLink">>) -> <<>>;
extract_filepath(<<"Link">>) -> <<>>;
extract_filepath(F) -> F.

extract_verb([]) -> modify;
extract_verb([<<"Removed">>, <<"Renamed">> | _ ]) -> modify;
extract_verb([<<"Created">>|_]) -> create;
extract_verb([<<"Removed">>|_]) -> delete;
extract_verb([<<"MovedFrom">>|_]) -> delete;
extract_verb([<<"MovedTo">>|_]) -> create;
extract_verb([<<"Renamed">>|_]) -> create;
extract_verb([_|Flags]) -> extract_verb(Flags).