lib/gen_lsp/test.ex

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(),
            lsp_id: atom(),
            buffer: pid(),
            buffer_id: atom(),
            port: integer()
          }

  @typedoc """
  The test client data structure.
  """
  @opaque client :: %{socket: :gen_tcp.socket(), listener: pid()}

  @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_id = Keyword.get(opts, :buffer_id, :buffer)
    lsp_id = Keyword.get(opts, :lsp_id, :lsp)

    buffer =
      start_supervised!({GenLSP.Buffer, communication: {GenLSP.Communication.TCP, [port: 0]}},
        id: buffer_id
      )

    {:ok, port} = :inet.port(GenLSP.Buffer.comm_state(buffer).lsocket)

    lsp = start_supervised!({mod, Keyword.merge([buffer: buffer], opts)}, id: lsp_id)

    %{lsp: lsp, buffer: buffer, port: port, buffer_id: buffer_id, lsp_id: lsp_id}
  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()

    {:ok, listener} =
      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)}")

            other ->
              IO.warn(
                "Ended stream with value: #{inspect(other)}. This was probably due to something going wrong."
              )
          end
        )
        |> Enum.to_list()
      end)

    %{socket: socket, listener: listener}
  end

  @doc """
  Shuts down an LSP server.
  """
  @spec shutdown_server!(server :: server()) :: :ok
  def shutdown_server!(server) do
    stop_supervised!(server.buffer_id)
    stop_supervised!(server.lsp_id)

    :ok
  end

  @doc """
  Shuts down an LSP client.
  """
  @spec shutdown_client!(client :: client()) :: :ok
  def shutdown_client!(client) do
    Process.exit(client.listener, :normal)

    :ok
  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

  @doc ~S"""
  Assert on a request that was sent from the server.

  ## Usage

  ```elixir
  assert_request(client, "client/registerCapability", 1000, fn params ->
    assert params == %{
             "registrations" => [
               %{
                 "id" => "file-watching",
                 "method" => "workspace/didChangeWatchedFiles",
                 "registerOptions" => %{
                   "watchers" => [
                     %{
                       "globPattern" => "{lib|test}/**/*.{ex|exs|heex|eex|leex|surface}"
                     }
                   ]
                 }
               }
             ]
           }

    nil
  end)
  ```
  """
  defmacro assert_request(
             client,
             method,
             timeout \\ Application.get_env(:ex_unit, :assert_receive_timeout),
             callback
           ) do
    quote do
      assert_receive %{
                       "jsonrpc" => "2.0",
                       "id" => id,
                       "method" => unquote(method),
                       "params" => params
                     },
                     unquote(timeout)

      result = unquote(callback).(params)

      GenLSP.Communication.TCP.write(
        Jason.encode!(%{jsonrpc: "2.0", id: id, result: result}),
        unquote(client)
      )
    end
  end

  defp connect(port, start_time) do
    now = System.monotonic_time(:millisecond)

    case :gen_tcp.connect(~c"localhost", port, @connect_opts) do
      {:error, :econnrefused} when now - start_time > 5000 ->
        connect(port, start_time)

      other ->
        other
    end
  end
end