diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index 4629fcb03..cfcc89ae3 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -1,4 +1,7 @@ -impossible_to_format = ["test/fixtures/token_missing_error/lib/has_error.ex"] +impossible_to_format = [ + "test/fixtures/token_missing_error/lib/has_error.ex", + "test/fixtures/project_with_tests/test/error_test.exs" +] [ inputs: diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index 36dfd96c1..0e7dc7f18 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -10,7 +10,8 @@ defmodule ElixirLS.LanguageServer do {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, {ElixirLS.LanguageServer.JsonRpc, name: ElixirLS.LanguageServer.JsonRpc}, {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, - {ElixirLS.LanguageServer.Tracer, []} + {ElixirLS.LanguageServer.Tracer, []}, + {ElixirLS.LanguageServer.ExUnitTestTracer, []} ] opts = [strategy: :one_for_one, name: ElixirLS.LanguageServer.Supervisor, max_restarts: 0] diff --git a/apps/language_server/lib/language_server/ex_unit_test_tracer.ex b/apps/language_server/lib/language_server/ex_unit_test_tracer.ex new file mode 100644 index 000000000..9cfc75ec8 --- /dev/null +++ b/apps/language_server/lib/language_server/ex_unit_test_tracer.ex @@ -0,0 +1,115 @@ +defmodule ElixirLS.LanguageServer.ExUnitTestTracer do + use GenServer + + @tables ~w(tests)a + + for table <- @tables do + defp table_name(unquote(table)) do + :"#{__MODULE__}:#{unquote(table)}" + end + end + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + def get_tests(path) do + GenServer.call(__MODULE__, {:get_tests, path}, :infinity) + end + + @impl true + def init(_args) do + for table <- @tables do + table_name = table_name(table) + + :ets.new(table_name, [ + :named_table, + :public, + read_concurrency: true, + write_concurrency: true + ]) + end + + ExUnit.start(autorun: false) + + {:ok, %{}} + end + + @impl true + def handle_call({:get_tests, path}, _from, state) do + :ets.delete_all_objects(table_name(:tests)) + tracers = Code.compiler_options()[:tracers] + # TODO build lock? + Code.put_compiler_option(:tracers, [__MODULE__]) + + result = + try do + # TODO parallel compiler and diagnostics? + _ = Code.compile_file(path) + + result = + :ets.tab2list(table_name(:tests)) + |> Enum.map(fn {{_file, module, line}, describes} -> + %{ + module: inspect(module), + line: line, + describes: describes + } + end) + + {:ok, result} + rescue + e -> + {:error, e} + after + Code.put_compiler_option(:tracers, tracers) + end + + {:reply, result, state} + end + + def trace({:on_module, _, _}, %Macro.Env{} = env) do + test_info = Module.get_attribute(env.module, :ex_unit_tests) + + if test_info != nil do + describe_infos = + test_info + |> Enum.group_by(fn %ExUnit.Test{tags: tags} -> {tags.describe, tags.describe_line} end) + |> Enum.map(fn {{describe, describe_line}, tests} -> + tests = + tests + |> Enum.map(fn %ExUnit.Test{tags: tags} = test -> + # drop test prefix + "test " <> test_name = Atom.to_string(test.name) + + test_name = + if describe != nil do + test_name |> String.replace_prefix(describe <> " ", "") + else + test_name + end + + %{ + name: test_name, + type: tags.test_type, + line: tags.line - 1 + } + end) + + %{ + describe: describe, + line: if(describe_line, do: describe_line - 1), + tests: tests + } + end) + + :ets.insert(table_name(:tests), {{env.file, env.module, env.line - 1}, describe_infos}) + end + + :ok + end + + def trace(_, %Macro.Env{} = _env) do + :ok + end +end diff --git a/apps/language_server/lib/language_server/providers/execute_command.ex b/apps/language_server/lib/language_server/providers/execute_command.ex index 427521244..414759acc 100644 --- a/apps/language_server/lib/language_server/providers/execute_command.ex +++ b/apps/language_server/lib/language_server/providers/execute_command.ex @@ -10,7 +10,8 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand do "expandMacro" => ExecuteCommand.ExpandMacro, "manipulatePipes" => ExecuteCommand.ManipulatePipes, "restart" => ExecuteCommand.Restart, - "mixClean" => ExecuteCommand.MixClean + "mixClean" => ExecuteCommand.MixClean, + "getExUnitTestsInFile" => ExecuteCommand.GetExUnitTestsInFile } @callback execute([any], %ElixirLS.LanguageServer.Server{}) :: diff --git a/apps/language_server/lib/language_server/providers/execute_command/get_ex_unit_tests_in_file.ex b/apps/language_server/lib/language_server/providers/execute_command/get_ex_unit_tests_in_file.ex new file mode 100644 index 000000000..dfc167e7b --- /dev/null +++ b/apps/language_server/lib/language_server/providers/execute_command/get_ex_unit_tests_in_file.ex @@ -0,0 +1,18 @@ +defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.GetExUnitTestsInFile do + alias ElixirLS.LanguageServer.{SourceFile, ExUnitTestTracer} + @behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand + + @impl ElixirLS.LanguageServer.Providers.ExecuteCommand + def execute([uri], _state) do + if Version.match?(System.version(), ">= 1.13.0") do + path = SourceFile.Path.from_uri(uri) + + case ExUnitTestTracer.get_tests(path) do + {:ok, tests} -> {:ok, tests} + {:error, reason} -> {:error, :server_error, inspect(reason)} + end + else + {:error, :server_error, "This feature requires elixir >= 1.13"} + end + end +end diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index e79feab77..ce7cec7e5 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -240,10 +240,10 @@ defmodule ElixirLS.LanguageServer.SourceFile do try do true = Code.ensure_loaded?(Mix.Tasks.Format) - if function_exported?(Mix.Tasks.Format, :formatter_for_file, 1) do - {:ok, Mix.Tasks.Format.formatter_for_file(path)} + if Version.match?(System.version(), ">= 1.13.0") do + {:ok, apply(Mix.Tasks.Format, :formatter_for_file, [path])} else - {:ok, {nil, Mix.Tasks.Format.formatter_opts_for_file(path)}} + {:ok, {nil, apply(Mix.Tasks.Format, :formatter_opts_for_file, [path])}} end rescue e -> diff --git a/apps/language_server/lib/language_server/tracer.ex b/apps/language_server/lib/language_server/tracer.ex index 3b7d7023c..ebdb8c156 100644 --- a/apps/language_server/lib/language_server/tracer.ex +++ b/apps/language_server/lib/language_server/tracer.ex @@ -206,14 +206,22 @@ defmodule ElixirLS.LanguageServer.Tracer do defp build_module_info(module, file, line) do defs = - for {name, arity} <- Module.definitions_in(module) do - def_info = Module.get_definition(module, {name, arity}) - {{name, arity}, build_def_info(def_info)} + if Version.match?(System.version(), ">= 1.12.0") do + for {name, arity} <- Module.definitions_in(module) do + def_info = apply(Module, :get_definition, [module, {name, arity}]) + {{name, arity}, build_def_info(def_info)} + end + else + [] end attributes = - for name <- Module.attributes_in(module) do - {name, Module.get_attribute(module, name)} + if Version.match?(System.version(), ">= 1.13.0") do + for name <- apply(Module, :attributes_in, [module]) do + {name, Module.get_attribute(module, name)} + end + else + [] end %{ diff --git a/apps/language_server/test/providers/execute_command/get_ex_unit_tests_in_file_test.exs b/apps/language_server/test/providers/execute_command/get_ex_unit_tests_in_file_test.exs new file mode 100644 index 000000000..688cc9f6a --- /dev/null +++ b/apps/language_server/test/providers/execute_command/get_ex_unit_tests_in_file_test.exs @@ -0,0 +1,55 @@ +defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.GetExUnitTestsInFileTest do + alias ElixirLS.LanguageServer.{ExUnitTestTracer, SourceFile} + alias ElixirLS.LanguageServer.Providers.ExecuteCommand.GetExUnitTestsInFile + use ElixirLS.Utils.MixTest.Case, async: false + + setup do + {:ok, _} = start_supervised(ExUnitTestTracer) + + {:ok, %{}} + end + + if Version.match?(System.version(), ">= 1.13.0") do + @tag fixture: true + test "return tests" do + in_fixture(Path.join(__DIR__, "../../../test_fixtures"), "project_with_tests", fn -> + uri = SourceFile.Path.to_uri(Path.join(File.cwd!(), "test/fixture_test.exs")) + + assert {:ok, + [ + %{ + describes: [ + %{ + describe: nil, + line: nil, + tests: [ + %{line: 19, name: "this will be a test in future", type: :test}, + %{line: 6, name: "fixture test", type: :test} + ] + }, + %{ + describe: "describe with test", + line: 10, + tests: [ + %{line: 11, name: "fixture test", type: :test} + ] + } + ], + line: 0, + module: "FixtureTest" + } + ]} = GetExUnitTestsInFile.execute([uri], nil) + end) + end + + @tag fixture: true + test "return error when file fails to compile" do + in_fixture(Path.join(__DIR__, "../../../test_fixtures"), "project_with_tests", fn -> + uri = SourceFile.Path.to_uri(Path.join(File.cwd!(), "test/error_test.exs")) + + assert {:error, :server_error, "%TokenMissingError" <> _} = + GetExUnitTestsInFile.execute([uri], nil) + end) + end + end +end diff --git a/apps/language_server/test/server_test.exs b/apps/language_server/test/server_test.exs index bb49655f7..ca8b3f453 100644 --- a/apps/language_server/test/server_test.exs +++ b/apps/language_server/test/server_test.exs @@ -726,7 +726,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do Server.receive_packet(server, did_open(uri, "elixir", 1, code)) Server.receive_packet(server, completion_req(1, uri, 2, 25)) - resp = assert_receive(%{"id" => 1}, 1000) + resp = assert_receive(%{"id" => 1}, 5000) assert response(1, %{ "isIncomplete" => true, diff --git a/apps/language_server/test_fixtures/fixtures/project_with_tests/mix.exs b/apps/language_server/test_fixtures/fixtures/project_with_tests/mix.exs new file mode 100644 index 000000000..71a23daa8 --- /dev/null +++ b/apps/language_server/test_fixtures/fixtures/project_with_tests/mix.exs @@ -0,0 +1,9 @@ +defmodule ProjectWithTests.MixProject do + use Mix.Project + + def project do + [app: :project_with_tests, version: "0.1.0"] + end + + def application, do: [] +end diff --git a/apps/language_server/test_fixtures/fixtures/project_with_tests/test/error_test.exs b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/error_test.exs new file mode 100644 index 000000000..2ef75b8c6 --- /dev/null +++ b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/error_test.exs @@ -0,0 +1,21 @@ +defmodule ErrorTest do + use ExUnit.Case + + defmodule ModuleWithoutTests do + end + + test "fixture test" do + assert true + end + + describe "describe with test" do + test "fixture test" do + assert true + end + end + + describe "describe without test" do + end + + test "this will be a test in future" do +end diff --git a/apps/language_server/test_fixtures/fixtures/project_with_tests/test/fixture_test.exs b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/fixture_test.exs new file mode 100644 index 000000000..dd215cb29 --- /dev/null +++ b/apps/language_server/test_fixtures/fixtures/project_with_tests/test/fixture_test.exs @@ -0,0 +1,21 @@ +defmodule FixtureTest do + use ExUnit.Case + + defmodule ModuleWithoutTests do + end + + test "fixture test" do + assert true + end + + describe "describe with test" do + test "fixture test" do + assert true + end + end + + describe "describe without test" do + end + + test "this will be a test in future" +end