Skip to content

Commit

Permalink
evaluate conditional breakpoints, hit counts and log points in Macro.…
Browse files Browse the repository at this point in the history
…Env obtained from metadata
  • Loading branch information
lukaszsamson committed Sep 5, 2024
1 parent f0c4a81 commit ed2d2ca
Show file tree
Hide file tree
Showing 4 changed files with 296 additions and 149 deletions.
101 changes: 65 additions & 36 deletions apps/debug_adapter/lib/debug_adapter/breakpoint_condition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,27 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do
@spec register_condition(
module,
module,
[non_neg_integer],
non_neg_integer,
String.t(),
String.t() | nil,
non_neg_integer
String.t()
) ::
{:ok, {module, atom}} | {:error, :limit_reached}
def register_condition(name \\ __MODULE__, module, lines, condition, log_message, hit_count) do
def register_condition(name \\ __MODULE__, module, line, env, condition, log_message, hit_count) do
GenServer.call(
name,
{:register_condition, {module, lines}, condition, log_message, hit_count}
{:register_condition, {module, line}, env, 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}})
@spec unregister_condition(module, module, non_neg_integer) :: :ok
def unregister_condition(name \\ __MODULE__, module, line) do
GenServer.cast(name, {:unregister_condition, {module, line}})
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}})
@spec has_condition?(module, module, non_neg_integer) :: boolean
def has_condition?(name \\ __MODULE__, module, line) do
GenServer.call(name, {:has_condition?, {module, line}})
end

@spec get_condition(module, non_neg_integer) ::
Expand Down Expand Up @@ -96,7 +96,7 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do

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

{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
Expand All @@ -120,7 +120,8 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do
{number, _old_condition} ->
state = %{
state
| conditions: conditions |> Map.put(key, {number, {condition, log_message, hit_count}})
| conditions:
conditions |> Map.put(key, {number, {env, condition, log_message, hit_count}})
}

{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
Expand All @@ -132,11 +133,11 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do
end

def handle_call({:get_condition, number}, _from, %{conditions: conditions, hits: hits} = state) do
{condition, log_message, hit_count} =
{env, 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}
{:reply, {env, condition, log_message, hit_count, hits}, state}
end

def handle_call(:clear, _from, _state) do
Expand Down Expand Up @@ -177,14 +178,20 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do
for i <- @range do
@spec unquote(:"check_#{i}")(term) :: boolean
def unquote(:"check_#{i}")(binding) do
{condition, log_message, hit_count, hits} = get_condition(unquote(i))
{env, condition, log_message, hit_count_condition, hits} = get_condition(unquote(i))
elixir_binding = binding |> ElixirLS.DebugAdapter.Binding.to_elixir_variable_names()
result = eval_condition(condition, elixir_binding)
result = eval_condition(condition, elixir_binding, env)

result =
if result do
register_hit(unquote(i))
# do not break if hit count not reached
# the spec requires:
# If both this property and `condition` are specified, `hitCondition` should
# be evaluated only if the `condition` is met, and the debug adapter should
# stop only if both conditions are met.

hit_count = eval_hit_condition(hit_count_condition, elixir_binding, env)
hits + 1 > hit_count
else
result
Expand All @@ -194,20 +201,22 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition 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.
Output.debugger_console(interpolate(log_message, elixir_binding))
# If either `hitCondition` or `condition` is specified, then the message
# should only be logged if those conditions are met.
Output.debugger_console(interpolate(log_message, elixir_binding, env))
false
else
result
end
end
end

@spec eval_condition(String.t(), keyword) :: boolean
def eval_condition("true", _binding), do: true
@spec eval_condition(String.t(), keyword, Macro.Env.t()) :: boolean
def eval_condition("true", _binding, _env), do: true

def eval_condition(condition, elixir_binding) do
def eval_condition(condition, elixir_binding, env) do
try do
{term, _bindings} = Code.eval_string(condition, elixir_binding)
{term, _bindings} = Code.eval_string(condition, elixir_binding, env)
if term, do: true, else: false
catch
kind, error ->
Expand All @@ -219,9 +228,29 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do
end
end

def eval_string(expression, elixir_binding) do
@spec eval_hit_condition(String.t(), keyword, Macro.Env.t()) :: number
def eval_hit_condition("0", _binding, _env), do: 0

def eval_hit_condition(condition, elixir_binding, env) do
try do
{term, _bindings} = Code.eval_string(condition, elixir_binding, env)

if is_number(term) do
term
else
raise "Hit count evaluated to non number #{inspect(term)}"
end
catch
kind, error ->
Output.debugger_important("Error in hit count: " <> Exception.format_banner(kind, error))

0
end
end

def eval_string(expression, elixir_binding, env) do
try do
{term, _bindings} = Code.eval_string(expression, elixir_binding)
{term, _bindings} = Code.eval_string(expression, elixir_binding, env)
to_string(term)
catch
kind, error ->
Expand All @@ -233,39 +262,39 @@ defmodule ElixirLS.DebugAdapter.BreakpointCondition do
end
end

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

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

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

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

def interpolate(<<"{", rest::binary>>, acc, elixir_binding) do
def interpolate(<<"{", rest::binary>>, acc, elixir_binding, env) 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)
eval_result = eval_string(expression, elixir_binding, env)
interpolate(expression_rest, [eval_result | acc], elixir_binding, env)

:error ->
Output.debugger_important("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 interpolate(<<char::binary-size(1), rest::binary>>, acc, elixir_binding, env),
do: interpolate(rest, [char | acc], elixir_binding, env)

def parse_expression(<<>>, _acc), do: :error
def parse_expression(<<"\\{", rest::binary>>, acc), do: parse_expression(rest, ["{" | acc])
Expand Down
98 changes: 52 additions & 46 deletions apps/debug_adapter/lib/debug_adapter/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,9 @@ defmodule ElixirLS.DebugAdapter.Server do

for {{m, f, a}, lines} <- state.function_breakpoints,
not Map.has_key?(parsed_mfas_conditions, {m, f, a}) do
BreakpointCondition.unregister_condition(m, lines)
for line <- lines do
BreakpointCondition.unregister_condition(m, line)
end

case :int.del_break_in(m, f, a) do
:ok ->
Expand All @@ -993,6 +995,14 @@ defmodule ElixirLS.DebugAdapter.Server do
into: %{},
do:
(
path =
try do
module_info = ModuleInfoCache.get(m) || m.module_info()
Path.expand(to_string(module_info[:compile][:source]))
rescue
_ -> "nofile"
end

result =
case current[{m, f, a}] do
nil ->
Expand All @@ -1011,6 +1021,7 @@ defmodule ElixirLS.DebugAdapter.Server do

# pass nil as log_message - not supported on function breakpoints as of DAP 1.63
update_break_condition(
path,
m,
lines,
condition,
Expand Down Expand Up @@ -1038,7 +1049,15 @@ defmodule ElixirLS.DebugAdapter.Server do

lines ->
# pass nil as log_message - not supported on function breakpoints as of DAP 1.51
update_break_condition(m, lines, condition, nil, hit_count, state.config)
update_break_condition(
path,
m,
lines,
condition,
nil,
hit_count,
state.config
)

{:ok, lines}
end
Expand Down Expand Up @@ -2461,7 +2480,7 @@ defmodule ElixirLS.DebugAdapter.Server do
Output.debugger_console("Setting breakpoint in #{inspect(module)} #{path}:#{line}")
# no need to handle errors here, it can fail only with {:error, :break_exists}
:int.break(module, line)
update_break_condition(module, line, condition, log_message, hit_count, config)
update_break_condition(path, module, line, condition, log_message, hit_count, config)

[module | added]

Expand Down Expand Up @@ -2524,38 +2543,49 @@ defmodule ElixirLS.DebugAdapter.Server do
end
end

def update_break_condition(module, lines, condition, log_message, hit_count, config) do
def update_break_condition(path, module, lines, condition, log_message, hit_count, config) do
lines = List.wrap(lines)

condition = parse_condition(condition)
condition = parse_condition(condition, "true")

hit_count = eval_hit_count(hit_count)
hit_count = parse_condition(hit_count, "0")

log_message = if log_message not in ["", nil], do: log_message

register_break_condition(module, lines, condition, log_message, hit_count, config)
register_break_condition(path, module, lines, condition, log_message, hit_count, config)
end

defp register_break_condition(module, lines, condition, log_message, hit_count, %{
defp register_break_condition(file, module, lines, condition, log_message, hit_count, %{
"request" => "launch"
}) do
case BreakpointCondition.register_condition(module, lines, condition, log_message, hit_count) do
{:ok, mf} ->
for line <- lines do
for line <- lines do
{_metadata, _env, macro_env_or_opts} = parse_file(file, line)
# TODO use Code.env_for_eval when we require elixir 1.14
env = ElixirLS.DebugAdapter.Code.env_for_eval(macro_env_or_opts)

case BreakpointCondition.register_condition(
module,
line,
env,
condition,
log_message,
hit_count
) do
{:ok, mf} ->
:int.test_at_break(module, line, mf)
end

{:error, reason} ->
Output.debugger_important(
"Unable to set condition on a breakpoint in #{module}:#{inspect(lines)}: #{inspect(reason)}"
)
{:error, reason} ->
Output.debugger_important(
"Unable to set condition on a breakpoint in #{module}:#{inspect(line)}: #{inspect(reason)}"
)
end
end
end

defp register_break_condition(_module, _lines, condition, log_message, hit_count, %{
defp register_break_condition(_file, _module, _lines, condition, log_message, hit_count, %{
"request" => "attach"
}) do
if condition != "true" || log_message || hit_count != 0 do
if condition != "true" || log_message || hit_count != "0" do
# Module passed to :int.test_at_break has to be available on remote nodes. Otherwise break condition will
# always evaluate to false. We cannot easily distribute BreakpointCondition to remote nodes.
Output.debugger_important(
Expand All @@ -2564,39 +2594,16 @@ defmodule ElixirLS.DebugAdapter.Server do
end
end

defp parse_condition(condition) when condition in [nil, ""], do: "true"
defp parse_condition(condition, default) when condition in [nil, ""], do: default

defp parse_condition(condition) do
defp parse_condition(condition, default) do
case Code.string_to_quoted(condition) do
{:ok, _} ->
condition

{:error, reason} ->
Output.debugger_important("Cannot parse breakpoint condition: #{inspect(reason)}")
"true"
end
end

defp eval_hit_count(hit_count) when hit_count in [nil, ""], do: 0

defp eval_hit_count(hit_count) do
try do
# TODO binding?
{term, _bindings} = Code.eval_string(hit_count, [])

if is_integer(term) do
term
else
Output.debugger_important("Hit condition must evaluate to integer")
0
end
catch
kind, error ->
Output.debugger_important(
"Error while evaluating hit condition: " <> Exception.format_banner(kind, error)
)

0
default
end
end

Expand Down Expand Up @@ -2920,8 +2927,7 @@ defmodule ElixirLS.DebugAdapter.Server do
buffer_file_metadata = ElixirSense.Core.Parser.parse_string(code, false, true, {line, 1})

env = ElixirSense.Core.Metadata.get_env(buffer_file_metadata, {line, 1})
# TODO env_for_eval?
# should we clear versioned_vars?

{buffer_file_metadata, env, ElixirSense.Core.State.Env.to_macro_env(env, file, line)}
else
# do not try to parse non elixir files
Expand Down
Loading

0 comments on commit ed2d2ca

Please sign in to comment.