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 /> + + <.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