Skip to content

Improve autocomplete and signature help #273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 86 additions & 23 deletions apps/language_server/lib/language_server/providers/completion.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
alias ElixirLS.LanguageServer.SourceFile

@enforce_keys [:label, :kind, :insert_text, :priority, :tags]
defstruct [:label, :kind, :detail, :documentation, :insert_text, :filter_text, :priority, :tags]
defstruct [
:label,
:kind,
:detail,
:documentation,
:insert_text,
:filter_text,
:priority,
:tags,
:command
]

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

items =
Expand Down Expand Up @@ -278,7 +289,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
end
end

insert_text = def_snippet(def_str, name, args, arity, options)
opts = Keyword.put(options, :with_parens?, true)
insert_text = def_snippet(def_str, name, args, arity, opts)
label = "#{def_str}#{function_label(name, args, arity)}"

filter_text =
Expand Down Expand Up @@ -434,12 +446,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
nil
end

defp function_label(name, args, arity) do
if args && args != "" do
Enum.join([to_string(name), "(", args, ")"])
else
Enum.join([to_string(name), "/", arity])
end
defp function_label(name, _args, arity) do
Enum.join([to_string(name), "/", arity])
end

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

Keyword.get(opts, :trigger_signature?, false) ->
text_after_cursor = Keyword.get(opts, :text_after_cursor, "")

# Don't add the closing parenthesis to the snippet if the cursor is
# immediately before a valid argument (this usually happens when we
# want to wrap an existing variable or literal, e.g. using IO.inspect)
if Regex.match?(~r/^[a-zA-Z0-9_:"'%<\[\{]/, text_after_cursor) do
"#{name}("
else
"#{name}($1)$0"
end

true ->
args_list =
if args && args != "" do
Expand All @@ -478,7 +498,14 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
|> Enum.with_index()
|> Enum.map(fn {arg, i} -> "${#{i + 1}:#{arg}}" end)

Enum.join([name, "(", Enum.join(tabstops, ", "), ")"])
{before_args, after_args} =
if Keyword.get(opts, :with_parens?, false) do
{"(", ")"}
else
{" ", ""}
end

Enum.join([name, before_args, Enum.join(tabstops, ", "), after_args])
end
end

Expand Down Expand Up @@ -527,9 +554,12 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
|> String.replace("$", "\\$")
|> String.replace("}", "\\}")
|> String.split(",")
|> Enum.reject(&is_default_argument?/1)
|> Enum.map(&String.trim/1)
end

defp is_default_argument?(s), do: String.contains?(s, "\\\\")

defp module_attr_snippets(%{prefix: prefix, scope: :module, def_before: nil}) do
for {name, snippet, docs} <- @module_attr_snippets,
label = "@" <> name,
Expand Down Expand Up @@ -575,6 +605,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
defp function_completion(info, context, options) do
%{
type: type,
visibility: visibility,
args: args,
name: name,
summary: summary,
Expand All @@ -590,9 +621,19 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
%{
pipe_before?: pipe_before?,
capture_before?: capture_before?,
text_after_cursor: text_after_cursor
text_after_cursor: text_after_cursor,
module: module
} = context

locals_without_parens = Keyword.get(options, :locals_without_parens)
with_parens? = function_name_with_parens?(name, arity, locals_without_parens)

trigger_signature? =
Keyword.get(options, :signature_help_supported, false) &&
Keyword.get(options, :snippets_supported, false) &&
arity > 0 &&
with_parens?

{label, insert_text} =
cond do
match?("sigil_" <> _, name) ->
Expand All @@ -614,33 +655,48 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
Keyword.merge(
options,
pipe_before?: pipe_before?,
capture_before?: capture_before?
capture_before?: capture_before?,
trigger_signature?: trigger_signature?,
locals_without_parens: locals_without_parens,
text_after_cursor: text_after_cursor,
with_parens?: with_parens?
)
)

{label, insert_text}
end

detail =
cond do
spec && spec != "" ->
spec
detail_header =
if inspect(module) == origin do
"#{visibility} #{type}"
else
"#{origin} #{type}"
end

String.starts_with?(type, ["private", "public"]) ->
String.replace(type, "_", " ")
footer =
if String.starts_with?(type, ["private", "public"]) do
String.replace(type, "_", " ")
else
SourceFile.format_spec(spec, line_length: 30)
end

true ->
"(#{origin}) #{type}"
command =
if trigger_signature? do
%{
"title" => "Trigger Parameter Hint",
"command" => "editor.action.triggerParameterHints"
}
end

%__MODULE__{
label: label,
kind: :function,
detail: detail,
documentation: summary,
detail: detail_header <> "\n\n" <> Enum.join([to_string(name), "(", args, ")"]),
documentation: summary <> footer,
insert_text: insert_text,
priority: 7,
tags: metadata_to_tags(metadata)
tags: metadata_to_tags(metadata),
command: command
}
end

Expand Down Expand Up @@ -678,6 +734,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
"filterText" => item.filter_text,
"sortText" => String.pad_leading(to_string(idx), 8, "0"),
"insertText" => item.insert_text,
"command" => item.command,
"insertTextFormat" =>
if Keyword.get(options, :snippets_supported, false) do
insert_text_format(:snippet)
Expand Down Expand Up @@ -724,4 +781,10 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do
_ -> [:deprecated]
end
end

defp function_name_with_parens?(name, arity, locals_without_parens) do
(locals_without_parens || MapSet.new())
|> MapSet.member?({String.to_atom(name), arity})
|> Kernel.not()
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand do
formatted =
try do
target_line_length =
Mix.Tasks.Format.formatter_opts_for_file(SourceFile.path_from_uri(uri))
uri
|> SourceFile.formatter_opts()
|> Keyword.get(:line_length, 98)

target_line_length = target_line_length - String.length(indentation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do

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

response =
Expand Down Expand Up @@ -41,10 +40,6 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do
String.starts_with?(Path.absname(file_path), cwd)
end

defp formatter_opts(for_file) do
Mix.Tasks.Format.formatter_opts_for_file(for_file)
end

defp myers_diff_to_text_edits(myers_diff, starting_pos \\ {0, 0}) do
myers_diff_to_text_edits(myers_diff, starting_pos, [])
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule ElixirLS.LanguageServer.Providers.SignatureHelp do
alias ElixirLS.LanguageServer.SourceFile

def signature(source_file, line, character) do
response =
case ElixirSense.signature(source_file.text, line + 1, character + 1) do
Expand All @@ -23,9 +25,19 @@ defmodule ElixirLS.LanguageServer.Providers.SignatureHelp do
response = %{"label" => label, "parameters" => params_info}

case {spec, documentation} do
{"", ""} -> response
{"", _} -> Map.put(response, "documentation", documentation)
{_, _} -> Map.put(response, "documentation", "#{spec}\n#{documentation}")
{"", ""} ->
response

{"", _} ->
put_documentation(response, documentation)

{_, _} ->
spec_str = SourceFile.format_spec(spec, line_length: 42)
put_documentation(response, "#{documentation}\n#{spec_str}")
end
end

defp put_documentation(response, documentation) do
Map.put(response, "documentation", %{"kind" => "markdown", "value" => documentation})
end
end
13 changes: 12 additions & 1 deletion apps/language_server/lib/language_server/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,22 @@ defmodule ElixirLS.LanguageServer.Server do
%{"valueSet" => value_set} -> value_set
end

signature_help_supported =
!!get_in(state.client_capabilities, ["textDocument", "signatureHelp"])

locals_without_parens =
uri
|> SourceFile.formatter_opts()
|> Keyword.get(:locals_without_parens, [])
|> MapSet.new()

fun = fn ->
Completion.completion(state.source_files[uri].text, line, character,
snippets_supported: snippets_supported,
deprecated_supported: deprecated_supported,
tags_supported: tags_supported
tags_supported: tags_supported,
signature_help_supported: signature_help_supported,
locals_without_parens: locals_without_parens
)
end

Expand Down
51 changes: 51 additions & 0 deletions apps/language_server/lib/language_server/source_file.ex
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,55 @@ defmodule ElixirLS.LanguageServer.SourceFile do
_other -> {function, arity}
end
end

@spec format_spec(String.t(), keyword()) :: String.t()
def format_spec(spec, _opts) when spec in [nil, ""] do
""
end

def format_spec(spec, opts) do
line_length = Keyword.fetch!(opts, :line_length)

spec_str =
case format_code(spec, line_length: line_length) do
{:ok, code} ->
code
|> to_string()
|> lines()
|> remove_indentation(String.length("@spec "))
|> Enum.join("\n")

{:error, _} ->
spec
end

"""
```
#{spec_str}
```
"""
end

@spec formatter_opts(String.t()) :: keyword()
def formatter_opts(uri) do
uri
|> path_from_uri()
|> Mix.Tasks.Format.formatter_opts_for_file()
end

defp format_code(code, opts) do
try do
{:ok, Code.format_string!(code, opts)}
rescue
e ->
{:error, e}
end
end

defp remove_indentation([line | rest], length) do
[line | Enum.map(rest, &String.slice(&1, length..-1))]
end

defp remove_indentation(lines, _), do: lines
end
Loading