Skip to content

Commit b4dd38a

Browse files
authored
Conditional breakpoints (#661)
* extract and test erlang binding processing fix some isses when var is usend more than 10 times add explicite ordering by variable instance discard underscored variables * breakpoint conditions support added do not warn when setting already set breakpoint * update readme # Conflicts: # README.md * log when expression crashes * add support for conditional function breakpoints * update readme
1 parent 423d7f8 commit b4dd38a

File tree

7 files changed

+657
-51
lines changed

7 files changed

+657
-51
lines changed

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ ElixirLS includes debugger support adhering to the [Debug Adapter Protocol](http
101101

102102
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.
103103

104+
### Debuging tests and `.exs` files
105+
104106
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:
105107

106108
```
@@ -155,6 +157,7 @@ Use the following launch config to debug phoenix apps
155157

156158
Please make sure that `startApps` is not set to `true` as it prevents phoenix from starting correctly. On the other hand phoenix tests expects that the apps are already started so in that case set it to `true`.
157159

160+
### NIF modules limitation
158161

159162
Please note that due to `:int` limitation NIF modules cannot be interpreted and need to be excluded via `excludeModules` option. This option can be also used to disable interpreting for some modules when it is not desirable e.g. when performance is not satisfactory.
160163

@@ -177,7 +180,27 @@ Please note that due to `:int` limitation NIF modules cannot be interpreted and
177180
}
178181
```
179182

180-
Function breakpoints will break on the first line of every clause of the specified function. The function needs to be specified as MFA (module, function, arity) in the standard elixir format, e.g. `:some_module.function/1` or `Some.Module.some_function/2`.
183+
### Function breakpoints
184+
185+
Function breakpoints will break on the first line of every clause of the specified function. The function needs to be specified as MFA (module, function, arity) in the standard elixir format, e.g. `:some_module.function/1` or `Some.Module.some_function/2`. Breaking on private functions is not supported via function breakpoints.
186+
187+
### Conditional breakpoints
188+
189+
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.
190+
191+
### Expression evaluator
192+
193+
An expression evaluator is included in the debbuger. It evaluates elixir expressions in the context of a process stopped on a breakpoint. All bound variables are accessible (no support for attributes as those are compile time). Please note that there are limitations due to `:int` operating on beam instruction level. The binding returns multiple versions of variables in Static Singe Assignment with no indication which one is valid in the current elixir scope. A heuristic is used that selects the highest versions but it does not behave correctly in all cases, e.g. in
194+
195+
```elixir
196+
a = 4
197+
if true do
198+
a = 5
199+
end
200+
some
201+
```
202+
203+
when a breakpoint is reached on line with `some`, the last bound value for `a` seen by expression breakpoint evaluator is 5 (should be 4).
181204

182205
## Automatic builds and error reporting
183206

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule ElixirLS.Debugger.Binding do
2+
def to_elixir_variable_names(bindings) do
3+
bindings
4+
|> Enum.group_by(fn {key, _} -> get_elixir_variable(key) end)
5+
# filter out underscore binding as those are invalid in elixir
6+
|> Enum.reject(fn {classic_key, _} ->
7+
classic_key |> Atom.to_string() |> String.starts_with?("_")
8+
end)
9+
|> Enum.map(fn {classic_key, list} ->
10+
# assume binding with highest number is the current one
11+
# this may not be allways true, e.g. in
12+
# a = 5
13+
# if true do
14+
# a = 4
15+
# end
16+
# results in _a@1 = 5 and _a@2 = 4
17+
# but we have no way of telling which one is current
18+
{_, last_value} = list |> Enum.max_by(fn {key, _} -> key end)
19+
{classic_key, last_value}
20+
end)
21+
end
22+
23+
def get_elixir_variable(key) do
24+
# binding is present with prefix _ and postfix @
25+
# for example _key@1 and _value@1 are representations of current function variables
26+
key
27+
|> Atom.to_string()
28+
|> String.replace(~r/_(.*)@\d+/, "\\1")
29+
|> String.to_atom()
30+
end
31+
32+
def get_number(key) do
33+
key
34+
|> Atom.to_string()
35+
|> String.replace(~r/_.*@(\d+)/, "\\1")
36+
|> String.to_integer()
37+
end
38+
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
defmodule ElixirLS.Debugger.BreakpointCondition do
2+
@moduledoc """
3+
Server that tracks breakpoint conditions
4+
"""
5+
6+
use GenServer
7+
@range 0..99
8+
9+
def start_link(args) do
10+
GenServer.start_link(__MODULE__, Keyword.delete(args, :name),
11+
name: Keyword.get(args, :name, __MODULE__)
12+
)
13+
end
14+
15+
def register_condition(name \\ __MODULE__, module, lines, condition) do
16+
GenServer.call(name, {:register_condition, {module, lines}, condition})
17+
end
18+
19+
def unregister_condition(name \\ __MODULE__, module, lines) do
20+
GenServer.cast(name, {:unregister_condition, {module, lines}})
21+
end
22+
23+
def has_condition?(name \\ __MODULE__, module, lines) do
24+
GenServer.call(name, {:has_condition?, {module, lines}})
25+
end
26+
27+
def get_condition(name \\ __MODULE__, number) do
28+
GenServer.call(name, {:get_condition, number})
29+
end
30+
31+
@impl GenServer
32+
def init(_args) do
33+
{:ok,
34+
%{
35+
free: @range |> Enum.map(& &1),
36+
conditions: %{}
37+
}}
38+
end
39+
40+
@impl GenServer
41+
def handle_call(
42+
{:register_condition, key, condition},
43+
_from,
44+
%{free: free, conditions: conditions} = state
45+
) do
46+
case conditions[key] do
47+
nil ->
48+
case free do
49+
[] ->
50+
{:reply, {:error, :limit_reached}, state}
51+
52+
[number | rest] ->
53+
state = %{
54+
state
55+
| free: rest,
56+
conditions: conditions |> Map.put(key, {number, condition})
57+
}
58+
59+
{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
60+
end
61+
62+
{number, _old_condition} ->
63+
state = %{state | conditions: conditions |> Map.put(key, {number, condition})}
64+
{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
65+
end
66+
end
67+
68+
def handle_call({:has_condition?, key}, _from, %{conditions: conditions} = state) do
69+
{:reply, Map.has_key?(conditions, key), state}
70+
end
71+
72+
def handle_call({:get_condition, number}, _from, %{conditions: conditions} = state) do
73+
condition = conditions |> Map.values() |> Enum.find(fn {n, _c} -> n == number end) |> elem(1)
74+
{:reply, condition, state}
75+
end
76+
77+
@impl GenServer
78+
def handle_cast({:unregister_condition, key}, %{free: free, conditions: conditions} = state) do
79+
state =
80+
case Map.pop(conditions, key) do
81+
{{number, _}, conditions} ->
82+
%{state | free: [number | free], conditions: conditions}
83+
84+
{nil, _} ->
85+
state
86+
end
87+
88+
{:noreply, state}
89+
end
90+
91+
# `:int` module supports setting breakpoint conditions in the form `{module, function}`
92+
# we need a way of dynamically generating such pairs and assigning conditions that they will evaluate
93+
# an arbitrary limit of 100 conditions was chosen
94+
for i <- @range do
95+
@spec unquote(:"check_#{i}")(term) :: boolean
96+
def unquote(:"check_#{i}")(binding) do
97+
condition = get_condition(unquote(i))
98+
eval_condition(condition, binding)
99+
end
100+
end
101+
102+
def eval_condition(condition, binding) do
103+
elixir_binding = binding |> ElixirLS.Debugger.Binding.to_elixir_variable_names()
104+
105+
try do
106+
{term, _bindings} = Code.eval_string(condition, elixir_binding)
107+
if term, do: true, else: false
108+
catch
109+
kind, error ->
110+
IO.warn("Error in conditional breakpoint: " <> Exception.format_banner(kind, error))
111+
false
112+
end
113+
end
114+
end

0 commit comments

Comments
 (0)