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 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
1 change: 1 addition & 0 deletions apps/language_server/lib/language_server/json_rpc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,5 @@ defmodule ElixirLS.LanguageServer.JsonRpc do

defp error_code_and_message(:request_cancelled), do: {-32800, "Request cancelled"}
defp error_code_and_message(:content_modified), do: {-32801, "Content modified"}
defp error_code_and_message(:code_lens_error), do: {-32900, "Error while building code lenses"}
end
145 changes: 17 additions & 128 deletions apps/language_server/lib/language_server/providers/code_lens.ex
Original file line number Diff line number Diff line change
@@ -1,139 +1,28 @@
defmodule ElixirLS.LanguageServer.Providers.CodeLens do
@moduledoc """
Collects the success typings inferred by Dialyzer, translates the syntax to Elixir, and shows them
inline in the editor as @spec suggestions.
Provides different code lenses to the client.

The server, unfortunately, has no way to force the client to refresh the @spec code lenses when new
success typings, so we let this request block until we know we have up-to-date results from
Dialyzer. We rely on the client being able to await this result while still making other requests
in parallel. If the client is unable to perform requests in parallel, the client or user should
disable this feature.
Supports the following code lenses:
* Suggestions for Dialyzer @spec definitions
* Shortcuts for executing tests
"""
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.TypeSpec.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
160 changes: 160 additions & 0 deletions apps/language_server/lib/language_server/providers/code_lens/test.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test do
@moduledoc """
Identifies test execution targets and provides code lenses for automatically executing them.

Supports the following execution targets:
* Test modules (any module that imports ExUnit.Case)
* Describe blocks (any call to describe/2 inside a test module)
* Test blocks (any call to test/2 or test/3 inside a test module)
"""

alias ElixirLS.LanguageServer.Providers.CodeLens
alias ElixirLS.LanguageServer.Providers.CodeLens.Test.DescribeBlock
alias ElixirLS.LanguageServer.Providers.CodeLens.Test.TestBlock
alias ElixirLS.LanguageServer.SourceFile
alias ElixirSense.Core.Parser
alias ElixirSense.Core.Metadata

@run_test_command "elixir.lens.test.run"

def code_lens(uri, text) do
with {:ok, buffer_file_metadata} <- parse_source(text) do
source_lines = SourceFile.lines(text)

file_path = SourceFile.path_from_uri(uri)

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

lines_to_env_list = Map.to_list(buffer_file_metadata.lines_to_env)

describe_blocks = find_describe_blocks(lines_to_env_list, calls_list, source_lines)
describe_lenses = get_describe_lenses(describe_blocks, file_path)

test_lenses =
lines_to_env_list
|> find_test_blocks(calls_list, describe_blocks, source_lines)
|> get_test_lenses(file_path)

module_lenses =
buffer_file_metadata
|> get_test_modules()
|> get_module_lenses(file_path)

{:ok, test_lenses ++ describe_lenses ++ module_lenses}
end
end

defp get_test_lenses(test_blocks, file_path) do
args = fn block ->
%{
"filePath" => file_path,
"testName" => block.name
}
|> Map.merge(if block.describe != nil, do: %{"describe" => block.describe.name}, else: %{})
end

test_blocks
|> Enum.map(fn block ->
CodeLens.build_code_lens(block.line, "Run test", @run_test_command, args.(block))
end)
end

defp get_describe_lenses(describe_blocks, file_path) do
describe_blocks
|> Enum.map(fn block ->
CodeLens.build_code_lens(block.line, "Run tests", @run_test_command, %{
"filePath" => file_path,
"describe" => block.name
})
end)
end

defp find_test_blocks(lines_to_env_list, calls_list, describe_blocks, source_lines) do
runnable_functions = [{:test, 3}, {:test, 2}]

for func <- runnable_functions,
{line, _col} <- calls_to(calls_list, func),
is_test_module?(lines_to_env_list, line) do
{_line, %{scope_id: scope_id}} =
Enum.find(lines_to_env_list, fn {env_line, _env} -> env_line == line end)

describe =
describe_blocks
|> Enum.find(nil, fn describe ->
describe.body_scope_id == scope_id
end)

%{"name" => test_name} =
~r/^\s*test "(?<name>.*)"(,.*)? do/
|> Regex.named_captures(Enum.at(source_lines, line - 1))

%TestBlock{name: test_name, describe: describe, line: line}
end
end

defp find_describe_blocks(lines_to_env_list, calls_list, source_lines) do
lines_to_env_list_length = length(lines_to_env_list)

for {line, _col} <- calls_to(calls_list, {:describe, 2}),
is_test_module?(lines_to_env_list, line) do
DescribeBlock.find_block_info(
line,
lines_to_env_list,
lines_to_env_list_length,
source_lines
)
end
end

defp get_module_lenses(test_modules, file_path) do
test_modules
|> Enum.map(fn {module, line} ->
CodeLens.build_code_lens(line, "Run tests in module", @run_test_command, %{
"filePath" => file_path,
"module" => module
})
end)
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 is_test_module?(lines_to_env), do: is_test_module?(lines_to_env, :infinity)

defp is_test_module?(lines_to_env, line) when is_list(lines_to_env) do
lines_to_env
|> Enum.max_by(fn
{env_line, _env} when env_line < line -> env_line
_ -> -1
end)
|> 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
for call_info <- calls_list,
call_info.func == function and call_info.arity === arity do
call_info.position
end
end

defp parse_source(text) do
buffer_file_metadata =
text
|> Parser.parse_string(true, true, 1)

if buffer_file_metadata.error != nil do
{:error, buffer_file_metadata}
else
{:ok, buffer_file_metadata}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test.DescribeBlock do
alias ElixirSense.Core.State.Env

@struct_keys [:line, :name, :body_scope_id]

@enforce_keys @struct_keys
defstruct @struct_keys

def find_block_info(line, lines_to_env_list, lines_to_env_list_length, source_lines) do
name = get_name(source_lines, line)

body_scope_id =
get_body_scope_id(
line,
lines_to_env_list,
lines_to_env_list_length
)

%__MODULE__{line: line, body_scope_id: body_scope_id, name: name}
end

defp get_name(source_lines, declaration_line) do
%{"name" => name} =
~r/^\s*describe "(?<name>.*)" do/
|> Regex.named_captures(Enum.at(source_lines, declaration_line - 1))

name
end

defp get_body_scope_id(
declaration_line,
lines_to_env_list,
lines_to_env_list_length
) do
env_index =
lines_to_env_list
|> Enum.find_index(fn {line, _env} -> line == declaration_line end)

{_line, %{scope_id: declaration_scope_id}} =
lines_to_env_list
|> Enum.at(env_index)

with true <- env_index + 1 < lines_to_env_list_length,
next_env = Enum.at(lines_to_env_list, env_index + 1),
{_line, %Env{scope_id: body_scope_id}} <- next_env,
true <- body_scope_id != declaration_scope_id do
body_scope_id
else
_ -> nil
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test.TestBlock do
@struct_keys [:name, :describe, :line]

@enforce_keys @struct_keys
defstruct @struct_keys
end
Loading