Skip to content

Commit 8e92bdc

Browse files
authored
Add support for hit count and log message on breakpoints (#671)
* add support for breakpoint hit condition * readme updated * implement log message * readme updated
1 parent 8878b85 commit 8e92bdc

File tree

5 files changed

+582
-88
lines changed

5 files changed

+582
-88
lines changed

README.md

Lines changed: 11 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+
Currently there is a limit of 100 breakpoints.
105+
104106
### Debuging tests and `.exs` files
105107

106108
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:
@@ -186,7 +188,15 @@ Function breakpoints will break on the first line of every clause of the specifi
186188

187189
### Conditional breakpoints
188190

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.
191+
Break conditions are supported and evaluate elixir expressions within the context set of breakpoints. See also limitations on Expression evaluator for further details.
192+
193+
### Hit conditions
194+
195+
An expression that evaluates to integer can be used to contro how many hits of a breakpoint are ignored before the process is stopped.
196+
197+
### Log points
198+
199+
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.
190200

191201
### Expression evaluator
192202

apps/elixir_ls_debugger/lib/debugger/breakpoint_condition.ex

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,59 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
1212
)
1313
end
1414

15-
def register_condition(name \\ __MODULE__, module, lines, condition) do
16-
GenServer.call(name, {:register_condition, {module, lines}, condition})
15+
@spec register_condition(
16+
module,
17+
module,
18+
[non_neg_integer],
19+
String.t(),
20+
String.t() | nil,
21+
non_neg_integer
22+
) ::
23+
{:ok, {module, atom}} | {:error, :limit_reached}
24+
def register_condition(name \\ __MODULE__, module, lines, condition, log_message, hit_count) do
25+
GenServer.call(
26+
name,
27+
{:register_condition, {module, lines}, condition, log_message, hit_count}
28+
)
1729
end
1830

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

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

41+
@spec get_condition(module, non_neg_integer) :: {String.t(), non_neg_integer, non_neg_integer}
2742
def get_condition(name \\ __MODULE__, number) do
2843
GenServer.call(name, {:get_condition, number})
2944
end
3045

46+
@spec register_hit(module, non_neg_integer) :: :ok
47+
def register_hit(name \\ __MODULE__, number) do
48+
GenServer.cast(name, {:register_hit, number})
49+
end
50+
51+
def clear(name \\ __MODULE__) do
52+
GenServer.call(name, :clear)
53+
end
54+
3155
@impl GenServer
3256
def init(_args) do
3357
{:ok,
3458
%{
3559
free: @range |> Enum.map(& &1),
36-
conditions: %{}
60+
conditions: %{},
61+
hits: %{}
3762
}}
3863
end
3964

4065
@impl GenServer
4166
def handle_call(
42-
{:register_condition, key, condition},
67+
{:register_condition, key, condition, log_message, hit_count},
4368
_from,
4469
%{free: free, conditions: conditions} = state
4570
) do
@@ -53,14 +78,19 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
5378
state = %{
5479
state
5580
| free: rest,
56-
conditions: conditions |> Map.put(key, {number, condition})
81+
conditions:
82+
conditions |> Map.put(key, {number, {condition, log_message, hit_count}})
5783
}
5884

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

6288
{number, _old_condition} ->
63-
state = %{state | conditions: conditions |> Map.put(key, {number, condition})}
89+
state = %{
90+
state
91+
| conditions: conditions |> Map.put(key, {number, {condition, log_message, hit_count}})
92+
}
93+
6494
{:reply, {:ok, {__MODULE__, :"check_#{number}"}}, state}
6595
end
6696
end
@@ -69,17 +99,33 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
6999
{:reply, Map.has_key?(conditions, key), state}
70100
end
71101

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}
102+
def handle_call({:get_condition, number}, _from, %{conditions: conditions, hits: hits} = state) do
103+
{condition, log_message, hit_count} =
104+
conditions |> Map.values() |> Enum.find(fn {n, _c} -> n == number end) |> elem(1)
105+
106+
hits = hits |> Map.get(number, 0)
107+
{:reply, {condition, log_message, hit_count, hits}, state}
108+
end
109+
110+
def handle_call(:clear, _from, _state) do
111+
{:ok, state} = init([])
112+
{:reply, :ok, state}
75113
end
76114

77115
@impl GenServer
78-
def handle_cast({:unregister_condition, key}, %{free: free, conditions: conditions} = state) do
116+
def handle_cast(
117+
{:unregister_condition, key},
118+
%{free: free, conditions: conditions, hits: hits} = state
119+
) do
79120
state =
80121
case Map.pop(conditions, key) do
81122
{{number, _}, conditions} ->
82-
%{state | free: [number | free], conditions: conditions}
123+
%{
124+
state
125+
| free: [number | free],
126+
conditions: conditions,
127+
hits: hits |> Map.delete(number)
128+
}
83129

84130
{nil, _} ->
85131
state
@@ -88,20 +134,45 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
88134
{:noreply, state}
89135
end
90136

137+
def handle_cast({:register_hit, number}, %{hits: hits} = state) do
138+
hits = hits |> Map.update(number, 1, &(&1 + 1))
139+
{:noreply, %{state | hits: hits}}
140+
end
141+
91142
# `:int` module supports setting breakpoint conditions in the form `{module, function}`
92143
# we need a way of dynamically generating such pairs and assigning conditions that they will evaluate
93144
# an arbitrary limit of 100 conditions was chosen
94145
for i <- @range do
95146
@spec unquote(:"check_#{i}")(term) :: boolean
96147
def unquote(:"check_#{i}")(binding) do
97-
condition = get_condition(unquote(i))
98-
eval_condition(condition, binding)
148+
{condition, log_message, hit_count, hits} = get_condition(unquote(i))
149+
elixir_binding = binding |> ElixirLS.Debugger.Binding.to_elixir_variable_names()
150+
result = eval_condition(condition, elixir_binding)
151+
152+
result =
153+
if result do
154+
register_hit(unquote(i))
155+
# do not break if hit count not reached
156+
hits + 1 > hit_count
157+
else
158+
result
159+
end
160+
161+
if result and log_message != nil do
162+
# Debug Adapter Protocol:
163+
# If this attribute exists and is non-empty, the backend must not 'break' (stop)
164+
# but log the message instead. Expressions within {} are interpolated.
165+
IO.puts(interpolate(log_message, elixir_binding))
166+
false
167+
else
168+
result
169+
end
99170
end
100171
end
101172

102-
def eval_condition(condition, binding) do
103-
elixir_binding = binding |> ElixirLS.Debugger.Binding.to_elixir_variable_names()
173+
def eval_condition("true", _binding), do: true
104174

175+
def eval_condition(condition, elixir_binding) do
105176
try do
106177
{term, _bindings} = Code.eval_string(condition, elixir_binding)
107178
if term, do: true, else: false
@@ -111,4 +182,58 @@ defmodule ElixirLS.Debugger.BreakpointCondition do
111182
false
112183
end
113184
end
185+
186+
def eval_string(expression, elixir_binding) do
187+
try do
188+
{term, _bindings} = Code.eval_string(expression, elixir_binding)
189+
to_string(term)
190+
catch
191+
kind, error ->
192+
IO.warn("Error in log message interpolation: " <> Exception.format_banner(kind, error))
193+
""
194+
end
195+
end
196+
197+
def interpolate(format_string, elixir_binding) do
198+
interpolate(format_string, [], elixir_binding)
199+
|> Enum.reverse()
200+
|> IO.iodata_to_binary()
201+
end
202+
203+
def interpolate(<<>>, acc, _elixir_binding), do: acc
204+
205+
def interpolate(<<"\\{", rest::binary>>, acc, elixir_binding),
206+
do: interpolate(rest, ["{" | acc], elixir_binding)
207+
208+
def interpolate(<<"\\}", rest::binary>>, acc, elixir_binding),
209+
do: interpolate(rest, ["}" | acc], elixir_binding)
210+
211+
def interpolate(<<"{", rest::binary>>, acc, elixir_binding) do
212+
case parse_expression(rest, []) do
213+
{:ok, expression_iolist, expression_rest} ->
214+
expression =
215+
expression_iolist
216+
|> Enum.reverse()
217+
|> IO.iodata_to_binary()
218+
219+
eval_result = eval_string(expression, elixir_binding)
220+
interpolate(expression_rest, [eval_result | acc], elixir_binding)
221+
222+
:error ->
223+
IO.warn("Log message has unpaired or nested `{}`")
224+
acc
225+
end
226+
end
227+
228+
def interpolate(<<char::binary-size(1), rest::binary>>, acc, elixir_binding),
229+
do: interpolate(rest, [char | acc], elixir_binding)
230+
231+
def parse_expression(<<>>, _acc), do: :error
232+
def parse_expression(<<"\\{", rest::binary>>, acc), do: parse_expression(rest, ["{" | acc])
233+
def parse_expression(<<"\\}", rest::binary>>, acc), do: parse_expression(rest, ["}" | acc])
234+
def parse_expression(<<"{", _rest::binary>>, _acc), do: :error
235+
def parse_expression(<<"}", rest::binary>>, acc), do: {:ok, acc, rest}
236+
237+
def parse_expression(<<char::binary-size(1), rest::binary>>, acc),
238+
do: parse_expression(rest, [char | acc])
114239
end

0 commit comments

Comments
 (0)