Skip to content

Commit 14d6d6f

Browse files
authored
Improve errors and diagnostics (#757)
* Add custom Surface.CompileError, which can handle column and snippet * Normalize compile error across Elixir versions * Add Surface.Case with ANSI helpers
1 parent 5a11bc4 commit 14d6d6f

30 files changed

+568
-349
lines changed

lib/mix/tasks/compile/surface/validate_components.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponents do
4141
missing_props_names = required_props_names -- existing_props_names
4242

4343
for prop_name <- missing_props_names do
44-
message = "Missing required property \"#{prop_name}\" for component <#{node_alias}>"
44+
message = "missing required property \"#{prop_name}\" for component <#{node_alias}>"
4545

4646
message =
4747
if prop_name == :id and Helpers.is_stateful_component(module) do

lib/surface/base_component.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ defmodule Surface.BaseComponent do
123123
components = Enum.uniq_by(components_calls, & &1.component)
124124

125125
{imports, requires} =
126-
for %{component: mod, line: line, dep_type: dep_type} <- components, mod != env.module, reduce: {[], []} do
126+
for %{component: mod, file: file, line: line, dep_type: dep_type} <- components,
127+
mod != env.module,
128+
reduce: {[], []} do
127129
{imports, requires} ->
128130
case dep_type do
129131
:export ->
@@ -135,9 +137,15 @@ defmodule Surface.BaseComponent do
135137
], requires}
136138

137139
:compile ->
140+
# We use `require` for macros or when there's an error loading the
141+
# module. This way if the missing/failing module is created/fixed,
142+
# Elixir will recompile this file.
143+
# NOTE: there's a bug in Elixir that report the error with the wrong line
144+
# in versions <= 1.17. See https://github.com/elixir-lang/elixir/issues/13542
145+
# for details.
138146
{imports,
139147
[
140-
quote line: line do
148+
quote file: file, line: line do
141149
require(unquote(mod)).__info__(:module)
142150
end
143151
| requires

lib/surface/catalogue.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,15 @@ defmodule Surface.Catalogue do
104104
subject
105105

106106
_ ->
107-
message = """
108-
no subject defined for #{inspect(type)}
107+
message = "no subject defined for #{inspect(type)}"
109108

110-
Hint: You can define the subject using the :subject option. Example:
109+
hint = """
110+
you can define the subject using the :subject option. Example:
111111
112112
use #{inspect(type)}, subject: MyApp.MyButton
113113
"""
114114

115-
Surface.IOHelper.compile_error(message, caller.file, caller.line)
115+
Surface.IOHelper.compile_error(message, hint, caller.file, caller.line)
116116
end
117117
end
118118

lib/surface/compile_error.ex

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
defmodule Surface.CompileError do
2+
@moduledoc """
3+
An exception raised when there's a Surface compile error.
4+
5+
The following fields of this exceptions are public and can be accessed freely:
6+
7+
* `:file` (`t:Path.t/0` or `nil`) - the file where the error occurred, or `nil` if
8+
the error occurred in code that did not come from a file
9+
* `:line` - the line where the error occurred
10+
* `:column` - the column where the error occurred
11+
* `:description` - a description of the error
12+
* `:hint` - a hint to help the user to fix the issue
13+
14+
"""
15+
16+
@support_snippet Version.match?(System.version(), ">= 1.17.0")
17+
18+
defexception [:file, :line, :column, :snippet, :hint, description: "compile error"]
19+
20+
@impl true
21+
def message(%{
22+
file: file,
23+
line: line,
24+
column: column,
25+
description: description,
26+
hint: hint
27+
}) do
28+
format_message(file, line, column, description, hint)
29+
end
30+
31+
if @support_snippet do
32+
defp format_message(file, line, column, description, hint) do
33+
message =
34+
if File.exists?(file) do
35+
{lineCode, _} = File.stream!(file) |> Stream.with_index() |> Enum.at(line - 1)
36+
lineCode = String.trim_trailing(lineCode)
37+
:elixir_errors.format_snippet(:error, {line, column}, file, description, lineCode, %{})
38+
else
39+
prefix = IO.ANSI.format([:red, "error:"])
40+
"#{prefix} #{description}"
41+
end
42+
43+
hint =
44+
if hint do
45+
"\n\n" <> :elixir_errors.format_snippet(:hint, nil, nil, hint, nil, %{})
46+
else
47+
""
48+
end
49+
50+
location = Exception.format_file_line_column(Path.relative_to_cwd(file), line, column)
51+
location <> "\n" <> message <> hint
52+
end
53+
else
54+
defp format_message(file, line, column, description, hint) do
55+
location = Exception.format_file_line_column(Path.relative_to_cwd(file), line, column)
56+
57+
hint =
58+
if hint do
59+
prefix = IO.ANSI.format([:blue, "hint:"])
60+
"\n\n#{prefix} " <> hint
61+
else
62+
""
63+
end
64+
65+
prefix = IO.ANSI.format([:red, "error:"])
66+
location <> "\n#{prefix} " <> description <> hint
67+
end
68+
end
69+
end

lib/surface/compiler.ex

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -283,13 +283,15 @@ defmodule Surface.Compiler do
283283
{:ok, ast} ->
284284
process_directives(ast)
285285

286-
{:error, {message, line}, meta} ->
287-
IOHelper.warn(message, compile_meta.caller, meta.file, line)
286+
{:error, {message, line, column}, meta} ->
287+
IOHelper.warn(message, compile_meta.caller, meta.file, {line, column})
288288
%AST.Error{message: message, meta: meta}
289289

290-
{:error, {message, details, line}, meta} ->
291-
details = if details, do: "\n\n" <> details, else: ""
292-
IOHelper.warn(message <> details, compile_meta.caller, meta.file, line)
290+
{:error, {message, details, line, column}, meta} ->
291+
# TODO: turn it back as a warning when using @after_verify in Elixir >= 0.14.
292+
# Make sure to check if the genarated `require <component>.__info__()` doesn't get called,
293+
# raising Elixir's CompileError.
294+
IOHelper.compile_error(message, details, meta.file, {line, column})
293295
%AST.Error{message: message, meta: meta}
294296
end
295297
end
@@ -854,8 +856,9 @@ defmodule Surface.Compiler do
854856
end
855857
else
856858
false ->
857-
{:error, {"cannot render <#{node_alias}> (MacroComponents must export an expand/3 function)", meta.line},
858-
meta}
859+
{:error,
860+
{"cannot render <#{node_alias}> (MacroComponents must export an expand/3 function)", meta.line,
861+
meta.column}, meta}
859862

860863
error ->
861864
handle_convert_node_to_ast_error(node_alias, error, meta)
@@ -1246,7 +1249,7 @@ defmodule Surface.Compiler do
12461249
!Map.has_key?(slot_entries, name) or
12471250
Enum.all?(Map.get(slot_entries, name, []), &Helpers.is_blank_or_empty/1) do
12481251
message = "missing required slot \"#{name}\" for component <#{meta.node_alias}>"
1249-
IOHelper.warn(message, meta.caller, meta.file, meta.line)
1252+
IOHelper.warn(message, meta.caller, meta.file, {meta.line, meta.column})
12501253
end
12511254

12521255
for {slot_name, slot_entry_instances} <- slot_entries,
@@ -1508,13 +1511,13 @@ defmodule Surface.Compiler do
15081511
defp handle_convert_node_to_ast_error(name, error, meta) do
15091512
case error do
15101513
{:error, message, details} ->
1511-
{:error, {"cannot render <#{name}> (#{message})", details, meta.line}, meta}
1514+
{:error, {"cannot render <#{name}> (#{message})", details, meta.line, meta.column}, meta}
15121515

15131516
{:error, message} ->
1514-
{:error, {"cannot render <#{name}> (#{message})", meta.line}, meta}
1517+
{:error, {"cannot render <#{name}> (#{message})", meta.line, meta.column}, meta}
15151518

15161519
_ ->
1517-
{:error, {"cannot render <#{name}>", meta.line}, meta}
1520+
{:error, {"cannot render <#{name}>", meta.line, meta.column}, meta}
15181521
end
15191522
end
15201523

lib/surface/compiler/eex_engine.ex

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,7 @@ defmodule Surface.Compiler.EExEngine do
874874
meta:
875875
%AST.Meta{
876876
module: mod,
877+
file: file,
877878
line: line,
878879
column: col
879880
} = meta
@@ -882,7 +883,7 @@ defmodule Surface.Compiler.EExEngine do
882883
])
883884
when not is_nil(mod) do
884885
%{attributes: attributes, directives: directives, meta: %{node_alias: node_alias}} = component
885-
store_component_call(meta.caller.module, node_alias, mod, attributes, directives, line, col, :compile)
886+
store_component_call(meta.caller.module, node_alias, mod, attributes, directives, file, line, col, :compile)
886887
[to_dynamic_nested_html(children) | to_dynamic_nested_html(nodes)]
887888
end
888889

@@ -960,12 +961,12 @@ defmodule Surface.Compiler.EExEngine do
960961
{requires, Map.put(by_name, name, Enum.reverse(slot_entries))}
961962
end)
962963

963-
%{caller: caller, node_alias: node_alias, line: line, column: col} = component.meta
964+
%{caller: caller, node_alias: node_alias, file: file, line: line, column: col} = component.meta
964965
%{props: props, directives: directives} = component
965966

966967
if type != AST.FunctionComponent do
967968
dep_type = if is_atom(mod) and function_exported?(mod, :transform, 1), do: :compile, else: :export
968-
store_component_call(caller.module, node_alias, mod, props, directives, line, col, dep_type)
969+
store_component_call(caller.module, node_alias, mod, props, directives, file, line, col, dep_type)
969970
end
970971

971972
[requires, %{component | slot_entries: slot_entries_by_name} | to_dynamic_nested_html(nodes)]
@@ -984,6 +985,7 @@ defmodule Surface.Compiler.EExEngine do
984985
module,
985986
attributes,
986987
directives,
988+
meta.file,
987989
meta.line,
988990
meta.column,
989991
:compile
@@ -1143,7 +1145,7 @@ defmodule Surface.Compiler.EExEngine do
11431145
|> Macro.var(nil)
11441146
end
11451147

1146-
defp store_component_call(module, node_alias, component, props, directives, line, col, dep_type)
1148+
defp store_component_call(module, node_alias, component, props, directives, file, line, col, dep_type)
11471149
when dep_type in [:compile, :export] do
11481150
# No need to store dynamic modules
11491151
if !match?(%Surface.AST.AttributeExpr{}, component) do
@@ -1152,6 +1154,7 @@ defmodule Surface.Compiler.EExEngine do
11521154
component: component,
11531155
props: map_attrs(props),
11541156
directives: map_attrs(directives),
1157+
file: file,
11551158
line: line,
11561159
column: col,
11571160
dep_type: dep_type

lib/surface/compiler/helpers.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ defmodule Surface.Compiler.Helpers do
291291

292292
defp hint_for_unloaded_module(node_alias) do
293293
"""
294-
Hint: Make sure module `#{node_alias}` can be successfully compiled.
294+
make sure module `#{node_alias}` can be successfully compiled.
295295
296296
If the module is namespaced, you can use its full name. For instance:
297297

lib/surface/io_helper.ex

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,51 @@ defmodule Surface.IOHelper do
1313
IO.warn(message, stacktrace)
1414
end
1515

16+
if Version.match?(System.version(), ">= 1.14.0") do
17+
def warn(message, _caller, file, {line, column}) do
18+
IO.warn(message, file: file, line: line, column: column)
19+
end
20+
else
21+
# TODO: Remove this clause in Surface v0.13 and set required elixir to >= v1.14
22+
def warn(message, caller, file, {line, _column}) do
23+
warn(message, caller, file, line)
24+
end
25+
end
26+
1627
def warn(message, caller, file, line) do
1728
stacktrace = Macro.Env.stacktrace(%{caller | file: file, line: line})
1829

1930
IO.warn(message, stacktrace)
2031
end
2132

22-
@spec compile_error(String.t(), String.t(), integer()) :: no_return()
33+
if Version.match?(System.version(), ">= 1.14.0") do
34+
def compile_error(message, file, {line, column}) do
35+
reraise(%Surface.CompileError{file: file, line: line, column: column, description: message}, [])
36+
end
37+
else
38+
# TODO: Remove this clause in Surface v0.13 and set required elixir to >= v1.14
39+
def compile_error(message, file, {line, _column}) do
40+
reraise(%Surface.CompileError{line: line, file: file, description: message}, [])
41+
end
42+
end
43+
2344
def compile_error(message, file, line) do
24-
reraise(%CompileError{line: line, file: file, description: message}, [])
45+
reraise(%Surface.CompileError{line: line, file: file, description: message}, [])
46+
end
47+
48+
if Version.match?(System.version(), ">= 1.14.0") do
49+
def compile_error(message, hint, file, {line, column}) do
50+
reraise(%Surface.CompileError{file: file, line: line, column: column, description: message, hint: hint}, [])
51+
end
52+
else
53+
# TODO: Remove this clause in Surface v0.13 and set required elixir to >= v1.14
54+
def compile_error(message, hint, file, {line, _column}) do
55+
reraise(%Surface.CompileError{file: file, line: line, description: message, hint: hint}, [])
56+
end
57+
end
58+
59+
def compile_error(message, hint, file, line) do
60+
reraise(%Surface.CompileError{line: line, file: file, description: message, hint: hint}, [])
2561
end
2662

2763
@spec syntax_error(String.t(), String.t(), integer()) :: no_return()

test/mix/tasks/compile/surface/validate_components_test.exs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
7373
compiler_name: "Surface",
7474
details: nil,
7575
file: Path.expand("code"),
76-
message: "Missing required property \"title\" for component <RequiredPropTitle>",
76+
message: "missing required property \"title\" for component <RequiredPropTitle>",
7777
position: {0, 2},
7878
severity: :warning
7979
}
@@ -118,7 +118,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
118118
details: nil,
119119
file: Path.expand("code"),
120120
message: ~S"""
121-
Missing required property "id" for component <LiveComponentHasRequiredIdProp>
121+
missing required property "id" for component <LiveComponentHasRequiredIdProp>
122122
123123
Hint: Components using `Surface.LiveComponent` automatically define a required `id` prop to make them stateful.
124124
If you meant to create a stateless component, you can switch to `use Surface.Component`.
@@ -190,7 +190,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
190190
compiler_name: "Surface",
191191
details: nil,
192192
file: Path.expand("code"),
193-
message: "Missing required property \"title\" for component <#Macro>",
193+
message: "missing required property \"title\" for component <#Macro>",
194194
position: {1, 4},
195195
severity: :warning
196196
}
@@ -220,7 +220,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
220220
Path.expand(
221221
"test/support/mix/tasks/compile/surface/validate_components_test/live_view_with_external_template.sface"
222222
),
223-
message: "Missing required property \"value\" for component <ComponentCall>",
223+
message: "missing required property \"value\" for component <ComponentCall>",
224224
position: {1, 2},
225225
severity: :warning
226226
}
@@ -263,7 +263,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
263263
compiler_name: "Surface",
264264
details: nil,
265265
file: Path.expand("code"),
266-
message: "Missing required property \"list\" for component <Recursive>",
266+
message: "missing required property \"list\" for component <Recursive>",
267267
position: {0, 2},
268268
severity: :warning
269269
}

test/support/case.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
defmodule Surface.Case do
2+
@moduledoc """
3+
This module defines a generic test case importing common funcions for tests.
4+
"""
5+
6+
use ExUnit.CaseTemplate
7+
8+
using do
9+
quote do
10+
import ANSIHelpers
11+
end
12+
end
13+
end

test/support/conn_case.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defmodule Surface.ConnCase do
1515
use Surface.LiveViewTest
1616
import Floki
1717
import FlokiHelpers
18+
import ANSIHelpers
1819

1920
# The default endpoint for testing
2021
@endpoint Endpoint

0 commit comments

Comments
 (0)