-
-
Notifications
You must be signed in to change notification settings - Fork 82
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added support for per-file .eex compilation (#296)
* 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
Showing
9 changed files
with
381 additions
and
131 deletions.
There are no files selected for viewing
134 changes: 4 additions & 130 deletions
134
apps/remote_control/lib/lexical/remote_control/build/document.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
26 changes: 26 additions & 0 deletions
26
apps/remote_control/lib/lexical/remote_control/build/document/compiler.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
45 changes: 45 additions & 0 deletions
45
apps/remote_control/lib/lexical/remote_control/build/document/compilers/eex.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
43 changes: 43 additions & 0 deletions
43
apps/remote_control/lib/lexical/remote_control/build/document/compilers/elixir.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
14 changes: 14 additions & 0 deletions
14
apps/remote_control/lib/lexical/remote_control/build/document/compilers/no_op.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.