DEV Community ๐Ÿ‘ฉโ€๐Ÿ’ป๐Ÿ‘จโ€๐Ÿ’ป

bakenator
bakenator

Posted on

A Deep Dive into Mix

This post started out its life as a short example of bubble sort in elixir. But I encountered an interesting message when I tried to run my script with mix run bubble.exs

** (Mix) Cannot execute "mix run" without a Mix.Project, please ensure you are running Mix in a directory with a mix.exs file or pass the --no-mix-exs flag

In this case since the file is a true one page script with no dependencies, I could have run elixir bubble.exs

But this error message caught my attention and led to some bigger questions. How does this flag work? How does Mix work?

Mix Tasks

In this example we are using mix run so I thought I would try to track down the run command in the mix source code. Mix is a sub application within the Elixir source code. So I went to the Elixir repo and tried a search for "def run".
Repo: https://github.com/elixir-lang/elixir/tree/master/lib/mix/lib

After poking around for a while I got lucky and found the module Mix.Tasks.Run. It appears that the "run" command is a Task within Mix. This fits with the high level overview of mix provided in the top level Mix module file.

Basically each of the default commands that you can provide to Mix in the command line is a separate task. Each task has a corresponding module with a "run" method that is called.

Tracing a Mix Command

Now that we know Mix is passing our run command to the Mix.Tasks.Run module, lets dig in.

The file starts with a catch all run method:

  def run(args) do
    {opts, head} =
      OptionParser.parse_head!(
        args,
        aliases: [r: :require, p: :parallel, e: :eval, c: :config],
        strict: [
          parallel: :boolean,
          require: :keep,
          eval: :keep,
          config: :keep,
          mix_exs: :boolean,
          halt: :boolean,
          ...
        ]
      )

    run(args, opts, head, &Code.eval_string/1, &Code.require_file/1)
    unless Keyword.get(opts, :halt, true), do: System.no_halt(true)
    Mix.Task.reenable("run")
    :ok
  end

This method parses the command line options and passes the parsed data to a run method with more parameters. Interesting that the --no-halt option is handled at this level.

Also deep in the OptionParser file, I found this line interesting.
defp tag_option("no-" <> option = original, config) do
The no- syntax is used as a switch for options. I always assumed that --halt and --no-halt would just be identified as separate strings

Next up we have the run method with expanded parameters.
def run(args, opts, head, expr_evaluator, file_evaluator) do
First it does some more processing of the command line args, then we reach this crucial code block.

cond do
      Mix.Project.get() ->
        Mix.Task.run("app.start", args)

      "--no-mix-exs" in args ->
        :ok

      true ->
        Mix.raise(
          "Cannot execute \"mix run\" without a Mix.Project, " <>
            "please ensure you are running Mix in a directory with a mix.exs file " <>
            "or pass the --no-mix-exs option"
        )
    end

Here we can see exactly where the --no-mix-exs option comes into play.

First it checks if we are in a normal Mix project directory with Mix.Project.get(). If that returns false it checks the args for --no-mix-exs. Finally it will throw the error we saw at the start. Mystery Solved!

Going Deeper

Mix.Project.get()

It took me forever to track down how this thing works!

When the Mix app is first invoked the Mix.CLI module tries to compile the mix.exs file in the current directory. By default that file will have the line use Mix.Project. Once the file is compiled it runs this post compile hook in Mix.Project

  def __after_compile__(env, _binary) do
    push(env.module, env.file)
  end

This hook pushes the env.module name onto an internal stack that is checked in the Mix.Project.get() command.

Mix.Task.run("app.start", args)

If a mix.exs file is available in the current directory, the run function moves onto the line Mix.Task.run("app.start", args). This starts a new task, as you may guess the task is in the Mix.Tasks.App.Start module.

This module starts in its run method and progresses down several functions until it reaches the line case Application.start(app, type) do This passes the app down to the erlang call :application.start(app, type) to start the app.

Back to Mix.Tasks.Run.run

After checking if we are in a project directory, this function still has a few more tricks up its sleeve.

    process_load(opts, expr_evaluator)

    if file do
      if File.regular?(file) do
        file_evaluator.(file)
      else
        Mix.raise("No such file: #{file}")
      end
    end

I wanted to include this because I think it is especially neat. If you run a command like mix run -e "IO.inspect 123" Mix will run the inline code. This happens in the process_load function. It checks to see if the -e or eval opts were provided and then uses the Code.eval_string/1 function to run the code on the fly.

Finally Mix will run any individual file given in the command line using the &Code.require_file/1 function.

Wrap Up

Thanks for making it this far. It was a challenge to track down exactly what was going on in the Mix app, but it was well worth the effort. Mix is probably the most used Elixir app there is!

Now that you know all about Mix Tasks, maybe you will be inspired to write your own? https://dev.to/drumusician/things-you-could-do-with-mix-2ni3

Coding in Elixir is fun!

Top comments (0)

๐Ÿ› See a bug on this page?

Join our team and help us fix it. We're hiring for a Senior Full Stack Engineer โ€” Head here to learn more and apply.