Skip to content

Commit befc60e

Browse files
jonleightonaxelson
andauthored
Fix formatting performance problems with .formatter.exs in subdirectories (#609)
* Convert should_format? test to use format/3 and fixtures This function is really an implementation detail, so let’s test the public API. The function also relies on interaction with the filesystem, so testing without fixtures is a bad idea because it will lead to incorrect results. * Check .formatter.exs :inputs relative to its directory When dealing with `.formatter.exs` files that are in subdirectories of the main project, the behaviour was incorrect: #361 And also caused performance problems: #381 A good example is a project I currently work on. We have a top-level `.formatter.exs`, as well as a `test/.formatter.exs`. The one in `test/` has the `:inputs` option set to `["**/*.{ex,exs}"]`. Before this commit, should_format?/3 would take this pattern and concatenate it with the project directory. This would result in Path.wildcard/2 globbing for all source files in the entire project, rather than just under test/. This causes formatting to take about 5 seconds on our project. This commit fixes the problem by taking the file to be formatted, and finding the `.formatter.exs` file that exists in the closest parent directory (so long as that directory is still within the project root). We then perform the Path.wildcard/2 relative to that directory. I’ve removed the hack that was in place to support umbrella projects, because this is a more general solution to the problem. On our project, I am now able to format a file in 100ms or so, which isn’t a bad speed-up! * Avoid costly Path.wildcard/2 call The call to Path.wildcard/2 can cause use to traverse a large number of files, which can be very slow depending on the project and the globs used: #381 I wrote a library that implements a glob parser and allows us to check whether a glob matches a path without touching the filesystem: https://github.com/jonleighton/path_glob This commit switches out the Path.wildcard/2 call to use this library. Co-authored-by: Jason Axelson <[email protected]>
1 parent 07209bd commit befc60e

File tree

9 files changed

+86
-21
lines changed

9 files changed

+86
-21
lines changed

apps/language_server/lib/language_server/providers/formatting.ex

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,35 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do
5353

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

56-
def should_format?(file_uri, project_dir, inputs) when is_list(inputs) do
56+
defp should_format?(file_uri, project_dir, inputs) when is_list(inputs) do
5757
file_path = file_uri |> SourceFile.abs_path_from_uri()
58+
formatter_dir = find_formatter_dir(project_dir, Path.dirname(file_path))
5859

59-
inputs
60-
|> Stream.flat_map(fn glob ->
61-
[
62-
Path.join([project_dir, glob]),
63-
Path.join([project_dir, "apps", "*", glob])
64-
]
60+
Enum.any?(inputs, fn input_glob ->
61+
glob = Path.join(formatter_dir, input_glob)
62+
PathGlob.match?(file_path, glob, match_dot: true)
6563
end)
66-
|> Stream.flat_map(&Path.wildcard(&1, match_dot: true))
67-
|> Enum.any?(&(file_path == &1))
6864
end
6965

70-
def should_format?(_file_uri, _project_dir, _inputs), do: true
66+
defp should_format?(_file_uri, _project_dir, _inputs), do: true
67+
68+
# Finds the deepest directory that contains file_path, that also contains a
69+
# .formatter.exs. It's possible, though unlikely, that the .formatter.exs we
70+
# find is not actually linked to the project_dir via the :subdirectories
71+
# option in the top-level .formatter.exs. Currently, that edge case is
72+
# glossed over.
73+
defp find_formatter_dir(project_dir, dir) do
74+
cond do
75+
dir == project_dir ->
76+
project_dir
77+
78+
Path.join(dir, ".formatter.exs") |> File.exists?() ->
79+
dir
80+
81+
true ->
82+
find_formatter_dir(project_dir, Path.dirname(dir))
83+
end
84+
end
7185

7286
defp myers_diff_to_text_edits(myers_diff) do
7387
myers_diff_to_text_edits(myers_diff, {0, 0}, [])

apps/language_server/mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ defmodule ElixirLS.LanguageServer.Mixfile do
3131
{:erl2ex, github: "dazuma/erl2ex"},
3232
{:dialyxir, "~> 1.0", runtime: false},
3333
{:jason_vendored, github: "elixir-lsp/jason", branch: "vendored"},
34-
{:stream_data, "~> 0.5", only: :test}
34+
{:stream_data, "~> 0.5", only: :test},
35+
{:path_glob, "~> 0.1.0"}
3536
]
3637
end
3738

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
[
22
line_length: 20,
3-
subdirectories: ["lib"]
3+
subdirectories: [
4+
"lib",
5+
"test",
6+
"symlink",
7+
"apps/*"
8+
],
9+
inputs: ["*.ex"]
410
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[
2+
inputs: ["foo.ex"]
3+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[
2+
inputs: ["*.exs"]
3+
]

apps/language_server/test/providers/formatting_test.exs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -474,18 +474,49 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do
474474
end)
475475
end
476476

477+
@tag :fixture
477478
test "honors :inputs when deciding to format" do
478-
file = __ENV__.file
479-
uri = SourceFile.path_to_uri(file)
480-
project_dir = Path.dirname(file)
479+
in_fixture(Path.join(__DIR__, ".."), "formatter", fn ->
480+
project_dir = Path.expand(".")
481+
482+
assert_formatted("file.ex", project_dir)
483+
484+
# test/.formatter.exs has [inputs: ["*.exs"]]
485+
assert_formatted("test/file.exs", project_dir)
486+
refute_formatted("test/file.ex", project_dir)
487+
488+
assert_formatted("symlink/file.exs", project_dir)
489+
refute_formatted("symlink/file.ex", project_dir)
490+
491+
File.mkdir!("#{project_dir}/test/foo")
492+
refute_formatted("test/foo/file.ex", project_dir)
493+
494+
# apps/foo/bar/.formatter.exs has [inputs: ["foo.ex"]]
495+
assert_formatted("apps/foo/foo.ex", project_dir)
496+
refute_formatted("apps/foo/bar.ex", project_dir)
497+
refute_formatted("apps/foo.ex", project_dir)
498+
end)
499+
end
500+
501+
def assert_formatted(path, project_dir) do
502+
assert match?({:ok, [%{}]}, format(path, project_dir)), "expected '#{path}' to be formatted"
503+
end
504+
505+
def refute_formatted(path, project_dir) do
506+
assert match?({:ok, []}, format(path, project_dir)), "expected '#{path}' not to be formatted"
507+
end
481508

482-
opts = []
483-
assert Formatting.should_format?(uri, project_dir, opts[:inputs])
509+
defp format(path, project_dir) do
510+
project_dir = maybe_convert_path_separators(project_dir)
511+
path = maybe_convert_path_separators("#{project_dir}/#{path}")
484512

485-
opts = [inputs: ["*.exs"]]
486-
assert Formatting.should_format?(uri, project_dir, opts[:inputs])
513+
source_file = %SourceFile{
514+
text: "",
515+
version: 1,
516+
dirty?: true
517+
}
487518

488-
opts = [inputs: ["*.ex"]]
489-
refute Formatting.should_format?(uri, project_dir, opts[:inputs])
519+
File.write!(path, "")
520+
Formatting.format(source_file, SourceFile.path_to_uri(path), project_dir)
490521
end
491522
end

apps/language_server/test/server_test.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,10 @@ defmodule ElixirLS.LanguageServer.ServerTest do
917917
in_fixture(__DIR__, "formatter", fn ->
918918
uri = Path.join([root_uri(), "file.ex"])
919919

920+
uri
921+
|> SourceFile.abs_path_from_uri()
922+
|> File.write!("")
923+
920924
code = """
921925
defmodule MyModule do
922926
def my_fn do

mix.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"},
88
"jason_vendored": {:git, "https://github.com/elixir-lsp/jason.git", "ee95ca80cd67b3a499a14f469536140935eb4483", [branch: "vendored"]},
99
"mix_task_archive_deps": {:git, "https://github.com/elixir-lsp/mix_task_archive_deps.git", "30fa76221def649286835685fec5d151be83c354", []},
10+
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
11+
"path_glob": {:hex, :path_glob, "0.1.0", "55067de99f3e920b849dfc18d7895cf6750c4e182d96690c8fec4d9aa950ac5d", [:mix], [{:nimble_parsec, "~> 1.1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9784b48d2f0f9b948b559954578300da3cd3382a7ed326528c489a8bc486cd07"},
1012
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
1113
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
1214
}

0 commit comments

Comments
 (0)