From d716c19841aeffd17c9f9ac788a62db4a3713e71 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 01/11] 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 8b8f0e092c2420147c9c9a02edebc9b74dfef9e9 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 02/11] 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 beb5615d45516700de87c4eb5f461c134ed8a8cf 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 03/11] 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 fe22b39de072f86dd54465effeb233fcae59b92c 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 04/11] 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 c59977274de5ae1599b42df108b5c48bcfdbefe2 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 05/11] 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 e166a3f41c0ea0bf02f8df77932ad72aa42f1e19 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 06/11] 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 2eea4c5f7457c0205b57b1a5b7d9ea242706a184 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 07/11] 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 From d89b1d81d3ec0bf12e9d22df22f343c8a8213a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Mon, 5 Jan 2026 11:52:05 +0100 Subject: [PATCH 08/11] Use Engine.Module.Loader for faster lookups --- apps/engine/lib/engine/code_intelligence/heex_normalizer.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex index 134262ad..8717d93b 100644 --- a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -37,7 +37,7 @@ defmodule Engine.CodeIntelligence.HeexNormalizer do end defp phoenix_component_available? do - Code.ensure_loaded?(Phoenix.Component) + Engine.Module.Loader.ensure_loaded?(Phoenix.Component) end defp normalize_ast(analysis, position) do From 5158ee6ee457b8c3231f5e26d3ffbe196ab054ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Mon, 5 Jan 2026 11:57:53 +0100 Subject: [PATCH 09/11] Use Sourceror.FastZipper --- .../lib/engine/code_intelligence/heex_normalizer.ex | 8 ++++---- 1 file changed, 4 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 8717d93b..0197551f 100644 --- a/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -6,7 +6,7 @@ defmodule Engine.CodeIntelligence.HeexNormalizer do alias Forge.Document alias Forge.Document.Position alias Forge.Document.Range - alias Sourceror.Zipper + alias Sourceror.FastZipper # Matches both opening and closing shorthand components (used for cursor detection) @component_regex ~r/<\/?\.([a-zA-Z0-9_!?.]+)/ @@ -46,11 +46,11 @@ defmodule Engine.CodeIntelligence.HeexNormalizer do new_sigil = normalize_heex_node(sigil) analysis.ast - |> Zipper.zip() - |> Zipper.find(&(&1 == sigil)) + |> FastZipper.zip() + |> FastZipper.find(&(&1 == sigil)) |> case do nil -> analysis.ast - zipper -> zipper |> Zipper.replace(new_sigil) |> Zipper.root() + zipper -> zipper |> FastZipper.replace(new_sigil) |> FastZipper.root() end else _ -> analysis.ast From 072753eab3c5d89d7eb0aefd8b1d00c04ec36f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Mon, 5 Jan 2026 15:35:17 +0100 Subject: [PATCH 10/11] Simplify tests --- .../code_intelligence/definition_test.exs | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/apps/expert/test/engine/code_intelligence/definition_test.exs b/apps/expert/test/engine/code_intelligence/definition_test.exs index 5289dcc8..34a8e187 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -499,10 +499,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - result = definition(project, subject_module, [uri, subject_uri]) - assert {:ok, file, definition} = result - - assert file == uri + assert {:ok, ^uri, definition} = definition(project, subject_module, [uri, subject_uri]) assert definition == " def «button»(_assigns) do" end @@ -525,9 +522,9 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - result = definition(project, subject_module, [uri, subject_uri]) - assert {:ok, file, fragment} = result - assert file == subject_uri + assert {:ok, ^subject_uri, fragment} = + definition(project, subject_module, [uri, subject_uri]) + assert fragment == " def «button»(_assigns), do: nil" end @@ -549,9 +546,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - result = definition(project, subject_module, [uri, subject_uri]) - assert {:ok, file, fragment} = result - assert file == uri + assert {:ok, ^uri, fragment} = definition(project, subject_module, [uri, subject_uri]) assert fragment == " def «button»(_assigns) do" end @@ -573,9 +568,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - result = definition(project, subject_module, [uri, subject_uri]) - assert {:ok, file, fragment} = result - assert file == uri + assert {:ok, ^uri, fragment} = definition(project, subject_module, [uri, subject_uri]) assert fragment == " def «button»(_assigns) do" end @@ -597,8 +590,7 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do end ] - result = definition(project, subject_module, [uri, subject_uri]) - assert {:ok, ^uri, fragment} = result + assert {:ok, ^uri, fragment} = definition(project, subject_module, [uri, subject_uri]) assert fragment == " def «button»(_assigns) do" end end @@ -624,15 +616,10 @@ defmodule Expert.Engine.CodeIntelligence.DefinitionTest do :ok <- index(project, referenced_uri), {:ok, location} <- EngineApi.definition(project, document, position) do - 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)} + 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)} end end end From 83cf8eca9c846bc8d7693322e325e6ea263152cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Thu, 8 Jan 2026 12:29:48 +0100 Subject: [PATCH 11/11] Provide custom module defining sigil_H Phoenix.Component is not available and it causes issues with OTP 24.3.4.1 --- apps/forge/test/fixtures/navigations/lib/component.ex | 9 +++++++++ .../forge/test/fixtures/navigations/lib/my_components.ex | 2 +- apps/forge/test/fixtures/navigations/lib/my_live_view.ex | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 apps/forge/test/fixtures/navigations/lib/component.ex diff --git a/apps/forge/test/fixtures/navigations/lib/component.ex b/apps/forge/test/fixtures/navigations/lib/component.ex new file mode 100644 index 00000000..1b4551ca --- /dev/null +++ b/apps/forge/test/fixtures/navigations/lib/component.ex @@ -0,0 +1,9 @@ +defmodule Component do + defmacro __using__(_) do + quote do + import(unquote(__MODULE__)) + end + end + + def sigil_H(_, _), do: nil +end diff --git a/apps/forge/test/fixtures/navigations/lib/my_components.ex b/apps/forge/test/fixtures/navigations/lib/my_components.ex index 340d68da..ee535799 100644 --- a/apps/forge/test/fixtures/navigations/lib/my_components.ex +++ b/apps/forge/test/fixtures/navigations/lib/my_components.ex @@ -4,7 +4,7 @@ defmodule MyComponents do This module provides components that can be used with shorthand notation. """ - use Phoenix.Component + use Component @doc """ A simple button component. diff --git a/apps/forge/test/fixtures/navigations/lib/my_live_view.ex b/apps/forge/test/fixtures/navigations/lib/my_live_view.ex index b94b9477..8669e141 100644 --- a/apps/forge/test/fixtures/navigations/lib/my_live_view.ex +++ b/apps/forge/test/fixtures/navigations/lib/my_live_view.ex @@ -1,5 +1,5 @@ defmodule Navigations.MyLiveView do - use Phoenix.Component + use Component import MyComponents def render(assigns) do