Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for per-file .eex compilation #296

Merged
merged 5 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,134 @@
defmodule Lexical.RemoteControl.Build.Document.Compilers.Quoted do
@moduledoc """
A compiler for elixir quoted AST

Most of the other compilers produce AST and then delegate to this one, which then
has to compile the AST and, prior to 1.15, capture the IO messages that it emits.
"""
alias Elixir.Features
alias Lexical.Document
alias Lexical.Plugin.V1.Diagnostic.Result
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.ModuleMappings

import Lexical.RemoteControl.Build.CaptureIO, only: [capture_io: 2]

def compile(%Document{} = document, quoted_ast, compiler_name) do
prepare_compile(document.path)

{status, diagnostics} =
if Features.with_diagnostics?() do
do_compile(quoted_ast, document)
else
do_compile_and_capture_io(quoted_ast, document)
end

{status, Enum.map(diagnostics, &replace_source(&1, compiler_name))}
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 replace_source(%Result{} = result, compiler_name) do
%Result{result | source: compiler_name}
end
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
Loading
Loading