diff --git a/apps/common/lib/lexical/ast/analysis.ex b/apps/common/lib/lexical/ast/analysis.ex index 4e2356abe..19c74d2d1 100644 --- a/apps/common/lib/lexical/ast/analysis.ex +++ b/apps/common/lib/lexical/ast/analysis.ex @@ -10,7 +10,6 @@ defmodule Lexical.Ast.Analysis do alias Lexical.Ast.Analysis.Scope alias Lexical.Ast.Analysis.State alias Lexical.Document - alias Lexical.Document alias Lexical.Document.Position alias Lexical.Document.Range alias Lexical.Identifier @@ -92,6 +91,35 @@ defmodule Lexical.Ast.Analysis do end end + @doc """ + Returns the scope of the nearest enclosing module of the given function. + + If there is no enclosing module scope, the global scope is returned + """ + @spec module_scope(t(), Range.t()) :: Scope.t() + def module_scope(%__MODULE__{} = analysis, %Range{} = range) do + enclosing_scopes = + analysis + |> scopes_at(range.start) + |> enclosing_scopes(range) + + first_scope = List.first(enclosing_scopes) + + Enum.reduce_while(enclosing_scopes, first_scope, fn + %Scope{module: same} = current, %Scope{module: same} -> + {:cont, current} + + _, current -> + {:halt, current} + end) + end + + defp enclosing_scopes(scopes, range) do + Enum.filter(scopes, fn scope -> + Range.contains?(scope.range, range.start) + end) + end + defp traverse(quoted, %Document{} = document) do quoted = preprocess(quoted) diff --git a/apps/common/lib/lexical/ast/analysis/scope.ex b/apps/common/lib/lexical/ast/analysis/scope.ex index e80a9db19..1e4d36d8d 100644 --- a/apps/common/lib/lexical/ast/analysis/scope.ex +++ b/apps/common/lib/lexical/ast/analysis/scope.ex @@ -64,6 +64,7 @@ defmodule Lexical.Ast.Analysis.Scope do end end + def empty?(%__MODULE__{id: :global}), do: false def empty?(%__MODULE__{aliases: [], imports: []}), do: true def empty?(%__MODULE__{}), do: false diff --git a/apps/remote_control/lib/lexical/remote_control/code_action.ex b/apps/remote_control/lib/lexical/remote_control/code_action.ex index c44632d84..b92043616 100644 --- a/apps/remote_control/lib/lexical/remote_control/code_action.ex +++ b/apps/remote_control/lib/lexical/remote_control/code_action.ex @@ -28,7 +28,8 @@ defmodule Lexical.RemoteControl.CodeAction do @handlers [ Handlers.ReplaceRemoteFunction, Handlers.ReplaceWithUnderscore, - Handlers.OrganizeAliases + Handlers.OrganizeAliases, + Handlers.AddAlias ] @spec new(Lexical.uri(), String.t(), code_action_kind(), Changes.t()) :: t() diff --git a/apps/remote_control/lib/lexical/remote_control/code_action/handlers/add_alias.ex b/apps/remote_control/lib/lexical/remote_control/code_action/handlers/add_alias.ex new file mode 100644 index 000000000..f3203cdc9 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_action/handlers/add_alias.ex @@ -0,0 +1,211 @@ +defmodule Lexical.RemoteControl.CodeAction.Handlers.AddAlias do + alias Lexical.Ast + alias Lexical.Ast.Analysis + alias Lexical.Ast.Analysis.Alias + alias Lexical.Document + alias Lexical.Document.Changes + alias Lexical.Document.Position + alias Lexical.Document.Range + alias Lexical.Formats + alias Lexical.RemoteControl + alias Lexical.RemoteControl.Analyzer + alias Lexical.RemoteControl.CodeAction + alias Lexical.RemoteControl.CodeIntelligence.Entity + alias Lexical.RemoteControl.CodeMod + alias Lexical.RemoteControl.Modules + alias Lexical.RemoteControl.Search.Fuzzy + alias Lexical.RemoteControl.Search.Indexer.Entry + alias Mix.Tasks.Namespace + alias Sourceror.Zipper + + @behaviour CodeAction.Handler + + @impl CodeAction.Handler + def actions(%Document{} = doc, %Range{} = range, _diagnostics) do + with {:ok, _doc, %Analysis{valid?: true} = analysis} <- + Document.Store.fetch(doc.uri, :analysis), + {:ok, resolved, _} <- Entity.resolve(analysis, range.start), + {:ok, unaliased_module} <- fetch_unaliased_module(analysis, range.start, resolved) do + current_aliases = CodeMod.Aliases.in_scope(analysis, range) + + unaliased_module + |> possible_aliases() + |> filter_by_resolution(resolved) + |> Stream.map(&build_code_action(analysis, range, current_aliases, &1)) + |> Enum.reject(&is_nil/1) + else + _ -> + [] + end + end + + @impl CodeAction.Handler + def kinds do + [:quick_fix] + end + + defp build_code_action(%Analysis{} = analysis, range, current_aliases, potential_alias_module) do + case Ast.Module.safe_split(potential_alias_module, as: :atoms) do + {:erlang, _} -> + nil + + {:elixir, segments} -> + {insert_position, trailer} = CodeMod.Aliases.insert_position(analysis, range.start) + alias_to_add = %Alias{module: segments, as: List.last(segments), explicit?: true} + replace_current_alias = get_current_replacement(analysis, range, segments) + + alias_edits = + CodeMod.Aliases.to_edits( + [alias_to_add | current_aliases], + insert_position, + trailer + ) + + changes = Changes.new(analysis.document, replace_current_alias ++ alias_edits) + + CodeAction.new( + analysis.document.uri, + "alias #{Formats.module(potential_alias_module)}", + :quick_fix, + changes + ) + end + end + + def fetch_unaliased_module(%Analysis{} = analysis, %Position{} = position, resolved) do + with {:ok, module} <- fetch_module(resolved), + %{} = aliases <- Analyzer.aliases_at(analysis, position), + false <- module in Map.values(aliases) do + {:ok, module} + else + _ -> + :error + end + end + + defp fetch_module({:module, module}), do: {:ok, module} + defp fetch_module({:struct, module}), do: {:ok, module} + defp fetch_module({:call, module, _function, _arity}), do: {:ok, module} + defp fetch_module(_), do: :error + + defp get_current_replacement(%Analysis{} = analysis, %Range{} = range, segments) do + with {:ok, patches} <- replace_full_module_on_line(analysis, range.start.line, segments), + {:ok, edits} <- Ast.patches_to_edits(analysis.document, patches) do + edits + else + _ -> + [] + end + end + + defp replace_full_module_on_line(%Analysis{} = analysis, line, segments) do + aliased_module = + segments + |> List.last() + |> List.wrap() + |> Module.concat() + |> Formats.module() + + analysis.document + |> Ast.traverse_line(line, [], fn + %Zipper{node: {:__aliases__, _, ^segments}} = zipper, patches -> + range = Sourceror.get_range(zipper.node) + + patch = %{range: range, change: aliased_module} + {zipper, [patch | patches]} + + zipper, acc -> + {zipper, acc} + end) + |> case do + {:ok, _, patches} -> {:ok, patches} + error -> error + end + end + + @similarity_threshold 0.75 + defp similar?(a, b), do: String.jaro_distance(a, b) >= @similarity_threshold + + defp filter_by_resolution(modules_stream, {:call, _module, function, _arity}) do + query_function = Atom.to_string(function) + + Stream.filter(modules_stream, fn module -> + case Modules.fetch_functions(module) do + {:ok, functions} -> + Enum.any?(functions, fn {name, _arity} -> + module_function = Atom.to_string(name) + similar?(module_function, query_function) + end) + + _ -> + false + end + end) + end + + defp filter_by_resolution(modules_stream, {:struct, _}) do + Stream.filter(modules_stream, fn module -> + case Modules.fetch_functions(module) do + {:ok, functions} -> Keyword.has_key?(functions, :__struct__) + _ -> false + end + end) + end + + defp filter_by_resolution(modules_stream, _) do + modules_stream + end + + def possible_aliases(unaliased_module) do + module_subject = Formats.module(unaliased_module) + + case Ast.Module.safe_split(unaliased_module) do + {:elixir, unaliased_strings} -> + module_subject + |> do_fuzzy_search() + |> Stream.filter(fn module -> + {:elixir, split} = Ast.Module.safe_split(module) + alias_as = List.last(split) + subject_module = module + RemoteControl.Module.Loader.ensure_loaded(subject_module) + + protocol_or_implementation? = function_exported?(module, :__impl__, 1) + + not protocol_or_implementation? and + Enum.any?(unaliased_strings, &similar?(&1, alias_as)) + end) + + _ -> + [] + end + end + + defp do_fuzzy_search(subject) do + # Note: we can't use the indexer's fuzzy matcher here, since it + # ignores all deps, and then we won't be able to alias any deps module + + for {mod, _, _} <- all_modules(), + elixir_module?(mod), + not Namespace.Module.prefixed?(mod) do + module_name = List.to_atom(mod) + + %Entry{ + id: module_name, + path: "", + subject: module_name, + subtype: :definition, + type: :module + } + end + |> Fuzzy.from_entries() + |> Fuzzy.match(subject) + end + + defp all_modules do + # Note: this is for testing + :code.all_available() + end + + defp elixir_module?([?E, ?l, ?i, ?x, ?i, ?r, ?. | _]), do: true + defp elixir_module?(_), do: false +end diff --git a/apps/remote_control/lib/lexical/remote_control/code_action/handlers/organize_aliases.ex b/apps/remote_control/lib/lexical/remote_control/code_action/handlers/organize_aliases.ex index 53c0015b7..01638ba12 100644 --- a/apps/remote_control/lib/lexical/remote_control/code_action/handlers/organize_aliases.ex +++ b/apps/remote_control/lib/lexical/remote_control/code_action/handlers/organize_aliases.ex @@ -1,13 +1,11 @@ defmodule Lexical.RemoteControl.CodeAction.Handlers.OrganizeAliases do alias Lexical.Ast.Analysis - alias Lexical.Ast.Analysis.Alias alias Lexical.Ast.Analysis.Scope alias Lexical.Document alias Lexical.Document.Changes - alias Lexical.Document.Edit - alias Lexical.Document.Position alias Lexical.Document.Range alias Lexical.RemoteControl.CodeAction + alias Lexical.RemoteControl.CodeMod require Logger @@ -17,13 +15,9 @@ defmodule Lexical.RemoteControl.CodeAction.Handlers.OrganizeAliases do def actions(%Document{} = doc, %Range{} = range, _diagnostics) do with {:ok, _doc, analysis} <- Document.Store.fetch(doc.uri, :analysis), :ok <- check_aliases(doc, analysis, range) do - edits = - analysis - |> Analysis.scopes_at(range.start) - |> enclosing_scopes(range) - |> narrorwest_scope(range.start) - |> aliases_in_scope() - |> aliases_to_edits() + aliases = CodeMod.Aliases.in_scope(analysis, range) + {insert_position, trailer} = CodeMod.Aliases.insert_position(analysis, range.start) + edits = CodeMod.Aliases.to_edits(aliases, insert_position, trailer) if Enum.empty?(edits) do [] @@ -42,132 +36,10 @@ defmodule Lexical.RemoteControl.CodeAction.Handlers.OrganizeAliases do [:source, :source_organize_imports] end - defp aliases_to_edits([]), do: [] - - defp aliases_to_edits(aliases) do - first_alias_start = first_alias_range(aliases).start - initial_spaces = first_alias_start.character - 1 - - alias_text = - aliases - # get rid of duplicate aliases - |> Enum.uniq_by(& &1.module) - |> Enum.map_join("\n", fn %Alias{} = a -> - text = - if List.last(a.module) == a.as do - "alias #{join(a.module)}" - else - "alias #{join(a.module)}, as: #{join(List.wrap(a.as))}" - end - - indent(text, initial_spaces) - end) - |> String.trim_trailing() - - zeroed_start = %Position{first_alias_start | character: 1} - new_alias_range = Range.new(zeroed_start, zeroed_start) - edits = remove_old_aliases(aliases) - - edits ++ - [Edit.new(alias_text, new_alias_range)] - end - - defp remove_old_aliases(aliases) do - ranges = - aliases - # iterating back to start means we won't have prior edits - # clobber subsequent edits - |> Enum.sort_by(& &1.range.start.line, :desc) - |> Enum.uniq_by(& &1.range) - |> Enum.map(fn %Alias{} = alias -> - orig_range = alias.range - - orig_range - |> put_in([:start, :character], 1) - |> update_in([:end], fn %Position{} = pos -> - %Position{pos | character: 1, line: pos.line + 1} - end) - end) - - first_alias_index = length(ranges) - 1 - - ranges - |> Enum.with_index() - |> Enum.map(fn - {range, ^first_alias_index} -> - # add a new line where the first alias was to make space - # for the rewritten aliases - Edit.new("\n", range) - - {range, _} -> - Edit.new("", range) - end) - end - defp check_aliases(%Document{}, %Analysis{} = analysis, %Range{} = range) do - narroest_scope = - analysis - |> Analysis.scopes_at(range.start) - |> narrorwest_scope(range.start) - - with %Scope{} <- narroest_scope, - false <- Enum.empty?(narroest_scope.aliases) do - :ok - else - _ -> - :error + case Analysis.module_scope(analysis, range) do + %Scope{aliases: [_ | _]} -> :ok + _ -> :error end end - - defp aliases_in_scope(%Scope{} = scope) do - scope.aliases - |> Enum.filter(fn %Alias{} = scope_alias -> - scope_alias.explicit? and Range.contains?(scope.range, scope_alias.range.start) - end) - |> Enum.sort_by(fn %Alias{} = scope_alias -> - Enum.map(scope_alias.module, fn elem -> elem |> Atom.to_string() |> String.downcase() end) - end) - end - - defp aliases_in_scope(_) do - [] - end - - defp enclosing_scopes(scopes, range) do - Enum.filter(scopes, fn scope -> - Range.contains?(scope.range, range.start) - end) - end - - defp first_alias_range(aliases) do - aliases - |> Enum.min_by(fn %Alias{} = a -> - {a.range.start.line, a.range.start.character} - end) - |> Map.get(:range) - end - - defp join(module) do - Enum.join(module, ".") - end - - defp indent(text, spaces) do - String.duplicate(" ", spaces) <> text - end - - defp narrorwest_scope(scope_list, %Position{} = position) do - Enum.reduce(scope_list, nil, fn - scope, nil -> - scope - - %Scope{id: :global}, %Scope{} = current -> - current - - %Scope{} = next_scope, %Scope{} = current_scope -> - Enum.min_by([next_scope, current_scope], fn %Scope{} = scope -> - scope_start = scope.range.start - position.line - scope_start.line - end) - end) - end end diff --git a/apps/remote_control/lib/lexical/remote_control/code_mod/aliases.ex b/apps/remote_control/lib/lexical/remote_control/code_mod/aliases.ex new file mode 100644 index 000000000..ca66122c9 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_mod/aliases.ex @@ -0,0 +1,230 @@ +defmodule Lexical.RemoteControl.CodeMod.Aliases do + alias Lexical.Ast + alias Lexical.Ast.Analysis + alias Lexical.Ast.Analysis.Alias + alias Lexical.Ast.Analysis.Scope + alias Lexical.Document + alias Lexical.Document.Edit + alias Lexical.Document.Position + alias Lexical.Document.Range + alias Sourceror.Zipper + + @doc """ + Returns the aliases that are in scope at the given range. + """ + @spec in_scope(Analysis.t(), Range.t()) :: [Alias.t()] + def in_scope(%Analysis{} = analysis, %Range{} = range) do + analysis + |> Analysis.module_scope(range) + |> aliases_in_scope() + end + + @doc """ + Sorts the given aliases according to our rules + """ + @spec sort(Enumerable.t(Alias.t())) :: [Alias.t()] + def sort(aliases) do + Enum.sort_by(aliases, fn %Alias{} = scope_alias -> + Enum.map(scope_alias.module, fn elem -> elem |> to_string() |> String.downcase() end) + end) + end + + @doc """ + Returns the position in the document where aliases should be inserted + Since a document can have multiple module definitions, the cursor position is used to + determine the initial starting point. + + This function also returns a string that should be appended to the end of the + edits that are performed. + """ + @spec insert_position(Analysis.t(), Position.t()) :: {Position.t(), String.t() | nil} + def insert_position(%Analysis{} = analysis, %Position{} = cursor_position) do + range = Range.new(cursor_position, cursor_position) + current_aliases = in_scope(analysis, range) + do_insert_position(analysis, current_aliases, range) + end + + @doc """ + Turns a list of aliases into aliases into edits + """ + @spec to_edits([Alias.t()], Position.t(), trailer :: String.t() | nil) :: [Edit.t()] + + def to_edits(aliases, position, trailer \\ nil) + def to_edits([], _, _), do: [] + + def to_edits(aliases, %Position{} = insert_position, trailer) do + aliases = sort(aliases) + initial_spaces = insert_position.character - 1 + + alias_text = + aliases + # get rid of duplicate aliases + |> Enum.uniq_by(& &1.module) + |> Enum.map_join("\n", fn %Alias{} = a -> + text = + if List.last(a.module) == a.as do + "alias #{join(a.module)}" + else + "alias #{join(a.module)}, as: #{join(List.wrap(a.as))}" + end + + indent(text, initial_spaces) + end) + |> String.trim_trailing() + + zeroed = put_in(insert_position.character, 1) + new_alias_range = Range.new(zeroed, zeroed) + + alias_text = + if is_binary(trailer) do + alias_text <> trailer + else + alias_text + end + + edits = remove_old_aliases(aliases) + + edits ++ + [Edit.new(alias_text, new_alias_range)] + end + + defp aliases_in_scope(%Scope{} = scope) do + scope.aliases + |> Enum.filter(fn %Alias{} = scope_alias -> + scope_alias.explicit? and Range.contains?(scope.range, scope_alias.range.start) + end) + |> sort() + end + + defp join(module) do + Enum.join(module, ".") + end + + defp indent(text, spaces) do + String.duplicate(" ", spaces) <> text + end + + defp remove_old_aliases(aliases) do + ranges = + aliases + # Reject new aliases that don't have a range + |> Enum.reject(&is_nil(&1.range)) + # iterating back to start means we won't have prior edits + # clobber subsequent edits + |> Enum.sort_by(& &1.range.start.line, :desc) + |> Enum.uniq_by(& &1.range) + |> Enum.map(fn %Alias{} = alias -> + orig_range = alias.range + + orig_range + |> put_in([:start, :character], 1) + |> update_in([:end], fn %Position{} = pos -> + %Position{pos | character: 1, line: pos.line + 1} + end) + end) + + first_alias_index = length(ranges) - 1 + + ranges + |> Enum.with_index() + |> Enum.map(fn + {range, ^first_alias_index} -> + # add a new line where the first alias was to make space + # for the rewritten aliases + Edit.new("\n", range) + + {range, _} -> + Edit.new("", range) + end) + |> merge_adjacent_edits() + end + + defp merge_adjacent_edits([]), do: [] + defp merge_adjacent_edits([_] = edit), do: edit + + defp merge_adjacent_edits([edit | rest]) do + rest + |> Enum.reduce([edit], fn %Edit{} = current, [%Edit{} = last | rest] = edits -> + with {same_text, same_text} <- {last.text, current.text}, + {same, same} <- {to_tuple(current.range.end), to_tuple(last.range.start)} do + collapsed = put_in(current.range.end, last.range.end) + + [collapsed | rest] + else + _ -> + [current | edits] + end + end) + |> Enum.reverse() + end + + defp to_tuple(%Position{} = position) do + {position.line, position.character} + end + + defp do_insert_position(%Analysis{}, [%Alias{} | _] = aliases, _) do + first = Enum.min_by(aliases, &{&1.range.start.line, &1.range.start.character}) + {first.range.start, nil} + end + + defp do_insert_position(%Analysis{} = analysis, _, range) do + case Analysis.module_scope(analysis, range) do + %Scope{id: :global} = scope -> + {scope.range.start, "\n"} + + %Scope{} = scope -> + scope_start = scope.range.start + # we use the end position here because the start position is right after + # the do for modules, which puts it well into the line. The end position + # is before the end, which is equal to the indent of the scope. + initial_position = + scope_start + |> put_in([:line], scope_start.line + 1) + |> put_in([:character], scope.range.end.character + 2) + |> constrain_to_range(scope.range) + + case Ast.zipper_at(analysis.document, scope_start) do + {:ok, zipper} -> + {_, position} = + Zipper.traverse(zipper, initial_position, fn + %Zipper{node: {:@, _, [{:moduledoc, _, _}]}} = zipper, _acc -> + # If we detect a moduledoc node, place the alias after it + range = Sourceror.get_range(zipper.node) + + {zipper, after_node(analysis.document, scope.range, range)} + + zipper, acc -> + {zipper, acc} + end) + + position + + _ -> + initial_position + end + end + end + + defp after_node(%Document{} = document, %Range{} = scope_range, %{ + start: start_pos, + end: end_pos + }) do + document + |> Position.new(end_pos[:line] + 1, start_pos[:column]) + |> constrain_to_range(scope_range) + end + + defp constrain_to_range(%Position{} = position, %Range{} = scope_range) do + cond do + position.line == scope_range.end.line -> + character = min(scope_range.end.character, position.character) + {%Position{position | character: character}, "\n"} + + position.line > scope_range.end.line -> + {%Position{scope_range.end | character: 1}, "\n"} + + true -> + {position, "\n"} + end + end +end diff --git a/apps/remote_control/lib/mix/tasks/namespace/module.ex b/apps/remote_control/lib/mix/tasks/namespace/module.ex index 58fba401e..54833af6d 100644 --- a/apps/remote_control/lib/mix/tasks/namespace/module.ex +++ b/apps/remote_control/lib/mix/tasks/namespace/module.ex @@ -33,6 +33,10 @@ defmodule Mix.Tasks.Namespace.Module do def prefixed?("lx_" <> _), do: true + def prefixed?([?l, ?x, ?_ | _]), do: true + def prefixed?([?E, ?l, ?i, ?x, ?i, ?r, ?., ?L, ?X | _]), do: true + def prefixed?([?L, ?X | _]), do: true + def prefixed?(_), do: false diff --git a/apps/remote_control/test/lexical/remote_control/code_action/handlers/add_alias_test.exs b/apps/remote_control/test/lexical/remote_control/code_action/handlers/add_alias_test.exs new file mode 100644 index 000000000..fb858f228 --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/code_action/handlers/add_alias_test.exs @@ -0,0 +1,302 @@ +defmodule Lexical.RemoteControl.CodeAction.Handlers.AddAliasTest do + alias Lexical.Ast.Analysis.Scope + alias Lexical.CodeUnit + alias Lexical.Completion.SortScope + alias Lexical.Document + alias Lexical.Document.Line + alias Lexical.Document.Range + alias Lexical.RemoteControl + alias Lexical.RemoteControl.CodeAction.Handlers.AddAlias + alias Lexical.RemoteControl.Search.Store + + import Lexical.Test.CursorSupport + import Lexical.Test.CodeSigil + + use Lexical.Test.CodeMod.Case, enable_ast_conversion: false + use Patch + + setup do + start_supervised!({Document.Store, derive: [analysis: &Lexical.Ast.analyze/1]}) + :ok + end + + def apply_code_mod(text, _ast, options) do + range = options[:range] + uri = "file:///file.ex" + :ok = Document.Store.open(uri, text, 1) + {:ok, document} = Document.Store.fetch(uri) + + edits = + case AddAlias.actions(document, range, []) do + [action] -> action.changes.edits + _ -> [] + end + + {:ok, edits} + end + + def add_alias(original_text, modules_to_return) do + {position, stripped_text} = pop_cursor(original_text) + patch_fuzzy_search(modules_to_return) + range = Range.new(position, position) + modify(stripped_text, range: range) + end + + def patch_fuzzy_search(modules_to_return) do + all_modules = + Enum.map(modules_to_return, fn module -> + {Atom.to_charlist(module), :code.which(module), :code.is_loaded(module)} + end) + + patch(AddAlias, :all_modules, all_modules) + end + + describe "in an existing module with no aliases" do + test "aliases are added at the top of the module" do + {:ok, added} = + ~q[ + defmodule MyModule do + def my_fn do + Line| + end + end + ] + |> add_alias([Line]) + + expected = ~q[ + defmodule MyModule do + alias Lexical.Document.Line + def my_fn do + Line + end + end + ]t + assert added =~ expected + end + end + + describe "in an existing module" do + end + + describe "in the root context" do + end + + describe "adding an alias" do + test "does nothing on an invalid document" do + {:ok, added} = add_alias("%Lexical.RemoteControl.Search.", [Lexical.RemoteControl.Search]) + + assert added == "%Lexical.RemoteControl.Search." + end + + test "outside of a module with aliases" do + {:ok, added} = + ~q[ + alias ZZ.XX.YY + Line| + ] + |> add_alias([Line]) + + expected = ~q[ + alias Lexical.Document.Line + alias ZZ.XX.YY + Line + ]t + + assert added == expected + end + + test "when a full module name is given" do + {:ok, added} = + ~q[ + Lexical.RemoteControl.Search.Store.Backend| + ] + |> add_alias([Store.Backend]) + + expected = ~q[ + alias Lexical.RemoteControl.Search.Store.Backend + Backend + ]t + + assert added == expected + end + + test "when a full module name is given in a module function" do + {:ok, added} = + ~q[ + defmodule MyModule do + def my_fun do + result = Lexical.RemoteControl.Search.Store| + end + end + ] + |> add_alias([Store]) + + expected = ~q[ + defmodule MyModule do + alias Lexical.RemoteControl.Search.Store + def my_fun do + result = Store + end + end + ]t + + assert added =~ expected + end + + test "outside of a module with no aliases" do + {:ok, added} = + ~q[Line|] + |> add_alias([Line]) + + expected = ~q[ + alias Lexical.Document.Line + Line + ]t + + assert added == expected + end + + test "in a module with no aliases" do + {:ok, added} = + ~q[ + defmodule MyModule do + def my_fun do + Line| + end + end + ] + |> add_alias([Line]) + + expected = ~q[ + defmodule MyModule do + alias Lexical.Document.Line + def my_fun do + Line + end + end + ]t + + assert added =~ expected + end + + test "outside of functions" do + {:ok, added} = + ~q[ + defmodule MyModule do + alias Something.Else + Line| + end + ] + |> add_alias([Line]) + + expected = ~q[ + defmodule MyModule do + alias Lexical.Document.Line + alias Something.Else + Line + end + ] + + assert expected =~ added + end + + test "inside a function" do + {:ok, added} = + ~q[ + defmodule MyModule do + alias Something.Else + def my_fn do + Line| + end + end + ] + |> add_alias([Line]) + + expected = ~q[ + defmodule MyModule do + alias Lexical.Document.Line + alias Something.Else + def my_fn do + Line + end + end + ] + assert expected =~ added + end + + test "inside a nested module" do + {:ok, added} = + ~q[ + defmodule Parent do + alias Top.Level + defmodule Child do + alias Some.Other + Line| + end + end + ] + |> add_alias([Line]) + + expected = ~q[ + defmodule Parent do + alias Top.Level + defmodule Child do + alias Lexical.Document.Line + alias Some.Other + Line + end + end + ]t + + assert added =~ expected + end + + test "aliases for struct references don't include non-struct modules" do + {:ok, added} = add_alias("%Scope|{}", [SortScope, Scope]) + + expected = ~q[ + alias Lexical.Ast.Analysis.Scope + %Scope + ]t + + assert added =~ expected + end + + test "only modules with a similarly named function will be included in aliases" do + {:ok, added} = add_alias("Document.fetch|", [Document, RemoteControl]) + + expected = ~q[ + alias Lexical.Document + Document.fetch + ]t + + assert added =~ expected + end + + test "protocols are excluded" do + {:ok, added} = add_alias("Co|", [Collectable, CodeUnit]) + expected = ~q[ + alias Lexical.CodeUnit + Co + ]t + + assert added =~ expected + end + + test "protocol implementations are excluded" do + {:ok, added} = + add_alias("Lin|", [Lexical.Document.Lines, Enumerable.Lexical.Document.Lines]) + + expected = ~q[ + alias Lexical.Document.Lines + Lin + ]t + assert added =~ expected + end + + test "erlang modules are excluded" do + {:ok, added} = add_alias(":ets|", [:ets]) + assert added =~ ":ets" + end + end +end diff --git a/apps/remote_control/test/lexical/remote_control/code_mod/aliases_test.exs b/apps/remote_control/test/lexical/remote_control/code_mod/aliases_test.exs new file mode 100644 index 000000000..e02c22391 --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/code_mod/aliases_test.exs @@ -0,0 +1,197 @@ +defmodule Lexical.RemoteControl.CodeMod.AliasesTest do + alias Lexical.Ast + alias Lexical.RemoteControl.CodeMod.Aliases + + use Lexical.Test.CodeMod.Case + import Lexical.Test.CursorSupport + + def insert_position(orig) do + {cursor, document} = pop_cursor(orig, as: :document) + analysis = Ast.analyze(document) + {position, _trailer} = Aliases.insert_position(analysis, cursor) + + {:ok, document, position} + end + + describe "insert_position" do + test "is directly after a module's definition if there are no aliases present" do + {:ok, document, position} = + ~q[ + defmodule MyModule do| + end + ] + |> insert_position() + + assert decorate_cursor(document, position) =~ ~q[ + defmodule MyModule do + |end + ] + end + + test "is after the moduledoc if no aliases are present" do + {:ok, document, position} = + ~q[ + defmodule MyModule do| + @moduledoc """ + This is my funny moduledoc + """ + end + ] + |> insert_position() + + assert decorate_cursor(document, position) =~ ~q[ + defmodule MyModule do + @moduledoc """ + This is my funny moduledoc + """ + |end + ] + end + + test "is before use statements" do + {:ok, document, position} = + ~q[ + defmodule MyModule do| + use Something.That.Exists + end + ] + |> insert_position() + + expected = ~q[ + defmodule MyModule do + |use Something.That.Exists + end + ] + assert decorate_cursor(document, position) =~ expected + end + + test "is before require statements" do + {:ok, document, position} = + ~q[ + defmodule MyModule do| + require Something.That.Exists + end + ] + |> insert_position() + + expected = ~q[ + defmodule MyModule do + |require Something.That.Exists + end + ] + assert decorate_cursor(document, position) =~ expected + end + + test "is before import statements" do + {:ok, document, position} = + ~q[ + defmodule MyModule do| + import Something.That.Exists + end + ] + |> insert_position() + + expected = ~q[ + defmodule MyModule do + |import Something.That.Exists + end + ] + assert decorate_cursor(document, position) =~ expected + end + + test "is where existing aliases are" do + {:ok, document, position} = + ~q[ + defmodule MyModule do| + alias Something.That.Exists + end + ] + |> insert_position() + + expected = ~q[ + defmodule MyModule do + |alias Something.That.Exists + end + ] + assert decorate_cursor(document, position) =~ expected + end + + test "in nested empty modules" do + {:ok, document, position} = + ~q[ + defmodule Outer do + defmodule Inner do| + end + end + ] + |> insert_position() + + expected = ~q[ + defmodule Outer do + defmodule Inner do + |end + end + ]t + + assert decorate_cursor(document, position) =~ expected + end + + test "in nested modules that both have existing aliases" do + {:ok, document, position} = + ~q[ + defmodule Outer do + alias First.Thing + + defmodule Inner do| + alias Second.Person + end + end + ] + |> insert_position() + + expected = ~q[ + defmodule Outer do + alias First.Thing + + defmodule Inner do + |alias Second.Person + end + end + ]t + + assert decorate_cursor(document, position) =~ expected + end + + test "is after moduledocs in nested modules" do + {:ok, document, position} = + ~q[ + defmodule Outer do + alias First.Thing + + defmodule Inner do| + @moduledoc """ + This is my documentation, it + spans multiple lines + """ + end + end + ] + |> insert_position() + + expected = ~q[ + defmodule Outer do + alias First.Thing + + defmodule Inner do + @moduledoc """ + This is my documentation, it + spans multiple lines + """ + |end + end + ]t + + assert decorate_cursor(document, position) =~ expected + end + end +end diff --git a/projects/lexical_test/lib/lexical/test/cursor_support.ex b/projects/lexical_test/lib/lexical/test/cursor_support.ex index ea2e15bd0..7936d843a 100644 --- a/projects/lexical_test/lib/lexical/test/cursor_support.ex +++ b/projects/lexical_test/lib/lexical/test/cursor_support.ex @@ -4,9 +4,12 @@ defmodule Lexical.Test.CursorSupport do """ alias Lexical.Document + alias Lexical.Document.Line alias Lexical.Document.Position alias Lexical.Test.PositionSupport + import Line + @default_cursor "|" @starting_line 1 @starting_column 1 @@ -104,6 +107,24 @@ defmodule Lexical.Test.CursorSupport do IO.iodata_to_binary(iodata) end + def decorate_cursor(%Document{} = document, %Position{} = position) do + replace_line = position.line + + document.lines + |> Enum.map(fn + line(line_number: ^replace_line, text: text, ending: ending) -> + {leading, trailing} = String.split_at(text, position.character - 1) + + leading = String.pad_leading(leading, position.character - 1) + + [leading, "|", trailing, ending] + + line(text: text, ending: ending) -> + [text, ending] + end) + |> IO.iodata_to_binary() + end + defp cursor_position(text, opts) do cursor = Keyword.get(opts, :cursor, @default_cursor) default_to_end? = Keyword.get(opts, :default_to_end, true)