diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index f913823ef..36dfd96c1 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -9,7 +9,8 @@ defmodule ElixirLS.LanguageServer do children = [ {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, {ElixirLS.LanguageServer.JsonRpc, name: ElixirLS.LanguageServer.JsonRpc}, - {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []} + {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, + {ElixirLS.LanguageServer.Tracer, []} ] opts = [strategy: :one_for_one, name: ElixirLS.LanguageServer.Supervisor, max_restarts: 0] diff --git a/apps/language_server/lib/language_server/build.ex b/apps/language_server/lib/language_server/build.ex index b400019f2..622910dde 100644 --- a/apps/language_server/lib/language_server/build.ex +++ b/apps/language_server/lib/language_server/build.ex @@ -1,5 +1,5 @@ defmodule ElixirLS.LanguageServer.Build do - alias ElixirLS.LanguageServer.{Server, JsonRpc, Diagnostics} + alias ElixirLS.LanguageServer.{Server, JsonRpc, Diagnostics, Tracer} alias ElixirLS.Utils.MixfileHelpers def build(parent, root_path, opts) when is_binary(root_path) do @@ -40,6 +40,7 @@ defmodule ElixirLS.LanguageServer.Build do end end) + Tracer.save() JsonRpc.log_message(:info, "Compile took #{div(us, 1000)} milliseconds") end) end) @@ -213,4 +214,22 @@ defmodule ElixirLS.LanguageServer.Build do :ok end + + def set_compiler_options(options \\ [], parser_options \\ []) do + parser_options = + parser_options + |> Keyword.merge( + columns: true, + token_metadata: true + ) + + options = + options + |> Keyword.merge( + tracers: [Tracer], + parser_options: parser_options + ) + + Code.compiler_options(options) + end end diff --git a/apps/language_server/lib/language_server/cli.ex b/apps/language_server/lib/language_server/cli.ex index d66815a33..f747e5841 100644 --- a/apps/language_server/lib/language_server/cli.ex +++ b/apps/language_server/lib/language_server/cli.ex @@ -1,11 +1,14 @@ defmodule ElixirLS.LanguageServer.CLI do alias ElixirLS.Utils.{WireProtocol, Launch} alias ElixirLS.LanguageServer.JsonRpc + alias ElixirLS.LanguageServer.Build def main do WireProtocol.intercept_output(&JsonRpc.print/1, &JsonRpc.print_err/1) Launch.start_mix() + Build.set_compiler_options() + start_language_server() IO.puts("Started ElixirLS v#{Launch.language_server_version()}") diff --git a/apps/language_server/lib/language_server/providers/references.ex b/apps/language_server/lib/language_server/providers/references.ex index 7188ac608..2ca5ae513 100644 --- a/apps/language_server/lib/language_server/providers/references.ex +++ b/apps/language_server/lib/language_server/providers/references.ex @@ -17,7 +17,9 @@ defmodule ElixirLS.LanguageServer.Providers.References do {line, character} = SourceFile.lsp_position_to_elixir(text, {line, character}) Build.with_build_lock(fn -> - ElixirSense.references(text, line, character) + trace = ElixirLS.LanguageServer.Tracer.get_trace() + + ElixirSense.references(text, line, character, trace) |> Enum.map(fn elixir_sense_reference -> elixir_sense_reference |> build_reference(uri, text) diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 37abaddac..d5fef5c7d 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -35,6 +35,7 @@ defmodule ElixirLS.LanguageServer.Server do } alias ElixirLS.Utils.Launch + alias ElixirLS.LanguageServer.Tracer use Protocol @@ -57,6 +58,7 @@ defmodule ElixirLS.LanguageServer.Server do source_files: %{}, awaiting_contracts: [], supports_dynamic: false, + mix_project?: false, no_mixfile_warned?: false ] @@ -415,6 +417,17 @@ defmodule ElixirLS.LanguageServer.Server do state.source_files[uri].dirty?) end) + # TODO remove uniq when duplicated subscriptions from vscode plugin are fixed + deleted_paths = + for change <- changes, + change["type"] == 3, + uniq: true, + do: SourceFile.path_from_uri(change["uri"]) + + for path <- deleted_paths do + Tracer.notify_file_deleted(path) + end + source_files = changes |> Enum.reduce(state.source_files, fn @@ -447,6 +460,7 @@ defmodule ElixirLS.LanguageServer.Server do state = %{state | source_files: source_files} + # TODO remove uniq when duplicated subscriptions from vscode plugin are fixed changes |> Enum.map(& &1["uri"]) |> Enum.uniq() @@ -1043,7 +1057,10 @@ defmodule ElixirLS.LanguageServer.Server do text {:error, reason} -> - IO.warn("Couldn't read file #{file}: #{inspect(reason)}") + if reason != :enoent do + IO.warn("Couldn't read file #{file}: #{inspect(reason)}") + end + nil end end @@ -1099,6 +1116,7 @@ defmodule ElixirLS.LanguageServer.Server do |> add_watched_extensions(additional_watched_extensions) state = create_gitignore(state) + Tracer.set_project_dir(state.project_dir) trigger_build(%{state | settings: settings}) end @@ -1211,7 +1229,7 @@ defmodule ElixirLS.LanguageServer.Server do is_nil(prev_project_dir) -> File.cd!(project_dir) - %{state | project_dir: File.cwd!()} + %{state | project_dir: File.cwd!(), mix_project?: File.exists?("mix.exs")} prev_project_dir != project_dir -> JsonRpc.show_message( diff --git a/apps/language_server/lib/language_server/tracer.ex b/apps/language_server/lib/language_server/tracer.ex new file mode 100644 index 000000000..dc2d1d618 --- /dev/null +++ b/apps/language_server/lib/language_server/tracer.ex @@ -0,0 +1,335 @@ +defmodule ElixirLS.LanguageServer.Tracer do + @moduledoc """ + """ + use GenServer + require Logger + + @tables ~w(modules calls)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 set_project_dir(project_dir) do + GenServer.call(__MODULE__, {:set_project_dir, project_dir}) + end + + defp get_project_dir() do + case Process.get(:elixir_ls_project_dir) do + nil -> + project_dir = GenServer.call(__MODULE__, :get_project_dir) + Process.put(:elixir_ls_project_dir, project_dir) + project_dir + + project_dir -> + project_dir + end + end + + def notify_file_deleted(file) do + delete_modules_by_file(file) + delete_calls_by_file(file) + 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 + + {:ok, %{project_dir: nil}} + end + + @impl true + def handle_call({:set_project_dir, project_dir}, _from, state) do + maybe_close_tables(state) + + for table <- @tables do + table_name = table_name(table) + :ets.delete_all_objects(table_name) + end + + if project_dir != nil do + {us, _} = + :timer.tc(fn -> + for table <- @tables do + init_table(table, project_dir) + end + end) + + Logger.info("Loaded DETS databases in #{div(us, 1000)}ms") + end + + {:reply, :ok, %{state | project_dir: project_dir}} + end + + def handle_call(:get_project_dir, _from, %{project_dir: project_dir} = state) do + {:reply, project_dir, state} + end + + @impl true + def terminate(_reason, state) do + maybe_close_tables(state) + end + + defp maybe_close_tables(%{project_dir: nil}), do: :ok + + defp maybe_close_tables(%{project_dir: project_dir}) do + for table <- @tables do + close_table(table, project_dir) + end + + :ok + end + + defp dets_path(project_dir, table) do + Path.join([project_dir, ".elixir_ls", "#{table}.dets"]) + end + + def init_table(table, project_dir) do + table_name = table_name(table) + path = dets_path(project_dir, table) + + {:ok, _} = + :dets.open_file(table_name, + file: path |> String.to_charlist(), + auto_save: 60_000 + ) + + case :dets.to_ets(table_name, table_name) do + ^table_name -> + :ok + + {:error, reason} -> + Logger.error("Unable to load DETS #{path}, #{inspect(reason)}") + end + end + + def close_table(table, project_dir) do + path = dets_path(project_dir, table) + table_name = table_name(table) + sync(table_name) + + case :dets.close(table_name) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Unable to close DETS #{path}, #{inspect(reason)}") + end + end + + defp modules_by_file_matchspec(file, return) do + [ + {{:"$1", :"$2"}, + [ + { + :andalso, + {:andalso, {:==, {:map_get, :file, :"$2"}, file}} + } + ], [return]} + ] + end + + def get_modules_by_file(file) do + ms = modules_by_file_matchspec(file, :"$_") + # ms = :ets.fun2ms(fn {_, map} when :erlang.map_get(:file, map) == file -> map end) + + :ets.select(table_name(:modules), ms) + end + + def delete_modules_by_file(file) do + ms = modules_by_file_matchspec(file, true) + # ms = :ets.fun2ms(fn {_, map} when :erlang.map_get(:file, map) == file -> true end) + + :ets.select_delete(table_name(:modules), ms) + end + + def trace(:start, %Macro.Env{} = env) do + delete_modules_by_file(env.file) + delete_calls_by_file(env.file) + :ok + end + + def trace({:on_module, _, _}, %Macro.Env{} = env) do + info = build_module_info(env.module, env.file, env.line) + :ets.insert(table_name(:modules), {env.module, info}) + :ok + end + + def trace({kind, meta, module, name, arity}, %Macro.Env{} = env) + when kind in [:imported_function, :imported_macro, :remote_function, :remote_macro] do + register_call(meta, module, name, arity, env) + end + + def trace({kind, meta, name, arity}, %Macro.Env{} = env) + when kind in [:local_function, :local_macro] do + register_call(meta, env.module, name, arity, env) + end + + def trace(_trace, _env) do + # IO.inspect(trace, label: "skipped") + :ok + end + + 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)} + end + + attributes = + for name <- Module.attributes_in(module) do + {name, Module.get_attribute(module, name)} + end + + %{ + defs: defs, + attributes: attributes, + file: file, + line: line + } + end + + defp build_def_info({:v1, def_kind, meta_1, clauses}) do + clauses = + for {meta_2, arguments, guards, _body} <- clauses do + %{ + arguments: arguments, + guards: guards, + meta: meta_2 + } + end + + %{ + kind: def_kind, + clauses: clauses, + meta: meta_1 + } + end + + defp register_call(meta, module, name, arity, env) do + if in_project_sources?(env.file) do + do_register_call(meta, module, name, arity, env) + end + + :ok + end + + defp do_register_call(meta, module, name, arity, env) do + callee = {module, name, arity} + + call = %{ + callee: callee, + file: env.file, + line: meta[:line], + column: meta[:column] + } + + updated_calls = + case :ets.lookup(table_name(:calls), callee) do + [{_callee, callee_calls}] when is_map(callee_calls) -> + file_calle_calls = + case callee_calls[env.file] do + nil -> [call] + file_calle_calls -> [call | file_calle_calls] + end + + Map.put(callee_calls, env.file, file_calle_calls) + + [] -> + %{env.file => [call]} + end + + :ets.insert(table_name(:calls), {callee, updated_calls}) + end + + def get_trace do + :ets.tab2list(table_name(:calls)) + |> Map.new(fn {callee, calls_by_file} -> + calls = calls_by_file |> Map.values() |> List.flatten() + {callee, calls} + end) + end + + def save do + for table <- @tables do + table_name = table_name(table) + + sync(table_name) + end + end + + defp sync(table_name) do + with :ok <- :dets.from_ets(table_name, table_name), + :ok <- :dets.sync(table_name) do + :ok + else + {:error, reason} -> + Logger.error("Unable to sync DETS #{table_name}, #{inspect(reason)}") + end + end + + defp in_project_sources?(path) do + project_dir = get_project_dir() + + if project_dir != nil do + topmost_path_segment = + path + |> Path.relative_to(project_dir) + |> Path.split() + |> hd + + topmost_path_segment != "deps" + else + false + end + end + + defp calls_by_file_matchspec(file, return) do + [ + {{:"$1", :"$2"}, + [ + { + :andalso, + {:andalso, {:is_list, {:map_get, file, :"$2"}}} + } + ], [return]} + ] + end + + def get_calls_by_file(file) do + ms = calls_by_file_matchspec(file, :"$_") + + :ets.select(table_name(:calls), ms) + end + + def delete_calls_by_file(file) do + ms = calls_by_file_matchspec(file, :"$_") + table_name = table_name(:calls) + + for {callee, calls_by_file} <- :ets.select(table_name(:calls), ms) do + calls_by_file = calls_by_file |> Map.delete(file) + + if calls_by_file == %{} do + :ets.delete(table_name, callee) + else + :ets.insert(table_name, {callee, calls_by_file}) + end + end + end +end diff --git a/apps/language_server/test/dialyzer_test.exs b/apps/language_server/test/dialyzer_test.exs index fa4d516af..2dfc944dd 100644 --- a/apps/language_server/test/dialyzer_test.exs +++ b/apps/language_server/test/dialyzer_test.exs @@ -1,7 +1,7 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do # TODO: Test loading and saving manifest - alias ElixirLS.LanguageServer.{Dialyzer, Server, Protocol, SourceFile, JsonRpc} + alias ElixirLS.LanguageServer.{Dialyzer, Server, Protocol, SourceFile, JsonRpc, Tracer, Build} import ExUnit.CaptureLog use ElixirLS.Utils.MixTest.Case, async: false use Protocol @@ -10,10 +10,18 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do # This will generate a large PLT file and will take a long time, so we need to make sure that # Mix.Utils.home() is in the saved build artifacts for any automated testing Dialyzer.Manifest.load_elixir_plt() + compiler_options = Code.compiler_options() + Build.set_compiler_options() + + on_exit(fn -> + Code.compiler_options(compiler_options) + end) + {:ok, %{}} end setup do + {:ok, _} = Tracer.start_link([]) server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() {:ok, %{server: server}} diff --git a/apps/language_server/test/providers/definition_test.exs b/apps/language_server/test/providers/definition_test.exs index f5933fe26..16ef598f8 100644 --- a/apps/language_server/test/providers/definition_test.exs +++ b/apps/language_server/test/providers/definition_test.exs @@ -7,19 +7,19 @@ defmodule ElixirLS.LanguageServer.Providers.DefinitionTest do alias ElixirLS.LanguageServer.Test.FixtureHelpers require ElixirLS.Test.TextLoc - test "find definition" do - file_path = FixtureHelpers.get_path("references_a.ex") + test "find definition remote function call" do + file_path = FixtureHelpers.get_path("references_remote.ex") text = File.read!(file_path) uri = SourceFile.path_to_uri(file_path) - b_file_path = FixtureHelpers.get_path("references_b.ex") + b_file_path = FixtureHelpers.get_path("references_referenced.ex") b_uri = SourceFile.path_to_uri(b_file_path) - {line, char} = {2, 30} + {line, char} = {4, 28} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ - ElixirLS.Test.ReferencesB.b_fun() - ^ + ReferencesReferenced.referenced_fun() + ^ """) assert {:ok, %Location{uri: ^b_uri, range: range}} = @@ -30,4 +30,172 @@ defmodule ElixirLS.LanguageServer.Providers.DefinitionTest do "end" => %{"line" => 1, "character" => 6} } end + + test "find definition remote macro call" do + file_path = FixtureHelpers.get_path("references_remote.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {8, 28} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + ReferencesReferenced.referenced_macro a do + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 8, "character" => 11}, + "end" => %{"line" => 8, "character" => 11} + } + end + + test "find definition imported function call" do + file_path = FixtureHelpers.get_path("references_imported.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {4, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_fun() + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 1, "character" => 6}, + "end" => %{"line" => 1, "character" => 6} + } + end + + test "find definition imported macro call" do + file_path = FixtureHelpers.get_path("references_imported.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {8, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_macro a do + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 8, "character" => 11}, + "end" => %{"line" => 8, "character" => 11} + } + end + + test "find definition local function call" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {15, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_fun() + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 1, "character" => 6}, + "end" => %{"line" => 1, "character" => 6} + } + end + + test "find definition local macro call" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {19, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + referenced_macro a do + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 8, "character" => 11}, + "end" => %{"line" => 8, "character" => 11} + } + end + + test "find definition variable" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {4, 13} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + IO.puts(referenced_variable + 1) + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 2, "character" => 4}, + "end" => %{"line" => 2, "character" => 4} + } + end + + test "find definition attribute" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + b_file_path = FixtureHelpers.get_path("references_referenced.ex") + b_uri = SourceFile.path_to_uri(b_file_path) + + {line, char} = {27, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + @referenced_attribute + ^ + """) + + assert {:ok, %Location{uri: ^b_uri, range: range}} = + Definition.definition(uri, text, line, char) + + assert range == %{ + "start" => %{"line" => 24, "character" => 2}, + "end" => %{"line" => 24, "character" => 2} + } + end end diff --git a/apps/language_server/test/providers/execute_command/mix_clean_test.exs b/apps/language_server/test/providers/execute_command/mix_clean_test.exs index f9e55464d..a82cfe529 100644 --- a/apps/language_server/test/providers/execute_command/mix_clean_test.exs +++ b/apps/language_server/test/providers/execute_command/mix_clean_test.exs @@ -1,9 +1,10 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.MixCleanTest do - alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile} + alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile, Tracer} use ElixirLS.Utils.MixTest.Case, async: false use Protocol setup do + {:ok, _} = Tracer.start_link([]) server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() {:ok, %{server: server}} diff --git a/apps/language_server/test/providers/references_test.exs b/apps/language_server/test/providers/references_test.exs index 334a9e55b..3882f4bc9 100644 --- a/apps/language_server/test/providers/references_test.exs +++ b/apps/language_server/test/providers/references_test.exs @@ -1,45 +1,81 @@ defmodule ElixirLS.LanguageServer.Providers.ReferencesTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias ElixirLS.LanguageServer.Providers.References alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Test.FixtureHelpers + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.LanguageServer.Build require ElixirLS.Test.TextLoc - test "finds references to a function" do - file_path = FixtureHelpers.get_path("references_b.ex") + setup_all context do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + {:ok, pid} = Tracer.start_link([]) + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + compiler_options = Code.compiler_options() + Build.set_compiler_options(ignore_module_conflict: true) + + on_exit(fn -> + Code.compiler_options(compiler_options) + Process.monitor(pid) + Process.unlink(pid) + GenServer.stop(pid) + + receive do + {:DOWN, _, _, _, _} -> :ok + end + end) + + Code.compile_file(FixtureHelpers.get_path("references_referenced.ex")) + Code.compile_file(FixtureHelpers.get_path("references_imported.ex")) + Code.compile_file(FixtureHelpers.get_path("references_remote.ex")) + Code.compile_file(FixtureHelpers.get_path("uses_macro_a.ex")) + Code.compile_file(FixtureHelpers.get_path("macro_a.ex")) + {:ok, context} + end + + test "finds local, remote and imported references to a function" do + file_path = FixtureHelpers.get_path("references_referenced.ex") text = File.read!(file_path) uri = SourceFile.path_to_uri(file_path) - {line, char} = {2, 8} + {line, char} = {1, 8} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ - some_var = 42 + def referenced_fun do ^ """) - ElixirLS.Utils.TestUtils.assert_match_list( - References.references(text, uri, line, char, true), - [ - %{ - "range" => %{ - "start" => %{"line" => 2, "character" => 4}, - "end" => %{"line" => 2, "character" => 12} - }, - "uri" => uri - }, - %{ - "range" => %{ - "start" => %{"line" => 4, "character" => 12}, - "end" => %{"line" => 4, "character" => 20} - }, - "uri" => uri - } - ] - ) + list = References.references(text, uri, line, char, true) + + assert length(list) == 3 + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_remote.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_imported.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_referenced.ex"))) + end + + test "finds local, remote and imported references to a macro" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + {line, char} = {8, 12} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + defmacro referenced_macro(clause, do: expression) do + ^ + """) + + list = References.references(text, uri, line, char, true) + + assert length(list) == 3 + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_remote.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_imported.ex"))) + assert Enum.any?(list, &(&1["uri"] |> String.ends_with?("references_referenced.ex"))) end - test "cannot find a references to a macro generated function call" do + test "find a references to a macro generated function call" do file_path = FixtureHelpers.get_path("uses_macro_a.ex") text = File.read!(file_path) uri = SourceFile.path_to_uri(file_path) @@ -50,7 +86,15 @@ defmodule ElixirLS.LanguageServer.Providers.ReferencesTest do ^ """) - assert References.references(text, uri, line, char, true) == [] + assert References.references(text, uri, line, char, true) == [ + %{ + "range" => %{ + "end" => %{"character" => 16, "line" => 6}, + "start" => %{"character" => 4, "line" => 6} + }, + "uri" => uri + } + ] end test "finds a references to a macro imported function call" do @@ -76,31 +120,60 @@ defmodule ElixirLS.LanguageServer.Providers.ReferencesTest do end test "finds references to a variable" do - file_path = FixtureHelpers.get_path("references_b.ex") + file_path = FixtureHelpers.get_path("references_referenced.ex") text = File.read!(file_path) uri = SourceFile.path_to_uri(file_path) {line, char} = {4, 14} ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ - IO.puts(some_var + 1) + IO.puts(referenced_variable + 1) ^ """) assert References.references(text, uri, line, char, true) == [ %{ "range" => %{ - "end" => %{"character" => 12, "line" => 2}, + "end" => %{"character" => 23, "line" => 2}, "start" => %{"character" => 4, "line" => 2} }, "uri" => uri }, %{ "range" => %{ - "end" => %{"character" => 20, "line" => 4}, + "end" => %{"character" => 31, "line" => 4}, "start" => %{"character" => 12, "line" => 4} }, "uri" => uri } ] end + + test "finds references to an attribute" do + file_path = FixtureHelpers.get_path("references_referenced.ex") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + {line, char} = {24, 5} + + ElixirLS.Test.TextLoc.annotate_assert(file_path, line, char, """ + @referenced_attribute \"123\" + ^ + """) + + assert References.references(text, uri, line, char, true) == [ + %{ + "range" => %{ + "end" => %{"character" => 23, "line" => 24}, + "start" => %{"character" => 2, "line" => 24} + }, + "uri" => uri + }, + %{ + "range" => %{ + "end" => %{"character" => 25, "line" => 27}, + "start" => %{"character" => 4, "line" => 27} + }, + "uri" => uri + } + ] + end end diff --git a/apps/language_server/test/server_test.exs b/apps/language_server/test/server_test.exs index c9faf3b0e..e3d5284c4 100644 --- a/apps/language_server/test/server_test.exs +++ b/apps/language_server/test/server_test.exs @@ -1,5 +1,5 @@ defmodule ElixirLS.LanguageServer.ServerTest do - alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile} + alias ElixirLS.LanguageServer.{Server, Protocol, SourceFile, Tracer, Build} alias ElixirLS.Utils.PacketCapture alias ElixirLS.LanguageServer.Test.FixtureHelpers use ElixirLS.Utils.MixTest.Case, async: false @@ -231,8 +231,9 @@ defmodule ElixirLS.LanguageServer.ServerTest do setup context do unless context[:skip_server] do server = ElixirLS.LanguageServer.Test.ServerTestHelpers.start_server() + {:ok, tracer} = Tracer.start_link([]) - {:ok, %{server: server}} + {:ok, %{server: server, tracer: tracer}} else :ok end @@ -1100,7 +1101,10 @@ defmodule ElixirLS.LanguageServer.ServerTest do text = File.read!(file_path) reference_uri = SourceFile.path_to_uri("lib/a.ex") + Build.set_compiler_options() + initialize(server) + wait_until_compiled(server) Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) Server.receive_packet( @@ -1119,6 +1123,8 @@ defmodule ElixirLS.LanguageServer.ServerTest do wait_until_compiled(server) end) + after + Code.put_compiler_option(:tracers, []) end @tag :fixture @@ -1129,7 +1135,10 @@ defmodule ElixirLS.LanguageServer.ServerTest do text = File.read!(file_path) reference_uri = SourceFile.path_to_uri("apps/app1/lib/app1.ex") + Build.set_compiler_options() + initialize(server) + wait_until_compiled(server) Server.receive_packet(server, did_open(file_uri, "elixir", 1, text)) Server.receive_packet( @@ -1148,6 +1157,8 @@ defmodule ElixirLS.LanguageServer.ServerTest do wait_until_compiled(server) end) + after + Code.put_compiler_option(:tracers, []) end @tag fixture: true, skip_server: true @@ -1157,6 +1168,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do # First to compile the applications and build the cache. # Second time to see if loads modules with_new_server(fn server -> + {:ok, _pid} = Tracer.start_link([]) initialize(server) wait_until_compiled(server) end) diff --git a/apps/language_server/test/support/fixtures/.elixir_ls/.gitkeep b/apps/language_server/test/support/fixtures/.elixir_ls/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/language_server/test/support/fixtures/references_a.ex b/apps/language_server/test/support/fixtures/references_a.ex deleted file mode 100644 index c9087d4d6..000000000 --- a/apps/language_server/test/support/fixtures/references_a.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule ElixirLS.Test.ReferencesA do - def a_fun do - ElixirLS.Test.ReferencesB.b_fun() - end -end diff --git a/apps/language_server/test/support/fixtures/references_b.ex b/apps/language_server/test/support/fixtures/references_b.ex deleted file mode 100644 index 0bbb1f3c6..000000000 --- a/apps/language_server/test/support/fixtures/references_b.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule ElixirLS.Test.ReferencesB do - def b_fun do - some_var = 42 - - IO.puts(some_var + 1) - :ok - end -end diff --git a/apps/language_server/test/support/fixtures/references_imported.ex b/apps/language_server/test/support/fixtures/references_imported.ex new file mode 100644 index 000000000..ae20fd4c8 --- /dev/null +++ b/apps/language_server/test/support/fixtures/references_imported.ex @@ -0,0 +1,13 @@ +defmodule ElixirLS.Test.ReferencesImported do + import ElixirLS.Test.ReferencesReferenced + + def uses_fun do + referenced_fun() + end + + def uses_macro(a) do + referenced_macro a do + :ok + end + end +end diff --git a/apps/language_server/test/support/fixtures/references_referenced.ex b/apps/language_server/test/support/fixtures/references_referenced.ex new file mode 100644 index 000000000..7f422cdc0 --- /dev/null +++ b/apps/language_server/test/support/fixtures/references_referenced.ex @@ -0,0 +1,30 @@ +defmodule ElixirLS.Test.ReferencesReferenced do + def referenced_fun do + referenced_variable = 42 + + IO.puts(referenced_variable + 1) + :ok + end + + defmacro referenced_macro(clause, do: expression) do + quote do + if(!unquote(clause), do: unquote(expression)) + end + end + + def uses_fun(_a) do + referenced_fun() + end + + def uses_macro(a) do + referenced_macro a do + :ok + end + end + + @referenced_attribute "123" + + def uses_attribute do + @referenced_attribute + end +end diff --git a/apps/language_server/test/support/fixtures/references_remote.ex b/apps/language_server/test/support/fixtures/references_remote.ex new file mode 100644 index 000000000..409d21201 --- /dev/null +++ b/apps/language_server/test/support/fixtures/references_remote.ex @@ -0,0 +1,13 @@ +defmodule ElixirLS.Test.ReferencesRemote do + require ElixirLS.Test.ReferencesReferenced, as: ReferencesReferenced + + def uses_fun do + ReferencesReferenced.referenced_fun() + end + + def uses_macro(a) do + ReferencesReferenced.referenced_macro a do + :ok + end + end +end diff --git a/apps/language_server/test/tracer_test.exs b/apps/language_server/test/tracer_test.exs new file mode 100644 index 000000000..4840cfee9 --- /dev/null +++ b/apps/language_server/test/tracer_test.exs @@ -0,0 +1,209 @@ +defmodule ElixirLS.LanguageServer.TracerTest do + use ExUnit.Case, async: false + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.LanguageServer.Test.FixtureHelpers + + setup context do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/modules.dets")) + {:ok, _pid} = Tracer.start_link([]) + + {:ok, context} + end + + test "project dir is nil" do + assert GenServer.call(Tracer, :get_project_dir) == nil + end + + test "set project dir" do + project_path = FixtureHelpers.get_path("") + + Tracer.set_project_dir(project_path) + + assert GenServer.call(Tracer, :get_project_dir) == project_path + end + + test "saves DETS" do + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + Tracer.save() + + assert File.exists?(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + assert File.exists?(FixtureHelpers.get_path(".elixir_ls/modules.dets")) + end + + describe "call trace" do + setup context do + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + {:ok, context} + end + + defp sorted_calls() do + :ets.tab2list(:"#{Tracer}:calls") |> Enum.sort() + end + + test "trace is empty" do + assert sorted_calls() == [] + end + + test "registers calls same function different files" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :called, 1}, + %Macro.Env{ + module: OtherCallingModule, + file: "other_calling_module.ex" + } + ) + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ], + "other_calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 3, + file: "other_calling_module.ex", + line: 13 + } + ] + }} + ] == sorted_calls() + end + + test "registers calls same function in one file" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 3, + file: "calling_module.ex", + line: 13 + }, + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ] + }} + ] == sorted_calls() + end + + test "registers calls different functions" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :other_called, 1}, + %Macro.Env{ + module: OtherCallingModule, + file: "other_calling_module.ex" + } + ) + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ] + }}, + {{CalledModule, :other_called, 1}, + %{ + "other_calling_module.ex" => [ + %{ + callee: {CalledModule, :other_called, 1}, + column: 3, + file: "other_calling_module.ex", + line: 13 + } + ] + }} + ] == sorted_calls() + end + + test "deletes calls by file" do + Tracer.trace( + {:remote_function, [line: 12, column: 2], CalledModule, :called, 1}, + %Macro.Env{ + module: CallingModule, + file: "calling_module.ex" + } + ) + + Tracer.trace( + {:remote_function, [line: 13, column: 3], CalledModule, :called, 1}, + %Macro.Env{ + module: OtherCallingModule, + file: "other_calling_module.ex" + } + ) + + Tracer.delete_calls_by_file("other_calling_module.ex") + + assert [ + {{CalledModule, :called, 1}, + %{ + "calling_module.ex" => [ + %{ + callee: {CalledModule, :called, 1}, + column: 2, + file: "calling_module.ex", + line: 12 + } + ] + }} + ] == sorted_calls() + + Tracer.delete_calls_by_file("calling_module.ex") + + assert [] == sorted_calls() + end + end +end diff --git a/mix.lock b/mix.lock index 30f8eac22..869f92dd8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, "docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"}, - "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "08bac2a5a0ad867908d77a7087eac756bf7cce43", []}, + "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "12bdf7ac9971a9f1cb278b66364f912d63af4c0f", []}, "erl2ex": {:git, "https://github.com/dazuma/erl2ex.git", "244c2d9ed5805ef4855a491d8616b8842fef7ca4", []}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"},