Rewriting imports to aliases with compilation tracers

UPDATE #1: Thanks to feedback from @kleinernik, we’ve changed the script to a Mix task to avoid warnings on protocol consolidation.

UPDATE #2: Elixir v1.11+ will no longer consider imports as compile-time dependencies. Therefore converting imports to aliases is no longer strictly necessary for improving recompilation times. This article, however, can still be useful for those interested in converting imports to aliases for code readability reasons or for those willing to learn more about compilation tracers.

This article will explore how we used compilation tracers to implement a tool that automatically rewrote imports to aliases on the Hex.pm codebase, called import2alias.

For example, to replace HexpmWeb.ViewHelpers imported calls with ViewHelpers, we used the script like this:

cd /path/to/hexpm
mkdir -p lib/mix/tasks

curl https://gist.githubusercontent.com/wojtekmach/4e04cbda82ba88af3f84c44ec746b7ca/raw/import2alias.ex > lib/mix/tasks/import2alias.ex

curl https://gist.githubusercontent.com/wojtekmach/4e04cbda82ba88af3f84c44ec746b7ca/raw/lib_import2alias.ex > lib_import2alias.ex

elixir -r lib_import2alias.ex -S mix import2alias HexpmWeb.ViewHelpers ViewHelpers

As you can see, the script is actually quite tiny! In this blog post we’ll look under the hood and discuss some other compilation improvements we’ve recently made. Let’s get started.

Compilation tracers

import2alias is built on top of compilation tracers, a feature introduced in Elixir v1.10. Per Elixir Code documentation:

A tracer is a module that implements the trace/2 function. The function receives the event name as first argument and Macro.Env as second and it must return :ok.

And here are some example events:

  • {:import, meta, module, opts} - traced whenever module is imported. meta is the import AST metadata and opts are the import options.

  • {:imported_function, meta, module, name, arity} and {:imported_macro, meta, module, name, arity} - traced whenever an imported function or macro is invoked. (…)

  • {:local_function, meta, name, arity} and {:local_macro, meta, name, arity} - traced whenever a local function or macro is referenced. (…)

etc.

Here’s the tracer we wrote for our import2alias script:

defmodule Import2Alias.CallerTracer do
  def trace({:imported_function, meta, module, name, arity}, env) do
    Import2Alias.Server.record(env.file, meta[:line], meta[:column], module, name, arity)
    :ok
  end

  def trace(_event, _env) do
    :ok
  end
end

We are only interested in :imported_function events, we record file/line/column and module/name/arity for further processing and ignore remaining events.

We could do the processing in the trace/2 function directly but the recommendation is to do there as least work as possible because it slows down the compilation. Thus, we save the work for further processing. Import2Alias.Server is an Agent that filters imported calls and groups them by source filenames. This way we’d rewrite any given source file just once:

for {file, entries} <- entries do
  lines = File.read!(file) |> String.split("\n")

  lines =
    Enum.reduce(entries, lines, fn entry, acc ->
      {line, column, module, name, arity} = entry

      List.update_at(acc, line - 1, fn string ->
        # ...
      end)
    end)

  File.write!(file, Enum.join(lines, "\n"))
end

If we have the column information, we rewrite the line becasue we know exactly where the imported call started and we rewrite it to be an aliased call.

if column do
  pre = String.slice(string, 0, column - 1)
  offset = column - 1 + String.length("#{name}")
  post = String.slice(string, offset, String.length(string))
  pre <> "#{inspect(alias)}.#{name}" <> post
else
  # print warning
end

and this results in e.g.:

-  <%= pretty_date(last_use.used_at) %> ...
+  <%= ViewHelpers.pretty_date(last_use.used_at) %> ...

and that’s it!

However, you may have noticed that we explicitly checked if the column information is available. Why wouldn’t we have the column information? This brings us to…

Other compiler improvements

To get precise information where a function is called, not only at which line but also at which column, we’ve set this compile option:

Code.put_compile_option(:parser_options, [columns: true])

This worked fine in .ex files but not in .eex files, the EEx engine uses its own compiler. We’ve changed EEx.Compiler to properly track column information and use that in error messages.

EEx templates can also be directly embedded in Elixir modules, such as using Phoenix ~E or Phoenix LiveView ~L sigils:

defmodule AppWeb.ThermostatLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~L"""
    Current temperature: <%= pretty_temperature @temperature %>
    """
  end
end

To handle that, we’ve changed Elixir compiler to track indentation of heredoc blocks and used that in EEx, Phoenix.HTML ~E, and Phoenix.LiveView ~L.

To take advantage of these improvements you need to wait for Elixir v1.11 or use a version manager, such as asdf install elixir master, to get the latest.

These compiler changes, besides making import2alias more useful, should give more capabilities to existing and future tooling and allow more accurate stacktraces, editor integrations, and more. Perhaps that is the biggest win from all of this recent work after all!

Summary

In this article we’ve looked at the import2alias script, how it was built on top of compilation tracers, and about some of our recent compiler changes that made that more reliable. We are looking forward to hearing what you’ve built with compilation tracers, happy hacking!