Skip to content

Commit edcb909

Browse files
authored
Show code snippet on syntax and token missing errors (#11332)
Closes #11280.
1 parent e414cc0 commit edcb909

File tree

10 files changed

+222
-83
lines changed

10 files changed

+222
-83
lines changed

lib/elixir/lib/code.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,13 @@ defmodule Code do
10081008
{forms, comments}
10091009

10101010
{:error, {location, error, token}} ->
1011-
:elixir_errors.parse_error(location, Keyword.get(opts, :file, "nofile"), error, token)
1011+
:elixir_errors.parse_error(
1012+
location,
1013+
Keyword.get(opts, :file, "nofile"),
1014+
error,
1015+
token,
1016+
{string, Keyword.get(opts, :line, 1), Keyword.get(opts, :column, 1)}
1017+
)
10121018
end
10131019
end
10141020

lib/elixir/lib/exception.ex

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,17 @@ defmodule Exception do
697697
end
698698
end
699699

700+
@doc false
701+
def format_snippet(snippet, error_line) do
702+
line_digits = error_line |> Integer.to_string() |> byte_size()
703+
placeholder = String.duplicate(" ", max(line_digits, 2))
704+
padding = if line_digits < 2, do: " "
705+
706+
" #{placeholder} |\n" <>
707+
" #{padding}#{error_line} | #{snippet.content}\n" <>
708+
" #{placeholder} | #{String.duplicate(" ", snippet.offset)}^"
709+
end
710+
700711
defp format_location(opts) when is_list(opts) do
701712
format_file_line(Keyword.get(opts, :file), Keyword.get(opts, :line), " ")
702713
end
@@ -795,20 +806,56 @@ defmodule SystemLimitError do
795806
end
796807

797808
defmodule SyntaxError do
798-
defexception [:file, :line, :column, description: "syntax error"]
809+
defexception [:file, :line, :column, :snippet, description: "syntax error"]
810+
811+
@impl true
812+
def message(%{
813+
file: file,
814+
line: line,
815+
column: column,
816+
description: description,
817+
snippet: snippet
818+
})
819+
when not is_nil(snippet) and not is_nil(column) do
820+
Exception.format_file_line_column(Path.relative_to_cwd(file), line, column) <>
821+
" " <> description <> "\n" <> Exception.format_snippet(snippet, line)
822+
end
799823

800824
@impl true
801-
def message(%{file: file, line: line, column: column, description: description}) do
825+
def message(%{
826+
file: file,
827+
line: line,
828+
column: column,
829+
description: description
830+
}) do
802831
Exception.format_file_line_column(Path.relative_to_cwd(file), line, column) <>
803832
" " <> description
804833
end
805834
end
806835

807836
defmodule TokenMissingError do
808-
defexception [:file, :line, :column, description: "expression is incomplete"]
837+
defexception [:file, :line, :snippet, :column, description: "expression is incomplete"]
838+
839+
@impl true
840+
def message(%{
841+
file: file,
842+
line: line,
843+
column: column,
844+
description: description,
845+
snippet: snippet
846+
})
847+
when not is_nil(snippet) and not is_nil(column) do
848+
Exception.format_file_line_column(Path.relative_to_cwd(file), line, column) <>
849+
" " <> description <> "\n" <> Exception.format_snippet(snippet, line)
850+
end
809851

810852
@impl true
811-
def message(%{file: file, line: line, column: column, description: description}) do
853+
def message(%{
854+
file: file,
855+
line: line,
856+
column: column,
857+
description: description
858+
}) do
812859
Exception.format_file_line_column(file && Path.relative_to_cwd(file), line, column) <>
813860
" " <> description
814861
end

lib/elixir/src/elixir.erl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,10 @@ parser_location(Meta) ->
397397
{ok, Forms} ->
398398
Forms;
399399
{error, {Meta, Error, Token}} ->
400-
elixir_errors:parse_error(Meta, File, Error, Token)
400+
elixir_errors:parse_error(Meta, File, Error, Token, {String, StartLine, StartColumn})
401401
end;
402402
{error, {Meta, Error, Token}} ->
403-
elixir_errors:parse_error(Meta, File, Error, Token)
403+
elixir_errors:parse_error(Meta, File, Error, Token, {String, StartLine, StartColumn})
404404
end.
405405

406406
to_binary(List) when is_list(List) -> elixir_utils:characters_to_binary(List);

lib/elixir/src/elixir_errors.erl

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
%% Note that this is also called by the Erlang backend, so we also support
55
%% the line number to be none (as it may happen in some erlang errors).
66
-module(elixir_errors).
7-
-export([compile_error/3, compile_error/4, form_error/4, parse_error/4]).
7+
-export([compile_error/3, compile_error/4, form_error/4, parse_error/5]).
88
-export([warning_prefix/0, erl_warn/3, print_warning/3, log_and_print_warning/4, form_warn/4]).
99
-include("elixir.hrl").
1010
-type location() :: non_neg_integer() | {non_neg_integer(), non_neg_integer()}.
@@ -82,24 +82,36 @@ compile_error(Meta, File, Format, Args) when is_list(Format) ->
8282
compile_error(Meta, File, io_lib:format(Format, Args)).
8383

8484
%% Tokenization parsing/errors.
85+
snippet(InputString, Location, StartLine, StartColumn) ->
86+
{line, Line} = lists:keyfind(line, 1, Location),
87+
case lists:keyfind(column, 1, Location) of
88+
{column, Column} ->
89+
Lines = string:split(InputString, "\n", all),
90+
Snippet = elixir_utils:characters_to_binary(lists:nth(Line - StartLine + 1, Lines)),
91+
#{content => Snippet, offset => (Column - StartColumn)};
92+
93+
false ->
94+
nil
95+
end.
8596

8697
-spec parse_error(elixir:keyword(), binary() | {binary(), binary()},
87-
binary(), binary()) -> no_return().
88-
parse_error(Location, File, Error, <<>>) ->
98+
binary(), binary(), {list(), integer(), integer()}) -> no_return().
99+
parse_error(Location, File, Error, <<>>, {InputString, StartLine, StartColumn}) ->
89100
Message = case Error of
90101
<<"syntax error before: ">> -> <<"syntax error: expression is incomplete">>;
91-
_ -> Error
102+
_ -> <<Error/binary>>
92103
end,
93-
raise(Location, File, 'Elixir.TokenMissingError', Message);
104+
Snippet = snippet(InputString, Location, StartLine, StartColumn),
105+
raise(Location, File, 'Elixir.TokenMissingError', Message, Snippet);
94106

95107
%% Show a nicer message for end of line
96-
parse_error(Location, File, <<"syntax error before: ">>, <<"eol">>) ->
108+
parse_error(Location, File, <<"syntax error before: ">>, <<"eol">>, _Input) ->
97109
raise(Location, File, 'Elixir.SyntaxError',
98110
<<"unexpectedly reached end of line. The current expression is invalid or incomplete">>);
99111

100112

101113
%% Show a nicer message for keywords pt1 (Erlang keywords show up wrapped in single quotes)
102-
parse_error(Location, File, <<"syntax error before: ">>, Keyword)
114+
parse_error(Location, File, <<"syntax error before: ">>, Keyword, _Input)
103115
when Keyword == <<"'not'">>;
104116
Keyword == <<"'and'">>;
105117
Keyword == <<"'or'">>;
@@ -110,7 +122,7 @@ parse_error(Location, File, <<"syntax error before: ">>, Keyword)
110122
raise_reserved(Location, File, binary_part(Keyword, 1, byte_size(Keyword) - 2));
111123

112124
%% Show a nicer message for keywords pt2 (Elixir keywords show up as is)
113-
parse_error(Location, File, <<"syntax error before: ">>, Keyword)
125+
parse_error(Location, File, <<"syntax error before: ">>, Keyword, _Input)
114126
when Keyword == <<"fn">>;
115127
Keyword == <<"else">>;
116128
Keyword == <<"rescue">>;
@@ -121,7 +133,7 @@ parse_error(Location, File, <<"syntax error before: ">>, Keyword)
121133
raise_reserved(Location, File, Keyword);
122134

123135
%% Produce a human-readable message for errors before a sigil
124-
parse_error(Location, File, <<"syntax error before: ">>, <<"{sigil,", _Rest/binary>> = Full) ->
136+
parse_error(Location, File, <<"syntax error before: ">>, <<"{sigil,", _Rest/binary>> = Full, _Input) ->
125137
{sigil, _, Sigil, [Content | _], _, _, _} = parse_erl_term(Full),
126138
Content2 = case is_binary(Content) of
127139
true -> Content;
@@ -131,15 +143,15 @@ parse_error(Location, File, <<"syntax error before: ">>, <<"{sigil,", _Rest/bina
131143
raise(Location, File, 'Elixir.SyntaxError', Message);
132144

133145
%% Binaries (and interpolation) are wrapped in [<<...>>]
134-
parse_error(Location, File, Error, <<"[", _/binary>> = Full) when is_binary(Error) ->
146+
parse_error(Location, File, Error, <<"[", _/binary>> = Full, _Input) when is_binary(Error) ->
135147
Term = case parse_erl_term(Full) of
136148
[H | _] when is_binary(H) -> <<$", H/binary, $">>;
137149
_ -> <<$">>
138150
end,
139151
raise(Location, File, 'Elixir.SyntaxError', <<Error/binary, Term/binary>>);
140152

141153
%% Given a string prefix and suffix to insert the token inside the error message rather than append it
142-
parse_error(Location, File, {ErrorPrefix, ErrorSuffix}, Token) when is_binary(ErrorPrefix), is_binary(ErrorSuffix), is_binary(Token) ->
154+
parse_error(Location, File, {ErrorPrefix, ErrorSuffix}, Token, _Input) when is_binary(ErrorPrefix), is_binary(ErrorSuffix), is_binary(Token) ->
143155
Message = <<ErrorPrefix/binary, Token/binary, ErrorSuffix/binary >>,
144156
raise(Location, File, 'Elixir.SyntaxError', Message);
145157

@@ -148,14 +160,15 @@ parse_error(Location, File, {ErrorPrefix, ErrorSuffix}, Token) when is_binary(Er
148160
%% because {char, _, _} is a valid Erlang token for an Erlang char literal. We
149161
%% want to represent that token as ?a in the error, according to the Elixir
150162
%% syntax.
151-
parse_error(Location, File, <<"syntax error before: ">>, <<$$, Char/binary>>) ->
163+
parse_error(Location, File, <<"syntax error before: ">>, <<$$, Char/binary>>, _Input) ->
152164
Message = <<"syntax error before: ?", Char/binary>>,
153165
raise(Location, File, 'Elixir.SyntaxError', Message);
154166

155167
%% Everything else is fine as is
156-
parse_error(Location, File, Error, Token) when is_binary(Error), is_binary(Token) ->
157-
Message = <<Error/binary, Token/binary >>,
158-
raise(Location, File, 'Elixir.SyntaxError', Message).
168+
parse_error(Location, File, Error, Token, {InputString, StartLine, StartColumn}) when is_binary(Error), is_binary(Token) ->
169+
Message = <<Error/binary, Token/binary>>,
170+
Snippet = snippet(InputString, Location, StartLine, StartColumn),
171+
raise(Location, File, 'Elixir.SyntaxError', Message, Snippet).
159172

160173
parse_erl_term(Term) ->
161174
{ok, Tokens, _} = erl_scan:string(binary_to_list(Term)),
@@ -196,6 +209,9 @@ meta_location(Meta, File) ->
196209
nil -> [{file, File}, {line, ?line(Meta)}]
197210
end.
198211

212+
raise(Location, File, Kind, Message, Snippet) when is_binary(File) ->
213+
raise(Kind, Message, [{file, File}, {snippet, Snippet} | Location]).
214+
199215
raise(Location, File, Kind, Message) when is_binary(File) ->
200216
raise(Kind, Message, [{file, File} | Location]).
201217

lib/elixir/test/elixir/code_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ defmodule CodeTest do
140140
Code.unrequire_files([fixture_path("code_sample.exs")])
141141
end
142142

143+
test "string_to_quoted!/2 errors take lines and columns into account" do
144+
message = "nofile:1:5: syntax error before: '*'\n |\n 1 | 1 + * 3\n | ^"
145+
146+
assert_raise SyntaxError, message, fn ->
147+
Code.string_to_quoted!("1 + * 3")
148+
end
149+
150+
message = "nofile:10:5: syntax error before: '*'\n |\n 10 | 1 + * 3\n | ^"
151+
152+
assert_raise SyntaxError, message, fn ->
153+
Code.string_to_quoted!("1 + * 3", line: 10)
154+
end
155+
156+
message = "nofile:10:7: syntax error before: '*'\n |\n 10 | 1 + * 3\n | ^"
157+
158+
assert_raise SyntaxError, message, fn ->
159+
Code.string_to_quoted!("1 + * 3", line: 10, column: 3)
160+
end
161+
end
162+
143163
test "compile source" do
144164
assert __MODULE__.__info__(:compile)[:source] == String.to_charlist(__ENV__.file)
145165
end

lib/elixir/test/elixir/kernel/errors_test.exs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ defmodule Kernel.ErrorsTest do
233233
'%{foo: 1, :bar => :bar}'
234234
end
235235

236+
test "syntax errors include formatted snippet" do
237+
message = "nofile:1:5: syntax error before: '*'\n |\n 1 | 1 + * 3\n | ^"
238+
assert_eval_raise SyntaxError, message, "1 + * 3"
239+
end
240+
236241
test "struct fields on defstruct" do
237242
assert_eval_raise ArgumentError, "struct field names must be atoms, got: 1", '''
238243
defmodule Kernel.ErrorsTest.StructFieldsOnDefstruct do
@@ -478,12 +483,12 @@ defmodule Kernel.ErrorsTest do
478483

479484
test "invalid fn args" do
480485
assert_eval_raise TokenMissingError,
481-
"nofile:1:5: missing terminator: end (for \"fn\" starting at line 1)",
486+
~r/nofile:1:5: missing terminator: end \(for "fn" starting at line 1\).*/,
482487
'fn 1'
483488
end
484489

485490
test "invalid escape" do
486-
assert_eval_raise TokenMissingError, "nofile:1:3: invalid escape \\ at end of file", '1 \\'
491+
assert_eval_raise TokenMissingError, ~r/nofile:1:3: invalid escape \\ at end of file/, '1 \\'
487492
end
488493

489494
test "function local conflict" do

0 commit comments

Comments
 (0)