Skip to content

Commit fafb2ca

Browse files
authored
feat(elixir): compiler diagnostics (#8)
Introduce the "extension" concept and creates the ElixirExtension, which provides Elixir compiler diagnostics. TODO: Correct the column information on the diagnostics. I think there are some commits on main that fix some of this. But there are Tokenizer diagnostics that need some massaging as well. The interface for extensions also needs to to be finalized.
1 parent aabdda0 commit fafb2ca

File tree

10 files changed

+412
-97
lines changed

10 files changed

+412
-97
lines changed

lib/next_ls.ex

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,40 @@ defmodule NextLS do
2929
TextDocumentSyncOptions
3030
}
3131

32+
alias NextLS.Runtime
33+
alias NextLS.DiagnosticCache
34+
3235
def start_link(args) do
33-
{args, opts} = Keyword.split(args, [:task_supervisor, :runtime_supervisor])
36+
{args, opts} =
37+
Keyword.split(args, [
38+
:cache,
39+
:task_supervisor,
40+
:dynamic_supervisor,
41+
:extensions,
42+
:extension_registry
43+
])
3444

3545
GenLSP.start_link(__MODULE__, args, opts)
3646
end
3747

3848
@impl true
3949
def init(lsp, args) do
4050
task_supervisor = Keyword.fetch!(args, :task_supervisor)
41-
runtime_supervisor = Keyword.fetch!(args, :runtime_supervisor)
51+
dynamic_supervisor = Keyword.fetch!(args, :dynamic_supervisor)
52+
extension_registry = Keyword.fetch!(args, :extension_registry)
53+
extensions = Keyword.get(args, :extensions, [NextLS.ElixirExtension])
54+
cache = Keyword.fetch!(args, :cache)
4255

4356
{:ok,
4457
assign(lsp,
4558
exit_code: 1,
4659
documents: %{},
4760
refresh_refs: %{},
61+
cache: cache,
4862
task_supervisor: task_supervisor,
49-
runtime_supervisor: runtime_supervisor,
63+
dynamic_supervisor: dynamic_supervisor,
64+
extension_registry: extension_registry,
65+
extensions: extensions,
5066
runtime_task: nil,
5167
ready: false
5268
)}
@@ -90,12 +106,20 @@ defmodule NextLS do
90106

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

109+
for extension <- lsp.assigns.extensions do
110+
{:ok, _} =
111+
DynamicSupervisor.start_child(
112+
lsp.assigns.dynamic_supervisor,
113+
{extension, cache: lsp.assigns.cache, registry: lsp.assigns.extension_registry, publisher: self()}
114+
)
115+
end
116+
93117
GenLSP.log(lsp, "[NextLS] Booting runime...")
94118

95119
{:ok, runtime} =
96120
DynamicSupervisor.start_child(
97-
lsp.assigns.runtime_supervisor,
98-
{NextLS.Runtime, working_dir: working_dir, parent: self()}
121+
lsp.assigns.dynamic_supervisor,
122+
{NextLS.Runtime, extension_registry: lsp.assigns.extension_registry, working_dir: working_dir, parent: self()}
99123
)
100124

101125
Process.monitor(runtime)
@@ -117,7 +141,7 @@ defmodule NextLS do
117141
:ready
118142
end)
119143

120-
{:noreply, assign(lsp, runtime_task: task)}
144+
{:noreply, assign(lsp, refresh_refs: Map.put(lsp.assigns.refresh_refs, task.ref, task.ref), runtime_task: task)}
121145
end
122146

123147
def handle_notification(%TextDocumentDidSave{}, %{assigns: %{ready: false}} = lsp) do
@@ -133,7 +157,15 @@ defmodule NextLS do
133157
},
134158
%{assigns: %{ready: true}} = lsp
135159
) do
136-
{:noreply, lsp |> then(&put_in(&1.assigns.documents[uri], String.split(text, "\n")))}
160+
task =
161+
Task.Supervisor.async_nolink(lsp.assigns.task_supervisor, fn ->
162+
Runtime.compile(lsp.assigns.runtime)
163+
end)
164+
165+
{:noreply,
166+
lsp
167+
|> then(&put_in(&1.assigns.documents[uri], String.split(text, "\n")))
168+
|> then(&put_in(&1.assigns.refresh_refs[task.ref], task.ref))}
137169
end
138170

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

143175
def handle_notification(%TextDocumentDidChange{}, lsp) do
144176
for task <- Task.Supervisor.children(lsp.assigns.task_supervisor),
145-
task != lsp.assigns.runtime_task do
177+
task != lsp.assigns.runtime_task.pid do
146178
Process.exit(task, :kill)
147179
end
148180

@@ -170,6 +202,24 @@ defmodule NextLS do
170202
{:noreply, lsp}
171203
end
172204

205+
def handle_info(:publish, lsp) do
206+
all =
207+
for {_namespace, cache} <- DiagnosticCache.get(lsp.assigns.cache), {file, diagnostics} <- cache, reduce: %{} do
208+
d -> Map.update(d, file, diagnostics, fn value -> value ++ diagnostics end)
209+
end
210+
211+
for {file, diagnostics} <- all do
212+
GenLSP.notify(lsp, %GenLSP.Notifications.TextDocumentPublishDiagnostics{
213+
params: %GenLSP.Structures.PublishDiagnosticsParams{
214+
uri: "file://#{file}",
215+
diagnostics: diagnostics
216+
}
217+
})
218+
end
219+
220+
{:noreply, lsp}
221+
end
222+
173223
def handle_info({ref, resp}, %{assigns: %{refresh_refs: refs}} = lsp)
174224
when is_map_key(refs, ref) do
175225
Process.demonitor(ref, [:flush])
@@ -178,19 +228,21 @@ defmodule NextLS do
178228
lsp =
179229
case resp do
180230
:ready ->
181-
assign(lsp, ready: true)
231+
task =
232+
Task.Supervisor.async_nolink(lsp.assigns.task_supervisor, fn ->
233+
Runtime.compile(lsp.assigns.runtime)
234+
end)
235+
236+
assign(lsp, ready: true, refresh_refs: Map.put(refs, task.ref, task.ref))
182237

183238
_ ->
184-
lsp
239+
assign(lsp, refresh_refs: refs)
185240
end
186241

187-
{:noreply, assign(lsp, refresh_refs: refs)}
242+
{:noreply, lsp}
188243
end
189244

190-
def handle_info(
191-
{:DOWN, ref, :process, _pid, _reason},
192-
%{assigns: %{refresh_refs: refs}} = lsp
193-
)
245+
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{assigns: %{refresh_refs: refs}} = lsp)
194246
when is_map_key(refs, ref) do
195247
{_token, refs} = Map.pop(refs, ref)
196248

lib/next_ls/diagnostic_cache.ex

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule NextLS.DiagnosticCache do
2+
# TODO: this should be an ETS table
3+
@moduledoc """
4+
Cache for diagnostics.
5+
"""
6+
use Agent
7+
8+
def start_link(opts) do
9+
Agent.start_link(fn -> Map.new() end, Keyword.take(opts, [:name]))
10+
end
11+
12+
def get(cache) do
13+
Agent.get(cache, & &1)
14+
end
15+
16+
def put(cache, namespace, filename, diagnostic) do
17+
Agent.update(cache, fn cache ->
18+
Map.update(cache, namespace, %{filename => [diagnostic]}, fn cache ->
19+
Map.update(cache, filename, [diagnostic], fn v ->
20+
[diagnostic | v]
21+
end)
22+
end)
23+
end)
24+
end
25+
26+
def clear(cache, namespace) do
27+
Agent.update(cache, fn cache ->
28+
Map.update(cache, namespace, %{}, fn cache ->
29+
for {k, _} <- cache, into: Map.new() do
30+
{k, []}
31+
end
32+
end)
33+
end)
34+
end
35+
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
defmodule NextLS.ElixirExtension do
2+
use GenServer
3+
4+
alias NextLS.DiagnosticCache
5+
6+
def start_link(args) do
7+
GenServer.start_link(
8+
__MODULE__,
9+
Keyword.take(args, [:cache, :registry, :publisher]),
10+
Keyword.take(args, [:name])
11+
)
12+
end
13+
14+
@impl GenServer
15+
def init(args) do
16+
cache = Keyword.fetch!(args, :cache)
17+
registry = Keyword.fetch!(args, :registry)
18+
publisher = Keyword.fetch!(args, :publisher)
19+
20+
Registry.register(registry, :extension, :elixir)
21+
22+
{:ok, %{cache: cache, registry: registry, publisher: publisher}}
23+
end
24+
25+
@impl GenServer
26+
def handle_info({:compiler, diagnostics}, state) do
27+
DiagnosticCache.clear(state.cache, :elixir)
28+
29+
for d <- diagnostics do
30+
# TODO: some compiler diagnostics only have the line number
31+
# but we want to only highlight the source code, so we
32+
# need to read the text of the file (either from the lsp cache
33+
# if the source code is "open", or read from disk) and calculate the
34+
# column of the first non-whitespace character.
35+
#
36+
# it is not clear to me whether the LSP process or the extension should
37+
# be responsible for this. The open documents live in the LSP process
38+
DiagnosticCache.put(state.cache, :elixir, d.file, %GenLSP.Structures.Diagnostic{
39+
severity: severity(d.severity),
40+
message: d.message,
41+
source: d.compiler_name,
42+
range: range(d.position)
43+
})
44+
end
45+
46+
send(state.publisher, :publish)
47+
48+
{:noreply, state}
49+
end
50+
51+
defp severity(:error), do: GenLSP.Enumerations.DiagnosticSeverity.error()
52+
defp severity(:warning), do: GenLSP.Enumerations.DiagnosticSeverity.warning()
53+
defp severity(:info), do: GenLSP.Enumerations.DiagnosticSeverity.information()
54+
defp severity(:hint), do: GenLSP.Enumerations.DiagnosticSeverity.hint()
55+
56+
defp range({start_line, start_col, end_line, end_col}) do
57+
%GenLSP.Structures.Range{
58+
start: %GenLSP.Structures.Position{
59+
line: start_line - 1,
60+
character: start_col
61+
},
62+
end: %GenLSP.Structures.Position{
63+
line: end_line - 1,
64+
character: end_col
65+
}
66+
}
67+
end
68+
69+
defp range({line, col}) do
70+
%GenLSP.Structures.Range{
71+
start: %GenLSP.Structures.Position{
72+
line: line - 1,
73+
character: col
74+
},
75+
end: %GenLSP.Structures.Position{
76+
line: line - 1,
77+
character: 999
78+
}
79+
}
80+
end
81+
82+
defp range(line) do
83+
%GenLSP.Structures.Range{
84+
start: %GenLSP.Structures.Position{
85+
line: line - 1,
86+
character: 0
87+
},
88+
end: %GenLSP.Structures.Position{
89+
line: line - 1,
90+
character: 999
91+
}
92+
}
93+
end
94+
end

lib/next_ls/lsp_supervisor.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,16 @@ defmodule NextLS.LSPSupervisor do
3333
end
3434

3535
children = [
36-
{DynamicSupervisor, name: NextLS.RuntimeSupervisor},
36+
{DynamicSupervisor, name: NextLS.DynamicSupervisor},
3737
{Task.Supervisor, name: NextLS.TaskSupervisor},
3838
{GenLSP.Buffer, buffer_opts},
39-
{NextLS, task_supervisor: NextLS.TaskSupervisor, runtime_supervisor: NextLS.RuntimeSupervisor}
39+
{NextLS.DiagnosticCache, [name: :diagnostic_cache]},
40+
{Registry, name: NextLS.ExtensionRegistry, keys: :duplicate},
41+
{NextLS,
42+
cache: :diagnostic_cache,
43+
task_supervisor: NextLS.TaskSupervisor,
44+
dynamic_supervisor: NextLS.DynamicSupervisor,
45+
extension_registry: NextLS.ExtensionRegistry}
4046
]
4147

4248
Supervisor.init(children, strategy: :one_for_one)

lib/next_ls/runtime.ex

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,16 @@ defmodule NextLS.Runtime do
3232
end
3333
end
3434

35+
def compile(server) do
36+
GenServer.call(server, :compile)
37+
end
38+
3539
@impl GenServer
3640
def init(opts) do
3741
sname = "nextls#{System.system_time()}"
3842
working_dir = Keyword.fetch!(opts, :working_dir)
3943
parent = Keyword.fetch!(opts, :parent)
44+
extension_registry = Keyword.fetch!(opts, :extension_registry)
4045

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

83-
:ok =
84-
:rpc.call(
85-
node,
86-
:_next_ls_private_compiler,
87-
:compile,
88-
[]
89-
)
90-
9188
send(me, {:node, node})
9289
else
9390
_ -> send(me, :cancel)
9491
end
9592
end)
9693

97-
{:ok, %{port: port, parent: parent}}
94+
{:ok, %{port: port, parent: parent, errors: nil, extension_registry: extension_registry}}
9895
end
9996

10097
@impl GenServer
@@ -111,6 +108,20 @@ defmodule NextLS.Runtime do
111108
{:reply, reply, state}
112109
end
113110

111+
def handle_call(:compile, _, %{node: node} = state) do
112+
{_, errors} = :rpc.call(node, :_next_ls_private_compiler, :compile, [])
113+
114+
foo = "foo"
115+
116+
Registry.dispatch(state.extension_registry, :extension, fn entries ->
117+
for {pid, _} <- entries do
118+
send(pid, {:compiler, errors})
119+
end
120+
end)
121+
122+
{:reply, errors, %{state | errors: errors}}
123+
end
124+
114125
@impl GenServer
115126
def handle_info({:node, node}, state) do
116127
Node.monitor(node, true)

priv/monkey/_next_ls_private_compiler.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ defmodule :_next_ls_private_compiler do
55
# keep stdout on this node
66
Process.group_leader(self(), Process.whereis(:user))
77

8+
Mix.Task.clear()
9+
810
# load the paths for deps and compile them
911
# will noop if they are already compiled
1012
# The mix cli basically runs this before any mix task
@@ -13,9 +15,7 @@ defmodule :_next_ls_private_compiler do
1315
# --no-compile, so nothing was compiled, but the
1416
# task was not re-enabled it seems
1517
Mix.Task.rerun("deps.loadpaths")
16-
Mix.Task.rerun("compile")
17-
18-
:ok
18+
Mix.Task.rerun("compile", ["--no-protocol-consolidation", "--return-errors"])
1919
rescue
2020
e -> {:error, e}
2121
end

0 commit comments

Comments
 (0)