Skip to content

Commit 6afbc75

Browse files
authored
Support Elixir 1.15 (#261)
* Support as-you-type compiling on Elixir 1.15 Starting with Elixir 1.15, we don't need to capture io for compiling individual files or quoted anymore, because with_diagnostics returns all compile-time diagnostics. So I have implemented two ways to compile and process the compiled results. You can see from the tests that the location of the undefined diagnostic is more precise, and I've also enhanced the unused and undefined messages to make them more concise and clear.
1 parent 8b084d1 commit 6afbc75

File tree

16 files changed

+380
-193
lines changed

16 files changed

+380
-193
lines changed

.credo.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
name: "default",
55
files: %{
66
included: ["lib/", "src/", "web/", "apps/"],
7-
excluded: ["apps/remote_control/test/fixtures/**/*.ex", "apps/common/lib/elixir/lib/future/**/*.ex"]
7+
excluded: ["apps/remote_control/test/fixtures/**/*.ex", "apps/common/lib/future/**/*.ex"]
88
},
99
plugins: [],
1010
requires: [],

.github/workflows/elixir.yml

+2
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ jobs:
156156
# and running the workflow steps.
157157
matrix:
158158
include:
159+
- elixir: '1.15.3-otp-25'
160+
otp: '25.3'
159161
- elixir: '1.14.5-otp-25'
160162
otp: '25.3'
161163
- elixir: '1.13.4-otp-25'

.tool-versions

-4
This file was deleted.

apps/common/lib/elixir/features.ex

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
defmodule Elixir.Features do
2+
def with_diagnostics? do
3+
Version.match?(System.version(), ">= 1.15.3")
4+
end
5+
6+
def compile_wont_change_directory? do
7+
Version.match?(System.version(), ">= 1.15.0")
8+
end
9+
end

apps/remote_control/.tool-versions

-2
This file was deleted.

apps/remote_control/lib/lexical/remote_control/build/error.ex

+103-15
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,27 @@ defmodule Lexical.RemoteControl.Build.Error do
77

88
@elixir_source "Elixir"
99

10-
def normalize_diagnostic(%Compiler.Diagnostic{} = diagnostic) do
10+
@doc """
11+
Diagnostics can come from compiling the whole project,
12+
from compiling individual files, or from erlang's diagnostics (Code.with_diagnostics since elixir1.15),
13+
so we need to do some post-processing.
14+
15+
Includes:
16+
1. Normalize each one to the standard result
17+
2. Format the message to make it readable in the editor
18+
3. Remove duplicate messages on the same line
19+
"""
20+
def refine_diagnostics(diagnostics) do
21+
diagnostics
22+
|> Enum.map(fn diagnostic ->
23+
diagnostic
24+
|> normalize()
25+
|> format()
26+
end)
27+
|> uniq()
28+
end
29+
30+
defp normalize(%Compiler.Diagnostic{} = diagnostic) do
1131
Result.new(
1232
diagnostic.file,
1333
diagnostic.position,
@@ -17,6 +37,65 @@ defmodule Lexical.RemoteControl.Build.Error do
1737
)
1838
end
1939

40+
defp normalize(%Result{} = result) do
41+
result
42+
end
43+
44+
defp format(%Result{} = result) do
45+
%Result{result | message: format_message(result.message)}
46+
end
47+
48+
@undefined_function_pattern ~r/ \(expected ([A-Za-z0-9_\.]*) to [^\)]+\)/
49+
50+
defp format_message("undefined" <> _ = message) do
51+
# All undefined function messages explain the *same* thing inside the parentheses
52+
String.replace(message, @undefined_function_pattern, "")
53+
end
54+
55+
defp format_message(message) when is_binary(message) do
56+
maybe_format_unused(message)
57+
end
58+
59+
defp maybe_format_unused(message) do
60+
# Same reason as the `undefined` message above, we can remove the things in parentheses
61+
case String.split(message, "is unused (", parts: 2) do
62+
[prefix, _] ->
63+
prefix <> "is unused"
64+
65+
_ ->
66+
message
67+
end
68+
end
69+
70+
defp reject_zeroth_line(diagnostics) do
71+
# Since 1.15, Elixir has some nonsensical error on line 0,
72+
# e.g.: Can't compile this file
73+
# We can simply ignore it, as there is a more accurate one
74+
Enum.reject(diagnostics, fn diagnostic ->
75+
diagnostic.position == 0
76+
end)
77+
end
78+
79+
defp uniq(diagnostics) do
80+
# We need to uniq by position because the same position can be reported
81+
# and the `end_line_diagnostic` is always the precise one
82+
extract_line = fn
83+
%Result{position: {line, _column}} -> line
84+
%Result{position: {start_line, _start_col, _end_line, _end_col}} -> start_line
85+
%Result{position: line} -> line
86+
end
87+
88+
# Note: Sometimes error and warning appear on one line at the same time
89+
# So we need to uniq by line and severity,
90+
# and :error is always more important than :warning
91+
extract_line_and_severity = &{extract_line.(&1), &1.severity}
92+
93+
diagnostics
94+
|> Enum.sort_by(extract_line_and_severity)
95+
|> Enum.uniq_by(extract_line)
96+
|> reject_zeroth_line()
97+
end
98+
2099
# Parse errors happen during Code.string_to_quoted and are raised as SyntaxErrors, and TokenMissingErrors.
21100
def parse_error_to_diagnostics(
22101
%Document{} = source,
@@ -49,21 +128,16 @@ defmodule Lexical.RemoteControl.Build.Error do
49128
)
50129
end
51130

52-
defp uniq(diagnostics) do
53-
# We need to uniq by position because the same position can be reported
54-
# and the `end_line_diagnostic` is always the precise one
55-
extract_line = fn
56-
%Result{position: {line, _column}} -> line
57-
%Result{position: {start_line, _start_col, _end_line, _end_col}} -> start_line
58-
%Result{position: line} when is_integer(line) -> line
59-
end
60-
61-
Enum.uniq_by(diagnostics, extract_line)
62-
end
63-
64131
defp build_end_line_diagnostics(%Document{} = source, context, message_info, token) do
65132
[end_line_message | _] = String.split(message_info, "\n")
66-
message = "#{end_line_message}#{token}"
133+
134+
message =
135+
if String.ends_with?(end_line_message, token) do
136+
end_line_message
137+
else
138+
end_line_message <> token
139+
end
140+
67141
diagnostic = Result.new(source.uri, context_to_position(context), message, :error, "Elixir")
68142
[diagnostic]
69143
end
@@ -96,6 +170,20 @@ defmodule Lexical.RemoteControl.Build.Error do
96170
end
97171
end
98172

173+
@doc """
174+
The `diagnostics_from_mix/2` is only for Elixir version > 1.15
175+
176+
From 1.15 onwards with_diagnostics can return some compile-time errors,
177+
more details: https://github.com/elixir-lang/elixir/pull/12742
178+
"""
179+
def diagnostics_from_mix(%Document{} = doc, all_errors_and_warnings)
180+
when is_list(all_errors_and_warnings) do
181+
for error_or_wanning <- all_errors_and_warnings do
182+
%{position: position, message: message, severity: severity} = error_or_wanning
183+
Result.new(doc.uri, position, message, severity, @elixir_source)
184+
end
185+
end
186+
99187
def error_to_diagnostic(
100188
%Document{} = source,
101189
%CompileError{} = compile_error,
@@ -179,7 +267,7 @@ defmodule Lexical.RemoteControl.Build.Error do
179267
[{_, _, _, context}, {_, call, _, second_to_last_context} | _] = reversed_stack
180268

181269
pipe_or_struct? = call in [:|>, :__struct__]
182-
expanding_macro? = second_to_last_context[:file] == 'expanding macro'
270+
expanding_macro? = second_to_last_context[:file] == ~c"expanding macro"
183271
message = Exception.message(argument_error)
184272

185273
position =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
defmodule Lexical.RemoteControl.Build.File do
2+
alias Elixir.Features
3+
alias Lexical.Document
4+
alias Lexical.RemoteControl.Build
5+
alias Lexical.RemoteControl.ModuleMappings
6+
7+
import Lexical.RemoteControl.Build.CaptureIO, only: [capture_io: 2]
8+
9+
def compile(%Document{} = document) do
10+
case to_quoted(document) do
11+
{:ok, quoted} ->
12+
prepare_compile(document.path)
13+
14+
if Features.with_diagnostics?() do
15+
do_compile(quoted, document)
16+
else
17+
do_compile_and_capture_io(quoted, document)
18+
end
19+
20+
{:error, {meta, message_info, token}} ->
21+
diagnostics = Build.Error.parse_error_to_diagnostics(document, meta, message_info, token)
22+
{:error, diagnostics}
23+
end
24+
end
25+
26+
defp to_quoted(document) do
27+
source_string = Document.to_string(document)
28+
parser_options = [file: document.path] ++ parser_options()
29+
Code.put_compiler_option(:ignore_module_conflict, true)
30+
Code.string_to_quoted(source_string, parser_options)
31+
end
32+
33+
defp do_compile(quoted_ast, document) do
34+
old_modules = ModuleMappings.modules_in_file(document.path)
35+
36+
case compile_quoted_with_diagnostics(quoted_ast, document.path) do
37+
{{:ok, modules}, []} ->
38+
purge_removed_modules(old_modules, modules)
39+
{:ok, []}
40+
41+
{{:ok, modules}, all_errors_and_warnings} ->
42+
purge_removed_modules(old_modules, modules)
43+
44+
diagnostics =
45+
document
46+
|> Build.Error.diagnostics_from_mix(all_errors_and_warnings)
47+
|> Build.Error.refine_diagnostics()
48+
49+
{:ok, diagnostics}
50+
51+
{{:exception, exception, stack, quoted_ast}, all_errors_and_warnings} ->
52+
converted = Build.Error.error_to_diagnostic(document, exception, stack, quoted_ast)
53+
maybe_diagnostics = Build.Error.diagnostics_from_mix(document, all_errors_and_warnings)
54+
55+
diagnostics =
56+
[converted | maybe_diagnostics]
57+
|> Enum.reverse()
58+
|> Build.Error.refine_diagnostics()
59+
60+
{:error, diagnostics}
61+
end
62+
end
63+
64+
defp do_compile_and_capture_io(quoted_ast, document) do
65+
# credo:disable-for-next-line Credo.Check.Design.TagTODO
66+
# TODO: remove this function once we drop support for Elixir 1.14
67+
old_modules = ModuleMappings.modules_in_file(document.path)
68+
compile = fn -> safe_compile_quoted(quoted_ast, document.path) end
69+
70+
case capture_io(:stderr, compile) do
71+
{captured_messages, {:error, {:exception, {exception, _inner_stack}, stack}}} ->
72+
error = Build.Error.error_to_diagnostic(document, exception, stack, [])
73+
diagnostics = Build.Error.message_to_diagnostic(document, captured_messages)
74+
75+
{:error, [error | diagnostics]}
76+
77+
{captured_messages, {:exception, exception, stack, quoted_ast}} ->
78+
error = Build.Error.error_to_diagnostic(document, exception, stack, quoted_ast)
79+
diagnostics = Build.Error.message_to_diagnostic(document, captured_messages)
80+
81+
{:error, [error | diagnostics]}
82+
83+
{"", {:ok, modules}} ->
84+
purge_removed_modules(old_modules, modules)
85+
{:ok, []}
86+
87+
{captured_warnings, {:ok, modules}} ->
88+
purge_removed_modules(old_modules, modules)
89+
diagnostics = Build.Error.message_to_diagnostic(document, captured_warnings)
90+
{:ok, diagnostics}
91+
end
92+
end
93+
94+
defp prepare_compile(path) do
95+
# If we're compiling a mix.exs file, the after compile callback from
96+
# `use Mix.Project` will blow up if we add the same project to the project stack
97+
# twice. Preemptively popping it prevents that error from occurring.
98+
if Path.basename(path) == "mix.exs" do
99+
Mix.ProjectStack.pop()
100+
end
101+
102+
Mix.Task.run(:loadconfig)
103+
end
104+
105+
@dialyzer {:nowarn_function, compile_quoted_with_diagnostics: 2}
106+
107+
defp compile_quoted_with_diagnostics(quoted_ast, path) do
108+
Code.with_diagnostics(fn ->
109+
safe_compile_quoted(quoted_ast, path)
110+
end)
111+
end
112+
113+
defp safe_compile_quoted(quoted_ast, path) do
114+
try do
115+
{:ok, Code.compile_quoted(quoted_ast, path)}
116+
rescue
117+
exception ->
118+
{filled_exception, stack} = Exception.blame(:error, exception, __STACKTRACE__)
119+
{:exception, filled_exception, stack, quoted_ast}
120+
end
121+
end
122+
123+
defp purge_removed_modules(old_modules, new_modules) do
124+
new_modules = MapSet.new(new_modules, fn {module, _bytecode} -> module end)
125+
old_modules = MapSet.new(old_modules)
126+
127+
old_modules
128+
|> MapSet.difference(new_modules)
129+
|> Enum.each(fn to_remove ->
130+
:code.purge(to_remove)
131+
:code.delete(to_remove)
132+
end)
133+
end
134+
135+
defp parser_options do
136+
[columns: true, token_metadata: true]
137+
end
138+
end

0 commit comments

Comments
 (0)