Skip to content

Commit 715a38a

Browse files
author
José Valim
committed
Integrate non-fetchable (path) dependencies into compilers
This commit changes Elixir compilers so a path dependencies no longer forces the "parent" project to recompile. This means each compiler must know how to track dependencies but it gives faster compilation times as a benefit. Signed-off-by: José Valim <[email protected]>
1 parent 7cc1ab4 commit 715a38a

File tree

6 files changed

+137
-70
lines changed

6 files changed

+137
-70
lines changed

lib/mix/lib/mix/compilers/elixir.ex

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ defmodule Mix.Compilers.Elixir do
2020
all = Mix.Utils.extract_files(keep, exts)
2121
{all_entries, skip_entries, all_sources, skip_sources} = parse_manifest(manifest, keep)
2222

23+
modified = Mix.Utils.last_modified(manifest)
24+
2325
removed =
2426
for {source, _files} <- all_sources,
2527
not(source in all),
@@ -31,25 +33,25 @@ defmodule Mix.Compilers.Elixir do
3133
# changed, let's just compile everything
3234
all
3335
else
34-
modified = Mix.Utils.last_modified(manifest)
35-
all_mtimes = mtimes(all_sources)
36+
sources_mtimes = mtimes(all_sources)
3637

37-
# Otherwise let's start with the new ones
38-
# plus the ones that have changed
38+
# Otherwise let's start with the new sources
3939
for(source <- all,
4040
not Map.has_key?(all_sources, source),
4141
do: source)
4242
++
43+
# Plus the sources that have changed in disk
4344
for({source, files} <- all_sources,
44-
times = Enum.map([source|files], &Map.fetch!(all_mtimes, &1)),
45+
times = Enum.map([source|files], &Map.fetch!(sources_mtimes, &1)),
4546
Mix.Utils.stale?(times, [modified]),
4647
do: source)
4748
end
4849

4950
sources = update_stale_sources(all_sources, removed, changed)
50-
{entries, changed} = remove_stale_entries(all_entries, removed ++ changed)
51-
stale = changed -- removed
51+
{entries, changed} = update_stale_entries(all_entries, removed ++ changed,
52+
stale_local_deps(modified))
5253

54+
stale = changed -- removed
5355
new_entries = entries ++ skip_entries
5456
new_sources = Map.merge(sources, skip_sources)
5557

@@ -89,9 +91,9 @@ defmodule Mix.Compilers.Elixir do
8991
Returns protocols and implementations for the given manifest.
9092
"""
9193
def protocols_and_impls(manifest) do
92-
for {_, module, kind, _, _, _, _} <- read_manifest(manifest),
94+
for {beam, module, kind, _, _, _, _} <- read_manifest(manifest),
9395
match?(:protocol, kind) or match?({:impl, _}, kind),
94-
do: {module, kind}
96+
do: {module, kind, beam}
9597
end
9698

9799
defp compile_manifest(manifest, entries, sources, stale, dest, on_start) do
@@ -183,12 +185,13 @@ defmodule Mix.Compilers.Elixir do
183185
# files that have changed. It then, recursively, figures out
184186
# all the files that changed (via the module dependencies) and
185187
# return the non-changed entries and the removed sources.
186-
defp remove_stale_entries(all, []) do
188+
defp update_stale_entries(all, [], stale) when stale == %{} do
187189
{all, []}
188190
end
189191

190-
defp remove_stale_entries(all, changed) do
191-
remove_stale_entries(all, %{}, Enum.into(changed, %{}, &{&1, true}))
192+
defp update_stale_entries(all, changed, stale) do
193+
removed = Enum.into(changed, %{}, &{&1, true})
194+
remove_stale_entries(all, stale, removed)
192195
end
193196

194197
defp remove_stale_entries(entries, old_stale, old_removed) do
@@ -225,6 +228,16 @@ defmodule Mix.Compilers.Elixir do
225228
end
226229
end
227230

231+
defp stale_local_deps(modified) do
232+
for %{scm: scm} = dep <- Mix.Dep.children,
233+
not scm.fetchable?,
234+
path <- Mix.Dep.load_paths(dep),
235+
beam <- Path.wildcard(Path.join(path, "*.beam")),
236+
Mix.Utils.last_modified(beam) > modified,
237+
do: {beam |> Path.basename |> Path.rootname |> String.to_atom, true},
238+
into: %{}
239+
end
240+
228241
## Manifest handling
229242

230243
defp read_manifest(manifest) do

lib/mix/lib/mix/tasks/compile.elixir.ex

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,6 @@ defmodule Mix.Tasks.Compile.Elixir do
8080
Mix.Compilers.Elixir.clean(manifest())
8181
end
8282

83-
@doc false
84-
def protocols_and_impls do
85-
Mix.Compilers.Elixir.protocols_and_impls(manifest())
86-
end
87-
8883
defp set_compiler_opts(project, opts, extra) do
8984
opts = Keyword.take(opts, Code.available_compiler_options)
9085
opts = Keyword.merge(project[:elixirc_options] || [], opts)

lib/mix/lib/mix/tasks/compile.ex

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ defmodule Mix.Tasks.Compile do
8181
Mix.Project.get!
8282
Mix.Task.run "loadpaths", args
8383

84-
if local_deps_changed?() do
85-
Mix.Dep.Lock.touch_manifest
86-
end
87-
8884
res = Mix.Task.run "compile.all", args
8985
res = if :ok in List.wrap(res), do: :ok, else: :noop
9086

@@ -105,17 +101,6 @@ defmodule Mix.Tasks.Compile do
105101
Mix.Project.config[:consolidate_protocols]
106102
end
107103

108-
defp local_deps_changed? do
109-
manifest = Path.absname(Mix.Dep.Lock.manifest())
110-
111-
Enum.any?(Mix.Dep.children(), fn %{scm: scm} = dep ->
112-
not scm.fetchable? and Mix.Dep.in_dependency(dep, fn _ ->
113-
files = Mix.Project.config_files ++ manifests()
114-
Mix.Utils.stale?(files, [manifest])
115-
end)
116-
end)
117-
end
118-
119104
@doc """
120105
Returns all compilers.
121106
"""

lib/mix/lib/mix/tasks/compile.protocols.ex

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule Mix.Tasks.Compile.Protocols do
22
use Mix.Task
33

44
@manifest ".compile.protocols"
5-
@manifest_vsn :v1
5+
@manifest_vsn :v2
66

77
@moduledoc ~S"""
88
Consolidates all protocols in all paths.
@@ -41,11 +41,12 @@ defmodule Mix.Tasks.Compile.Protocols do
4141
Mix.Task.run "compile", args
4242
{opts, _, _} = OptionParser.parse(args, switches: [force: :boolean])
4343

44-
output = default_path()
44+
output = default_path(config)
4545
manifest = Path.join(output, @manifest)
46+
4647
protocols_and_impls =
4748
unless Mix.Project.umbrella?(config) do
48-
Mix.Tasks.Compile.Elixir.protocols_and_impls
49+
protocols_and_impls(config)
4950
end
5051

5152
cond do
@@ -74,7 +75,24 @@ defmodule Mix.Tasks.Compile.Protocols do
7475
end
7576

7677
@doc false
77-
def default_path, do: Path.join(Mix.Project.build_path, "consolidated")
78+
def default_path(config \\ Mix.Project.config) do
79+
Path.join(Mix.Project.build_path(config), "consolidated")
80+
end
81+
82+
defp protocols_and_impls(config) do
83+
deps = for(%{scm: scm, app: app} <- Mix.Dep.children,
84+
not scm.fetchable?,
85+
do: app)
86+
87+
build_path = Path.join(Mix.Project.build_path(config), "lib")
88+
89+
protocols_and_impls =
90+
for app <- [config[:app] | deps],
91+
elixir = Path.join([build_path, Atom.to_string(app), ".compile.elixir"]),
92+
do: Mix.Compilers.Elixir.protocols_and_impls(elixir)
93+
94+
Enum.concat(protocols_and_impls)
95+
end
7896

7997
defp consolidation_paths do
8098
filter_otp(:code.get_path, :code.lib_dir)
@@ -134,23 +152,34 @@ defmodule Mix.Tasks.Compile.Protocols do
134152
end
135153

136154
defp diff_manifest(manifest, new_metadata, output) do
155+
modified = Mix.Utils.last_modified(manifest)
137156
old_metadata = read_manifest(manifest)
138157

139-
additions =
140-
Enum.flat_map(new_metadata -- old_metadata, fn
141-
{_, {:impl, protocol}} -> [protocol]
142-
{protocol, :protocol} -> [protocol]
158+
protocols =
159+
for {protocol, :protocol, beam} <- new_metadata,
160+
Mix.Utils.last_modified(beam) > modified,
161+
remove_consolidated(protocol, output),
162+
do: {protocol, true},
163+
into: %{}
164+
165+
protocols =
166+
Enum.reduce(new_metadata -- old_metadata, protocols, fn
167+
{_, {:impl, protocol}, _beam}, protocols ->
168+
Map.put(protocols, protocol, true)
169+
{protocol, :protocol, _beam}, protocols ->
170+
Map.put(protocols, protocol, true)
143171
end)
144172

145-
removals =
146-
Enum.flat_map(old_metadata -- new_metadata, fn
147-
{_, {:impl, protocol}} -> [protocol]
148-
{protocol, :protocol} ->
173+
protocols =
174+
Enum.reduce(old_metadata -- new_metadata, protocols, fn
175+
{_, {:impl, protocol}, _beam}, protocols ->
176+
Map.put(protocols, protocol, true)
177+
{protocol, :protocol, _beam}, protocols ->
149178
remove_consolidated(protocol, output)
150-
[]
179+
protocols
151180
end)
152181

153-
additions ++ removals
182+
Map.keys(protocols)
154183
end
155184

156185
defp remove_consolidated(protocol, output) do

lib/mix/lib/mix/tasks/deps.compile.ex

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ defmodule Mix.Tasks.Deps.Compile do
6565
Enum.map(deps, fn %Mix.Dep{app: app, status: status, opts: opts, scm: scm} = dep ->
6666
check_unavailable!(app, status)
6767

68-
compiled = cond do
68+
compiled? = cond do
6969
not is_nil(opts[:compile]) ->
7070
do_compile dep, config
7171
mix?(dep) ->
@@ -79,20 +79,27 @@ defmodule Mix.Tasks.Deps.Compile do
7979
true ->
8080
shell.error "Could not compile #{inspect app}, no \"mix.exs\", \"rebar.config\" or \"Makefile\" " <>
8181
"(pass :compile as an option to customize compilation, set it to \"false\" to do nothing)"
82+
false
8283
end
8384

8485
unless mix?(dep), do: build_structure(dep, config)
85-
touch_fetchable(scm, opts[:build])
86-
compiled
86+
# We should touch fetchable dependencies even if they
87+
# did not compile otherwise they will always be marked
88+
# as stale, even when there is nothing to do.
89+
fetchable? = touch_fetchable(scm, opts[:build])
90+
compiled? and fetchable?
8791
end)
8892

89-
if Enum.any?(compiled), do: Mix.Dep.Lock.touch_manifest, else: :ok
93+
if true in compiled, do: Mix.Dep.Lock.touch_manifest, else: :ok
9094
end
9195

9296
defp touch_fetchable(scm, path) do
9397
if scm.fetchable? do
9498
File.mkdir_p!(path)
9599
File.touch!(Path.join(path, ".compile.fetch"))
100+
true
101+
else
102+
false
96103
end
97104
end
98105

lib/mix/test/mix/umbrella_test.exs

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -261,34 +261,72 @@ defmodule Mix.UmbrellaTest do
261261
in_fixture("umbrella_dep/deps/umbrella/apps", fn ->
262262
Mix.Project.in_project(:bar, "bar", fn _ ->
263263
Mix.Task.run "compile"
264-
assert Mix.Tasks.Compile.Elixir.run([]) == :noop
265264
assert_receive {:mix_shell, :info, ["Compiled lib/foo.ex"]}
266265
assert_receive {:mix_shell, :info, ["Compiled lib/bar.ex"]}
267-
assert File.regular?("_build/dev/consolidated/Elixir.Enumerable.beam")
268266

269-
# Ensure we can measure a timestamp difference
270-
ensure_touched("_build/dev/lib/foo/.compile.elixir",
271-
File.stat!("_build/dev/lib/bar/.compile.lock").mtime)
272-
ensure_touched("../foo/mix.exs",
273-
File.stat!("_build/dev/lib/foo/.compile.elixir").mtime)
267+
# Noop by default
268+
assert Mix.Tasks.Compile.Elixir.run([]) == :noop
274269

275-
# Mark locks and protocols as outdated
276-
File.touch!("_build/dev/lib/foo/.compile.elixir",
277-
{{2010, 1, 1}, {0, 0, 0}})
278-
File.touch!("_build/dev/consolidated/Elixir.Enumerable.beam",
279-
{{2010, 1, 1}, {0, 0, 0}})
270+
# Noop when there is no runtime dependency
271+
ensure_touched("_build/dev/lib/foo/ebin/Elixir.Foo.beam",
272+
File.stat!("_build/dev/lib/bar/.compile.elixir").mtime)
273+
assert Mix.Tasks.Compile.Elixir.run([]) == :noop
280274

281-
purge [Foo, Bar]
282-
Mix.Task.clear
283-
Mix.shell.flush
275+
# Add runtime dependency
276+
File.write!("lib/bar.ex", """
277+
defmodule Bar do
278+
def bar, do: Foo.foo
279+
end
280+
""")
281+
assert Mix.Tasks.Compile.Elixir.run([]) == :ok
282+
assert_receive {:mix_shell, :info, ["Compiled lib/bar.ex"]}
284283

285-
assert Mix.Task.run("compile") == :ok
284+
# Noop for runtime dependencies
285+
ensure_touched("_build/dev/lib/foo/ebin/Elixir.Foo.beam",
286+
File.stat!("_build/dev/lib/bar/.compile.elixir").mtime)
286287
assert Mix.Tasks.Compile.Elixir.run([]) == :noop
287-
assert_receive {:mix_shell, :info, ["Compiled lib/foo.ex"]}
288+
289+
# Add compile time dependency
290+
File.write!("lib/bar.ex", "defmodule Bar, do: Foo.foo")
291+
assert Mix.Tasks.Compile.Elixir.run([]) == :ok
292+
assert_receive {:mix_shell, :info, ["Compiled lib/bar.ex"]}
293+
294+
# Recompiles for compile time dependencies
295+
ensure_touched("_build/dev/lib/foo/ebin/Elixir.Foo.beam",
296+
File.stat!("_build/dev/lib/bar/.compile.elixir").mtime)
297+
assert Mix.Tasks.Compile.Elixir.run([]) == :ok
288298
assert_receive {:mix_shell, :info, ["Compiled lib/bar.ex"]}
289-
assert File.stat!("_build/dev/consolidated/Elixir.Enumerable.beam").mtime >
290-
{{2010, 1, 1}, {0, 0, 0}}
291-
purge [Foo, Bar]
299+
end)
300+
end)
301+
end
302+
303+
test "reconsolidates after path dependency changes" do
304+
in_fixture("umbrella_dep/deps/umbrella/apps", fn ->
305+
Mix.Project.in_project(:bar, "bar", fn _ ->
306+
# Add a protocol dependency
307+
File.write!("../foo/lib/foo.ex", """
308+
defprotocol Foo do
309+
def foo(arg)
310+
end
311+
defimpl Foo, for: List do
312+
def foo(list), do: list
313+
end
314+
""")
315+
Mix.Task.run("compile")
316+
assert File.regular?("_build/dev/consolidated/Elixir.Foo.beam")
317+
assert Mix.Tasks.Compile.Protocols.run([]) == :noop
318+
319+
# Mark protocol as outdated
320+
File.touch!("_build/dev/consolidated/Elixir.Foo.beam",
321+
{{2010, 1, 1}, {0, 0, 0}})
322+
323+
ensure_touched("_build/dev/lib/foo/ebin/Elixir.Foo.beam",
324+
File.stat!("_build/dev/consolidated/.compile.protocols").mtime)
325+
assert Mix.Tasks.Compile.Protocols.run([]) == :ok
326+
327+
# Check new timestamp
328+
assert File.stat!("_build/dev/consolidated/Elixir.Foo.beam").mtime >
329+
{{2010, 1, 1}, {0, 0, 0}}
292330
end)
293331
end)
294332
end

0 commit comments

Comments
 (0)