Skip to content

Commit b79f80d

Browse files
authored
Improve autocomplete and signature help (#273)
* Improve autocomple and signagture help * Addressing feedback * Fix callback completion test
1 parent 3ac739c commit b79f80d

File tree

11 files changed

+489
-72
lines changed

11 files changed

+489
-72
lines changed

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

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
1010
alias ElixirLS.LanguageServer.SourceFile
1111

1212
@enforce_keys [:label, :kind, :insert_text, :priority, :tags]
13-
defstruct [:label, :kind, :detail, :documentation, :insert_text, :filter_text, :priority, :tags]
13+
defstruct [
14+
:label,
15+
:kind,
16+
:detail,
17+
:documentation,
18+
:insert_text,
19+
:filter_text,
20+
:priority,
21+
:tags,
22+
:command
23+
]
1424

1525
@module_attr_snippets [
1626
{"doc", "doc \"\"\"\n$0\n\"\"\"", "Documents a function"},
@@ -130,7 +140,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
130140
def_before: def_before,
131141
pipe_before?: Regex.match?(Regex.recompile!(~r/\|>\s*#{prefix}$/), text_before_cursor),
132142
capture_before?: Regex.match?(Regex.recompile!(~r/&#{prefix}$/), text_before_cursor),
133-
scope: scope
143+
scope: scope,
144+
module: env.module
134145
}
135146

136147
items =
@@ -278,7 +289,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
278289
end
279290
end
280291

281-
insert_text = def_snippet(def_str, name, args, arity, options)
292+
opts = Keyword.put(options, :with_parens?, true)
293+
insert_text = def_snippet(def_str, name, args, arity, opts)
282294
label = "#{def_str}#{function_label(name, args, arity)}"
283295

284296
filter_text =
@@ -434,12 +446,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
434446
nil
435447
end
436448

437-
defp function_label(name, args, arity) do
438-
if args && args != "" do
439-
Enum.join([to_string(name), "(", args, ")"])
440-
else
441-
Enum.join([to_string(name), "/", arity])
442-
end
449+
defp function_label(name, _args, arity) do
450+
Enum.join([to_string(name), "/", arity])
443451
end
444452

445453
defp def_snippet(def_str, name, args, arity, opts) do
@@ -458,6 +466,18 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
458466
not Keyword.get(opts, :snippets_supported, false) ->
459467
name
460468

469+
Keyword.get(opts, :trigger_signature?, false) ->
470+
text_after_cursor = Keyword.get(opts, :text_after_cursor, "")
471+
472+
# Don't add the closing parenthesis to the snippet if the cursor is
473+
# immediately before a valid argument (this usually happens when we
474+
# want to wrap an existing variable or literal, e.g. using IO.inspect)
475+
if Regex.match?(~r/^[a-zA-Z0-9_:"'%<\[\{]/, text_after_cursor) do
476+
"#{name}("
477+
else
478+
"#{name}($1)$0"
479+
end
480+
461481
true ->
462482
args_list =
463483
if args && args != "" do
@@ -478,7 +498,14 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
478498
|> Enum.with_index()
479499
|> Enum.map(fn {arg, i} -> "${#{i + 1}:#{arg}}" end)
480500

481-
Enum.join([name, "(", Enum.join(tabstops, ", "), ")"])
501+
{before_args, after_args} =
502+
if Keyword.get(opts, :with_parens?, false) do
503+
{"(", ")"}
504+
else
505+
{" ", ""}
506+
end
507+
508+
Enum.join([name, before_args, Enum.join(tabstops, ", "), after_args])
482509
end
483510
end
484511

@@ -527,9 +554,12 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
527554
|> String.replace("$", "\\$")
528555
|> String.replace("}", "\\}")
529556
|> String.split(",")
557+
|> Enum.reject(&is_default_argument?/1)
530558
|> Enum.map(&String.trim/1)
531559
end
532560

561+
defp is_default_argument?(s), do: String.contains?(s, "\\\\")
562+
533563
defp module_attr_snippets(%{prefix: prefix, scope: :module, def_before: nil}) do
534564
for {name, snippet, docs} <- @module_attr_snippets,
535565
label = "@" <> name,
@@ -575,6 +605,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
575605
defp function_completion(info, context, options) do
576606
%{
577607
type: type,
608+
visibility: visibility,
578609
args: args,
579610
name: name,
580611
summary: summary,
@@ -590,9 +621,19 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
590621
%{
591622
pipe_before?: pipe_before?,
592623
capture_before?: capture_before?,
593-
text_after_cursor: text_after_cursor
624+
text_after_cursor: text_after_cursor,
625+
module: module
594626
} = context
595627

628+
locals_without_parens = Keyword.get(options, :locals_without_parens)
629+
with_parens? = function_name_with_parens?(name, arity, locals_without_parens)
630+
631+
trigger_signature? =
632+
Keyword.get(options, :signature_help_supported, false) &&
633+
Keyword.get(options, :snippets_supported, false) &&
634+
arity > 0 &&
635+
with_parens?
636+
596637
{label, insert_text} =
597638
cond do
598639
match?("sigil_" <> _, name) ->
@@ -614,33 +655,48 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
614655
Keyword.merge(
615656
options,
616657
pipe_before?: pipe_before?,
617-
capture_before?: capture_before?
658+
capture_before?: capture_before?,
659+
trigger_signature?: trigger_signature?,
660+
locals_without_parens: locals_without_parens,
661+
text_after_cursor: text_after_cursor,
662+
with_parens?: with_parens?
618663
)
619664
)
620665

621666
{label, insert_text}
622667
end
623668

624-
detail =
625-
cond do
626-
spec && spec != "" ->
627-
spec
669+
detail_header =
670+
if inspect(module) == origin do
671+
"#{visibility} #{type}"
672+
else
673+
"#{origin} #{type}"
674+
end
628675

629-
String.starts_with?(type, ["private", "public"]) ->
630-
String.replace(type, "_", " ")
676+
footer =
677+
if String.starts_with?(type, ["private", "public"]) do
678+
String.replace(type, "_", " ")
679+
else
680+
SourceFile.format_spec(spec, line_length: 30)
681+
end
631682

632-
true ->
633-
"(#{origin}) #{type}"
683+
command =
684+
if trigger_signature? do
685+
%{
686+
"title" => "Trigger Parameter Hint",
687+
"command" => "editor.action.triggerParameterHints"
688+
}
634689
end
635690

636691
%__MODULE__{
637692
label: label,
638693
kind: :function,
639-
detail: detail,
640-
documentation: summary,
694+
detail: detail_header <> "\n\n" <> Enum.join([to_string(name), "(", args, ")"]),
695+
documentation: summary <> footer,
641696
insert_text: insert_text,
642697
priority: 7,
643-
tags: metadata_to_tags(metadata)
698+
tags: metadata_to_tags(metadata),
699+
command: command
644700
}
645701
end
646702

@@ -678,6 +734,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
678734
"filterText" => item.filter_text,
679735
"sortText" => String.pad_leading(to_string(idx), 8, "0"),
680736
"insertText" => item.insert_text,
737+
"command" => item.command,
681738
"insertTextFormat" =>
682739
if Keyword.get(options, :snippets_supported, false) do
683740
insert_text_format(:snippet)
@@ -724,4 +781,10 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
724781
_ -> [:deprecated]
725782
end
726783
end
784+
785+
defp function_name_with_parens?(name, arity, locals_without_parens) do
786+
(locals_without_parens || MapSet.new())
787+
|> MapSet.member?({String.to_atom(name), arity})
788+
|> Kernel.not()
789+
end
727790
end

apps/language_server/lib/language_server/providers/execute_command.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand do
4747
formatted =
4848
try do
4949
target_line_length =
50-
Mix.Tasks.Format.formatter_opts_for_file(SourceFile.path_from_uri(uri))
50+
uri
51+
|> SourceFile.formatter_opts()
5152
|> Keyword.get(:line_length, 98)
5253

5354
target_line_length = target_line_length - String.length(indentation)

apps/language_server/lib/language_server/providers/formatting.ex

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do
88

99
def format(source_file, uri, project_dir) do
1010
if can_format?(uri, project_dir) do
11-
file = SourceFile.path_from_uri(uri) |> Path.relative_to(project_dir)
12-
opts = formatter_opts(file)
11+
opts = SourceFile.formatter_opts(uri)
1312
formatted = IO.iodata_to_binary([Code.format_string!(source_file.text, opts), ?\n])
1413

1514
response =
@@ -41,10 +40,6 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do
4140
String.starts_with?(Path.absname(file_path), cwd)
4241
end
4342

44-
defp formatter_opts(for_file) do
45-
Mix.Tasks.Format.formatter_opts_for_file(for_file)
46-
end
47-
4843
defp myers_diff_to_text_edits(myers_diff, starting_pos \\ {0, 0}) do
4944
myers_diff_to_text_edits(myers_diff, starting_pos, [])
5045
end

apps/language_server/lib/language_server/providers/signature_help.ex

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defmodule ElixirLS.LanguageServer.Providers.SignatureHelp do
2+
alias ElixirLS.LanguageServer.SourceFile
3+
24
def signature(source_file, line, character) do
35
response =
46
case ElixirSense.signature(source_file.text, line + 1, character + 1) do
@@ -23,9 +25,19 @@ defmodule ElixirLS.LanguageServer.Providers.SignatureHelp do
2325
response = %{"label" => label, "parameters" => params_info}
2426

2527
case {spec, documentation} do
26-
{"", ""} -> response
27-
{"", _} -> Map.put(response, "documentation", documentation)
28-
{_, _} -> Map.put(response, "documentation", "#{spec}\n#{documentation}")
28+
{"", ""} ->
29+
response
30+
31+
{"", _} ->
32+
put_documentation(response, documentation)
33+
34+
{_, _} ->
35+
spec_str = SourceFile.format_spec(spec, line_length: 42)
36+
put_documentation(response, "#{documentation}\n#{spec_str}")
2937
end
3038
end
39+
40+
defp put_documentation(response, documentation) do
41+
Map.put(response, "documentation", %{"kind" => "markdown", "value" => documentation})
42+
end
3143
end

apps/language_server/lib/language_server/server.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,11 +471,22 @@ defmodule ElixirLS.LanguageServer.Server do
471471
%{"valueSet" => value_set} -> value_set
472472
end
473473

474+
signature_help_supported =
475+
!!get_in(state.client_capabilities, ["textDocument", "signatureHelp"])
476+
477+
locals_without_parens =
478+
uri
479+
|> SourceFile.formatter_opts()
480+
|> Keyword.get(:locals_without_parens, [])
481+
|> MapSet.new()
482+
474483
fun = fn ->
475484
Completion.completion(state.source_files[uri].text, line, character,
476485
snippets_supported: snippets_supported,
477486
deprecated_supported: deprecated_supported,
478-
tags_supported: tags_supported
487+
tags_supported: tags_supported,
488+
signature_help_supported: signature_help_supported,
489+
locals_without_parens: locals_without_parens
479490
)
480491
end
481492

apps/language_server/lib/language_server/source_file.ex

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,55 @@ defmodule ElixirLS.LanguageServer.SourceFile do
181181
_other -> {function, arity}
182182
end
183183
end
184+
185+
@spec format_spec(String.t(), keyword()) :: String.t()
186+
def format_spec(spec, _opts) when spec in [nil, ""] do
187+
""
188+
end
189+
190+
def format_spec(spec, opts) do
191+
line_length = Keyword.fetch!(opts, :line_length)
192+
193+
spec_str =
194+
case format_code(spec, line_length: line_length) do
195+
{:ok, code} ->
196+
code
197+
|> to_string()
198+
|> lines()
199+
|> remove_indentation(String.length("@spec "))
200+
|> Enum.join("\n")
201+
202+
{:error, _} ->
203+
spec
204+
end
205+
206+
"""
207+
208+
```
209+
#{spec_str}
210+
```
211+
"""
212+
end
213+
214+
@spec formatter_opts(String.t()) :: keyword()
215+
def formatter_opts(uri) do
216+
uri
217+
|> path_from_uri()
218+
|> Mix.Tasks.Format.formatter_opts_for_file()
219+
end
220+
221+
defp format_code(code, opts) do
222+
try do
223+
{:ok, Code.format_string!(code, opts)}
224+
rescue
225+
e ->
226+
{:error, e}
227+
end
228+
end
229+
230+
defp remove_indentation([line | rest], length) do
231+
[line | Enum.map(rest, &String.slice(&1, length..-1))]
232+
end
233+
234+
defp remove_indentation(lines, _), do: lines
184235
end

0 commit comments

Comments
 (0)