Skip to content

Commit 4ab4fb4

Browse files
authored
Merge pull request #9 from elixir-lang/doorgan/fix_completions_for_shared_dependencies
[fix] Determine if completion is displayable based on the project dependencies
2 parents c081bf9 + 3a47058 commit 4ab4fb4

File tree

8 files changed

+150
-25
lines changed

8 files changed

+150
-25
lines changed

apps/engine/lib/engine/engine.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ defmodule Engine do
55
context of the remote VM.
66
"""
77

8-
alias Forge.Project
9-
108
alias Engine.Api.Proxy
119
alias Engine.CodeAction
1210
alias Engine.CodeIntelligence
1311
alias Engine.ProjectNode
12+
alias Forge.Project
13+
14+
alias Mix.Tasks.Namespace
1415

1516
require Logger
1617

@@ -64,6 +65,12 @@ defmodule Engine do
6465

6566
defdelegate workspace_symbols(query), to: CodeIntelligence.Symbols, as: :for_workspace
6667

68+
def list_apps do
69+
for {app, _, _} <- :application.loaded_applications(),
70+
not Namespace.Module.prefixed?(app),
71+
do: app
72+
end
73+
6774
def start_link(%Project{} = project) do
6875
:ok = ensure_epmd_started()
6976
start_net_kernel(project)

apps/engine/lib/engine/engine/api.ex

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
defmodule Engine.Api do
2+
alias Engine.CodeIntelligence
23
alias Forge.Ast.Analysis
34
alias Forge.Ast.Env
45
alias Forge.Document
56
alias Forge.Document.Position
67
alias Forge.Document.Range
78
alias Forge.Project
89

9-
alias Engine.CodeIntelligence
10-
1110
require Logger
1211

1312
def schedule_compile(%Project{} = project, force?) do
@@ -35,6 +34,10 @@ defmodule Engine.Api do
3534
Engine.call(project, Engine, :list_modules)
3635
end
3736

37+
def project_apps(%Project{} = project) do
38+
Engine.call(project, Engine, :list_apps)
39+
end
40+
3841
def format(%Project{} = project, %Document{} = document) do
3942
Engine.call(project, Engine, :format, [document])
4043
end

apps/engine/lib/mix/tasks/namespace.ex

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ defmodule Mix.Tasks.Namespace do
1515
use Mix.Task
1616

1717
@dev_deps [:patch]
18+
# Unless explicitly added, nimble_parsec won't show up as a loaded app
19+
# and will therefore not be namespaced.
20+
@no_app_deps [:nimble_parsec]
1821

1922
# These app names and root modules are strings to avoid them being namespaced
2023
# by this task. Plugin discovery uses this task, which happens after
@@ -27,15 +30,13 @@ defmodule Mix.Tasks.Namespace do
2730
"forge" => "Forge"
2831
}
2932

30-
@deps_apps Engine.MixProject.project()
31-
|> Keyword.get(:deps)
32-
|> Enum.map(&elem(&1, 0))
33-
|> then(fn dep_names -> dep_names -- @dev_deps end)
34-
|> Enum.map(&to_string/1)
35-
3633
require Logger
3734

3835
def run([base_directory]) do
36+
# Ensure we cache the loaded apps at the time of namespacing
37+
# Otherwise only the @extra_apps will be cached
38+
init()
39+
3940
Transform.Apps.apply_to_all(base_directory)
4041
Transform.Beams.apply_to_all(base_directory)
4142
Transform.Scripts.apply_to_all(base_directory)
@@ -115,10 +116,31 @@ defmodule Mix.Tasks.Namespace do
115116
end
116117

117118
defp init do
118-
@deps_apps
119-
|> Enum.map(&String.to_atom/1)
119+
discover_deps_apps()
120+
|> Enum.concat(@no_app_deps)
121+
|> then(&(&1 -- @dev_deps))
120122
|> root_modules_for_apps()
121123
|> Map.merge(extra_apps())
122124
|> register_mappings()
123125
end
126+
127+
defp discover_deps_apps do
128+
cwd = File.cwd!()
129+
130+
:application.loaded_applications()
131+
|> Enum.flat_map(fn {app_name, _description, _version} ->
132+
try do
133+
app_dir = Application.app_dir(app_name)
134+
135+
if String.starts_with?(app_dir, cwd) do
136+
[app_name]
137+
else
138+
[]
139+
end
140+
rescue
141+
_ -> []
142+
end
143+
end)
144+
|> Enum.sort()
145+
end
124146
end

apps/engine/test/fixtures/project/mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ defmodule Project.MixProject do
1616
# Run "mix help compile.app" to learn about applications.
1717
def application do
1818
[
19-
extra_applications: [:logger]
19+
extra_applications: [:logger, :erts]
2020
]
2121
end
2222

apps/expert/lib/expert/code_intelligence/completion.ex

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@ defmodule Expert.CodeIntelligence.Completion do
1717

1818
require Logger
1919

20+
@build_env Mix.env()
2021
@expert_deps Enum.map([:expert | Mix.Project.deps_apps()], &Atom.to_string/1)
2122

22-
@expert_dep_modules Enum.map(@expert_deps, &Macro.camelize/1)
23-
2423
def trigger_characters do
2524
[".", "@", "&", "%", "^", ":", "!", "-", "~"]
2625
end
@@ -166,9 +165,10 @@ defmodule Expert.CodeIntelligence.Completion do
166165
%CompletionContext{} = context
167166
) do
168167
debug_local_completions(local_completions)
168+
project_apps = Engine.Api.project_apps(project)
169169

170170
for result <- local_completions,
171-
displayable?(project, result),
171+
displayable?(project, project_apps, result),
172172
applies_to_context?(project, result, context),
173173
applies_to_env?(env, result),
174174
%CompletionItem{} = item <- to_completion_item(result, env) do
@@ -206,7 +206,7 @@ defmodule Expert.CodeIntelligence.Completion do
206206
|> List.wrap()
207207
end
208208

209-
defp displayable?(%Project{} = project, result) do
209+
defp displayable?(%Project{} = project, project_apps, result) do
210210
suggested_module =
211211
case result do
212212
%_{full_name: full_name} when is_binary(full_name) -> full_name
@@ -223,16 +223,65 @@ defmodule Expert.CodeIntelligence.Completion do
223223
true
224224

225225
true ->
226-
Enum.reduce_while(@expert_dep_modules, true, fn module, _ ->
227-
if String.starts_with?(suggested_module, module) do
228-
{:halt, false}
229-
else
230-
{:cont, true}
231-
end
232-
end)
226+
project_module?(project, project_apps, suggested_module, result)
227+
end
228+
end
229+
230+
defp project_module?(_, _, "", _), do: true
231+
232+
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
233+
defp project_module?(%Project{} = project, project_apps, suggested_module, result) do
234+
module = module_string_to_atom(suggested_module)
235+
module_app = Application.get_application(module)
236+
project_app = Application.get_application(project.project_module)
237+
238+
metadata = Map.get(result, :metadata)
239+
240+
result_app = metadata[:app]
241+
242+
cond do
243+
module_app in project_apps ->
244+
true
245+
246+
# This is useful for some struct field completions, where
247+
# a suggested module is not always part of the result struct,
248+
# but the application is.
249+
# If no application is set though, it's usually part of a result
250+
# that is not part of any application yet.
251+
result_app in project_apps or is_nil(metadata) ->
252+
true
253+
254+
not is_nil(module_app) and module_app == project_app ->
255+
true
256+
257+
is_nil(module_app) and not is_nil(project.project_module) and
258+
module == project.project_module ->
259+
true
260+
261+
true ->
262+
# The following cases happen on test cases, due to the application
263+
# controller not always recognizing project fixture modules as part
264+
# of any application.
265+
test_env?() and is_nil(module_app) and is_nil(project.project_module)
233266
end
234267
end
235268

269+
# Because the build env is fixed at compile time, dialyzer knows that
270+
# in :dev and :prod environments, this function will always return false,
271+
# so it produces a warning.
272+
@dialyzer {:nowarn_function, test_env?: 0}
273+
defp test_env?, do: @build_env == :test
274+
275+
defp module_string_to_atom(""), do: nil
276+
277+
defp module_string_to_atom(module_string) do
278+
Forge.Ast.Module.to_atom(module_string)
279+
rescue
280+
_e in ArgumentError ->
281+
# Return nil if we can't safely convert the module string to an atom
282+
nil
283+
end
284+
236285
defp applies_to_env?(%Env{} = env, %struct_module{} = result) do
237286
cond do
238287
Env.in_context?(env, :struct_reference) ->

apps/expert/test/expert/code_intelligence/completion/translations/module_or_behaviour_test.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule Expert.CodeIntelligence.Completion.Translations.ModuleOrBehaviourTest
33
alias GenLSP.Enumerations.InsertTextFormat
44

55
use Expert.Test.Expert.CompletionCase
6+
use Patch
67

78
describe "module completions" do
89
test "modules should emit a completion for stdlib modules", %{project: project} do
@@ -262,6 +263,8 @@ defmodule Expert.CodeIntelligence.Completion.Translations.ModuleOrBehaviourTest
262263
%{
263264
project: project
264265
} do
266+
patch(Engine.Api, :project_apps, [:project, :ex_unit, :stream_data])
267+
265268
source = ~q[
266269
use En|
267270
]

apps/expert/test/expert/code_intelligence/completion_test.exs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,62 @@ defmodule Expert.CodeIntelligence.CompletionTest do
99
use Expert.Test.Expert.CompletionCase
1010
use Patch
1111

12+
setup %{project: project} do
13+
project = %{project | project_module: Project}
14+
{:ok, project: project}
15+
end
16+
1217
describe "excluding modules from expert dependencies" do
1318
test "expert modules are removed", %{project: project} do
19+
patch(Engine.Api, :project_apps, [:project, :sourceror])
1420
assert [] = complete(project, "Expert.CodeIntelligence|")
1521
end
1622

1723
test "Expert submodules are removed", %{project: project} do
24+
patch(Engine.Api, :project_apps, [:project, :sourceror])
1825
assert [] = complete(project, "Engin|e")
1926
assert [] = complete(project, "Forg|e")
2027
end
2128

2229
test "Expert functions are removed", %{project: project} do
30+
patch(Engine.Api, :project_apps, [:project, :sourceror])
2331
assert [] = complete(project, "Engine.|")
2432
end
2533

2634
test "Dependency modules are removed", %{project: project} do
35+
patch(Engine.Api, :project_apps, [:project, :sourceror])
2736
assert [] = complete(project, "ElixirSense|")
2837
end
2938

3039
test "Dependency functions are removed", %{project: project} do
40+
patch(Engine.Api, :project_apps, [:project, :sourceror])
3141
assert [] = complete(project, "Jason.encod|")
3242
end
3343

3444
test "Dependency protocols are removed", %{project: project} do
45+
patch(Engine.Api, :project_apps, [:project, :sourceror])
3546
assert [] = complete(project, "Jason.Encode|")
3647
end
3748

3849
test "Dependency structs are removed", %{project: project} do
50+
patch(Engine.Api, :project_apps, [:project, :sourceror])
3951
assert [] = complete(project, "Jason.Fragment|")
4052
end
4153

4254
test "Dependency exceptions are removed", %{project: project} do
55+
patch(Engine.Api, :project_apps, [:project, :sourceror])
4356
assert [] = complete(project, "Jason.DecodeErro|")
4457
end
4558
end
4659

60+
test "includes modules from dependencies shared by the project and Expert", %{project: project} do
61+
patch(Engine.Api, :project_apps, [:project, :sourceror])
62+
assert [sourceror_module] = complete(project, "Sourcer|")
63+
64+
assert sourceror_module.kind == CompletionItemKind.module()
65+
assert sourceror_module.label == "Sourceror"
66+
end
67+
4768
test "ensure completion works for project", %{project: project} do
4869
refute [] == complete(project, "Project.|")
4970
end
@@ -169,7 +190,7 @@ defmodule Expert.CodeIntelligence.CompletionTest do
169190

170191
def with_all_completion_candidates(_) do
171192
name = "Foo"
172-
full_name = "A.B.Foo"
193+
full_name = "Project"
173194

174195
all_completions = [
175196
%Candidate.Behaviour{name: "#{name}-behaviour", full_name: full_name},

apps/forge/lib/forge/ast/module.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ defmodule Forge.Ast.Module do
7676
def safe_split(module, opts) when is_atom(module) do
7777
string_name = Atom.to_string(module)
7878

79+
do_safe_split(string_name, opts)
80+
end
81+
82+
defp do_safe_split(string_name, opts \\ []) do
7983
{type, split_module} =
8084
case String.split(string_name, ".") do
8185
["Elixir" | rest] ->
@@ -96,4 +100,20 @@ defmodule Forge.Ast.Module do
96100

97101
{type, split_module}
98102
end
103+
104+
@spec to_atom(module() | String.t()) :: module()
105+
def to_atom(module) when is_atom(module) do
106+
module
107+
end
108+
109+
def to_atom(":" <> module_string) do
110+
String.to_existing_atom(module_string)
111+
end
112+
113+
def to_atom(module_string) when is_binary(module_string) do
114+
case do_safe_split("Elixir." <> module_string) do
115+
{:erlang, [module]} -> String.to_existing_atom(module)
116+
{:elixir, parts} -> Module.concat(parts)
117+
end
118+
end
99119
end

0 commit comments

Comments
 (0)