defmodule GenLSP.Test do
@moduledoc """
Conveniences for testing GenLSP processes.
"""
import ExUnit.Callbacks
import ExUnit.Assertions
@connect_opts [:binary, packet: :raw, active: false]
@typedoc """
The test server data structure.
"""
@opaque server :: %{lsp: pid(), buffer: pid(), port: integer()}
@typedoc """
The test client data structure.
"""
@opaque client :: %{socket: :gen_tcp.socket()}
@doc """
Starts a new server.
## Usage
```elixir
import GenLSP.Test
server = server(MyLSP, some_arg: some_arg)
```
"""
@spec server(mod :: atom()) :: server()
def server(mod, opts \\ []) do
buffer =
start_supervised!({GenLSP.Buffer, communication: {GenLSP.Communication.TCP, [port: 0]}})
{:ok, port} = :inet.port(GenLSP.Buffer.comm_state(buffer).lsocket)
lsp = start_supervised!({mod, Keyword.merge([buffer: buffer], opts)})
%{lsp: lsp, buffer: buffer, port: port}
end
@doc """
Starts a new LSP client for the given server.
The "client" is equivalent to a text editor.
## Usage
```elixir
import GenLSP.Test
server = server(MyLSP, some_arg: some_arg)
client = client(server)
```
"""
@spec client(server()) :: client()
def client(server) do
start_time = System.monotonic_time(:millisecond)
{:ok, socket} = connect(server.port, start_time)
me = self()
Task.start_link(fn ->
Stream.resource(
fn -> "" end,
fn buffer ->
case GenLSP.Communication.TCP.read(%{socket: socket}, buffer) do
:eof ->
{:halt, :ok}
{:ok, body, buffer} ->
send(me, Jason.decode!(body))
{[body], buffer}
end
end,
fn
:ok ->
:ok
{:error, reason} ->
IO.warn("Unable to read from device: #{inspect(reason)}")
end
)
|> Enum.to_list()
end)
%{socket: socket}
end
@doc ~S"""
Send a request from the client to the server.
The response from the server will be sent as a message to the current process and can be asserted on using `GenLSP.Test.assert_result/3`.
## Usage
```elixir
import GenLSP.Test
request(client, %{
method: "initialize",
id: 1,
jsonrpc: "2.0",
params: %{capabilities: %{}, rootUri: "file://#{root_path}"}
})
```
"""
@spec request(client(), Jason.Encoder.t()) ::
{:ok, any()} | {:error, :closed | {:timeout, binary()} | :inet.posix()}
def request(%{socket: socket}, body) do
GenLSP.Communication.TCP.write(Jason.encode!(body), %{socket: socket})
end
@doc """
Send a notification from the client to the server.
## Usage
```elixir
import GenLSP.Test
notify(client, %{
method: "initialized",
jsonrpc: "2.0",
params: %{}
})
```
"""
@spec notify(client(), Jason.Encoder.t()) :: :ok
def notify(%{socket: socket}, body) do
GenLSP.Communication.TCP.write(Jason.encode!(body), %{socket: socket})
end
@doc """
Simple helper to determine whether the LSP process is alive.
"""
@spec alive?(server()) :: boolean()
def alive?(%{lsp: lsp}) do
Process.alive?(lsp)
end
@doc ~S"""
Assert on the successful response of a request that was sent with `GenLSP.Test.request/2`.
The second argument is a pattern, similar to `ExUnit.Assertions.assert_receive/3`.
## Usage
```elixir
import GenLSP.Test
id = 1
request(client, %{
method: "initialize",
id: id,
jsonrpc: "2.0",
params: %{capabilities: %{}, rootUri: "file://#{root_path}"}
})
assert_result(^id, %{
"capabilities" => %{
"textDocumentSync" => %{
"openClose" => true,
"save" => %{
"includeText" => true
},
"change" => 1
}
},
"serverInfo" => %{"name" => "Credo"}
})
```
"""
defmacro assert_result(
id,
pattern,
timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout)
) do
quote do
assert_receive %{
"jsonrpc" => "2.0",
"id" => unquote(id),
"result" => unquote(pattern)
},
unquote(timeout)
end
end
@doc ~S"""
Assert on the error response of a request that was sent with `GenLSP.Test.request/2`.
The second argument is a pattern, similar to `ExUnit.Assertions.assert_receive/3`.
## Usage
```elixir
import GenLSP.Test
id = 3
request(client, %{
method: "textDocument/documentSymbol",
id: id,
jsonrpc: "2.0",
params: %{
textDocument: %{
uri: "file://file/doesnt/matter.ex"
}
}
})
assert_error(^id, %{
"code" => -32601,
"message" => "Method Not Found"
})
```
"""
defmacro assert_error(
id,
pattern,
timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout)
) do
quote do
assert_receive %{
"jsonrpc" => "2.0",
"id" => unquote(id),
"error" => unquote(pattern)
},
unquote(timeout)
end
end
@doc ~S"""
Assert on a notification that was sent from the server.
The second argument is a pattern, similar to `ExUnit.Assertions.assert_receive/3`.
## Usage
```elixir
import GenLSP.Test
notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}})
assert_notification("window/logMessage", %{
"message" => "[MyLSP] LSP Initialized!",
"type" => 4
})
```
"""
defmacro assert_notification(
method,
pattern,
timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout)
) do
quote do
assert_receive %{
"jsonrpc" => "2.0",
"method" => unquote(method),
"params" => unquote(pattern)
},
unquote(timeout)
end
end
defp connect(port, start_time) do
now = System.monotonic_time(:millisecond)
case :gen_tcp.connect('localhost', port, @connect_opts) do
{:error, :econnrefused} when now - start_time > 5000 ->
connect(port, start_time)
other ->
other
end
end
end