Skip to content
1 change: 1 addition & 0 deletions apps/engine/lib/engine/code_intelligence/definition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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] ->
Expand Down
19 changes: 15 additions & 4 deletions apps/engine/lib/engine/code_intelligence/entity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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

Expand Down
172 changes: 172 additions & 0 deletions apps/engine/lib/engine/code_intelligence/heex_normalizer.ex
Original file line number Diff line number Diff line change
@@ -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.FastZipper

# 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 "</ \\1(assigns)"

# Normalizes HEEx templates by converting anonymous component references
# (e.g., `<.component`) to explicit function calls (e.g., `<component(assigns)`).
# It's done in both the AST and document text.
#
# 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 maybe_normalize(Analysis.t(), Position.t()) :: Analysis.t()
def maybe_normalize(analysis, position) do
if phoenix_component_available?() do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, I think we also need to check in the analysis scopes if sigil_H from Phoenix.Component is imported

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do that, but I'm not sure if this can be done reliably. For example, I find it hard to detect things like use MyAppWeb, :live_component, which imports sigil_H, not to mention some potential more elaborate metaprogramming resulting in an import.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we integrate spitfire, we should be able to detect any aliases and imports from use macros and metaprogramming. it can get you the current environment for a cursor location (imported functions, aliased modules, etc)

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
Engine.Module.Loader.ensure_loaded?(Phoenix.Component)
end

defp normalize_ast(analysis, position) do
with {:ok, path} <- Ast.path_at(analysis, position),
{:sigil_H, _, _} = sigil <- Enum.find(path, &match?({:sigil_H, _, _}, &1)) do
new_sigil = normalize_heex_node(sigil)

analysis.ast
|> FastZipper.zip()
|> FastZipper.find(&(&1 == sigil))
|> case do
nil -> analysis.ast
zipper -> zipper |> FastZipper.replace(new_sigil) |> FastZipper.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, "</")
{match_start, match_len, component_name, is_closing}
else
nil
end
end)
end

defp build_replacement_text(
original_text,
match_start,
match_length,
component_name,
is_closing
) do
prefix = binary_part(original_text, 0, match_start)

suffix =
binary_part(
original_text,
match_start + match_length,
byte_size(original_text) - (match_start + match_length)
)

replacement =
if is_closing do
"</ #{component_name}(assigns)"
else
"< #{component_name}(assigns)"
end

prefix <> 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
25 changes: 25 additions & 0 deletions apps/engine/test/engine/code_intelligence/entity_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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</.button>
"""
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"))
Expand Down
1 change: 1 addition & 0 deletions apps/expert/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
]
Expand Down
9 changes: 9 additions & 0 deletions apps/expert/mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@
"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"},
"snowflake": {:hex, :snowflake, "1.0.4", "8433b4e04fbed19272c55e1b7de0f7a1ee1230b3ae31a813b616fd6ef279e87a", [:mix], [], "hexpm", "badb07ebb089a5cff737738297513db3962760b10fe2b158ae3bebf0b4d5be13"},
"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"},
}
116 changes: 116 additions & 0 deletions apps/expert/test/engine/code_intelligence/definition_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,122 @@ 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"""
<MyDefinition.but|ton navigate="/home">Home</MyDefinition.button>
"""
end
end
]

assert {:ok, ^uri, definition} = definition(project, subject_module, [uri, subject_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</.button>
"""
end

def button(_assigns), do: nil
end
]

assert {:ok, ^subject_uri, fragment} =
definition(project, subject_module, [uri, 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</.button>
"""
end
end
]

assert {:ok, ^uri, fragment} = definition(project, subject_module, [uri, subject_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</.but|ton>
"""
end
end
]

assert {:ok, ^uri, fragment} = definition(project, subject_module, [uri, subject_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
]

assert {:ok, ^uri, fragment} = definition(project, subject_module, [uri, subject_uri])
assert fragment == " def «button»(_assigns) do"
end
end

describe "edge cases" do
setup [:with_referenced_file]

Expand Down
Loading
Loading