diff --git a/apps/remote_control/lib/lexical/remote_control/build/document.ex b/apps/remote_control/lib/lexical/remote_control/build/document.ex index 354b16569..16d4065e1 100644 --- a/apps/remote_control/lib/lexical/remote_control/build/document.ex +++ b/apps/remote_control/lib/lexical/remote_control/build/document.ex @@ -3,7 +3,7 @@ defmodule Lexical.RemoteControl.Build.Document do alias Lexical.Document alias Lexical.RemoteControl.Build.Document.Compilers - @compilers [Compilers.Config, Compilers.Elixir, Compilers.EEx, Compilers.NoOp] + @compilers [Compilers.Config, Compilers.Elixir, Compilers.EEx, Compilers.HEEx, Compilers.NoOp] def compile(%Document{} = document) do compiler = Enum.find(@compilers, & &1.recognizes?(document)) diff --git a/apps/remote_control/lib/lexical/remote_control/build/document/compilers/eex.ex b/apps/remote_control/lib/lexical/remote_control/build/document/compilers/eex.ex index 93ed1a19b..89c2671f7 100644 --- a/apps/remote_control/lib/lexical/remote_control/build/document/compilers/eex.ex +++ b/apps/remote_control/lib/lexical/remote_control/build/document/compilers/eex.ex @@ -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 @@ -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 @@ -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, _eval_result} -> + :ok + + {{:ok, _eval_result}, _} -> + # 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 + {result, _} = Code.eval_quoted(quoted_ast, [assigns: %{}], file: path) + {:ok, result} + 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} diff --git a/apps/remote_control/lib/lexical/remote_control/build/document/compilers/heex.ex b/apps/remote_control/lib/lexical/remote_control/build/document/compilers/heex.ex new file mode 100644 index 000000000..a08459782 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/build/document/compilers/heex.ex @@ -0,0 +1,68 @@ +defmodule Lexical.RemoteControl.Build.Document.Compilers.HEEx do + @moduledoc """ + A compiler for .heex files + """ + alias Lexical.Document + alias Lexical.Plugin.V1.Diagnostic.Result + alias Lexical.RemoteControl.Build.Document.Compiler + alias Lexical.RemoteControl.Build.Document.Compilers + require Logger + + @behaviour Compiler + + def recognizes?(%Document{} = document) do + Path.extname(document.path) == ".heex" + end + + def enabled? do + true + end + + def compile(%Document{} = document) do + case heex_to_quoted(document) do + {:ok, _} -> + Compilers.EEx.compile(document) + + other -> + other + end + end + + defp heex_to_quoted(%Document{} = document) do + try do + source = Document.to_string(document) + + opts = + [ + source: source, + file: document.path, + caller: __ENV__, + engine: Phoenix.LiveView.TagEngine, + subengine: Phoenix.LiveView.Engine, + tag_handler: Phoenix.LiveView.HTMLEngine + ] + + quoted = EEx.compile_string(source, opts) + + {:ok, quoted} + rescue + error -> + {:error, [error_to_result(document, error)]} + 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, "EEx") + end + + defp error_to_result(document, %error_struct{} = error) + when error_struct in [ + TokenMissingError, + Phoenix.LiveView.Tokenizer.ParseError + ] do + position = {error.line, error.column} + Result.new(document.uri, position, error.description, :error, "HEEx") + end +end diff --git a/apps/remote_control/mix.exs b/apps/remote_control/mix.exs index 8e72f2351..86094c6a1 100644 --- a/apps/remote_control/mix.exs +++ b/apps/remote_control/mix.exs @@ -41,7 +41,8 @@ defmodule Lexical.RemoteControl.MixProject do {:lexical_test, path: "../../projects/lexical_test", only: :test}, {:patch, "~> 0.12", only: [:dev, :test], optional: true, runtime: false}, {:path_glob, "~> 0.2", optional: true}, - {:sourceror, "~> 0.14.0"} + {:sourceror, "~> 0.14.0"}, + {:phoenix_live_view, "~> 0.19.5", only: [:test], optional: true, runtime: false} ] end diff --git a/apps/remote_control/test/lexical/remote_control/build/document/compilers/eex_test.exs b/apps/remote_control/test/lexical/remote_control/build/document/compilers/eex_test.exs index 38b7f8bb6..f53933a0e 100644 --- a/apps/remote_control/test/lexical/remote_control/build/document/compilers/eex_test.exs +++ b/apps/remote_control/test/lexical/remote_control/build/document/compilers/eex_test.exs @@ -45,7 +45,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 @@ -65,6 +65,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]} = @@ -75,10 +83,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 diff --git a/apps/remote_control/test/lexical/remote_control/build/document/compilers/heex_test.exs b/apps/remote_control/test/lexical/remote_control/build/document/compilers/heex_test.exs new file mode 100644 index 000000000..a8e232749 --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/build/document/compilers/heex_test.exs @@ -0,0 +1,86 @@ +defmodule Lexical.RemoteControl.Build.Document.Compilers.HeexTest do + alias Lexical.Document + alias Lexical.Plugin.V1.Diagnostic.Result + alias Lexical.RemoteControl.Build.CaptureServer + alias Lexical.RemoteControl.Build.Document.Compilers + alias Lexical.RemoteControl.Dispatch + alias Lexical.RemoteControl.ModuleMappings + + import Lexical.Test.CodeSigil + import Compilers.HEEx, only: [compile: 1] + + use ExUnit.Case + + def with_capture_server(_) do + start_supervised!(CaptureServer) + start_supervised!(Dispatch) + start_supervised!(ModuleMappings) + :ok + end + + def document_with_content(content) do + Document.new("file:///file.heex", content, 0) + end + + setup do + Code.compiler_options(parser_options: [columns: true, token_metadata: true]) + end + + describe "compile/1" do + setup [:with_capture_server] + + test "handles valid HEEx content" do + document = document_with_content(~q[ +