diff --git a/apps/engine/lib/engine/code_intelligence/definition.ex b/apps/engine/lib/engine/code_intelligence/definition.ex index d8f21302..f557f2b8 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.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 515836b9..669bf02a 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.maybe_normalize(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..134262ad --- /dev/null +++ b/apps/engine/lib/engine/code_intelligence/heex_normalizer.ex @@ -0,0 +1,172 @@ +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 + + # Matches both opening and closing shorthand components (used for cursor detection) + @component_regex ~r/<\/?\.([a-zA-Z0-9_!?.]+)/ + # Separate regexes for AST normalization to avoid overlap issues + @opening_component_regex ~r/<\.([a-zA-Z0-9_!?.]+)/ + @closing_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(@closing_component_regex, &1, @closing_replacement)) + |> then(&Regex.replace(@opening_component_regex, &1, @opening_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/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/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 c42a30dd..5289dcc8 100644 --- a/apps/expert/test/engine/code_intelligence/definition_test.exs +++ b/apps/expert/test/engine/code_intelligence/definition_test.exs @@ -479,6 +479,130 @@ 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 + use Phoenix.Component + + 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 + use Phoenix.Component + + 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 + use Phoenix.Component + 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 + use Phoenix.Component + 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 + + 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 setup [:with_referenced_file] @@ -500,10 +624,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_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/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