Skip to content

Commit

Permalink
First implementation of Kernel.dbg/2 (elixir-lang#11974)
Browse files Browse the repository at this point in the history
Co-authored-by: José Valim <[email protected]>
  • Loading branch information
whatyouhide and josevalim authored Jul 12, 2022
1 parent 4e572d5 commit a8bf349
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 0 deletions.
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 @@ -2401,4 +2401,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 @@ -279,6 +279,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

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

0 comments on commit a8bf349

Please sign in to comment.