Skip to content

Commit

Permalink
Code Action: Add alias (#740)
Browse files Browse the repository at this point in the history
* Code Action: Add alias

When coding, it is more convenient to type what you mean, and then
come back later and clean up things like aliases. This change
implements a code action that understands your current editing context
and adds aliases as you type. It finds suggestions using our fuzzy
matcher (though it has a bit stricter fuzziness), then provides code
actions for each suggestion.

It will fix the following alias types:

  `Foo.Bar.Baz|` will result in aliases for modules similar to `Baz`
  `%Foo.Bar.Baz|{}` will result in aliases similar to `Baz`, but only
those that have structs defined in them
  `Foo.Bar.baz.function|` will result in aliases for modules with a
  function with a name similar to `function`

This PR also pulled a bunch of common code out of `orgaize_aliases`
and moved it to a code mod file, which resulted in `organize_aliases`
having very little code left in it.

Fixes #716


We couldn't use the existing fuzzy matcher, which excludes dependencies,
which meant that you couldn't add an alias to a dependency's
modules. Instead, we now build a new fuzzy matcher with all available
modules, and match against that.
  • Loading branch information
scohen authored May 23, 2024
1 parent b6a6a47 commit 00765e1
Show file tree
Hide file tree
Showing 10 changed files with 1,004 additions and 137 deletions.
30 changes: 29 additions & 1 deletion apps/common/lib/lexical/ast/analysis.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions apps/common/lib/lexical/ast/analysis/scope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 00765e1

Please sign in to comment.