Skip to content

First implementation of Kernel.dbg/2 #11974

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jul 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions lib/elixir/lib/kernel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5731,6 +5731,88 @@ defmodule Kernel do
end
end

@doc """
Debugs the given `code`.

`dbg/2` can be used to debug the given `code` through a configurable debug function.
It returns the result of the given code.

## Examples

Let's take this call to `dbg/2`:

dbg(Atom.to_string(:debugging))
#=> "debugging"

It returns the string `"debugging"`, which is the result of the `Atom.to_string/1` call.
Additionally, the call above prints:

[my_file.ex:10: MyMod.my_fun/0]
Atom.to_string(:debugging) #=> "debugging"

The default debugging function prints additional debugging info when dealing with
pipelines. It prints the values at every "step" of the pipeline.

"Elixir is cool!"
|> String.trim_trailing("!")
|> String.split()
|> List.first()
|> dbg()
#=> "Elixir"

The code above prints:

[my_file.ex:10: MyMod.my_fun/0]
"Elixir is cool!" #=> "Elixir is cool!"
|> String.trim_trailing("!") #=> "Elixir is cool"
|> String.split() #=> ["Elixir", "is", "cool"]
|> List.first() #=> "Elixir"

## Configuring the debug function

One of the benefits of `dbg/2` is that its debugging logic is configurable,
allowing tools to extend `dbg` with enhanced behaviour. The debug function
can be configured at compile time through the `:dbg_callback` key of the `:elixir`
application. The debug function must be a `{module, function, args}` tuple.
The `function` function in `module` will be invoked with three arguments
*prepended* to `args`:

1. The AST of `code`
2. The AST of `options`
3. The `Macro.Env` environment of where `dbg/2` is invoked

Whatever is returned by the debug function is then the return value of `dbg/2`. The
debug function is invoked at compile time.

Here's a simple example:

defmodule MyMod do
def debug_fun(code, options, caller, device) do
quote do
result = unquote(code)
IO.inspect(unquote(device), result, label: unquote(Macro.to_string(code)))
end
end
end

To configure the debug function:

# In config/config.exs
config :elixir, :dbg_callback, {MyMod, :debug_fun, [:stdio]}

### Default debug function

By default, the debug function we use is `Macro.dbg/3`. It just prints
information about the code to standard output and returns the value
returned by evaluating `code`. `options` are used to control how terms
are inspected. They are the same options accepted by `inspect/2`.
"""
@doc since: "1.14.0"
defmacro dbg(code, options \\ []) do
{mod, fun, args} = Application.get_env(:elixir, :dbg_callback, {Macro, :dbg, []})
apply(mod, fun, [code, options, __CALLER__ | args])
end

## Sigils

@doc ~S"""
Expand Down
134 changes: 134 additions & 0 deletions lib/elixir/lib/macro.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2397,4 +2397,138 @@ defmodule Macro do
defp trim_leading_while_valid_identifier(other) do
other
end

@doc """
Default backend for `Kernel.dbg/2`.

This function provides a default backend for `Kernel.dbg/2`. See the
`Kernel.dbg/2` documentation for more information.

This function:

* prints information about the given `env`
* prints information about `code` and its returned value (using `opts` to inspect terms)
* returns the value returned by evaluating `code`

You can call this function directly to build `Kernel.dbg/2` backends that fall back
to this function.
"""
@doc since: "1.14.0"
@spec dbg(t, t, Macro.Env.t()) :: t
def dbg(code, options, %Macro.Env{} = env) do
header = dbg_format_header(env)

quote do
to_debug = unquote(dbg_ast_to_debuggable(code))
unquote(__MODULE__).__dbg__(unquote(header), to_debug, unquote(options))
end
end

# Pipelines.
defp dbg_ast_to_debuggable({:|>, _meta, _args} = pipe_ast) do
value_var = Macro.unique_var(:value, __MODULE__)
values_acc_var = Macro.unique_var(:values, __MODULE__)

[start_ast | rest_asts] = asts = for {ast, 0} <- unpipe(pipe_ast), do: ast
rest_asts = Enum.map(rest_asts, &pipe(value_var, &1, 0))

string_asts = Enum.map(asts, &to_string/1)

initial_acc =
quote do
unquote(value_var) = unquote(start_ast)
unquote(values_acc_var) = [unquote(value_var)]
end

values_ast =
for step_ast <- rest_asts, reduce: initial_acc do
ast_acc ->
quote do
unquote(ast_acc)
unquote(value_var) = unquote(step_ast)
unquote(values_acc_var) = [unquote(value_var) | unquote(values_acc_var)]
end
end

quote do
unquote(values_ast)
{:pipe, unquote(string_asts), Enum.reverse(unquote(values_acc_var))}
end
end

# Any other AST.
defp dbg_ast_to_debuggable(ast) do
quote do: {:value, unquote(to_string(ast)), unquote(ast)}
end

# Made public to be called from Macro.dbg/3, so that we generate as little code
# as possible and call out into a function as soon as we can.
@doc false
def __dbg__(header_string, to_debug, options) do
syntax_colors = if IO.ANSI.enabled?(), do: dbg_default_syntax_colors(), else: []
options = Keyword.merge([width: 80, pretty: true, syntax_colors: syntax_colors], options)

{formatted, result} = dbg_format_ast_to_debug(to_debug, options)

formatted = [
:cyan,
:italic,
header_string,
:reset,
"\n",
formatted,
"\n\n"
]

ansi_enabled? = options[:syntax_colors] != []
:ok = IO.write(IO.ANSI.format(formatted, ansi_enabled?))

result
end

defp dbg_format_ast_to_debug({:pipe, code_asts, values}, options) do
result = List.last(values)

formatted =
Enum.map(Enum.zip(code_asts, values), fn {code_ast, value} ->
[
:faint,
"|> ",
:reset,
dbg_format_ast(code_ast),
" ",
inspect(value, options),
?\n
]
end)

{formatted, result}
end

defp dbg_format_ast_to_debug({:value, code_ast, value}, options) do
{[dbg_format_ast(code_ast), " ", inspect(value, options)], value}
end

defp dbg_format_header(env) do
env = Map.update!(env, :file, &(&1 && Path.relative_to_cwd(&1)))
[stacktrace_entry] = Macro.Env.stacktrace(env)
"[" <> Exception.format_stacktrace_entry(stacktrace_entry) <> "]"
end

defp dbg_format_ast(ast) do
[:bright, ast, :reset, :faint, " #=>", :reset]
end

defp dbg_default_syntax_colors do
[
atom: :cyan,
string: :green,
list: :default_color,
boolean: :magenta,
nil: :magenta,
tuple: :default_color,
binary: :default_color,
map: :default_color
]
end
end
25 changes: 25 additions & 0 deletions lib/elixir/test/elixir/kernel_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1456,4 +1456,29 @@ defmodule KernelTest do
Code.eval_string(~s{~U[2015-01-13 13:00:07+00:30]})
end
end

describe "dbg/2" do
import ExUnit.CaptureIO

test "prints the given expression and returns its value" do
output = capture_io(fn -> assert dbg(List.duplicate(:foo, 3)) == [:foo, :foo, :foo] end)
assert output =~ "kernel_test.exs"
assert output =~ "KernelTest"
assert output =~ "List.duplicate(:foo, 3)"
assert output =~ ":foo"
end

test "doesn't print any colors if :syntax_colors is []" do
output =
capture_io(fn ->
assert dbg(List.duplicate(:foo, 3), syntax_colors: []) == [:foo, :foo, :foo]
end)

assert output =~ "kernel_test.exs"
assert output =~ "KernelTest."
assert output =~ "List.duplicate(:foo, 3)"
assert output =~ "[:foo, :foo, :foo]"
refute output =~ "\\e["
end
end
end
79 changes: 79 additions & 0 deletions lib/elixir/test/elixir/macro_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,85 @@ defmodule MacroTest do
assert Macro.var(:foo, Other) == {:foo, [], Other}
end

describe "dbg/3" do
defmacrop dbg_format(ast, options \\ quote(do: [syntax_colors: []])) do
quote do
ExUnit.CaptureIO.with_io(fn ->
unquote(Macro.dbg(ast, options, __CALLER__))
end)
end
end
Comment on lines +273 to +279
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opted to avoid the "no I/O" testing strategy for now in favor of simplicity. Testing with capturing I/O lets us keep the code in Macro simpler, with less functions involved. This should be fast enough for now. If we add a lot more tests, we can always go back to a purely-functional testing approach by reworking the new Macro functions slightly.


test "with a simple expression" do
{result, formatted} = dbg_format(1 + 1)
assert result == 2
assert formatted =~ "1 + 1 #=> 2"
end

test "with variables" do
my_var = 1 + 1
{result, formatted} = dbg_format(my_var)
assert result == 2
assert formatted =~ "my_var #=> 2"
end

test "with a function call" do
{result, formatted} = dbg_format(Atom.to_string(:foo))

assert result == "foo"
assert formatted =~ ~s[Atom.to_string(:foo) #=> "foo"]
end

test "with a multiline input" do
{result, formatted} =
dbg_format(
case 1 + 1 do
2 -> :two
_other -> :math_is_broken
end
)

assert result == :two

assert formatted =~ """
case 1 + 1 do
2 -> :two
_other -> :math_is_broken
end #=> :two
"""
end

test "with a pipeline" do
{result, formatted} = dbg_format([:a, :b, :c] |> tl() |> tl |> Kernel.hd())
assert result == :c

assert formatted =~ "macro_test.exs"

assert formatted =~ """
[:a, :b, :c] #=> [:a, :b, :c]
|> tl() #=> [:b, :c]
|> tl #=> [:c]
|> Kernel.hd() #=> :c
"""
end

test "with \"syntax_colors: []\" it doesn't print any color sequences" do
{_result, formatted} = dbg_format("hello")
refute formatted =~ "\e["
end

test "with \"syntax_colors: [...]\" it forces color sequences" do
{_result, formatted} = dbg_format("hello", syntax_colors: [string: :cyan])
assert formatted =~ IO.iodata_to_binary(IO.ANSI.format([:cyan, ~s("hello")]))
end

test "forwards options to the underlying inspect calls" do
value = 'hello'
assert {^value, formatted} = dbg_format(value, syntax_colors: [], charlists: :as_lists)
assert formatted =~ "value #=> [104, 101, 108, 108, 111]\n"
end
end

describe "to_string/1" do
test "converts quoted to string" do
assert Macro.to_string(quote do: hello(world)) == "hello(world)"
Expand Down