Skip to content

Commit 728b76e

Browse files
authored
fix(engine): unify import lookup to correctly resolve local calls (#547)
When working on macro expansion indexing, I figured out that `Entity.resolve` is ignoring imports, leading to wrong mfa. This causes index search to find nothing, in which case we fallback to elixirsense (this may be the reason some cases seem to work). At least function capture case did not work.
1 parent 9de53c2 commit 728b76e

File tree

5 files changed

+225
-31
lines changed

5 files changed

+225
-31
lines changed

apps/engine/lib/engine/analyzer.ex

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule Engine.Analyzer do
1313

1414
defdelegate aliases_at(analysis, position), to: Aliases, as: :at
1515
defdelegate imports_at(analysis, position), to: Imports, as: :at
16+
defdelegate import_module_for(analysis, position, fun, arity), to: Imports, as: :module_for
1617

1718
@spec requires_at(Analysis.t(), Position.t()) :: [module()]
1819
def requires_at(%Analysis{} = analysis, %Position{} = position) do
@@ -39,20 +40,14 @@ defmodule Engine.Analyzer do
3940
end
4041

4142
def resolve_local_call(%Analysis{} = analysis, %Position{} = position, function_name, arity) do
42-
maybe_imported_mfa =
43-
analysis
44-
|> imports_at(position)
45-
|> Enum.find(fn
46-
{_, ^function_name, ^arity} -> true
47-
_ -> false
48-
end)
49-
50-
if is_nil(maybe_imported_mfa) do
51-
aliases = aliases_at(analysis, position)
52-
current_module = aliases[[:__MODULE__]]
53-
{current_module, function_name, arity}
54-
else
55-
maybe_imported_mfa
43+
case import_module_for(analysis, position, function_name, arity) do
44+
{:ok, module} ->
45+
{module, function_name, arity}
46+
47+
:error ->
48+
aliases = aliases_at(analysis, position)
49+
current_module = aliases[[:__MODULE__]]
50+
{current_module, function_name, arity}
5651
end
5752
end
5853

apps/engine/lib/engine/analyzer/imports.ex

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,68 @@ defmodule Engine.Analyzer.Imports do
1919
end
2020
end
2121

22+
@doc """
23+
Returns the source module for an imported function by walking import declarations in scope.
24+
"""
25+
@spec module_for(Analysis.t(), Position.t(), atom(), non_neg_integer()) ::
26+
{:ok, module()} | :error
27+
def module_for(%Analysis{} = analysis, %Position{} = position, function_name, arity) do
28+
case Analysis.scopes_at(analysis, position) do
29+
[%Scope{} = scope | _] ->
30+
end_line = Scope.end_line(scope, position)
31+
32+
scope.imports
33+
|> Enum.sort_by(& &1.range.start.line)
34+
|> Enum.take_while(&(&1.range.start.line <= end_line))
35+
|> Enum.reverse(kernel_imports(scope))
36+
|> Enum.find_value(:error, fn %Import{} = import ->
37+
module = Aliases.resolve_at(scope, import.module, import.range.start.line)
38+
39+
if import_allows?(module, import.selector, function_name, arity) do
40+
{:ok, module}
41+
else
42+
false
43+
end
44+
end)
45+
46+
_ ->
47+
:error
48+
end
49+
end
50+
51+
defp import_allows?(module, :all, fun, arity) do
52+
not Loader.ensure_loaded?(module) or
53+
{fun, arity} in function_and_arities_for_module(module, :functions) or
54+
{fun, arity} in function_and_arities_for_module(module, :macros)
55+
end
56+
57+
defp import_allows?(module, [only: :functions], fun, arity) do
58+
not Loader.ensure_loaded?(module) or
59+
{fun, arity} in function_and_arities_for_module(module, :functions)
60+
end
61+
62+
defp import_allows?(module, [only: :macros], fun, arity) do
63+
not Loader.ensure_loaded?(module) or
64+
{fun, arity} in function_and_arities_for_module(module, :macros)
65+
end
66+
67+
defp import_allows?(module, [only: :sigils], fun, arity) do
68+
not Loader.ensure_loaded?(module) or
69+
{fun, arity} in function_and_arities_for_module(module, :sigils)
70+
end
71+
72+
defp import_allows?(_module, [only: fns], fun, arity) when is_list(fns),
73+
do: {fun, arity} in fns
74+
75+
defp import_allows?(module, [except: fns], fun, arity) when is_list(fns) do
76+
{fun, arity} not in fns and
77+
(not Loader.ensure_loaded?(module) or
78+
{fun, arity} in function_and_arities_for_module(module, :functions) or
79+
{fun, arity} in function_and_arities_for_module(module, :macros))
80+
end
81+
82+
defp import_allows?(_module, _selector, _fun, _arity), do: false
83+
2284
@spec imports(Scope.t(), Scope.scope_position()) :: [Scope.import_mfa()]
2385
def imports(%Scope{} = scope, position \\ :end) do
2486
scope

apps/engine/lib/engine/code_intelligence/entity.ex

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,18 @@ defmodule Engine.CodeIntelligence.Entity do
117117
end
118118

119119
defp resolve({:local_arity, chars}, node_range, analysis, position) do
120-
current_module = current_module(analysis, position)
120+
fun = List.to_atom(chars)
121121

122122
with {:ok, %Zipper{node: {:/, _, [_, {:__block__, _, [arity]}]}} = zipper} <-
123123
Ast.zipper_at(analysis.document, position),
124124
true <- inside_capture?(zipper) do
125-
{:ok, {:call, current_module, List.to_atom(chars), arity}, node_range}
125+
module =
126+
case fetch_module_for_function(analysis, position, fun, arity) do
127+
{:ok, mod} -> mod
128+
_ -> current_module(analysis, position)
129+
end
130+
131+
{:ok, {:call, module, fun, arity}, node_range}
126132
else
127133
_ ->
128134
{:error, :not_found}
@@ -491,22 +497,10 @@ defmodule Engine.CodeIntelligence.Entity do
491497

492498
defp fetch_module_for_function(analysis, position, function_name, arity) do
493499
with :error <- fetch_module_for_local_function(analysis, position, function_name, arity) do
494-
fetch_module_for_imported_function(analysis, position, function_name, arity)
500+
Engine.Analyzer.import_module_for(analysis, position, function_name, arity)
495501
end
496502
end
497503

498-
defp fetch_module_for_imported_function(analysis, position, function_name, arity) do
499-
analysis
500-
|> Engine.Analyzer.imports_at(position)
501-
|> Enum.find_value({:error, :not_found}, fn
502-
{imported_module, ^function_name, ^arity} ->
503-
{:ok, imported_module}
504-
505-
_ ->
506-
false
507-
end)
508-
end
509-
510504
defp fetch_module_for_local_function(analysis, position, function_name, arity) do
511505
with {:ok, current_module} <- Engine.Analyzer.current_module(analysis, position),
512506
true <- function_exported?(current_module, function_name, arity) do
@@ -537,8 +531,13 @@ defmodule Engine.CodeIntelligence.Entity do
537531
range = Ast.Range.fetch!(zipper.node, analysis.document)
538532
range = put_in(range.end.character, range.start.character + function_name_length)
539533

540-
current_module = current_module(analysis, position)
541-
{:ok, {:call, current_module, local_func_name, arity}, range}
534+
module =
535+
case fetch_module_for_function(analysis, position, local_func_name, arity) do
536+
{:ok, mod} -> mod
537+
_ -> current_module(analysis, position)
538+
end
539+
540+
{:ok, {:call, module, local_func_name, arity}, range}
542541
else
543542
_ ->
544543
{:error, :not_found}

apps/engine/test/engine/analyzer/imports_test.exs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ defmodule WithSigils do
3737
end
3838
end
3939

40+
defmodule ShadowsKernel do
41+
# to_string/1 exists in Kernel — used to test that explicit imports shadow Kernel
42+
def to_string(x), do: x
43+
end
44+
4045
defmodule Engine.Ast.Analysis.ImportsTest do
4146
use ExUnit.Case
4247

@@ -55,6 +60,14 @@ defmodule Engine.Ast.Analysis.ImportsTest do
5560
|> Analyzer.imports_at(position)
5661
end
5762

63+
def module_for_cursor(text, function, arity) do
64+
{position, document} = pop_cursor(text, as: :document)
65+
66+
document
67+
|> Ast.analyze()
68+
|> Analyzer.import_module_for(position, function, arity)
69+
end
70+
5871
def assert_imported(imports, module) do
5972
functions = module.__info__(:functions)
6073
macros = module.__info__(:macros)
@@ -373,6 +386,31 @@ defmodule Engine.Ast.Analysis.ImportsTest do
373386
end
374387
end
375388

389+
describe "module_for/4" do
390+
test "finds kernel functions without any explicit import" do
391+
assert {:ok, Kernel} = module_for_cursor(~q[
392+
defmodule Foo do
393+
|
394+
end
395+
], :to_string, 1)
396+
end
397+
398+
test "explicit import shadows kernel for the same function" do
399+
assert {:ok, ShadowsKernel} = module_for_cursor(~q[
400+
import ShadowsKernel
401+
|
402+
], :to_string, 1)
403+
end
404+
405+
test "returns error when function does not exist in any import" do
406+
assert :error = module_for_cursor(~q[
407+
defmodule Foo do
408+
|
409+
end
410+
], :does_not_exist_function, 0)
411+
end
412+
end
413+
376414
describe "import scopes" do
377415
test "an import defined in a named function" do
378416
imports =

apps/engine/test/engine/code_intelligence/entity_test.exs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,106 @@ defmodule Engine.CodeIntelligence.EntityTest do
899899
assert {:ok, {:call, Parent, :from, 1}, resolved_range} = resolve(code)
900900
assert resolved_range =~ ~S[«from»(doc)]
901901
end
902+
903+
test "imported from unloaded module (local_call)" do
904+
code = ~q[
905+
defmodule MyProject.B do
906+
def foo(), do: 1
907+
end
908+
909+
defmodule MyProject do
910+
import MyProject.B
911+
912+
def test() do
913+
|foo()
914+
end
915+
end
916+
]
917+
918+
assert {:ok, {:call, MyProject.B, :foo, 0}, _} = resolve(code)
919+
end
920+
921+
test "imported from unloaded module (local_or_var)" do
922+
code = ~q[
923+
defmodule MyProject.B do
924+
def foo(), do: 1
925+
end
926+
927+
defmodule MyProject do
928+
import MyProject.B
929+
930+
def test() do
931+
|foo
932+
end
933+
end
934+
]
935+
936+
assert {:ok, {:call, MyProject.B, :foo, 0}, _} = resolve(code)
937+
end
938+
939+
test "imported from unloaded module (module-level call)" do
940+
code = ~q[
941+
defmodule MyProject.B do
942+
def foo(), do: 1
943+
end
944+
945+
defmodule MyProject do
946+
import MyProject.B
947+
948+
|foo()
949+
end
950+
]
951+
952+
assert {:ok, {:call, MyProject.B, :foo, 0}, _} = resolve(code)
953+
end
954+
955+
test "imported from unloaded module (capture)" do
956+
code = ~q[
957+
defmodule MyProject.B do
958+
def foo(), do: 1
959+
end
960+
961+
defmodule MyProject do
962+
import MyProject.B
963+
964+
def test() do
965+
&foo|/0
966+
end
967+
end
968+
]
969+
970+
assert {:ok, {:call, MyProject.B, :foo, 0}, _} = resolve(code)
971+
end
972+
973+
test "imported function in a capture" do
974+
code = ~q[
975+
defmodule Parent do
976+
import Forge.Ast
977+
978+
def function do
979+
&from|/1
980+
end
981+
end
982+
]
983+
984+
assert {:ok, {:call, Forge.Ast, :from, 1}, resolved_range} = resolve(code)
985+
assert resolved_range =~ ~S[&«from»/1]
986+
end
987+
988+
test "imported function in a capture (cursor before name)" do
989+
code = ~q[
990+
defmodule Parent do
991+
import Forge.Ast
992+
993+
def function do
994+
&|from/1
995+
end
996+
end
997+
]
998+
999+
assert {:ok, {:call, Forge.Ast, :from, 1}, resolved_range} = resolve(code)
1000+
assert resolved_range =~ ~S[&«from»/1]
1001+
end
9021002
end
9031003

9041004
describe "type resolve/2" do

0 commit comments

Comments
 (0)