Initial commit

This commit is contained in:
Mikko Ahlroth 2017-03-21 07:44:44 +02:00
commit 6b1729f4a7
11 changed files with 749 additions and 0 deletions

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# The directory Mix will write compiled artifacts to.
/_build
# If you run "mix test --cover", coverage assets end up here.
/cover
# The directory Mix downloads your dependencies sources to.
/deps
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez

27
LICENSE Normal file
View file

@ -0,0 +1,27 @@
Copyright © 2017, Mikko Ahlroth
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

18
README.md Normal file
View file

@ -0,0 +1,18 @@
# FBU: Frontend Build Utilities
**TODO: Add description**
## 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`:
```elixir
def deps do
[{:fbu, "~> 0.1.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).

30
config/config.exs Normal file
View file

@ -0,0 +1,30 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
use Mix.Config
# This configuration is loaded before any dependency and is restricted
# to this project. If another project depends on this project, this
# file won't be loaded nor affect the parent project. For this reason,
# if you want to provide default values for your application for
# 3rd-party users, it should be done in your "mix.exs" file.
# You can configure for your application as:
#
# config :fbu, key: :value
#
# And access this configuration in your application as:
#
# Application.get_env(:fbu, :key)
#
# Or configure a 3rd-party app:
#
# config :logger, level: :info
#
# It is also possible to import configuration files, relative to this
# directory. For example, you can emulate configuration per environment
# by uncommenting the line below and defining dev.exs, test.exs and such.
# Configuration from the imported file will override the ones defined
# here (which is why it is important to import them last).
#
# import_config "#{Mix.env}.exs"

86
lib/apprunner.exs Normal file
View file

@ -0,0 +1,86 @@
defmodule FBU.AppRunner do
@moduledoc """
AppRunner is a script for ensuring that external apps are terminated
properly when executed from BEAM.
If you start an application that does not listen to STDIN from BEAM
and BEAM is terminated, the application's STDIN is closed but it may
never close. AppRunner is meant to sit in between, monitoring the
STDIN pipe and killing the application if STDIN closes. This ensures
no zombie processes are left behind if the host BEAM dies.
Output from the application will be sent back to the host BEAM. If the
application dies for any reason, AppRunner is also closed.
"""
def wait_for_eof() do
case IO.getn("", 1024) do
:eof -> nil
_ -> wait_for_eof()
end
end
def exec(program, args) do
Port.open(
{:spawn_executable, program},
[
:exit_status,
:stderr_to_stdout, # Redirect stderr to stdout to log properly
args: args,
line: 1024
]
)
end
def get_pid(port) do
case Port.info(port) do
nil ->
nil
info when is_list(info) ->
case Keyword.get(info, :os_pid) do
nil -> nil
pid -> Integer.to_string(pid)
end
end
end
def kill(pid) do
System.find_executable("kill") |> System.cmd([pid])
end
def wait_loop(port) do
receive do
{_, {:data, {:eol, msg}}} ->
msg |> :unicode.characters_to_binary(:unicode) |> IO.puts()
{_, {:data, {:noeol, msg}}} ->
msg |> :unicode.characters_to_binary(:unicode) |> IO.write()
{_, :eof_received} ->
get_pid(port) |> kill()
:erlang.halt(0)
{_, :closed} ->
:erlang.halt(0)
{_, {:exit_status, status}} ->
:erlang.halt(status)
{:EXIT, _, _} ->
:erlang.halt(1)
end
wait_loop(port)
end
end
[program | args] = System.argv()
port = FBU.AppRunner.exec(program, args)
Task.async(fn ->
FBU.AppRunner.wait_for_eof()
:eof_received
end)
FBU.AppRunner.wait_loop(port)

44
lib/buildtask.ex Normal file
View file

@ -0,0 +1,44 @@
defmodule FBU.BuildTask do
@moduledoc """
BuildTask contains the macros that are used for making the tasks look nicer.
"""
@doc """
Sets up the necessary things for a build task.
Each build task should have `use FBU.BuildTask` at its beginning. BuildTask automatically
requires Logger so that doesn't need to be added into the task itself.
"""
defmacro __using__(_opts) do
quote do
use Mix.Task
require Logger
import FBU.BuildTask
# Dependencies of the task that will be automatically run before it
@deps []
end
end
@doc """
Replacement for Mix.Task's run/1, used similarly. Code inside will be run when the task
is called, with @deps being run first unless `deps: false` is given in the arguments.
"""
defmacro task(args, do: block) do
quote do
def run(unquote(args) = fbu_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)
end
unquote(block)
Logger.info("[Finished] #{task}")
end
end
end
end

407
lib/utils.ex Normal file
View file

@ -0,0 +1,407 @@
defmodule FBU.TaskUtils do
@moduledoc """
Utilities for project build tasks.
"""
require Logger
@elixir System.find_executable("elixir")
@default_task_timeout 60000
defmodule ProgramSpec do
@moduledoc """
Program that is executed with arguments. Name is used for prefixing logs.
"""
defstruct [
name: "",
port: nil,
pending_output: ""
]
end
defmodule WatchSpec do
@moduledoc """
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.
"""
defstruct [
name: "",
path: "",
callback: nil,
pid: nil,
name_atom: nil
]
end
@doc """
Get configuration value.
"""
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
@doc """
Run the given Mix task and wait for it to stop before returning.
See run_tasks/2 for the argument description.
"""
def run_task(task, args \\ []) do
run_tasks([{task, args}])
end
@doc """
Run the given tasks in parallel and wait for them all to stop
before returning.
Valid task types:
* `{task_name, args}`, where `task_name` is a Mix task name or module,
* `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.
"""
def run_tasks(tasks) do
tasks
|> Enum.map(fn
task_name when is_atom(task_name) or is_binary(task_name) ->
{task_name, []}
{task_name, args} when is_atom(task_name) or is_binary(task_name) ->
{task_name, args}
fun when is_function(fun) ->
fun
end)
# Convert module references to string task names
|> Enum.map(fn
{task_module, args} when is_atom(task_module) ->
{Mix.Task.task_name(task_module), args}
{task_name, args} when is_binary(task_name) ->
{task_name, args}
fun when is_function(fun) ->
fun
end)
|> Enum.map(fn
{task, args} ->
# Rerun is used here to enable tasks being run again in watches
fn -> Mix.Task.rerun(task, args) end
fun when is_function(fun) ->
fun
end)
|> run_funs(@default_task_timeout)
end
@doc """
Run the given functions in parallel and wait for them all to stop
before returning.
Functions can either be anonymous functions or tuples of
{module, fun, args}.
"""
def run_funs(funs, timeout \\ @default_task_timeout) when is_list(funs) do
funs
|> Enum.map(fn
fun when is_function(fun) ->
Task.async(fun)
{module, fun, args} ->
Task.async(module, fun, args)
end)
|> Enum.map(fn task ->
Task.await(task, timeout)
end)
end
@doc """
Start an external program with apprunner.
Apprunner handles killing the program if BEAM is abruptly shut down.
Name is used as a prefix for logging output.
Options that can be given:
- name: Use as name for logging, otherwise name of binary is used.
- cd: Directory to change to before executing.
Returns ProgramSpec for the started program.
"""
def exec(executable, args, opts \\ []) do
name = Keyword.get(
opts,
:name,
(executable |> Path.rootname() |> Path.basename())
)
options = [
:exit_status, # Send msg with status when command stops
args: [Path.join(__DIR__, "apprunner.exs") | [executable | args]],
line: 1024 # Send command output as lines of 1k length
]
options = case Keyword.get(opts, :cd) do
nil -> options
cd -> Keyword.put(options, :cd, cd)
end
Logger.debug("[Spawned] Program #{name}")
%ProgramSpec{
name: name,
pending_output: "",
port: Port.open(
{:spawn_executable, @elixir},
options
)
}
end
@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.
Returns a WatchSpec.
"""
def watch(name, path, fun) do
name_atom = String.to_atom(name)
{:ok, pid} = :fs.start_link(name_atom, String.to_charlist(path))
:fs.subscribe(name_atom)
Logger.debug("[Spawned] Watch #{name}")
%WatchSpec{
name: name,
path: path,
callback: fun,
pid: pid,
name_atom: name_atom
}
end
@doc """
Listen to messages from 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.
"""
def listen(specs, opts \\ [])
# If there are no specs, stop running
def listen([], _), do: :ok
def listen(spec, opts) when not is_list(spec) do
listen([spec], opts)
end
def listen(specs, opts) when is_list(specs) do
# Start another task to ask for user input if we are in watch mode
task = with \
true <- Keyword.get(opts, :watch, false),
nil <- Keyword.get(opts, :task),
{:ok, task} <- Task.start_link(__MODULE__, :wait_for_input, [self()])
do
Logger.info("Programs/watches started, press ENTER to exit.")
task
end
specs = receive do
# User pressed enter
:user_input_received ->
Logger.info("ENTER received, killing tasks.")
Enum.each(specs, &kill/1)
[]
# Program sent output with end of line
{port, {:data, {:eol, msg}}} ->
program = Enum.find(specs, program_checker(port))
msg = :unicode.characters_to_binary(msg, :unicode)
prefix = "[#{program.name}] #{program.pending_output}"
Logger.debug(prefix <> msg)
specs
|> Enum.reject(program_checker(port))
|> Enum.concat([
%{program | pending_output: ""}
])
# Program sent output without end of line
{port, {:data, {:noeol, msg}}} ->
program = Enum.find(specs, program_checker(port))
msg = :unicode.characters_to_binary(msg, :unicode)
specs
|> Enum.reject(program_checker(port))
|> Enum.concat([
%{program | pending_output: "#{program.pending_output}#{msg}"}
])
# Port was closed normally after being told to close
{port, :closed} ->
handle_closed(specs, port)
# Port closed because the program closed by itself
{port, {:exit_status, 0}} ->
handle_closed(specs, port)
# Program closed with error status
{port, {:exit_status, status}} ->
program = Enum.find(specs, program_checker(port))
Logger.error("Program #{program.name} returned status #{status}.")
raise "Failed status #{status} from #{program.name}!"
# Port crashed
{:EXIT, port, _} ->
handle_closed(specs, port)
# FS watch sent file event
{_, {:fs, :file_event}, {file, events}} ->
handle_events(specs, file, events)
end
listen(specs, Keyword.put(opts, :task, task))
end
@doc """
Kill a running program returned by exec().
"""
def kill(%ProgramSpec{name: name, port: port}) do
if name != nil do
Logger.debug("[Killing] #{name}")
end
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)
end
@doc """
Print file size of given file in human readable form.
If old file is given as second argument, print the old file's size
and the diff also.
"""
def print_size(new_file, old_file \\ nil) do
new_size = get_size(new_file)
{prefix, postfix} = if old_file != nil do
old_size = get_size(old_file)
{
"#{human_size(old_size)} -> ",
" Diff: #{human_size(old_size - new_size)}"
}
else
{"", ""}
end
Logger.debug("#{Path.basename(new_file)}: #{prefix}#{human_size(new_size)}.#{postfix}")
end
def wait_for_input(target) do
IO.gets("")
send(target, :user_input_received)
end
defp get_size(file) do
%File.Stat{size: size} = File.stat!(file)
size
end
defp human_size(size) do
size_units = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] # You never know
human_size(size, size_units)
end
defp human_size(size, [unit | []]), do: size_with_unit(size, unit)
defp human_size(size, [unit | rest]) do
if size > 1024 do
human_size(size / 1024, rest)
else
size_with_unit(size, unit)
end
end
defp size_with_unit(size, unit) when is_float(size), do: "#{Float.round(size, 2)} #{unit}"
defp size_with_unit(size, unit), do: "#{size} #{unit}"
# Utility to find program in spec list based on port
defp program_checker(port) do
fn
%ProgramSpec{port: program_port} ->
program_port == port
%WatchSpec{} ->
false
end
end
# Utility to find watch in spec list based on path
defp watch_checker(path) do
fn
%ProgramSpec{} ->
false
%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
end
end
defp handle_closed(specs, port) do
case Enum.find(specs, program_checker(port)) do
%ProgramSpec{} = program ->
Logger.debug("[Stopped] #{program.name}")
specs
|> Enum.reject(program_checker(port))
nil ->
# Program was already removed
specs
end
end
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}")
callback.(file, events)
nil ->
# Watch was maybe removed for some reason
Logger.debug("[Error] Watch sent event but path was not in specs list: #{inspect(events)} #{file}")
end
specs
end
end

36
mix.exs Normal file
View file

@ -0,0 +1,36 @@
defmodule FBU.Mixfile do
use Mix.Project
def project do
[app: :fbu,
version: "0.1.0",
elixir: "~> 1.4",
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
deps: deps()]
end
# Configuration for the OTP application
#
# Type "mix help compile.app" for more information
def application do
# Specify extra applications you'll use from Erlang/Elixir
[extra_applications: [:logger]]
end
# Dependencies can be Hex packages:
#
# {:my_dep, "~> 0.3.0"}
#
# Or git/path repositories:
#
# {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
#
# Type "mix help deps" for more examples and options
defp deps do
[
{:ex_doc, "~> 0.15.0", only: :dev},
{:fs, "~> 2.12.0"}
]
end
end

3
mix.lock Normal file
View file

@ -0,0 +1,3 @@
%{"earmark": {:hex, :earmark, "1.2.0", "bf1ce17aea43ab62f6943b97bd6e3dc032ce45d4f787504e3adf738e54b42f3a", [:mix], []},
"ex_doc": {:hex, :ex_doc, "0.15.0", "e73333785eef3488cf9144a6e847d3d647e67d02bd6fdac500687854dd5c599f", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]},
"fs": {:hex, :fs, "2.12.0", "ad631efacc9a5683c8eaa1b274e24fa64a1b8eb30747e9595b93bec7e492e25e", [:rebar3], []}}

77
test/fbu_test.exs Normal file
View file

@ -0,0 +1,77 @@
defmodule FBUTest do
use ExUnit.Case
# Dependency functions to call in tasks
defmodule Depfuns do
def foo(), do: send(Process.whereis(:deps_test_runner), :foo)
def bar(), do: send(Process.whereis(:deps_test_runner), :bar)
def foo_no(), do: send(Process.whereis(:no_deps_test_runner), :foo)
def bar_no(), do: send(Process.whereis(:no_deps_test_runner), :bar)
end
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
task args do
assert args == [1]
end
end
assert Mix.Task.Foo1.run([1]) == :ok
end
test "that the macro works when given _ to denote ignored args" do
defmodule Mix.Task.Foo2 do
use FBU.BuildTask
task _ do
:ok
end
end
assert Mix.Task.Foo2.run([]) == :ok
end
test "that deps are run before the task" do
defmodule Mix.Task.Foo3 do
use FBU.BuildTask
@deps [
&Depfuns.foo/0,
&Depfuns.bar/0
]
task _ do
:ok
end
end
Process.register(self(), :deps_test_runner)
Mix.Task.Foo3.run([])
assert_receive :foo
assert_receive :bar
end
test "that deps are not run if deps: false is given" do
defmodule Mix.Task.Foo4 do
use FBU.BuildTask
@deps [
&Depfuns.foo_no/0,
&Depfuns.bar_no/0
]
task _ do
:ok
end
end
Process.register(self(), :no_deps_test_runner)
Mix.Task.Foo4.run([1, 2, 3, 4, 5, deps: false])
refute_receive :foo
refute_receive :bar
end
end

1
test/test_helper.exs Normal file
View file

@ -0,0 +1 @@
ExUnit.start()