Skip to content

Commit

Permalink
Support resolving Controller or LiveView modules within the `Rout…
Browse files Browse the repository at this point in the history
…er`'s scope block (lexical-lsp#659)

When the module under the cursor is a Phoenix` LiveView` module or `Controller` module.
  • Loading branch information
scottming authored Apr 6, 2024
1 parent 07a132a commit f8ef46b
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 5 deletions.
19 changes: 19 additions & 0 deletions apps/common/lib/lexical/ast/module.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,23 @@ defmodule Lexical.Ast.Module do
|> inspect()
|> name()
end

@doc """
local module name is the last part of a module name
## Examples:
iex> local_name('Lexical.Ast.Module')
"Module"
"""
def local_name(entity) when is_list(entity) do
entity
|> to_string()
|> local_name()
end

def local_name(entity) when is_binary(entity) do
entity
|> String.split(".")
|> List.last()
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ defmodule Lexical.RemoteControl.CodeIntelligence.Entity do
alias Lexical.Document
alias Lexical.Document.Position
alias Lexical.Document.Range
alias Lexical.Formats
alias Lexical.RemoteControl
alias Sourceror.Zipper

require Logger
require Sourceror.Identifier
Expand Down Expand Up @@ -182,10 +184,13 @@ defmodule Lexical.RemoteControl.CodeIntelligence.Entity do

# Modules on a single line, e.g. "Foo.Bar.Baz"
defp resolve_module(charlist, {{line, column}, {line, _}}, analysis, %Position{} = position) do
module_string = module_before_position(charlist, column, position)
module_before_cursor = module_before_position(charlist, column, position)

with {:ok, module} <- expand_alias(module_string, analysis, position) do
end_column = column + String.length(module_string)
maybe_prepended =
maybe_prepend_phoenix_scope_module(module_before_cursor, analysis, position)

with {:ok, module} <- expand_alias(maybe_prepended, analysis, position) do
end_column = column + String.length(module_before_cursor)
{:ok, {:module, module}, {{line, column}, {line, end_column}}}
end
end
Expand All @@ -199,6 +204,57 @@ defmodule Lexical.RemoteControl.CodeIntelligence.Entity do
end
end

defp maybe_prepend_phoenix_scope_module(module_string, analysis, position) do
with {:ok, scope_segments} <- fetch_phoenix_scope_alias_segments(analysis, position),
{:ok, scope_module} <-
RemoteControl.Analyzer.expand_alias(scope_segments, analysis, position),
cursor_module = Module.concat(scope_module, module_string),
true <-
phoenix_controller_module?(cursor_module) or phoenix_liveview_module?(cursor_module) do
Formats.module(cursor_module)
else
_ ->
module_string
end
end

defp fetch_phoenix_scope_alias_segments(analysis, position) do
# fetch the alias segments from the `scope` macro
# e.g. `scope "/foo", FooWeb.Controllers`
# the alias module is `FooWeb.Controllers`, and the segments is `[:FooWeb, :Controllers]`
path =
analysis
|> Ast.cursor_path(position)
|> Enum.filter(&match?({:scope, _, [_ | _]}, &1))
# There might be nested `scope` macros, we need the immediate ancestor
|> List.last()

if path do
{_, paths} =
path
|> Zipper.zip()
|> Zipper.traverse([], fn
%Zipper{node: {:scope, _, [_, {:__aliases__, _, segments} | _]}} = zipper, acc ->
{zipper, [segments | acc]}

zipper, acc ->
{zipper, acc}
end)

{:ok, paths |> Enum.reverse() |> List.flatten()}
else
:error
end
end

defp phoenix_controller_module?(module) do
function_exists?(module, :call, 2) and function_exists?(module, :action, 2)
end

defp phoenix_liveview_module?(module) do
function_exists?(module, :mount, 3) and function_exists?(module, :render, 1)
end

# Take only the segments at and before the cursor, e.g.
# Foo|.Bar.Baz -> Foo
# Foo.|Bar.Baz -> Foo.Bar
Expand Down Expand Up @@ -397,4 +453,9 @@ defmodule Lexical.RemoteControl.CodeIntelligence.Entity do
_ -> :error
end
end

defp function_exists?(module, function, arity) do
# Wrap the `function_exported?` from `Kernel` to simplify testing.
function_exported?(module, function, arity)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ defmodule Lexical.RemoteControl.CodeIntelligence.EntityTest do
import Lexical.Test.Fixtures
import Lexical.Test.RangeSupport

use ExUnit.Case, async: true
use ExUnit.Case
use Patch

describe "module resolve/2" do
test "succeeds with trailing period" do
Expand Down Expand Up @@ -189,7 +190,92 @@ defmodule Lexical.RemoteControl.CodeIntelligence.EntityTest do

test "fails for plain old atoms" do
code = ~q[:not_a_module|]
assert {:error, {:unsupported, {:unquoted_atom, 'not_a_module'}}} = resolve(code)
assert {:error, {:unsupported, {:unquoted_atom, ~c"not_a_module"}}} = resolve(code)
end
end

describe "controller module resolve/2 in the phoenix router" do
setup do
patch(Entity, :function_exists?, fn
FooWeb.FooController, :call, 2 -> true
FooWeb.FooController, :action, 2 -> true
end)

:ok
end

test "succeeds in the `get` block" do
code = ~q[
scope "/foo", FooWeb do
get "/foo", |FooController, :index
end
]

assert {:ok, {:module, FooWeb.FooController}, resolved_range} = resolve(code)
assert resolved_range =~ ~S[get "/foo", «FooController», :index]
end

test "succeeds in the `post` block" do
code = ~q[
scope "/foo", FooWeb do
post "/foo", |FooController, :create
end
]

assert {:ok, {:module, FooWeb.FooController}, resolved_range} = resolve(code)
assert resolved_range =~ ~S[post "/foo", «FooController», :create]
end

test "succeeds even the scope module has multiple dots" do
patch(Entity, :function_exists?, fn
FooWeb.Bar.FooController, :call, 2 -> true
FooWeb.Bar.FooController, :action, 2 -> true
end)

code = ~q[
scope "/foo", FooWeb.Bar do
get "/foo", |FooController, :index
end
]

assert {:ok, {:module, FooWeb.Bar.FooController}, resolved_range} = resolve(code)
assert resolved_range =~ ~S[get "/foo", «FooController», :index]
end

test "succeeds in the nested scopes" do
patch(Entity, :function_exists?, fn
FooWeb.Bar.FooController, :call, 2 -> true
FooWeb.Bar.FooController, :action, 2 -> true
end)

code = ~q[
scope "/", FooWeb do
scope "/bar", Bar do
get "/foo", |FooController, :index
end
end
]

assert {:ok, {:module, FooWeb.Bar.FooController}, resolved_range} = resolve(code)
assert resolved_range =~ ~S[get "/foo", «FooController», :index]
end
end

describe "liveview module resolve in the router" do
test "succeeds in the `live` block" do
patch(Entity, :function_exists?, fn
FooWeb.FooLive, :mount, 2 -> true
FooWeb.FooLive, :render, 1 -> true
end)

code = ~q[
scope "/foo", FooWeb do
live "/foo", |FooLive
end
]

assert {:ok, {:module, FooLive}, resolved_range} = resolve(code)
assert resolved_range =~ ~S[live "/foo", «FooLive»]
end
end

Expand Down

0 comments on commit f8ef46b

Please sign in to comment.