Skip to content

Commit

Permalink
Provide test running code lens (#389)
Browse files Browse the repository at this point in the history
* Provide code lenses for running tests (WIP)

* Remove unused function

* Properly identify test modules

* Handle errors in server request

* Extract calls list

* Validate that parsing did not fail

* Clean up searching for test calls

* Refactor provider to include test name in lenses

* Rename Spec code lens provider to TypeSpec

* Update code lenses provider documentation

* Move doc + update command

* Improve error handling

* Add code_lens_error to JsonRpc

* Don't raise if describe not found

* Add provider unit tests

* Add server test

* Extract describe and test block modules

* Handle setting for disabling test code lenses

* Return empty list instead of error for disabled lenses

* Update tests

* Fix tests

* Delete .tool-versions

Co-authored-by: Jason Axelson <[email protected]>
  • Loading branch information
Étienne Lévesque and axelson authored Dec 4, 2020
1 parent d1e0e47 commit 5b2d236
Show file tree
Hide file tree
Showing 11 changed files with 687 additions and 133 deletions.
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
"""

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)
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

0 comments on commit 5b2d236

Please sign in to comment.