Rename to MBU and update documentation, make watches wait to trigger

This commit is contained in:
Mikko Ahlroth 2017-04-03 23:24:52 +03:00
parent 6b1729f4a7
commit 0347b8cd45
7 changed files with 205 additions and 56 deletions

View file

@ -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).

View file

@ -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:
#

View file

@ -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)

View file

@ -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)

View file

@ -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
View file

@ -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()]

View file

@ -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,