Skip to content

[WIP] Add an :ets table to cache the list of files that can be formatted #394

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

Closed
3 changes: 2 additions & 1 deletion apps/language_server/lib/language_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ defmodule ElixirLS.LanguageServer do
children = [
{ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server},
{ElixirLS.LanguageServer.JsonRpc, name: ElixirLS.LanguageServer.JsonRpc},
{ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}
{ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []},
{ElixirLS.LanguageServer.Providers.Formatting, []}
]

opts = [strategy: :one_for_one, name: ElixirLS.LanguageServer.Supervisor, max_restarts: 0]
Expand Down
175 changes: 145 additions & 30 deletions apps/language_server/lib/language_server/providers/formatting.ex
Original file line number Diff line number Diff line change
@@ -1,23 +1,54 @@
defmodule ElixirLS.LanguageServer.Providers.Formatting do
@moduledoc """
Formatting Provider. Caches the list of files to be formatted, and formats them.
"""

## Approach
# On initialization, the GenServer in this module populates an `:ets` table
# with paths that should be formatted, allowing for rapid lookup when a given
# file is saved.
#
# Lookups _are_ serialized through this genserver to avoid race conditions
# when the server boots or when the cache has to be rebuilt due to changes in
# a .formatter.exs file. The GenServer call is extremely fast, since all of the
# actual formatting is accomplished client side.

use GenServer

defstruct [
:format_table,
:no_format_table,
:formatter_opts,
:project_dir
]

import ElixirLS.LanguageServer.Protocol, only: [range: 4]
alias ElixirLS.LanguageServer.JsonRpc
alias ElixirLS.LanguageServer.SourceFile

def build_cache(root_uri) do
GenServer.call(__MODULE__, {:build_cache, root_uri})
end

def formatting_opts_for_file(uri) do
GenServer.call(__MODULE__, {:opts_for_file, uri})
end

def format(%SourceFile{} = source_file, uri, project_dir) do
if can_format?(uri, project_dir) do
case SourceFile.formatter_opts(uri) do
{:ok, opts} ->
if should_format?(uri, project_dir, opts[:inputs]) do
formatted = IO.iodata_to_binary([Code.format_string!(source_file.text, opts), ?\n])
case formatting_opts_for_file(uri) do
{:format, opts} ->
formatted = IO.iodata_to_binary([Code.format_string!(source_file.text, opts), ?\n])

response =
source_file.text
|> String.myers_difference(formatted)
|> myers_diff_to_text_edits()

response =
source_file.text
|> String.myers_difference(formatted)
|> myers_diff_to_text_edits()
{:ok, response}

{:ok, response}
else
{:ok, []}
end
:ignore ->
{:ok, []}

:error ->
{:error, :internal_error, "Unable to fetch formatter options"}
Expand All @@ -43,24 +74,6 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do
String.starts_with?(file_path, File.cwd!())
end

defp can_format?(_uri, _project_dir), do: false

def should_format?(file_uri, project_dir, inputs) when is_list(inputs) do
file_path = file_uri |> SourceFile.path_from_uri() |> Path.absname()

inputs
|> Stream.flat_map(fn glob ->
[
Path.join([project_dir, glob]),
Path.join([project_dir, "apps", "*", glob])
]
end)
|> Stream.flat_map(&Path.wildcard(&1, match_dot: true))
|> Enum.any?(&(file_path == &1))
end

def should_format?(_file_uri, _project_dir, _inputs), do: true

defp myers_diff_to_text_edits(myers_diff) do
myers_diff_to_text_edits(myers_diff, {0, 0}, [])
end
Expand Down Expand Up @@ -101,4 +114,106 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do
end
end)
end

## GenServer Callbacks
#####################
def start_link(opts) do
GenServer.start_link(__MODULE__, :ok, opts |> Keyword.put_new(:name, __MODULE__))
end

def init(_) do
format_table = :ets.new(__MODULE__, [:set, :private])
no_format_table = :ets.new(__MODULE__, [:set, :private])

state = %__MODULE__{
format_table: format_table,
no_format_table: no_format_table,
formatter_opts: :error
}

{:ok, state}
end

def handle_call({:opts_for_file, file_uri}, _, state) do
file_path = file_uri |> SourceFile.path_from_uri() |> Path.absname()

reply =
case state.formatter_opts do
{:ok, opts} ->
formatting_directive(state, opts, file_path)

:error ->
:error
end

{:reply, reply, state}
end

def handle_call({:build_cache, dir}, _, state) do
opts_result = SourceFile.formatter_opts(dir)
:ets.delete_all_objects(state.format_table)
:ets.delete_all_objects(state.no_format_table)

case opts_result do
{:ok, opts} ->
JsonRpc.log_message(:info, "[ElixirLS Formatting] Building cache...")
populate_cache(dir, state.format_table, opts)
JsonRpc.log_message(:info, "[ElixirLS Formatting] Cache built.")

:error ->
JsonRpc.log_message(
:info,
"[ElixirLS Formatting] Cache will not be built: unable to handle formatter opts"
)
end

{:reply, :ok, %{state | project_dir: dir, formatter_opts: opts_result}}
end

defp populate_cache(project_dir, ets, opts) do
if inputs = opts[:inputs] do
inputs
|> Stream.flat_map(fn glob ->
[
Path.join([project_dir, glob]),
Path.join([project_dir, "apps", "*", glob])
]
end)
|> Stream.flat_map(&Path.wildcard(&1, match_dot: true))
|> Enum.each(fn file ->
:ets.insert(ets, {file})
end)
end
end

defp formatting_directive(state, opts, file_path) do
cond do
!opts[:inputs] ->
{:format, opts}

:ets.member(state.format_table, file_path) ->
{:format, opts}

:ets.member(state.no_format_table, file_path) ->
:ignore

true ->
# If the file is a path we have never seen before, we know there is
# a new file. We have no way of knowing whether that file should
# be formatted or not, so we have to rebuild the cache from the globs
# and re-check membership. If it's a member, great! If it is not,
# then we know for sure it should not be formatted and we cache that
# information for fast future lookup.
# :ets.insert(state.no_format_table, {file_path})

populate_cache(state.project_dir, state.format_table, opts)

if :ets.member(state.format_table, file_path) do
{:format, opts}
else
:ets.insert(state.no_format_table, {file_path})
:ignore
end
end
end
end
14 changes: 13 additions & 1 deletion apps/language_server/lib/language_server/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ defmodule ElixirLS.LanguageServer.Server do

if reason == :normal do
WorkspaceSymbols.notify_build_complete()
Formatting.build_cache(state.project_dir)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK so this solves the issue with formatting not being able to pull in deps initially. By re-building the the cache after every build we trivially catch updates to deps or other changes that effect formatting.

end

state = if state.needs_build?, do: trigger_build(state), else: state
Expand Down Expand Up @@ -296,7 +297,9 @@ defmodule ElixirLS.LanguageServer.Server do
prev_settings
end

set_settings(state, new_settings)
state = set_settings(state, new_settings)
Formatting.build_cache(state.project_dir)
state
end

defp handle_notification(notification("exit"), state) do
Expand Down Expand Up @@ -439,6 +442,15 @@ defmodule ElixirLS.LanguageServer.Server do
|> Enum.uniq()
|> WorkspaceSymbols.notify_uris_modified()

formatter_changed? =
Enum.any?(changes, fn %{"uri" => uri} ->
Path.basename(uri) == ".formatter.exs"
end)

if formatter_changed? do
Formatting.build_cache(state.project_dir)
end

if needs_build, do: trigger_build(state), else: state
end

Expand Down
4 changes: 4 additions & 0 deletions apps/language_server/test/support/server_test_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule ElixirLS.LanguageServer.Test.ServerTestHelpers do
alias ElixirLS.LanguageServer.Server
alias ElixirLS.LanguageServer.JsonRpc
alias ElixirLS.LanguageServer.Providers.WorkspaceSymbols
alias ElixirLS.LanguageServer.Providers.Formatting
alias ElixirLS.Utils.PacketCapture

def start_server do
Expand All @@ -15,6 +16,9 @@ defmodule ElixirLS.LanguageServer.Test.ServerTestHelpers do
json_rpc = start_supervised!({JsonRpc, name: JsonRpc})
Process.group_leader(json_rpc, packet_capture)

formatting = start_supervised!({Formatting, []})
Process.group_leader(formatting, packet_capture)

workspace_symbols = start_supervised!({WorkspaceSymbols, []})
Process.group_leader(workspace_symbols, packet_capture)

Expand Down