Skip to content

Commit

Permalink
Move the logic related to EEX into the EEX file.
Browse files Browse the repository at this point in the history
It's like peeling an onion. When we have an HEEx file,
we first use the HTML engine to transform and compile it.
Once the part that belongs to HTML is fine,
we then use the EEx engine to transform it, evaluate it, and finally compile it.
  • Loading branch information
scottming committed Aug 30, 2023
1 parent e9769f6 commit e988515
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 196 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.EEx do
"""
alias Lexical.Document
alias Lexical.Plugin.V1.Diagnostic.Result
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.Build.Document.Compiler
alias Lexical.RemoteControl.Build.Document.Compilers

Expand All @@ -18,8 +19,15 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.EEx do
end

def compile(%Document{} = document) do
with {:ok, quoted} <- eex_to_quoted(document) do
Compilers.Quoted.compile(document, quoted, "EEx")
with {:ok, quoted} <- eex_to_quoted(document),
:ok <- eval_quoted(document, quoted) do
compile_quoted(document, quoted)
end
end

defp compile_quoted(%Document{} = document, quoted) do
with {:error, errors} <- Compilers.Quoted.compile(document, quoted, "EEx") do
{:error, reject_undefined_assigns(errors)}
end
end

Expand All @@ -37,6 +45,72 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.EEx do
end
end

defp eval_quoted(%Document{} = document, quoted_ast) do
result =
if Elixir.Features.with_diagnostics?() do
eval_quoted_with_diagnostics(quoted_ast, document.path)
else
do_eval_quoted(quoted_ast, document.path)
end

case result do
{:ok, _quoted_ast} ->
:ok

{{:ok, _quoted_ast}, _} ->
# Ignore warnings for now
# because they will be handled by `compile_quoted/2`
# like: `assign @thing not available in EEx template`
:ok

{:exception, exception, stack, _quoted_ast} ->
converted =
document
|> Build.Error.error_to_diagnostic(exception, stack, quoted_ast)
|> Map.put(:source, "EEx")

{:error, [converted]}

{{:exception, exception, stack, _quoted_ast}, all_errors_and_warnings} ->
converted = Build.Error.error_to_diagnostic(document, exception, stack, quoted_ast)
maybe_diagnostics = Build.Error.diagnostics_from_mix(document, all_errors_and_warnings)

diagnostics =
[converted | maybe_diagnostics]
|> Enum.reverse()
|> Build.Error.refine_diagnostics()
|> Enum.map(&Map.replace!(&1, :source, "EEx"))

{:error, diagnostics}
end
end

defp eval_quoted_with_diagnostics(quoted_ast, path) do
# Using apply to prevent a compile warning on elixir < 1.15
# credo:disable-for-next-line
apply(Code, :with_diagnostics, [fn -> do_eval_quoted(quoted_ast, path) end])
end

def do_eval_quoted(quoted_ast, path) do
try do
{_, _} = Code.eval_quoted(quoted_ast, [assigns: %{}], file: path)
{:ok, quoted_ast}
rescue
exception ->
{filled_exception, stack} = Exception.blame(:error, exception, __STACKTRACE__)
{:exception, filled_exception, stack, quoted_ast}
end
end

defp reject_undefined_assigns(errors) do
# NOTE: Ignoring error for assigns makes sense,
# because we don't want such a error report,
# for example: `<%= @name %>`
Enum.reject(errors, fn %Result{message: message} ->
message =~ ~s[undefined variable "assigns"]
end)
end

defp error_to_result(%Document{} = document, %EEx.SyntaxError{} = error) do
position = {error.line, error.column}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.HEEx do
"""
alias Lexical.Document
alias Lexical.Plugin.V1.Diagnostic.Result
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.Build.Document.Compiler
alias Lexical.RemoteControl.Build.Document.Compilers
require Logger
Expand All @@ -20,15 +19,12 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.HEEx do
end

def compile(%Document{} = document) do
with {:ok, _quoted} <- heex_to_quoted(document),
{:ok, eex_quoted_ast} <- eval(document) do
compile_quoted(document, eex_quoted_ast)
end
end
case heex_to_quoted(document) do
{:ok, _} ->
Compilers.EEx.compile(document)

defp compile_quoted(%Document{} = document, quoted) do
with {:error, errors} <- Compilers.Quoted.compile(document, quoted, "HEEx") do
{:error, reject_undefined_assigns(errors)}
other ->
other
end
end

Expand All @@ -55,83 +51,10 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.HEEx do
end
end

defp eval(%Document{} = document) do
# Evaluating the Html.Engine compiled quoted doesn't report any errors,
# so we need to use the original `EEx` to compile it to quoted and evaluate it.
quoted_ast =
document
|> Document.to_string()
|> EEx.compile_string(file: document.path)

result =
if Elixir.Features.with_diagnostics?() do
eval_quoted_with_diagnostics(quoted_ast, document.path)
else
eval_quoted(quoted_ast, document.path)
end

case result do
{:ok, quoted_ast} ->
{:ok, quoted_ast}

{:exception, exception, stack} ->
converted =
document
|> Build.Error.error_to_diagnostic(exception, stack, quoted_ast)
|> Map.put(:source, "HEEx")

{:error, [converted]}

{{:ok, quoted_ast}, _} ->
# Ignore warnings for now
# because they will be handled by `compile_quoted/2`
# like: `assign @thing not available in EEx template`
{:ok, quoted_ast}

{{:exception, exception, stack, quoted_ast}, all_errors_and_warnings} ->
converted = Build.Error.error_to_diagnostic(document, exception, stack, quoted_ast)
maybe_diagnostics = Build.Error.diagnostics_from_mix(document, all_errors_and_warnings)

diagnostics =
[converted | maybe_diagnostics]
|> Enum.reverse()
|> Build.Error.refine_diagnostics()
|> Enum.map(&Map.replace!(&1, :source, "HEEx"))

{:error, diagnostics}
end
end

defp eval_quoted_with_diagnostics(quoted_ast, path) do
# Using apply to prevent a compile warning on elixir < 1.15
# credo:disable-for-next-line
apply(Code, :with_diagnostics, [fn -> eval_quoted(quoted_ast, path) end])
end

def eval_quoted(quoted_ast, path) do
try do
{_, _} = Code.eval_quoted(quoted_ast, [assigns: %{}], file: path)
{:ok, quoted_ast}
rescue
exception ->
{filled_exception, stack} = Exception.blame(:error, exception, __STACKTRACE__)
{:exception, filled_exception, stack, quoted_ast}
end
end

defp reject_undefined_assigns(errors) do
# NOTE: Ignoring error for assigns makes sense,
# because we don't want such a error report,
# for example: `<%= @name %>`
Enum.reject(errors, fn %Result{message: message} ->
message =~ ~s[undefined variable "assigns"]
end)
end

defp error_to_result(%Document{} = document, %EEx.SyntaxError{} = error) do
position = {error.line, error.column}

Result.new(document.uri, position, error.message, :error, "HEEx")
Result.new(document.uri, position, error.message, :error, "EEx")
end

defp error_to_result(document, %error_struct{} = error)
Expand Down
3 changes: 2 additions & 1 deletion apps/remote_control/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ defmodule Lexical.RemoteControl.MixProject do
{:elixir_sense, git: "https://github.com/elixir-lsp/elixir_sense.git"},
{:patch, "~> 0.12", only: [:dev, :test], optional: true, runtime: false},
{:path_glob, "~> 0.2", optional: true},
{:sourceror, "~> 0.12"}
{:sourceror, "~> 0.12"},
{:phoenix_live_view, "~> 0.19.5", only: [:dev, :test], optional: true, runtime: false}
]
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.EExTest do
end
end

describe "compile/1" do
describe "eex_to_quoted/1" do
setup [:with_capture_server]

test "handles syntax errors" do
Expand All @@ -63,6 +63,14 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.EExTest do
assert result.source == "EEx"
assert result.uri
end
end

describe "compile_quoted/2" do
setup [:with_capture_server]

setup do
Code.compiler_options(parser_options: [columns: true, token_metadata: true])
end

test "handles unused variables" do
assert {:ok, [%Result{} = result]} =
Expand All @@ -73,10 +81,45 @@ defmodule Lexical.RemoteControl.Build.Document.Compilers.EExTest do
|> compile()

assert result.message =~ ~s["something" is unused]
assert result.position == 1
assert result.position in [1, {1, 5}]
assert result.severity == :warning
assert result.source == "EEx"
assert result.uri =~ "file:///file.eex"
end
end

describe "eval_quoted/2" do
test "handles undefinied function" do
document = document_with_content(~q[
<%= IO.uts("thing") %>
])

assert {:error, [%Result{} = result]} = compile(document)
assert result.message =~ "function IO.uts/1 is undefined or private"
assert result.position == {1, 8}
assert result.severity == :error
assert result.source == "EEx"
assert result.uri =~ "file:///file.eex"
end

@tag :with_diagnostics
test "handles undefinied variable" do
document = document_with_content(~q[
<%= thing %>
])

assert {:error, [%Result{} = result]} = compile(document)

if Features.with_diagnostics?() do
assert result.message =~ "undefined variable \"thing\""
else
assert result.message =~ "undefined function thing/0"
end

assert result.position in [1, {1, 5}]
assert result.severity == :error
assert result.source == "EEx"
assert result.uri =~ "file:///file.eex"
end
end
end
Loading

0 comments on commit e988515

Please sign in to comment.