You already know that IEx is the go-to utility to play with Elixir code. It also has a secret and dark second life as a debugger. It isn’t fancy, but it lets you get into a running program and examine the environment.
You enter the debugger when running Elixir code hits a breakpoint. There are two ways of creating a breakpoint. One works by adding calls into the code you want to debug. The other is initiated from inside IEx. We’ll look at both using the following (buggy) code:
| defmodule Buggy do |
| def parse_header( |
| << |
| format::integer-16, |
| tracks::integer-16, |
» | division::integer-16 |
| >> |
| ) do |
| |
| IO.puts "format: #{format}" |
| IO.puts "tracks: #{tracks}" |
| IO.puts "division: #{decode(division)}" |
| end |
| |
| def decode(<< 1::1, beats::15 >>) do |
| "♩ = #{beats}" |
| end |
| |
| def decode(<< 0::1, fps::7, beats::8 >>) do |
| "#{-fps} fps, #{beats}/frame" |
| end |
| end |
This code is supposed to decode the data part of a MIDI header frame. This contains three 16-bit fields: the format, the number of tracks, and the time division. This last field comes in one of two formats:
The parse_header/1 function splits the overall header into the three fields, and the decode/1 function works out which type of time division we have.
Let’s run it, using a sample header I extracted from a MIDI file.
| $ iex -S mix |
| iex> header = << 0, 1, 0, 8, 0, 120 >> |
| <<0, 1, 0, 8, 0, 120>> |
| iex> Buggy.parse_header header |
| format: 1 |
| tracks: 8 |
| ** (FunctionClauseError) no function clause matching in Buggy.decode/1 |
| iex> |
Oh no! That was totally unexpected. It looks like we’re not passing the correct value to decode. Let’s use the debugger to find out what’s going on.
We can add a breakpoint to our source code using the pry function. For example, to stop our code just before we call decode we could write this:
| def parse_header( |
| << |
| format::integer-16, |
| tracks::integer-16, |
| division::integer-16 |
| >> |
| ) do |
| |
» | require IEx; IEx.pry |
| IO.puts "format: #{format}" |
| IO.puts "tracks: #{tracks}" |
| IO.puts "division: #{decode(division)}" |
| end |
(We need the require because pry is a macro.)
Let’s try the code now:
| $ iex -S mix |
| iex> Buggy.parse_header << 0, 1, 0, 8, 0, 120 >> |
| Break reached: Buggy.parse_header/1 (lib/buggy.ex:11) |
| |
| 9: |
» | 10: require IEx; IEx.pry |
| 11: IO.puts "format: #{format}" |
| |
| pry> binding |
| [division: 120, format: 1, tracks: 8] |
| iex> continue() |
| format: 1 |
| tracks: 8 |
| ** (FunctionClauseError) no function clause matching in Buggy.decode/1 |
We reached the breakpoint, and IEx entered pry mode. It showed us the function we were in as well as the source lines surrounding the breakpoint.
At this point, IEx is running in the context of this function, so a call to binding shows the local variables. The value in the division function is 120, but that isn’t matching either of the parameters to decode.
Aha! decode is expecting a binary, not an integer. Let’s fix our code:
| def parse_header( |
| << |
| format::integer-16, |
| tracks::integer-16, |
» | division::bits-16 |
| >> |
| ) do |
| ... |
The pry call is still in there, so let’s recompile and try again:
| iex> r Buggy |
| {:reloaded, Buggy, [Buggy]} |
| iex> Buggy.parse_header << 0, 1, 0, 8, 0, 120 >> |
| Break reached: Buggy.parse_header/1 (lib/buggy.ex:12) |
| |
| 10: ) do |
| 11: |
| 12: require IEx; IEx.pry |
| 13: IO.puts "format: #{format}" |
| 14: IO.puts "tracks: #{tracks}" |
| |
| pry> binding |
| [division: <<0, 120>>, format: 1, tracks: 8] |
| pry> continue |
| format: 1 |
| tracks: 8 |
| division: 0 fps, 120/frame |
| :ok |
Now the division is a binary, and when we continue the code runs and outputs the header fields. Except…it’s parsing the time division as if it were the SMPTE version, and not the beats/quarter note version.
The second way to create a breakpoint doesn’t involve any code changes. Instead, you can use the break! command inside IEx to add a breakpoint on any public function. Let’s remove the call to pry and run the code again. Inside IEx we’ll add a breakpoint on the decode function:
| iex> require IEx |
| IEx |
| iex> break! Buggy.decode/1 |
| 1 |
| iex> breaks |
| |
| ID Module.function/arity Pending stops |
| ---- ----------------------- --------------- |
| 1 Buggy.decode/1 1 |
| |
| iex> Buggy.parse_header << 0, 1, 0, 8, 0, 120 >> |
| format: 1 |
| tracks: 8 |
| Break reached: Buggy.decode/1 (lib/buggy.ex:21) |
| |
| 19: end |
| 20: |
| 21: def decode(<< 0::1, fps::7, beats::8 >>) do |
| 22: "#{-fps} fps, #{beats}/frame" |
| 23: end |
| |
| pry> binding |
| [division: <<0, 120>>, format: 1, tracks: 8] |
We hit the breakpoint, and we are indeed matching the wrong version of the decode function when we pass it 0000000001111000. Ah, that’s because I’m discriminating based on the value of the top bit, and I got it the wrong way around: the SMPTE version should be
| def decode(<< 1::1, fps::7, beats::8 >>) do |
and the beats version should be
| def decode(<< 0::1, beats::15 >>) do |
There’s lots more functionality in the debugger. You can start by getting help for IEx.break/4.
I have a confession to make. The only time I use the Elixir breakpoint facility is when I work on this section of the book. If I have to add code to the source to break in the middle of a function, then I can just raise an exception there instead to get the information I need. And the fact that I can only break at public functions from inside IEx means that I can’t get the kind of granularity I need to diagnose issues, because 90% of my functions are private.
However, I’m an old curmudgeon—my favorite editor is a card punch. Don’t let my lack of enthusiasm stop you from trying the debugger.