Skip to content

Commit

Permalink
Improve errors and diagnostics
Browse files Browse the repository at this point in the history
  • Loading branch information
msaraiva committed Oct 23, 2024
1 parent 5a11bc4 commit a403c38
Show file tree
Hide file tree
Showing 26 changed files with 256 additions and 158 deletions.
2 changes: 1 addition & 1 deletion lib/mix/tasks/compile/surface/validate_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponents do
missing_props_names = required_props_names -- existing_props_names

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

message =
if prop_name == :id and Helpers.is_stateful_component(module) do
Expand Down
12 changes: 10 additions & 2 deletions lib/surface/base_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ defmodule Surface.BaseComponent do
components = Enum.uniq_by(components_calls, & &1.component)

{imports, requires} =
for %{component: mod, line: line, dep_type: dep_type} <- components, mod != env.module, reduce: {[], []} do
for %{component: mod, file: file, line: line, dep_type: dep_type} <- components,
mod != env.module,
reduce: {[], []} do
{imports, requires} ->
case dep_type do
:export ->
Expand All @@ -135,9 +137,15 @@ defmodule Surface.BaseComponent do
], requires}

:compile ->
# We use `require` for macros or when there's an error loading the
# module. This way if the missing/failing module is created/fixed,
# Elixir will recompile this file.
# NOTE: there's a bug in Elixir that report the error with the wrong line
# in versions <= 1.7. See https://github.com/elixir-lang/elixir/issues/13542
# for details.
{imports,
[
quote line: line do
quote file: file, line: line do
require(unquote(mod)).__info__(:module)
end
| requires
Expand Down
58 changes: 58 additions & 0 deletions lib/surface/compile_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
defmodule Surface.CompileError do
@moduledoc """
An exception raised when there's a Surface compile error.
The following fields of this exceptions are public and can be accessed freely:
* `:file` (`t:Path.t/0` or `nil`) - the file where the error occurred, or `nil` if
the error occurred in code that did not come from a file
* `:line` - the line where the error occurred
* `:column` - the column where the error occurred
* `:description` - a description of the error
* `:hint` - a hint to help the user to fix the issue
"""

@support_snippet Version.match?(System.version(), ">= 1.16.0")

defexception [:file, :line, :column, :snippet, :hint, description: "compile error"]

@impl true
def message(%{
file: file,
line: line,
column: column,
description: description,
hint: hint
}) do
format_message(file, line, column, description, hint)
end

if @support_snippet do
defp format_message(file, line, column, description, hint) do
message =
if File.exists?(file) do
{lineCode, _} = File.stream!(file) |> Stream.with_index() |> Enum.at(line - 1)
lineCode = String.trim_trailing(lineCode)
:elixir_errors.format_snippet(:error, {line, column}, file, description, lineCode, %{})
else
description
end

hint =
if hint do
"\n\n" <> :elixir_errors.format_snippet(:hint, nil, nil, hint, nil, %{})
else
""
end

location = Exception.format_file_line_column(Path.relative_to_cwd(file), line, column)
location <> " " <> message <> hint
end
else
defp format_message(file, line, column, description, hint) do
location = Exception.format_file_line_column(Path.relative_to_cwd(file), line, column)
location <> " " <> description <> (hint || "")
end
end
end
23 changes: 13 additions & 10 deletions lib/surface/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -283,13 +283,15 @@ defmodule Surface.Compiler do
{:ok, ast} ->
process_directives(ast)

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

{:error, {message, details, line}, meta} ->
{:error, {message, details, line, column}, meta} ->
details = if details, do: "\n\n" <> details, else: ""
IOHelper.warn(message <> details, compile_meta.caller, meta.file, line)
IOHelper.warn(message <> details, compile_meta.caller, meta.file, {line, column})
# TODO: check if it's better to raise a compile error
# IOHelper.compile_error(message, details, meta.file, {line, column})
%AST.Error{message: message, meta: meta}
end
end
Expand Down Expand Up @@ -854,8 +856,9 @@ defmodule Surface.Compiler do
end
else
false ->
{:error, {"cannot render <#{node_alias}> (MacroComponents must export an expand/3 function)", meta.line},
meta}
{:error,
{"cannot render <#{node_alias}> (MacroComponents must export an expand/3 function)", meta.line,
meta.column}, meta}

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

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

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

_ ->
{:error, {"cannot render <#{name}>", meta.line}, meta}
{:error, {"cannot render <#{name}>", meta.line, meta.column}, meta}
end
end

Expand Down
11 changes: 7 additions & 4 deletions lib/surface/compiler/eex_engine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,7 @@ defmodule Surface.Compiler.EExEngine do
meta:
%AST.Meta{
module: mod,
file: file,
line: line,
column: col
} = meta
Expand All @@ -882,7 +883,7 @@ defmodule Surface.Compiler.EExEngine do
])
when not is_nil(mod) do
%{attributes: attributes, directives: directives, meta: %{node_alias: node_alias}} = component
store_component_call(meta.caller.module, node_alias, mod, attributes, directives, line, col, :compile)
store_component_call(meta.caller.module, node_alias, mod, attributes, directives, file, line, col, :compile)
[to_dynamic_nested_html(children) | to_dynamic_nested_html(nodes)]
end

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

%{caller: caller, node_alias: node_alias, line: line, column: col} = component.meta
%{caller: caller, node_alias: node_alias, file: file, line: line, column: col} = component.meta
%{props: props, directives: directives} = component

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

[requires, %{component | slot_entries: slot_entries_by_name} | to_dynamic_nested_html(nodes)]
Expand All @@ -984,6 +985,7 @@ defmodule Surface.Compiler.EExEngine do
module,
attributes,
directives,
meta.file,
meta.line,
meta.column,
:compile
Expand Down Expand Up @@ -1143,7 +1145,7 @@ defmodule Surface.Compiler.EExEngine do
|> Macro.var(nil)
end

defp store_component_call(module, node_alias, component, props, directives, line, col, dep_type)
defp store_component_call(module, node_alias, component, props, directives, file, line, col, dep_type)
when dep_type in [:compile, :export] do
# No need to store dynamic modules
if !match?(%Surface.AST.AttributeExpr{}, component) do
Expand All @@ -1152,6 +1154,7 @@ defmodule Surface.Compiler.EExEngine do
component: component,
props: map_attrs(props),
directives: map_attrs(directives),
file: file,
line: line,
column: col,
dep_type: dep_type
Expand Down
2 changes: 1 addition & 1 deletion lib/surface/compiler/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ defmodule Surface.Compiler.Helpers do

defp hint_for_unloaded_module(node_alias) do
"""
Hint: Make sure module `#{node_alias}` can be successfully compiled.
Hint: make sure module `#{node_alias}` can be successfully compiled.
If the module is namespaced, you can use its full name. For instance:
Expand Down
25 changes: 23 additions & 2 deletions lib/surface/io_helper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,36 @@ defmodule Surface.IOHelper do
IO.warn(message, stacktrace)
end

if Version.match?(System.version(), ">= 1.14.0") do
def warn(message, _caller, file, {line, column}) do
IO.warn(message, file: file, line: line, column: column)
end
else
# TODO: Remove this clause in Surface v0.13 and set required elixir to >= v1.14
def warn(message, caller, file, {line, _column}) do
warn(message, caller, file, line)
end
end

def warn(message, caller, file, line) do
stacktrace = Macro.Env.stacktrace(%{caller | file: file, line: line})

IO.warn(message, stacktrace)
end

@spec compile_error(String.t(), String.t(), integer()) :: no_return()
if Version.match?(System.version(), ">= 1.14.0") do
def compile_error(message, hint, file, {line, column}) do
reraise(%Surface.CompileError{file: file, line: line, column: column, description: message, hint: hint}, [])
end
else
# TODO: Remove this clause in Surface v0.13 and set required elixir to >= v1.14
def compile_error(message, file, {line, _column}) do
compile_error(message, file, line)
end
end

def compile_error(message, file, line) do
reraise(%CompileError{line: line, file: file, description: message}, [])
reraise(%Surface.CompileError{line: line, file: file, description: message}, [])
end

@spec syntax_error(String.t(), String.t(), integer()) :: no_return()
Expand Down
10 changes: 5 additions & 5 deletions test/mix/tasks/compile/surface/validate_components_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
compiler_name: "Surface",
details: nil,
file: Path.expand("code"),
message: "Missing required property \"title\" for component <RequiredPropTitle>",
message: "missing required property \"title\" for component <RequiredPropTitle>",
position: {0, 2},
severity: :warning
}
Expand Down Expand Up @@ -118,7 +118,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
details: nil,
file: Path.expand("code"),
message: ~S"""
Missing required property "id" for component <LiveComponentHasRequiredIdProp>
missing required property "id" for component <LiveComponentHasRequiredIdProp>
Hint: Components using `Surface.LiveComponent` automatically define a required `id` prop to make them stateful.
If you meant to create a stateless component, you can switch to `use Surface.Component`.
Expand Down Expand Up @@ -190,7 +190,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
compiler_name: "Surface",
details: nil,
file: Path.expand("code"),
message: "Missing required property \"title\" for component <#Macro>",
message: "missing required property \"title\" for component <#Macro>",
position: {1, 4},
severity: :warning
}
Expand Down Expand Up @@ -220,7 +220,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
Path.expand(
"test/support/mix/tasks/compile/surface/validate_components_test/live_view_with_external_template.sface"
),
message: "Missing required property \"value\" for component <ComponentCall>",
message: "missing required property \"value\" for component <ComponentCall>",
position: {1, 2},
severity: :warning
}
Expand Down Expand Up @@ -263,7 +263,7 @@ defmodule Mix.Tasks.Compile.Surface.ValidateComponentsTest do
compiler_name: "Surface",
details: nil,
file: Path.expand("code"),
message: "Missing required property \"list\" for component <Recursive>",
message: "missing required property \"list\" for component <Recursive>",
position: {0, 2},
severity: :warning
}
Expand Down
Loading

0 comments on commit a403c38

Please sign in to comment.