From fb1becbe6931cef84ab1d53faf1f38bdee515694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Sun, 23 Nov 2025 13:04:16 +0100 Subject: [PATCH 1/7] feat(engine): support shorthand notation inside ~H sigil The full-module notation (``) was already supported in "go to definition", because when passed to ElixirSense, it correctly recognized the form and found the appropriate module and the function. However, with a shorthand notation it was not so simple, because `.button` is not valid Elixir and ElixirSense was not able to make anything of it. This implements the support for shorthand notation by doing a preliminary step before sending the code to ElixirSense. It modifies the AST and the Document of ForgeAnalysis and replaces all the calls to `<.button>` with `< button(assigns)` so that ElixirSense can correctly interpret it as a local function call with arity 1. This works for functions defined in the same module, but also for imported functions. Support for ending tag is also included. While this might seem as a hacky solution, it makes handling shorthand notation as close as possible to handling the full-module notation, making sure these two stay conceptually close. --- .../engine/code_intelligence/definition.ex | 1 + .../lib/engine/code_intelligence/entity.ex | 19 ++- .../code_intelligence/heex_normalizer.ex | 157 ++++++++++++++++++ .../code_intelligence/definition_test.exs | 110 +++++++++++- .../fixtures/navigations/lib/my_definition.ex | 5 + .../fixtures/navigations/lib/my_live_view.ex | 20 +++ 6 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 apps/engine/lib/engine/code_intelligence/heex_normalizer.ex create mode 100644 apps/forge/test/fixtures/navigations/lib/my_live_view.ex diff --git a/apps/engine/lib/engine/code_intelligence/definition.ex b/apps/engine/lib/engine/code_intelligence/definition.ex index d8f21302..93a3fb82 100644 --- a/apps/engine/lib/engine/code_intelligence/definition.ex +++ b/apps/engine/lib/engine/code_intelligence/definition.ex @@ -85,6 +85,7 @@ defmodule Engine.CodeIntelligence.Definition do [] -> Logger.info("No definition found for #{inspect(resolved)} with Indexer.") + analysis = Engine.CodeIntelligence.HeexNormalizer.call(analysis, position) elixir_sense_definition(analysis, position) [location] -> diff --git a/apps/engine/lib/engine/code_intelligence/entity.ex b/apps/engine/lib/engine/code_intelligence/entity.ex index 515836b9..cf7d632c 100644 --- a/apps/engine/lib/engine/code_intelligence/entity.ex +++ b/apps/engine/lib/engine/code_intelligence/entity.ex @@ -30,7 +30,10 @@ defmodule Engine.CodeIntelligence.Entity do """ @spec resolve(Analysis.t(), Position.t()) :: {:ok, resolved, Range.t()} | {:error, term()} def resolve(%Analysis{} = analysis, %Position{} = position) do - analysis = Ast.reanalyze_to(analysis, position) + analysis = + analysis + |> Ast.reanalyze_to(position) + |> Engine.CodeIntelligence.HeexNormalizer.call(position) with :ok <- check_commented(analysis, position), {:ok, surround_context} <- Ast.surround_context(analysis, position), @@ -39,9 +42,17 @@ defmodule Engine.CodeIntelligence.Entity do Logger.info("Resolved entity: #{inspect(resolved)}") {:ok, resolved, to_range(analysis.document, begin_pos, end_pos)} else - :error -> {:error, :not_found} - {:error, :surround_context} -> maybe_local_capture_func(analysis, position) - {:error, _} = error -> error + :error -> + {:error, :not_found} + + {:error, :surround_context} -> + case maybe_local_capture_func(analysis, position) do + {:ok, _, _} = result -> result + _ -> {:error, :not_found} + end + + {:error, _} = error -> + error end end diff --git a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex new file mode 100644 index 00000000..6d639e81 --- /dev/null +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -0,0 +1,157 @@ +defmodule Engine.CodeIntelligence.HeexNormalizer do + @moduledoc false + + alias Forge.Ast + alias Forge.Ast.Analysis + alias Forge.Document + alias Forge.Document.Position + alias Forge.Document.Range + alias Sourceror.Zipper + + @component_regex ~r/<\/?\.([a-zA-Z0-9_!?.]+)/ + @opening_replacement "< \1(assigns)" + @closing_replacement " Zipper.zip() + |> Zipper.find(&(&1 == sigil)) + |> case do + nil -> analysis.ast + zipper -> zipper |> Zipper.replace(new_sigil) |> Zipper.root() + end + else + _ -> analysis.ast + end + end + + defp normalize_document(analysis, position) do + case extract_heex_range(analysis, position) do + {:ok, _sigil, start_pos, end_pos} -> + start_pos = Position.new(analysis.document, start_pos[:line], start_pos[:column]) + end_pos = Position.new(analysis.document, end_pos[:line], end_pos[:column]) + range = Range.new(start_pos, end_pos) + + original_text = Document.fragment(analysis.document, start_pos, end_pos) + new_text = normalize_heex_text(analysis.document, original_text, position, start_pos) + + change = %{range: range, text: new_text} + + case Document.apply_content_changes(analysis.document, analysis.document.version + 1, [ + change + ]) do + {:ok, doc} -> doc + _ -> analysis.document + end + + _ -> + analysis.document + end + end + + defp extract_heex_range(analysis, position) do + with {:ok, path} <- Ast.path_at(analysis, position), + {:sigil_H, _, _} = sigil <- Enum.find(path, &match?({:sigil_H, _, _}, &1)), + %{start: start_pos, end: end_pos} <- Sourceror.get_range(sigil) do + {:ok, sigil, start_pos, end_pos} + else + _ -> :error + end + end + + defp normalize_heex_text(document, original_text, cursor_position, start_pos) do + text_before = Document.fragment(document, start_pos, cursor_position) + cursor_offset = byte_size(text_before) + + case find_component_match(original_text, cursor_offset) do + {match_start, match_length, component_name, is_closing} -> + build_replacement_text( + original_text, + match_start, + match_length, + component_name, + is_closing + ) + + nil -> + original_text + end + end + + defp find_component_match(text, cursor_offset) do + matches = Regex.scan(@component_regex, text, return: :index) + + Enum.find_value(matches, fn + [{match_start, match_len}, {name_start, name_len}] -> + if cursor_offset >= match_start and cursor_offset <= match_start + match_len do + matched_text = binary_part(text, match_start, match_len) + component_name = binary_part(text, name_start, name_len) + is_closing = String.starts_with?(matched_text, " replacement <> suffix + end + + defp normalize_heex_node({:sigil_H, meta, [{:<<>>, string_meta, parts}, modifiers]}) + when is_list(parts) do + new_parts = + Enum.map(parts, fn + part when is_binary(part) -> + part + |> then(&Regex.replace(@component_regex, &1, @opening_replacement)) + |> then(&Regex.replace(~r/<\/\.([a-zA-Z0-9_!?.]+)/, &1, @closing_replacement)) + + other -> + other + end) + + {:sigil_H, meta, [{:<<>>, string_meta, new_parts}, modifiers]} + end + + defp normalize_heex_node(node), do: node +end diff --git a/apps/expert/test/engine/code_intelligence/definition_test.exs b/apps/expert/test/engine/code_intelligence/definition_test.exs index c42a30dd..318313cb 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -178,7 +178,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do assert {:ok, ^referenced_uri, definition_line} = definition(project, subject_module, referenced_uri) - assert definition_line == ~S[ def «greet(name)» do] + assert definition_line == ~S[ def «greet»(name) do] end test "find the definition of a remote macro call", @@ -479,6 +479,101 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end end + describe "definition/2 within LiveView's ~H sigil" do + setup [:with_referenced_file] + + test "find the definition when full module specified", %{ + project: project, + uri: uri, + subject_uri: subject_uri + } do + subject_module = ~q[ + defmodule MyLiveView do + def render(assigns) do + ~H""" + Home + """ + end + end + ] + + result = definition(project, subject_module, [uri, subject_uri]) + assert {:ok, file, definition} = result + + assert file == uri + assert definition == " def «button»(_assigns) do" + end + + test "find the definition when shorthand notation for function from same module", %{ + project: project, + uri: uri, + subject_uri: subject_uri + } do + subject_module = ~q[ + defmodule MyLiveView do + def render(assigns) do + ~H""" + <.but|ton navigate="/home">Home + """ + end + + def button(_assigns), do: nil + end + ] + + result = definition(project, subject_module, [uri, subject_uri]) + assert {:ok, file, fragment} = result + assert file == subject_uri + assert fragment == " def «button»(_assigns), do: nil" + end + + test "find the definition when shorthand notation used and imported function", %{ + project: project, + uri: uri, + subject_uri: subject_uri + } do + subject_module = ~q[ + defmodule MyLiveView do + import MyDefinition + + def render(assigns) do + ~H""" + <.but|ton navigate="/home">Home + """ + end + end + ] + + result = definition(project, subject_module, [uri, subject_uri]) + assert {:ok, file, fragment} = result + assert file == uri + assert fragment == " def «button»(_assigns) do" + end + + test "find the definition when shorthand notation used on closing tag", %{ + project: project, + uri: uri, + subject_uri: subject_uri + } do + subject_module = ~q[ + defmodule MyLiveView do + import MyDefinition + + def render(assigns) do + ~H""" + <.button navigate="/home">Home + """ + end + end + ] + + result = definition(project, subject_module, [uri, subject_uri]) + assert {:ok, file, fragment} = result + assert file == uri + assert fragment == " def «button»(_assigns) do" + end + end + describe "edge cases" do setup [:with_referenced_file] @@ -500,10 +595,15 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do :ok <- index(project, referenced_uri), {:ok, location} <- EngineApi.definition(project, document, position) do - if is_list(location) do - {:ok, Enum.map(location, &{&1.document.uri, decorate(&1.document, &1.range)})} - else - {:ok, location.document.uri, decorate(location.document, location.range)} + cond do + is_list(location) -> + {:ok, Enum.map(location, &{&1.document.uri, decorate(&1.document, &1.range)})} + + location == nil -> + {:ok, nil} + + true -> + {:ok, location.document.uri, decorate(location.document, location.range)} end end end diff --git a/apps/forge/test/fixtures/navigations/lib/my_definition.ex b/apps/forge/test/fixtures/navigations/lib/my_definition.ex index 28d9d4e5..e0f2d7cf 100644 --- a/apps/forge/test/fixtures/navigations/lib/my_definition.ex +++ b/apps/forge/test/fixtures/navigations/lib/my_definition.ex @@ -23,4 +23,9 @@ defmodule MyDefinition do IO.puts("Hello, world!") end end + + # This simulates Phoenix component, but ~H is not available here, so it would not compile + def button(_assigns) do + nil + end end diff --git a/apps/forge/test/fixtures/navigations/lib/my_live_view.ex b/apps/forge/test/fixtures/navigations/lib/my_live_view.ex new file mode 100644 index 00000000..b94b9477 --- /dev/null +++ b/apps/forge/test/fixtures/navigations/lib/my_live_view.ex @@ -0,0 +1,20 @@ +defmodule Navigations.MyLiveView do + use Phoenix.Component + import MyComponents + + def render(assigns) do + ~H""" + <.button>Click me + <.table rows={@rows}> + <:col header="Name"><%= @row.name %> + + Click me too + """ + end + + def table(assigns) do + ~H""" + <%= for row <- @rows do %><%= render_slot(@col, row) %><% end %>
+ """ + end +end From b566d41d8626a427eaa4228aceeeac48fd9b275b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Fri, 19 Dec 2025 16:30:15 +0100 Subject: [PATCH 2/7] Fix regex for opening and closing tags --- .../lib/engine/code_intelligence/heex_normalizer.ex | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex index 6d639e81..d7c22fe8 100644 --- a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -8,9 +8,13 @@ defmodule Engine.CodeIntelligence.HeexNormalizer do alias Forge.Document.Range alias Sourceror.Zipper + # Matches both opening and closing shorthand components (used for cursor detection) @component_regex ~r/<\/?\.([a-zA-Z0-9_!?.]+)/ - @opening_replacement "< \1(assigns)" - @closing_replacement " part - |> then(&Regex.replace(@component_regex, &1, @opening_replacement)) - |> then(&Regex.replace(~r/<\/\.([a-zA-Z0-9_!?.]+)/, &1, @closing_replacement)) + |> then(&Regex.replace(@closing_component_regex, &1, @closing_replacement)) + |> then(&Regex.replace(@opening_component_regex, &1, @opening_replacement)) other -> other From 8b2fa42a4614f2fd5d28ab3109f02648d0e056f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Tue, 23 Dec 2025 14:13:40 +0100 Subject: [PATCH 3/7] Only trigger HEEx normalization when phoenix_live_view is present in deps --- .../code_intelligence/heex_normalizer.ex | 17 ++++++-- apps/expert/mix.exs | 1 + apps/expert/mix.lock | 9 ++++ .../code_intelligence/definition_test.exs | 8 +++- .../fixtures/navigations/lib/my_components.ex | 43 +++++++++++++++++++ .../lib/basic_module.ex | 33 ++++++++++++++ .../lib/sigil_example.ex | 28 ++++++++++++ .../fixtures/sigils_without_live_view/mix.exs | 25 +++++++++++ 8 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 apps/forge/test/fixtures/navigations/lib/my_components.ex create mode 100644 apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex create mode 100644 apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex create mode 100644 apps/forge/test/fixtures/sigils_without_live_view/mix.exs diff --git a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex index d7c22fe8..59768705 100644 --- a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -22,11 +22,22 @@ defmodule Engine.CodeIntelligence.HeexNormalizer do # # This allows ElixirSense to understand the shorthand HEEX notation as a local function # (be it imported or not) and return correct location for go-to-definition and hover. + # + # This normalization is only performed when Phoenix.Component is available in the project + # (i.e., phoenix_live_view is in the dependencies). @spec call(Analysis.t(), Position.t()) :: Analysis.t() def call(analysis, position) do - new_ast = normalize_ast(analysis, position) - new_document = normalize_document(analysis, position) - %{analysis | ast: new_ast, document: new_document} + if phoenix_component_available?() do + new_ast = normalize_ast(analysis, position) + new_document = normalize_document(analysis, position) + %{analysis | ast: new_ast, document: new_document} + else + analysis + end + end + + defp phoenix_component_available? do + Code.ensure_loaded?(Phoenix.Component) end defp normalize_ast(analysis, position) do diff --git a/apps/expert/mix.exs b/apps/expert/mix.exs index 6bbb161c..3ea49f9f 100644 --- a/apps/expert/mix.exs +++ b/apps/expert/mix.exs @@ -91,6 +91,7 @@ defmodule Expert.MixProject do {:logger_file_backend, "~> 0.0", only: [:dev, :prod]}, {:patch, "~> 0.15", runtime: false, only: [:dev, :test]}, {:path_glob, "~> 0.2"}, + {:phoenix_live_view, "~> 1.0", only: [:test], runtime: false}, {:schematic, "~> 0.2"}, {:sourceror, "~> 1.9"} ] diff --git a/apps/expert/mix.lock b/apps/expert/mix.lock index 2f2186f7..9361d23f 100644 --- a/apps/expert/mix.lock +++ b/apps/expert/mix.lock @@ -19,6 +19,13 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "patch": {:hex, :patch, "0.15.0", "947dd6a8b24a2d2d1137721f20bb96a8feb4f83248e7b4ad88b4871d52807af5", [:mix], [], "hexpm", "e8dadf9b57b30e92f6b2b1ce2f7f57700d14c66d4ed56ee27777eb73fb77e58d"}, "path_glob": {:hex, :path_glob, "0.2.0", "b9e34b5045cac5ecb76ef1aa55281a52bf603bf7009002085de40958064ca312", [:mix], [{:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "be2594cb4553169a1a189f95193d910115f64f15f0d689454bb4e8cfae2e7ebc"}, + "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "refactorex": {:hex, :refactorex, "0.1.52", "22a69062c84e0f20a752d3d6580269c09c242645ee4f722f03d4270dd8cbf218", [:mix], [{:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "4927fe6c3acd1f4695d6d3e443380167d61d004d507b1279c6084433900c94d0"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"}, @@ -26,4 +33,6 @@ "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, } diff --git a/apps/expert/test/engine/code_intelligence/definition_test.exs b/apps/expert/test/engine/code_intelligence/definition_test.exs index 318313cb..075feb35 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -178,7 +178,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do assert {:ok, ^referenced_uri, definition_line} = definition(project, subject_module, referenced_uri) - assert definition_line == ~S[ def «greet»(name) do] + assert definition_line == ~S[ def «greet(name)» do] end test "find the definition of a remote macro call", @@ -489,6 +489,8 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do } do subject_module = ~q[ defmodule MyLiveView do + use Phoenix.Component + def render(assigns) do ~H""" Home @@ -511,6 +513,8 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do } do subject_module = ~q[ defmodule MyLiveView do + use Phoenix.Component + def render(assigns) do ~H""" <.but|ton navigate="/home">Home @@ -534,6 +538,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do } do subject_module = ~q[ defmodule MyLiveView do + use Phoenix.Component import MyDefinition def render(assigns) do @@ -557,6 +562,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do } do subject_module = ~q[ defmodule MyLiveView do + use Phoenix.Component import MyDefinition def render(assigns) do diff --git a/apps/forge/test/fixtures/navigations/lib/my_components.ex b/apps/forge/test/fixtures/navigations/lib/my_components.ex new file mode 100644 index 00000000..340d68da --- /dev/null +++ b/apps/forge/test/fixtures/navigations/lib/my_components.ex @@ -0,0 +1,43 @@ +defmodule MyComponents do + @moduledoc """ + Example Phoenix components module for testing ~H sigil handling. + This module provides components that can be used with shorthand notation. + """ + + use Phoenix.Component + + @doc """ + A simple button component. + """ + def button(assigns) do + ~H""" + + """ + end + + @doc """ + A table component with slots for columns. + """ + def table(assigns) do + ~H""" + + + + <%= for col <- @col do %> + + <% end %> + + + + <%= for row <- @rows do %> + + <%= for col <- @col do %> + + <% end %> + + <% end %> + +
<%= col.header %>
<%= render_slot(col, row) %>
+ """ + end +end diff --git a/apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex b/apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex new file mode 100644 index 00000000..e417418b --- /dev/null +++ b/apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex @@ -0,0 +1,33 @@ +defmodule SigilsWithoutLiveView.BasicModule do + @moduledoc """ + A basic Elixir module without any Phoenix dependencies. + This module demonstrates standard Elixir code that should work + regardless of whether Phoenix/LiveView is available. + """ + + @type result :: String.t() + + defstruct [:name, :value] + + @doc """ + A simple greeting function. + """ + @spec greet(String.t()) :: result + def greet(name) do + "Hello, #{name}!" + end + + @doc """ + Returns a list of items. + """ + def list_items do + [:item1, :item2, :item3] + end + + @doc """ + Processes a struct. + """ + def process(%__MODULE__{name: name, value: value}) do + {name, value} + end +end diff --git a/apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex b/apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex new file mode 100644 index 00000000..e0a53c20 --- /dev/null +++ b/apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex @@ -0,0 +1,28 @@ +defmodule SigilsWithoutLiveView.SigilExample do + @moduledoc """ + Demonstrates that the HeexNormalizer only activates when `use Phoenix.Component` + (or LiveView/LiveComponent) is present. This module defines a custom ~H sigil, + but jump-to-definition for `<.button>` will NOT work because Phoenix.Component + is not used. + """ + + defmacrop sigil_H({:<<>>, _meta, [string]}, _modifiers) when is_binary(string) do + string + end + + defmacrop sigil_H({:<<>>, _meta, _parts} = ast, _modifiers) do + quote do: unquote(ast) + end + + def button(assigns) do + "" + end + + def render(assigns) do + ~H""" +
+ <.button label="Click me"> +
+ """ + end +end diff --git a/apps/forge/test/fixtures/sigils_without_live_view/mix.exs b/apps/forge/test/fixtures/sigils_without_live_view/mix.exs new file mode 100644 index 00000000..1e9f0f1d --- /dev/null +++ b/apps/forge/test/fixtures/sigils_without_live_view/mix.exs @@ -0,0 +1,25 @@ +defmodule SigilsWithoutLiveView.MixProject do + use Mix.Project + + def project do + Code.put_compiler_option(:ignore_module_conflict, true) + + [ + app: :sigils_without_live_view, + version: "0.1.0", + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [] + end +end From d14b31b9598badbe4839a32367b4ac242a65e993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Tue, 23 Dec 2025 21:55:03 +0100 Subject: [PATCH 4/7] Rename call as maybe_normalize --- apps/engine/lib/engine/code_intelligence/definition.ex | 2 +- apps/engine/lib/engine/code_intelligence/entity.ex | 2 +- apps/engine/lib/engine/code_intelligence/heex_normalizer.ex | 4 ++-- .../expert/test/engine/code_intelligence/definition_test.exs | 5 ++++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/engine/lib/engine/code_intelligence/definition.ex b/apps/engine/lib/engine/code_intelligence/definition.ex index 93a3fb82..f557f2b8 100644 --- a/apps/engine/lib/engine/code_intelligence/definition.ex +++ b/apps/engine/lib/engine/code_intelligence/definition.ex @@ -85,7 +85,7 @@ defmodule Engine.CodeIntelligence.Definition do [] -> Logger.info("No definition found for #{inspect(resolved)} with Indexer.") - analysis = Engine.CodeIntelligence.HeexNormalizer.call(analysis, position) + analysis = Engine.CodeIntelligence.HeexNormalizer.maybe_normalize(analysis, position) elixir_sense_definition(analysis, position) [location] -> diff --git a/apps/engine/lib/engine/code_intelligence/entity.ex b/apps/engine/lib/engine/code_intelligence/entity.ex index cf7d632c..669bf02a 100644 --- a/apps/engine/lib/engine/code_intelligence/entity.ex +++ b/apps/engine/lib/engine/code_intelligence/entity.ex @@ -33,7 +33,7 @@ defmodule Engine.CodeIntelligence.Entity do analysis = analysis |> Ast.reanalyze_to(position) - |> Engine.CodeIntelligence.HeexNormalizer.call(position) + |> Engine.CodeIntelligence.HeexNormalizer.maybe_normalize(position) with :ok <- check_commented(analysis, position), {:ok, surround_context} <- Ast.surround_context(analysis, position), diff --git a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex index 59768705..134262ad 100644 --- a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -25,8 +25,8 @@ defmodule Engine.CodeIntelligence.HeexNormalizer do # # This normalization is only performed when Phoenix.Component is available in the project # (i.e., phoenix_live_view is in the dependencies). - @spec call(Analysis.t(), Position.t()) :: Analysis.t() - def call(analysis, position) do + @spec maybe_normalize(Analysis.t(), Position.t()) :: Analysis.t() + def maybe_normalize(analysis, position) do if phoenix_component_available?() do new_ast = normalize_ast(analysis, position) new_document = normalize_document(analysis, position) diff --git a/apps/expert/test/engine/code_intelligence/definition_test.exs b/apps/expert/test/engine/code_intelligence/definition_test.exs index 075feb35..23614725 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -591,7 +591,10 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - assert {:ok, _file, _definition} = definition(project, subject_module, [uri]) + # The test verifies no crash occurs; finding the definition is a bonus + # (behavior varies by Elixir/OTP version for macro-generated modules) + result = definition(project, subject_module, [uri]) + assert match?({:ok, _}, result) or match?({:ok, _, _}, result) end end From ce36de35dbf6fc4fad38960458a42d68877f91b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Tue, 23 Dec 2025 22:38:22 +0100 Subject: [PATCH 5/7] Delete fixture app (not used in tests) The testing happens in Engine and there the presence of LiveView is mocked. --- .../lib/basic_module.ex | 33 ------------------- .../lib/sigil_example.ex | 28 ---------------- .../fixtures/sigils_without_live_view/mix.exs | 25 -------------- 3 files changed, 86 deletions(-) delete mode 100644 apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex delete mode 100644 apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex delete mode 100644 apps/forge/test/fixtures/sigils_without_live_view/mix.exs diff --git a/apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex b/apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex deleted file mode 100644 index e417418b..00000000 --- a/apps/forge/test/fixtures/sigils_without_live_view/lib/basic_module.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule SigilsWithoutLiveView.BasicModule do - @moduledoc """ - A basic Elixir module without any Phoenix dependencies. - This module demonstrates standard Elixir code that should work - regardless of whether Phoenix/LiveView is available. - """ - - @type result :: String.t() - - defstruct [:name, :value] - - @doc """ - A simple greeting function. - """ - @spec greet(String.t()) :: result - def greet(name) do - "Hello, #{name}!" - end - - @doc """ - Returns a list of items. - """ - def list_items do - [:item1, :item2, :item3] - end - - @doc """ - Processes a struct. - """ - def process(%__MODULE__{name: name, value: value}) do - {name, value} - end -end diff --git a/apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex b/apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex deleted file mode 100644 index e0a53c20..00000000 --- a/apps/forge/test/fixtures/sigils_without_live_view/lib/sigil_example.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule SigilsWithoutLiveView.SigilExample do - @moduledoc """ - Demonstrates that the HeexNormalizer only activates when `use Phoenix.Component` - (or LiveView/LiveComponent) is present. This module defines a custom ~H sigil, - but jump-to-definition for `<.button>` will NOT work because Phoenix.Component - is not used. - """ - - defmacrop sigil_H({:<<>>, _meta, [string]}, _modifiers) when is_binary(string) do - string - end - - defmacrop sigil_H({:<<>>, _meta, _parts} = ast, _modifiers) do - quote do: unquote(ast) - end - - def button(assigns) do - "" - end - - def render(assigns) do - ~H""" -
- <.button label="Click me"> -
- """ - end -end diff --git a/apps/forge/test/fixtures/sigils_without_live_view/mix.exs b/apps/forge/test/fixtures/sigils_without_live_view/mix.exs deleted file mode 100644 index 1e9f0f1d..00000000 --- a/apps/forge/test/fixtures/sigils_without_live_view/mix.exs +++ /dev/null @@ -1,25 +0,0 @@ -defmodule SigilsWithoutLiveView.MixProject do - use Mix.Project - - def project do - Code.put_compiler_option(:ignore_module_conflict, true) - - [ - app: :sigils_without_live_view, - version: "0.1.0", - elixir: "~> 1.15", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger] - ] - end - - defp deps do - [] - end -end From 233d82429e002241bf512b91275f386785faf88c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Tue, 23 Dec 2025 22:42:21 +0100 Subject: [PATCH 6/7] Revert unnecessary test change --- .../expert/test/engine/code_intelligence/definition_test.exs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/expert/test/engine/code_intelligence/definition_test.exs b/apps/expert/test/engine/code_intelligence/definition_test.exs index 23614725..075feb35 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -591,10 +591,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - # The test verifies no crash occurs; finding the definition is a bonus - # (behavior varies by Elixir/OTP version for macro-generated modules) - result = definition(project, subject_module, [uri]) - assert match?({:ok, _}, result) or match?({:ok, _, _}, result) + assert {:ok, _file, _definition} = definition(project, subject_module, [uri]) end end From 36d2efdce719be6b7d7ac66e2ea21490b235faf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Tue, 23 Dec 2025 22:59:19 +0100 Subject: [PATCH 7/7] More test cases --- .../engine/code_intelligence/entity_test.exs | 25 +++++++++++++++++++ .../code_intelligence/definition_test.exs | 23 +++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/apps/engine/test/engine/code_intelligence/entity_test.exs b/apps/engine/test/engine/code_intelligence/entity_test.exs index 5a71183c..4a316221 100644 --- a/apps/engine/test/engine/code_intelligence/entity_test.exs +++ b/apps/engine/test/engine/code_intelligence/entity_test.exs @@ -959,6 +959,31 @@ defmodule Engine.CodeIntelligence.EntityTest do end end + describe "resolve/2 within ~H sigil when phoenix_live_view is NOT available" do + setup do + patch(Engine.CodeIntelligence.HeexNormalizer, :phoenix_component_available?, false) + :ok + end + + test "shorthand component notation does not resolve" do + code = ~q[ + defmodule MyLiveView do + use Phoenix.Component + + def render(assigns) do + ~H""" + <.but|ton>Click + """ + end + + def button(assigns), do: nil + end + ] + + assert {:error, :not_found} = resolve(code) + end + end + defp subject_module_uri do project() |> file_path(Path.join("lib", "my_module.ex")) diff --git a/apps/expert/test/engine/code_intelligence/definition_test.exs b/apps/expert/test/engine/code_intelligence/definition_test.exs index 075feb35..5289dcc8 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -578,6 +578,29 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do assert file == uri assert fragment == " def «button»(_assigns) do" end + + test "find the definition when shorthand notation used on self-closing tag", %{ + project: project, + uri: uri, + subject_uri: subject_uri + } do + subject_module = ~q[ + defmodule MyLiveView do + use Phoenix.Component + import MyDefinition + + def render(assigns) do + ~H""" + <.but|ton /> + """ + end + end + ] + + result = definition(project, subject_module, [uri, subject_uri]) + assert {:ok, ^uri, fragment} = result + assert fragment == " def «button»(_assigns) do" + end end describe "edge cases" do