├── .gitattributes ├── .gitignore ├── AUTHORS ├── COMPATIBILITY ├── CONTRIBUTING ├── INSTALL ├── LICENSE ├── Makefile ├── README ├── VERSION ├── exmake.bat ├── lib ├── application.ex ├── cache.ex ├── cache_error.ex ├── config.ex ├── coordinator.ex ├── env.ex ├── env_error.ex ├── file.ex ├── helpers.ex ├── lib.ex ├── lib │ ├── csharp.ex │ ├── elixir.ex │ ├── erlang.ex │ ├── exmake.ex │ ├── fsharp.ex │ └── host.ex ├── libraries.ex ├── load_error.ex ├── loader.ex ├── logger.ex ├── runner.ex ├── script_error.ex ├── shell_error.ex ├── stale_error.ex ├── state.ex ├── supervisor.ex ├── throw_error.ex ├── timer.ex ├── usage_error.ex ├── utils.ex └── worker.ex ├── mix.exs └── test ├── load_test.exs └── test_helper.exs /.gitattributes: -------------------------------------------------------------------------------- 1 | *.bat text=crlf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build/* 2 | deps/* 3 | tmp/* 4 | 5 | *.dump 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lycus/exmake/841cfead39753b09acac96f8342079f50b098ab8/AUTHORS -------------------------------------------------------------------------------- /COMPATIBILITY: -------------------------------------------------------------------------------- 1 | == ExMake: Modern Scriptable Make == 2 | 3 | -- Compatibility -- 4 | 5 | ExMake requires Erlang 17.0+ and Elixir 0.15.0+. You will also need GNU Make 6 | and basic POSIX utilities to build. 7 | 8 | ExMake should generally run on any operating system and architecture that the 9 | Erlang virtual machine runs on. 10 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | == ExMake: Modern Scriptable Make == 2 | 3 | -- Contributing -- 4 | 5 | Contributed code is expected to follow the established code style. See 6 | the STYLE file for that. 7 | 8 | A test case for a change should almost always be included unless it is 9 | problematic to test. In such cases, please explain why in a commit 10 | message. You are also expected to run the test suite and verify that no 11 | regressions have been caused by your changes before submitting them. It 12 | might be a good idea to set up a Dialyzer PLT so you can run Dialyzer 13 | on your changes. 14 | 15 | Please avoid sending unrelated patches in the same review/patch series. 16 | 17 | All contributed code will go through the review process as described on 18 | the Lycus wiki: https://github.com/lycus/lycus.github.com/wiki/Review-Process 19 | 20 | Note that all code contributed to ExMake must be licensed under the terms 21 | found in the LICENSE file. Additionally, copyright must be assigned to 22 | the Lycus Foundation when submitting contributions. The project 23 | maintainers reserve the right to alter the license at any point in time, 24 | should this ever become necessary. 25 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | == ExMake: Modern Scriptable Make == 2 | 3 | -- Installation -- 4 | 5 | ExMake can be built, tested, and installed by invoking a number of Make 6 | targets. Though the build process uses Mix, you should avoid invoking it 7 | directly as doing so can interfere with the build. 8 | 9 | The available makefile targets are: 10 | 11 | * all: Runs ebin and escript. This is the default target. 12 | * ebin: Builds all ExMake modules. 13 | * escript: Builds the escriptized ExMake binary. Runs ebin if needed. 14 | * test: Runs the ExMake test suite. Runs ebin if needed. 15 | * dialyze: Runs Dialyzer on compiled ExMake modules. Runs ebin if needed. 16 | * clean: Cleans up the tree (removes compiled modules). 17 | * install: Install ExMake to PREFIX. Runs escript if needed. 18 | * uninstall: Remove ExMake from PREFIX. 19 | 20 | By default, the install and uninstall targets set PREFIX to /usr/local. The 21 | PREFIX variable can be set on the Make command line to install to a different 22 | location. 23 | 24 | A number of variables can be set in the environment to override the tools used 25 | by the makefile: 26 | 27 | * INSTALL The POSIX install utility. 28 | * MIX: The Mix build tool shipped with Elixir. 29 | * DIALYZER: The Dialyzer tool from the Erlang suite. 30 | 31 | You should not normally have to override these if the tools are present in 32 | your environment. 33 | 34 | To get an escript: 35 | 36 | $ make escript 37 | 38 | This places the escript named exmake in the _build/shared/lib/exmake/ebin 39 | directory. It is completely self-contained so it can be moved anywhere and will 40 | run fine provided the host system it is run on has Erlang installed. 41 | 42 | It's a good idea to run the test suite before using ExMake: 43 | 44 | $ make test 45 | 46 | If this passes, you should be good to go. 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | == ExMake: Modern Scriptable Make == 2 | 3 | -- License -- 4 | 5 | ExMake is available under the following license terms: 6 | 7 | The MIT License 8 | 9 | Copyright (c) 2014 The Lycus Foundation - http://lycus.org 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 | 29 | This notice holds for all files shipped with ExMake. Therefore, it will not 30 | be present in individual files. 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | INSTALL ?= install 2 | MIX ?= mix 3 | DIALYZER ?= dialyzer 4 | 5 | PREFIX ?= /usr/local 6 | 7 | override _build = _build/shared/lib/exmake/ebin 8 | 9 | .PHONY: all escript ebin clean test dialyze install uninstall 10 | 11 | all: $(_build)/exmake 12 | 13 | escript: $(_build)/exmake 14 | 15 | $(_build)/exmake: $(_build)/exmake.app 16 | @$(MIX) escript.build 17 | 18 | ebin: $(_build)/exmake.app 19 | 20 | $(_build)/exmake.app: $(wildcard lib/*.ex) $(wildcard lib/lib/*.ex) 21 | @$(MIX) compile 22 | 23 | clean: 24 | @$(MIX) clean 25 | @$(RM) -r tmp 26 | 27 | test: 28 | @$(MIX) test --trace 29 | 30 | dialyze: $(_build)/exmake.app 31 | $(DIALYZER) --no_check_plt -r $(_build) \ 32 | -Wunmatched_returns \ 33 | -Werror_handling 34 | 35 | install: $(_build)/exmake 36 | $(INSTALL) -m755 -d $(PREFIX) 37 | $(INSTALL) -m755 -d $(PREFIX)/bin 38 | $(INSTALL) -m755 $(_build)/exmake $(PREFIX)/bin 39 | $(INSTALL) -m755 -d $(PREFIX)/lib 40 | $(INSTALL) -m755 -d $(PREFIX)/lib/exmake 41 | 42 | uninstall: 43 | $(RM) $(PREFIX)/bin/exmake 44 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | == ExMake: Modern Scriptable Make == 2 | 3 | -- Introduction -- 4 | 5 | ExMake is a scriptable, dependency-based build system loosely based on the 6 | principles behind the POSIX Make utility program. It is intended to be a 7 | full replacement for Make, with much better scripting support and more 8 | familiar syntax based on the Elixir programming language. 9 | 10 | For further information, see: 11 | 12 | * LICENSE 13 | - Licensing and copyright information. 14 | * COMPATIBILITY 15 | - Supported Erlang and Elixir versions, build prerequisites, etc. 16 | * INSTALL 17 | - Instructions on building and installing ExMake. 18 | * AUTHORS 19 | - Names and contact information for ExMake developers. 20 | * CONTRIBUTING 21 | - Notes and guidelines for contributors. 22 | 23 | You can reach the ExMake community in several ways: 24 | 25 | * IRC channels 26 | - #lycus @ irc.oftc.net 27 | * Mailing lists 28 | - lycus-announce: http://groups.google.com/group/lycus-announce 29 | - lycus-discuss: http://groups.google.com/group/lycus-discuss 30 | - lycus-develop: http://groups.google.com/group/lycus-develop 31 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.5.0 2 | -------------------------------------------------------------------------------- /exmake.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | escript exmake %* 3 | -------------------------------------------------------------------------------- /lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Application do 2 | @moduledoc """ 3 | This is the main entry point of the ExMake application. 4 | """ 5 | 6 | use Application 7 | 8 | require ExMake.Helpers 9 | 10 | @doc false 11 | @spec main([String.t()]) :: no_return() 12 | def main(args) do 13 | {opts, rest, inv, tail} = parse(args) 14 | 15 | Enum.each(inv, fn({opt, val}) -> 16 | ExMake.Logger.error("Invalid value '#{val}' for option '--#{opt}'") 17 | end) 18 | 19 | if inv != [], do: System.halt(1) 20 | 21 | if opts[:version] do 22 | ExMake.Logger.info("ExMake - #{ExMake.Helpers.get_exmake_version()}") 23 | ExMake.Logger.info(ExMake.Helpers.get_exmake_license()) 24 | ExMake.Logger.info("Available under the terms of the MIT License") 25 | ExMake.Logger.info("") 26 | end 27 | 28 | if opts[:help] do 29 | ExMake.Logger.info("Usage: exmake [switches] [--] [targets] [--args args]") 30 | ExMake.Logger.info("") 31 | ExMake.Logger.info("The default target is 'all'.") 32 | ExMake.Logger.info("") 33 | ExMake.Logger.info("Switches:") 34 | ExMake.Logger.info("") 35 | ExMake.Logger.info(" --help, -h Print this help text.") 36 | ExMake.Logger.info(" --version, -v Print the program version.") 37 | ExMake.Logger.info(" --file, -f Use the specified script file.") 38 | ExMake.Logger.info(" --loud, -l Print targets and commands.") 39 | ExMake.Logger.info(" --question, -q Exit with 0 if everything is up to date; otherwise, 1.") 40 | ExMake.Logger.info(" --jobs, -j Run the specified number of concurrent jobs.") 41 | ExMake.Logger.info(" --time, -t Print timing information.") 42 | ExMake.Logger.info(" --clear, -c Clear the graph and environment cache.") 43 | ExMake.Logger.info(" --args, -a Pass all remaining arguments to libraries.") 44 | ExMake.Logger.info("") 45 | ExMake.Logger.info("If '--' is encountered anywhere before '--args', all remaining") 46 | ExMake.Logger.info("arguments are parsed as if they're target names, even if they") 47 | ExMake.Logger.info("contain dashes.") 48 | ExMake.Logger.info("") 49 | end 50 | 51 | if opts[:help] || opts[:version] do 52 | System.halt(2) 53 | end 54 | 55 | if Enum.empty?(rest), do: rest = ["all"] 56 | 57 | cfg = %ExMake.Config{targets: rest, 58 | options: opts, 59 | args: tail} 60 | 61 | ExMake.Coordinator.set_config(cfg) 62 | code = ExMake.Worker.work() 63 | 64 | System.halt(code) 65 | end 66 | 67 | @doc """ 68 | Parses the given command line arguments into an 69 | `{options, rest, invalid, tail}` tuple and returns it. 70 | 71 | `args` must be a list of binaries containing the command line arguments. 72 | """ 73 | @spec parse([String.t()]) :: {Keyword.t(), [String.t()], Keyword.t(), [String.t()]} 74 | def parse(args) do 75 | {args, t} = Enum.split_while(args, fn(x) -> x != "--args" end) 76 | 77 | # Strip off the --args element, if any. 78 | if t != [], do: t = tl(t) 79 | 80 | switches = [help: :boolean, 81 | version: :boolean, 82 | loud: :boolean, 83 | question: :boolean, 84 | jobs: :integer, 85 | time: :boolean, 86 | clear: :boolean] 87 | 88 | aliases = [h: :help, 89 | v: :version, 90 | f: :file, 91 | l: :loud, 92 | q: :question, 93 | j: :jobs, 94 | t: :time, 95 | c: :clear] 96 | 97 | tup = OptionParser.parse(args, [switches: switches, aliases: aliases]) 98 | i = if tuple_size(tup) >= 3, do: elem(tup, 2), else: [] 99 | 100 | {elem(tup, 0), elem(tup, 1), i, t} 101 | end 102 | 103 | @doc """ 104 | Starts the ExMake application. Returns `:ok` on success. 105 | """ 106 | @spec start() :: :ok 107 | def start() do 108 | :ok = Application.start(:exmake) 109 | end 110 | 111 | @doc """ 112 | Stops the ExMake application. Returns `:ok` on success. 113 | """ 114 | @spec stop() :: :ok 115 | def stop() do 116 | :ok = Application.stop(:exmake) 117 | end 118 | 119 | @doc false 120 | @spec start(:normal | {:takeover, node()} | {:failover, node()}, []) :: {:ok, pid(), nil} 121 | def start(_, []) do 122 | {:ok, pid} = ExMake.Supervisor.start_link() 123 | {:ok, pid, nil} 124 | end 125 | 126 | @doc false 127 | @spec stop(nil) :: :ok 128 | def stop(nil) do 129 | :ok 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Cache do 2 | @moduledoc """ 3 | Provides functionality to persist dependency graphs, environment 4 | tables, and compiled script modules to disk and load them back in. 5 | This is used to avoid a lot of the startup overhead that most 6 | traditional Make-style tools suffer from. 7 | """ 8 | 9 | @spec ensure_cache_dir(Path.t()) :: :ok 10 | defp ensure_cache_dir(dir) do 11 | case File.mkdir_p(dir) do 12 | {:error, r} -> 13 | ExMake.Logger.log_debug("Failed to create cache directory '#{dir}': #{inspect(r)}") 14 | raise(ExMake.CacheError, [message: "Could not create cache directory '#{dir}'"]) 15 | _ -> :ok 16 | end 17 | end 18 | 19 | @spec get_cache_files(Path.t()) :: [Path.t(), ...] 20 | defp get_cache_files(dir) do 21 | [Path.join(dir, "vertices.dag"), 22 | Path.join(dir, "edges.dag"), 23 | Path.join(dir, "neighbors.dag"), 24 | Path.join(dir, "fallbacks.trm"), 25 | Path.join(dir, "table.env"), 26 | Path.join(dir, "manifest.lst"), 27 | Path.join(dir, "config.env"), 28 | Path.join(dir, "config.arg")] 29 | end 30 | 31 | @spec get_manifest_list(Path.t()) :: [Path.t()] 32 | defp get_manifest_list(dir) do 33 | case File.read(Path.join(dir, "manifest.lst")) do 34 | {:ok, lines} -> 35 | String.split(lines, "\n") |> 36 | Enum.filter(fn(x) -> x != "" end) 37 | _ -> [] 38 | end 39 | end 40 | 41 | @spec get_beam_files(Path.t()) :: [Path.t()] 42 | defp get_beam_files(dir) do 43 | Path.wildcard(Path.join([dir, "**", "*.beam"])) 44 | end 45 | 46 | @doc """ 47 | Removes all cached files from the given directory 48 | if they exist. 49 | 50 | `dir` must be the path to the cache directory. 51 | """ 52 | @spec clear_cache(Path.t()) :: :ok 53 | def clear_cache(dir \\ ".exmake") do 54 | Enum.each(get_cache_files(dir) ++ get_beam_files(dir), fn(f) -> File.rm(f) end) 55 | end 56 | 57 | @doc """ 58 | Checks if the cache files are stale with regards to the 59 | script files in the manifest. 60 | 61 | `dir` must be the path to the cache directory. 62 | """ 63 | @spec cache_stale?(Path.t()) :: boolean() 64 | def cache_stale?(dir \\ ".exmake") do 65 | case get_manifest_list(dir) do 66 | [] -> true 67 | files -> 68 | caches = get_cache_files(dir) ++ get_beam_files(dir) 69 | 70 | script_time = Enum.map(files, fn(s) -> ExMake.Helpers.last_modified(s) end) |> Enum.max() 71 | cache_time = Enum.map(caches, fn(c) -> ExMake.Helpers.last_modified(c) end) |> Enum.min() 72 | 73 | script_time > cache_time 74 | end 75 | end 76 | 77 | @doc """ 78 | Saves the `:exmake_env` table to the environment 79 | table cache file in the given cache directory. Raises 80 | `ExMake.CacheError` if something went wrong. 81 | 82 | `table` must be an ETS table ID. `dir` must be the 83 | path to the cache directory. 84 | """ 85 | @spec save_env(Path.t()) :: :ok 86 | def save_env(dir \\ ".exmake") do 87 | ensure_cache_dir(dir) 88 | 89 | # Ensure that the table has been created. 90 | ExMake.Env.put("EXMAKE_STAMP", inspect(:erlang.now())) 91 | 92 | path = Path.join(dir, "table.env") 93 | 94 | case :ets.tab2file(:exmake_env, String.to_char_list(path)) do 95 | {:error, r} -> 96 | ExMake.Logger.log_debug("Failed to save environment cache file '#{path}': #{inspect(r)}") 97 | raise(ExMake.CacheError, [message: "Could not save environment cache file '#{path}'"]) 98 | _ -> :ok 99 | end 100 | end 101 | 102 | @doc """ 103 | Loads the environment table cache file from the 104 | given cache directory. It is expected that the 105 | table has the name `:exmake_env`. Raises 106 | `ExMake.CacheError` if something went wrong. 107 | 108 | `dir` must be the path to the cache directory. 109 | """ 110 | @spec load_env(Path.t()) :: :exmake_env 111 | def load_env(dir \\ ".exmake") do 112 | path = Path.join(dir, "table.env") 113 | 114 | # If the table exists, kill it, then reload from cache. 115 | try do 116 | :ets.delete(:exmake_env) 117 | rescue 118 | ArgumentError -> :ok 119 | end 120 | 121 | case :ets.file2tab(String.to_char_list(path)) do 122 | {:error, r} -> 123 | ExMake.Logger.log_debug("Failed to load environment cache file '#{path}': #{inspect(r)}") 124 | raise(ExMake.CacheError, [message: "Could not load environment cache file '#{path}'"]) 125 | {:ok, tab} -> tab 126 | end 127 | end 128 | 129 | defp trick_dialyzer(g) do 130 | # Dialyzer notices that we exploit knowledge 131 | # about digraph's representation below and 132 | # (appropriately) reports it. However, since 133 | # we have no other choice, try to silence 134 | # Dialyzer's warnings by using this function 135 | # as indirection. 136 | g 137 | end 138 | 139 | @doc """ 140 | Saves the given graph to the given cache directory. 141 | Raises `ExMake.CacheError` if something went wrong. 142 | 143 | `graph` must be a `:digraph` instance. `dir` must be 144 | the path to the cache directory. 145 | """ 146 | @spec save_graph(:digraph.graph(), Path.t()) :: :ok 147 | def save_graph(graph, dir \\ ".exmake") do 148 | ensure_cache_dir(dir) 149 | 150 | # We really shouldn't be exploiting knowledge about 151 | # the representation of digraph, but since the API 152 | # doesn't provide save/load functions, we can't do 153 | # it any other way. 154 | {_, vertices, edges, neighbors, _} = trick_dialyzer(graph) 155 | 156 | pairs = [{vertices, Path.join(dir, "vertices.dag")}, 157 | {edges, Path.join(dir, "edges.dag")}, 158 | {neighbors, Path.join(dir, "neighbors.dag")}] 159 | 160 | Enum.each(pairs, fn({tab, path}) -> 161 | case :ets.tab2file(tab, String.to_char_list(path)) do 162 | {:error, r} -> 163 | ExMake.Logger.log_debug("Failed to save graph cache file '#{path}': #{inspect(r)}") 164 | raise(ExMake.CacheError, [message: "Could not save graph cache file '#{path}'"]) 165 | _ -> :ok 166 | end 167 | end) 168 | end 169 | 170 | @doc """ 171 | Loads a graph from the given cache directory and 172 | returns it. Raises `ExMake.CacheError` if something 173 | went wrong. 174 | 175 | `dir` must be the path to the cache directory. 176 | """ 177 | @spec load_graph(Path.t()) :: :digraph.graph() 178 | def load_graph(dir \\ ".exmake") do 179 | files = [Path.join(dir, "vertices.dag"), 180 | Path.join(dir, "edges.dag"), 181 | Path.join(dir, "neighbors.dag")] 182 | 183 | # It's intentional that we don't create the 184 | # directory here. We should only create it if 185 | # needed in save_graph. 186 | list = Enum.map(files, fn(path) -> 187 | case :ets.file2tab(String.to_char_list(path)) do 188 | {:error, r} -> 189 | ExMake.Logger.log_debug("Failed to load graph cache file '#{path}': #{inspect(r)}") 190 | raise(ExMake.CacheError, [message: "Could not load graph cache file '#{path}'"]) 191 | {:ok, tab} -> tab 192 | end 193 | end) 194 | 195 | [vertices, edges, neighbors] = list 196 | 197 | trick_dialyzer({:digraph, vertices, edges, neighbors, false}) 198 | end 199 | 200 | @doc """ 201 | Saves the given list of fallback tasks to the given 202 | cache directory. Raises `ExMake.CacheError` if 203 | something went wrong. 204 | 205 | `list` must be a list of keyword lists. `dir` must 206 | be the path to the cache directory. 207 | """ 208 | @spec save_fallbacks([Keyword.t()], Path.t()) :: :ok 209 | def save_fallbacks(list, dir \\ ".exmake") do 210 | ensure_cache_dir(dir) 211 | 212 | path = Path.join(dir, "fallbacks.trm") 213 | data = list |> 214 | Enum.map(fn(x) -> IO.iodata_to_binary(:io_lib.format('~p.~n', [x])) end) |> 215 | Enum.join() 216 | 217 | case File.write(path, data) do 218 | {:error, r} -> 219 | ExMake.Logger.log_debug("Failed to save fallbacks file '#{path}': #{inspect(r)}") 220 | raise(ExMake.CacheError, [message: "Could not save fallbacks file '#{path}'"]) 221 | :ok -> :ok 222 | end 223 | end 224 | 225 | @doc """ 226 | Loads a fallback task list from the given cache 227 | directory and returns it. Raises `ExMake.CacheError` 228 | if something went wrong. 229 | 230 | `dir` must be the path to the cache directory. 231 | """ 232 | @spec load_fallbacks(Path.t()) :: [Keyword.t()] 233 | def load_fallbacks(dir \\ ".exmake") do 234 | path = Path.join(dir, "fallbacks.trm") 235 | 236 | case :file.consult(path) do 237 | {:error, r} -> 238 | r = if is_tuple(r) && tuple_size(r) == 3, do: :file.format_error(r), else: inspect(r) 239 | 240 | ExMake.Logger.log_debug("Failed to load fallbacks file '#{path}': #{r}") 241 | raise(ExMake.CacheError, [message: "Could not load fallbacks file '#{path}'"]) 242 | {:ok, list} -> list 243 | end 244 | end 245 | 246 | @doc """ 247 | Writes the given list of files to the manifest file 248 | in the given cache directory. Raises `ExMake.CacheError` 249 | if something went wrong. 250 | 251 | `files` must be a list of files that are to be 252 | considered part of the cache manifest. `dir` must 253 | be the path to the cache directory. 254 | """ 255 | @spec append_manifest([Path.t()], Path.t()) :: :ok 256 | def append_manifest(files, dir \\ ".exmake") do 257 | ensure_cache_dir(dir) 258 | 259 | path = Path.join(dir, "manifest.lst") 260 | data = Enum.join(files, "\n") <> "\n" 261 | 262 | case File.write(path, data, [:append]) do 263 | {:error, r} -> 264 | ExMake.Logger.log_debug("Failed to save manifest file '#{path}': #{inspect(r)}") 265 | raise(ExMake.CacheError, [message: "Could not save manifest file '#{path}'"]) 266 | :ok -> :ok 267 | end 268 | end 269 | 270 | @doc """ 271 | Saves the given list of modules to the given 272 | cache directory. Raises `ExMake.CacheError` if 273 | something went wrong. 274 | 275 | `mods` must be a list of `{mod, bin}` pairs, where 276 | `mod` is the module name and `bin` is the bytecode. 277 | `dir` must be the path to the cache directory. 278 | """ 279 | @spec save_mods([{module(), binary()}], Path.t()) :: :ok 280 | def save_mods(mods, dir \\ ".exmake") do 281 | ensure_cache_dir(dir) 282 | 283 | Enum.each(mods, fn({mod, bin}) -> 284 | path = Path.join(dir, Atom.to_string(mod) <> ".beam") 285 | 286 | case File.write(path, bin) do 287 | {:error, r} -> 288 | ExMake.Logger.log_debug("Failed to save cached module '#{path}': #{inspect(r)}") 289 | raise(ExMake.CacheError, [message: "Could not save cached module '#{path}'"]) 290 | :ok -> :ok 291 | end 292 | end) 293 | end 294 | 295 | @doc """ 296 | Loads all modules in the given cache directory. 297 | Raises `ExMake.CacheError` if something went wrong. 298 | 299 | `dir` must be the path to the cache directory. 300 | """ 301 | @spec load_mods(Path.t()) :: :ok 302 | def load_mods(dir \\ ".exmake") do 303 | Enum.each(get_beam_files(dir), fn(beam) -> 304 | path = Path.rootname(beam) 305 | 306 | case :code.load_abs(String.to_char_list(path)) do 307 | {:error, r} -> 308 | ExMake.Logger.log_debug("Failed to load cached module '#{beam}': #{inspect(r)}") 309 | raise(ExMake.CacheError, [message: "Could not load cached module '#{beam}'"]) 310 | _ -> :ok 311 | end 312 | end) 313 | end 314 | 315 | @doc """ 316 | Saves the given configuration arguments and 317 | environment variables to the given cache directory. 318 | Raises `ExMake.CacheError` if something went wrong. 319 | 320 | `args` must be a list of strings. `vars` must be a 321 | list of key/value pairs mapping environment variable 322 | names to values. `dir` must be the path to the cache 323 | directory. 324 | """ 325 | @spec save_config([String.t()], [{String.t(), String.t()}], Path.t()) :: :ok 326 | def save_config(args, vars, dir \\ ".exmake") do 327 | ensure_cache_dir(dir) 328 | 329 | path_arg = Path.join(dir, "config.arg") 330 | path_env = Path.join(dir, "config.env") 331 | 332 | args = args |> 333 | Enum.map(fn(x) -> IO.iodata_to_binary(:io_lib.format('~p.~n', [x])) end) |> 334 | Enum.join() 335 | 336 | vars = vars |> 337 | Enum.map(fn(x) -> IO.iodata_to_binary(:io_lib.format('~p.~n', [x])) end) |> 338 | Enum.join() 339 | 340 | case File.write(path_arg, args) do 341 | {:error, r} -> 342 | ExMake.Logger.log_debug("Failed to save configuration arguments file '#{path_arg}': #{inspect(r)}") 343 | raise(ExMake.CacheError, [message: "Could not save configuration arguments file '#{path_arg}'"]) 344 | :ok -> :ok 345 | end 346 | 347 | case File.write(path_env, vars) do 348 | {:error, r} -> 349 | ExMake.Logger.log_debug("Failed to save configuration variables file '#{path_env}': #{inspect(r)}") 350 | raise(ExMake.CacheError, [message: "Could not save configuration variables file '#{path_env}'"]) 351 | :ok -> :ok 352 | end 353 | end 354 | 355 | @doc """ 356 | Loads the configuration arguments and environment 357 | variables from the given cache directory. Raises 358 | `ExMake.CacheError` if something went wrong. 359 | 360 | `dir` must be the path to the cache directory. 361 | """ 362 | @spec load_config(Path.t()) :: {[String.t()], [{String.t(), String.t()}]} 363 | def load_config(dir \\ ".exmake") do 364 | path_arg = Path.join(dir, "config.arg") 365 | path_env = Path.join(dir, "config.env") 366 | 367 | args = case :file.consult(path_arg) do 368 | {:error, r} -> 369 | r = if is_tuple(r) && tuple_size(r) == 3, do: :file.format_error(r), else: inspect(r) 370 | 371 | ExMake.Logger.log_debug("Failed to load configuration arguments file '#{path_arg}': #{r}") 372 | raise(ExMake.CacheError, [message: "Could not load configuration arguments file '#{path_arg}'"]) 373 | {:ok, args} -> args 374 | end 375 | 376 | vars = case :file.consult(path_env) do 377 | {:error, r} -> 378 | r = if is_tuple(r) && tuple_size(r) == 3, do: :file.format_error(r), else: inspect(r) 379 | 380 | ExMake.Logger.log_debug("Failed to load configuration variables file '#{path_env}': #{r}") 381 | raise(ExMake.CacheError, [message: "Could not load configuration variables file '#{path_env}'"]) 382 | {:ok, vars} -> vars 383 | end 384 | 385 | {args, vars} 386 | end 387 | 388 | @doc """ 389 | Checks whether configuration arguments and 390 | environment variables are saved in the given 391 | cache directory. 392 | 393 | `dir` must be the path to the cache directory. 394 | """ 395 | @spec config_cached?(Path.t()) :: boolean() 396 | def config_cached?(dir \\ ".exmake") do 397 | File.exists?(Path.join(dir, "config.arg")) && File.exists?(Path.join(dir, "config.env")) 398 | end 399 | end 400 | -------------------------------------------------------------------------------- /lib/cache_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.CacheError do 2 | @moduledoc """ 3 | The exception raised if something went wrong when saving 4 | or loading the cache. 5 | """ 6 | 7 | defexception [:message] 8 | 9 | @type t() :: %ExMake.CacheError{message: String.t()} 10 | end 11 | -------------------------------------------------------------------------------- /lib/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Config do 2 | @moduledoc """ 3 | Represents the configuration for an invocation of ExMake. 4 | 5 | `targets` is a list of targets to build. `options` is a keyword list 6 | of global options. `args` is the list of tail arguments given with 7 | `--args`. 8 | 9 | `options` can contain: 10 | 11 | * `help`: Boolean value indicating whether to print the help message. 12 | * `version`: Boolean value indicating whether to print the version. 13 | * `file`: String value indicating which script file to use. 14 | * `loud`: Boolean value indicating whether to print targets and commands. 15 | * `question`: Boolean value indicating whether to just perform an up-to-date check. 16 | * `jobs`: Integer value indicating how many concurrent jobs to run. 17 | * `time`: Boolean value indicating whether to print timing information. 18 | * `clear`: Boolean value indicating whther to clear the graph and environment cache. 19 | """ 20 | 21 | defstruct targets: [], 22 | options: [], 23 | args: [] 24 | 25 | @type t() :: %ExMake.Config{targets: [String.t(), ...], 26 | options: Keyword.t(), 27 | args: [String.t()]} 28 | end 29 | -------------------------------------------------------------------------------- /lib/coordinator.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Coordinator do 2 | @moduledoc """ 3 | Encapsulates a coordinator process that stores configuration information 4 | and kicks off job processes for recipes. 5 | """ 6 | 7 | use GenServer 8 | 9 | @typep request() :: {:set_cfg, ExMake.Config.t()} | 10 | {:get_cfg} | 11 | {:enqueue, Keyword.t(), term(), pid()} | 12 | {:done, Keyword.t(), pid(), :ok | tuple()} | 13 | {:apply_timer, ((ExMake.Timer.session()) -> ExMake.Timer.session())} | 14 | {:get_libs} | 15 | {:add_lib, module()} | 16 | {:del_lib, module()} | 17 | {:clear_libs} 18 | 19 | @typep reply() :: {:set_cfg} | 20 | {:get_cfg, ExMake.Config.t()} | 21 | {:enqueue} | 22 | {:done} | 23 | {:apply_timer} | 24 | {:get_libs, [module()]} | 25 | {:add_lib} | 26 | {:del_lib} | 27 | {:clear_libs} 28 | 29 | @doc false 30 | @spec start_link() :: {:ok, pid()} 31 | def start_link() do 32 | {:ok, _} = GenServer.start_link(__MODULE__, %ExMake.State{}, [name: :exmake_coordinator]) 33 | end 34 | 35 | @doc """ 36 | Sets the configuration values for the ExMake application. 37 | 38 | `cfg` must be a valid `ExMake.Config` instance. `timeout` must be 39 | `:infinity` or a millisecond value specifying how much time to wait for 40 | the operation to complete. 41 | """ 42 | @spec set_config(ExMake.Config.t(), timeout()) :: :ok 43 | def set_config(cfg, timeout \\ :infinity) do 44 | GenServer.call(:exmake_coordinator, {:set_cfg, cfg}, timeout) 45 | :ok 46 | end 47 | 48 | @doc """ 49 | Gets the configuration values used by the ExMake application. Returns 50 | `nil` if no values have been set yet. 51 | 52 | `timeout` must be `:infinity` or a millisecond value specifying how much 53 | time to wait for the operation to complete. 54 | """ 55 | @spec get_config(timeout()) :: ExMake.Config.t() | nil 56 | def get_config(timeout \\ :infinity) do 57 | {:get_cfg, cfg} = GenServer.call(:exmake_coordinator, {:get_cfg}, timeout) 58 | cfg 59 | end 60 | 61 | @doc """ 62 | Enqueues a job. 63 | 64 | Jobs are executed as soon as there is a free job slot available. Once the 65 | job has executed, the coordinator will send a message to `owner`: 66 | 67 | * `{:exmake_done, rule, data, :ok}` if the job executed successfully. 68 | * `{:exmake_done, rule, data, {:throw, value}}` if a value was thrown. 69 | * `{:exmake_done, rule, data, {:raise, exception}}` if an exception was raised. 70 | 71 | Here, `rule` is the rule originally passed to this function. `data` is the 72 | arbitrary term passed as the second argument to this function. 73 | 74 | `rule` must be the keyword list describing the rule. `data` can be any 75 | term to attach to the job. `owner` must be a PID pointing to the process 76 | that should be notified once the job is done. `timeout` must be `:infinity` 77 | or a millisecond value specifying how much time to wait for the operation 78 | to complete. 79 | """ 80 | @spec enqueue(Keyword.t(), term(), pid(), timeout()) :: :ok 81 | def enqueue(rule, data \\ nil, owner \\ self(), timeout \\ :infinity) do 82 | GenServer.call(:exmake_coordinator, {:enqueue, rule, data, owner}, timeout) 83 | :ok 84 | end 85 | 86 | @doc """ 87 | Applies a given function on the `ExMake.Timer` session object. The function 88 | receives the session object as its only parameter and must return a new session 89 | object. 90 | 91 | `fun` must be the function to apply on the session object. `timeout` must be 92 | `:infinity` or a millisecond value specifying how much time to wait for the 93 | operation to complete. 94 | """ 95 | @spec apply_timer_fn(((ExMake.Timer.session()) -> ExMake.Timer.session()), timeout()) :: :ok 96 | def apply_timer_fn(fun, timeout \\ :infinity) do 97 | GenServer.call(:exmake_coordinator, {:apply_timer, fun}, timeout) 98 | :ok 99 | end 100 | 101 | @doc false 102 | @spec get_libraries(timeout()) :: [module()] 103 | def get_libraries(timeout \\ :infinity) do 104 | {:get_libs, libs} = GenServer.call(:exmake_coordinator, {:get_libs}, timeout) 105 | libs 106 | end 107 | 108 | @doc false 109 | @spec add_library(module(), timeout()) :: :ok 110 | def add_library(module, timeout \\ :infinity) do 111 | GenServer.call(:exmake_coordinator, {:add_lib, module}, timeout) 112 | :ok 113 | end 114 | 115 | @doc false 116 | @spec remove_library(module(), timeout()) :: :ok 117 | def remove_library(module, timeout \\ :infinity) do 118 | GenServer.call(:exmake_coordinator, {:del_lib, module}, timeout) 119 | :ok 120 | end 121 | 122 | @doc false 123 | @spec clear_libraries(timeout()) :: :ok 124 | def clear_libraries(timeout \\ :infinity) do 125 | GenServer.call(:exmake_coordinator, {:clear_libs}, timeout) 126 | :ok 127 | end 128 | 129 | @doc false 130 | @spec handle_call(request(), {pid(), term()}, ExMake.State.t()) :: {:reply, reply(), ExMake.State.t()} 131 | def handle_call(msg, {sender, _}, state) do 132 | reply = case msg do 133 | {:set_cfg, cfg} -> 134 | state = %ExMake.State{state | :config => cfg, 135 | :max_jobs => cfg.options[:jobs] || 1} 136 | {:set_cfg} 137 | {:get_cfg} -> 138 | {:get_cfg, state.config} 139 | {:enqueue, rule, data, owner} -> 140 | if Set.size(state.jobs) < state.max_jobs do 141 | # If we have a free job slot, just run it right away. 142 | job = ExMake.Runner.start(rule, data, owner) 143 | state = %ExMake.State{state | :jobs => Set.put(state.jobs, {rule, data, owner, job})} 144 | else 145 | # No free slot, so schedule the job for later. We'll run it 146 | # once we get a :done message from some other job. 147 | state = %ExMake.State{state | :queue => :queue.in({rule, data, owner}, state.queue)} 148 | end 149 | 150 | {:enqueue} 151 | {:done, rule, data, owner, result} -> 152 | state = %ExMake.State{state | :jobs => Set.delete(state.jobs, {rule, data, owner, sender})} 153 | 154 | send(owner, {:exmake_done, rule, data, result}) 155 | 156 | # We have a free job slot, so run a job if one is enqueued. 157 | case :queue.out(state.queue) do 158 | {{:value, {rule, data, owner}}, queue} -> 159 | job = ExMake.Runner.start(rule, data, owner) 160 | state = %ExMake.State{state | :queue => queue, 161 | :jobs => Set.put(state.jobs, {rule, data, owner, job})} 162 | {:empty, _} -> :ok 163 | end 164 | 165 | {:done} 166 | {:apply_timer, fun} -> 167 | state = %ExMake.State{state | :timing => fun.(state.timing)} 168 | {:apply_timer} 169 | {:get_libs} -> 170 | {:get_libs, Set.to_list(state.libraries)} 171 | {:add_lib, lib} -> 172 | state = %ExMake.State{state | :libraries => Set.put(state.libraries, lib)} 173 | {:add_lib} 174 | {:del_lib, lib} -> 175 | state = %ExMake.State{state | :libraries => Set.delete(state.libraries, lib)} 176 | {:del_lib} 177 | {:clear_libs} -> 178 | state = %ExMake.State{state | :libraries => HashSet.new()} 179 | {:clear_libs} 180 | end 181 | 182 | {:reply, reply, state} 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/env.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Env do 2 | @moduledoc """ 3 | Provides functions to manipulate the environment table. 4 | 5 | This is a separate table from the system environment in order to 6 | avoid potential conflicts and unintended influence from the 7 | external environment. 8 | 9 | In general, avoid changing the environment table in recipes as 10 | this can interfere with other, unrelated recipes. 11 | 12 | All keys beginning with `EXMAKE_` are reserved by ExMake. 13 | """ 14 | 15 | @spec ensure_ets_table() :: :exmake_env 16 | defp ensure_ets_table() do 17 | tab = :exmake_env 18 | 19 | _ = try do 20 | :ets.new(tab, [:public, :named_table]) 21 | rescue 22 | ArgumentError -> :ok 23 | end 24 | 25 | tab 26 | end 27 | 28 | @doc """ 29 | Sets the given key to the given string value. 30 | 31 | `name` and `value` must both be strings. 32 | """ 33 | @spec put(String.t(), String.t()) :: :ok 34 | def put(name, value) do 35 | tab = ensure_ets_table() 36 | 37 | :ets.insert(tab, {name, value}) 38 | 39 | :ok 40 | end 41 | 42 | @doc """ 43 | Gets the value for a given key. 44 | 45 | `name` must be a string. 46 | """ 47 | @spec get(String.t()) :: String.t() | nil 48 | def get(name) do 49 | tab = ensure_ets_table() 50 | 51 | case :ets.lookup(tab, name) do 52 | [{_, value}] -> value 53 | [] -> nil 54 | end 55 | end 56 | 57 | @doc """ 58 | Deletes the entry for the given key. 59 | 60 | `name` must be a string. 61 | """ 62 | @spec delete(String.t()) :: :ok 63 | def delete(name) do 64 | tab = ensure_ets_table() 65 | 66 | :ets.delete(tab, name) 67 | 68 | :ok 69 | end 70 | 71 | @doc """ 72 | Sets the given key to the empty list. 73 | 74 | `name` must be a string. 75 | """ 76 | @spec list_put(String.t()) :: :ok 77 | def list_put(name) do 78 | tab = ensure_ets_table() 79 | 80 | :ets.insert(tab, {name, []}) 81 | 82 | :ok 83 | end 84 | 85 | @doc """ 86 | Appends a value to a list identified by the given key. Raises an 87 | `ExMake.EnvError` if the value for the given key is not a list. 88 | 89 | `name` and `value` must both be strings. 90 | """ 91 | @spec list_append(String.t(), String.t()) :: :ok 92 | def list_append(name, value) do 93 | tab = ensure_ets_table() 94 | 95 | list = case :ets.lookup(tab, name) do 96 | [{_, list}] -> list 97 | [] -> [] 98 | end 99 | 100 | if !is_list(list) do 101 | raise(ExMake.EnvError, 102 | [message: "Value for key '#{name}' is not a list - cannot append element", 103 | name: name]) 104 | end 105 | 106 | :ets.insert(tab, {name, list ++ [value]}) 107 | 108 | :ok 109 | end 110 | 111 | @doc """ 112 | Prepends a value to a list identified by the given key. Raises an 113 | `ExMake.EnvError` if the value for the given key is not a list. 114 | 115 | `name` and `value` must both be strings. 116 | """ 117 | @spec list_prepend(String.t(), String.t()) :: :ok 118 | def list_prepend(name, value) do 119 | tab = ensure_ets_table() 120 | 121 | list = case :ets.lookup(tab, name) do 122 | [{_, list}] -> list 123 | [] -> [] 124 | end 125 | 126 | if !is_list(list) do 127 | raise(ExMake.EnvError, 128 | [message: "Value for key '#{name}' is not a list - cannot prepend element", 129 | name: name]) 130 | end 131 | 132 | :ets.insert(tab, {name, [value | list]}) 133 | 134 | :ok 135 | end 136 | 137 | @doc """ 138 | Gets a list identified by a given key. Raises an `ExMake.EnvError` 139 | if the value for the given key is not a list. 140 | 141 | `name` must be a string. 142 | """ 143 | @spec list_get(String.t()) :: [String.t()] 144 | def list_get(name) do 145 | tab = ensure_ets_table() 146 | 147 | case :ets.lookup(tab, name) do 148 | [{_, list}] -> 149 | if !is_list(list) do 150 | raise(ExMake.EnvError, 151 | [message: "Value for key '#{name}' is not a list - cannot retrieve", 152 | name: name]) 153 | end 154 | 155 | list 156 | [] -> [] 157 | end 158 | end 159 | 160 | @doc """ 161 | Deletes a value from a list identified by the given key. Raises an 162 | `ExMake.EnvError` if the value for the given key is not a list. 163 | 164 | `name` and `value` must both be strings. 165 | """ 166 | @spec list_delete(String.t(), String.t() | Regex.t()) :: :ok 167 | def list_delete(name, value) do 168 | tab = ensure_ets_table() 169 | 170 | list = case :ets.lookup(tab, name) do 171 | [{_, list}] -> list 172 | [] -> [] 173 | end 174 | 175 | if !is_list(list) do 176 | raise(ExMake.EnvError, 177 | [message: "Value for key '#{name}' is not a list - cannot delete element", 178 | name: name]) 179 | end 180 | 181 | list = Enum.reject(list, fn(e) -> if is_binary(value), do: e == value, else: e =~ value end) 182 | 183 | :ets.insert(tab, {name, list}) 184 | 185 | :ok 186 | end 187 | 188 | @doc """ 189 | Performs an `Enum.reduce/3` over the environment table. Note that the 190 | value component of the key/value pairs iterated over with this function 191 | can be either a string or a list of strings. 192 | 193 | `acc` can be any term. `fun` must be a function taking the key/value pair 194 | as its first argument and the accumulator as its second argument. It must 195 | return the new accumulator. 196 | """ 197 | @spec reduce(term(), (({String.t(), String.t() | [String.t()]}, term()) -> term())) :: term() 198 | def reduce(acc, fun) do 199 | tab = ensure_ets_table() 200 | 201 | :ets.foldl(fun, acc, tab) 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/env_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.EnvError do 2 | @moduledoc """ 3 | The exception raised if something went wrong when accessing 4 | a particular environment key. 5 | 6 | `name` is the name of the key that caused the error. 7 | """ 8 | 9 | defexception [:message, 10 | :name] 11 | 12 | @type t() :: %ExMake.EnvError{message: String.t(), 13 | name: String.t()} 14 | end 15 | -------------------------------------------------------------------------------- /lib/file.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.File do 2 | @moduledoc """ 3 | Provides various useful functions and macros for constructing script files. 4 | 5 | This module should be `use`d like so: 6 | 7 | defmodule MyProject.Exmakefile do 8 | use ExMake.File 9 | 10 | # ... 11 | end 12 | 13 | Using this module implicitly imports the following modules: 14 | 15 | * `File` 16 | * `IO` 17 | * `Path` 18 | * `System` 19 | * `ExMake.Env` 20 | * `ExMake.File` 21 | * `ExMake.Utils` 22 | 23 | A general note pertaining to the macros in this module: Avoid generating 24 | source and target file lists based on external factors such as system 25 | environment variables. The dependency graph that is generated based on 26 | a script file is cached, so if external factors influence the graph's 27 | layout, ExMake won't pick up the changes. It is, however, acceptable to 28 | base the layout on files declared with `manifest/1`. 29 | """ 30 | 31 | @doc false 32 | defmacro __using__(_) do 33 | quote do 34 | import File 35 | import IO 36 | import Path 37 | import System 38 | import ExMake.Env 39 | import ExMake.File 40 | import ExMake.Utils 41 | 42 | @before_compile unquote(__MODULE__) 43 | 44 | Module.register_attribute(__MODULE__, :exmake_subdirectories, [accumulate: true, persist: true]) 45 | Module.register_attribute(__MODULE__, :exmake_rules, [accumulate: true, persist: true]) 46 | Module.register_attribute(__MODULE__, :exmake_tasks, [accumulate: true, persist: true]) 47 | Module.register_attribute(__MODULE__, :exmake_fallbacks, [accumulate: true, persist: true]) 48 | Module.register_attribute(__MODULE__, :exmake_manifest, [accumulate: true, persist: true]) 49 | end 50 | end 51 | 52 | @doc false 53 | defmacro __before_compile__(_) do 54 | quote do 55 | def __exmake__(:subdirectories), do: Enum.reverse(@exmake_subdirectories) 56 | def __exmake__(:rules), do: Enum.reverse(@exmake_rules) 57 | def __exmake__(:tasks), do: Enum.reverse(@exmake_tasks) 58 | def __exmake__(:fallbacks), do: Enum.reverse(@exmake_fallbacks) 59 | def __exmake__(:manifest), do: Enum.reverse(@exmake_manifest) 60 | end 61 | end 62 | 63 | @doc """ 64 | Similar to `load_lib/2`, but only `require`s the library instead of `import`ing it. 65 | """ 66 | defmacro load_lib_qual(lib, args \\ []) do 67 | # Keep in sync with ExMake.Lib.require_lib/1. 68 | lib_mod = Module.concat(ExMake.Lib, Macro.expand_once(lib, __ENV__)) 69 | 70 | quote do 71 | {:module, _} = Code.ensure_loaded(unquote(lib_mod)) 72 | 73 | if !Enum.member?(ExMake.Coordinator.get_libraries(), unquote(lib_mod)) do 74 | case unquote(lib_mod).__exmake__(:on_load) do 75 | {m, f} -> 76 | cargs = ExMake.Coordinator.get_config().args() 77 | 78 | apply(m, f, [unquote(args), cargs]) 79 | _ -> :ok 80 | end 81 | 82 | ExMake.Coordinator.add_library(unquote(lib_mod)) 83 | 84 | require unquote(lib_mod) 85 | end 86 | end 87 | end 88 | 89 | @doc """ 90 | Loads a library. A list of arguments can be given if the library needs it. The library 91 | is `import`ed after being loaded. 92 | 93 | `lib` must be the library name, e.g. `C` to load the C compiler module. Note that it 94 | must be a compile-time value. `args` must be a list of arbitrary terms. 95 | """ 96 | defmacro load_lib(lib, args \\ []) do 97 | lib_mod = Module.concat(ExMake.Lib, Macro.expand_once(lib, __ENV__)) 98 | 99 | quote do 100 | load_lib_qual(unquote(lib), unquote(args)) 101 | 102 | import unquote(lib_mod) 103 | end 104 | end 105 | 106 | @doc """ 107 | Declares a file that is part of the cached manifest. 108 | 109 | Example: 110 | 111 | # ExMake cannot see that the script file 112 | # depends on the my_file.exs file. 113 | Code.require_file "my_file.exs", __DIR__ 114 | 115 | defmodule MyProject.Exmakefile do 116 | use ExMake.File 117 | 118 | # Declare that if my_file.exs changes 119 | # the cache should be invalidated. 120 | manifest "my_file.exs" 121 | 122 | # ... 123 | end 124 | 125 | This is useful to tell ExMake about non-script files that should be considered 126 | part of the manifest used to determine whether the cache should be invalidated. 127 | Such non-script files are typically not referenced directly (via `recurse/2`) 128 | and so ExMake is not aware that if they change, the behavior of the entire 129 | build can change. When those files are declared with this macro, ExMake will 130 | correctly invalidate the cache if they change, forcing a new evaluation of the 131 | script file(s). 132 | """ 133 | defmacro manifest(file) do 134 | quote do: @exmake_manifest unquote(file) 135 | end 136 | 137 | @doc ~S""" 138 | Specifies a directory to recurse into. 139 | 140 | Example: 141 | 142 | defmodule MyProject.Exmakefile do 143 | use ExMake.File 144 | 145 | recurse "utils" 146 | 147 | rule ["foo.o"], 148 | ["foo.c"], 149 | [src], [tgt] do 150 | shell("${CC} -c #{src} -o #{tgt}") 151 | end 152 | 153 | rule ["my_exe"], 154 | ["foo.o", "utils/bar.o"], 155 | srcs, [tgt] do 156 | shell("${CC} #{Enum.join(srcs, " ")} -o #{tgt}") 157 | end 158 | end 159 | 160 | And in `utils`: 161 | 162 | defmodule MyProject.Utils.Exmakefile do 163 | use ExMake.File 164 | 165 | rule ["bar.o"], 166 | ["bar.c"], 167 | [src], [tgt] do 168 | shell("${CC} -c #{src} -o #{tgt}") 169 | end 170 | end 171 | 172 | This can be used to split script files into multiple directories so that they are 173 | easier to maintain. It also allows invoking ExMake inside a subdirectory without 174 | having to build everything from the top-level script file. 175 | 176 | Unlike in other Make-style tools, recursion in ExMake does not mean invoking ExMake 177 | itself within a subdirectory. Rather, when ExMake is invoked, it collects the full 178 | list of directories to recurse into and includes all rules in those directories 179 | into the canonical dependency graph. 180 | """ 181 | defmacro recurse(dir, file \\ "Exmakefile") do 182 | quote do: @exmake_subdirectories {unquote(dir), unquote(file)} 183 | end 184 | 185 | @doc ~S""" 186 | Defines a rule. 187 | 188 | Example: 189 | 190 | defmodule MyProject.Exmakefile do 191 | use ExMake.File 192 | 193 | rule ["foo.o"], 194 | ["foo.c"], 195 | [src], [tgt] do 196 | shell("${CC} -c #{src} -o #{tgt}") 197 | end 198 | end 199 | 200 | The first argument to the macro is the list of files that the rule needs in order 201 | to produce output files. The second argument is the list of files that the rule 202 | produces when executed. Following those lists are three argument patterns and 203 | finally the recipe `do` block that performs actual work. The argument patterns work 204 | just like in any other Elixir function definition. The first argument is the list of 205 | source files, the second is the list of output files, and the third is the directory 206 | of the script file that the rule is defined in. 207 | 208 | The list of source files can be both source code files and intermediary files that 209 | are produced by other rules. In the latter case, ExMake will invoke the necessary 210 | rules to produce those files. 211 | """ 212 | defmacro rule(targets, sources, srcs_arg \\ (quote do: _), tgts_arg \\ (quote do: _), 213 | dir_arg \\ (quote do: _), [do: block]) do 214 | srcs_arg = Macro.escape(srcs_arg) 215 | tgts_arg = Macro.escape(tgts_arg) 216 | dir_arg = Macro.escape(dir_arg) 217 | block = Macro.escape(block) 218 | 219 | quote bind_quoted: binding do 220 | line = __ENV__.line() 221 | fn_name = :"rule_#{length(@exmake_rules) + 1}_line_#{line}" 222 | 223 | @doc false 224 | def unquote(fn_name)(unquote(srcs_arg), 225 | unquote(tgts_arg), 226 | unquote(dir_arg)), do: unquote(block) 227 | 228 | @exmake_rules Keyword.put([targets: targets, sources: sources], :recipe, {__MODULE__, fn_name, line}) 229 | end 230 | end 231 | 232 | @doc ~S""" 233 | Defines a task. 234 | 235 | Example: 236 | 237 | defmodule MyProject.Exmakefile do 238 | use ExMake.File 239 | 240 | task "all", 241 | ["foo.o"] do 242 | end 243 | 244 | task "clean", 245 | [], 246 | _, _, dir do 247 | Enum.each(Path.wildcard(Path.join(dir, "*.o")), fn(f) -> File.rm!(f) end) 248 | end 249 | 250 | rule ["foo.o"], 251 | ["foo.c"], 252 | [src], [tgt] do 253 | shell("${CC} -c #{src} -o #{tgt}") 254 | end 255 | end 256 | 257 | A task is similar to a regular rule, but with the significant difference that it 258 | has no target files. That is, it acts more as a command or shortcut. In the 259 | example above, the `all` task depends on `foo.o` but performs no work itself. This 260 | means that whenever the `all` task is invoked, it'll make sure `foo.o` is up to 261 | date. The `clean` task, on the other hand, has an empty `sources` list meaning 262 | that it will always execute when invoked (since there's no way to know if it's up 263 | to date). 264 | 265 | The first argument to the macro is the name of the task. The second argument is 266 | the list of files that the task depends on. Following those lists are three argument 267 | patterns and finally the recipe `do` block that performs actual work. The argument 268 | patterns work just like in any other Elixir function definition. The first argument 269 | is the name of the task, the second is the list of source files, and the third is 270 | the directory of the script file that the task is defined in. 271 | 272 | The list of source files can be both source code files and intermediary files that 273 | are produced by other rules. In the latter case, ExMake will invoke the necessary 274 | rules to produce those files. 275 | """ 276 | defmacro task(name, sources, name_arg \\ (quote do: _), srcs_arg \\ (quote do: _), 277 | dir_arg \\ (quote do: _), [do: block]) do 278 | name_arg = Macro.escape(name_arg) 279 | srcs_arg = Macro.escape(srcs_arg) 280 | dir_arg = Macro.escape(dir_arg) 281 | block = Macro.escape(block) 282 | 283 | quote bind_quoted: binding do 284 | line = __ENV__.line() 285 | fn_name = :"task_#{length(@exmake_tasks) + 1}_line_#{line}" 286 | 287 | @doc false 288 | def unquote(fn_name)(unquote(name_arg), 289 | unquote(srcs_arg), 290 | unquote(dir_arg)), do: unquote(block) 291 | 292 | @exmake_tasks Keyword.put([name: name, sources: sources], :recipe, {__MODULE__, fn_name, line}) 293 | end 294 | end 295 | 296 | @doc ~S""" 297 | Defines a fallback task. 298 | 299 | Example: 300 | 301 | defmodule MyProject.Exmakefile do 302 | use ExMake.File 303 | 304 | fallback do 305 | IO.puts "The primary tasks of this build script are:" 306 | IO.puts "all - build everything; default if no other task is mentioned" 307 | IO.puts "clean - clean up intermediate and output files" 308 | end 309 | 310 | task "all", 311 | ["foo.o"] do 312 | end 313 | 314 | task "clean", 315 | [], 316 | _, _, dir do 317 | Enum.each(Path.wildcard(Path.join(dir, "*.o")), fn(f) -> File.rm!(f) end) 318 | end 319 | 320 | rule ["foo.o"], 321 | ["foo.c"], 322 | [src], [tgt] do 323 | shell("${CC} -c #{src} -o #{tgt}") 324 | end 325 | end 326 | 327 | This defines a fallback task that is executed if a target mentioned on the command 328 | line is unknown to ExMake. There can be multiple fallback tasks, in which case they 329 | are executed in the order they were defined. They are always executed serially. Only 330 | the fallback tasks in the entry point build script are executed. 331 | 332 | The first argument to the macro is an argument pattern. The last argument is the 333 | `do` block that performs actual work. The argument pattern works just like in any 334 | other Elixir function definition. The argument is the directory of the script file 335 | that the fallback task is defined in. 336 | 337 | In the case where some targets mentioned on the command line *do* exist, even one 338 | target that doesn't exist will make ExMake discard all mentioned targets and only 339 | execute fallbacks. 340 | 341 | Fallback tasks cannot be invoked explicitly. 342 | """ 343 | defmacro fallback(dir_arg \\ (quote do: _), [do: block]) do 344 | dir_arg = Macro.escape(dir_arg) 345 | block = Macro.escape(block) 346 | 347 | quote bind_quoted: binding do 348 | line = __ENV__.line() 349 | fn_name = :"fallback_#{length(@exmake_fallbacks) + 1}_line_#{line}" 350 | 351 | @doc false 352 | def unquote(fn_name)(unquote(dir_arg)), do: unquote(block) 353 | 354 | @exmake_fallbacks Keyword.put([], :recipe, {__MODULE__, fn_name, line}) 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /lib/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Helpers do 2 | @moduledoc false 3 | 4 | @spec last_modified(Path.t()) :: :file.date_time() 5 | def last_modified(path) do 6 | case File.stat(path) do 7 | {:ok, %File.Stat{mtime: mtime}} -> mtime 8 | {:error, _} -> {{1970, 1, 1}, {0, 0, 0}} 9 | end 10 | end 11 | 12 | @spec get_target(:digraph.graph(), Path.t()) :: {:digraph.vertex(), Keyword.t()} | nil 13 | def get_target(graph, target) do 14 | Enum.find_value(:digraph.vertices(graph), fn(v) -> 15 | {_, r} = :digraph.vertex(graph, v) 16 | 17 | cond do 18 | (n = r[:name]) && Path.expand(n) == Path.expand(target) -> {v, r} 19 | (t = r[:targets]) && Enum.any?(t, fn(p) -> Path.expand(p) == Path.expand(target) end) -> {v, r} 20 | true -> nil 21 | end 22 | end) 23 | end 24 | 25 | @spec make_presentable(Keyword.t()) :: Keyword.t() 26 | def make_presentable(rule) do 27 | rule |> 28 | Keyword.delete(:recipe) |> 29 | Keyword.delete(:directory) |> 30 | Keyword.delete(:real_sources) 31 | end 32 | 33 | defmacro get_exmake_version() do 34 | ver = String.strip(File.read!("VERSION")) 35 | 36 | quote do 37 | unquote(ver) 38 | end 39 | end 40 | 41 | @spec get_exmake_version_tuple() :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} 42 | def get_exmake_version_tuple() do 43 | {:ok, ver} = Version.parse(get_exmake_version()) 44 | 45 | {ver.major, ver.minor, ver.patch} 46 | end 47 | 48 | defmacro get_exmake_license() do 49 | lic = File.stream!("LICENSE") |> 50 | Stream.drop(8) |> 51 | Enum.take(1) |> 52 | hd() |> 53 | String.strip() 54 | 55 | quote do 56 | unquote(lic) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/lib.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib do 2 | @moduledoc """ 3 | Provides various useful functions and macros for constructing libraries. 4 | 5 | This module should be `use`d by all libraries: 6 | 7 | defmodule ExMake.Lib.MyLang do 8 | use ExMake.Lib 9 | 10 | # ... 11 | end 12 | 13 | Using this module implicitly imports the following modules: 14 | 15 | * `ExMake.Env` 16 | * `ExMake.File` 17 | * `ExMake.Lib` 18 | * `ExMake.Logger` 19 | * `ExMake.Utils` 20 | """ 21 | 22 | @doc false 23 | defmacro __using__(_) do 24 | quote do 25 | import ExMake.Env 26 | import ExMake.File 27 | import ExMake.Lib 28 | import ExMake.Logger 29 | import ExMake.Utils 30 | 31 | @before_compile unquote(__MODULE__) 32 | 33 | @exmake_description "" 34 | @exmake_version {0, 0, 0} 35 | @exmake_url "" 36 | @exmake_on_load nil 37 | 38 | Module.register_attribute(__MODULE__, :exmake_description, [persist: true]) 39 | Module.register_attribute(__MODULE__, :exmake_licenses, [accumulate: true, persist: true]) 40 | Module.register_attribute(__MODULE__, :exmake_version, [persist: true]) 41 | Module.register_attribute(__MODULE__, :exmake_url, [persist: true]) 42 | Module.register_attribute(__MODULE__, :exmake_authors, [accumulate: true, persist: true]) 43 | Module.register_attribute(__MODULE__, :exmake_on_load, [persist: true]) 44 | Module.register_attribute(__MODULE__, :exmake_precious, [accumulate: true, persist: true]) 45 | end 46 | end 47 | 48 | @doc false 49 | defmacro __before_compile__(_) do 50 | quote do 51 | def __exmake__(:description), do: @exmake_description 52 | def __exmake__(:licenses), do: Enum.reverse(@exmake_licenses) 53 | def __exmake__(:version), do: @exmake_version 54 | def __exmake__(:url), do: @exmake_url 55 | def __exmake__(:authors), do: Enum.reverse(@exmake_authors) 56 | def __exmake__(:on_load), do: @exmake_on_load 57 | def __exmake__(:precious), do: Enum.reverse(@exmake_precious) 58 | end 59 | end 60 | 61 | @doc """ 62 | Sets a description for the library. Should be a string. 63 | """ 64 | defmacro description(description) do 65 | quote do: @exmake_description unquote(description) 66 | end 67 | 68 | @doc """ 69 | Adds a license name to the list of licenses. Should be a string. 70 | """ 71 | defmacro license(license) do 72 | quote do: @exmake_licenses unquote(license) 73 | end 74 | 75 | @doc """ 76 | Sets the version tuple of the library. All three version components should be 77 | non-negative integers. 78 | """ 79 | defmacro version(tuple) do 80 | quote do: @exmake_version unquote(tuple) 81 | end 82 | 83 | @doc """ 84 | Sets the URL to the library's repository. Should be a string. 85 | """ 86 | defmacro url(url) do 87 | quote do: @exmake_url unquote(url) 88 | end 89 | 90 | @doc """ 91 | Adds an author name/email pair to the list of authors. Both name and email should 92 | be strings. 93 | """ 94 | defmacro author(author, email) do 95 | quote do: @exmake_author {unquote(author), unquote(email)} 96 | end 97 | 98 | @doc """ 99 | Asserts that a particular library is loaded. If not, raises `ExMake.ScriptError`. 100 | 101 | Example: 102 | 103 | defmodule ExMake.Lib.Foo do 104 | use ExMake.Lib 105 | 106 | require ExMake.Lib.Erlang 107 | 108 | on_load _, _ do 109 | load_lib Erlang 110 | end 111 | 112 | defmacro fancy_wrapper(arg) do 113 | quote do: erl unquote(arg) 114 | end 115 | end 116 | 117 | This ensures that `ExMake.Lib.Erlang` has been loaded by the user before the `on_load` 118 | function of `ExMake.Lib.Foo` is called. The `require` is needed so that `ExMake.Lib.Foo` 119 | can make use of functions and macros exported from `ExMake.Lib.Erlang`. 120 | """ 121 | def require_lib(lib) do 122 | lib_mod = Module.concat(ExMake.Lib, lib) 123 | 124 | if !:code.is_loaded(lib_mod) do 125 | raise(ExMake.ScriptError, [description: "Library #{lib} must be loaded"]) 126 | end 127 | end 128 | 129 | @doc """ 130 | Defines a function that gets called when the library is loaded. 131 | 132 | Example: 133 | 134 | defmodule ExMake.Lib.Foo do 135 | use ExMake.Lib 136 | 137 | on_load args do 138 | Enum.each(args, fn(arg) -> 139 | # ... 140 | end) 141 | end 142 | end 143 | 144 | The first argument to the `on_load` function is a list of terms, as originally 145 | given to `ExMake.File.load_lib/2` or `ExMake.File.load_lib_qual/2`. The second 146 | argument is the list of arguments passed via the `--args` option to ExMake. In 147 | general, libraries should avoid using the second argument - it is primarily 148 | intended to be used in `configure`-like libraries written by users. 149 | 150 | Note that the `on_load` function will only be called when the environment table 151 | cache file does not exist. In other words, an `on_load` function should avoid 152 | having side-effects beyond setting variables in the environment table. 153 | """ 154 | defmacro on_load(args1_arg \\ (quote do: _), args2_arg \\ (quote do: _), [do: block]) do 155 | args1_arg = Macro.escape(args1_arg) 156 | args2_arg = Macro.escape(args2_arg) 157 | block = Macro.escape(block) 158 | 159 | quote bind_quoted: binding do 160 | fn_name = :on_load 161 | 162 | @doc false 163 | def unquote(fn_name)(unquote(args1_arg), 164 | unquote(args2_arg)), do: unquote(block) 165 | 166 | @exmake_on_load {__MODULE__, fn_name} 167 | end 168 | end 169 | 170 | @doc """ 171 | Declares an environment variable as precious. 172 | 173 | Example: 174 | 175 | defmodule ExMake.Lib.Foo do 176 | use ExMake.Lib 177 | 178 | precious "CC" 179 | 180 | on_load _, _ do 181 | if cc = args[:cc] || find_exe("cc", "CC") do 182 | put("CC", cc) 183 | end 184 | end 185 | end 186 | 187 | This causes the variable to be saved in the configuration cache. This is useful 188 | to ensure that the same values are used in environment variables when ExMake 189 | executes configuration checks anew because of a stale cache. 190 | 191 | Note that only environment variables that are actually set will be cached. 192 | """ 193 | defmacro precious(var) do 194 | quote do: @exmake_precious unquote(var) 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/lib/csharp.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib.CSharp do 2 | use ExMake.Lib 3 | 4 | require ExMake.Helpers 5 | 6 | description "Support for the C# programming language." 7 | license "MIT License" 8 | version ExMake.Helpers.get_exmake_version_tuple() 9 | url "https://github.com/lycus/exmake" 10 | author "Alex Rønne Petersen", "alex@lycus.org" 11 | 12 | precious "CSC" 13 | 14 | on_load args, _ do 15 | put("CSC", args[:csc] || find_exe(["csc", "mcs"], "CSC")) 16 | 17 | text = shell("${CSC} /?", silent: true, ignore: true) 18 | 19 | type = cond do 20 | String.starts_with?(text, "Microsoft (R) Visual C# Compiler") -> "csc" 21 | String.starts_with?(text, "Mono C# compiler") -> "mcs" 22 | true -> "unknown" 23 | end 24 | 25 | ExMake.Logger.log_result("C# compiler type: #{type}") 26 | put("CSC_TYPE", type) 27 | 28 | list_put("CSC_FLAGS") 29 | list_put("CSC_LIBS") 30 | end 31 | 32 | defmacro csc_flag(flag) do 33 | quote do: ExMake.Env.list_append("CSC_FLAGS", unquote(flag)) 34 | end 35 | 36 | defmacro csc_lib(dir) do 37 | quote do: ExMake.Env.list_append("CSC_LIBS", unquote(dir)) 38 | end 39 | 40 | defmacro cs(srcs, tgt, opts \\ []) do 41 | quote do 42 | @exm_csharp_opts unquote(opts) 43 | 44 | dbg = if @exm_csharp_opts[:debug] do 45 | case get("CSC_TYPE") do 46 | "csc" -> [unquote(tgt) <> ".pdb"] 47 | "mcs" -> [unquote(tgt) <> ".mdb"] 48 | "unknown" -> [] 49 | end 50 | else 51 | [] 52 | end 53 | 54 | mods = @exm_csharp_opts[:net_modules] || [] 55 | kf = if k = @exm_csharp_opts[:key_file], do: [k], else: [] 56 | srcs = unquote(srcs) ++ mods ++ kf 57 | doc = if d = @exm_csharp_opts[:doc_file], do: [d], else: [] 58 | tgts = [unquote(tgt)] ++ doc ++ dbg 59 | 60 | rule tgts, 61 | srcs, 62 | srcs, [tgt | _], dir do 63 | flags = Enum.join(@exm_csharp_opts[:flags] || [], " ") 64 | not_srcs = (@exm_csharp_opts[:net_modules] || []) |> 65 | Enum.concat(if k = @exm_csharp_opts[:key_file], do: [k], else: []) |> 66 | Enum.map(fn(x) -> Path.join(dir, x) end) 67 | srcs = Enum.into(srcs, HashSet.new()) |> 68 | Set.difference(Enum.into(not_srcs, HashSet.new())) |> 69 | Set.to_list() |> 70 | Enum.join(" ") 71 | mods = (@exm_csharp_opts[:net_modules] || []) |> 72 | Enum.map(fn(m) -> "/addmodule:#{Path.join(dir, m)}" end) |> 73 | Enum.join(" ") 74 | kf = if k, do: "/keyfile:#{Path.join(dir, k)}" 75 | doc = if s = @exm_csharp_opts[:doc_file], do: "/doc:#{Path.join(dir, s)}" 76 | libs = list_get("CSC_LIBS") ++ (@exm_csharp_opts[:libs] || []) |> 77 | Enum.map(fn(l) -> "/lib:#{Path.join(dir, l)}" end) |> 78 | Enum.join(" ") 79 | dbg = if @exm_csharp_opts[:debug] && get("CSC_TYPE") != "unknown", do: "/debug" 80 | 81 | shell("${CSC} ${CSC_FLAGS} #{flags} -nologo #{libs} #{mods} #{kf} #{doc} #{dbg} /out:#{tgt} -- #{srcs}") 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/lib/elixir.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib.Elixir do 2 | use ExMake.Lib 3 | 4 | require ExMake.Helpers 5 | 6 | description "Support for the Elixir programming language." 7 | license "MIT License" 8 | version ExMake.Helpers.get_exmake_version_tuple() 9 | url "https://github.com/lycus/exmake" 10 | author "Alex Rønne Petersen", "alex@lycus.org" 11 | 12 | precious "ELIXIRC" 13 | 14 | on_load args, _ do 15 | put("ELIXIRC", args[:elixirc] || find_exe("elixirc", "ELIXIRC")) 16 | 17 | list_put("ELIXIRC_FLAGS") 18 | end 19 | 20 | defmacro elixirc_flag(flag) do 21 | quote do: ExMake.Env.list_append("ELIXIRC_FLAGS", unquote(flag)) 22 | end 23 | 24 | defmacro ex(src, mods, opts \\ []) do 25 | quote do 26 | @exm_elixir_opts unquote(opts) 27 | 28 | src = unquote(src) 29 | srcs = [src] ++ (@exm_elixir_opts[:deps] || []) 30 | mods = unquote(mods) |> 31 | Enum.map(fn(m) -> m <> ".beam" end) |> 32 | Enum.map(fn(m) -> (@exm_elixir_opts[:output_dir] || Path.dirname(src)) |> 33 | Path.join(m) end) 34 | 35 | rule mods, 36 | srcs, 37 | [src | _], _, dir do 38 | flags = Enum.join(@exm_elixir_opts[:flags] || [], " ") 39 | output_dir = if s = @exm_elixir_opts[:output_dir], do: Path.join(dir, s), else: Path.dirname(src) 40 | 41 | shell("${ELIXIRC} ${ELIXIRC_FLAGS} #{flags} -o #{output_dir} #{src}") 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/lib/erlang.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib.Erlang do 2 | use ExMake.Lib 3 | 4 | require ExMake.Helpers 5 | 6 | description "Support for the Erlang programming language." 7 | license "MIT License" 8 | version ExMake.Helpers.get_exmake_version_tuple() 9 | url "https://github.com/lycus/exmake" 10 | author "Alex Rønne Petersen", "alex@lycus.org" 11 | 12 | precious "ERLC" 13 | 14 | on_load args, _ do 15 | put("ERLC", args[:erlc] || find_exe("erlc", "ERLC")) 16 | 17 | list_put("ERLC_FLAGS") 18 | list_put("ERLC_INCLUDES") 19 | end 20 | 21 | defmacro erlc_flag(flag) do 22 | quote do: ExMake.Env.list_append("ERLC_FLAGS", unquote(flag)) 23 | end 24 | 25 | defmacro erlc_include(dir) do 26 | quote do: ExMake.Env.list_append("ERLC_INCLUDES", unquote(dir)) 27 | end 28 | 29 | defmacro erl(src, opts \\ []) do 30 | quote do 31 | @exm_erlang_opts unquote(opts) 32 | 33 | src = unquote(src) 34 | srcs = [src] ++ (@exm_erlang_opts[:headers] || []) 35 | tgt = (@exm_erlang_opts[:output_dir] || Path.dirname(src)) |> 36 | Path.join(Path.rootname(Path.basename(src)) <> ".beam") 37 | 38 | rule [tgt], 39 | srcs, 40 | [src | _], _, dir do 41 | flags = Enum.join(@exm_erlang_opts[:flags] || [], " ") 42 | output_dir = if s = @exm_erlang_opts[:output_dir], do: Path.join(dir, s), else: Path.dirname(src) 43 | includes = list_get("ERLC_INCLUDES") ++ (@exm_erlang_opts[:includes] || []) |> 44 | Enum.map(fn(i) -> "-I #{Path.join(dir, i)}" end) |> 45 | Enum.join(" ") 46 | 47 | shell("${ERLC} ${ERLC_FLAGS} #{flags} #{includes} -o #{output_dir} #{src}") 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/lib/exmake.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib.ExMake do 2 | use ExMake.Lib 3 | 4 | require ExMake.Helpers 5 | 6 | description "Support for building ExMake libraries." 7 | license "MIT License" 8 | version ExMake.Helpers.get_exmake_version_tuple() 9 | url "https://github.com/lycus/exmake" 10 | author "Alex Rønne Petersen", "alex@lycus.org" 11 | 12 | defmacro exm_lib(src, mods, opts \\ []) do 13 | quote do 14 | @exm_exmake_opts unquote(opts) 15 | 16 | src = unquote(src) 17 | srcs = [src] ++ (@exm_exmake_opts[:deps] || []) 18 | mods = unquote(mods) |> 19 | Enum.map(fn(m) -> m <> ".beam" end) |> 20 | Enum.map(fn(m) -> (@exm_exmake_opts[:output_dir] || Path.dirname(src)) |> 21 | Path.join(m) end) 22 | 23 | rule mods, 24 | srcs, 25 | [src | _], _, dir do 26 | output_dir = if s = @exm_exmake_opts[:output_dir], do: Path.join(dir, s), else: Path.dirname(src) 27 | 28 | Enum.each(Code.compile_string(File.read!(src), src), fn({mod, code}) -> 29 | File.write!(Path.join(output_dir, Atom.to_string(mod) <> ".beam"), code) 30 | end) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/lib/fsharp.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib.FSharp do 2 | use ExMake.Lib 3 | 4 | require ExMake.Helpers 5 | 6 | description "Support for the F# programming language." 7 | license "MIT License" 8 | version ExMake.Helpers.get_exmake_version_tuple() 9 | url "https://github.com/lycus/exmake" 10 | author "Alex Rønne Petersen", "alex@lycus.org" 11 | 12 | precious "FSHARPC" 13 | 14 | on_load args, _ do 15 | put("FSHARPC", args[:fsharpc] || find_exe(["fsharpc"], "FSHARPC")) 16 | 17 | text = shell("${FSHARPC} --help", silent: true, ignore: true) 18 | 19 | type = cond do 20 | String.starts_with?(text, "Microsoft (R) F# Compiler") -> "fsc" 21 | String.starts_with?(text, "F# Compiler") -> "fsharpc" 22 | true -> "unknown" 23 | end 24 | 25 | ExMake.Logger.log_result("F# compiler type: #{type}") 26 | put("FSHARPC_TYPE", type) 27 | 28 | list_put("FSHARPC_FLAGS") 29 | list_put("FSHARPC_LIBS") 30 | end 31 | 32 | defmacro fsharpc_flag(flag) do 33 | quote do: ExMake.Env.list_append("FSHARPC_FLAGS", unquote(flag)) 34 | end 35 | 36 | defmacro fsharpc_lib(dir) do 37 | quote do: ExMake.Env.list_append("FSHARPC_LIBS", unquote(dir)) 38 | end 39 | 40 | defmacro fs(srcs, tgt, opts \\ []) do 41 | quote do 42 | @exm_fsharp_opts unquote(opts) 43 | 44 | dbg = if @exm_fsharp_opts[:debug] do 45 | case get("FSHARPC_TYPE") do 46 | "fsc" -> [unquote(tgt) <> ".pdb"] 47 | "fsharpc" -> [unquote(tgt) <> ".mdb"] 48 | "unknown" -> [] 49 | end 50 | else 51 | [] 52 | end 53 | 54 | kf = if k = @exm_fsharp_opts[:key_file], do: [k], else: [] 55 | srcs = unquote(srcs) ++ kf 56 | doc = if d = @exm_fsharp_opts[:doc_file], do: [d], else: [] 57 | tgts = [unquote(tgt)] ++ doc ++ dbg 58 | 59 | rule tgts, 60 | srcs, 61 | srcs, [tgt | _], dir do 62 | flags = Enum.join(@exm_fsharp_opts[:flags] || [], " ") 63 | srcs = if k = @exm_fsharp_opts[:key_file], do: List.delete(srcs, Path.join(dir, k)), else: srcs 64 | srcs = Enum.join(srcs, " ") 65 | kf = if k, do: "--keyfile:#{Path.join(dir, k)}" 66 | doc = if s = @exm_fsharp_opts[:doc_file], do: "--doc:#{Path.join(dir, s)}" 67 | libs = list_get("FSHARPC_LIBS") ++ (@exm_fsharp_opts[:libs] || []) |> 68 | Enum.map(fn(l) -> "--lib:#{Path.join(dir, l)}" end) |> 69 | Enum.join(" ") 70 | dbg = if @exm_fsharp_opts[:debug] && get("FSHARPC_TYPE") != "unknown", do: "--debug+" 71 | 72 | shell("${FSHARPC} ${FSHARPC_FLAGS} #{flags} --nologo #{libs} #{kf} #{doc} #{dbg} --out:#{tgt} #{srcs}") 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/lib/host.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Lib.Host do 2 | use ExMake.Lib 3 | 4 | require ExMake.Helpers 5 | 6 | description "Host operating system and architecture detection." 7 | license "MIT License" 8 | version ExMake.Helpers.get_exmake_version_tuple() 9 | url "https://github.com/lycus/exmake" 10 | author "Alex Rønne Petersen", "alex@lycus.org" 11 | 12 | on_load _, _ do 13 | # http://en.wikipedia.org/wiki/Uname#Examples 14 | {os, fmt} = case :os.type() do 15 | {:unix, :aix} -> {"aix", "elf"} 16 | {:unix, :darwin} -> {"osx", "macho"} 17 | {:unix, :dragonfly} -> {"dragonflybsd", "elf"} 18 | {:unix, :freebsd} -> {"freebsd", "elf"} 19 | {:unix, :gnu} -> {"hurd", "elf"} 20 | {:unix, :"gnu/kfreebsd"} -> {"kfreebsd", "elf"} 21 | {:unix, :haiku} -> {"haiku", "elf"} 22 | {:unix, :"hp-ux"} -> {"hpux", "elf"} 23 | {:unix, :irix64} -> {"irix", "elf"} 24 | {:unix, :linux} -> {"linux", "elf"} 25 | {:unix, :netbsd} -> {"netbsd", "elf"} 26 | {:unix, :openbsd} -> {"openbsd", "elf"} 27 | {:unix, :plan9} -> {"plan9", "elf"} 28 | {:unix, :qnx} -> {"qnx", "elf"} 29 | {:unix, :sunos} -> {"solaris", "elf"} 30 | {:unix, :unixware} -> {"unixware", "elf"} 31 | {:win32, _} -> {"windows", "pe"} 32 | {x, y} -> 33 | ExMake.Logger.log_warn("Unknown host operating system '#{x}/#{y}'; assuming binary format is 'elf'") 34 | 35 | {"unknown", "elf"} 36 | end 37 | 38 | ExMake.Logger.log_result("Host operating system: #{os}") 39 | put("HOST_OS", os) 40 | 41 | ExMake.Logger.log_result("Host binary format: #{fmt}") 42 | put("HOST_FORMAT", fmt) 43 | 44 | sys_arch = :erlang.system_info(:system_architecture) |> 45 | List.to_string() |> 46 | String.split("-") |> 47 | Enum.fetch!(0) 48 | 49 | re = fn(re) -> Regex.match?(re, sys_arch) end 50 | 51 | # http://wiki.debian.org/Multiarch/Tuples 52 | # TODO: There are more than these in the wild. 53 | arch = cond do 54 | re.(~r/^aarch64(_eb)?$/) -> "aarch64" 55 | re.(~r/^alpha$/) -> "alpha" 56 | re.(~r/^arm(eb)?$/) -> "arm" 57 | re.(~r/^hppa$/) -> "hppa" 58 | re.(~r/^i386$/) -> "i386" 59 | re.(~r/^ia64$/) -> "ia64" 60 | re.(~r/^m68k$/) -> "m68k" 61 | re.(~r/^mips(el)?$/) -> "mips" 62 | re.(~r/^mips64(el)?$/) -> "mips64" 63 | re.(~r/^powerpc$/) -> "ppc" 64 | re.(~r/^ppc64$/) -> "ppc64" 65 | re.(~r/^s390$/) -> "s390" 66 | re.(~r/^s390x$/) -> "s390x" 67 | re.(~r/^sh4$/) -> "sh4" 68 | re.(~r/^sparc$/) -> "sparc" 69 | re.(~r/^sparc64$/) -> "sparc64" 70 | re.(~r/^x86_64$/) -> "amd64" 71 | true -> 72 | ExMake.Logger.log_warn("Unknown host architecture '#{sys_arch}'") 73 | 74 | "unknown" 75 | end 76 | 77 | ExMake.Logger.log_result("Host architecture: #{arch}") 78 | put("HOST_ARCH", arch) 79 | 80 | endian = case <<1234 :: size(32) - native()>> do 81 | <<1234 :: size(32) - big()>> -> "big" 82 | <<1234 :: size(32) - little()>> -> "little" 83 | end 84 | 85 | ExMake.Logger.log_result("Host endianness: #{endian}") 86 | put("HOST_ENDIAN", endian) 87 | end 88 | 89 | def host_binary_patterns() do 90 | case get("HOST_FORMAT") do 91 | "elf" -> 92 | [obj: "~ts.o", 93 | stlib: "lib~ts.a", 94 | shlib: "lib~ts.so", 95 | exe: "~ts"] 96 | "macho" -> 97 | [obj: "~ts.o", 98 | stlib: "lib~ts.a", 99 | shlib: "lib~ts.dylib", 100 | exe: "~ts"] 101 | "pe" -> 102 | [obj: "~ts.obj", 103 | stlib: "lib~ts.a", 104 | shlib: "~ts.dll", 105 | implib: "~ts.lib", 106 | exe: "~ts"] 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/libraries.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Libraries do 2 | @moduledoc """ 3 | Contains functions to manage the loading of 'libraries'; that is, modules 4 | that script files can `use` and call macros/functions in. 5 | 6 | By convention, such modules begin with `ExMake.Lib.` and are followed by 7 | some named describing what the module contains, e.g. `ExMake.Lib.C`. 8 | 9 | Further, libraries are free to use all attributes that begin with 10 | `@exm_foo_` where `foo` is the lowercase name of the library `Foo`. 11 | 12 | By default, ExMake looks for libraries in: 13 | 14 | * `./exmake` 15 | * `/usr/local/lib/exmake` 16 | * `/usr/lib/exmake` 17 | * `/lib/exmake` 18 | 19 | Additionally, if the `HOME` environment variable is defined, it will look 20 | in `$HOME/.exmake`. 21 | 22 | The `EXMAKE_PATH` environment variable can be set to a colon-separated 23 | list of paths to use. When it is set, the above paths will not be considered. 24 | """ 25 | 26 | @doc """ 27 | Returns a list of paths to search for libraries in. 28 | """ 29 | @spec search_paths() :: [Path.t()] 30 | def search_paths() do 31 | if s = System.get_env("EXMAKE_PATH") do 32 | ExMake.Logger.log_debug("Using EXMAKE_PATH: #{s}") 33 | 34 | Enum.filter(String.split(s, ":"), fn(s) -> s != "" end) 35 | else 36 | p = [Path.join(["/usr", "local", "lib", "exmake"]), 37 | Path.join(["/usr", "lib", "exmake"]), 38 | Path.join("/lib", "exmake")] 39 | 40 | if s = System.get_env("HOME") do 41 | p = [Path.join(s, ".exmake")] ++ p 42 | end 43 | 44 | p = [Path.join(".", "exmake")] ++ p 45 | 46 | p 47 | end 48 | end 49 | 50 | @doc """ 51 | Adds a given path to the global code path such that script files can 52 | load libraries from it. 53 | 54 | Returns `:ok` on success or `:error` if something went wrong (e.g. the 55 | path doesn't exist). 56 | """ 57 | @spec append_path(Path.t()) :: :ok | :error 58 | def append_path(path) do 59 | p = path |> 60 | Path.expand() |> 61 | Path.absname() 62 | 63 | case Code.append_path(p) do 64 | true -> :ok 65 | {:error, r} -> 66 | ExMake.Logger.log_debug("Could not add code path #{path} (#{p}): #{r}") 67 | :error 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/load_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.LoadError do 2 | @moduledoc """ 3 | The exception raised by `ExMake.Loader.load/2` if a script file could 4 | not be loaded. 5 | 6 | `file` is the script file name. `directory` is the directory the file 7 | is (supposedly) located in. `error` is the underlying exception. 8 | """ 9 | 10 | defexception [:message, 11 | :file, 12 | :directory, 13 | :error] 14 | 15 | @type t() :: %ExMake.LoadError{message: String.t(), 16 | file: Path.t(), 17 | directory: Path.t(), 18 | error: Exception.t() | nil} 19 | end 20 | -------------------------------------------------------------------------------- /lib/loader.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Loader do 2 | @moduledoc """ 3 | Contains logic to load script files. 4 | """ 5 | 6 | @doc """ 7 | Loads script file `file` in directory `dir`. Returns a list of 8 | `{dir, file, mod, bin}` where `mod` is the name of the module containing 9 | rules and recipes and `bin` is the bytecode of the module. Raises 10 | `ExMake.LoadError` if loading failed for some reason. Raises 11 | `ExMake.ScriptError` if an `ExMake.File.recurse` directive contained 12 | an invalid directory or file name. Raises `ExMake.UsageError` if `file` 13 | is invalid. 14 | 15 | `dir` must be a path to a directory. `file` must be the name of the 16 | file to load in `dir`. 17 | """ 18 | @spec load(Path.t(), Path.t()) :: [{Path.t(), Path.t(), module(), binary()}, ...] 19 | def load(dir, file \\ "Exmakefile") do 20 | p = Path.join(dir, file) 21 | 22 | list = try do 23 | File.cd!(dir, fn() -> Code.load_file(file) end) 24 | rescue 25 | ex in [Code.LoadError] -> 26 | raise(ExMake.LoadError, 27 | [message: "#{p}: Could not load file", 28 | file: file, 29 | directory: dir, 30 | error: ex]) 31 | ex in [CompileError] -> 32 | raise(ExMake.LoadError, 33 | [message: Exception.message(ex), 34 | file: file, 35 | directory: dir, 36 | error: ex]) 37 | end 38 | 39 | cnt = Enum.count(list, fn({x, _}) -> Atom.to_string(x) |> String.ends_with?(".Exmakefile") end) 40 | 41 | cond do 42 | cnt == 0 -> 43 | raise(ExMake.LoadError, 44 | [message: "#{p}: No module ending in '.Exmakefile' defined", 45 | file: file, 46 | directory: dir, 47 | error: nil]) 48 | cnt > 1 -> 49 | raise(ExMake.LoadError, 50 | [message: "#{p}: #{cnt} modules ending in '.Exmakefile' defined", 51 | file: file, 52 | directory: dir, 53 | error: nil]) 54 | true -> :ok 55 | end 56 | 57 | {mod, bin} = Enum.fetch!(list, 0) 58 | rec = mod.__exmake__(:subdirectories) 59 | 60 | Enum.each(rec, fn({sub, file}) -> 61 | if !String.valid?(sub) do 62 | raise(ExMake.ScriptError, [message: "Subdirectory path must be a string"]) 63 | end 64 | 65 | if !String.valid?(file) do 66 | raise(ExMake.ScriptError, [message: "Subdirectory file must be a string"]) 67 | end 68 | end) 69 | 70 | lists = rec |> 71 | Enum.map(fn({d, f}) -> load(Path.join(dir, d), f) end) |> 72 | List.flatten() 73 | 74 | [{dir, file, mod, bin} | lists] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Logger do 2 | @moduledoc """ 3 | Provides logging facilities. 4 | 5 | If the `:exmake_event_pid` application configuration key is set for the 6 | `:exmake` application, log messages will be sent as `{:exmake_stdout, msg}` 7 | (where `msg` is a binary) to that PID instead of being printed to standard 8 | output. 9 | 10 | Note also that if `:exmake_event_pid` is set, the current terminal is 11 | not ANSI-compatible, or the `EXMAKE_COLORS` environment variable is set to 12 | `0`, colored output will be disabled. 13 | """ 14 | 15 | @spec colorize(String.t(), IO.ANSI.ansicode()) :: String.t() 16 | defp colorize(str, color) do 17 | emit = IO.ANSI.enabled?() && Application.get_env(:exmake, :exmake_event_pid) == nil && System.get_env("EXMAKE_COLORS") != "0" 18 | IO.ANSI.format([color, :bright, str], emit) |> IO.iodata_to_binary() 19 | end 20 | 21 | @spec output(String.t()) :: :ok 22 | defp output(str) do 23 | _ = case Application.get_env(:exmake, :exmake_event_pid) do 24 | nil -> IO.puts(str) 25 | pid -> send(pid, {:exmake_stdout, str <> "\n"}) 26 | end 27 | 28 | :ok 29 | end 30 | 31 | @doc false 32 | @spec info(String.t()) :: :ok 33 | def info(str) do 34 | output(str) 35 | end 36 | 37 | @doc false 38 | @spec warn(String.t()) :: :ok 39 | def warn(str) do 40 | output(colorize("Warning:", :yellow) <> " " <> colorize(str, :white)) 41 | end 42 | 43 | @doc false 44 | @spec error(String.t()) :: :ok 45 | def error(prefix \\ "Error", str) do 46 | output(colorize(prefix <> ":", :red) <> " " <> colorize(str, :white)) 47 | end 48 | 49 | @doc """ 50 | Prints an informational message in `--loud` mode. Returns `:ok`. 51 | 52 | `str` must be a binary containing the message. 53 | """ 54 | @spec log_info(String.t()) :: :ok 55 | def log_info(str) do 56 | if ExMake.Coordinator.get_config().options()[:loud], do: info(str) 57 | 58 | :ok 59 | end 60 | 61 | @doc """ 62 | Prints a notice in `--loud` mode. Colorized as green and white. Returns 63 | `:ok`. 64 | 65 | `str` must be a binary containing the message. 66 | """ 67 | @spec log_note(String.t()) :: :ok 68 | def log_note(str) do 69 | if ExMake.Coordinator.get_config().options()[:loud], do: output(colorize(str, :green)) 70 | 71 | :ok 72 | end 73 | 74 | @doc """ 75 | Prints a warning in `--loud` mode. Colorized as yellow and white. Returns 76 | `:ok`. 77 | 78 | `str` must be a binary containing the message. 79 | """ 80 | @spec log_warn(String.t()) :: :ok 81 | def log_warn(str) do 82 | if ExMake.Coordinator.get_config().options()[:loud], do: warn(str) 83 | 84 | :ok 85 | end 86 | 87 | @doc """ 88 | Prints a result message in `--loud` mode. Colorized as cyan and white. 89 | Returns `:ok`. 90 | 91 | `str` must be a binary containing the message. 92 | """ 93 | @spec log_result(String.t()) :: :ok 94 | def log_result(str) do 95 | if ExMake.Coordinator.get_config().options()[:loud], do: output(colorize(str, :cyan)) 96 | 97 | :ok 98 | end 99 | 100 | @doc """ 101 | Prints a debug message if the `EXMAKE_DEBUG` environment variable is set 102 | to `1`. Colorized as magenta and white. Returns `:ok`. 103 | 104 | `str` must be a binary containing the message. 105 | """ 106 | @spec log_debug(String.t()) :: :ok 107 | def log_debug(str) do 108 | if System.get_env("EXMAKE_DEBUG") == "1" do 109 | output(colorize("Debug:", :magenta) <> " " <> colorize(str, :white)) 110 | end 111 | 112 | :ok 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Runner do 2 | @moduledoc false 3 | 4 | @spec start(Keyword.t(), term(), pid()) :: pid() 5 | def start(rule, data, owner) do 6 | spawn(fn() -> 7 | result = try do 8 | {run, args} = cond do 9 | # Handle tasks. 10 | rule[:name] -> 11 | Enum.each(rule[:real_sources], fn(src) -> 12 | if !File.exists?(src) do 13 | raise(ExMake.UsageError, [message: "No rule to make target '#{src}'"]) 14 | end 15 | end) 16 | 17 | {true, [rule[:name], rule[:sources]]} 18 | # Handle rules. 19 | rule[:targets] -> 20 | Enum.each(rule[:sources], fn(src) -> 21 | if !File.exists?(src) do 22 | raise(ExMake.UsageError, [message: "No rule to make target '#{src}'"]) 23 | end 24 | end) 25 | 26 | src_time = Enum.map(rule[:sources], fn(src) -> ExMake.Helpers.last_modified(src) end) |> Enum.max() 27 | tgt_time = Enum.map(rule[:targets], fn(tgt) -> ExMake.Helpers.last_modified(tgt) end) |> Enum.min() 28 | 29 | {src_time > tgt_time, [rule[:sources], rule[:targets]]} 30 | # Handle fallbacks. 31 | true -> 32 | {true, []} 33 | end 34 | 35 | if run do 36 | {m, f, _} = rule[:recipe] 37 | 38 | cwd = File.cwd!() 39 | 40 | apply(m, f, args ++ [rule[:directory]]) 41 | 42 | if (ncwd = File.cwd!()) != cwd do 43 | r = inspect(ExMake.Helpers.make_presentable(rule)) 44 | 45 | raise(ExMake.ScriptError, 46 | [message: "Recipe for rule #{r} changed directory from '#{cwd}' to '#{ncwd}'"]) 47 | end 48 | 49 | if tgts = rule[:targets] do 50 | Enum.each(tgts, fn(tgt) -> 51 | if !File.exists?(tgt) do 52 | r = inspect(ExMake.Helpers.make_presentable(rule)) 53 | 54 | raise(ExMake.ScriptError, 55 | [message: "Recipe for rule #{r} did not produce #{tgt} as expected"]) 56 | end 57 | end) 58 | end 59 | end 60 | 61 | :ok 62 | catch 63 | val -> {:throw, val, System.stacktrace()} 64 | rescue 65 | ex -> {:raise, ex, System.stacktrace()} 66 | end 67 | 68 | if result != :ok do 69 | ExMake.Logger.log_debug("Caught #{elem(result, 0)} in runner: #{inspect(elem(result, 1))}") 70 | ExMake.Logger.log_debug(Exception.format_stacktrace(elem(result, 2))) 71 | 72 | # If the recipe failed, remove all target files. 73 | if tgts = rule[:targets], do: Enum.each(tgts, fn(tgt) -> File.rm(tgt) end) 74 | 75 | result = Tuple.delete_at(result, 2) 76 | end 77 | 78 | GenServer.call(:exmake_coordinator, {:done, rule, data, owner, result}, :infinity) 79 | end) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/script_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.ScriptError do 2 | @moduledoc """ 3 | The exception raised if a rule in a script file is invalid. 4 | """ 5 | 6 | defexception [:message] 7 | 8 | @type t() :: %ExMake.ScriptError{message: String.t()} 9 | end 10 | -------------------------------------------------------------------------------- /lib/shell_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.ShellError do 2 | @moduledoc """ 3 | The exception raised by `ExMake.Utils.shell/2` if a program does not 4 | exit with an exit code of zero. 5 | 6 | `command` contains the full command line that was executed. `output` 7 | contains all `stdout` and `stderr` output of the program. `exit_code` 8 | contains the exit code of the program. 9 | """ 10 | 11 | defexception [:message, 12 | :command, 13 | :output, 14 | :exit_code] 15 | 16 | @type t() :: %ExMake.ShellError{message: String.t(), 17 | command: String.t(), 18 | output: String.t(), 19 | exit_code: integer()} 20 | end 21 | -------------------------------------------------------------------------------- /lib/stale_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.StaleError do 2 | @moduledoc """ 3 | The exception raised in `--question` mode if a rule has stale targets. 4 | 5 | `rule` is the rule that has stale targets. 6 | """ 7 | 8 | defexception [:message, 9 | :rule] 10 | 11 | @type t() :: %ExMake.StaleError{message: String.t(), rule: Keyword.t()} 12 | end 13 | -------------------------------------------------------------------------------- /lib/state.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.State do 2 | @moduledoc false 3 | 4 | defstruct config: nil, 5 | max_jobs: 1, 6 | jobs: HashSet.new(), 7 | queue: :queue.new(), 8 | timing: nil, 9 | libraries: HashSet.new() 10 | 11 | @type t() :: %ExMake.State{config: ExMake.Config.t() | nil, 12 | max_jobs: non_neg_integer(), 13 | jobs: Set.t(), 14 | queue: :queue.queue({Keyword.t(), term(), pid()}), 15 | timing: ExMake.Timer.session() | nil, 16 | libraries: Set.t()} 17 | end 18 | -------------------------------------------------------------------------------- /lib/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Supervisor do 2 | @moduledoc """ 3 | Contains the default ExMake supervisor which supervises the following 4 | singleton processes: 5 | 6 | * `ExMake.Worker` 7 | * `ExMake.Coordinator` 8 | """ 9 | 10 | use Supervisor 11 | 12 | @doc false 13 | @spec start_link() :: {:ok, pid()} 14 | def start_link() do 15 | {:ok, _} = Supervisor.start_link(__MODULE__, nil, []) 16 | end 17 | 18 | @doc false 19 | @spec init(nil) :: {:ok, {{:one_for_all, non_neg_integer(), pos_integer()}, [Supervisor.Spec.spec()]}} 20 | def init(nil) do 21 | supervise([worker(ExMake.Worker, [], [restart: :temporary, shutdown: :infinity]), 22 | worker(ExMake.Coordinator, [], [restart: :temporary, shutdown: :infinity])], 23 | [strategy: :one_for_all]) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/throw_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.ThrowError do 2 | @moduledoc """ 3 | The exception raised by ExMake when an arbitrary value is thrown. 4 | 5 | `value` is the Erlang term that was thrown. 6 | """ 7 | 8 | defexception [:message, 9 | :value] 10 | 11 | @type t() :: %ExMake.ThrowError{message: String.t(), 12 | value: term()} 13 | end 14 | -------------------------------------------------------------------------------- /lib/timer.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Timer do 2 | @moduledoc """ 3 | Provides convenience functions for timing. 4 | """ 5 | 6 | @opaque session() :: {String.t(), non_neg_integer(), non_neg_integer(), Dict.t()} 7 | @opaque finished_session() :: {String.t(), Dict.t()} 8 | 9 | @doc """ 10 | Creates a timing session. Returns an opaque session object. 11 | 12 | `title` must be a binary containing the title of this timing session. 13 | """ 14 | @spec create_session(String.t()) :: session() 15 | def create_session(title) do 16 | {title, 0, 0, HashDict.new()} 17 | end 18 | 19 | @doc """ 20 | Starts a pass in the given session. Returns the updated session. 21 | 22 | `session` must be a session object. `name` must be a binary containing the 23 | name of this timing pass. 24 | """ 25 | @spec start_pass(session(), String.t()) :: session() 26 | def start_pass(session, name) do 27 | {title, time, n, passes} = session 28 | {title, time, n, Dict.put(passes, name, {n, :erlang.now()})} 29 | end 30 | 31 | @doc """ 32 | Ends the current timing pass in the given session. Returns the updated 33 | session. 34 | 35 | `session` must be a session object with an in-progress pass. `name` must be 36 | the name given to the `start_pass/2` function previously. 37 | """ 38 | @spec end_pass(session(), String.t()) :: session() 39 | def end_pass(session, name) do 40 | {title, time, n, passes} = session 41 | diff = :timer.now_diff(:erlang.now(), elem(passes[name], 1)) 42 | {title, time + diff, n + 1, Dict.update(passes, name, nil, fn({n, _}) -> {n, diff} end)} 43 | end 44 | 45 | @doc """ 46 | Ends a given timing session. Returns the finished session object. 47 | 48 | `session` must be a session object with no in-progress passes. 49 | """ 50 | @spec finish_session(session()) :: finished_session() 51 | def finish_session(session) do 52 | {title, time, n, passes} = session 53 | pairs = for {n, {i, t}} <- Dict.to_list(passes), do: {i, n, t, t / time * 100} 54 | {title, pairs ++ [{n + 1, "Total", time, 100.0}]} 55 | end 56 | 57 | @doc """ 58 | Formats a finished session in a user-presentable way. Returns the resulting 59 | binary containing the formatted session. 60 | 61 | `session` must be a finished session object. 62 | """ 63 | @spec format_session(finished_session()) :: String.t() 64 | def format_session(session) do 65 | {title, passes} = session 66 | 67 | sep = " ===------------------------------------------------------------------------------------------===" 68 | head = " #{title}" 69 | head2 = " Time Percent Name" 70 | sep2 = " --------------------------------------------- ---------- -------------------------------" 71 | 72 | passes = for {_, name, time, perc} <- Enum.sort(passes) do 73 | msecs = div(time, 1000) 74 | secs = div(msecs, 1000) 75 | mins = div(secs, 60) 76 | hours = div(mins, 60) 77 | days = div(hours, 24) 78 | 79 | ftime = "#{days}d | #{hours}h | #{mins}m | #{secs}s | #{msecs}ms | #{time}us" 80 | 81 | :unicode.characters_to_binary(:io_lib.format(" ~-45s ~-10.1f #{name}", [ftime, perc])) 82 | end 83 | 84 | joined = Enum.join(passes, "\n") 85 | 86 | "\n" <> sep <> "\n" <> head <> "\n" <> sep <> "\n\n" <> head2 <> "\n" <> sep2 <> "\n" <> joined <> "\n" 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/usage_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.UsageError do 2 | @moduledoc """ 3 | The exception raised if invalid command line arguments are provided. 4 | """ 5 | 6 | defexception [:message] 7 | 8 | @type t() :: %ExMake.UsageError{message: String.t()} 9 | end 10 | -------------------------------------------------------------------------------- /lib/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Utils do 2 | @moduledoc """ 3 | Contains various utilities to assist in writing recipes. 4 | 5 | Automatically imported in all script files. 6 | """ 7 | 8 | @doc """ 9 | Runs a command in the system shell. Returns the output of the command 10 | as a string. Raises `ExMake.ShellError` if the command returns a 11 | non-zero exit code. 12 | 13 | Any `${...}` instance in the command string where the `...` matches a 14 | key in the `ExMake.Env` table will be replaced with the value that 15 | key maps to. 16 | 17 | Example: 18 | 19 | shell("${CC} -c foo.c -o foo.o") 20 | 21 | `cmd` must be a string containing the command to execute. `opts` must 22 | be a list of Boolean options (`:silent` and `:ignore`). 23 | """ 24 | @spec shell(String.t(), Keyword.t()) :: String.t() 25 | def shell(cmd, opts \\ []) do 26 | silent = opts[:silent] || false 27 | ignore = opts[:ignore] || false 28 | 29 | cmd = ExMake.Env.reduce(cmd, fn({k, v}, cmd) -> 30 | value = if is_binary(v) do 31 | v 32 | else 33 | Enum.join(v, " ") 34 | end 35 | 36 | String.replace(cmd, "${#{k}}", value) 37 | end) 38 | 39 | if !silent do 40 | ExMake.Logger.log_note(cmd) 41 | end 42 | 43 | port = Port.open({:spawn, String.to_char_list(cmd)}, [:binary, 44 | :exit_status, 45 | :hide, 46 | :stderr_to_stdout]) 47 | 48 | recv = fn(recv, port, acc) -> 49 | receive do 50 | {^port, {:data, data}} -> recv.(recv, port, acc <> data) 51 | {^port, {:exit_status, code}} -> {acc, code} 52 | end 53 | end 54 | 55 | {text, code} = recv.(recv, port, "") 56 | 57 | if code != 0 && !ignore do 58 | out = if String.strip(text) != "", do: "\n#{text}", else: "" 59 | 60 | raise(ExMake.ShellError, 61 | [message: "Command '#{cmd}' exited with code: #{code}#{out}", 62 | command: cmd, 63 | output: text, 64 | exit_code: code]) 65 | end 66 | 67 | if String.strip(text) != "" && !silent do 68 | ExMake.Logger.log_info(text) 69 | end 70 | 71 | text 72 | end 73 | 74 | @doc """ 75 | Runs a given function while ignoring all errors. The function 76 | should take no arguments. 77 | 78 | If nothing goes wrong, returns `{:ok, result}` where `result` 79 | is the value returned by the given function. If a value was 80 | thrown, returns `{:throw, value}`. If an exception was raised, 81 | returns `{:rescue, exception}`. 82 | """ 83 | @spec ignore((() -> term())) :: {:ok, term()} | {:throw, term()} | {:rescue, tuple()} 84 | def ignore(fun) do 85 | try do 86 | {:ok, fun.()} 87 | catch 88 | x -> {:throw, x} 89 | rescue 90 | x -> {:rescue, x} 91 | end 92 | end 93 | 94 | @doc """ 95 | Attempts to find an executable in `PATH` given its name. An 96 | environment variable name can optionally be given, which, if 97 | set, will be preferred. Raises `ExMake.ScriptError` if the 98 | executable could not be found. 99 | 100 | `name` must be the name of the executable as a string, or a 101 | list of names. `var` must be an environment variable name as 102 | a string. `opts` must be a list of Boolean options (`:silent` 103 | and `:ignore`). 104 | """ 105 | @spec find_exe(String.t() | [String.t()], String.t(), Keyword.t()) :: String.t() | nil 106 | def find_exe(name, var \\ "", opts \\ []) do 107 | silent = opts[:silent] || false 108 | ignore = opts[:ignore] || false 109 | 110 | if s = System.get_env(var), do: name = s 111 | 112 | names = if is_list(name), do: name, else: [name] 113 | 114 | exe = Enum.find_value(names, fn(name) -> 115 | case :os.find_executable(String.to_char_list(name)) do 116 | false -> nil 117 | path -> List.to_string(path) 118 | end 119 | end) 120 | 121 | var_desc = if var == "", do: "", else: " ('#{var}'#{if s, do: " = '#{s}'", else: ""})" 122 | 123 | if !exe && !ignore do 124 | list = Enum.join(Enum.map(names, fn(s) -> "'#{s}'" end), ", ") 125 | 126 | raise(ExMake.ScriptError, [message: "Could not locate program #{list}#{var_desc}"]) 127 | end 128 | 129 | if !silent do 130 | ExMake.Logger.log_result("Located program '#{exe}'#{var_desc}") 131 | end 132 | 133 | exe 134 | end 135 | 136 | @doc """ 137 | Formats a string according to `:io_lib.format/2` and returns 138 | the resulting string as a binary. 139 | 140 | Please note that you should usually use the `~ts` modifier 141 | rather than `~s` as the latter will not handle UTF-8 strings 142 | correctly. The same goes for `~tc` and `~c`. 143 | 144 | `str` must be the format string as a binary. `args` must be 145 | a list of terms to pass to the formatting function. 146 | """ 147 | @spec format(String.t(), [term()]) :: String.t() 148 | def format(str, args) do 149 | :io_lib.format(str, args) |> IO.iodata_to_binary() 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Worker do 2 | @moduledoc """ 3 | Encapsulates a worker process that executes a script file and returns 4 | the exit code for the execution. Can be supervised by an OTP supervisor. 5 | """ 6 | 7 | use GenServer 8 | 9 | @doc false 10 | @spec start_link() :: {:ok, pid()} 11 | def start_link() do 12 | {:ok, _} = GenServer.start_link(__MODULE__, nil, [name: :exmake_worker]) 13 | end 14 | 15 | @doc """ 16 | Instructs the worker process to execute a script file specified by 17 | the configuration previously handed to the coordinator via the 18 | `ExMake.Coordinator.set_config/2` function. Returns the exit code 19 | of the operation. 20 | 21 | `timeout` must be `:infinity` or a millisecond value specifying 22 | how much time to wait for the operation to complete. 23 | """ 24 | @spec work(timeout()) :: non_neg_integer() 25 | def work(timeout \\ :infinity) do 26 | code = GenServer.call(:exmake_worker, :work, timeout) 27 | 28 | _ = case Application.get_env(:exmake, :exmake_event_pid) do 29 | nil -> :ok 30 | pid -> send(pid, {:exmake_shutdown, code}) 31 | end 32 | 33 | code 34 | end 35 | 36 | @doc false 37 | @spec handle_call(:work, {pid(), term()}, nil) :: {:reply, non_neg_integer(), nil} 38 | def handle_call(:work, _, nil) do 39 | ExMake.Coordinator.clear_libraries() 40 | 41 | cfg = ExMake.Coordinator.get_config() 42 | 43 | if cfg.options[:time] do 44 | ExMake.Coordinator.apply_timer_fn(fn(_) -> ExMake.Timer.create_session("ExMake Build Process") end) 45 | 46 | pass_go = fn(p) -> ExMake.Coordinator.apply_timer_fn(fn(s) -> ExMake.Timer.start_pass(s, p) end) end 47 | pass_end = fn(p) -> ExMake.Coordinator.apply_timer_fn(fn(s) -> ExMake.Timer.end_pass(s, p) end) end 48 | else 49 | # Makes the code below less ugly. 50 | pass_go = fn(_) -> end 51 | pass_end = fn(_) -> end 52 | end 53 | 54 | file = cfg.options[:file] || "Exmakefile" 55 | 56 | Process.put(:exmake_jobs, 0) 57 | 58 | code = try do 59 | File.cd!(Path.dirname(file)) 60 | 61 | pass_go.("Set Library Paths") 62 | 63 | Enum.each(ExMake.Libraries.search_paths(), fn(x) -> ExMake.Libraries.append_path(x) end) 64 | 65 | pass_end.("Set Library Paths") 66 | 67 | # Slight optimization: If we're clearing the 68 | # cache, then it's definitely stale. 69 | stale = if cfg.options[:clear] do 70 | pass_go.("Clear Build Cache") 71 | 72 | ExMake.Cache.clear_cache() 73 | 74 | pass_end.("Clear Build Cache") 75 | 76 | true 77 | else 78 | pass_go.("Check Cache Timestamps") 79 | 80 | stale = ExMake.Cache.cache_stale?() 81 | 82 | pass_end.("Check Cache Timestamps") 83 | 84 | stale 85 | end 86 | 87 | {g, f} = if stale do 88 | # If the cache is stale and the configuration 89 | # files exist, we should still load them and 90 | # attempt to use them since we'll be running 91 | # all sorts of configuration logic again. 92 | if ExMake.Cache.config_cached?() do 93 | pass_go.("Load Configuration Cache") 94 | 95 | {args, vars} = ExMake.Cache.load_config() 96 | 97 | Enum.each(vars, fn({k, v}) -> if !System.get_env(k), do: System.put_env(k, v) end) 98 | 99 | if cfg.args == [], do: ExMake.Coordinator.set_config(%ExMake.Config{cfg | :args => args}) 100 | 101 | pass_end.("Load Configuration Cache") 102 | end 103 | 104 | pass_go.("Load Script Files") 105 | 106 | mods = ExMake.Loader.load(".", file) 107 | 108 | pass_end.("Load Script Files") 109 | 110 | pass_go.("Save Module Cache") 111 | 112 | ExMake.Cache.save_mods(Enum.map(mods, fn({_, _, m, c}) -> {m, c} end)) 113 | 114 | pass_end.("Save Module Cache") 115 | 116 | pass_go.("Save Environment Cache") 117 | 118 | ExMake.Cache.save_env() 119 | 120 | pass_end.("Save Environment Cache") 121 | 122 | g = construct_graph(Enum.map(mods, fn({d, f, m, _}) -> {d, f, m} end), pass_go, pass_end) 123 | 124 | pass_go.("Save Graph Cache") 125 | 126 | # Cache the generated graph. 127 | ExMake.Cache.save_graph(g) 128 | 129 | pass_end.("Save Graph Cache") 130 | 131 | # We only care about the fallbacks in the entry 132 | # point script, and don't need to check anything. 133 | f = elem(hd(mods), 2).__exmake__(:fallbacks) 134 | 135 | pass_go.("Save Fallback Cache") 136 | 137 | ExMake.Cache.save_fallbacks(f) 138 | 139 | pass_end.("Save Fallback Cache") 140 | 141 | pass_go.("Save Configuration Cache") 142 | 143 | vars = ExMake.Coordinator.get_libraries() |> 144 | Enum.map(fn(m) -> m.__exmake__(:precious) end) |> 145 | Enum.concat() |> 146 | Enum.map(fn(v) -> {v, System.get_env(v)} end) |> 147 | Enum.filter(fn({_, v}) -> v end) 148 | 149 | ExMake.Cache.save_config(cfg.args, vars) 150 | 151 | pass_end.("Save Configuration Cache") 152 | 153 | pass_go.("Check Manifest Specifications") 154 | 155 | manifest_files = Enum.concat(Enum.map(mods, fn({d, _, m, _}) -> 156 | Enum.map(m.__exmake__(:manifest), fn(file) -> 157 | if !String.valid?(file) do 158 | raise(ExMake.ScriptError, [message: "Manifest file must be a string"]) 159 | end 160 | 161 | Path.join(d, file) 162 | end) 163 | end)) 164 | 165 | pass_end.("Check Manifest Specifications") 166 | 167 | pass_go.("Save Cache Manifest") 168 | 169 | manifest_mods = Enum.map(mods, fn({d, f, _, _}) -> Path.join(d, f) end) 170 | 171 | ExMake.Cache.append_manifest(manifest_mods ++ manifest_files) 172 | 173 | pass_end.("Save Cache Manifest") 174 | 175 | {g, f} 176 | else 177 | pass_go.("Load Module Cache") 178 | 179 | ExMake.Cache.load_mods() 180 | 181 | pass_end.("Load Module Cache") 182 | 183 | pass_go.("Load Environment Cache") 184 | 185 | ExMake.Cache.load_env() 186 | 187 | pass_end.("Load Environment Cache") 188 | 189 | pass_go.("Load Graph Cache") 190 | 191 | g = ExMake.Cache.load_graph() 192 | 193 | pass_end.("Load Graph Cache") 194 | 195 | pass_go.("Load Fallback Cache") 196 | 197 | f = ExMake.Cache.load_fallbacks() 198 | 199 | pass_end.("Load Fallback Cache") 200 | 201 | {g, f} 202 | end 203 | 204 | tgts = Enum.map(cfg.targets, fn(tgt) -> 205 | pass_go.("Locate Vertex (#{tgt})") 206 | 207 | rule = ExMake.Helpers.get_target(g, tgt) 208 | 209 | pass_end.("Locate Vertex (#{tgt})") 210 | 211 | {tgt, rule} 212 | end) 213 | 214 | bad = Enum.find(tgts, fn({_, r}) -> !r end) 215 | 216 | if bad do 217 | # Process fallbacks serially if we have any. 218 | Enum.each(f, fn(r) -> 219 | ExMake.Coordinator.enqueue(r, nil) 220 | 221 | receive do 222 | {:exmake_done, _, _, _} -> :ok 223 | end 224 | end) 225 | 226 | raise(ExMake.UsageError, [message: "Target '#{elem(bad, 0)}' not found"]) 227 | end 228 | 229 | # Now create pruned graphs for each target and process them. 230 | # We have to do this after loading the cached graph because 231 | # the exact layout of the pruned graph depends on the target(s) 232 | # given to ExMake on the command line. 233 | Enum.each(tgts, fn({tgt, rule}) -> 234 | {v, _} = rule 235 | 236 | pass_go.("Minimize DAG (#{tgt})") 237 | 238 | # Eliminate everything else in the graph. 239 | reachable = :digraph_utils.reachable([v], g) 240 | g2 = :digraph_utils.subgraph(g, reachable) 241 | 242 | pass_end.("Minimize DAG (#{tgt})") 243 | 244 | # Process leaves until the graph is empty. If we're running 245 | # in --question mode, only check staleness of files. 246 | if cfg.options[:question] do 247 | process_graph_question(tgt, g2, pass_go, pass_end) 248 | else 249 | pass_go.("Prepare DAG (#{tgt})") 250 | 251 | # Transform the labels into {rule, status} tuples. 252 | Enum.each(:digraph.vertices(g2), fn(v) -> 253 | {_, r} = :digraph.vertex(g2, v) 254 | 255 | :digraph.add_vertex(g2, v, {r, :pending}) 256 | end) 257 | 258 | pass_end.("Prepare DAG (#{tgt})") 259 | 260 | process_graph(tgt, g2, pass_go, pass_end) 261 | end 262 | end) 263 | 264 | if cfg.options[:time] do 265 | ExMake.Coordinator.apply_timer_fn(fn(session) -> 266 | ExMake.Logger.info(ExMake.Timer.format_session(ExMake.Timer.finish_session(session))) 267 | 268 | nil 269 | end) 270 | end 271 | 272 | 0 273 | rescue 274 | [ExMake.StaleError] -> 275 | # This is only raised in --question mode, and just means 276 | # that a rule has stale targets. So simply return 1. 277 | 1 278 | ex -> 279 | ExMake.Logger.error(inspect(ex.__struct__()), Exception.message(ex)) 280 | ExMake.Logger.log_debug(Exception.format_stacktrace(System.stacktrace())) 281 | 282 | # Wait for all remaining jobs to stop. 283 | if (n = Process.get(:exmake_jobs)) > 0 do 284 | ExMake.Logger.log_debug("Waiting for #{n} jobs to exit") 285 | 286 | Enum.each(1 .. n, fn(_) -> 287 | receive do 288 | {:exmake_done, _, _, _} -> :ok 289 | end 290 | end) 291 | end 292 | 293 | 1 294 | end 295 | 296 | File.cd!("..") 297 | 298 | {:reply, code, nil} 299 | end 300 | 301 | @spec construct_graph([{Path.t(), Path.t(), module()}, ...], ((atom()) -> :ok), ((atom()) -> :ok)) :: :digraph.graph() 302 | defp construct_graph(mods, pass_go, pass_end) do 303 | pass_go.("Check Rule Specifications") 304 | 305 | Enum.each(mods, fn({d, f, m}) -> 306 | Enum.each(m.__exmake__(:rules), fn(spec) -> 307 | tgts = spec[:targets] 308 | srcs = spec[:sources] 309 | loc = "#{Path.join(d, f)}:#{elem(spec[:recipe], 2)}" 310 | 311 | if !is_list(tgts) || Enum.any?(tgts, fn(t) -> !String.valid?(t) end) do 312 | raise(ExMake.ScriptError, [message: "#{loc}: Invalid target list; must be a list of strings"]) 313 | end 314 | 315 | if !is_list(srcs) || Enum.any?(srcs, fn(s) -> !String.valid?(s) end) do 316 | raise(ExMake.ScriptError, [message: "#{loc}: Invalid source list; must be a list of strings"]) 317 | end 318 | end) 319 | 320 | Enum.each(m.__exmake__(:tasks), fn(spec) -> 321 | name = spec[:name] 322 | srcs = spec[:sources] 323 | loc = "#{Path.join(d, f)}:#{elem(spec[:recipe], 2)}" 324 | 325 | if !String.valid?(name) do 326 | raise(ExMake.ScriptError, [message: "#{loc}: Invalid task name; must be a string"]) 327 | end 328 | 329 | if !is_list(srcs) || Enum.any?(srcs, fn(s) -> !is_binary(s) || !String.valid?(s) end) do 330 | raise(ExMake.ScriptError, [message: "#{loc}: Invalid source list; must be a list of strings"]) 331 | end 332 | end) 333 | end) 334 | 335 | pass_end.("Check Rule Specifications") 336 | 337 | pass_go.("Sanitize Rule Paths") 338 | 339 | # Make paths relative to the ExMake invocation directory. 340 | rules = Enum.concat(Enum.map(mods, fn({d, _, m}) -> 341 | Enum.map(m.__exmake__(:rules), fn(spec) -> 342 | tgts = Enum.map(spec[:targets], fn(f) -> Path.join(d, f) end) 343 | srcs = Enum.map(spec[:sources], fn(f) -> Path.join(d, f) end) 344 | 345 | [targets: tgts, sources: srcs, recipe: spec[:recipe], directory: d] 346 | end) 347 | end)) 348 | 349 | pass_end.("Sanitize Rule Paths") 350 | 351 | pass_go.("Sanitize Task Paths") 352 | 353 | # Do the same for tasks. 354 | tasks = Enum.concat(Enum.map(mods, fn({d, _, m}) -> 355 | Enum.map(m.__exmake__(:tasks), fn(spec) -> 356 | name = Path.join(d, spec[:name]) 357 | srcs = Enum.map(spec[:sources], fn(f) -> Path.join(d, f) end) 358 | 359 | [name: name, sources: srcs, recipe: spec[:recipe], directory: d] 360 | end) 361 | end)) 362 | 363 | pass_end.("Sanitize Task Paths") 364 | 365 | pass_go.("Check Rule Target Lists") 366 | 367 | target_names = rules |> 368 | Enum.map(fn(x) -> x[:targets] end) |> 369 | Enum.concat() 370 | 371 | target_names = Enum.reduce(target_names, HashSet.new(), fn(n, set) -> 372 | if Set.member?(set, n) do 373 | raise(ExMake.ScriptError, [message: "Multiple rules mention target '#{n}'"]) 374 | end 375 | 376 | Set.put(set, n) 377 | end) 378 | 379 | pass_end.("Check Rule Target Lists") 380 | 381 | pass_go.("Check Task Names") 382 | 383 | task_names = Enum.reduce(tasks, HashSet.new(), fn(p, set) -> 384 | n = p[:name] 385 | 386 | if Set.member?(target_names, n) do 387 | raise(ExMake.ScriptError, [message: "Task name '#{n}' conflicts with a rule"]) 388 | end 389 | 390 | Set.put(set, n) 391 | end) 392 | 393 | pass_end.("Check Task Names") 394 | 395 | pass_go.("Determine Task Sources") 396 | 397 | tasks = Enum.map(tasks, fn(r) -> 398 | srcs = Set.difference(Enum.into(r[:sources], HashSet.new()), task_names) 399 | 400 | Keyword.put(r, :real_sources, srcs) 401 | end) 402 | 403 | pass_end.("Determine Task Sources") 404 | 405 | pass_go.("Create DAG") 406 | 407 | g = :digraph.new([:acyclic]) 408 | 409 | pass_end.("Create DAG") 410 | 411 | pass_go.("Create DAG Vertices") 412 | 413 | # Add the rules to the graph as vertices. 414 | Enum.each(rules, fn(r) -> :digraph.add_vertex(g, :digraph.add_vertex(g), r) end) 415 | Enum.each(tasks, fn(r) -> :digraph.add_vertex(g, :digraph.add_vertex(g), r) end) 416 | 417 | pass_end.("Create DAG Vertices") 418 | 419 | vs = :digraph.vertices(g) 420 | 421 | pass_go.("Create DAG Edges") 422 | 423 | # Construct edges from goals to dependencies. 424 | Enum.each(vs, fn(v) -> 425 | {_, r} = :digraph.vertex(g, v) 426 | 427 | Enum.each(r[:sources], fn(src) -> 428 | dep = Enum.find_value(vs, fn(v2) -> 429 | {_, r2} = :digraph.vertex(g, v2) 430 | 431 | cond do 432 | (t = r2[:targets]) && src in t -> {v2, r2} 433 | (n = r2[:name]) && n == src -> {v2, r2} 434 | true -> nil 435 | end 436 | end) 437 | 438 | if dep do 439 | {v2, r2} = dep 440 | 441 | if r[:targets] && (n = r2[:name]) do 442 | r = inspect(ExMake.Helpers.make_presentable(r)) 443 | 444 | raise(ExMake.ScriptError, [message: "Rule #{r} depends on task '#{n}'"]) 445 | end 446 | 447 | case :digraph.add_edge(g, v, v2) do 448 | {:error, {:bad_edge, path}} -> 449 | [r1, r2] = [:digraph.vertex(g, hd(path)), :digraph.vertex(g, List.last(path))] |> 450 | Enum.map(fn(x) -> elem(x, 1) end) |> 451 | Enum.map(fn(x) -> ExMake.Helpers.make_presentable(x) end) |> 452 | Enum.map(fn(x) -> inspect(x) end) 453 | 454 | raise(ExMake.ScriptError, 455 | [message: "Cyclic dependency detected between\n#{r1}\nand\n#{r2}"]) 456 | _ -> :ok 457 | end 458 | end 459 | end) 460 | end) 461 | 462 | pass_end.("Create DAG Edges") 463 | 464 | g 465 | end 466 | 467 | @spec process_graph(String.t(), :digraph.graph(), ((atom()) -> :ok), ((atom()) -> :ok), non_neg_integer()) :: :ok 468 | defp process_graph(target, graph, pass_go, pass_end, n \\ 0) do 469 | verts = :digraph.vertices(graph) 470 | 471 | if verts != [] do 472 | pass_go.("Compute Leaves (#{target} - #{n})") 473 | 474 | # Compute the leaf vertices. These have no outgoing edges 475 | # and have a status of :pending. 476 | leaves = Enum.filter(verts, fn(v) -> 477 | :digraph.out_degree(graph, v) == 0 && elem(elem(:digraph.vertex(graph, v), 1), 1) == :pending 478 | end) 479 | 480 | pass_end.("Compute Leaves (#{target} - #{n})") 481 | 482 | pass_go.("Enqueue Jobs (#{target} - #{n})") 483 | 484 | # Enqueue jobs for all leaves. 485 | Enum.each(leaves, fn(v) -> 486 | {_, {r, _}} = :digraph.vertex(graph, v) 487 | 488 | ExMake.Logger.log_debug("Enqueuing rule: #{inspect(r)}") 489 | 490 | ExMake.Coordinator.enqueue(r, v) 491 | 492 | :digraph.add_vertex(graph, v, {r, :processing}) 493 | 494 | Process.put(:exmake_jobs, Process.get(:exmake_jobs) + 1) 495 | end) 496 | 497 | pass_end.("Enqueue Jobs (#{target} - #{n})") 498 | 499 | pass_go.("Wait for Job (#{target} - #{n})") 500 | 501 | {ex, v, rule} = receive do 502 | {:exmake_done, r, v, :ok} -> {nil, v, r} 503 | {:exmake_done, r, v, {:throw, val}} -> 504 | {%ExMake.ThrowError{message: "Erlang term was thrown: #{inspect(val)}", value: val}, v, r} 505 | {:exmake_done, r, v, {:raise, ex}} -> {ex, v, r} 506 | end 507 | 508 | ExMake.Logger.log_debug("Job done for rule: #{inspect(rule)}") 509 | 510 | Process.put(:exmake_jobs, Process.get(:exmake_jobs) - 1) 511 | 512 | if ex, do: raise(ex) 513 | 514 | :digraph.del_vertex(graph, v) 515 | 516 | pass_end.("Wait for Job (#{target} - #{n})") 517 | 518 | process_graph(target, graph, pass_go, pass_end, n + 1) 519 | else 520 | # The graph has been reduced to nothing, so we're done. 521 | :ok 522 | end 523 | end 524 | 525 | @spec process_graph_question(String.t(), :digraph.graph(), ((atom()) -> :ok), ((atom()) -> :ok), non_neg_integer()) :: :ok 526 | defp process_graph_question(target, graph, pass_go, pass_end, n \\ 0) do 527 | pass_go.("Compute Leaves (#{target} - #{n})") 528 | 529 | # Compute the leaf vertices. These have no outgoing edges. 530 | leaves = Enum.filter(:digraph.vertices(graph), fn(v) -> :digraph.out_degree(graph, v) == 0 end) 531 | 532 | pass_end.("Compute Leaves (#{target} - #{n})") 533 | 534 | pass_go.("Check Timestamps (#{target} - #{n})") 535 | 536 | Enum.each(leaves, fn(v) -> 537 | {_, r} = :digraph.vertex(graph, v) 538 | 539 | stale = if r[:name] do 540 | ExMake.Logger.warn("'--question' with tasks is meaningless; they are always considered stale") 541 | 542 | true 543 | else 544 | Enum.each(r[:sources], fn(src) -> 545 | if !File.exists?(src) do 546 | raise(ExMake.UsageError, [message: "No rule to make target '#{src}'"]) 547 | end 548 | end) 549 | 550 | src_time = Enum.map(r[:sources], fn(src) -> ExMake.Helpers.last_modified(src) end) |> Enum.max() 551 | tgt_time = Enum.map(r[:targets], fn(tgt) -> ExMake.Helpers.last_modified(tgt) end) |> Enum.min() 552 | 553 | src_time > tgt_time 554 | end 555 | 556 | if stale, do: raise(ExMake.StaleError, [message: "A rule has stale targets: #{r}", 557 | rule: r]) 558 | 559 | :digraph.del_vertex(graph, v) 560 | end) 561 | 562 | pass_end.("Check Timestamps (#{target} - #{n})") 563 | 564 | if :digraph.no_vertices(graph) == 0, do: :ok, else: process_graph_question(target, graph, pass_go, pass_end, n + 1) 565 | end 566 | end 567 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMake.Mixfile do 2 | use Mix.Project 3 | 4 | def project() do 5 | [app: :exmake, 6 | version: String.strip(File.read!("VERSION")), 7 | elixir: "~> 1.0.2", 8 | build_per_environment: false, 9 | escript: [main_module: ExMake.Application, 10 | path: Path.join(["_build", "shared", "lib", "exmake", "ebin", "exmake"]), 11 | emu_args: "-noshell -noinput +B -spp true"]] 12 | end 13 | 14 | def application() do 15 | [applications: [], 16 | mod: {ExMake.Application, []}] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/load_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("test_helper.exs", __DIR__) 2 | 3 | defmodule ExMake.Test.LoadTest do 4 | use ExMake.Test.Case 5 | 6 | test "no module" do 7 | {p, _} = create_fixture("no_module", "NoModule", "", raw: true) 8 | {t, c} = execute_in(p) 9 | 10 | assert c == 1 11 | assert t == "ExMake.LoadError: ./Exmakefile: No module ending in '.Exmakefile' defined" 12 | end 13 | 14 | test "too many modules" do 15 | c = """ 16 | defmodule TooManyModules1.Exmakefile do 17 | end 18 | 19 | defmodule TooManyModules2.Exmakefile do 20 | end 21 | """ 22 | 23 | {p, _} = create_fixture("too_many_modules", "TooManyModules", c, raw: true) 24 | {t, c} = execute_in(p) 25 | 26 | assert c == 1 27 | assert t == "ExMake.LoadError: ./Exmakefile: 2 modules ending in '.Exmakefile' defined" 28 | end 29 | 30 | test "single module" do 31 | s = """ 32 | task "all", 33 | [] do 34 | end 35 | """ 36 | 37 | {p, _} = create_fixture("single_module", "SingleModule", s) 38 | {t, c} = execute_in(p) 39 | 40 | assert c == 0 41 | assert t == "" 42 | end 43 | 44 | test "custom file name" do 45 | s = """ 46 | task "all", 47 | [] do 48 | end 49 | """ 50 | 51 | {p, _} = create_fixture("custom_file_name", "CustomFileName", s, file: "foo.exmake") 52 | {t, c} = execute_in(p, ["-f", "foo.exmake"]) 53 | 54 | assert c == 0 55 | assert t == "" 56 | end 57 | 58 | test "invalid file" do 59 | {p, _} = create_fixture("invalid_file", "InvalidFile", "", file: "invalid_file.exmake") 60 | {t, c} = execute_in(p) 61 | 62 | assert c == 1 63 | assert t == "ExMake.LoadError: ./Exmakefile: Could not load file" 64 | end 65 | 66 | test "compile error" do 67 | {p, _} = create_fixture("compile_error", "CompileError", "a + b") 68 | {t, c} = execute_in(p) 69 | 70 | assert c == 1 71 | assert t =~ ~r/ExMake.LoadError: .*Exmakefile:4: undefined function a\/0/ 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule ExMake.Test.Case do 4 | use ExUnit.CaseTemplate 5 | 6 | using do 7 | quote do 8 | import ExMake.Test.Case 9 | end 10 | end 11 | 12 | defp generate_file(name, contents) do 13 | """ 14 | defmodule #{name}.Exmakefile do 15 | use ExMake.File 16 | 17 | #{contents} 18 | end 19 | """ 20 | end 21 | 22 | def create_fixture(path, name, contents, opts \\ []) do 23 | p = Path.join("tmp", path) 24 | 25 | File.mkdir_p!(p) 26 | 27 | file = Path.join(p, opts[:file] || "Exmakefile") 28 | body = if opts[:raw], do: contents, else: generate_file(name, contents) 29 | 30 | File.write!(file, body) 31 | 32 | {p, file} 33 | end 34 | 35 | def create_file(path, name, contents) do 36 | p = Path.join(path, name) 37 | 38 | File.write!(p, contents) 39 | 40 | p 41 | end 42 | 43 | def execute_in(path, args \\ []) do 44 | File.cd!(path, fn() -> 45 | {opts, rest, _, tail} = ExMake.Application.parse(args) 46 | 47 | if Enum.empty?(rest), do: rest = ["all"] 48 | 49 | :application.set_env(:exmake, :exmake_event_pid, self()) 50 | 51 | cfg = %ExMake.Config{targets: rest, 52 | options: opts, 53 | args: tail} 54 | 55 | ExMake.Coordinator.set_config(cfg) 56 | ExMake.Worker.work() 57 | 58 | recv = fn(recv, acc) -> 59 | receive do 60 | {:exmake_stdout, str} -> recv.(recv, acc <> str) 61 | {:exmake_shutdown, code} -> {acc, code} 62 | end 63 | end 64 | 65 | {text, code} = recv.(recv, "") 66 | 67 | {String.strip(text), code} 68 | end) 69 | end 70 | end 71 | --------------------------------------------------------------------------------