Skip to content
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

Provide test running code lens #389

Merged
merged 24 commits into from
Dec 4, 2020
Merged
Show file tree
Hide file tree
Changes from 5 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
134 changes: 13 additions & 121 deletions apps/language_server/lib/language_server/providers/code_lens.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,130 +10,22 @@ defmodule ElixirLS.LanguageServer.Providers.CodeLens do
disable this feature.
"""
Blond11516 marked this conversation as resolved.
Show resolved Hide resolved

alias ElixirLS.LanguageServer.{Server, SourceFile}
alias Erl2ex.Convert.{Context, ErlForms}
alias Erl2ex.Pipeline.{Parse, ModuleData, ExSpec}
alias ElixirLS.LanguageServer.Providers.CodeLens
import ElixirLS.LanguageServer.Protocol

defmodule ContractTranslator do
def translate_contract(fun, contract, is_macro) do
# FIXME: Private module
{[%ExSpec{specs: [spec]} | _], _} =
"-spec foo#{contract}."
# FIXME: Private module
|> Parse.string()
|> hd()
|> elem(0)
# FIXME: Private module
|> ErlForms.conv_form(%Context{
in_type_expr: true,
# FIXME: Private module
module_data: %ModuleData{}
})
def spec_code_lens(server_instance_id, uri, text),
do: CodeLens.Spec.code_lens(server_instance_id, uri, text)

spec
|> Macro.postwalk(&tweak_specs/1)
|> drop_macro_env(is_macro)
|> Macro.to_string()
|> String.replace("()", "")
|> Code.format_string!(line_length: :infinity)
|> IO.iodata_to_binary()
|> String.replace_prefix("foo", to_string(fun))
end
def test_code_lens(uri, text), do: CodeLens.Test.code_lens(uri, text)

defp tweak_specs({:list, _meta, args}) do
case args do
[{:{}, _, [{:atom, _, []}, {wild, _, _}]}] when wild in [:_, :any] -> quote do: keyword()
list -> list
end
end

defp tweak_specs({:nonempty_list, _meta, args}) do
case args do
[{:any, _, []}] -> quote do: [...]
_ -> args ++ quote do: [...]
end
end

defp tweak_specs({:%{}, _meta, fields}) do
fields =
Enum.map(fields, fn
{:map_field_exact, _, [key, value]} -> {key, value}
{key, value} -> quote do: {optional(unquote(key)), unquote(value)}
field -> field
end)
|> Enum.reject(&match?({{:optional, _, [{:any, _, []}]}, {:any, _, []}}, &1))

fields
|> Enum.find_value(fn
{:__struct__, struct_type} when is_atom(struct_type) -> struct_type
_ -> nil
end)
|> case do
nil -> {:%{}, [], fields}
struct_type -> {{:., [], [struct_type, :t]}, [], []}
end
end

# Undo conversion of _ to any() when inside binary spec
defp tweak_specs({:<<>>, _, children}) do
children =
Macro.postwalk(children, fn
{:any, _, []} -> quote do: _
other -> other
end)

{:<<>>, [], children}
end

defp tweak_specs({:_, _, _}) do
quote do: any()
end

defp tweak_specs({:when, [], [spec, substitutions]}) do
substitutions = Enum.reject(substitutions, &match?({:_, {:any, _, []}}, &1))

case substitutions do
[] -> spec
_ -> {:when, [], [spec, substitutions]}
end
end

defp tweak_specs(node) do
node
end

defp drop_macro_env(ast, false), do: ast

defp drop_macro_env({:"::", [], [{:foo, [], [_env | rest]}, res]}, true) do
{:"::", [], [{:foo, [], rest}, res]}
end
end

def code_lens(server_instance_id, uri, text) do
resp =
for {_, line, {mod, fun, arity}, contract, is_macro} <- Server.suggest_contracts(uri),
SourceFile.function_def_on_line?(text, line, fun),
spec = ContractTranslator.translate_contract(fun, contract, is_macro) do
%{
"range" => range(line - 1, 0, line - 1, 0),
"command" => %{
"title" => "@spec #{spec}",
"command" => "spec:#{server_instance_id}",
"arguments" => [
%{
"uri" => uri,
"mod" => to_string(mod),
"fun" => to_string(fun),
"arity" => arity,
"spec" => spec,
"line" => line
}
]
}
}
end

{:ok, resp}
def build_code_lens(line, title, command, argument) do
%{
"range" => range(line - 1, 0, line - 1, 0),
"command" => %{
"title" => title,
"command" => command,
"arguments" => [argument]
}
}
end
end
124 changes: 124 additions & 0 deletions apps/language_server/lib/language_server/providers/code_lens/spec.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Spec do
Blond11516 marked this conversation as resolved.
Show resolved Hide resolved
alias ElixirLS.LanguageServer.Providers.CodeLens
alias ElixirLS.LanguageServer.{Server, SourceFile}
alias Erl2ex.Convert.{Context, ErlForms}
alias Erl2ex.Pipeline.{Parse, ModuleData, ExSpec}

defmodule ContractTranslator do
def translate_contract(fun, contract, is_macro) do
# FIXME: Private module
{[%ExSpec{specs: [spec]} | _], _} =
"-spec foo#{contract}."
# FIXME: Private module
|> Parse.string()
|> hd()
|> elem(0)
# FIXME: Private module
|> ErlForms.conv_form(%Context{
in_type_expr: true,
# FIXME: Private module
module_data: %ModuleData{}
})

spec
|> Macro.postwalk(&tweak_specs/1)
|> drop_macro_env(is_macro)
|> Macro.to_string()
|> String.replace("()", "")
|> Code.format_string!(line_length: :infinity)
|> IO.iodata_to_binary()
|> String.replace_prefix("foo", to_string(fun))
end

defp tweak_specs({:list, _meta, args}) do
case args do
[{:{}, _, [{:atom, _, []}, {wild, _, _}]}] when wild in [:_, :any] -> quote do: keyword()
list -> list
end
end

defp tweak_specs({:nonempty_list, _meta, args}) do
case args do
[{:any, _, []}] -> quote do: [...]
_ -> args ++ quote do: [...]
end
end

defp tweak_specs({:%{}, _meta, fields}) do
fields =
Enum.map(fields, fn
{:map_field_exact, _, [key, value]} -> {key, value}
{key, value} -> quote do: {optional(unquote(key)), unquote(value)}
field -> field
end)
|> Enum.reject(&match?({{:optional, _, [{:any, _, []}]}, {:any, _, []}}, &1))

fields
|> Enum.find_value(fn
{:__struct__, struct_type} when is_atom(struct_type) -> struct_type
_ -> nil
end)
|> case do
nil -> {:%{}, [], fields}
struct_type -> {{:., [], [struct_type, :t]}, [], []}
end
end

# Undo conversion of _ to any() when inside binary spec
defp tweak_specs({:<<>>, _, children}) do
children =
Macro.postwalk(children, fn
{:any, _, []} -> quote do: _
other -> other
end)

{:<<>>, [], children}
end

defp tweak_specs({:_, _, _}) do
quote do: any()
end

defp tweak_specs({:when, [], [spec, substitutions]}) do
substitutions = Enum.reject(substitutions, &match?({:_, {:any, _, []}}, &1))

case substitutions do
[] -> spec
_ -> {:when, [], [spec, substitutions]}
end
end

defp tweak_specs(node) do
node
end

defp drop_macro_env(ast, false), do: ast

defp drop_macro_env({:"::", [], [{:foo, [], [_env | rest]}, res]}, true) do
{:"::", [], [{:foo, [], rest}, res]}
end
end

def code_lens(server_instance_id, uri, text) do
resp =
for {_, line, {mod, fun, arity}, contract, is_macro} <- Server.suggest_contracts(uri),
SourceFile.function_def_on_line?(text, line, fun),
spec = ContractTranslator.translate_contract(fun, contract, is_macro) do
CodeLens.build_code_lens(
line,
"@spec #{spec}",
"spec:#{server_instance_id}",
%{
"uri" => uri,
"mod" => to_string(mod),
"fun" => to_string(fun),
"arity" => arity,
"spec" => spec,
"line" => line
}
)
end

{:ok, resp}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test do
alias ElixirLS.LanguageServer.Providers.CodeLens
alias ElixirLS.LanguageServer.SourceFile
alias ElixirSense.Core.Parser
alias ElixirSense.Core.Metadata

@run_test_command "elixir.test.run"
axelson marked this conversation as resolved.
Show resolved Hide resolved

def code_lens(uri, text) do
buffer_file_metadata =
Blond11516 marked this conversation as resolved.
Show resolved Hide resolved
text
|> Parser.parse_string(true, true, 1)

file_path = SourceFile.path_from_uri(uri)

function_lenses = get_function_lenses(buffer_file_metadata, file_path)
module_lenses = get_module_lenses(buffer_file_metadata, file_path)

{:ok, function_lenses ++ module_lenses}
end

defp get_module_lenses(%Metadata{} = metadata, file_path) do
metadata
|> get_test_modules()
|> Enum.map(&build_test_module_code_lens(file_path, &1))
end

defp get_test_modules(%Metadata{lines_to_env: lines_to_env}) do
lines_to_env
|> Enum.group_by(fn {_line, env} -> env.module end)
|> Enum.filter(fn {_module, module_lines_to_env} -> is_test_module?(module_lines_to_env) end)
|> Enum.map(fn {module, [{line, _env} | _rest]} -> {module, line} end)
end

defp get_function_lenses(%Metadata{} = metadata, file_path) do
runnable_functions = [{:test, 3}, {:test, 2}, {:describe, 2}]

calls_list =
metadata.calls
|> Enum.map(fn {_k, v} -> v end)
|> List.flatten()

for func <- runnable_functions do
Blond11516 marked this conversation as resolved.
Show resolved Hide resolved
for {line, _col} <- calls_to(calls_list, func),
is_test_module?(metadata.lines_to_env, line) do
build_function_test_code_lens(func, file_path, line)
end
end
|> List.flatten()
end

defp is_test_module?(lines_to_env), do: is_test_module?(lines_to_env, :infinity)

defp is_test_module?(lines_to_env, line) when is_map(lines_to_env) do
lines_to_env
|> Map.to_list()
|> is_test_module?(line)
end

defp is_test_module?(lines_to_env, line) when is_list(lines_to_env) do
lines_to_env
|> Enum.filter(fn {env_line, _env} -> env_line < line end)
Blond11516 marked this conversation as resolved.
Show resolved Hide resolved
|> List.last()
|> elem(1)
|> Map.get(:imports)
|> Enum.any?(fn module -> module == ExUnit.Case end)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably works well, although an alternative would be to use the same configuration that ex_unit itself is using:

:test_pattern - a pattern to load test files. Defaults to *_test.exs

To be clear I don't think we should change it at this time (unless the current code results in very poor performance or some similar concern).

end

defp calls_to(calls_list, {function, arity}) do
calls_list
Blond11516 marked this conversation as resolved.
Show resolved Hide resolved
|> Enum.filter(fn call_info -> call_info.func == function and call_info.arity === arity end)
|> Enum.map(fn call -> call.position end)
end

defp build_test_module_code_lens(file_path, {module, line}) do
CodeLens.build_code_lens(line, "Run tests in module", @run_test_command, %{
"file_path" => file_path,
"module" => module
})
end

defp build_function_test_code_lens(title, file_path, line) when is_binary(title) do
CodeLens.build_code_lens(line, title, @run_test_command, %{
"file_path" => file_path,
"line" => line
})
end

defp build_function_test_code_lens({:test, _arity}, file_path, line),
do: build_function_test_code_lens("Run test", file_path, line)

defp build_function_test_code_lens({:describe, _arity}, file_path, line),
do: build_function_test_code_lens("Run tests", file_path, line)
end
21 changes: 15 additions & 6 deletions apps/language_server/lib/language_server/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -530,13 +530,14 @@ defmodule ElixirLS.LanguageServer.Server do
end

defp handle_request(code_lens_req(_id, uri), state) do
if dialyzer_enabled?(state) and state.settings["suggestSpecs"] != false do
{:async,
fn -> CodeLens.code_lens(state.server_instance_id, uri, state.source_files[uri].text) end,
state}
else
{:ok, nil, state}
fun = fn ->
with {:ok, spec_code_lens} <- get_spec_code_lens(state, uri),
{:ok, test_code_lens} <- CodeLens.test_code_lens(uri, state.source_files[uri].text) do
{:ok, spec_code_lens ++ test_code_lens}
end
end

{:async, fun, state}
end

defp handle_request(execute_command_req(_id, command, args), state) do
Expand Down Expand Up @@ -595,6 +596,14 @@ defmodule ElixirLS.LanguageServer.Server do
}
end

defp get_spec_code_lens(state, uri) do
if dialyzer_enabled?(state) and state.settings["suggestSpecs"] != false do
CodeLens.spec_code_lens(state.server_instance_id, uri, state.source_files[uri].text)
else
{:ok, []}
end
end

# Build

defp trigger_build(state) do
Expand Down