Packages

Generates typed Elixir query modules from plain SQL files using Postgres inference or static metadata.

Current section

Files

Jump to
squirr_elix lib squirrelix watch.ex
Raw

lib/squirrelix/watch.ex

defmodule Squirrelix.Watch do
@moduledoc false
alias Squirrelix.Project
@default_debounce_ms 200
@doc false
@spec sql_query_path?(Path.t(), Path.t()) :: boolean()
def sql_query_path?(path, root) when is_binary(path) and is_binary(root) do
path = canonicalize(path)
root = canonicalize(root)
Path.extname(path) == ".sql" and
Path.basename(Path.dirname(path)) == "sql" and
under_source_root?(path, root)
end
@doc false
@spec watchable_dirs(Path.t()) :: [Path.t()]
def watchable_dirs(root) when is_binary(root) do
root
|> Project.source_roots()
|> Enum.filter(&File.dir?/1)
end
@doc false
@spec watch!(keyword()) :: :ok
def watch!(opts) when is_list(opts) do
root = Keyword.fetch!(opts, :root) |> Path.expand()
dirs = Keyword.get_lazy(opts, :dirs, fn -> watchable_dirs(root) end)
on_change = Keyword.fetch!(opts, :on_change)
debounce_ms = Keyword.get(opts, :debounce_ms, @default_debounce_ms)
after_start = Keyword.get(opts, :after_start)
case dirs do
[] ->
Mix.shell().error(
"Squirrelix watch: no lib/, test/, or dev/ directories to watch under #{root}."
)
:ok
dirs ->
filesystem_opts =
[dirs: dirs]
|> Keyword.merge(Application.get_env(:squirr_elix, :watch_filesystem_opts, []))
watcher_pid = start_filesystem!(filesystem_opts)
FileSystem.subscribe(watcher_pid)
if is_function(after_start, 1) do
after_start.(self())
end
run_loop(%{
root: root,
debounce_ms: debounce_ms,
watcher_pid: watcher_pid,
timer: nil,
on_change: on_change
})
end
end
defp start_filesystem!(filesystem_opts) do
case FileSystem.start_link(filesystem_opts) do
{:ok, pid} ->
pid
:ignore ->
Mix.raise("""
Could not start the file watcher (FileSystem returned :ignore).
On Linux, install inotify-tools (e.g. `apt install inotify-tools`).
See https://github.com/rvoicilas/inotify-tools/wiki
""")
{:error, reason} ->
Mix.raise("""
Could not start the file watcher.
Reason: #{format_watch_reason(reason)}
""")
end
end
@doc false
@spec run_loop(map()) :: :ok
def run_loop(state) when is_map(state) do
receive do
{:file_event, watcher_pid, {path, _events}} when watcher_pid == state.watcher_pid ->
state
|> maybe_schedule(path)
|> run_loop()
{:file_event, watcher_pid, :stop} when watcher_pid == state.watcher_pid ->
Mix.shell().info("Squirrelix watch stopped.")
:ok
:squirrelix_watch_regenerate ->
state.on_change.()
run_loop(%{state | timer: nil})
:squirrelix_watch_stop ->
cancel_timer(state.timer)
Mix.shell().info("Squirrelix watch stopped.")
:ok
end
end
defp maybe_schedule(state, path) do
if sql_query_path?(path, state.root) do
cancel_timer(state.timer)
timer = Process.send_after(self(), :squirrelix_watch_regenerate, state.debounce_ms)
%{state | timer: timer}
else
state
end
end
defp cancel_timer(nil), do: :ok
defp cancel_timer(timer), do: Process.cancel_timer(timer)
defp format_watch_reason(reason) when is_binary(reason), do: reason
defp format_watch_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
defp format_watch_reason(%{__exception__: true} = reason), do: Exception.message(reason)
defp format_watch_reason(reason), do: inspect(reason)
defp under_source_root?(path, root) do
Enum.any?(Project.source_roots(root), fn source_root ->
source_root = canonicalize(source_root)
relative = Path.relative_to(path, source_root)
Path.type(relative) != :absolute
end)
end
# macOS FileSystem backends often report `/private/var/...` while Elixir tmp
# paths use `/var/...` (symlink). Normalize so prefix checks agree.
defp canonicalize(path) do
path = Path.expand(path)
String.replace_prefix(path, "/private", "")
end
end