diff --git a/assets/app/hooks/tooltip.js b/assets/app/hooks/tooltip.js
index 369c9a165..47ddf8d26 100644
--- a/assets/app/hooks/tooltip.js
+++ b/assets/app/hooks/tooltip.js
@@ -82,14 +82,20 @@ const Tooltip = {
tooltipEl.style.display = 'none';
};
+ this.handleScroll = () => {
+ tooltipEl.style.display = 'none';
+ };
+
this.el.addEventListener('mouseenter', this.handleMouseEnter);
this.el.addEventListener('mouseleave', this.handleMouseLeave);
+ window.addEventListener('scroll', this.handleScroll, true);
},
destroyed() {
clearTimeout(this._hoverTimeout);
document.querySelector('#tooltip').style.display = 'none';
this.el.removeEventListener('mouseenter', this.handleMouseEnter);
this.el.removeEventListener('mouseleave', this.handleMouseLeave);
+ window.removeEventListener('scroll', this.handleScroll, true);
},
};
diff --git a/dev/live_views/main.ex b/dev/live_views/main.ex
index 6fad4bf72..18fef38d3 100644
--- a/dev/live_views/main.ex
+++ b/dev/live_views/main.ex
@@ -123,6 +123,10 @@ defmodule LiveDebuggerDev.LiveViews.Main do
{:noreply, assign(socket, :message, %{name: "message name", text: "some text"})}
end
+ def handle_event("increment", _, socket) when is_binary(socket) do
+ {:noreply, update(socket, :counter, &(&1 + 1))}
+ end
+
def handle_event("increment", _, socket) do
{:noreply, update(socket, :counter, &(&1 + 1))}
end
diff --git a/e2e/tests/async-jobs.spec.ts b/e2e/tests/async-jobs.spec.ts
index 78b62d30a..0111c5b0a 100644
--- a/e2e/tests/async-jobs.spec.ts
+++ b/e2e/tests/async-jobs.spec.ts
@@ -62,6 +62,7 @@ test('user can see and track async jobs in LiveView and LiveComponent', async ({
'No active async jobs found'
);
+ await devApp.locator('#component-long-load-toggle').click();
await devApp.locator('#component-start-cancelable-async-button').click();
await expect(
asyncJobName(dbgApp, ':component_cancelable_fetch')
diff --git a/e2e/tests/elements-inspection.spec.ts b/e2e/tests/elements-inspection.spec.ts
index fa2184e13..15c558b53 100644
--- a/e2e/tests/elements-inspection.spec.ts
+++ b/e2e/tests/elements-inspection.spec.ts
@@ -1,6 +1,7 @@
import {
expect,
findNodeModuleInfo,
+ findSidebarBasicInfo,
prepareDevDebuggerPairTest,
getDevPid,
Page,
@@ -42,7 +43,7 @@ const selectLiveViewByPid = async (dbgApp: Page, pid: string) => {
);
await btn.hover();
await btn.click();
- await expect(findNodeModuleInfo(dbgApp)).toBeVisible();
+ await expect(findSidebarBasicInfo(dbgApp)).toBeVisible();
};
const openDbgForLiveView = async (
@@ -67,7 +68,7 @@ const openMobileDbgForLiveView = async (
);
await btn.hover();
await btn.click();
- await expect(findNodeModuleInfo(dbgApp)).toBeVisible();
+ await expect(findSidebarBasicInfo(dbgApp)).toBeVisible();
return dbgApp;
};
diff --git a/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex b/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex
index bac2d9b7d..f8c22b45c 100644
--- a/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex
+++ b/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex
@@ -25,7 +25,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do
:body,
:side_section_left,
:side_section_right,
- :error
+ :error,
+ :source
]
@type type() :: :normal | :diff | :error
@@ -44,7 +45,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do
body: list({String.t(), term()}),
side_section_left: side_section_left(),
side_section_right: side_section_right(),
- error: ErrorTrace.t() | nil
+ error: ErrorTrace.t() | nil,
+ source: FunctionTrace.SourceLocation.t() | nil
}
@spec from_trace(Trace.t(), boolean()) :: t()
@@ -60,7 +62,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do
body: get_body(trace),
side_section_left: get_side_section_left(trace),
side_section_right: get_side_section_right(trace),
- error: get_error(trace)
+ error: get_error(trace),
+ source: get_source(trace)
}
end
@@ -121,4 +124,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do
defp get_error(%FunctionTrace{error: error}), do: error
defp get_error(_), do: nil
+
+ defp get_source(%FunctionTrace{source: source}), do: source
+
+ defp get_source(_), do: nil
end
diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex b/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex
index d8b8a4072..f266e8e4a 100644
--- a/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex
+++ b/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex
@@ -14,8 +14,57 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do
alias LiveDebugger.Structs.Trace.ErrorTrace
+ alias LiveDebugger.App.Debugger.Utils.Editor
alias Phoenix.LiveView.JS
+ @doc """
+ Button to open the trace source in an external editor.
+ """
+ attr(:id, :string, required: true)
+ attr(:elixir_editor, :string, default: nil)
+ attr(:source, SourceLocation, default: nil)
+ attr(:fullscreen?, :boolean, default: false)
+
+ def open_in_editor_button(assigns) do
+ assigns = assign(assigns, :editor_docs_url, Editor.editor_docs_url())
+
+ ~H"""
+ <.tooltip
+ :if={@source}
+ id={@id <> "-open-in-editor-tooltip"}
+ class="my-2"
+ content={
+ if(@elixir_editor,
+ do: "Open in editor",
+ else: "Editor not configured. Click for setup instructions."
+ )
+ }
+ position="top-center"
+ fullscreen?={@fullscreen?}
+ >
+ <.icon_button
+ :if={@elixir_editor}
+ id={"#{@id}-open-in-editor-button"}
+ icon="icon-external-link"
+ phx-click="open-in-editor"
+ phx-value-file={@source.source_file}
+ phx-value-line={@source.line}
+ variant="secondary"
+ />
+ <.button_link
+ :if={@elixir_editor == nil}
+ href={@editor_docs_url}
+ id={"#{@id}-open-in-editor"}
+ variant="secondary"
+ size="sm"
+ class="opacity-50 cursor-pointer"
+ >
+ <.icon name="icon-external-link" class="w-4 h-4" />
+
+
+ """
+ end
+
@doc """
Displays the label of the trace with a polymorphic composition.
"""
@@ -106,6 +155,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do
attr(:trace_display, TraceDisplay, required: true)
attr(:search_phrase, :string, required: true)
attr(:fullscreen?, :boolean, default: false)
+ attr(:elixir_editor, :string, default: nil)
def trace_body_navbar_wrapper(assigns) do
assigns =
@@ -133,6 +183,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do
"peer-checked/content:[&_.tab-content]:text-navbar-selected-bg peer-checked/content:[&_.tab-content]:border-navbar-selected-bg",
"peer-checked/stack:[&_.tab-stack]:text-navbar-selected-bg peer-checked/stack:[&_.tab-stack]:border-navbar-selected-bg",
"peer-checked/raw:[&_.tab-raw]:text-navbar-selected-bg peer-checked/raw:[&_.tab-raw]:border-navbar-selected-bg",
+ "peer-checked/content:[&_.editor-btn-content]:block",
"peer-checked/stack:[&_.copy-btn-stack]:block",
"peer-checked/raw:[&_.copy-btn-raw]:block"
]}>
@@ -183,6 +234,15 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do
/>
+
+ <.open_in_editor_button
+ id={@id}
+ elixir_editor={@elixir_editor}
+ source={@trace_display.source}
+ fullscreen?={@fullscreen?}
+ />
+
+
<.fullscreen_button
id={"trace-fullscreen-#{@id}"}
class="m-2"
@@ -237,6 +297,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do
attr(:displayed_trace, TraceDisplay, required: true)
attr(:search_phrase, :string, required: true)
attr(:page, :atom, required: true, values: [:node_inspector, :global_callbacks])
+ attr(:elixir_editor, :string, default: nil)
def trace_fullscreen(assigns) do
~H"""
@@ -261,16 +322,31 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do
/>
- div>div>div>button]:hidden",
- if(is_nil(@displayed_trace.error), do: "p-4", else: "[&>div>div>div>div>button]:hidden")
- ]}>
- <.trace_body_navbar_wrapper
- id={@id <> "-fullscreen"}
- trace_display={@displayed_trace}
- search_phrase={@search_phrase}
- fullscreen?={true}
- />
+
+
+
+ <.open_in_editor_button
+ id={@id <> "-fullscreen"}
+ elixir_editor={@elixir_editor}
+ source={@displayed_trace.source}
+ fullscreen?={true}
+ />
+
+
div>div>div>button]:hidden",
+ if(is_nil(@displayed_trace.error), do: "p-4", else: "[&>div>div>div>div>button]:hidden")
+ ]}>
+ <.trace_body_navbar_wrapper
+ id={@id <> "-fullscreen"}
+ trace_display={@displayed_trace}
+ search_phrase={@search_phrase}
+ fullscreen?={true}
+ elixir_editor={@elixir_editor}
+ />
+
"""
diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex b/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex
index 2acaeb346..945389a7f 100644
--- a/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex
+++ b/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex
@@ -31,6 +31,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do
alias LiveDebugger.Services.ProcessMonitor.Events.LiveComponentCreated
alias LiveDebugger.Services.ProcessMonitor.Events.LiveComponentDeleted
alias LiveDebugger.App.Debugger.CallbackTracing.Web.LiveComponents.FiltersForm
+ alias LiveDebugger.App.Debugger.Utils.Editor
@live_stream_limit 128
@page_size 25
@@ -97,7 +98,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do
node_id: nil,
url: url,
inspect_mode?: inspect_mode?,
- return_link: return_link
+ return_link: return_link,
+ elixir_editor: Editor.detect_editor()
)
|> stream(:existing_traces, [], reset: true)
|> put_private(:page_size, @page_size)
@@ -168,7 +170,11 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do
existing_traces={@streams.existing_traces}
>
<:trace :let={{id, trace_display}}>
-
+
<:label>
<.trace_label
id={id <> "-label"}
@@ -184,6 +190,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do
id={id <> "-body"}
trace_display={trace_display}
search_phrase={@trace_search_phrase}
+ elixir_editor={@elixir_editor}
/>
@@ -199,6 +206,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do
displayed_trace={@displayed_trace}
search_phrase={@trace_search_phrase}
page={:global_callbacks}
+ elixir_editor={@elixir_editor}
/>
diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex
index f31dc5b02..fa0be24ec 100644
--- a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex
+++ b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex
@@ -21,10 +21,17 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap
import LiveDebugger.App.Web.Hooks.Flash, only: [push_flash: 4]
+ import LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace,
+ only: [open_in_editor_button: 1]
+
alias LiveDebugger.API.TracesStorage
alias LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay
+ alias LiveDebugger.App.Debugger.Utils.Editor
+ alias LiveDebugger.Utils.FunctionMatcher
+ alias LiveDebugger.Services.CallbackTracer.Actions.FunctionTrace
+ alias LiveDebugger.Structs.Trace.DiffTrace
- @required_assigns [:lv_process, :displayed_trace, :parent_pid]
+ @required_assigns [:lv_process, :displayed_trace, :parent_pid, :elixir_editor]
@trace_not_found_close_delay_ms 200
@impl true
@@ -38,6 +45,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap
attr(:id, :string, required: true)
attr(:trace_display, TraceDisplay, required: true)
+ attr(:elixir_editor, :string, default: nil)
slot(:body, required: true)
slot(:label, required: true)
@@ -66,12 +74,20 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap
:if={@trace_display.render_body? && is_nil(@trace_display.error)}
class="absolute right-0 top-0 z-10"
>
- <.fullscreen_button
- id={"trace-fullscreen-#{@id}"}
- class="m-2"
- phx-click="open-trace"
- phx-value-trace-id={@trace_display.id}
- />
+
+ <.open_in_editor_button
+ id={@id}
+ elixir_editor={@elixir_editor}
+ source={@trace_display.source}
+ />
+
+ <.fullscreen_button
+ id={"trace-fullscreen-#{@id}"}
+ class="m-2"
+ phx-click="open-trace"
+ phx-value-trace-id={@trace_display.id}
+ />
+
+ stream_insert_trace(socket, diff_trace, !render_body?)
+
trace ->
+ trace = maybe_resolve_and_persist_source(trace)
stream_insert_trace(socket, trace, !render_body?)
end
|> halt()
end
+ defp handle_event("open-in-editor", %{"file" => file, "line" => line}, socket) do
+ Editor.open_in_editor(
+ socket.assigns.elixir_editor,
+ file,
+ String.to_integer(line),
+ socket.assigns.parent_pid
+ )
+
+ socket
+ |> halt()
+ end
+
defp handle_event(_, _, socket), do: {:cont, socket}
defp handle_info({:trace_wrapper_not_found, string_trace_id}, socket) do
@@ -136,6 +168,22 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap
defp handle_info(_, socket), do: {:cont, socket}
+ defp maybe_resolve_and_persist_source(
+ %{source: nil, module: module, function: function, args: args} = trace
+ ) do
+ case FunctionMatcher.find_matching_clause_line(module, function, args) do
+ {:ok, source} ->
+ new_trace = %{trace | source: source}
+ FunctionTrace.persist_trace(new_trace)
+ new_trace
+
+ _ ->
+ trace
+ end
+ end
+
+ defp maybe_resolve_and_persist_source(trace), do: trace
+
defp get_trace(socket, string_trace_id) do
TracesStorage.get_by_id!(socket.assigns.lv_process.pid, String.to_integer(string_trace_id))
end
diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex b/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex
index e99337905..f8612e058 100644
--- a/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex
+++ b/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex
@@ -12,6 +12,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do
alias LiveDebugger.Structs.LvProcess
+ alias LiveDebugger.App.Debugger.Utils.Editor
alias LiveDebugger.Bus
alias LiveDebugger.App.Debugger.Events.NodeIdParamChanged
@@ -74,7 +75,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do
displayed_trace: nil,
tracing_started?: false,
trace_callback_running?: false,
- trace_search_phrase: ""
+ trace_search_phrase: "",
+ elixir_editor: Editor.detect_editor()
)
|> stream(:existing_traces, [], reset: true)
|> put_private(:page_size, @page_size)
@@ -138,7 +140,11 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do
existing_traces={@streams.existing_traces}
>
<:trace :let={{id, trace_display}}>
-
+
<:label>
<.trace_label
id={id <> "-label"}
@@ -153,6 +159,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do
id={id <> "-body"}
trace_display={trace_display}
search_phrase={@trace_search_phrase}
+ elixir_editor={@elixir_editor}
/>
@@ -172,6 +179,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do
displayed_trace={@displayed_trace}
search_phrase={@trace_search_phrase}
page={:node_inspector}
+ elixir_editor={@elixir_editor}
/>
"""
diff --git a/lib/live_debugger/app/debugger/utils/editor.ex b/lib/live_debugger/app/debugger/utils/editor.ex
index 3802f85c2..36b1a2dd3 100644
--- a/lib/live_debugger/app/debugger/utils/editor.ex
+++ b/lib/live_debugger/app/debugger/utils/editor.ex
@@ -39,6 +39,45 @@ defmodule LiveDebugger.App.Debugger.Utils.Editor do
end
end
+ @editor_docs_url "https://hexdocs.pm/live_debugger/open_in_editor.html"
+
+ @doc """
+ Returns the URL to the "Open in Editor" documentation.
+ """
+ @spec editor_docs_url() :: String.t()
+ def editor_docs_url, do: @editor_docs_url
+
+ @doc """
+ Opens a file in the editor. Spawns a separate process to avoid blocking iex.
+ On error, sends a flash message to the given pid.
+ """
+ @spec open_in_editor(String.t(), String.t(), integer(), pid()) :: :ok
+ def open_in_editor(editor, file, line, flash_pid) do
+ alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData
+
+ cmd = get_editor_cmd(editor, file, line)
+
+ spawn(fn ->
+ case run_shell_cmd(cmd) do
+ :ok ->
+ :ok
+
+ {:error, reason} ->
+ send(
+ flash_pid,
+ {:put_flash, :error,
+ %LinkFlashData{
+ text: reason,
+ url: @editor_docs_url,
+ label: "See the docs"
+ }}
+ )
+ end
+ end)
+
+ :ok
+ end
+
@spec run_shell_cmd(String.t()) :: :ok | {:error, term()}
def run_shell_cmd(command) do
case System.shell(command, stderr_to_stdout: true) do
diff --git a/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex b/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex
index ec3d174bc..5f16df22c 100644
--- a/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex
+++ b/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex
@@ -5,35 +5,21 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
use LiveDebugger.App.Web, :live_component
- import LiveDebugger.App.Web.Hooks.Flash, only: [push_flash: 3]
alias LiveDebugger.App.Debugger.Structs.TreeNode
alias LiveDebugger.App.Debugger.Queries.Node, as: NodeQueries
alias LiveDebugger.App.Debugger.Web.LiveComponents.SendEventFullscreen
alias LiveDebugger.App.Utils.Parsers
alias LiveDebugger.App.Debugger.Web.Components.Pages
alias LiveDebugger.App.Debugger.Utils.Editor
- alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData
-
- @editor_docs_url "https://hexdocs.pm/live_debugger/open_in_editor.html"
@impl true
- def update(%{:editor_error => editor_error}, socket) do
- socket
- |> push_flash(:error, %LinkFlashData{
- text: editor_error,
- url: @editor_docs_url,
- label: "See the docs"
- })
- |> ok()
- end
-
def update(assigns, socket) do
socket
|> assign(:id, assigns.id)
|> assign(:node_id, assigns.node_id)
|> assign(:lv_process, assigns.lv_process)
|> assign(:elixir_editor, Editor.detect_editor())
- |> assign(:editor_docs_url, @editor_docs_url)
+ |> assign(:editor_docs_url, Editor.editor_docs_url())
|> assign_node_type()
|> assign_async_node_module()
|> ok()
@@ -170,24 +156,7 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
end
def handle_event("open-in-editor", %{"file" => file, "line" => line}, socket) do
- cmd = Editor.get_editor_cmd(socket.assigns.elixir_editor, file, line |> String.to_integer())
-
- # Some editors may block iex, so we spawn a new process
- component_id = socket.assigns.id
- component_pid = self()
-
- spawn(fn ->
- case Editor.run_shell_cmd(cmd) do
- :ok ->
- :ok
-
- {:error, reason} ->
- send_update(component_pid, __MODULE__,
- id: component_id,
- editor_error: reason
- )
- end
- end)
+ Editor.open_in_editor(socket.assigns.elixir_editor, file, String.to_integer(line), self())
{:noreply, socket}
end
diff --git a/lib/live_debugger/structs/trace/function_trace.ex b/lib/live_debugger/structs/trace/function_trace.ex
index 37a24a45a..25b251805 100644
--- a/lib/live_debugger/structs/trace/function_trace.ex
+++ b/lib/live_debugger/structs/trace/function_trace.ex
@@ -13,6 +13,16 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do
alias LiveDebugger.Structs.Trace
alias LiveDebugger.Structs.Trace.ErrorTrace
+ defmodule SourceLocation do
+ @moduledoc false
+ defstruct [:source_file, :line]
+
+ @type t :: %__MODULE__{
+ source_file: String.t(),
+ line: non_neg_integer()
+ }
+ end
+
defstruct [
:id,
:pid,
@@ -27,7 +37,8 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do
:execution_time,
:type,
:return_value,
- :error
+ :error,
+ :source
]
@type t() :: %__MODULE__{
@@ -45,7 +56,8 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do
execution_time: non_neg_integer() | nil,
type: :call | :return_from | :exception_from,
return_value: term() | nil,
- error: ErrorTrace.t() | nil
+ error: ErrorTrace.t() | nil,
+ source: SourceLocation.t() | nil
}
@doc """
diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex
new file mode 100644
index 000000000..7da11811e
--- /dev/null
+++ b/lib/live_debugger/utils/function_matcher.ex
@@ -0,0 +1,112 @@
+defmodule LiveDebugger.Utils.FunctionMatcher do
+ @moduledoc """
+ Matches function calls to their defining clause by replaying pattern matching
+ against BEAM debug info. Given a module, function name, and actual arguments,
+ it identifies which clause would handle the call and returns its source location.
+
+ Inspired by Elixir's exception logic:
+ https://github.com/elixir-lang/elixir/blob/v1.20.0-rc.3/lib/elixir/lib/exception.ex#L245
+ """
+ alias LiveDebugger.Structs.Trace.FunctionTrace.SourceLocation
+
+ def find_matching_clause_line(module, function_name, args) do
+ with {:ok, clauses} <- get_function_clauses(module, function_name, length(args)),
+ {:ok, matching} <- find_matching(clauses, args),
+ {:ok, file_path} <- get_module_file(module) do
+ {:ok, %SourceLocation{source_file: file_path, line: matching.line}}
+ end
+ rescue
+ _ -> :error
+ end
+
+ defp get_function_clauses(module, function_name, arity) do
+ with [_ | _] = path <- :code.which(module),
+ {:ok, {_, [debug_info: debug_info]}} <- :beam_lib.chunks(path, [:debug_info]),
+ {:debug_info_v1, backend, data} <- debug_info,
+ {:ok, %{definitions: defs}} <- backend.debug_info(:elixir_v1, module, data, []),
+ {_, _kind, _, clauses} <- List.keyfind(defs, {function_name, arity}, 0) do
+ enriched =
+ for {meta, ex_args, guards, _body} <- clauses do
+ scope = :elixir_erl.scope(meta, true)
+ ann = :elixir_erl.get_ann(meta)
+
+ {erl_args, scope} =
+ :elixir_erl_clauses.match(
+ ann,
+ &:elixir_erl_pass.translate_args/3,
+ ex_args,
+ scope
+ )
+
+ erl_guards = translate_guards(guards, ann, scope)
+
+ %{
+ line: Keyword.get(meta, :line, 0),
+ erl_args: erl_args,
+ erl_guards: erl_guards
+ }
+ end
+
+ {:ok, enriched}
+ else
+ _ -> {:error, :cannot_read_debug_info}
+ end
+ end
+
+ defp find_matching(clauses, args) do
+ Enum.find_value(clauses, {:error, :no_matching_clause}, fn clause ->
+ case try_match_clause(clause, args) do
+ true -> {:ok, clause}
+ false -> false
+ end
+ end)
+ end
+
+ defp try_match_clause(clause, args) do
+ try do
+ ann = :erl_anno.new(0)
+
+ binding = :orddict.store(:VAR, List.to_tuple(args), [])
+
+ pattern_tuple = {:tuple, ann, clause.erl_args}
+
+ {:value, _, binding} =
+ :erl_eval.expr({:match, ann, pattern_tuple, {:var, ann, :VAR}}, binding, :none)
+
+ check_guards(clause.erl_guards, binding)
+ rescue
+ _ -> false
+ catch
+ _, _ -> false
+ end
+ end
+
+ defp check_guards([], _binding), do: true
+
+ defp check_guards(guards, binding) do
+ Enum.any?(guards, fn guard ->
+ try do
+ {:value, true, _} = :erl_eval.expr(guard, binding, :none)
+ true
+ rescue
+ _ -> false
+ catch
+ _, _ -> false
+ end
+ end)
+ end
+
+ defp translate_guards(guards, ann, scope) do
+ Enum.map(guards, fn guard ->
+ {erl_guard, _scope} = :elixir_erl_pass.translate(guard, ann, scope)
+ erl_guard
+ end)
+ end
+
+ defp get_module_file(module) do
+ case module.module_info(:compile)[:source] do
+ nil -> {:error, :module_file_not_found}
+ source -> {:ok, List.to_string(source)}
+ end
+ end
+end
diff --git a/test/app/debugger/utils/editor_test.exs b/test/app/debugger/utils/editor_test.exs
index b6405b956..c756c9aca 100644
--- a/test/app/debugger/utils/editor_test.exs
+++ b/test/app/debugger/utils/editor_test.exs
@@ -50,6 +50,25 @@ defmodule LiveDebugger.App.Debugger.Utils.EditorTest do
end)
end
+ test "sends flash error on command failure" do
+ alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData
+
+ Editor.open_in_editor("1234", "lib/app.ex", 15, self())
+
+ assert_receive {:put_flash, :error, %LinkFlashData{text: text, url: url, label: label}},
+ 5_000
+
+ assert text =~ "Error when opening editor"
+ assert url =~ "open_in_editor"
+ assert label == "See the docs"
+ end
+
+ test "does not send flash on success" do
+ Editor.open_in_editor("echo", "lib/app.ex", 15, self())
+
+ refute_receive {:put_flash, _, _}, 1_000
+ end
+
# Sets envs only for the duration of the test
defp with_env(env_map, fun) do
original_state = Map.new(env_map, fn {k, _} -> {k, System.get_env(k)} end)
diff --git a/test/utils/function_matcher_test.exs b/test/utils/function_matcher_test.exs
new file mode 100644
index 000000000..91413a4da
--- /dev/null
+++ b/test/utils/function_matcher_test.exs
@@ -0,0 +1,125 @@
+defmodule LiveDebugger.Utils.FunctionMatcherTest do
+ use ExUnit.Case, async: true
+
+ alias LiveDebugger.Utils.FunctionMatcher
+ alias LiveDebugger.Structs.Trace.FunctionTrace.SourceLocation
+
+ describe "find_matching_clause_line/3" do
+ test "returns source location for a simple function" do
+ args = [%{counter: 0}]
+
+ assert {:ok, %SourceLocation{source_file: file, line: line}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :render,
+ args
+ )
+
+ assert is_binary(file)
+ assert String.ends_with?(file, "dev/live_views/main.ex")
+ assert is_integer(line)
+ assert line == 40
+ end
+
+ test "matches the correct clause when guards are present" do
+ socket = %Phoenix.LiveView.Socket{}
+
+ assert {:ok, %SourceLocation{line: guarded_line}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :handle_event,
+ ["increment", %{}, socket]
+ )
+
+ assert {:ok, %SourceLocation{line: string_line}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :handle_event,
+ ["increment", %{}, "a string socket"]
+ )
+
+ assert string_line < guarded_line
+ end
+
+ test "matches different clauses based on pattern matching" do
+ socket = %Phoenix.LiveView.Socket{}
+
+ assert {:ok, %SourceLocation{line: increment_line}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :handle_event,
+ ["increment", %{}, socket]
+ )
+
+ assert {:ok, %SourceLocation{line: slow_line}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :handle_event,
+ ["slow-increment", %{}, socket]
+ )
+
+ assert increment_line != slow_line
+ end
+
+ test "returns the same file path for all clauses of the same module" do
+ socket = %Phoenix.LiveView.Socket{}
+
+ assert {:ok, %SourceLocation{source_file: file1}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :render,
+ [%{counter: 0}]
+ )
+
+ assert {:ok, %SourceLocation{source_file: file2}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :handle_event,
+ ["increment", %{}, socket]
+ )
+
+ assert file1 == file2
+ end
+
+ test "returns error for non-existent module" do
+ assert {:error, _} =
+ FunctionMatcher.find_matching_clause_line(
+ NonExistentModule,
+ :foo,
+ []
+ )
+ end
+
+ test "returns error for non-existent function" do
+ assert {:error, _} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :non_existent_function,
+ []
+ )
+ end
+
+ test "returns error when no clause matches the given args" do
+ assert {:error, :no_matching_clause} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveViews.Main,
+ :handle_info,
+ [:unmatched_message, %Phoenix.LiveView.Socket{}]
+ )
+ end
+
+ test "works with LiveComponent modules" do
+ assigns = %{id: "test", myself: %Phoenix.LiveComponent.CID{cid: 1}}
+
+ assert {:ok, %SourceLocation{source_file: file, line: line}} =
+ FunctionMatcher.find_matching_clause_line(
+ LiveDebuggerDev.LiveComponents.Name,
+ :update,
+ [assigns, %Phoenix.LiveView.Socket{}]
+ )
+
+ assert String.ends_with?(file, "dev/live_components/name.ex")
+ assert line > 0
+ end
+ end
+end