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