-
Notifications
You must be signed in to change notification settings - Fork 196
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Provide test running code lens (#389)
* 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
Showing
11 changed files
with
687 additions
and
133 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
145 changes: 17 additions & 128 deletions
145
apps/language_server/lib/language_server/providers/code_lens.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
160
apps/language_server/lib/language_server/providers/code_lens/test.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
52 changes: 52 additions & 0 deletions
52
apps/language_server/lib/language_server/providers/code_lens/test/describe_block.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
6 changes: 6 additions & 0 deletions
6
apps/language_server/lib/language_server/providers/code_lens/test/test_block.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.