diff --git a/apps/engine/lib/engine.ex b/apps/engine/lib/engine.ex index 7d6ba0120..54b9a52cc 100644 --- a/apps/engine/lib/engine.ex +++ b/apps/engine/lib/engine.ex @@ -67,6 +67,10 @@ defmodule Engine do defdelegate workspace_symbols(query), to: CodeIntelligence.Symbols, as: :for_workspace + defdelegate prepare_rename(analysis, position), to: Engine.CodeMod.Rename, as: :prepare + + defdelegate rename(analysis, position, new_name, client_name), to: Engine.CodeMod.Rename + def list_apps do for {app, _, _} <- :application.loaded_applications(), not Forge.Namespace.Module.prefixed?(app), diff --git a/apps/engine/lib/engine/code_mod/rename.ex b/apps/engine/lib/engine/code_mod/rename.ex new file mode 100644 index 000000000..1164e6f00 --- /dev/null +++ b/apps/engine/lib/engine/code_mod/rename.ex @@ -0,0 +1,28 @@ +defmodule Engine.CodeMod.Rename do + @moduledoc """ + Entry point for rename operations. + + This module provides the main API for renaming entities in Elixir code. + It coordinates between the preparation phase and the actual rename execution. + """ + alias Engine.CodeMod.Rename + alias Forge.Ast.Analysis + alias Forge.Document + alias Forge.Document.Position + alias Forge.Document.Range + + @spec prepare(Analysis.t(), Position.t()) :: + {:ok, String.t(), Range.t()} | {:ok, nil} | {:error, term()} + defdelegate prepare(analysis, position), to: Rename.Prepare + + @rename_mappings %{function: Rename.Function} + + @spec rename(Analysis.t(), Position.t(), String.t(), String.t() | nil) :: + {:ok, [Document.Changes.t()]} | {:error, term()} + def rename(%Analysis{} = analysis, %Position{} = position, new_name, _client_name) do + with {:ok, {renamable, entity}, _range} <- Rename.Prepare.resolve(analysis, position) do + rename_module = Map.fetch!(@rename_mappings, renamable) + {:ok, rename_module.rename(new_name, entity)} + end + end +end diff --git a/apps/engine/lib/engine/code_mod/rename/function.ex b/apps/engine/lib/engine/code_mod/rename/function.ex new file mode 100644 index 000000000..1d2135bf7 --- /dev/null +++ b/apps/engine/lib/engine/code_mod/rename/function.ex @@ -0,0 +1,216 @@ +defmodule Engine.CodeMod.Rename.Function do + @moduledoc """ + Handles function renaming using the search index for locating all definitions + and references, and text-based edits for performing the rename. + """ + import Forge.Document.Line + + alias Engine.CodeIntelligence.Entity + alias Engine.Search.Indexer.Quoted + alias Engine.Search.Store + alias Engine.Search.Subject + alias Forge.Ast + alias Forge.Ast.Analysis + alias Forge.Document + alias Forge.Document.Edit + alias Forge.Document.Position + alias Forge.Document.Range + alias Forge.Project + alias Forge.Search.Indexer.Entry + + @spec recognizes?(Analysis.t(), Position.t()) :: boolean() + def recognizes?(%Analysis{} = analysis, %Position{} = position) do + match?({:ok, _, _}, resolve(analysis, position)) + end + + @spec prepare(Analysis.t(), Position.t()) :: + {:ok, {:function, {module(), atom(), arity()}}, Range.t()} + | {:error, :cannot_rename_function} + def prepare(%Analysis{} = analysis, %Position{} = position) do + resolve(analysis, position) + end + + @spec rename(String.t(), {module(), atom(), arity()}) :: + [Document.Changes.t()] + def rename(new_name, {module, fun_name, arity}) do + case rename_scope(module, fun_name, arity) do + {:ok, scope} -> + scope.paths + |> Enum.flat_map(&rename_file(&1, scope, fun_name, new_name)) + + :error -> + [] + end + end + + defp resolve(%Analysis{} = analysis, %Position{} = position) do + with {:ok, {:call, module, fun_name, arity}, range} <- Entity.resolve(analysis, position), + false <- is_nil(module), + true <- can_rename?(module, fun_name, arity) do + {:ok, {:function, {module, fun_name, arity}}, range} + else + _ -> + {:error, :cannot_rename_function} + end + end + + defp rename_scope(module, fun_name, arity) do + target_subject = Subject.mfa(module, fun_name, arity) + function_prefix = Subject.mfa(module, fun_name, "") + + definitions = + function_prefix + |> prefix_entries(type: {:function, :_}, subtype: :definition) + |> Enum.filter(&(function_entry?(&1) and String.starts_with?(&1.subject, function_prefix))) + + target_definition_keys = + definitions + |> Enum.filter(&(&1.subject == target_subject and owned_entry?(&1))) + |> MapSet.new(&definition_key/1) + + if MapSet.size(target_definition_keys) == 0 do + :error + else + subjects = + definitions + |> Enum.filter(&MapSet.member?(target_definition_keys, definition_key(&1))) + |> Enum.map(& &1.subject) + |> then(&[target_subject | &1]) + |> MapSet.new() + + paths = + subjects + |> Enum.flat_map(&exact_entries(&1, type: {:function, :_})) + |> Enum.filter( + &(function_entry?(&1) and MapSet.member?(subjects, &1.subject) and owned_entry?(&1)) + ) + |> Enum.uniq_by(& &1.path) + |> Enum.map(& &1.path) + + {:ok, %{subjects: subjects, paths: paths}} + end + end + + defp can_rename?(module, fun_name, arity) do + match?({:ok, %{paths: [_ | _]}}, rename_scope(module, fun_name, arity)) + end + + defp rename_file(path, scope, fun_name, new_name) do + uri = Document.Path.ensure_uri(path) + + with {:ok, document} <- Document.Store.open_temporary(uri), + %Analysis{valid?: true} = analysis <- Ast.analyze(document), + {:ok, entries} <- Quoted.index(analysis) do + edits = + entries + |> Enum.filter(&(function_entry?(&1) and MapSet.member?(scope.subjects, &1.subject))) + |> Enum.map(&function_name_range(document, &1, fun_name)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(&range_key/1) + |> Enum.sort_by(&range_key/1, :desc) + |> Enum.map(&Edit.new(new_name, &1)) + + if edits == [] do + [] + else + [Document.Changes.new(document, edits)] + end + else + _ -> + [] + end + end + + defp function_name_range(%Document{} = document, %Entry{} = entry, fun_name) do + fun_name_str = Atom.to_string(fun_name) + fun_name_length = String.length(fun_name_str) + + entry + |> candidate_name_ranges(fun_name_str, fun_name_length) + |> Enum.find(fn %Range{} = range -> + Document.fragment(document, range.start, range.end) == fun_name_str + end) + end + + defp candidate_name_ranges( + %Entry{range: %Range{} = range}, + fun_name_str, + fun_name_length + ) do + %Range{start: start_position} = range + context_line = start_position.context_line + + case context_line do + nil -> + [] + + line(text: nil) -> + [] + + line(text: text) -> + text + |> String.slice(start_position.character - 1, String.length(text)) + |> find_function_name_offsets(fun_name_str) + |> Enum.map(fn offset -> + start_character = start_position.character + offset + end_character = start_character + fun_name_length + + Range.new( + %{start_position | character: start_character}, + %{start_position | character: end_character} + ) + end) + end + end + + defp find_function_name_offsets(text_from_range_start, fun_name_str) do + pattern = ~r/(? Regex.scan(text_from_range_start, return: :index) + |> Enum.map(fn [{pos, _len} | _] -> pos end) + end + + defp exact_entries(subject, constraints) do + case Store.exact(subject, constraints) do + {:ok, entries} -> + entries + + _ -> + [] + end + end + + defp prefix_entries(prefix, constraints) do + case Store.prefix(prefix, constraints) do + {:ok, entries} -> entries + _ -> [] + end + end + + defp function_entry?(%Entry{type: {:function, _}}), do: true + defp function_entry?(_), do: false + + defp owned_entry?(%Entry{path: path}) do + case Engine.get_project() do + %Project{root_uri: uri} = project when not is_nil(uri) -> + Project.owns_path?(project, Document.Path.ensure_path(path)) + + _ -> + true + end + end + + defp definition_key(%Entry{} = entry) do + {Document.Path.ensure_path(entry.path), range_key(entry.range)} + end + + defp range_key(%Range{} = range) do + { + range.start.line, + range.start.character, + range.end.line, + range.end.character + } + end +end diff --git a/apps/engine/lib/engine/code_mod/rename/prepare.ex b/apps/engine/lib/engine/code_mod/rename/prepare.ex new file mode 100644 index 000000000..9441b0a56 --- /dev/null +++ b/apps/engine/lib/engine/code_mod/rename/prepare.ex @@ -0,0 +1,61 @@ +defmodule Engine.CodeMod.Rename.Prepare do + @moduledoc """ + Handles the preparation phase of rename operations. + + The preparation phase determines: + - Whether the entity at the cursor can be renamed + - What the current name is + - What range should be replaced + """ + alias Engine.CodeIntelligence.Entity + alias Engine.CodeMod.Rename + alias Forge.Ast.Analysis + alias Forge.Document.Position + alias Forge.Document.Range + + require Logger + + @renaming_modules [Rename.Function] + + @spec prepare(Analysis.t(), Position.t()) :: + {:ok, String.t(), Range.t()} | {:ok, nil} + def prepare(%Analysis{} = analysis, %Position{} = position) do + case resolve(analysis, position) do + {:ok, {:function, {_module, fun_name, _arity}}, range} -> + name = Atom.to_string(fun_name) + {:ok, name, narrow_to_name(range, name)} + + {:error, _} -> + {:ok, nil} + end + end + + # For qualified calls like `Foo.Bar.call`, `Entity.resolve` returns a range + # covering the whole dotted expression. The actual rename only touches the + # function name token, so narrow the range to the trailing `name` so that the + # editor highlights and replaces only that portion. + defp narrow_to_name(%Range{end: end_pos} = range, name) do + new_start = %{end_pos | character: end_pos.character - String.length(name)} + %{range | start: new_start} + end + + @spec resolve(Analysis.t(), Position.t()) :: + {:ok, {:function, {module(), atom(), arity()}}, Range.t()} | {:error, term()} + def resolve(%Analysis{} = analysis, %Position{} = position) do + prepare_result = + Enum.find_value(@renaming_modules, fn module -> + if module.recognizes?(analysis, position) do + module.prepare(analysis, position) + end + end) + + prepare_result || handle_unsupported_entity(analysis, position) + end + + defp handle_unsupported_entity(analysis, position) do + with {:ok, other, _range} <- Entity.resolve(analysis, position) do + Logger.info("Unsupported entity for renaming: #{inspect(other)}") + {:error, {:unsupported_entity, elem(other, 0)}} + end + end +end diff --git a/apps/engine/test/engine/code_mod/rename_test.exs b/apps/engine/test/engine/code_mod/rename_test.exs new file mode 100644 index 000000000..7a0c339c2 --- /dev/null +++ b/apps/engine/test/engine/code_mod/rename_test.exs @@ -0,0 +1,450 @@ +defmodule Engine.CodeMod.RenameTest do + use ExUnit.Case, async: false + use Patch + + import Forge.Test.CodeSigil + import Forge.Test.CursorSupport + import Forge.Test.EventualAssertions + import Forge.Test.Fixtures + + alias Engine.CodeMod.Rename + alias Engine.Search + alias Engine.Search.Store.Backends + alias Forge.Document + + setup do + project = project() + + Backends.Ets.destroy_all(project) + Engine.set_project(project) + + start_supervised!({Document.Store, derive: [analysis: &Forge.Ast.analyze/1]}) + start_supervised!(Engine.Dispatch) + start_supervised!(Engine.ApplicationCache) + start_supervised!(Backends.Ets) + + start_supervised!( + {Search.Store, [project, fn _ -> {:ok, []} end, fn _, _ -> {:ok, [], []} end, Backends.Ets]} + ) + + Search.Store.enable() + assert_eventually(Search.Store.loaded?(), 1500) + + on_exit(fn -> + Backends.Ets.destroy_all(project) + end) + + {:ok, project: project} + end + + describe "prepare/2 function" do + test "returns function name at defp definition" do + {:ok, result, _} = + ~q[ + defmodule MyApp.Users do + defp |helper do + :ok + end + end + ] + |> prepare() + + assert result == "helper" + end + + test "returns function name at defp definition with args" do + {:ok, result, _} = + ~q[ + defmodule MyApp.Users do + defp |helper(x), do: x + end + ] + |> prepare() + + assert result == "helper" + end + + test "returns function name at function call" do + {:ok, result, _} = + ~q[ + defmodule MyApp.Users do + def run, do: |helper() + + defp helper, do: :ok + end + ] + |> prepare() + + assert result == "helper" + end + + test "returns nil for external module functions without a workspace definition" do + assert {:ok, nil} = + ~q[ + defmodule MyApp.Users do + def run(items), do: Enum.|map(items, & &1) + end + ] + |> prepare() + end + + test "returns nil for variables" do + assert {:ok, nil} = + ~q[ + defmodule MyApp.Users do + def run do + |x = 1 + end + end + ] + |> prepare() + end + + test "returns nil for whitespace inside a function body" do + assert {:ok, nil} = + ~q[ + defmodule MyApp.Users do + def run do + | + :ok + end + end + ] + |> prepare() + end + + test "returns nil inside a comment" do + assert {:ok, nil} = + ~q[ + defmodule MyApp.Users do + # this is a |comment + def run, do: :ok + end + ] + |> prepare() + end + + test "returns nil inside a string literal" do + assert {:ok, nil} = + ~q[ + defmodule MyApp.Users do + def run, do: "hel|lo world" + end + ] + |> prepare() + end + + test "returns nil on an operator with no surround context" do + assert {:ok, nil} = + ~q[ + defmodule MyApp.Users do + def run, do: 1 |+ 2 + end + ] + |> prepare() + end + end + + describe "rename/4 function" do + test "renames private function at definition" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: helper() + + defp |helper do + :ok + end + end + ] + |> rename("do_work") + + assert result =~ "do_work()" + assert result =~ "defp do_work do" + refute result =~ "helper" + end + + test "renames private function at zero-arg definition" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: helper() + + defp |helper do + helper() + :ok + end + end + ] + |> rename("do_work") + + assert result =~ "do_work()" + assert result =~ "defp do_work do" + refute result =~ "helper" + end + + test "renames private function at call site" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |helper() + + defp helper do + :ok + end + end + ] + |> rename("do_work") + + assert result =~ "do_work()" + assert result =~ "defp do_work do" + refute result =~ "helper" + end + + test "renames function with multiple clauses" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |process(:a) + + defp process(:a), do: 1 + defp process(:b), do: 2 + end + ] + |> rename("handle") + + assert result =~ "handle(:a)" + assert result =~ "defp handle(:a)" + assert result =~ "defp handle(:b)" + refute result =~ "process" + end + + test "renames function with matching arity only" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |helper(1, 2) + + defp helper(a, b), do: a + b + defp helper(a, b, c), do: a + b + c + end + ] + |> rename("compute") + + assert result =~ "compute(1, 2)" + assert result =~ "defp compute(a, b)" + assert result =~ "defp helper(a, b, c)" + end + + test "renames all arities backed by the same default-argument definition" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |helper(1) + helper(1, 2) + + defp helper(a, b \\ 0), do: a + b + end + ] + |> rename("compute") + + assert result =~ "compute(1) + compute(1, 2)" + assert result =~ ~q[defp compute(a, b \\ 0)] + refute result =~ "helper" + refute result =~ "computee" + end + + test "does not affect functions in other modules" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |helper() + + defp helper, do: :ok + end + + defmodule MyApp.Other do + defp helper, do: :other + end + ] + |> rename("do_work") + + assert result =~ "defp do_work" + assert result =~ "defmodule MyApp.Other do\n defp helper" + end + + test "renames piped function calls" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run(data), do: data |> |transform() + + defp transform(data), do: data + end + ] + |> rename("process") + + assert result =~ "|> process()" + assert result =~ "defp process(data)" + refute result =~ "transform" + end + + test "does not rename arity-10 function when renaming arity-1 function" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run do + |helper(1) + helper(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + end + + defp helper(a), do: a + defp helper(a, b, c, d, e, f, g, h, i, j), do: {a, b, c, d, e, f, g, h, i, j} + end + ] + |> rename("compute") + + assert result =~ "compute(1)\n" + assert result =~ "defp compute(a)," + assert result =~ "helper(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)" + assert result =~ "defp helper(a, b, c, d, e, f, g, h, i, j)" + end + + test "renames function with bang in name" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |save!(1) + + defp save!(x), do: x + end + ] + |> rename("persist!") + + assert result =~ "persist!(1)" + assert result =~ "defp persist!(x)" + refute result =~ "save!" + end + + test "renames function with question mark in name" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |valid?(1) + + defp valid?(x), do: x > 0 + end + ] + |> rename("ok?") + + assert result =~ "ok?(1)" + assert result =~ "defp ok?(x)" + refute result =~ "valid?" + end + + test "does not rename a different function whose name starts with the target name" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: |fun() + fun_other() + + defp fun, do: 1 + defp fun_other, do: 2 + end + ] + |> rename("compute") + + assert result =~ "compute() + fun_other()" + assert result =~ "defp compute," + assert result =~ "defp fun_other," + end + + test "renames correctly when a sibling identifier starting with the same prefix appears earlier on the line" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def run, do: fun_other() + |fun() + + defp fun, do: 1 + defp fun_other, do: 2 + end + ] + |> rename("compute") + + assert result =~ "fun_other() + compute()" + assert result =~ "defp compute," + assert result =~ "defp fun_other," + end + + test "does not corrupt a similar-prefix call inside the def body when renaming the def" do + {:ok, result} = + ~q[ + defmodule MyApp.Users do + def fun_min, do: 0 + + def |fun(x), do: MyApp.Users.fun_min() + x + end + ] + |> rename("compute") + + assert result =~ "def compute(x), do: MyApp.Users.fun_min() + x" + assert result =~ "def fun_min," + refute result =~ "compute_min" + end + + test "returns empty changes for unsupported rename" do + assert {:ok, result} = + ~q[ + defmodule MyApp.Users do + def |run, do: :ok + end + ] + |> rename("execute") + + assert is_binary(result) + end + end + + # Helpers + + defp prepare(code) do + with {position, code} <- pop_cursor(code), + {:ok, _document, analysis} <- index(code) do + Rename.prepare(analysis, position) + end + end + + defp rename(code, new_name) do + with {position, code} <- pop_cursor(code), + {:ok, document, analysis} <- index(code), + {:ok, results} <- Rename.rename(analysis, position, new_name, nil) do + case results do + [%Document.Changes{edits: edits, document: doc}] -> + {:ok, edited_doc} = + Document.apply_content_changes(doc, doc.version + 1, edits) + + {:ok, Document.to_string(edited_doc)} + + [] -> + {:ok, Document.to_string(document)} + end + end + end + + defp index(code) do + project = project() + uri = module_uri(project) + + with :ok <- Document.Store.open(uri, code, 1), + {:ok, document, analysis} <- Document.Store.fetch(uri, :analysis), + {:ok, entries} <- Engine.Search.Indexer.Quoted.index(analysis) do + Search.Store.replace(entries) + {:ok, document, analysis} + end + end + + defp module_uri(project) do + project + |> file_path(Path.join("lib", "my_module.ex")) + |> Document.Path.ensure_uri() + end +end diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index 7debfe1da..e4b05ea53 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -542,6 +542,12 @@ defmodule Expert do %GenLSP.Requests.WorkspaceSymbol{} -> {:ok, Handlers.WorkspaceSymbol} + %GenLSP.Requests.TextDocumentPrepareRename{} -> + {:ok, Handlers.PrepareRename} + + %GenLSP.Requests.TextDocumentRename{} -> + {:ok, Handlers.Rename} + %request_module{} -> {:error, {:unhandled, request_module}} end diff --git a/apps/expert/lib/expert/engine_api.ex b/apps/expert/lib/expert/engine_api.ex index 7095d738b..c53497f30 100644 --- a/apps/expert/lib/expert/engine_api.ex +++ b/apps/expert/lib/expert/engine_api.ex @@ -154,5 +154,24 @@ defmodule Expert.EngineApi do call(project, Engine, :workspace_symbols, [query]) end + def prepare_rename(%Project{} = project, %Analysis{} = analysis, %Position{} = position) do + call(project, Engine, :prepare_rename, [analysis, position]) + end + + def rename( + %Project{} = project, + %Analysis{} = analysis, + %Position{} = position, + new_name, + client_name + ) do + call(project, Engine, :rename, [ + analysis, + position, + new_name, + client_name + ]) + end + defdelegate stop(project), to: EngineNode end diff --git a/apps/expert/lib/expert/provider/handlers/prepare_rename.ex b/apps/expert/lib/expert/provider/handlers/prepare_rename.ex new file mode 100644 index 000000000..83deb5721 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/prepare_rename.ex @@ -0,0 +1,54 @@ +defmodule Expert.Provider.Handlers.PrepareRename do + @moduledoc """ + Handler for textDocument/prepareRename requests. + + This handler determines if the entity at the cursor can be renamed + and returns the range and placeholder text for the rename operation. + """ + @behaviour Expert.Provider.Handler + + alias Expert.Document.Context + alias Expert.EngineApi + alias Forge.Ast + alias Forge.Document + alias Forge.Protocol.Convertible + alias GenLSP.Structures + + require Logger + + @impl Expert.Provider.Handler + def handle( + %GenLSP.Requests.TextDocumentPrepareRename{ + params: %Structures.PrepareRenameParams{} = params + }, + %Context{} = context + ) do + %Context{document: document, project: project} = context + + case Document.Store.fetch(document.uri, :analysis) do + {:ok, _document, %Ast.Analysis{valid?: true} = analysis} -> + prepare_rename(project, analysis, params.position) + + _ -> + {:error, :request_failed, "Document cannot be analyzed"} + end + end + + defp prepare_rename(project, analysis, position) do + case EngineApi.prepare_rename(project, analysis, position) do + {:ok, placeholder, range} when is_binary(placeholder) -> + with {:ok, lsp_range} <- Convertible.to_lsp(range) do + {:ok, %{range: lsp_range, placeholder: placeholder}} + end + + {:ok, nil} -> + {:ok, nil} + + {:error, error} when is_binary(error) -> + {:error, :request_failed, error} + + {:error, error} -> + {:error, :request_failed, inspect(error)} + end + end +end diff --git a/apps/expert/lib/expert/provider/handlers/rename.ex b/apps/expert/lib/expert/provider/handlers/rename.ex new file mode 100644 index 000000000..9955969b8 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/rename.ex @@ -0,0 +1,88 @@ +defmodule Expert.Provider.Handlers.Rename do + @moduledoc """ + Handler for textDocument/rename requests. + + This handler executes the rename operation and returns the workspace edit + containing all the text edits needed. + """ + @behaviour Expert.Provider.Handler + + alias Expert.Configuration + alias Expert.Document.Context + alias Expert.EngineApi + alias Forge.Ast + alias Forge.Document + alias Forge.Document.Changes + alias Forge.Protocol.Convertible + alias GenLSP.Structures + + require Logger + + @impl Expert.Provider.Handler + def handle( + %GenLSP.Requests.TextDocumentRename{ + params: %Structures.RenameParams{} = params + }, + %Context{} = context + ) do + %Context{document: document, project: project} = context + + case Document.Store.fetch(document.uri, :analysis) do + {:ok, _document, %Ast.Analysis{valid?: true} = analysis} -> + rename(project, analysis, params.position, params.new_name) + + _ -> + {:error, :request_failed, "Document cannot be analyzed"} + end + end + + defp rename(project, analysis, position, new_name) do + %Configuration{client_name: client_name} = Configuration.get() + + case EngineApi.rename(project, analysis, position, new_name, client_name) do + {:ok, []} -> + {:ok, nil} + + {:ok, results} -> + with {:ok, document_changes} <- to_document_changes(results) do + {:ok, %Structures.WorkspaceEdit{document_changes: document_changes}} + end + + {:error, {:unsupported_entity, entity}} -> + Logger.info("Cannot rename entity: #{inspect(entity)}") + {:ok, nil} + + {:error, reason} -> + {:error, :request_failed, inspect(reason)} + end + end + + defp to_document_changes(results) do + results + |> Enum.reduce_while({:ok, []}, fn changes, {:ok, acc} -> + case to_text_document_edit(changes) do + {:ok, edit} -> {:cont, {:ok, [edit | acc]}} + error -> {:halt, error} + end + end) + |> case do + {:ok, edits} -> {:ok, Enum.reverse(edits)} + error -> error + end + end + + defp to_text_document_edit(%Changes{document: document, edits: edits}) do + with {:ok, text_edits} <- Convertible.to_lsp(edits) do + text_document = %Structures.OptionalVersionedTextDocumentIdentifier{ + uri: document.uri, + version: document.version + } + + {:ok, + %Structures.TextDocumentEdit{ + edits: text_edits, + text_document: text_document + }} + end + end +end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index f9425c9e6..6ee56e749 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -376,6 +376,11 @@ defmodule Expert.State do trigger_characters: CodeIntelligence.Completion.trigger_characters() } + rename_options = + %Structures.RenameOptions{ + prepare_provider: true + } + server_capabilities = %Structures.ServerCapabilities{ code_action_provider: code_action_options, @@ -388,6 +393,7 @@ defmodule Expert.State do folding_range_provider: true, hover_provider: true, references_provider: true, + rename_provider: rename_options, text_document_sync: sync_options, workspace_symbol_provider: true, workspace: %{ diff --git a/apps/forge/lib/forge/document/changes.ex b/apps/forge/lib/forge/document/changes.ex index f7c8f15a6..5e30577ad 100644 --- a/apps/forge/lib/forge/document/changes.ex +++ b/apps/forge/lib/forge/document/changes.ex @@ -14,6 +14,7 @@ defmodule Forge.Document.Changes do alias Forge.Document defstruct [:document, :edits] + @type edits :: Document.Edit.t() | [Document.Edit.t()] @type t :: %__MODULE__{ document: Document.t(), diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index 0046ed4d0..46f0bd20e 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -159,6 +159,17 @@ defmodule Forge.Project do Document.Path.from_uri(project.root_uri) end + @doc """ + Returns true if the given path belongs to the project (inside root, outside deps). + """ + @spec owns_path?(t, Path.t()) :: boolean() + def owns_path?(%__MODULE__{} = project, path) do + root = root_path(project) + deps = Path.join(root, "deps") + + Forge.Path.contains?(path, root) and not Forge.Path.contains?(path, deps) + end + @spec project_path(t) :: Path.t() | nil def project_path(%__MODULE__{root_uri: nil}) do nil diff --git a/apps/forge/lib/forge/search/indexer/entry.ex b/apps/forge/lib/forge/search/indexer/entry.ex index 44921a6a9..a7dd073c4 100644 --- a/apps/forge/lib/forge/search/indexer/entry.ex +++ b/apps/forge/lib/forge/search/indexer/entry.ex @@ -23,7 +23,11 @@ defmodule Forge.Search.Indexer.Entry do @type entry_id :: pos_integer() | nil @type block_id :: pos_integer() | :root @type subject_query :: subject() | :_ - @type entry_type_query :: entry_type() | :_ + @type entry_type_query :: + entry_type() + | {:protocol, protocol_type() | :_} + | {:function, function_type() | :_} + | :_ @type entry_subtype_query :: entry_subtype() | :_ @type constraint :: {:type, entry_type_query()} | {:subtype, entry_subtype_query()} @type constraints :: [constraint()]