diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f15cb54365..77fa92c5c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,26 @@ The focus behind Elixir v1.13 has been on tooling, mainly tooling related to code formatting, code fragments, code reflection, and code recompilation. A lot of this functionality will directly impact developers working on large codebases and provide meaningful quality of life improvements for those working on Elixir tooling and environments, such as IDEs, notebooks, etc. -## Reduced recompilation +## Semantic recompilation Elixir v1.13 comes with many improvements to the compiler, so it recompiles your files less frequently. In particular: * The digest of the files are considered in addition to their size. This avoids recompiling many files when switching or rebasing branches. - * Changing your `mix.exs` will no longer trigger a whole project recompilation, unless you specifically change the configuration used by the Elixir compiler. + * Changing your `mix.exs` will no longer trigger a full recompilation, unless you specifically change the configurations used by the Elixir compiler (`:elixirc_paths` and `:elixirc_options`). + + * Changing compile-time configuration files (`config/config.exs` and any other file imported from it) now only recompiles the project files that depend on the reconfigured applications, instead of a full recompilation. However, if you change the configuration of your application itself, the whole project is still recompiled. + + * Adding or updating a dependency now only recompiles the project files that depend on the modified a dependency. Removing a dependency still triggers a whole project recompilation. * If your project has both Erlang and Elixir files, changing an Erlang file will now recompile only the Elixir files that depend on it. +In a nutshell, Elixir went from triggering full recompilations whenever any of `mix.exs`, `config/config.exs`, `src/*`, and `mix.lock` changed on disk to semantic recompilations where it only fully recompiles when: + + * you change the compilation options in `mix.exs` + * you change the configuration for the current project in `config/config.exs` + * you remove a dependency + ## mix xref `mix xref` is a tool that analyzes relationships between files. By analyzing the compile-time and runtime dependencies between files, it allows developers to understand what files have to be recompiled whenever a file changes. @@ -57,7 +67,7 @@ iex(1)> %File.St File.Stat File.Stream ``` -Finally, new compilation tracers have been added, alongside a handful of functions in `Module` to help reflect on module metadata, which can be used to enrich suggestions in programming environments. +Finally, new compilation tracers have been added, alongside a handful of functions in `Module` to retrieve module metadata, which can be used to enrich suggestions in programming environments. ## Extended code formatting @@ -113,8 +123,11 @@ The `Code` has been augmented with two functions: `Code.string_to_quoted_with_co * [mix archive.install] Run `loadconfig` before building archive * [mix compile] Move Elixir version check to before deps are compiled, in order to give feedback earlier + * [mix compile.elixir] Do not recompile files if their modification time change but their contents are still the same and the .beam files are still on disk * [mix compile.elixir] Do not recompile all Elixir sources when Erlang modules change, only dependent ones * [mix compile.elixir] Do not recompile Elixir files if `mix.exs` changes, instead recompile only files using `Mix.Project` or trigger a recompilation if a compiler option changes + * [mix compile.elixir] Only recompile needed files when a dependency is added or updated + * [mix compile.elixir] Only recompile needed files when a dependency is configured * [mix deps] Add `:subdir` option to git deps * [mix escript.install] Run `loadconfig` before building escript * [mix local.rebar] No longer support `sub_dirs` in Rebar 2 to help migration towards Rebar 3 diff --git a/lib/mix/lib/mix/compilers/elixir.ex b/lib/mix/lib/mix/compilers/elixir.ex index 44e0f6e8bba..a7fd2c7692b 100644 --- a/lib/mix/lib/mix/compilers/elixir.ex +++ b/lib/mix/lib/mix/compilers/elixir.ex @@ -1,7 +1,7 @@ defmodule Mix.Compilers.Elixir do @moduledoc false - @manifest_vsn 10 + @manifest_vsn 11 import Record @@ -30,25 +30,55 @@ defmodule Mix.Compilers.Elixir do between modules, which helps it recompile only the modules that have changed at runtime. """ - def compile(manifest, srcs, dest, exts, stale_modules, new_cache_key, force, opts) do + def compile(manifest, srcs, dest, deps_changed?, new_cache_key, stale, opts) do # We fetch the time from before we read files so any future # change to files are still picked up by the compiler. This # timestamp is used when writing BEAM files and the manifest. timestamp = System.os_time(:second) - all_paths = Mix.Utils.extract_files(srcs, exts) + all_paths = Mix.Utils.extract_files(srcs, [:ex]) + + {all_modules, all_sources, all_local_exports, old_cache_key, old_lock, old_config} = + parse_manifest(manifest, dest) + + {force?, stale, new_lock, new_config} = + cond do + !!opts[:force] or is_nil(old_lock) or is_nil(old_config) or old_cache_key != new_cache_key -> + {true, stale, Enum.sort(Mix.Dep.Lock.read()), + Enum.sort(Mix.Tasks.Loadconfig.read_compile())} + + deps_changed? -> + new_lock = Enum.sort(Mix.Dep.Lock.read()) + new_config = Enum.sort(Mix.Tasks.Loadconfig.read_compile()) + + with {:apps, apps} <- merge_lock(old_lock, new_lock, []), + apps = merge_config(old_config, new_config, apps), + # If the current app is in the list of changes, then we need to force it + false <- Mix.Project.config()[:app] in apps do + apps_stale = + apps + |> deps_on() + |> Enum.flat_map(fn {app, _} -> Application.spec(app, :modules) || [] end) + + {false, stale ++ apps_stale, new_lock, new_config} + else + _ -> {true, stale, new_lock, new_config} + end + + true -> + {false, stale, old_lock, old_config} + end - {all_modules, all_sources, all_local_exports, old_cache_key} = parse_manifest(manifest, dest) modified = Mix.Utils.last_modified(manifest) {stale_local_deps, stale_local_mods, stale_local_exports, all_local_exports} = - stale_local_deps(manifest, stale_modules, modified, all_local_exports) + stale_local_deps(manifest, stale, modified, all_local_exports) prev_paths = for source(source: source) <- all_sources, do: source removed = prev_paths -- all_paths {sources, removed_modules} = remove_removed_sources(all_sources, removed) {modules, exports, changed, sources_stats} = - if force || old_cache_key != new_cache_key do + if force? do compiler_info_from_force(manifest, all_paths, all_modules, dest) else compiler_info_from_updated( @@ -72,7 +102,7 @@ defmodule Mix.Compilers.Elixir do if opts[:all_warnings], do: show_warnings(sources) if stale != [] do - Mix.Utils.compiling_n(length(stale), hd(exts)) + Mix.Utils.compiling_n(length(stale), :ex) Mix.Project.ensure_structure() true = Code.prepend_path(dest) @@ -91,7 +121,18 @@ defmodule Mix.Compilers.Elixir do {:ok, _, warnings} -> {modules, _exports, sources, _pending_modules, _pending_exports} = get_compiler_info() sources = apply_warnings(sources, warnings) - write_manifest(manifest, modules, sources, all_local_exports, new_cache_key, timestamp) + + write_manifest( + manifest, + modules, + sources, + all_local_exports, + new_cache_key, + new_lock, + new_config, + timestamp + ) + put_compile_env(sources) {:ok, Enum.map(warnings, &diagnostic(&1, :warning))} @@ -108,13 +149,33 @@ defmodule Mix.Compilers.Elixir do delete_compiler_info() end else - # We need to return ok if stale_local_mods changed - # because we want that to propagate to compile.protocols + # We need to return ok if stale_local_mods changed because we want to + # propagate the changed status to compile.protocols. `stale_local_mods` + # will be non-empty whenever: + # + # * the lock file or a config changes + # * any module in a path dependency changes + # * the mix.exs changes + # * the Erlang manifest updates (Erlang files are compiled) + # + # In the first case, we will recompile from scratch. In the remaining, we + # will only compute the diff with current protocols. In fact, there is no + # need to reconsolidate if an Erlang file changes and it doesn't trigger + # any other change, but the diff check should be reasonably fast anyway. status = if removed != [] or stale_local_mods != %{}, do: :ok, else: :noop # If nothing changed but there is one more recent mtime, bump the manifest if status != :noop or Enum.any?(Map.values(sources_stats), &(elem(&1, 0) > modified)) do - write_manifest(manifest, modules, sources, all_local_exports, new_cache_key, timestamp) + write_manifest( + manifest, + modules, + sources, + all_local_exports, + new_cache_key, + new_lock, + new_config, + timestamp + ) end {status, warning_diagnostics(sources)} @@ -152,7 +213,7 @@ defmodule Mix.Compilers.Elixir do rescue _ -> {[], []} else - {@manifest_vsn, modules, sources, _local_exports, _cache_key} -> {modules, sources} + {@manifest_vsn, modules, sources, _, _, _, _} -> {modules, sources} _ -> {[], []} end end @@ -598,6 +659,8 @@ defmodule Mix.Compilers.Elixir do defp stale_local_deps(manifest, stale_modules, modified, old_exports) do base = Path.basename(manifest) + + # TODO: Use :maps.from_keys/2 on Erlang/OTP 24+ stale_modules = for module <- stale_modules, do: {module, true}, into: %{} for %{scm: scm, opts: opts} = dep <- Mix.Dep.cached(), @@ -701,6 +764,78 @@ defmodule Mix.Compilers.Elixir do } end + ## Merging of lock and config files + + # Lock for app didn't change + defp merge_lock([{app, value} | old_lock], [{app, value} | new_lock], apps), + do: merge_lock(old_lock, new_lock, apps) + + # Lock for app changed + defp merge_lock([{app, _} | old_lock], [{app, _} | new_lock], apps), + do: merge_lock(old_lock, new_lock, [app | apps]) + + # App is in new lock but not the old one, add it to the list + defp merge_lock([{app1, _} | _] = old_lock, [{app2, _} | new_lock], apps) when app1 > app2, + do: merge_lock(old_lock, new_lock, [app2 | apps]) + + # We are done and we may have left overs on new lock, add them to apps + defp merge_lock([], new_lock, apps), + do: {:apps, Enum.reduce(new_lock, apps, fn {app, _}, apps -> [app | apps] end)} + + # However, if the old lock has exclusive entries, it means deps were deleted, + # so we need to force recompilation + defp merge_lock(_, _, _), + do: :force + + # Config for app didn't change + defp merge_config([{app, value} | old_config], [{app, value} | new_config], apps), + do: merge_config(old_config, new_config, apps) + + # Config for app changed + defp merge_config([{app, _} | old_config], [{app, _} | new_config], apps), + do: merge_config(old_config, new_config, [app | apps]) + + # Added config for app + defp merge_config([{app1, _} | _] = old_config, [{app2, _} | new_config], apps) + when app1 > app2, + do: merge_config(old_config, new_config, [app2 | apps]) + + # Removed config for app + defp merge_config([{app1, _} | old_config], [{app2, _} | _] = new_config, apps) + when app1 < app2, + do: merge_config(old_config, new_config, [app1 | apps]) + + # One of them is done, add the others + defp merge_config(old_config, new_config, apps) do + apps = Enum.reduce(old_config, apps, fn {app, _}, apps -> [app | apps] end) + Enum.reduce(new_config, apps, fn {app, _}, apps -> [app | apps] end) + end + + defp deps_on(apps) do + # TODO: Use :maps.from_keys/2 on Erlang/OTP 24+ + apps = for app <- apps, do: {app, true}, into: %{} + deps_on(Mix.Dep.cached(), apps, [], false) + end + + defp deps_on([%{app: app, deps: deps} = dep | cached_deps], apps, acc, stored?) do + cond do + # We have already seen this dep + Map.has_key?(apps, app) -> + deps_on(cached_deps, apps, acc, stored?) + + # It depends on one of the apps, store it + Enum.any?(deps, &Map.has_key?(apps, &1.app)) -> + deps_on(cached_deps, Map.put(apps, app, true), acc, true) + + # Otherwise we will check it later + true -> + deps_on(cached_deps, apps, [dep | acc], stored?) + end + end + + defp deps_on([], apps, cached_deps, true), do: deps_on(cached_deps, apps, [], false) + defp deps_on([], apps, _cached_deps, false), do: apps + ## Manifest handling # Similar to read_manifest, but for internal consumption and with data migration support. @@ -709,13 +844,13 @@ defmodule Mix.Compilers.Elixir do manifest |> File.read!() |> :erlang.binary_to_term() rescue _ -> - {[], [], %{}, nil} + {[], [], %{}, nil, nil, nil} else - {@manifest_vsn, modules, sources, local_exports, cache_key} -> - {modules, sources, local_exports, cache_key} + {@manifest_vsn, modules, sources, local_exports, cache_key, lock, config} -> + {modules, sources, local_exports, cache_key, lock, config} # {vsn, modules, sources} v5-v7 (v1.10) - # {vsn, modules, sources, local_exports} v8-v9 (v1.11) + # {vsn, modules, sources, local_exports} v8-v10 (v1.11) manifest when is_tuple(manifest) and is_integer(elem(manifest, 0)) -> purge_old_manifest(compile_path, elem(manifest, 1)) @@ -724,7 +859,7 @@ defmodule Mix.Compilers.Elixir do purge_old_manifest(compile_path, data) _ -> - {[], [], %{}, nil} + {[], [], %{}, nil, nil, nil} end end @@ -743,18 +878,18 @@ defmodule Mix.Compilers.Elixir do ) end - {[], [], %{}, nil} + {[], [], %{}, nil, nil, nil} end - defp write_manifest(manifest, [], [], _exports, _cache_key, _timestamp) do + defp write_manifest(manifest, [], [], _exports, _cache_key, _lock, _config, _timestamp) do File.rm(manifest) :ok end - defp write_manifest(manifest, modules, sources, exports, cache_key, timestamp) do + defp write_manifest(manifest, modules, sources, exports, cache_key, lock, config, timestamp) do File.mkdir_p!(Path.dirname(manifest)) - term = {@manifest_vsn, modules, sources, exports, cache_key} + term = {@manifest_vsn, modules, sources, exports, cache_key, lock, config} manifest_data = :erlang.term_to_binary(term, [:compressed]) File.write!(manifest, manifest_data) File.touch!(manifest, timestamp) diff --git a/lib/mix/lib/mix/project_stack.ex b/lib/mix/lib/mix/project_stack.ex index 367eb80eb54..4aa83571925 100644 --- a/lib/mix/lib/mix/project_stack.ex +++ b/lib/mix/lib/mix/project_stack.ex @@ -145,11 +145,11 @@ defmodule Mix.ProjectStack do end) end - @spec compile_env([term] | :unset) :: [term] | :unset + @spec compile_env([term] | nil) :: [term] | nil def compile_env(compile_env) do update_stack(fn [h | t] -> {h.compile_env, [%{h | compile_env: compile_env} | t]} - [] -> {:unset, []} + [] -> {nil, []} end) end @@ -255,7 +255,7 @@ defmodule Mix.ProjectStack do config_files: [manifest_file | parent_config], config_mtime: nil, after_compiler: %{}, - compile_env: :unset + compile_env: nil } {:ok, {[project | stack], []}} diff --git a/lib/mix/lib/mix/tasks/compile.app.ex b/lib/mix/lib/mix/tasks/compile.app.ex index 8677cc32502..ca56ca76a80 100644 --- a/lib/mix/lib/mix/tasks/compile.app.ex +++ b/lib/mix/lib/mix/tasks/compile.app.ex @@ -177,8 +177,8 @@ defmodule Mix.Tasks.Compile.App do end defp load_compile_env(current_properties) do - case Mix.ProjectStack.compile_env(:unset) do - :unset -> Keyword.get(current_properties, :compile_env, []) + case Mix.ProjectStack.compile_env(nil) do + nil -> Keyword.get(current_properties, :compile_env, []) list -> list end end diff --git a/lib/mix/lib/mix/tasks/compile.elixir.ex b/lib/mix/lib/mix/tasks/compile.elixir.ex index a1f848efdfe..2f7b807d30d 100644 --- a/lib/mix/lib/mix/tasks/compile.elixir.ex +++ b/lib/mix/lib/mix/tasks/compile.elixir.ex @@ -104,9 +104,7 @@ defmodule Mix.Tasks.Compile.Elixir do manifest = manifest() manifest_last_modified = Mix.Utils.last_modified(manifest) - force = - opts[:force] || - Mix.Utils.stale?([Mix.Project.config_mtime()], [manifest_last_modified]) + deps_changed? = Mix.Utils.stale?([Mix.Project.config_mtime()], [manifest_last_modified]) stale = if Mix.Utils.stale?(Mix.Tasks.Compile.Erlang.manifests(), [manifest_last_modified]), @@ -127,7 +125,7 @@ defmodule Mix.Tasks.Compile.Elixir do |> tracers_opts(tracers) |> profile_opts() - Mix.Compilers.Elixir.compile(manifest, srcs, dest, [:ex], stale, cache_key, force, opts) + Mix.Compilers.Elixir.compile(manifest, srcs, dest, deps_changed?, cache_key, stale, opts) end @impl true diff --git a/lib/mix/lib/mix/tasks/compile.protocols.ex b/lib/mix/lib/mix/tasks/compile.protocols.ex index ffb52dedf74..d06586e03c3 100644 --- a/lib/mix/lib/mix/tasks/compile.protocols.ex +++ b/lib/mix/lib/mix/tasks/compile.protocols.ex @@ -52,6 +52,8 @@ defmodule Mix.Tasks.Compile.Protocols do protocols_and_impls = protocols_and_impls(config) cond do + # We need to reconsolidate all protocols whenever the dependency changes + # because we only track protocols from the current app and from local deps. opts[:force] || Mix.Utils.stale?([Mix.Project.config_mtime()], [manifest]) -> clean() paths = consolidation_paths() @@ -91,21 +93,18 @@ defmodule Mix.Tasks.Compile.Protocols do defp protocols_and_impls(config) do deps = for %{scm: scm, opts: opts} <- Mix.Dep.cached(), not scm.fetchable?, do: opts[:build] - app = + paths = if Mix.Project.umbrella?(config) do - [] + deps else - [Mix.Project.app_path(config)] - end - - protocols_and_impls = - for path <- app ++ deps do - manifest_path = Path.join(path, ".mix/compile.elixir") - compile_path = Path.join(path, "ebin") - Mix.Compilers.Elixir.protocols_and_impls(manifest_path, compile_path) + [Mix.Project.app_path(config) | deps] end - Enum.concat(protocols_and_impls) + Enum.flat_map(paths, fn path -> + manifest_path = Path.join(path, ".mix/compile.elixir") + compile_path = Path.join(path, "ebin") + Mix.Compilers.Elixir.protocols_and_impls(manifest_path, compile_path) + end) end defp consolidation_paths do diff --git a/lib/mix/lib/mix/tasks/loadconfig.ex b/lib/mix/lib/mix/tasks/loadconfig.ex index f518d102de5..f2020180ecc 100644 --- a/lib/mix/lib/mix/tasks/loadconfig.ex +++ b/lib/mix/lib/mix/tasks/loadconfig.ex @@ -43,12 +43,17 @@ defmodule Mix.Tasks.Loadconfig do end end + @doc false + def read_compile() do + Mix.State.read_cache(__MODULE__) || [] + end + @doc false # Loads compile-time configuration, they support imports, and are not deep merged. def load_compile(file) do {config, files} = Config.Reader.read_imports!(file, env: Mix.env(), target: Mix.target()) Mix.ProjectStack.loaded_config(persist_apps(config, file), files) - config + Mix.State.write_cache(__MODULE__, config) end @doc false diff --git a/lib/mix/test/mix/tasks/compile.elixir_test.exs b/lib/mix/test/mix/tasks/compile.elixir_test.exs index f48edf19b68..8ef628ca80e 100644 --- a/lib/mix/test/mix/tasks/compile.elixir_test.exs +++ b/lib/mix/test/mix/tasks/compile.elixir_test.exs @@ -10,6 +10,7 @@ defmodule Mix.Tasks.Compile.ElixirTest do :ok end + @old_time {{2010, 1, 1}, {0, 0, 0}} @elixir_otp_version {System.version(), :erlang.system_info(:otp_release)} test "compiles a project without per environment build" do @@ -149,12 +150,169 @@ defmodule Mix.Tasks.Compile.ElixirTest do assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]} - Mix.Task.clear() - mtime = File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime - ensure_touched(__ENV__.file, mtime) + File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time) + assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Now remove the dependency + File.write!("lib/a.ex", """ + defmodule A do + end + """) + + File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time) + assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Making the manifest olds returns :ok, but does not recompile + File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time) + assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []} + refute_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime > @old_time + end) + end + + test "recompiles files when config changes" do + in_fixture("no_mixfile", fn -> + Mix.Project.push(MixTest.Case.Sample, __ENV__.file) + Process.put({MixTest.Case.Sample, :application}, extra_applications: [:logger]) + File.mkdir_p!("config") + + File.write!("lib/a.ex", """ + defmodule A do + _ = Logger.metadata() + end + """) + + assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + recompile = fn -> + File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time) + Mix.ProjectStack.pop() + Mix.Project.push(MixTest.Case.Sample, __ENV__.file) + Mix.Tasks.Loadconfig.load_compile("config/config.exs") + Mix.Tasks.Compile.Elixir.run(["--verbose"]) + end + + # Adding config recompiles + File.write!("config/config.exs", """ + import Config + config :logger, :level, :debug + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Changing config recompiles + File.write!("config/config.exs", """ + import Config + config :logger, :level, :info + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Removing config recompiles + File.write!("config/config.exs", """ + import Config + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Changing self fully recompiles + File.write!("config/config.exs", """ + import Config + config :sample, :foo, :bar + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Changing an unknown dependency returns :ok but does not recompile + File.write!("config/config.exs", """ + import Config + config :sample, :foo, :bar + config :unknown, :unknown, :unknown + """) + + assert recompile.() == {:ok, []} + refute_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime > @old_time + end) + after + Application.delete_env(:sample, :foo, persistent: true) + end + + test "recompiles files when lock changes" do + in_fixture("no_mixfile", fn -> + Mix.Project.push(MixTest.Case.Sample, __ENV__.file) + Process.put({MixTest.Case.Sample, :application}, extra_applications: [:logger]) + + File.write!("lib/a.ex", """ + defmodule A do + _ = Logger.metadata() + end + """) + assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []} assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + recompile = fn -> + File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time) + Mix.ProjectStack.pop() + Mix.Project.push(MixTest.Case.Sample, __ENV__.file) + Mix.Tasks.WillRecompile.run([]) + Mix.Tasks.Compile.Elixir.run(["--verbose"]) + end + + # Adding to lock recompiles + File.write!("mix.lock", """ + %{"logger": :unused} + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Changing lock recompiles + File.write!("mix.lock", """ + %{"logger": :another} + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Removing a lock fully recompiles + File.write!("mix.lock", """ + %{} + """) + + assert recompile.() == {:ok, []} + assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} + assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + + # Adding an unknown dependency returns :ok but does not recompile + File.write!("mix.lock", """ + %{"unknown": :unknown} + """) + + assert recompile.() == {:ok, []} + refute_received {:mix_shell, :info, ["Compiled lib/a.ex"]} refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} + assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime > @old_time end) end @@ -180,8 +338,7 @@ defmodule Mix.Tasks.Compile.ElixirTest do assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]} Mix.Task.clear() - mtime = File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime - ensure_touched("src/foo.erl", mtime) + File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time) assert Mix.Tasks.Compile.run(["--verbose"]) == {:ok, []} assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} @@ -189,7 +346,7 @@ defmodule Mix.Tasks.Compile.ElixirTest do end) end - test "recompiles project if Elixir version changed" do + test "recompiles project if Elixir version changes" do in_fixture("no_mixfile", fn -> Mix.Project.push(MixTest.Case.Sample) Mix.Tasks.Compile.run([]) @@ -203,19 +360,19 @@ defmodule Mix.Tasks.Compile.ElixirTest do File.write!("_build/dev/lib/sample/consolidated/.to_be_removed", "") manifest_data = :erlang.term_to_binary({:v1, "0.0.0", nil}) File.write!("_build/dev/lib/sample/.mix/compile.elixir_scm", manifest_data) - File.touch!("_build/dev/lib/sample/.mix/compile.elixir_scm", {{2010, 1, 1}, {0, 0, 0}}) + File.touch!("_build/dev/lib/sample/.mix/compile.elixir_scm", @old_time) Mix.Tasks.Compile.run([]) assert Mix.Dep.ElixirSCM.read() == {:ok, @elixir_otp_version, Mix.SCM.Path} assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir_scm").mtime > - {{2010, 1, 1}, {0, 0, 0}} + @old_time refute File.exists?("_build/dev/lib/sample/consolidated/.to_be_removed") end) end - test "recompiles project if scm changed" do + test "recompiles project if scm changes" do in_fixture("no_mixfile", fn -> Mix.Project.push(MixTest.Case.Sample) Mix.Tasks.Compile.run(["--verbose"]) @@ -227,13 +384,13 @@ defmodule Mix.Tasks.Compile.ElixirTest do Mix.Task.clear() manifest_data = :erlang.term_to_binary({1, @elixir_otp_version, :another}) File.write!("_build/dev/lib/sample/.mix/compile.elixir_scm", manifest_data) - File.touch!("_build/dev/lib/sample/.mix/compile.elixir_scm", {{2010, 1, 1}, {0, 0, 0}}) + File.touch!("_build/dev/lib/sample/.mix/compile.elixir_scm", @old_time) Mix.Tasks.Compile.run([]) assert Mix.Dep.ElixirSCM.read() == {:ok, @elixir_otp_version, Mix.SCM.Path} assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir_scm").mtime > - {{2010, 1, 1}, {0, 0, 0}} + @old_time end) end @@ -369,7 +526,7 @@ defmodule Mix.Tasks.Compile.ElixirTest do test "compiles size changed files" do in_fixture("no_mixfile", fn -> Mix.Project.push(MixTest.Case.Sample) - past = {{2010, 1, 1}, {0, 0, 0}} + past = @old_time File.touch!("lib/a.ex", past) assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []} @@ -484,7 +641,7 @@ defmodule Mix.Tasks.Compile.ElixirTest do refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]} # Does not update on old existing resource - File.touch!("lib/a.eex", {{2010, 1, 1}, {0, 0, 0}}) + File.touch!("lib/a.eex", @old_time) assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:noop, []} Mix.shell().flush purge([A, B]) diff --git a/lib/mix/test/mix/tasks/deps.git_test.exs b/lib/mix/test/mix/tasks/deps.git_test.exs index 67b79120f28..548f259d47a 100644 --- a/lib/mix/test/mix/tasks/deps.git_test.exs +++ b/lib/mix/test/mix/tasks/deps.git_test.exs @@ -192,42 +192,6 @@ defmodule Mix.Tasks.DepsGitTest do purge([GitRepo, GitRepo.MixProject]) end - test "recompiles the project when a dep is fetched" do - in_fixture("no_mixfile", fn -> - Mix.Project.push(GitApp) - - Mix.Tasks.Deps.Get.run([]) - assert File.exists?("deps/git_repo/.fetch") - - # We can compile just fine - assert Mix.Tasks.Compile.run(["--verbose"]) == {:ok, []} - - # Clear up to prepare for the update - Mix.Task.clear() - Mix.shell().flush - purge([A, B, GitRepo]) - - # Update will mark the update required - Mix.Tasks.Deps.Update.run(["git_repo"]) - assert File.exists?("deps/git_repo/.fetch") - # Ensure timestamp differs - ensure_touched("deps/git_repo/.fetch") - - # mix deps.compile is required... - Mix.Tasks.Deps.run([]) - msg = " the dependency build is outdated, please run \"mix deps.compile\"" - assert_received {:mix_shell, :info, [^msg]} - - # But also ran automatically - Mix.Tasks.Compile.run(["--verbose"]) - assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]} - assert File.exists?("_build/dev/lib/git_repo/.mix/compile.fetch") - :ok - end) - after - purge([A, B, GitRepo, GitRepo.MixProject]) - end - test "all dependencies are up to date" do in_fixture("no_mixfile", fn -> Mix.Project.push(GitApp) diff --git a/lib/mix/test/mix/umbrella_test.exs b/lib/mix/test/mix/umbrella_test.exs index c8d9a198cee..30f0d82c2f1 100644 --- a/lib/mix/test/mix/umbrella_test.exs +++ b/lib/mix/test/mix/umbrella_test.exs @@ -478,9 +478,7 @@ defmodule Mix.UmbrellaTest do Mix.Task.clear() Application.unload(:foo) - - mtime = File.stat!("_build/dev/lib/bar/.mix/compile.elixir").mtime - ensure_touched("../foo/lib/foo.ex", mtime) + ensure_touched("../foo/lib/foo.ex", "_build/dev/lib/bar/.mix/compile.elixir") assert Mix.Task.run("compile", ["--verbose"]) == {:ok, []} assert_receive {:mix_shell, :info, ["Compiled lib/bar.ex"]} @@ -508,8 +506,10 @@ defmodule Mix.UmbrellaTest do # Mark protocol as outdated File.touch!("_build/dev/lib/bar/consolidated/Elixir.Foo.beam", {{2010, 1, 1}, {0, 0, 0}}) - mtime = File.stat!("_build/dev/lib/bar/.mix/compile.protocols").mtime - ensure_touched("_build/dev/lib/foo/ebin/Elixir.Foo.beam", mtime) + ensure_touched( + "_build/dev/lib/foo/ebin/Elixir.Foo.beam", + "_build/dev/lib/bar/.mix/compile.protocols" + ) assert Mix.Tasks.Compile.Protocols.run([]) == :ok diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index 228f5accc63..eeec232f2d9 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -136,10 +136,14 @@ defmodule MixTest.Case do end def ensure_touched(file) do - ensure_touched(file, File.stat!(file).mtime) + ensure_touched(file, file) end - def ensure_touched(file, current) do + def ensure_touched(file, current) when is_binary(current) do + ensure_touched(file, File.stat!(current).mtime) + end + + def ensure_touched(file, current) when is_tuple(current) do File.touch!(file) mtime = File.stat!(file).mtime