Skip to content

Commit 5b2d236

Browse files
Étienne Lévesqueaxelson
Étienne Lévesque
andauthored
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]>
1 parent d1e0e47 commit 5b2d236

File tree

11 files changed

+687
-133
lines changed

11 files changed

+687
-133
lines changed

apps/language_server/lib/language_server/json_rpc.ex

+1
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,5 @@ defmodule ElixirLS.LanguageServer.JsonRpc do
203203

204204
defp error_code_and_message(:request_cancelled), do: {-32800, "Request cancelled"}
205205
defp error_code_and_message(:content_modified), do: {-32801, "Content modified"}
206+
defp error_code_and_message(:code_lens_error), do: {-32900, "Error while building code lenses"}
206207
end
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,28 @@
11
defmodule ElixirLS.LanguageServer.Providers.CodeLens do
22
@moduledoc """
3-
Collects the success typings inferred by Dialyzer, translates the syntax to Elixir, and shows them
4-
inline in the editor as @spec suggestions.
3+
Provides different code lenses to the client.
54
6-
The server, unfortunately, has no way to force the client to refresh the @spec code lenses when new
7-
success typings, so we let this request block until we know we have up-to-date results from
8-
Dialyzer. We rely on the client being able to await this result while still making other requests
9-
in parallel. If the client is unable to perform requests in parallel, the client or user should
10-
disable this feature.
5+
Supports the following code lenses:
6+
* Suggestions for Dialyzer @spec definitions
7+
* Shortcuts for executing tests
118
"""
129

13-
alias ElixirLS.LanguageServer.{Server, SourceFile}
14-
alias Erl2ex.Convert.{Context, ErlForms}
15-
alias Erl2ex.Pipeline.{Parse, ModuleData, ExSpec}
10+
alias ElixirLS.LanguageServer.Providers.CodeLens
1611
import ElixirLS.LanguageServer.Protocol
1712

18-
defmodule ContractTranslator do
19-
def translate_contract(fun, contract, is_macro) do
20-
# FIXME: Private module
21-
{[%ExSpec{specs: [spec]} | _], _} =
22-
"-spec foo#{contract}."
23-
# FIXME: Private module
24-
|> Parse.string()
25-
|> hd()
26-
|> elem(0)
27-
# FIXME: Private module
28-
|> ErlForms.conv_form(%Context{
29-
in_type_expr: true,
30-
# FIXME: Private module
31-
module_data: %ModuleData{}
32-
})
13+
def spec_code_lens(server_instance_id, uri, text),
14+
do: CodeLens.TypeSpec.code_lens(server_instance_id, uri, text)
3315

34-
spec
35-
|> Macro.postwalk(&tweak_specs/1)
36-
|> drop_macro_env(is_macro)
37-
|> Macro.to_string()
38-
|> String.replace("()", "")
39-
|> Code.format_string!(line_length: :infinity)
40-
|> IO.iodata_to_binary()
41-
|> String.replace_prefix("foo", to_string(fun))
42-
end
16+
def test_code_lens(uri, text), do: CodeLens.Test.code_lens(uri, text)
4317

44-
defp tweak_specs({:list, _meta, args}) do
45-
case args do
46-
[{:{}, _, [{:atom, _, []}, {wild, _, _}]}] when wild in [:_, :any] -> quote do: keyword()
47-
list -> list
48-
end
49-
end
50-
51-
defp tweak_specs({:nonempty_list, _meta, args}) do
52-
case args do
53-
[{:any, _, []}] -> quote do: [...]
54-
_ -> args ++ quote do: [...]
55-
end
56-
end
57-
58-
defp tweak_specs({:%{}, _meta, fields}) do
59-
fields =
60-
Enum.map(fields, fn
61-
{:map_field_exact, _, [key, value]} -> {key, value}
62-
{key, value} -> quote do: {optional(unquote(key)), unquote(value)}
63-
field -> field
64-
end)
65-
|> Enum.reject(&match?({{:optional, _, [{:any, _, []}]}, {:any, _, []}}, &1))
66-
67-
fields
68-
|> Enum.find_value(fn
69-
{:__struct__, struct_type} when is_atom(struct_type) -> struct_type
70-
_ -> nil
71-
end)
72-
|> case do
73-
nil -> {:%{}, [], fields}
74-
struct_type -> {{:., [], [struct_type, :t]}, [], []}
75-
end
76-
end
77-
78-
# Undo conversion of _ to any() when inside binary spec
79-
defp tweak_specs({:<<>>, _, children}) do
80-
children =
81-
Macro.postwalk(children, fn
82-
{:any, _, []} -> quote do: _
83-
other -> other
84-
end)
85-
86-
{:<<>>, [], children}
87-
end
88-
89-
defp tweak_specs({:_, _, _}) do
90-
quote do: any()
91-
end
92-
93-
defp tweak_specs({:when, [], [spec, substitutions]}) do
94-
substitutions = Enum.reject(substitutions, &match?({:_, {:any, _, []}}, &1))
95-
96-
case substitutions do
97-
[] -> spec
98-
_ -> {:when, [], [spec, substitutions]}
99-
end
100-
end
101-
102-
defp tweak_specs(node) do
103-
node
104-
end
105-
106-
defp drop_macro_env(ast, false), do: ast
107-
108-
defp drop_macro_env({:"::", [], [{:foo, [], [_env | rest]}, res]}, true) do
109-
{:"::", [], [{:foo, [], rest}, res]}
110-
end
111-
end
112-
113-
def code_lens(server_instance_id, uri, text) do
114-
resp =
115-
for {_, line, {mod, fun, arity}, contract, is_macro} <- Server.suggest_contracts(uri),
116-
SourceFile.function_def_on_line?(text, line, fun),
117-
spec = ContractTranslator.translate_contract(fun, contract, is_macro) do
118-
%{
119-
"range" => range(line - 1, 0, line - 1, 0),
120-
"command" => %{
121-
"title" => "@spec #{spec}",
122-
"command" => "spec:#{server_instance_id}",
123-
"arguments" => [
124-
%{
125-
"uri" => uri,
126-
"mod" => to_string(mod),
127-
"fun" => to_string(fun),
128-
"arity" => arity,
129-
"spec" => spec,
130-
"line" => line
131-
}
132-
]
133-
}
134-
}
135-
end
136-
137-
{:ok, resp}
18+
def build_code_lens(line, title, command, argument) do
19+
%{
20+
"range" => range(line - 1, 0, line - 1, 0),
21+
"command" => %{
22+
"title" => title,
23+
"command" => command,
24+
"arguments" => [argument]
25+
}
26+
}
13827
end
13928
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test do
2+
@moduledoc """
3+
Identifies test execution targets and provides code lenses for automatically executing them.
4+
5+
Supports the following execution targets:
6+
* Test modules (any module that imports ExUnit.Case)
7+
* Describe blocks (any call to describe/2 inside a test module)
8+
* Test blocks (any call to test/2 or test/3 inside a test module)
9+
"""
10+
11+
alias ElixirLS.LanguageServer.Providers.CodeLens
12+
alias ElixirLS.LanguageServer.Providers.CodeLens.Test.DescribeBlock
13+
alias ElixirLS.LanguageServer.Providers.CodeLens.Test.TestBlock
14+
alias ElixirLS.LanguageServer.SourceFile
15+
alias ElixirSense.Core.Parser
16+
alias ElixirSense.Core.Metadata
17+
18+
@run_test_command "elixir.lens.test.run"
19+
20+
def code_lens(uri, text) do
21+
with {:ok, buffer_file_metadata} <- parse_source(text) do
22+
source_lines = SourceFile.lines(text)
23+
24+
file_path = SourceFile.path_from_uri(uri)
25+
26+
calls_list =
27+
buffer_file_metadata.calls
28+
|> Enum.map(fn {_k, v} -> v end)
29+
|> List.flatten()
30+
31+
lines_to_env_list = Map.to_list(buffer_file_metadata.lines_to_env)
32+
33+
describe_blocks = find_describe_blocks(lines_to_env_list, calls_list, source_lines)
34+
describe_lenses = get_describe_lenses(describe_blocks, file_path)
35+
36+
test_lenses =
37+
lines_to_env_list
38+
|> find_test_blocks(calls_list, describe_blocks, source_lines)
39+
|> get_test_lenses(file_path)
40+
41+
module_lenses =
42+
buffer_file_metadata
43+
|> get_test_modules()
44+
|> get_module_lenses(file_path)
45+
46+
{:ok, test_lenses ++ describe_lenses ++ module_lenses}
47+
end
48+
end
49+
50+
defp get_test_lenses(test_blocks, file_path) do
51+
args = fn block ->
52+
%{
53+
"filePath" => file_path,
54+
"testName" => block.name
55+
}
56+
|> Map.merge(if block.describe != nil, do: %{"describe" => block.describe.name}, else: %{})
57+
end
58+
59+
test_blocks
60+
|> Enum.map(fn block ->
61+
CodeLens.build_code_lens(block.line, "Run test", @run_test_command, args.(block))
62+
end)
63+
end
64+
65+
defp get_describe_lenses(describe_blocks, file_path) do
66+
describe_blocks
67+
|> Enum.map(fn block ->
68+
CodeLens.build_code_lens(block.line, "Run tests", @run_test_command, %{
69+
"filePath" => file_path,
70+
"describe" => block.name
71+
})
72+
end)
73+
end
74+
75+
defp find_test_blocks(lines_to_env_list, calls_list, describe_blocks, source_lines) do
76+
runnable_functions = [{:test, 3}, {:test, 2}]
77+
78+
for func <- runnable_functions,
79+
{line, _col} <- calls_to(calls_list, func),
80+
is_test_module?(lines_to_env_list, line) do
81+
{_line, %{scope_id: scope_id}} =
82+
Enum.find(lines_to_env_list, fn {env_line, _env} -> env_line == line end)
83+
84+
describe =
85+
describe_blocks
86+
|> Enum.find(nil, fn describe ->
87+
describe.body_scope_id == scope_id
88+
end)
89+
90+
%{"name" => test_name} =
91+
~r/^\s*test "(?<name>.*)"(,.*)? do/
92+
|> Regex.named_captures(Enum.at(source_lines, line - 1))
93+
94+
%TestBlock{name: test_name, describe: describe, line: line}
95+
end
96+
end
97+
98+
defp find_describe_blocks(lines_to_env_list, calls_list, source_lines) do
99+
lines_to_env_list_length = length(lines_to_env_list)
100+
101+
for {line, _col} <- calls_to(calls_list, {:describe, 2}),
102+
is_test_module?(lines_to_env_list, line) do
103+
DescribeBlock.find_block_info(
104+
line,
105+
lines_to_env_list,
106+
lines_to_env_list_length,
107+
source_lines
108+
)
109+
end
110+
end
111+
112+
defp get_module_lenses(test_modules, file_path) do
113+
test_modules
114+
|> Enum.map(fn {module, line} ->
115+
CodeLens.build_code_lens(line, "Run tests in module", @run_test_command, %{
116+
"filePath" => file_path,
117+
"module" => module
118+
})
119+
end)
120+
end
121+
122+
defp get_test_modules(%Metadata{lines_to_env: lines_to_env}) do
123+
lines_to_env
124+
|> Enum.group_by(fn {_line, env} -> env.module end)
125+
|> Enum.filter(fn {_module, module_lines_to_env} -> is_test_module?(module_lines_to_env) end)
126+
|> Enum.map(fn {module, [{line, _env} | _rest]} -> {module, line} end)
127+
end
128+
129+
defp is_test_module?(lines_to_env), do: is_test_module?(lines_to_env, :infinity)
130+
131+
defp is_test_module?(lines_to_env, line) when is_list(lines_to_env) do
132+
lines_to_env
133+
|> Enum.max_by(fn
134+
{env_line, _env} when env_line < line -> env_line
135+
_ -> -1
136+
end)
137+
|> elem(1)
138+
|> Map.get(:imports)
139+
|> Enum.any?(fn module -> module == ExUnit.Case end)
140+
end
141+
142+
defp calls_to(calls_list, {function, arity}) do
143+
for call_info <- calls_list,
144+
call_info.func == function and call_info.arity === arity do
145+
call_info.position
146+
end
147+
end
148+
149+
defp parse_source(text) do
150+
buffer_file_metadata =
151+
text
152+
|> Parser.parse_string(true, true, 1)
153+
154+
if buffer_file_metadata.error != nil do
155+
{:error, buffer_file_metadata}
156+
else
157+
{:ok, buffer_file_metadata}
158+
end
159+
end
160+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test.DescribeBlock do
2+
alias ElixirSense.Core.State.Env
3+
4+
@struct_keys [:line, :name, :body_scope_id]
5+
6+
@enforce_keys @struct_keys
7+
defstruct @struct_keys
8+
9+
def find_block_info(line, lines_to_env_list, lines_to_env_list_length, source_lines) do
10+
name = get_name(source_lines, line)
11+
12+
body_scope_id =
13+
get_body_scope_id(
14+
line,
15+
lines_to_env_list,
16+
lines_to_env_list_length
17+
)
18+
19+
%__MODULE__{line: line, body_scope_id: body_scope_id, name: name}
20+
end
21+
22+
defp get_name(source_lines, declaration_line) do
23+
%{"name" => name} =
24+
~r/^\s*describe "(?<name>.*)" do/
25+
|> Regex.named_captures(Enum.at(source_lines, declaration_line - 1))
26+
27+
name
28+
end
29+
30+
defp get_body_scope_id(
31+
declaration_line,
32+
lines_to_env_list,
33+
lines_to_env_list_length
34+
) do
35+
env_index =
36+
lines_to_env_list
37+
|> Enum.find_index(fn {line, _env} -> line == declaration_line end)
38+
39+
{_line, %{scope_id: declaration_scope_id}} =
40+
lines_to_env_list
41+
|> Enum.at(env_index)
42+
43+
with true <- env_index + 1 < lines_to_env_list_length,
44+
next_env = Enum.at(lines_to_env_list, env_index + 1),
45+
{_line, %Env{scope_id: body_scope_id}} <- next_env,
46+
true <- body_scope_id != declaration_scope_id do
47+
body_scope_id
48+
else
49+
_ -> nil
50+
end
51+
end
52+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
defmodule ElixirLS.LanguageServer.Providers.CodeLens.Test.TestBlock do
2+
@struct_keys [:name, :describe, :line]
3+
4+
@enforce_keys @struct_keys
5+
defstruct @struct_keys
6+
end

0 commit comments

Comments
 (0)