require Logger
defmodule FileSystem.Backends.FSInotify do
@moduledoc """
File system backend for GNU/Linux, FreeBSD, and OpenBSD.
This file is a fork from https://github.com/synrc/fs.
## Backend Options
* `:recursive` (bool, default: true), monitor directories and their contents recursively.
## Executable File Path
Useful when running `:file_system` with escript.
The default listener executable file is found through finding `inotifywait` from
`$PATH`.
Two ways to customize the executable file path:
* Module config with `config.exs`:
```elixir
config :file_system, :fs_inotify,
executable_file: "YOUR_EXECUTABLE_FILE_PATH"`
```
* System environment variable:
```
export FILESYSTEM_FSINOTIFY_EXECUTABLE_FILE="YOUR_EXECUTABLE_FILE_PATH"`
```
"""
use GenServer
@behaviour FileSystem.Backend
@sep_char <<1>>
def bootstrap do
exec_file = executable_path()
if is_nil(exec_file) do
Logger.error(
"`inotify-tools` is needed to run `file_system` for your system, check https://github.com/rvoicilas/inotify-tools/wiki for more information about how to install it. If it's already installed but not be found, appoint executable file with `config.exs` or `FILESYSTEM_FSINOTIFY_EXECUTABLE_FILE` env."
)
{:error, :fs_inotify_bootstrap_error}
else
:ok
end
end
def supported_systems do
[{:unix, :linux}, {:unix, :freebsd}, {:unix, :openbsd}]
end
def known_events do
[:created, :deleted, :closed, :modified, :isdir, :attribute, :undefined]
end
defp executable_path do
executable_path(:system_env) || executable_path(:config) || executable_path(:system_path)
end
defp executable_path(:config) do
Application.get_env(:file_system, :fs_inotify)[:executable_file]
end
defp executable_path(:system_env) do
System.get_env("FILESYSTEM_FSINOTIFY_EXECUTABLE_FILE")
end
defp executable_path(:system_path) do
System.find_executable("inotifywait")
end
def parse_options(options) do
case Keyword.pop(options, :dirs) do
{nil, _} ->
Logger.error("required argument `dirs` is missing")
{:error, :missing_dirs_argument}
{dirs, rest} ->
format = ["%w", "%e", "%f"] |> Enum.join(@sep_char) |> to_charlist
args = [
~c"-e",
~c"modify",
~c"-e",
~c"close_write",
~c"-e",
~c"moved_to",
~c"-e",
~c"moved_from",
~c"-e",
~c"create",
~c"-e",
~c"delete",
~c"-e",
~c"attrib",
~c"--format",
format,
~c"--quiet",
~c"-m",
~c"-r"
| dirs |> Enum.map(&Path.absname/1) |> Enum.map(&to_charlist/1)
]
parse_options(rest, args)
end
end
defp parse_options([], result), do: {:ok, result}
defp parse_options([{:recursive, true} | t], result) do
parse_options(t, result)
end
defp parse_options([{:recursive, false} | t], result) do
parse_options(t, result -- [~c"-r"])
end
defp parse_options([{:recursive, value} | t], result) do
Logger.error("unknown value `#{inspect(value)}` for recursive, ignore")
parse_options(t, result)
end
defp parse_options([h | t], result) do
Logger.error("unknown option `#{inspect(h)}`, ignore")
parse_options(t, result)
end
def start_link(args) do
GenServer.start_link(__MODULE__, args, [])
end
def init(args) do
{worker_pid, rest} = Keyword.pop(args, :worker_pid)
case parse_options(rest) do
{:ok, port_args} ->
bash_args = [
~c"-c",
~c"#{executable_path()} \"$0\" \"$@\" & PID=$!; read a; kill -KILL $PID"
]
all_args =
case :os.type() do
{:unix, :freebsd} ->
bash_args ++ [~c"--"] ++ port_args
_ ->
bash_args ++ port_args
end
port =
Port.open(
{:spawn_executable, ~c"/bin/sh"},
[
:binary,
:stream,
:exit_status,
{:line, 16384},
{:args, all_args}
]
)
Process.link(port)
Process.flag(:trap_exit, true)
{:ok, %{port: port, worker_pid: worker_pid}}
{:error, _} ->
:ignore
end
end
def handle_info({port, {:data, {:eol, line}}}, %{port: port} = state) do
{file_path, events} = line |> parse_line
send(state.worker_pid, {:backend_file_event, self(), {file_path, events}})
{:noreply, state}
end
def handle_info({port, {:exit_status, _}}, %{port: port} = state) do
send(state.worker_pid, {:backend_file_event, self(), :stop})
{:stop, :normal, state}
end
def handle_info({:EXIT, port, _reason}, %{port: port} = state) do
send(state.worker_pid, {:backend_file_event, self(), :stop})
{:stop, :normal, state}
end
def handle_info(_, state) do
{:noreply, state}
end
def parse_line(line) do
{path, flags} =
case String.split(line, @sep_char, trim: true) do
[dir, flags, file] -> {Path.join(dir, file), flags}
[path, flags] -> {path, flags}
end
{path, flags |> String.split(",") |> Enum.map(&convert_flag/1)}
end
defp convert_flag("CREATE"), do: :created
defp convert_flag("MOVED_TO"), do: :moved_to
defp convert_flag("DELETE"), do: :deleted
defp convert_flag("MOVED_FROM"), do: :moved_from
defp convert_flag("ISDIR"), do: :isdir
defp convert_flag("MODIFY"), do: :modified
defp convert_flag("CLOSE_WRITE"), do: :modified
defp convert_flag("CLOSE"), do: :closed
defp convert_flag("ATTRIB"), do: :attribute
defp convert_flag(_), do: :undefined
end