Skip to content

Commit 7f37d59

Browse files
authored
Suggest an appropriate module name with the 'defmodule' snippet (#684)
* Suggest an appropriate module name when auto-completing the 'defmodule' snippet * use processed file_path instead of raw file:// uri for determining appropriate module_names * add umbrella_app test to guard against future regressions * special case common Phoenix folders when suggesting module names * fix broken test Some plugin or setting in my editor trimmed extra spaces from the lines but in this case it cause a test to break by changing the cursor position of the auto-completion trigger
1 parent 99ab6e9 commit 7f37d59

File tree

3 files changed

+211
-2
lines changed

3 files changed

+211
-2
lines changed

apps/language_server/lib/language_server/providers/completion.ex

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,9 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
565565
completion
566566
end
567567

568-
if snippet = snippet_for({origin, name}, context) do
568+
file_path = Keyword.get(options, :file_path)
569+
570+
if snippet = snippet_for({origin, name}, Map.put(context, :file_path, file_path)) do
569571
%{completion | insert_text: snippet, kind: :snippet, label: name}
570572
else
571573
completion
@@ -576,6 +578,12 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
576578
nil
577579
end
578580

581+
defp snippet_for({"Kernel", "defmodule"}, %{file_path: file_path}) when is_binary(file_path) do
582+
# In a mix project the file_path can be something like "/some/code/path/project/lib/project/sub_path/my_file.ex"
583+
# so we'll try to guess the appropriate module name from the path
584+
"defmodule #{suggest_module_name(file_path)}$1 do\n\t$0\nend"
585+
end
586+
579587
defp snippet_for(key, %{pipe_before?: true}) do
580588
# Get pipe-friendly version of snippet if available, otherwise fallback to standard
581589
Map.get(@pipe_func_snippets, key) || Map.get(@func_snippets, key)
@@ -593,6 +601,75 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
593601
end
594602
end
595603

604+
def suggest_module_name(file_path) when is_binary(file_path) do
605+
file_path
606+
|> Path.split()
607+
|> Enum.reverse()
608+
|> do_suggest_module_name()
609+
end
610+
611+
defp do_suggest_module_name([]), do: nil
612+
613+
defp do_suggest_module_name([filename | reversed_path]) do
614+
filename
615+
|> String.split(".")
616+
|> case do
617+
[file, "ex"] ->
618+
do_suggest_module_name(reversed_path, [file], topmost_parent: "lib")
619+
620+
[file, "exs"] ->
621+
if String.ends_with?(file, "_test") do
622+
do_suggest_module_name(reversed_path, [file], topmost_parent: "test")
623+
else
624+
nil
625+
end
626+
627+
_otherwise ->
628+
nil
629+
end
630+
end
631+
632+
defp do_suggest_module_name([dir | _rest], module_name_acc, topmost_parent: topmost)
633+
when dir == topmost do
634+
module_name_acc
635+
|> Enum.map(&Macro.camelize/1)
636+
|> Enum.join(".")
637+
end
638+
639+
defp do_suggest_module_name(
640+
[probable_phoenix_dir | [project_web_dir | _] = rest],
641+
module_name_acc,
642+
opts
643+
)
644+
when probable_phoenix_dir in [
645+
"controllers",
646+
"views",
647+
"channels",
648+
"plugs",
649+
"endpoints",
650+
"sockets"
651+
] do
652+
if String.ends_with?(project_web_dir, "_web") do
653+
# by convention Phoenix doesn't use these folders as part of the module names
654+
# for modules located inside them, so we'll try to do the same
655+
do_suggest_module_name(rest, module_name_acc, opts)
656+
else
657+
# when not directly under the *_web folder however then we should make the folder
658+
# part of the module's name
659+
do_suggest_module_name(rest, [probable_phoenix_dir | module_name_acc], opts)
660+
end
661+
end
662+
663+
defp do_suggest_module_name([dir_name | rest], module_name_acc, opts) do
664+
do_suggest_module_name(rest, [dir_name | module_name_acc], opts)
665+
end
666+
667+
defp do_suggest_module_name([], _module_name_acc, _opts) do
668+
# we went all the way up without ever encountering a 'lib' or a 'test' folder
669+
# so we ignore the accumulated module name because it's probably wrong/useless
670+
nil
671+
end
672+
596673
def function_snippet(name, args, arity, opts) do
597674
snippets_supported? = Keyword.get(opts, :snippets_supported, false)
598675
trigger_signature? = Keyword.get(opts, :trigger_signature?, false)

apps/language_server/lib/language_server/server.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,8 @@ defmodule ElixirLS.LanguageServer.Server do
685685
tags_supported: tags_supported,
686686
signature_help_supported: signature_help_supported,
687687
locals_without_parens: locals_without_parens,
688-
signature_after_complete: signature_after_complete
688+
signature_after_complete: signature_after_complete,
689+
file_path: SourceFile.path_from_uri(uri)
689690
)
690691
end
691692

apps/language_server/test/providers/completion_test.exs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,62 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do
908908
"""
909909
}
910910
end
911+
912+
test "will suggest defmodule with module_name snippet when file path matches **/lib/**/*.ex" do
913+
text = """
914+
defmod
915+
# ^
916+
"""
917+
918+
{line, char} = {0, 6}
919+
920+
TestUtils.assert_has_cursor_char(text, line, char)
921+
922+
assert {:ok, %{"items" => [first | _] = _items}} =
923+
Completion.completion(
924+
text,
925+
line,
926+
char,
927+
@supports
928+
|> Keyword.put(
929+
:file_path,
930+
"/some/path/my_project/lib/my_project/sub_folder/my_file.ex"
931+
)
932+
)
933+
934+
assert %{
935+
"label" => "defmodule",
936+
"insertText" => "defmodule MyProject.SubFolder.MyFile$1 do\n\t$0\nend"
937+
} = first
938+
end
939+
940+
test "will suggest defmodule without module_name snippet when file path does not match expected patterns" do
941+
text = """
942+
defmod
943+
# ^
944+
"""
945+
946+
{line, char} = {0, 6}
947+
948+
TestUtils.assert_has_cursor_char(text, line, char)
949+
950+
assert {:ok, %{"items" => [first | _] = _items}} =
951+
Completion.completion(
952+
text,
953+
line,
954+
char,
955+
@supports
956+
|> Keyword.put(
957+
:file_path,
958+
"/some/path/my_project/lib/my_project/sub_folder/my_file.heex"
959+
)
960+
)
961+
962+
assert %{
963+
"label" => "defmodule",
964+
"insertText" => "defmodule $1 do\n\t$0\nend"
965+
} = first
966+
end
911967
end
912968

913969
describe "generic suggestions" do
@@ -1053,4 +1109,79 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do
10531109
assert insert_text =~ "if do\n\t"
10541110
end
10551111
end
1112+
1113+
describe "suggest_module_name/1" do
1114+
import Completion, only: [suggest_module_name: 1]
1115+
1116+
test "returns nil if current file_path is empty" do
1117+
assert nil == suggest_module_name("")
1118+
end
1119+
1120+
test "returns nil if current file is not an .ex file" do
1121+
assert nil == suggest_module_name("some/path/lib/dir/file.heex")
1122+
end
1123+
1124+
test "returns nil if current file is an .ex file but no lib folder exists in path" do
1125+
assert nil == suggest_module_name("some/path/not_lib/dir/file.ex")
1126+
end
1127+
1128+
test "returns nil if current file is an *_test.exs file but no test folder exists in path" do
1129+
assert nil == suggest_module_name("some/path/not_test/dir/file_test.exs")
1130+
end
1131+
1132+
test "returns an appropriate suggestion if file directly under lib" do
1133+
assert "MyProject" == suggest_module_name("some/path/my_project/lib/my_project.ex")
1134+
end
1135+
1136+
test "returns an appropriate suggestion if file arbitrarily nested under lib/" do
1137+
assert "MyProject.Foo.Bar.Baz.MyFile" =
1138+
suggest_module_name("some/path/my_project/lib/my_project/foo/bar/baz/my_file.ex")
1139+
end
1140+
1141+
test "returns an appropriate suggestion if file directly under test/" do
1142+
assert "MyProjectTest" ==
1143+
suggest_module_name("some/path/my_project/test/my_project_test.exs")
1144+
end
1145+
1146+
test "returns an appropriate suggestion if file arbitrarily nested under test" do
1147+
assert "MyProject.Foo.Bar.Baz.MyFileTest" ==
1148+
suggest_module_name(
1149+
"some/path/my_project/test/my_project/foo/bar/baz/my_file_test.exs"
1150+
)
1151+
end
1152+
1153+
test "returns an appropriate suggestion if file is part of an umbrella project" do
1154+
assert "MySubApp.Foo.Bar.Baz" ==
1155+
suggest_module_name(
1156+
"some/path/my_umbrella_project/apps/my_sub_app/lib/my_sub_app/foo/bar/baz.ex"
1157+
)
1158+
end
1159+
1160+
test "returns appropriate suggestions for modules nested under known phoenix dirs" do
1161+
[
1162+
{"MyProjectWeb.MyController", "controllers/my_controller.ex"},
1163+
{"MyProjectWeb.MyPlug", "plugs/my_plug.ex"},
1164+
{"MyProjectWeb.MyView", "views/my_view.ex"},
1165+
{"MyProjectWeb.MyChannel", "channels/my_channel.ex"},
1166+
{"MyProjectWeb.MyEndpoint", "endpoints/my_endpoint.ex"},
1167+
{"MyProjectWeb.MySocket", "sockets/my_socket.ex"}
1168+
]
1169+
|> Enum.each(fn {expected_module_name, partial_path} ->
1170+
path = "some/path/my_project/lib/my_project_web/#{partial_path}"
1171+
assert expected_module_name == suggest_module_name(path)
1172+
end)
1173+
end
1174+
1175+
test "uses known Phoenix dirs as part of a module's name if these are not located directly beneath the *_web folder" do
1176+
assert "MyProject.Controllers.MyController" ==
1177+
suggest_module_name(
1178+
"some/path/my_project/lib/my_project/controllers/my_controller.ex"
1179+
)
1180+
1181+
assert "MyProjectWeb.SomeNestedDir.Controllers.MyController" ==
1182+
suggest_module_name(
1183+
"some/path/my_project/lib/my_project_web/some_nested_dir/controllers/my_controller.ex"
1184+
)
1185+
end
1186+
end
10561187
end

0 commit comments

Comments
 (0)