diff --git a/apps/language_server/lib/language_server/experimental/protocol/requests.ex b/apps/language_server/lib/language_server/experimental/protocol/requests.ex index 7398b7b14..fdda1de74 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/requests.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/requests.ex @@ -27,6 +27,14 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Requests do position: Types.Position end + defmodule GotoDefinition do + use Proto + + defrequest "textDocument/definition", :exclusive, + text_document: Types.TextDocument.Identifier, + position: Types.Position + end + defmodule Formatting do use Proto diff --git a/apps/language_server/lib/language_server/experimental/protocol/responses.ex b/apps/language_server/lib/language_server/experimental/protocol/responses.ex index beebeac88..0d18db2ee 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/responses.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/responses.ex @@ -8,6 +8,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Responses do defresponse optional(list_of(Types.Location)) end + defmodule GotoDefinition do + use Proto + + defresponse optional(Types.Location) + end + defmodule Formatting do use Proto diff --git a/apps/language_server/lib/language_server/experimental/provider/handlers/goto_definition.ex b/apps/language_server/lib/language_server/experimental/provider/handlers/goto_definition.ex new file mode 100644 index 000000000..23904cc94 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/goto_definition.ex @@ -0,0 +1,34 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.GotoDefinition do + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.GotoDefinition + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + require Logger + + def handle(%GotoDefinition{} = request, _) do + source_file = request.source_file + pos = request.position + + maybe_location = + source_file |> SourceFile.to_string() |> ElixirSense.definition(pos.line, pos.character + 1) + + case to_response(request.id, maybe_location, source_file) do + {:ok, response} -> + {:reply, response} + + {:error, reason} -> + Logger.error("GotoDefinition conversion failed: #{inspect(reason)}") + {:error, Responses.GotoDefinition.error(request.id, :request_failed, inspect(reason))} + end + end + + defp to_response(request_id, %ElixirSense.Location{} = location, %SourceFile{} = source_file) do + with {:ok, lsp_location} <- Conversions.to_lsp(location, source_file) do + {:ok, Responses.GotoDefinition.new(request_id, lsp_location)} + end + end + + defp to_response(request_id, nil, _source_file) do + {:ok, Responses.GotoDefinition.new(request_id, nil)} + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/queue.ex b/apps/language_server/lib/language_server/experimental/provider/queue.ex index 834594b93..0284bbb8e 100644 --- a/apps/language_server/lib/language_server/experimental/provider/queue.ex +++ b/apps/language_server/lib/language_server/experimental/provider/queue.ex @@ -15,7 +15,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Queue do @requests_to_handler %{ Requests.FindReferences => Handlers.FindReferences, Requests.Formatting => Handlers.Formatting, - Requests.CodeAction => Handlers.CodeAction + Requests.CodeAction => Handlers.CodeAction, + Requests.GotoDefinition => Handlers.GotoDefinition } def new do diff --git a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex index c019b74fb..514f41086 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex @@ -15,6 +15,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do alias ElixirLS.LanguageServer.Experimental.SourceFile.Position, as: ElixirPosition alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position, as: LSPosition alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range, as: LSRange + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Location, as: LSLocation alias ElixirLS.LanguageServer.Protocol import Line @@ -102,6 +103,16 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do {:ok, range} end + def to_lsp(%ElixirSense.Location{} = location, %SourceFile{} = source_file) do + position = SourceFile.Position.new(location.line, location.column - 1) + + with {:ok, source_file} <- fetch_source_file(location, source_file), + {:ok, ls_position} <- to_lsp(position, source_file) do + ls_range = %LSRange{start: ls_position, end: ls_position} + {:ok, LSLocation.new(uri: source_file.uri, range: ls_range)} + end + end + def to_lsp(%ElixirRange{} = ex_range, %SourceFile{} = source) do with {:ok, start_pos} <- to_lsp(ex_range.start, source.document), {:ok, end_pos} <- to_lsp(ex_range.end, source.document) do @@ -128,6 +139,13 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do end # Private + defp fetch_source_file(%{file: nil}, source_file) do + {:ok, source_file} + end + + defp fetch_source_file(%{file: path}, _) do + SourceFile.Store.open_temporary(path) + end defp extract_lsp_character(%ElixirPosition{} = position, line(ascii?: true, text: text)) do character = min(position.character, byte_size(text)) diff --git a/apps/language_server/test/experimental/provider/handlers/goto_definition_test.exs b/apps/language_server/test/experimental/provider/handlers/goto_definition_test.exs new file mode 100644 index 000000000..387f293b4 --- /dev/null +++ b/apps/language_server/test/experimental/provider/handlers/goto_definition_test.exs @@ -0,0 +1,210 @@ +defmodule ElixirLS.Experimental.Provider.Handlers.GotoDefinitionTest do + use ExUnit.Case, async: true + + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.GotoDefinition + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.Provider.Handlers + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.Test.FixtureHelpers + + import LspProtocol + import ElixirLS.Test.TextLoc, only: [annotate_assert: 4] + + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + + def request(file_path, line, char) do + uri = Conversions.ensure_uri(file_path) + + params = [ + text_document: [uri: uri], + position: [line: line, character: char] + ] + + with {:ok, contents} <- File.read(file_path), + :ok <- SourceFile.Store.open(uri, contents, 1), + {:ok, req} <- build(GotoDefinition, params) do + GotoDefinition.to_elixir(req) + end + end + + def handle(request) do + Handlers.GotoDefinition.handle(request, Env.new()) + end + + def with_referenced_file(_) do + path = FixtureHelpers.get_path("references_referenced.ex") + uri = Conversions.ensure_uri(path) + {:ok, file_uri: uri, file_path: path} + end + + describe "when a file contains references" do + setup [:with_referenced_file] + + test "find definition remote function call", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_remote.ex") + {line, char} = {4, 28} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + ReferencesReferenced.referenced_fun() + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 1 + assert definition.range.start.character == 6 + assert definition.range.end.line == 1 + assert definition.range.end.character == 6 + end + + test "find definition remote macro call", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_remote.ex") + {line, char} = {8, 28} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + ReferencesReferenced.referenced_macro a do + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 8 + assert definition.range.start.character == 11 + assert definition.range.end.line == 8 + assert definition.range.end.character == 11 + end + + test "find definition imported function call", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_imported.ex") + {line, char} = {4, 5} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + referenced_fun() + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 1 + assert definition.range.start.character == 6 + assert definition.range.end.line == 1 + assert definition.range.end.character == 6 + end + + test "find definition imported macro call", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_imported.ex") + {line, char} = {8, 5} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + referenced_macro a do + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 8 + assert definition.range.start.character == 11 + assert definition.range.end.line == 8 + assert definition.range.end.character == 11 + end + + test "find definition local function call", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_referenced.ex") + {line, char} = {15, 5} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + referenced_fun() + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 1 + assert definition.range.start.character == 6 + assert definition.range.end.line == 1 + assert definition.range.end.character == 6 + end + + test "find definition local macro call", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_referenced.ex") + {line, char} = {19, 5} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + referenced_macro a do + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 8 + assert definition.range.start.character == 11 + assert definition.range.end.line == 8 + assert definition.range.end.character == 11 + end + + test "find definition variable", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_referenced.ex") + {line, char} = {4, 13} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + IO.puts(referenced_variable + 1) + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 2 + assert definition.range.start.character == 4 + assert definition.range.end.line == 2 + assert definition.range.end.character == 4 + end + + test "find definition attribute", %{file_uri: uri} do + file_path = FixtureHelpers.get_path("references_referenced.ex") + {line, char} = {27, 5} + + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + @referenced_attribute + ^ + """) + + {:reply, %Responses.GotoDefinition{result: definition}} = handle(request) + + assert definition.uri == uri + assert definition.range.start.line == 24 + assert definition.range.start.character == 2 + assert definition.range.end.line == 24 + assert definition.range.end.character == 2 + end + end +end