diff --git a/apps/engine/lib/engine.ex b/apps/engine/lib/engine.ex index a546981e9..8541800bb 100644 --- a/apps/engine/lib/engine.ex +++ b/apps/engine/lib/engine.ex @@ -46,6 +46,8 @@ defmodule Engine do to: Engine.Completion, as: :struct_fields + defdelegate declaration(document, position), to: CodeIntelligence.Declaration + defdelegate definition(document, position), to: CodeIntelligence.Definition defdelegate hover(document, position), to: CodeIntelligence.Hover diff --git a/apps/engine/lib/engine/code_intelligence/declaration.ex b/apps/engine/lib/engine/code_intelligence/declaration.ex new file mode 100644 index 000000000..894845a63 --- /dev/null +++ b/apps/engine/lib/engine/code_intelligence/declaration.ex @@ -0,0 +1,280 @@ +defmodule Engine.CodeIntelligence.Declaration do + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Parser + alias ElixirSense.Core.State + alias ElixirSense.Core.State.{ModFunInfo, SpecInfo} + alias ElixirSense.Core.SurroundContext + alias ElixirSense.Providers.Location, as: SenseLocation + alias Forge.Document + alias Forge.Document.Location + alias Forge.Document.Position + alias Forge.Document.Range + + require Introspection + + @spec declaration(Document.t(), Position.t()) :: + {:ok, Location.t() | [Location.t()] | nil} + def declaration(%Document{} = document, %Position{} = position) do + source = Document.to_string(document) + cursor = {position.line, position.character} + + locations = + case Code.Fragment.surround_context(source, cursor) do + :none -> + [] + + context -> + metadata = Parser.parse_string(source, true, false, cursor) + env = Metadata.get_cursor_env(metadata, cursor, {context.begin, context.end}) + + context + |> declarations(env, metadata) + |> Enum.flat_map(&to_location(&1, document)) + end + + case Enum.uniq_by(locations, &{&1.uri, &1.range}) do + [] -> {:ok, nil} + [location] -> {:ok, location} + locations -> {:ok, locations} + end + end + + defp declarations(context, %State.Env{} = env, metadata) do + binding = Binding.from_env(env, metadata, context.begin) + + context.context + |> SurroundContext.to_binding(env.module) + |> declarations_for_binding(context, env, metadata, binding) + end + + defp declarations_for_binding(nil, _context, _env, _metadata, _binding), do: [] + + defp declarations_for_binding({:keyword, _}, _context, _env, _metadata, _binding), do: [] + + defp declarations_for_binding( + {:variable, variable, version}, + context, + env, + metadata, + _binding + ) do + case Metadata.find_var(metadata, variable, version, context.begin) do + nil -> declarations_for_function({nil, variable}, context, env, metadata) + _variable -> [] + end + end + + defp declarations_for_binding({:attribute, _}, _context, _env, _metadata, _binding), + do: [] + + defp declarations_for_binding( + {{:variable, _, _} = receiver, function}, + context, + env, + metadata, + binding + ) do + declarations_for_expanded_receiver(receiver, function, context, env, metadata, binding) + end + + defp declarations_for_binding( + {{:attribute, _} = receiver, function}, + context, + env, + metadata, + binding + ) do + declarations_for_expanded_receiver(receiver, function, context, env, metadata, binding) + end + + defp declarations_for_binding( + {module, function}, + context, + env, + metadata, + _binding + ) do + declarations_for_function({module, function}, context, env, metadata) + end + + defp declarations_for_expanded_receiver(receiver, function, context, env, metadata, binding) do + case Binding.expand(binding, receiver) do + {:atom, module} -> + declarations_for_function( + {{:atom, module}, function}, + context, + env, + metadata + ) + + _other -> + [] + end + end + + defp declarations_for_function({module, function}, context, env, metadata) do + module = binding_module(module) + + case Introspection.actual_mod_fun( + {module, function}, + env, + metadata.mods_funs_to_positions, + metadata.types, + context.begin, + true + ) do + {_module, resolved_function, false, _kind} -> + arity = call_arity(metadata, env.module, resolved_function, context.end) + callback_declarations(env.module, resolved_function, arity, metadata, env) + + {resolved_module, resolved_function, true, :mod_fun} -> + arity = call_arity(metadata, resolved_module, resolved_function, context.end) + callback_declarations(resolved_module, resolved_function, arity, metadata, env) + + _other -> + [] + end + end + + defp binding_module({:atom, module}), do: module + defp binding_module(_module), do: nil + + defp call_arity(metadata, module, function, {line, column}) do + Metadata.get_call_arity(metadata, module, function, line, column) || :any + end + + defp callback_declarations(nil, _function, _arity, _metadata, _env), do: [] + + defp callback_declarations(module, function, arity, metadata, env) do + declarations = + metadata + |> Metadata.get_module_behaviours(env, module) + |> Kernel.++([module]) + |> Enum.uniq() + |> Enum.flat_map(fn behaviour -> + if Introspection.is_callback(behaviour, function, arity, metadata) do + callback_location(behaviour, function, arity, metadata) + else + [] + end + end) + + case declarations do + [] -> overridable_declarations(module, function, arity, metadata) + declarations -> declarations + end + end + + defp callback_location(module, function, arity, metadata) do + case find_callback_spec(metadata.specs, module, function, arity) do + %SpecInfo{} = spec -> + [sense_location(nil, :callback, SenseLocation.info_to_range(spec))] + + nil -> + case callback_source_location(module, function, arity) do + %SenseLocation{} = location -> [location] + nil -> [] + end + end + end + + defp find_callback_spec(specs, module, function, arity) do + Enum.find_value(specs, fn + {{^module, ^function, spec_arity}, %SpecInfo{kind: kind} = spec} + when kind in [:callback, :macrocallback] -> + if Introspection.matches_arity?(spec_arity, arity), do: spec + + _entry -> + nil + end) + end + + defp callback_source_location(module, function, arity) do + with source when is_binary(source) <- module_source(module), + metadata = Parser.parse_file(source, false, false, nil), + %SpecInfo{} = spec <- find_callback_spec(metadata.specs, module, function, arity) do + sense_location(source, :callback, SenseLocation.info_to_range(spec)) + else + _failure -> nil + end + end + + defp module_source(module) do + with true <- Code.ensure_loaded?(module), + source when not is_nil(source) <- module.module_info(:compile)[:source] do + source + |> to_string() + |> resolve_source_path() + else + _failure -> nil + end + end + + defp resolve_source_path(path) do + cond do + File.regular?(path) -> + path + + elixir_source = Application.get_env(:language_server, :elixir_src) -> + case Regex.run(~r{/lib/.+\.ex$}, path) do + [suffix] -> + candidate = Path.join(elixir_source, String.trim_leading(suffix, "/")) + if File.regular?(candidate), do: candidate + + _no_suffix -> + nil + end + + true -> + nil + end + end + + defp overridable_declarations(module, function, arity, metadata) do + metadata.mods_funs_to_positions + |> Enum.flat_map(fn + {{^module, ^function, defined_arity}, %ModFunInfo{overridable: {true, origin}}} + when Introspection.matches_arity?(defined_arity, arity) -> + case SenseLocation.find_mod_fun_source(origin, :__using__, :any) do + %SenseLocation{} = location -> [location] + nil -> [] + end + + _entry -> + [] + end) + end + + defp sense_location(file, type, {{line, column}, {end_line, end_column}}) do + %SenseLocation{ + type: type, + file: file, + line: line, + column: column, + end_line: end_line, + end_column: end_column + } + end + + defp to_location(%SenseLocation{file: nil} = location, %Document{} = document) do + [Location.new(to_range(location, document), document)] + end + + defp to_location(%SenseLocation{file: file} = location, _document) when is_binary(file) do + uri = Document.Path.ensure_uri(file) + + case Document.Store.open_temporary(uri) do + {:ok, document} -> [Location.new(to_range(location, document), document)] + _error -> [] + end + end + + defp to_range(%SenseLocation{} = location, %Document{} = document) do + Range.new( + Position.new(document, location.line, location.column), + Position.new(document, location.end_line, location.end_column) + ) + end +end diff --git a/apps/engine/test/engine/code_intelligence/declaration_test.exs b/apps/engine/test/engine/code_intelligence/declaration_test.exs new file mode 100644 index 000000000..f7edb1ed9 --- /dev/null +++ b/apps/engine/test/engine/code_intelligence/declaration_test.exs @@ -0,0 +1,157 @@ +defmodule Engine.CodeIntelligence.DeclarationTest do + use ExUnit.Case, async: false + + alias Engine.CodeIntelligence.Declaration + alias Forge.Document + alias Forge.Document.Location + alias Forge.Document.Position + + defmodule RemoteBehaviour do + @callback remote(term()) :: term() + end + + setup do + start_supervised!(Document.Store) + :ok + end + + test "finds a local behaviour callback" do + source = """ + defmodule Example.Behaviour do + @callback run(term()) :: term() + end + + defmodule Example.Implementation do + @behaviour Example.Behaviour + def run(value), do: value + end + """ + + assert {:ok, %Location{} = location} = declaration(source, 7, 8) + assert location.range.start.line == 2 + assert location_line(location) =~ "@callback run" + end + + test "finds a declaration from the callback itself" do + source = """ + defmodule Example.Callback do + @callback run(term()) :: term() + end + """ + + assert {:ok, %Location{} = location} = declaration(source, 2, 14) + assert location.range.start.line == 2 + end + + test "finds a declaration from an implementation call" do + source = """ + defmodule Example.CallBehaviour do + @callback run(term()) :: term() + end + + defmodule Example.CallImplementation do + @behaviour Example.CallBehaviour + def run(value), do: value + end + + Example.CallImplementation.run(:ok) + """ + + assert {:ok, %Location{} = location} = declaration(source, 10, 30) + assert location.range.start.line == 2 + end + + test "finds a local protocol function" do + source = """ + defprotocol Example.Protocol do + def convert(value) + end + + defimpl Example.Protocol, for: List do + def convert(value), do: value + end + """ + + assert {:ok, %Location{} = location} = declaration(source, 6, 8) + assert location.range.start.line == 2 + assert location_line(location) =~ "def convert" + end + + test "uses a protocol spec as the declaration" do + source = """ + defprotocol Example.SpecProtocol do + @spec convert(t()) :: term() + def convert(value) + end + + defimpl Example.SpecProtocol, for: List do + def convert(value), do: value + end + """ + + assert {:ok, %Location{} = location} = declaration(source, 7, 8) + assert location.range.start.line == 2 + assert location_line(location) =~ "@spec convert" + end + + test "finds a callback in a loaded behaviour source file" do + source = """ + defmodule Example.RemoteImplementation do + @behaviour #{inspect(RemoteBehaviour)} + def remote(value), do: value + end + """ + + assert {:ok, %Location{} = location} = declaration(source, 3, 8) + assert normalize_path(location.document.path) == normalize_path(__ENV__.file) + assert location_line(location) =~ "@callback remote" + end + + test "selects a callback by arity" do + source = """ + defmodule Example.Unary do + @callback run(term()) :: term() + end + + defmodule Example.Binary do + @callback run(term(), term()) :: term() + end + + defmodule Example.ArityImplementation do + @behaviour Example.Unary + @behaviour Example.Binary + def run(value), do: value + end + """ + + assert {:ok, %Location{} = location} = declaration(source, 12, 8) + assert location.range.start.line == 2 + end + + test "returns nil when the function has no declaration" do + source = """ + defmodule Example.Plain do + def run(value), do: value + end + """ + + assert {:ok, nil} = declaration(source, 2, 8) + end + + defp declaration(source, line, character) do + document = Document.new("file:///declaration_test.ex", source, 0) + position = Position.new(document, line, character) + Declaration.declaration(document, position) + end + + defp location_line(%Location{document: document, range: range}) do + {:ok, line} = Document.fetch_text_at(document, range.start.line) + line + end + + defp normalize_path(path) do + path + |> String.replace("\\", "/") + |> Path.expand() + end +end diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index 6b0185adc..4540c16a7 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -542,6 +542,9 @@ defmodule Expert do %Requests.TextDocumentCompletion{} -> {:ok, Handlers.Completion} + %Requests.TextDocumentDeclaration{} -> + {:ok, Handlers.GoToDeclaration} + %Requests.TextDocumentDefinition{} -> {:ok, Handlers.GoToDefinition} diff --git a/apps/expert/lib/expert/engine_api.ex b/apps/expert/lib/expert/engine_api.ex index b7e855aaa..2c6b65da7 100644 --- a/apps/expert/lib/expert/engine_api.ex +++ b/apps/expert/lib/expert/engine_api.ex @@ -93,6 +93,10 @@ defmodule Expert.EngineApi do ]) end + def declaration(%Project{} = project, %Document{} = document, %Position{} = position) do + call(project, Engine, :declaration, [document, position]) + end + def definition(%Project{} = project, %Document{} = document, %Position{} = position) do call(project, Engine, :definition, [document, position]) end diff --git a/apps/expert/lib/expert/provider/handlers/go_to_declaration.ex b/apps/expert/lib/expert/provider/handlers/go_to_declaration.ex new file mode 100644 index 000000000..7c09a61e2 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/go_to_declaration.ex @@ -0,0 +1,27 @@ +defmodule Expert.Provider.Handlers.GoToDeclaration do + @behaviour Expert.Provider.Handler + + alias Expert.Document.Context + alias Expert.EngineApi + alias GenLSP.Requests + alias GenLSP.Structures + + require Logger + + @impl Expert.Provider.Handler + def handle( + %Requests.TextDocumentDeclaration{params: %Structures.DeclarationParams{} = params}, + %Context{} = context + ) do + %Context{document: document, project: project} = context + + case EngineApi.declaration(project, document, params.position) do + {:ok, native_location} -> + {:ok, native_location} + + {:error, reason} -> + Logger.error("GoToDeclaration failed: #{inspect(reason)}") + {:ok, nil} + end + end +end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index da02c4efc..cee233004 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -411,6 +411,7 @@ defmodule Expert.State do code_action_provider: code_action_options, code_lens_provider: code_lens_options, completion_provider: completion_options, + declaration_provider: true, definition_provider: true, document_formatting_provider: true, document_symbol_provider: true, diff --git a/apps/expert/test/expert/provider/handlers/go_to_declaration_test.exs b/apps/expert/test/expert/provider/handlers/go_to_declaration_test.exs new file mode 100644 index 000000000..538552fd0 --- /dev/null +++ b/apps/expert/test/expert/provider/handlers/go_to_declaration_test.exs @@ -0,0 +1,49 @@ +defmodule Expert.Provider.Handlers.GoToDeclarationTest do + use ExUnit.Case, async: false + use Patch + + import Forge.Test.Fixtures + + alias Expert.Document.Context + alias Expert.EngineApi + alias Expert.Protocol.Convert + alias Expert.Provider.Handlers.GoToDeclaration + alias Expert.State + alias Forge.Document + alias GenLSP.Requests.TextDocumentDeclaration + alias GenLSP.Structures.DeclarationParams + alias GenLSP.Structures.Position + alias GenLSP.Structures.TextDocumentIdentifier + + setup do + start_supervised!(Expert.Application.document_store_child_spec()) + Expert.Configuration.new() |> Expert.Configuration.set() + on_exit(fn -> :persistent_term.erase(Expert.Configuration) end) + end + + test "forwards declaration requests to the project engine" do + project = project() + document = Document.new("file:///declaration.ex", "def run, do: :ok", 3) + context = Context.new(document.uri, document, project) + + lsp_request = + %TextDocumentDeclaration{ + id: 1, + params: %DeclarationParams{ + text_document: %TextDocumentIdentifier{uri: document.uri}, + position: %Position{line: 0, character: 4} + } + } + + assert {:ok, request} = Convert.to_native(lsp_request, document) + position = request.params.position + + patch(EngineApi, :declaration, fn ^project, ^document, ^position -> {:ok, nil} end) + + assert {:ok, nil} = GoToDeclaration.handle(request, context) + end + + test "advertises declaration support" do + assert State.initialize_result().capabilities.declaration_provider + end +end