Skip to content

Commit

Permalink
Added support for per-file .eex compilation (#296)
Browse files Browse the repository at this point in the history
* Added support for per-file .eex compilation

Refactor / improvement of per-file compilation. Prior, we were just
shunting all files into the the elixir compiler, but as it turns out,
there are other types of files that need to be compiled.
This PR makes a compiler behaviour, and three implementations: elixir,
eex and no-op.
  • Loading branch information
scohen authored Aug 1, 2023
1 parent 0643fc2 commit f24761f
Show file tree
Hide file tree
Showing 9 changed files with 381 additions and 131 deletions.
134 changes: 4 additions & 130 deletions apps/remote_control/lib/lexical/remote_control/build/document.ex
Original file line number Diff line number Diff line change
@@ -1,138 +1,12 @@
defmodule Lexical.RemoteControl.Build.Document do
alias Elixir.Features
alias Lexical.Document
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.ModuleMappings
alias Lexical.RemoteControl.Build.Document.Compilers

import Lexical.RemoteControl.Build.CaptureIO, only: [capture_io: 2]
@compilers [Compilers.Elixir, Compilers.EEx, Compilers.NoOp]

def compile(%Document{} = document) do
case to_quoted(document) do
{:ok, quoted} ->
prepare_compile(document.path)

if Features.with_diagnostics?() do
do_compile(quoted, document)
else
do_compile_and_capture_io(quoted, document)
end

{:error, {meta, message_info, token}} ->
diagnostics = Build.Error.parse_error_to_diagnostics(document, meta, message_info, token)
{:error, diagnostics}
end
end

defp to_quoted(document) do
source_string = Document.to_string(document)
parser_options = [file: document.path] ++ parser_options()
Code.put_compiler_option(:ignore_module_conflict, true)
Code.string_to_quoted(source_string, parser_options)
end

defp do_compile(quoted_ast, document) do
old_modules = ModuleMappings.modules_in_file(document.path)

case compile_quoted_with_diagnostics(quoted_ast, document.path) do
{{:ok, modules}, []} ->
purge_removed_modules(old_modules, modules)
{:ok, []}

{{:ok, modules}, all_errors_and_warnings} ->
purge_removed_modules(old_modules, modules)

diagnostics =
document
|> Build.Error.diagnostics_from_mix(all_errors_and_warnings)
|> Build.Error.refine_diagnostics()

{:ok, diagnostics}

{{: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()

{:error, diagnostics}
end
end

defp do_compile_and_capture_io(quoted_ast, document) do
# credo:disable-for-next-line Credo.Check.Design.TagTODO
# TODO: remove this function once we drop support for Elixir 1.14
old_modules = ModuleMappings.modules_in_file(document.path)
compile = fn -> safe_compile_quoted(quoted_ast, document.path) end

case capture_io(:stderr, compile) do
{captured_messages, {:error, {:exception, {exception, _inner_stack}, stack}}} ->
error = Build.Error.error_to_diagnostic(document, exception, stack, [])
diagnostics = Build.Error.message_to_diagnostic(document, captured_messages)

{:error, [error | diagnostics]}

{captured_messages, {:exception, exception, stack, quoted_ast}} ->
error = Build.Error.error_to_diagnostic(document, exception, stack, quoted_ast)
diagnostics = Build.Error.message_to_diagnostic(document, captured_messages)

{:error, [error | diagnostics]}

{"", {:ok, modules}} ->
purge_removed_modules(old_modules, modules)
{:ok, []}

{captured_warnings, {:ok, modules}} ->
purge_removed_modules(old_modules, modules)
diagnostics = Build.Error.message_to_diagnostic(document, captured_warnings)
{:ok, diagnostics}
end
end

defp prepare_compile(path) do
# If we're compiling a mix.exs file, the after compile callback from
# `use Mix.Project` will blow up if we add the same project to the project stack
# twice. Preemptively popping it prevents that error from occurring.
if Path.basename(path) == "mix.exs" do
Mix.ProjectStack.pop()
end

Mix.Task.run(:loadconfig)
end

@dialyzer {:nowarn_function, compile_quoted_with_diagnostics: 2}

defp compile_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 -> safe_compile_quoted(quoted_ast, path) end])
end

defp safe_compile_quoted(quoted_ast, path) do
try do
{:ok, Code.compile_quoted(quoted_ast, path)}
rescue
exception ->
{filled_exception, stack} = Exception.blame(:error, exception, __STACKTRACE__)
{:exception, filled_exception, stack, quoted_ast}
end
end

defp purge_removed_modules(old_modules, new_modules) do
new_modules = MapSet.new(new_modules, fn {module, _bytecode} -> module end)
old_modules = MapSet.new(old_modules)

old_modules
|> MapSet.difference(new_modules)
|> Enum.each(fn to_remove ->
:code.purge(to_remove)
:code.delete(to_remove)
end)
end

defp parser_options do
[columns: true, token_metadata: true]
compiler = Enum.find(@compilers, & &1.recognizes?(document))
compiler.compile(document)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Lexical.RemoteControl.Build.Document.Compiler do
@moduledoc """
A behaviour for document-level compilers
"""
alias Lexical.Document
alias Lexical.Plugin.V1.Diagnostic

@type compile_response :: {:ok, [Diagnostic.Result.t()]} | {:error, [Diagnostic.Result.t()]}

@doc """
Compiles a document
Compiles a document, returning an error tuple if the document won't compile,
or an ok tuple if it does. In either case, it will also return a list of warnings or errors
"""
@callback compile(Document.t()) :: compile_response()

@doc """
Returns true if the document can be compiled by the given compiler.
"""
@callback recognizes?(Document.t()) :: boolean()

@doc """
Returns true if the compiler is enabled.
"""
@callback enabled?() :: boolean
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule Lexical.RemoteControl.Build.Document.Compilers.EEx do
@moduledoc """
A compiler for .eex files
"""
alias Lexical.Document
alias Lexical.Plugin.V1.Diagnostic.Result
alias Lexical.RemoteControl.Build.Document.Compiler
alias Lexical.RemoteControl.Build.Document.Compilers

@behaviour Compiler

def recognizes?(%Document{} = document) do
Path.extname(document.path) == ".eex"
end

def enabled? do
true
end

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

defp eex_to_quoted(%Document{} = document) do
try do
quoted =
document
|> Document.to_string()
|> EEx.compile_string(file: document.path)

{: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
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
defmodule Lexical.RemoteControl.Build.Document.Compilers.Elixir do
@moduledoc """
A compiler for elixir source files (.ex and .exs)
"""

alias Elixir.Features
alias Lexical.Document
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.Build.Document.Compilers

@behaviour Build.Document.Compiler
@valid_extensions ~w(.ex .exs)

def recognizes?(%Document{} = doc) do
Path.extname(doc.path) in @valid_extensions
end

def enabled? do
true
end

def compile(%Document{} = document) do
case to_quoted(document) do
{:ok, quoted} ->
Compilers.Quoted.compile(document, quoted, "Elixir")

{:error, {meta, message_info, token}} ->
diagnostics = Build.Error.parse_error_to_diagnostics(document, meta, message_info, token)
{:error, diagnostics}
end
end

defp to_quoted(document) do
source_string = Document.to_string(document)
parser_options = [file: document.path] ++ parser_options()
Code.put_compiler_option(:ignore_module_conflict, true)
Code.string_to_quoted(source_string, parser_options)
end

defp parser_options do
[columns: true, token_metadata: true]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule Lexical.RemoteControl.Build.Document.Compilers.NoOp do
@moduledoc """
A no-op, catch-all compiler. Always enabled, recognizes everything and returns no errors
"""
alias Lexical.RemoteControl.Build.Document

@behaviour Document.Compiler

def recognizes?(_), do: true

def enabled?, do: true

def compile(_), do: {:ok, []}
end
Loading

0 comments on commit f24761f

Please sign in to comment.