Skip to content

Commit

Permalink
feat: server to client requests
Browse files Browse the repository at this point in the history
Fixes #34
  • Loading branch information
mhanberg committed Jul 13, 2023
1 parent b280433 commit a8b85a6
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 17 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ GenLSP is an OTP behaviour for building processes that implement the [Language S
<details>
<summary><a href="https://github.com/rrrene/credo">Credo</a> language server.</summary>

```elixir
<pre>
defmodule Credo.Lsp do
@moduledoc """
LSP implementation for Credo.
Expand Down Expand Up @@ -202,7 +202,7 @@ defmodule Credo.Lsp.Cache do
def category_to_severity(:consistency), do: 4
def category_to_severity(:readability), do: 4
end
```
</pre>

</details>

Expand Down
25 changes: 21 additions & 4 deletions lib/gen_lsp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -190,25 +190,42 @@ defmodule GenLSP do
send(pid, {:notification, from, notification})
end

@doc """
@doc ~S'''
Sends a notification to the client from the LSP process.
## Usage
```elixir
GenLSP.notify(lsp, %TextDocumentPublishDiagnostics{
params: %PublishDiagnosticsParams{
uri: "file://#\{file}",
uri: "file://#{file}",
diagnostics: diagnostics
}
})
```
"""
'''
@spec notify(GenLSP.LSP.t(), notification :: any()) :: :ok
def notify(%{buffer: buffer}, notification) do
GenLSP.Buffer.outgoing(buffer, dump!(notification.__struct__.schematic(), notification))
end

@doc ~S'''
Sends a request to the client from the LSP process.
## Usage
```elixir
GenLSP.request(lsp, %ClientRegisterCapability{
id: System.unique_integer([:positive]),
params: params
})
```
'''
@spec request(GenLSP.LSP.t(), request :: any()) :: any()
def request(%{buffer: buffer}, request) do
GenLSP.Buffer.outgoing_sync(buffer, dump!(request.__struct__.schematic(), request))
end

defp write_debug(device, event, name) do
IO.write(device, "#{inspect(name)} event = #{inspect(event)}")
end
Expand Down Expand Up @@ -287,7 +304,7 @@ defmodule GenLSP do
end
end

@spec attempt(LSP.t(), String.t(), (() -> any())) :: no_return()
@spec attempt(LSP.t(), String.t(), (-> any())) :: no_return()
defp attempt(lsp, message, callback) do
callback.()
rescue
Expand Down
36 changes: 28 additions & 8 deletions lib/gen_lsp/buffer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ defmodule GenLSP.Buffer do
GenServer.cast(server, {:outgoing, packet})
end

@doc false
def outgoing_sync(server, packet) do
GenServer.call(server, {:outgoing_sync, packet})
end

@doc false
def comm_state(server) do
GenServer.call(server, :comm_state)
Expand All @@ -52,23 +57,38 @@ defmodule GenLSP.Buffer do
{comm, comm_args} = opts[:communication]
{:ok, comm_data} = comm.init(comm_args)

{:ok, %{comm: comm, comm_data: comm_data}}
{:ok, %{comm: comm, comm_data: comm_data, awaiting_response: Map.new()}}
end

@doc false
def handle_call(:comm_state, _from, %{comm_data: comm_data} = state) do
{:reply, comm_data, state}
end

def handle_call({:outgoing_sync, %{"id" => id} = packet}, from, state) do
:ok = state.comm.write(Jason.encode!(packet), state.comm_data)

{:noreply, %{state | awaiting_response: Map.put(state.awaiting_response, id, from)}}
end

@doc false
def handle_cast({:incoming, packet}, %{lsp: lsp} = state) do
case Jason.decode!(packet) do
%{"id" => _} = request ->
GenLSP.request_server(lsp, request)

notification ->
GenLSP.notify_server(lsp, notification)
end
state =
case Jason.decode!(packet) do
%{"id" => id, "result" => result} when is_map_key(state.awaiting_response, id) ->
{from, awaiting_response} = Map.pop(state.awaiting_response, id)
GenServer.reply(from, result)

%{state | awaiting_response: awaiting_response}

%{"id" => _} = request ->
GenLSP.request_server(lsp, request)
state

notification ->
GenLSP.notify_server(lsp, notification)
state
end

{:noreply, state}
end
Expand Down
19 changes: 18 additions & 1 deletion lib/gen_lsp/protocol/type_aliases/lsp_any.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ defmodule GenLSP.TypeAlias.LSPAny do
@doc false
@spec schematic() :: Schematic.t()

Check warning on line 17 in lib/gen_lsp/protocol/type_aliases/lsp_any.ex

View workflow job for this annotation

GitHub Actions / dialyzer

contract_with_opaque

The @SPEC for schematic has an opaque subtype which is violated by the success typing.
def schematic() do
any()
%Schematic{
kind: "lspany",
unify: fn x, dir ->
case x do
%mod{} ->
Code.ensure_loaded(mod)

if function_exported?(mod, :schematic, 0) do
mod.schematic().unify.(x, dir)
else
{:ok, x}
end

_ ->
{:ok, x}
end
end
}
end
end
32 changes: 32 additions & 0 deletions lib/gen_lsp/test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,38 @@ defmodule GenLSP.Test do
end
end

@doc ~S"""
Assert on a request that was sent from the server.
## Usage
```elixir
```
"""
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)

Expand Down
2 changes: 1 addition & 1 deletion test/gen_lsp/communication/stdio_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ end
Main.run()'"

test "can read and write through stdio" do
port = Port.open({:spawn, @command}, [:binary, env: [{'MIX_ENV', 'test'}]])
port = Port.open({:spawn, @command}, [:binary, env: [{~c"MIX_ENV", ~c"test"}]])

expected_message = "Content-Length: #{@length}\r\n\r\n#{@string}"

Expand Down
2 changes: 1 addition & 1 deletion test/gen_lsp/communication/tcp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ defmodule GenLSP.Communication.TCPTest do
defp connect(start_time) do
now = System.monotonic_time(:millisecond)

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

Expand Down
43 changes: 43 additions & 0 deletions test/gen_lsp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ defmodule GenLSPTest do
500
end

test "can send a request from the server to the client", %{client: client} do
id = System.unique_integer([:positive])

assert :ok ==
request(client, %{
"jsonrpc" => "2.0",
"method" => "initialize",
"params" => %{"capabilities" => %{}},
"id" => id
})

assert_result ^id, %{"capabilities" => %{}, "serverInfo" => %{"name" => "Test LSP"}}, 500

assert :ok ==
notify(client, %{
method: "initialized",
jsonrpc: "2.0",
params: %{}
})

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)

assert_notification "window/logMessage", %{"message" => "done initializing"}, 500
end

test "the server can receive a notification", %{client: client} do
assert :ok ==
notify(client, %{
Expand Down
25 changes: 25 additions & 0 deletions test/support/example_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ defmodule GenLSPTest.ExampleServer do
}, lsp}
end

def handle_notification(%Notifications.Initialized{}, lsp) do
GenLSP.request(lsp, %GenLSP.Requests.ClientRegisterCapability{
id: System.unique_integer([:positive]),
params: %GenLSP.Structures.RegistrationParams{
registrations: [
%GenLSP.Structures.Registration{
id: "file-watching",
method: "workspace/didChangeWatchedFiles",
register_options: %GenLSP.Structures.DidChangeWatchedFilesRegistrationOptions{
watchers: [
%GenLSP.Structures.FileSystemWatcher{
glob_pattern: "{lib|test}/**/*.{ex|exs|heex|eex|leex|surface}"
}
]
}
}
]
}
})

GenLSP.log(lsp, "done initializing")

{:noreply, lsp}
end

@impl true
def handle_notification(%Notifications.TextDocumentDidOpen{} = notification, lsp) do
send(lsp.assigns.test_pid, {:callback, notification})
Expand Down

0 comments on commit a8b85a6

Please sign in to comment.