1defmodule Mix.Compilers.Erlang do 2 @moduledoc false 3 4 @manifest_vsn 1 5 6 @doc """ 7 Compiles the files in `mappings` with given extensions into 8 the destination, automatically invoking the callback for each 9 stale input and output pair (or for all if `force` is `true`) and 10 removing files that no longer have a source, while keeping the 11 `manifest` up to date. 12 13 `mappings` should be a list of tuples in the form of `{src, dest}` paths. 14 15 ## Options 16 17 * `:force` - forces compilation regardless of modification times 18 19 * `:parallel` - if `true` all files will be compiled in parallel, 20 otherwise the given list of source file names will be compiled 21 in parallel, all other files are compiled serially before the 22 parallel files 23 24 ## Examples 25 26 For example, a simple compiler for Lisp Flavored Erlang 27 would be implemented like: 28 29 manifest = Path.join(Mix.Project.manifest_path(), "compile.lfe") 30 dest = Mix.Project.compile_path() 31 32 compile(manifest, [{"src", dest}], :lfe, :beam, opts, fn input, output -> 33 :lfe_comp.file( 34 to_erl_file(input), 35 [{:outdir, Path.dirname(output)}, :return, :report] 36 ) 37 end) 38 39 The command above will: 40 41 1. look for files ending with the `lfe` extension in `src` path 42 and their `beam` counterpart in `ebin` path 43 44 2. for each stale file (or for all if `force` is `true`), 45 invoke the callback passing the calculated input 46 and output 47 48 3. update the manifest with the newly compiled outputs 49 50 4. remove any output in the manifest that does not 51 have an equivalent source 52 53 The callback must return `{:ok, term, warnings}` or 54 `{:error, errors, warnings}` in case of error. This function returns 55 `{status, diagnostics}` as specified in `Mix.Task.Compiler`. 56 """ 57 def compile(manifest, mappings, src_ext, dest_ext, opts, callback) when is_list(opts) do 58 force = opts[:force] 59 60 files = 61 for {src, dest} <- mappings, 62 target <- extract_targets(src, src_ext, dest, dest_ext, force), 63 do: target 64 65 compile(manifest, files, src_ext, opts, callback) 66 end 67 68 def compile(manifest, mappings, src_ext, dest_ext, force, callback) 69 when is_boolean(force) or is_nil(force) do 70 IO.warn( 71 "Mix.Compilers.Erlang.compile/6 with a boolean or nil as 5th argument is deprecated, " <> 72 "please pass [force: true] or [] instead" 73 ) 74 75 compile(manifest, mappings, src_ext, dest_ext, [force: force], callback) 76 end 77 78 @doc """ 79 Compiles the given `mappings`. 80 81 `mappings` should be a list of tuples in the form of `{src, dest}`. 82 83 A `manifest` file and a `callback` to be invoked for each src/dest pair 84 must be given. A src/dest pair where destination is `nil` is considered 85 to be up to date and won't be (re-)compiled. 86 87 ## Options 88 89 * `:force` - forces compilation regardless of modification times 90 91 * `:parallel` - if `true` all files will be compiled in parallel, 92 otherwise the given list of source file names will be compiled 93 in parallel, all other files are compiled serially before the 94 parallel files 95 96 """ 97 def compile(manifest, mappings, opts \\ [], callback) do 98 compile(manifest, mappings, :erl, opts, callback) 99 end 100 101 defp compile(manifest, mappings, ext, opts, callback) do 102 stale = for {:stale, src, dest} <- mappings, do: {src, dest} 103 104 # Get the previous entries from the manifest 105 timestamp = System.os_time(:second) 106 entries = read_manifest(manifest) 107 108 # Files to remove are the ones in the manifest 109 # but they no longer have a source 110 removed = 111 Enum.filter(entries, fn {dest, _} -> 112 not Enum.any?(mappings, fn {_status, _mapping_src, mapping_dest} -> 113 mapping_dest == dest 114 end) 115 end) 116 |> Enum.map(&elem(&1, 0)) 117 118 # Remove manifest entries with no source 119 Enum.each(removed, &File.rm/1) 120 verbose = opts[:verbose] 121 122 # Clear stale and removed files from manifest 123 entries = 124 Enum.reject(entries, fn {dest, _warnings} -> 125 dest in removed || Enum.any?(stale, fn {_, stale_dest} -> dest == stale_dest end) 126 end) 127 128 if opts[:all_warnings], do: show_warnings(entries) 129 130 if stale == [] && removed == [] do 131 {:noop, manifest_warnings(entries)} 132 else 133 Mix.Utils.compiling_n(length(stale), ext) 134 Mix.Project.ensure_structure() 135 136 # Let's prepend the newly created path so compiled files 137 # can be accessed still during compilation (for behaviours 138 # and what not). 139 Code.prepend_path(Mix.Project.compile_path()) 140 141 {parallel, serial} = 142 case opts[:parallel] || false do 143 true -> {stale, []} 144 false -> {[], stale} 145 parallel -> Enum.split_with(stale, fn {source, _target} -> source in parallel end) 146 end 147 148 serial_results = Enum.map(serial, &do_compile(&1, callback, timestamp, verbose)) 149 150 parallel_results = 151 parallel 152 |> Task.async_stream(&do_compile(&1, callback, timestamp, verbose), 153 timeout: :infinity, 154 ordered: false 155 ) 156 |> Enum.map(fn {:ok, result} -> result end) 157 158 # Compile stale files and print the results 159 {status, new_entries, warnings, errors} = 160 Enum.reduce(serial_results ++ parallel_results, {:ok, [], [], []}, &combine_results/2) 161 162 write_manifest(manifest, entries ++ new_entries, timestamp) 163 164 # Return status and diagnostics 165 warnings = manifest_warnings(entries) ++ to_diagnostics(warnings, :warning) 166 167 case status do 168 :ok -> 169 {:ok, warnings} 170 171 :error -> 172 errors = to_diagnostics(errors, :error) 173 {:error, warnings ++ errors} 174 end 175 end 176 end 177 178 @doc """ 179 Ensures the native OTP application is available. 180 """ 181 def ensure_application!(app, input) do 182 case Application.ensure_all_started(app) do 183 {:ok, _} -> 184 :ok 185 186 {:error, _} -> 187 Mix.raise( 188 "Could not compile #{inspect(Path.relative_to_cwd(input))} because " <> 189 "the application \"#{app}\" could not be found. This may happen if " <> 190 "your package manager broke Erlang into multiple packages and may " <> 191 "be fixed by installing the missing \"erlang-dev\" and \"erlang-#{app}\" packages" 192 ) 193 end 194 end 195 196 @doc """ 197 Removes compiled files for the given `manifest`. 198 """ 199 def clean(manifest) do 200 Enum.each(read_manifest(manifest), fn {file, _} -> File.rm(file) end) 201 File.rm(manifest) 202 end 203 204 @doc """ 205 Converts the given `file` to a format accepted by 206 the Erlang compilation tools. 207 """ 208 def to_erl_file(file) do 209 to_charlist(file) 210 end 211 212 @doc """ 213 Asserts that the `:erlc_paths` configuration option that many Mix tasks 214 rely on is valid. 215 216 Raises a `Mix.Error` exception if the option is not valid, returns `:ok` 217 otherwise. 218 """ 219 def assert_valid_erlc_paths(erlc_paths) do 220 if is_list(erlc_paths) do 221 :ok 222 else 223 Mix.raise(":erlc_paths should be a list of paths, got: #{inspect(erlc_paths)}") 224 end 225 end 226 227 defp extract_targets(src_dir, src_ext, dest_dir, dest_ext, force) do 228 files = Mix.Utils.extract_files(List.wrap(src_dir), List.wrap(src_ext)) 229 230 for file <- files do 231 module = module_from_artifact(file) 232 target = Path.join(dest_dir, module <> "." <> to_string(dest_ext)) 233 234 if force || Mix.Utils.stale?([file], [target]) do 235 {:stale, file, target} 236 else 237 {:ok, file, target} 238 end 239 end 240 end 241 242 defp module_from_artifact(artifact) do 243 artifact |> Path.basename() |> Path.rootname() 244 end 245 246 # The manifest file contains a list of {dest, warnings} tuples 247 defp read_manifest(file) do 248 try do 249 file |> File.read!() |> :erlang.binary_to_term() 250 rescue 251 _ -> [] 252 else 253 {@manifest_vsn, data} when is_list(data) -> data 254 _ -> [] 255 end 256 end 257 258 defp write_manifest(file, entries, timestamp) do 259 File.mkdir_p!(Path.dirname(file)) 260 File.write!(file, :erlang.term_to_binary({@manifest_vsn, entries})) 261 File.touch!(file, timestamp) 262 end 263 264 defp do_compile({input, output}, callback, timestamp, verbose) do 265 case callback.(input, output) do 266 {:ok, _, warnings} -> 267 File.touch!(output, timestamp) 268 verbose && Mix.shell().info("Compiled #{input}") 269 {:ok, [{output, warnings}], warnings, []} 270 271 {:error, errors, warnings} -> 272 {:error, [], warnings, errors} 273 274 {:ok, _} -> 275 IO.warn( 276 "returning {:ok, contents} in the Mix.Compilers.Erlang.compile/6 callback is deprecated " <> 277 "The callback should return {:ok, contents, warnings} or {:error, errors, warnings}" 278 ) 279 280 {:ok, [], [], []} 281 282 :error -> 283 IO.warn( 284 "returning :error in the Mix.Compilers.Erlang.compile/6 callback is deprecated " <> 285 "The callback should return {:ok, contents, warnings} or {:error, errors, warnings}" 286 ) 287 288 {:error, [], [], []} 289 end 290 end 291 292 defp combine_results(result1, result2) do 293 {status1, new_entries1, warnings1, errors1} = result1 294 {status2, new_entries2, warnings2, errors2} = result2 295 status = if status1 == :error or status2 == :error, do: :error, else: :ok 296 {status, new_entries1 ++ new_entries2, warnings1 ++ warnings2, errors1 ++ errors2} 297 end 298 299 defp manifest_warnings(entries) do 300 Enum.flat_map(entries, fn {_, warnings} -> 301 to_diagnostics(warnings, :warning) 302 end) 303 end 304 305 defp to_diagnostics(warnings_or_errors, severity) do 306 for {file, issues} <- warnings_or_errors, 307 {line, module, data} <- issues do 308 position = line(line) 309 310 %Mix.Task.Compiler.Diagnostic{ 311 file: Path.absname(file), 312 position: position, 313 message: to_string(module.format_error(data)), 314 severity: severity, 315 compiler_name: to_string(module), 316 details: data 317 } 318 end 319 end 320 321 defp show_warnings(entries) do 322 for {_, warnings} <- entries, 323 {file, issues} <- warnings, 324 {line, module, message} <- issues do 325 IO.puts("#{file}:#{line(line)}: Warning: #{module.format_error(message)}") 326 end 327 end 328 329 defp line({line, _column}) when is_integer(line) and line >= 1, do: line 330 # TODO: remove when we require OTP 24 331 defp line(line) when is_integer(line) and line >= 1, do: line 332 defp line(_), do: nil 333end 334