Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(elixir): compiler diagnostics #8

Merged
merged 1 commit into from
Jun 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 67 additions & 15 deletions lib/next_ls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,40 @@ defmodule NextLS do
TextDocumentSyncOptions
}

alias NextLS.Runtime
alias NextLS.DiagnosticCache

def start_link(args) do
{args, opts} = Keyword.split(args, [:task_supervisor, :runtime_supervisor])
{args, opts} =
Keyword.split(args, [
:cache,
:task_supervisor,
:dynamic_supervisor,
:extensions,
:extension_registry
])

GenLSP.start_link(__MODULE__, args, opts)
end

@impl true
def init(lsp, args) do
task_supervisor = Keyword.fetch!(args, :task_supervisor)
runtime_supervisor = Keyword.fetch!(args, :runtime_supervisor)
dynamic_supervisor = Keyword.fetch!(args, :dynamic_supervisor)
extension_registry = Keyword.fetch!(args, :extension_registry)
extensions = Keyword.get(args, :extensions, [NextLS.ElixirExtension])
cache = Keyword.fetch!(args, :cache)

{:ok,
assign(lsp,
exit_code: 1,
documents: %{},
refresh_refs: %{},
cache: cache,
task_supervisor: task_supervisor,
runtime_supervisor: runtime_supervisor,
dynamic_supervisor: dynamic_supervisor,
extension_registry: extension_registry,
extensions: extensions,
runtime_task: nil,
ready: false
)}
Expand Down Expand Up @@ -90,12 +106,20 @@ defmodule NextLS do

working_dir = URI.parse(lsp.assigns.root_uri).path

for extension <- lsp.assigns.extensions do
{:ok, _} =
DynamicSupervisor.start_child(
lsp.assigns.dynamic_supervisor,
{extension, cache: lsp.assigns.cache, registry: lsp.assigns.extension_registry, publisher: self()}
)
end

GenLSP.log(lsp, "[NextLS] Booting runime...")

{:ok, runtime} =
DynamicSupervisor.start_child(
lsp.assigns.runtime_supervisor,
{NextLS.Runtime, working_dir: working_dir, parent: self()}
lsp.assigns.dynamic_supervisor,
{NextLS.Runtime, extension_registry: lsp.assigns.extension_registry, working_dir: working_dir, parent: self()}
)

Process.monitor(runtime)
Expand All @@ -117,7 +141,7 @@ defmodule NextLS do
:ready
end)

{:noreply, assign(lsp, runtime_task: task)}
{:noreply, assign(lsp, refresh_refs: Map.put(lsp.assigns.refresh_refs, task.ref, task.ref), runtime_task: task)}
end

def handle_notification(%TextDocumentDidSave{}, %{assigns: %{ready: false}} = lsp) do
Expand All @@ -133,7 +157,15 @@ defmodule NextLS do
},
%{assigns: %{ready: true}} = lsp
) do
{:noreply, lsp |> then(&put_in(&1.assigns.documents[uri], String.split(text, "\n")))}
task =
Task.Supervisor.async_nolink(lsp.assigns.task_supervisor, fn ->
Runtime.compile(lsp.assigns.runtime)
end)

{:noreply,
lsp
|> then(&put_in(&1.assigns.documents[uri], String.split(text, "\n")))
|> then(&put_in(&1.assigns.refresh_refs[task.ref], task.ref))}
end

def handle_notification(%TextDocumentDidChange{}, %{assigns: %{ready: false}} = lsp) do
Expand All @@ -142,7 +174,7 @@ defmodule NextLS do

def handle_notification(%TextDocumentDidChange{}, lsp) do
for task <- Task.Supervisor.children(lsp.assigns.task_supervisor),
task != lsp.assigns.runtime_task do
task != lsp.assigns.runtime_task.pid do
Process.exit(task, :kill)
end

Expand Down Expand Up @@ -170,6 +202,24 @@ defmodule NextLS do
{:noreply, lsp}
end

def handle_info(:publish, lsp) do
all =
for {_namespace, cache} <- DiagnosticCache.get(lsp.assigns.cache), {file, diagnostics} <- cache, reduce: %{} do
d -> Map.update(d, file, diagnostics, fn value -> value ++ diagnostics end)
end

for {file, diagnostics} <- all do
GenLSP.notify(lsp, %GenLSP.Notifications.TextDocumentPublishDiagnostics{
params: %GenLSP.Structures.PublishDiagnosticsParams{
uri: "file://#{file}",
diagnostics: diagnostics
}
})
end

{:noreply, lsp}
end

def handle_info({ref, resp}, %{assigns: %{refresh_refs: refs}} = lsp)
when is_map_key(refs, ref) do
Process.demonitor(ref, [:flush])
Expand All @@ -178,19 +228,21 @@ defmodule NextLS do
lsp =
case resp do
:ready ->
assign(lsp, ready: true)
task =
Task.Supervisor.async_nolink(lsp.assigns.task_supervisor, fn ->
Runtime.compile(lsp.assigns.runtime)
end)

assign(lsp, ready: true, refresh_refs: Map.put(refs, task.ref, task.ref))

_ ->
lsp
assign(lsp, refresh_refs: refs)
end

{:noreply, assign(lsp, refresh_refs: refs)}
{:noreply, lsp}
end

def handle_info(
{:DOWN, ref, :process, _pid, _reason},
%{assigns: %{refresh_refs: refs}} = lsp
)
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{assigns: %{refresh_refs: refs}} = lsp)
when is_map_key(refs, ref) do
{_token, refs} = Map.pop(refs, ref)

Expand Down
35 changes: 35 additions & 0 deletions lib/next_ls/diagnostic_cache.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule NextLS.DiagnosticCache do
# TODO: this should be an ETS table
@moduledoc """
Cache for diagnostics.
"""
use Agent

def start_link(opts) do
Agent.start_link(fn -> Map.new() end, Keyword.take(opts, [:name]))
end

def get(cache) do
Agent.get(cache, & &1)
end

def put(cache, namespace, filename, diagnostic) do
Agent.update(cache, fn cache ->
Map.update(cache, namespace, %{filename => [diagnostic]}, fn cache ->
Map.update(cache, filename, [diagnostic], fn v ->
[diagnostic | v]
end)
end)
end)
end

def clear(cache, namespace) do
Agent.update(cache, fn cache ->
Map.update(cache, namespace, %{}, fn cache ->
for {k, _} <- cache, into: Map.new() do
{k, []}
end
end)
end)
end
end
94 changes: 94 additions & 0 deletions lib/next_ls/extensions/elixir_extension.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
defmodule NextLS.ElixirExtension do
use GenServer

alias NextLS.DiagnosticCache

def start_link(args) do
GenServer.start_link(
__MODULE__,
Keyword.take(args, [:cache, :registry, :publisher]),
Keyword.take(args, [:name])
)
end

@impl GenServer
def init(args) do
cache = Keyword.fetch!(args, :cache)
registry = Keyword.fetch!(args, :registry)
publisher = Keyword.fetch!(args, :publisher)

Registry.register(registry, :extension, :elixir)

{:ok, %{cache: cache, registry: registry, publisher: publisher}}
end

@impl GenServer
def handle_info({:compiler, diagnostics}, state) do
DiagnosticCache.clear(state.cache, :elixir)

for d <- diagnostics do
# TODO: some compiler diagnostics only have the line number
# but we want to only highlight the source code, so we
# need to read the text of the file (either from the lsp cache
# if the source code is "open", or read from disk) and calculate the
# column of the first non-whitespace character.
#
# it is not clear to me whether the LSP process or the extension should
# be responsible for this. The open documents live in the LSP process
DiagnosticCache.put(state.cache, :elixir, d.file, %GenLSP.Structures.Diagnostic{
severity: severity(d.severity),
message: d.message,
source: d.compiler_name,
range: range(d.position)
})
end

send(state.publisher, :publish)

{:noreply, state}
end

defp severity(:error), do: GenLSP.Enumerations.DiagnosticSeverity.error()
defp severity(:warning), do: GenLSP.Enumerations.DiagnosticSeverity.warning()
defp severity(:info), do: GenLSP.Enumerations.DiagnosticSeverity.information()
defp severity(:hint), do: GenLSP.Enumerations.DiagnosticSeverity.hint()

defp range({start_line, start_col, end_line, end_col}) do
%GenLSP.Structures.Range{
start: %GenLSP.Structures.Position{
line: start_line - 1,
character: start_col
},
end: %GenLSP.Structures.Position{
line: end_line - 1,
character: end_col
}
}
end

defp range({line, col}) do
%GenLSP.Structures.Range{
start: %GenLSP.Structures.Position{
line: line - 1,
character: col
},
end: %GenLSP.Structures.Position{
line: line - 1,
character: 999
}
}
end

defp range(line) do
%GenLSP.Structures.Range{
start: %GenLSP.Structures.Position{
line: line - 1,
character: 0
},
end: %GenLSP.Structures.Position{
line: line - 1,
character: 999
}
}
end
end
10 changes: 8 additions & 2 deletions lib/next_ls/lsp_supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,16 @@ defmodule NextLS.LSPSupervisor do
end

children = [
{DynamicSupervisor, name: NextLS.RuntimeSupervisor},
{DynamicSupervisor, name: NextLS.DynamicSupervisor},
{Task.Supervisor, name: NextLS.TaskSupervisor},
{GenLSP.Buffer, buffer_opts},
{NextLS, task_supervisor: NextLS.TaskSupervisor, runtime_supervisor: NextLS.RuntimeSupervisor}
{NextLS.DiagnosticCache, [name: :diagnostic_cache]},
{Registry, name: NextLS.ExtensionRegistry, keys: :duplicate},
{NextLS,
cache: :diagnostic_cache,
task_supervisor: NextLS.TaskSupervisor,
dynamic_supervisor: NextLS.DynamicSupervisor,
extension_registry: NextLS.ExtensionRegistry}
]

Supervisor.init(children, strategy: :one_for_one)
Expand Down
29 changes: 20 additions & 9 deletions lib/next_ls/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@ defmodule NextLS.Runtime do
end
end

def compile(server) do
GenServer.call(server, :compile)
end

@impl GenServer
def init(opts) do
sname = "nextls#{System.system_time()}"
working_dir = Keyword.fetch!(opts, :working_dir)
parent = Keyword.fetch!(opts, :parent)
extension_registry = Keyword.fetch!(opts, :extension_registry)

port =
Port.open(
Expand Down Expand Up @@ -80,21 +85,13 @@ defmodule NextLS.Runtime do
|> Path.join("monkey/_next_ls_private_compiler.ex")
|> then(&:rpc.call(node, Code, :compile_file, [&1]))

:ok =
:rpc.call(
node,
:_next_ls_private_compiler,
:compile,
[]
)

send(me, {:node, node})
else
_ -> send(me, :cancel)
end
end)

{:ok, %{port: port, parent: parent}}
{:ok, %{port: port, parent: parent, errors: nil, extension_registry: extension_registry}}
end

@impl GenServer
Expand All @@ -111,6 +108,20 @@ defmodule NextLS.Runtime do
{:reply, reply, state}
end

def handle_call(:compile, _, %{node: node} = state) do
{_, errors} = :rpc.call(node, :_next_ls_private_compiler, :compile, [])

foo = "foo"

Registry.dispatch(state.extension_registry, :extension, fn entries ->
for {pid, _} <- entries do
send(pid, {:compiler, errors})
end
end)

{:reply, errors, %{state | errors: errors}}
end

@impl GenServer
def handle_info({:node, node}, state) do
Node.monitor(node, true)
Expand Down
6 changes: 3 additions & 3 deletions priv/monkey/_next_ls_private_compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule :_next_ls_private_compiler do
# keep stdout on this node
Process.group_leader(self(), Process.whereis(:user))

Mix.Task.clear()

# load the paths for deps and compile them
# will noop if they are already compiled
# The mix cli basically runs this before any mix task
Expand All @@ -13,9 +15,7 @@ defmodule :_next_ls_private_compiler do
# --no-compile, so nothing was compiled, but the
# task was not re-enabled it seems
Mix.Task.rerun("deps.loadpaths")
Mix.Task.rerun("compile")

:ok
Mix.Task.rerun("compile", ["--no-protocol-consolidation", "--return-errors"])
rescue
e -> {:error, e}
end
Expand Down
Loading