-
Notifications
You must be signed in to change notification settings - Fork 215
Provide test running code lens #389
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
Changes from 17 commits
503f6c2
fd1793e
bd49269
b7420ea
d0b25ba
b8aeb9c
01b0758
03b36f6
642aa8b
f61fdd5
d938be9
395be90
4b7990c
1fb2bb6
95f205a
26fe78e
185a0d9
918ada4
069c323
2fe0e8e
dc012a2
6b616fb
1230ce3
f8c1c58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
defmodule DescribeBlock do | ||
Blond11516 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
) | ||
|
||
%DescribeBlock{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 | ||
|
||
defmodule TestBlock do | ||
Blond11516 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@struct_keys [:name, :describe, :line] | ||
|
||
@enforce_keys @struct_keys | ||
defstruct @struct_keys | ||
end | ||
|
||
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.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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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 |
Uh oh!
There was an error while loading. Please reload this page.