Skip to content
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

Add support for hit count and log message on breakpoints #671

Merged
merged 4 commits into from Feb 10, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Expand Up @@ -101,6 +101,8 @@ ElixirLS includes debugger support adhering to the [Debug Adapter Protocol](http

When debugging in Elixir or Erlang, only modules that have been "interpreted" (using `:int.ni/1` or `:int.i/1`) will accept breakpoints or show up in stack traces. The debugger in ElixirLS automatically interprets all modules in the Mix project and dependencies prior to launching the Mix task, so you can set breakpoints anywhere in your project or dependency modules.

Currently there is a limit of 100 breakpoints.

### Debuging tests and `.exs` files

In order to debug modules in `.exs` files (such as tests), they must be specified under `requireFiles` in your launch configuration so they can be loaded and interpreted prior to running the task. For example, the default launch configuration for "mix test" in the VS Code plugin looks like this:
Expand Down Expand Up @@ -186,7 +188,15 @@ Function breakpoints will break on the first line of every clause of the specifi

### Conditional breakpoints

Break conditions are supported and evaluate elixir expressions within the context set of breakpoints. There is currently a limit of 100 breakpoint conditions. Due to limitations in `:int` breakpoint conditions cannot be unset. See also limitations on Expression evaluator for further details.
Break conditions are supported and evaluate elixir expressions within the context set of breakpoints. See also limitations on Expression evaluator for further details.

### Hit conditions

An expression that evaluates to integer can be used to contro how many hits of a breakpoint are ignored before the process is stopped.

### Log points

When log message is set on a breakpoint the debugger will not break but instead log a message to standard output (as required by Debug Adapter Protocol specification). The message may contain interpolated expressions in `{}`, e.g. `my_var is {inspect(my_var)}` and will be evaluated in the context of the process. Special characters `{` and `}` can be emited with escape sequence `\{` and `\}`. As of Debug Adapter Protocol specification version 1.51, log messages are not supported on function breakpoints.

### Expression evaluator

Expand Down
155 changes: 140 additions & 15 deletions apps/elixir_ls_debugger/lib/debugger/breakpoint_condition.ex
Expand Up @@ -12,34 +12,59 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
)
end

def register_condition(name \\ __MODULE__, module, lines, condition) do
GenServer.call(name, {:register_condition, {module, lines}, condition})
@spec register_condition(
module,
module,
[non_neg_integer],
String.t(),
String.t() | nil,
non_neg_integer
) ::
{:ok, {module, atom}} | {:error, :limit_reached}
def register_condition(name \\ __MODULE__, module, lines, condition, log_message, hit_count) do
GenServer.call(
name,
{:register_condition, {module, lines}, condition, log_message, hit_count}
)
end

@spec unregister_condition(module, module, [non_neg_integer]) :: :ok
def unregister_condition(name \\ __MODULE__, module, lines) do
GenServer.cast(name, {:unregister_condition, {module, lines}})
end

@spec has_condition?(module, module, [non_neg_integer]) :: boolean
def has_condition?(name \\ __MODULE__, module, lines) do
GenServer.call(name, {:has_condition?, {module, lines}})
end

@spec get_condition(module, non_neg_integer) :: {String.t(), non_neg_integer, non_neg_integer}
def get_condition(name \\ __MODULE__, number) do
GenServer.call(name, {:get_condition, number})
end

@spec register_hit(module, non_neg_integer) :: :ok
def register_hit(name \\ __MODULE__, number) do
GenServer.cast(name, {:register_hit, number})
end

def clear(name \\ __MODULE__) do
GenServer.call(name, :clear)
end

@impl GenServer
def init(_args) do
{:ok,
%{
free: @range |> Enum.map(& &1),
conditions: %{}
conditions: %{},
hits: %{}
}}
end

@impl GenServer
def handle_call(
{:register_condition, key, condition},
{:register_condition, key, condition, log_message, hit_count},
_from,
%{free: free, conditions: conditions} = state
) do
Expand All @@ -53,14 +78,19 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
state = %{
state
| free: rest,
conditions: conditions |> Map.put(key, {number, condition})
conditions:
conditions |> Map.put(key, {number, {condition, log_message, hit_count}})
}

{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
end

{number, _old_condition} ->
state = %{state | conditions: conditions |> Map.put(key, {number, condition})}
state = %{
state
| conditions: conditions |> Map.put(key, {number, {condition, log_message, hit_count}})
}

{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
end
end
Expand All @@ -69,17 +99,33 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
{:reply, Map.has_key?(conditions, key), state}
end

def handle_call({:get_condition, number}, _from, %{conditions: conditions} = state) do
condition = conditions |> Map.values() |> Enum.find(fn {n, _c} -> n == number end) |> elem(1)
{:reply, condition, state}
def handle_call({:get_condition, number}, _from, %{conditions: conditions, hits: hits} = state) do
{condition, log_message, hit_count} =
conditions |> Map.values() |> Enum.find(fn {n, _c} -> n == number end) |> elem(1)

hits = hits |> Map.get(number, 0)
{:reply, {condition, log_message, hit_count, hits}, state}
end

def handle_call(:clear, _from, _state) do
{:ok, state} = init([])
{:reply, :ok, state}
end

@impl GenServer
def handle_cast({:unregister_condition, key}, %{free: free, conditions: conditions} = state) do
def handle_cast(
{:unregister_condition, key},
%{free: free, conditions: conditions, hits: hits} = state
) do
state =
case Map.pop(conditions, key) do
{{number, _}, conditions} ->
%{state | free: [number | free], conditions: conditions}
%{
state
| free: [number | free],
conditions: conditions,
hits: hits |> Map.delete(number)
}

{nil, _} ->
state
Expand All @@ -88,20 +134,45 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
{:noreply, state}
end

def handle_cast({:register_hit, number}, %{hits: hits} = state) do
hits = hits |> Map.update(number, 1, &(&1 + 1))
{:noreply, %{state | hits: hits}}
end

# `:int` module supports setting breakpoint conditions in the form `{module, function}`
# we need a way of dynamically generating such pairs and assigning conditions that they will evaluate
# an arbitrary limit of 100 conditions was chosen
for i <- @range do
@spec unquote(:"check_#{i}")(term) :: boolean
def unquote(:"check_#{i}")(binding) do
condition = get_condition(unquote(i))
eval_condition(condition, binding)
{condition, log_message, hit_count, hits} = get_condition(unquote(i))
elixir_binding = binding |> ElixirLS.Debugger.Binding.to_elixir_variable_names()
result = eval_condition(condition, elixir_binding)

result =
if result do
register_hit(unquote(i))
# do not break if hit count not reached
hits + 1 > hit_count
else
result
end

if result and log_message != nil do
# Debug Adapter Protocol:
# If this attribute exists and is non-empty, the backend must not 'break' (stop)
# but log the message instead. Expressions within {} are interpolated.
IO.puts(interpolate(log_message, elixir_binding))
false
else
result
end
end
end

def eval_condition(condition, binding) do
elixir_binding = binding |> ElixirLS.Debugger.Binding.to_elixir_variable_names()
def eval_condition("true", _binding), do: true

def eval_condition(condition, elixir_binding) do
try do
{term, _bindings} = Code.eval_string(condition, elixir_binding)
if term, do: true, else: false
Expand All @@ -111,4 +182,58 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
false
end
end

def eval_string(expression, elixir_binding) do
try do
{term, _bindings} = Code.eval_string(expression, elixir_binding)
to_string(term)
catch
kind, error ->
IO.warn("Error in log message interpolation: " <> Exception.format_banner(kind, error))
""
end
end

def interpolate(format_string, elixir_binding) do
interpolate(format_string, [], elixir_binding)
|> Enum.reverse()
|> IO.iodata_to_binary()
end

def interpolate(<<>>, acc, _elixir_binding), do: acc

def interpolate(<<"\\{", rest::binary>>, acc, elixir_binding),
do: interpolate(rest, ["{" | acc], elixir_binding)

def interpolate(<<"\\}", rest::binary>>, acc, elixir_binding),
do: interpolate(rest, ["}" | acc], elixir_binding)

def interpolate(<<"{", rest::binary>>, acc, elixir_binding) do
case parse_expression(rest, []) do
{:ok, expression_iolist, expression_rest} ->
expression =
expression_iolist
|> Enum.reverse()
|> IO.iodata_to_binary()

eval_result = eval_string(expression, elixir_binding)
interpolate(expression_rest, [eval_result | acc], elixir_binding)

:error ->
IO.warn("Log message has unpaired or nested `{}`")
acc
end
end

def interpolate(<<char::binary-size(1), rest::binary>>, acc, elixir_binding),
do: interpolate(rest, [char | acc], elixir_binding)

def parse_expression(<<>>, _acc), do: :error
def parse_expression(<<"\\{", rest::binary>>, acc), do: parse_expression(rest, ["{" | acc])
def parse_expression(<<"\\}", rest::binary>>, acc), do: parse_expression(rest, ["}" | acc])
def parse_expression(<<"{", _rest::binary>>, _acc), do: :error
def parse_expression(<<"}", rest::binary>>, acc), do: {:ok, acc, rest}

def parse_expression(<<char::binary-size(1), rest::binary>>, acc),
do: parse_expression(rest, [char | acc])
end