Rename to MBU and update documentation, make watches wait to trigger
This commit is contained in:
parent
6b1729f4a7
commit
0347b8cd45
7 changed files with 205 additions and 56 deletions
84
README.md
84
README.md
|
@ -1,18 +1,92 @@
|
|||
# FBU: Frontend Build Utilities
|
||||
# MBU: Mix Build Utilities
|
||||
|
||||
**TODO: Add description**
|
||||
_MBU_ is a collection of utility functions and scripts to turn Mix into a build tool
|
||||
like Make. Sort of. With it, you can write tasks that build parts of your system,
|
||||
be it front end or back end, without having to leave the safety of Elixir.
|
||||
|
||||
Shortly put, MBU allows you to write Mix tasks that depend on other tasks and contains
|
||||
helper functions and macros to make writing them easier. See the basic usage section
|
||||
for code examples.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
A typical MBU task looks like this:
|
||||
|
||||
```elixir
|
||||
defmodule Mix.Task.Build.Css do
|
||||
use MBU.BuildTask
|
||||
import MBU.TaskUtils
|
||||
|
||||
@deps [
|
||||
"build.scss",
|
||||
"build.assets"
|
||||
]
|
||||
|
||||
task _args do
|
||||
exec("css-processor", ["--output", "dist/css"]) |> listen()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
There are a few parts to note here:
|
||||
|
||||
* With MBU, you don't call `use Mix.Task`, instead you call `use MBU.BuildTask` that will
|
||||
insert the `task` macro and internally call `use Mix.Task`.
|
||||
* You can define dependencies to your task with the `@deps` list. They are executed in
|
||||
parallel before your task is run. If you need to run a task without running the
|
||||
dependencies, you can use `MBU.TaskUtils.run_task/2` and give it the `deps: false`
|
||||
option.
|
||||
* The task is enclosed in the `task` macro that handles running the dependencies and
|
||||
logging debug output. This is compared to the `run/1` function of an ordinary Mix task.
|
||||
* You can execute programs easily with the `MBU.TaskUtils.exec/2` function that is in the `MBU.TaskUtils`
|
||||
module. It starts the program and returns a program spec that can be given to `MBU.TaskUtils.listen/2`.
|
||||
The listen function listens to output from the program and prints it on the screen.
|
||||
|
||||
MBU also has watch support both for watches builtin to commands and custom watches:
|
||||
|
||||
```elixir
|
||||
defmodule Mix.Task.Watch.Css do
|
||||
use MBU.BuildTask
|
||||
import MBU.TaskUtils
|
||||
|
||||
@deps [
|
||||
"build.css"
|
||||
]
|
||||
|
||||
task _args do
|
||||
[
|
||||
# Builtin watch
|
||||
exec("css-processor", ["--output", "dist-css", "-w"]),
|
||||
|
||||
# Custom watch
|
||||
watch("CopyAssets", "/path/to/assets", fn _events -> File.cp_r!("from", "to") end)
|
||||
]
|
||||
|> listen(watch: true)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
As you can see, there are two types of watches here. The `css-processor` command has its
|
||||
own watch, activated with a command line flag `-w`. The second watch is a custom watch,
|
||||
useful for when CLI tools don't have watch support or when you want to run custom Elixir
|
||||
code. The `MBU.TaskUtils.watch/3` function takes in the watch name (for logging), directory
|
||||
to watch and a callback function that is called for change events.
|
||||
|
||||
Here, the listening function is given an argument `watch: true`. The arguments makes it
|
||||
listen to the user's keyboard input and if the user presses the enter key, the watches and
|
||||
programs are stopped. Otherwise you would have to kill the task to stop them.
|
||||
|
||||
## Installation
|
||||
|
||||
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
|
||||
by adding `fbu` to your list of dependencies in `mix.exs`:
|
||||
by adding `mbu` to your list of dependencies in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
def deps do
|
||||
[{:fbu, "~> 0.1.0"}]
|
||||
[{:mbu, "~> 0.2.0"}]
|
||||
end
|
||||
```
|
||||
|
||||
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
|
||||
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
|
||||
be found at [https://hexdocs.pm/fbu](https://hexdocs.pm/fbu).
|
||||
be found at [https://hexdocs.pm/mbu](https://hexdocs.pm/mbu).
|
||||
|
|
|
@ -10,11 +10,11 @@ use Mix.Config
|
|||
|
||||
# You can configure for your application as:
|
||||
#
|
||||
# config :fbu, key: :value
|
||||
# config :mbu, key: :value
|
||||
#
|
||||
# And access this configuration in your application as:
|
||||
#
|
||||
# Application.get_env(:fbu, :key)
|
||||
# Application.get_env(:mbu, :key)
|
||||
#
|
||||
# Or configure a 3rd-party app:
|
||||
#
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
defmodule FBU.AppRunner do
|
||||
defmodule MBU.AppRunner do
|
||||
@moduledoc """
|
||||
AppRunner is a script for ensuring that external apps are terminated
|
||||
properly when executed from BEAM.
|
||||
|
@ -76,11 +76,11 @@ defmodule FBU.AppRunner do
|
|||
end
|
||||
|
||||
[program | args] = System.argv()
|
||||
port = FBU.AppRunner.exec(program, args)
|
||||
port = MBU.AppRunner.exec(program, args)
|
||||
|
||||
Task.async(fn ->
|
||||
FBU.AppRunner.wait_for_eof()
|
||||
MBU.AppRunner.wait_for_eof()
|
||||
:eof_received
|
||||
end)
|
||||
|
||||
FBU.AppRunner.wait_loop(port)
|
||||
MBU.AppRunner.wait_loop(port)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
defmodule FBU.BuildTask do
|
||||
defmodule MBU.BuildTask do
|
||||
@moduledoc """
|
||||
BuildTask contains the macros that are used for making the tasks look nicer.
|
||||
"""
|
||||
|
@ -6,7 +6,7 @@ defmodule FBU.BuildTask do
|
|||
@doc """
|
||||
Sets up the necessary things for a build task.
|
||||
|
||||
Each build task should have `use FBU.BuildTask` at its beginning. BuildTask automatically
|
||||
Each build task should have `use MBU.BuildTask` at its beginning. BuildTask automatically
|
||||
requires Logger so that doesn't need to be added into the task itself.
|
||||
"""
|
||||
defmacro __using__(_opts) do
|
||||
|
@ -14,7 +14,7 @@ defmodule FBU.BuildTask do
|
|||
use Mix.Task
|
||||
require Logger
|
||||
|
||||
import FBU.BuildTask
|
||||
import MBU.BuildTask
|
||||
|
||||
# Dependencies of the task that will be automatically run before it
|
||||
@deps []
|
||||
|
@ -27,12 +27,12 @@ defmodule FBU.BuildTask do
|
|||
"""
|
||||
defmacro task(args, do: block) do
|
||||
quote do
|
||||
def run(unquote(args) = fbu_buildtask_args) do
|
||||
def run(unquote(args) = mbu_buildtask_args) do
|
||||
task = Mix.Task.task_name(__MODULE__)
|
||||
Logger.info("[Started] #{task}")
|
||||
|
||||
if Keyword.get(fbu_buildtask_args, :deps, true) and not Enum.empty?(@deps) do
|
||||
FBU.TaskUtils.run_tasks(@deps)
|
||||
if Keyword.get(mbu_buildtask_args, :deps, true) and not Enum.empty?(@deps) do
|
||||
MBU.TaskUtils.run_tasks(@deps)
|
||||
end
|
||||
|
||||
unquote(block)
|
||||
|
|
129
lib/utils.ex
129
lib/utils.ex
|
@ -1,4 +1,4 @@
|
|||
defmodule FBU.TaskUtils do
|
||||
defmodule MBU.TaskUtils do
|
||||
@moduledoc """
|
||||
Utilities for project build tasks.
|
||||
"""
|
||||
|
@ -8,6 +8,10 @@ defmodule FBU.TaskUtils do
|
|||
@elixir System.find_executable("elixir")
|
||||
|
||||
@default_task_timeout 60000
|
||||
@watch_combine_time 200
|
||||
|
||||
@typep task_name :: String.t | module
|
||||
@typep task_list :: [task_name | fun | {task_name, [...]}]
|
||||
|
||||
defmodule ProgramSpec do
|
||||
@moduledoc """
|
||||
|
@ -25,36 +29,32 @@ defmodule FBU.TaskUtils do
|
|||
Watch specification, target to watch and callback to execute on events.
|
||||
Name is used for prefixing logs.
|
||||
|
||||
Callback must have arity 2, gets filename/path and list of events as arguments.
|
||||
Callback must have arity 1, gets list of 2-tuples representing change events,
|
||||
each containing path to changed file and list of events that occurred.
|
||||
"""
|
||||
defstruct [
|
||||
name: "",
|
||||
path: "",
|
||||
callback: nil,
|
||||
pid: nil,
|
||||
name_atom: nil
|
||||
name_atom: nil,
|
||||
events: [],
|
||||
waiting_to_trigger: false
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get configuration value.
|
||||
@typedoc """
|
||||
A watch specification or program specification that is used internally to keep
|
||||
track of running programs and watches.
|
||||
"""
|
||||
def conf(key) when is_atom(key) do
|
||||
Application.get_env(:code_stats, key)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get absolute path to a program in $PATH.
|
||||
"""
|
||||
def exec_path(program) do
|
||||
System.find_executable(program)
|
||||
end
|
||||
@type buildspec :: %WatchSpec{} | %ProgramSpec{}
|
||||
|
||||
@doc """
|
||||
Run the given Mix task and wait for it to stop before returning.
|
||||
|
||||
See run_tasks/2 for the argument description.
|
||||
"""
|
||||
@spec run_task(task_name, [...]) :: any
|
||||
def run_task(task, args \\ []) do
|
||||
run_tasks([{task, args}])
|
||||
end
|
||||
|
@ -69,8 +69,12 @@ defmodule FBU.TaskUtils do
|
|||
* `task_name`, where `task_name` is a Mix task name or module to call without
|
||||
arguments, or
|
||||
* `task_function` where `task_function` is a function to call without arguments.
|
||||
|
||||
Arguments should be keyword lists. If an argument `deps: false` is given to a task
|
||||
that is an `MBU.BuildTask`, its dependencies will not be executed.
|
||||
"""
|
||||
def run_tasks(tasks) do
|
||||
@spec run_tasks(task_list) :: any
|
||||
def run_tasks(tasks) when is_list(tasks) do
|
||||
tasks
|
||||
|
||||
|> Enum.map(fn
|
||||
|
@ -114,7 +118,11 @@ defmodule FBU.TaskUtils do
|
|||
|
||||
Functions can either be anonymous functions or tuples of
|
||||
{module, fun, args}.
|
||||
|
||||
Can be given optional timeout, how long to wait for the execution of a task.
|
||||
By default it's 60 seconds.
|
||||
"""
|
||||
@spec run_funs([fun | {module, fun, [...]}], integer) :: any
|
||||
def run_funs(funs, timeout \\ @default_task_timeout) when is_list(funs) do
|
||||
funs
|
||||
|> Enum.map(fn
|
||||
|
@ -141,6 +149,7 @@ defmodule FBU.TaskUtils do
|
|||
|
||||
Returns ProgramSpec for the started program.
|
||||
"""
|
||||
@spec exec(String.t, list, list) :: %ProgramSpec{}
|
||||
def exec(executable, args, opts \\ []) do
|
||||
name = Keyword.get(
|
||||
opts,
|
||||
|
@ -174,11 +183,33 @@ defmodule FBU.TaskUtils do
|
|||
@doc """
|
||||
Start watching a path. Name is used for prefixing logs.
|
||||
|
||||
Path can point to a file or a directory in which case all subdirs will be watched.
|
||||
Path must point to a directory. All subdirs will be watched automatically.
|
||||
|
||||
The callback function is called whenever changes occur in the watched directory. It
|
||||
will receive a list of 2-tuples, each tuple describing one change. Each tuple has
|
||||
two elements: path to the changed file and a list of change events for that file
|
||||
(returned from `:fs`).
|
||||
|
||||
Instead of a callback function, you can also give a module name of an `MBU.BuildTask`.
|
||||
In that case, the specified task will be called without arguments and with `deps: false`.
|
||||
|
||||
Watch events are combined so that all events occurring in ~200 milliseconds are
|
||||
sent in the same call. This is to avoid running the watch callback many times
|
||||
when a bunch of files change.
|
||||
|
||||
**NOTE:** Never add multiple watches to the same path, or you may end up with
|
||||
unexpected issues!
|
||||
|
||||
Returns a WatchSpec.
|
||||
"""
|
||||
def watch(name, path, fun) do
|
||||
@spec watch(String.t, String.t, ([{String.t, [atom]}] -> any)) | module :: %WatchSpec{}
|
||||
def watch(name, path, fun_or_mod)
|
||||
|
||||
def watch(name, path, fun_or_mod) when is_atom(fun_or_mod), do
|
||||
watch(name, path, fn _ -> run_task(fun_or_mod, deps: false) end)
|
||||
end
|
||||
|
||||
def watch(name, path, fun_or_mod) when is_function(fun_or_mod) do
|
||||
name_atom = String.to_atom(name)
|
||||
{:ok, pid} = :fs.start_link(name_atom, String.to_charlist(path))
|
||||
:fs.subscribe(name_atom)
|
||||
|
@ -188,18 +219,19 @@ defmodule FBU.TaskUtils do
|
|||
%WatchSpec{
|
||||
name: name,
|
||||
path: path,
|
||||
callback: fun,
|
||||
callback: fun_or_mod,
|
||||
pid: pid,
|
||||
name_atom: name_atom
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Listen to messages from specs and print them to the screen.
|
||||
Listen to messages from the given specs and print them to the screen.
|
||||
|
||||
If watch: true is given in the options, will listen for user's enter key and
|
||||
kill programs if enter is pressed.
|
||||
kill programs/watches if enter is pressed.
|
||||
"""
|
||||
@spec listen(buildspec | [buildspec], list) :: any
|
||||
def listen(specs, opts \\ [])
|
||||
|
||||
# If there are no specs, stop running
|
||||
|
@ -274,14 +306,21 @@ defmodule FBU.TaskUtils do
|
|||
# FS watch sent file event
|
||||
{_, {:fs, :file_event}, {file, events}} ->
|
||||
handle_events(specs, file, events)
|
||||
|
||||
# A watch was triggered after the timeout
|
||||
{:trigger_watch, path} ->
|
||||
handle_watch_trigger(specs, path)
|
||||
end
|
||||
|
||||
listen(specs, Keyword.put(opts, :task, task))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kill a running program returned by exec().
|
||||
Kill a running program or watch.
|
||||
"""
|
||||
@spec kill(buildspec) :: any
|
||||
def kill(spec)
|
||||
|
||||
def kill(%ProgramSpec{name: name, port: port}) do
|
||||
if name != nil do
|
||||
Logger.debug("[Killing] #{name}")
|
||||
|
@ -290,9 +329,6 @@ defmodule FBU.TaskUtils do
|
|||
send(port, {self(), :close})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kill a running watch.
|
||||
"""
|
||||
def kill(%WatchSpec{name: name, pid: pid}) do
|
||||
Logger.debug("[Killing] #{name}")
|
||||
Process.exit(pid, :kill)
|
||||
|
@ -304,6 +340,7 @@ defmodule FBU.TaskUtils do
|
|||
If old file is given as second argument, print the old file's size
|
||||
and the diff also.
|
||||
"""
|
||||
@spec print_size(String.t, String.t) :: any
|
||||
def print_size(new_file, old_file \\ nil) do
|
||||
new_size = get_size(new_file)
|
||||
|
||||
|
@ -321,6 +358,14 @@ defmodule FBU.TaskUtils do
|
|||
Logger.debug("#{Path.basename(new_file)}: #{prefix}#{human_size(new_size)}.#{postfix}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Wait for user's enter key and send a message `:user_input_received` to the given
|
||||
target process when enter was pressed.
|
||||
|
||||
This is exposed due to internal code structure and is not really useful to call
|
||||
yourself.
|
||||
"""
|
||||
@spec wait_for_input(pid) :: any
|
||||
def wait_for_input(target) do
|
||||
IO.gets("")
|
||||
send(target, :user_input_received)
|
||||
|
@ -371,7 +416,7 @@ defmodule FBU.TaskUtils do
|
|||
%WatchSpec{path: watch_path} ->
|
||||
# If given path is relative to (under) the watch path or is the same
|
||||
# path completely, it's a match.
|
||||
path != watch_path and Path.relative_to(path, watch_path) != path
|
||||
path == watch_path or Path.relative_to(path, watch_path) != path
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -392,16 +437,38 @@ defmodule FBU.TaskUtils do
|
|||
defp handle_events(specs, file, events) do
|
||||
file = to_string(file)
|
||||
case Enum.find(specs, watch_checker(file)) do
|
||||
%WatchSpec{name: name, callback: callback} ->
|
||||
Logger.debug("[#{name}] Changed #{inspect(events)}: #{file}")
|
||||
%WatchSpec{path: path, waiting_to_trigger: waiting} = spec ->
|
||||
# Add to spec's events and start waiting to trigger events if not already
|
||||
# waiting
|
||||
specs = Enum.reject(specs, watch_checker(path))
|
||||
spec_events = [spec.events | [{file, events}]]
|
||||
|
||||
callback.(file, events)
|
||||
if not waiting do
|
||||
Process.send_after(self(), {:trigger_watch, path}, @watch_combine_time)
|
||||
end
|
||||
|
||||
[specs | [%{spec | events: spec_events, waiting_to_trigger: true}]]
|
||||
|
||||
nil ->
|
||||
# Watch was maybe removed for some reason
|
||||
Logger.debug("[Error] Watch sent event but path was not in specs list: #{inspect(events)} #{file}")
|
||||
Logger.error("[Error] Watch sent event but path was not in specs list: #{inspect(events)} #{file}")
|
||||
specs
|
||||
end
|
||||
end
|
||||
|
||||
specs
|
||||
defp handle_watch_trigger(specs, path) do
|
||||
case Enum.find(specs, watch_checker(path)) do
|
||||
%WatchSpec{name: name, callback: callback, events: events} = spec ->
|
||||
Logger.debug("[#{name}] Changed: #{inspect(events)}")
|
||||
|
||||
callback.(events)
|
||||
|
||||
specs = Enum.reject(specs, watch_checker(path))
|
||||
[specs | [%{spec | events: [], waiting_to_trigger: false}]]
|
||||
|
||||
nil ->
|
||||
Logger.error("[Error] Watch triggered but did not exist anymore: #{inspect(path)}")
|
||||
specs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
14
mix.exs
14
mix.exs
|
@ -1,10 +1,18 @@
|
|||
defmodule FBU.Mixfile do
|
||||
defmodule MBU.Mixfile do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[app: :fbu,
|
||||
version: "0.1.0",
|
||||
[app: :mbu,
|
||||
version: "0.2.0",
|
||||
elixir: "~> 1.4",
|
||||
name: "MBU: Mix Build Utilities",
|
||||
source_url: "https://github.com/Nicd/mbu",
|
||||
docs: [
|
||||
main: "readme",
|
||||
extras: ["README.md"]
|
||||
],
|
||||
|
||||
|
||||
build_embedded: Mix.env == :prod,
|
||||
start_permanent: Mix.env == :prod,
|
||||
deps: deps()]
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
defmodule FBUTest do
|
||||
defmodule MBUTest do
|
||||
use ExUnit.Case
|
||||
|
||||
# Dependency functions to call in tasks
|
||||
|
@ -11,7 +11,7 @@ defmodule FBUTest do
|
|||
|
||||
test "that a module using the task macro has a run function that takes one argument" do
|
||||
defmodule Mix.Task.Foo1 do
|
||||
use FBU.BuildTask
|
||||
use MBU.BuildTask
|
||||
|
||||
task args do
|
||||
assert args == [1]
|
||||
|
@ -23,7 +23,7 @@ defmodule FBUTest do
|
|||
|
||||
test "that the macro works when given _ to denote ignored args" do
|
||||
defmodule Mix.Task.Foo2 do
|
||||
use FBU.BuildTask
|
||||
use MBU.BuildTask
|
||||
|
||||
task _ do
|
||||
:ok
|
||||
|
@ -35,7 +35,7 @@ defmodule FBUTest do
|
|||
|
||||
test "that deps are run before the task" do
|
||||
defmodule Mix.Task.Foo3 do
|
||||
use FBU.BuildTask
|
||||
use MBU.BuildTask
|
||||
|
||||
@deps [
|
||||
&Depfuns.foo/0,
|
||||
|
@ -56,7 +56,7 @@ defmodule FBUTest do
|
|||
|
||||
test "that deps are not run if deps: false is given" do
|
||||
defmodule Mix.Task.Foo4 do
|
||||
use FBU.BuildTask
|
||||
use MBU.BuildTask
|
||||
|
||||
@deps [
|
||||
&Depfuns.foo_no/0,
|
Loading…
Reference in a new issue