# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2021 The Elixir Team
# SPDX-FileCopyrightText: 2012 Plataformatec

defmodule Mix.Tasks.Compile.Elixir do
  use Mix.Task.Compiler

  @recursive true
  @manifest "compile.elixir"

  @moduledoc """
  Compiles Elixir source files.

  Elixir is smart enough to recompile only files that have changed
  and their dependencies. This means if `lib/a.ex` is invoking
  a function defined over `lib/b.ex` at compile time, whenever
  `lib/b.ex` changes, `lib/a.ex` is also recompiled. More details
  about dependencies between files can be found in the documentation
  of [`mix xref`](`Mix.Tasks.Xref`).

  Note Elixir considers a file as changed if its source file has
  changed on disk since the last compilation AND its contents are
  no longer the same.

  ## `@external_resource`

  If a module depends on external files, those can be annotated
  with the `@external_resource` module attribute. If these files
  change, the Elixir module is automatically recompiled.

  ## `__mix_recompile__?/0`

  A module may export a `__mix_recompile__?/0` function that can
  cause the module to be recompiled using custom rules. For example,
  to recompile whenever a file is changed in a given directory, you
  can use a combination of `@external_resource` for existing files
  and a `__mix_recompile__?/0` check to verify when new entries are
  added to the directory itself:

      defmodule MyModule do
        paths = Path.wildcard("*.txt")
        @paths_hash :erlang.md5(paths)

        for path <- paths do
          @external_resource path
        end

        def __mix_recompile__?() do
          Path.wildcard("*.txt") |> :erlang.md5() != @paths_hash
        end
      end

  Compiler calls `__mix_recompile__?/0` for every module being
  compiled (or previously compiled) and thus it is very important
  to do there as little work as possible to not slow down the
  compilation.

  If module has `@compile {:autoload, false}`, `__mix_recompile__?/0` will
  not be used.

  ## Command line options

    * `--all-warnings` (`--no-all-warnings`) - prints all warnings, including previous compilations
      (default is true except on errors)
    * `--docs` (`--no-docs`) - attaches (or not) documentation to compiled modules
    * `--debug-info` (`--no-debug-info`) - attaches (or not) debug info to compiled modules.
      Passing this flag will force a full recompilation
    * `--force` - forces compilation regardless of modification times
    * `--ignore-module-conflict` - does not emit warnings if a module was previously defined
    * `--long-compilation-threshold N` - sets the "long compilation" threshold
      (in seconds) to `N` (see the docs for `Kernel.ParallelCompiler.compile/2`)
    * `--long-verification-threshold N` - sets the "long verification" threshold
      (in seconds) to `N` (see the docs for `Kernel.ParallelCompiler.compile/2`)
    * `--no-verification` - disables code verification, such as unused functions,
      deprecation warnings, and type checking. It must be used solely for debugging
      alongside `MIX_DEBUG=1`
    * `--no-check-cwd` - (since v1.19.2) Elixir stores absolute paths in .beam files,
      which means that relocating the project root triggers a full build. Pass this option
      if you don't want the current working directory to be checked
    * `--purge-consolidation-path-if-stale PATH` - deletes and purges modules in the
      given protocol consolidation path if compilation is required
    * `--purge-compiler-modules` - automatically purge compilation modules
      after compilation (see `Code.purge_compiler_modules/0`)
    * `--profile` - if set to `time`, outputs timing information of compilation steps
    * `--tracer` - adds a compiler tracer in addition to any specified in the `mix.exs` file
    * `--verbose` - prints each file being compiled
    * `--warnings-as-errors` - exit with non-zero status if compilation has one or more warnings

  ## Configuration

    * `:elixirc_paths` - directories to find source files.
      Defaults to `["lib"]`.

    * `:elixirc_options` - compilation options that apply to Elixir's compiler.
      It supports many of the options above  plus the options listed in
      `Code.put_compiler_option/2`. In case conflicting options are given,
      the ones given through the command line are used.

    * `[xref: [exclude: ...]]` - a list of `module` or `{module, function, arity}`
      that should not be warned on in case on undefined modules or undefined
      application warnings.

  """

  @switches [
    force: :boolean,
    docs: :boolean,
    consolidate_protocols: :boolean,
    warnings_as_errors: :boolean,
    ignore_module_conflict: :boolean,
    debug_info: :boolean,
    verbose: :boolean,
    long_compilation_threshold: :integer,
    long_verification_threshold: :integer,
    purge_consolidation_path_if_stale: :string,
    purge_compiler_modules: :boolean,
    profile: :string,
    all_warnings: :boolean,
    verification: :boolean,
    tracer: :keep,
    check_cwd: :boolean
  ]

  @impl true
  def run(args) do
    {opts, _, _} = OptionParser.parse(args, switches: @switches)
    {tracers, opts} = pop_tracers(opts)

    project = Mix.Project.config()
    dest = Mix.Project.compile_path(project)
    srcs = project[:elixirc_paths]

    if not (is_list(srcs) and Enum.all?(srcs, &is_binary/1)) do
      Mix.raise(":elixirc_paths should be a list of string paths, got: #{inspect(srcs)}")
    end

    manifest = manifest()

    # Infer signatures should not trigger a full recompile but it does trigger rechecking
    {infer_signatures, base} =
      (project[:elixirc_options] || [])
      |> xref_exclude_opts(project)
      |> Keyword.pop(:infer_signatures, true)

    # Optional dependencies and debug info affect how artifacts are generated
    cache_key =
      {base, srcs, "--no-optional-deps" in args, "--no-debug-info" in args}

    opts =
      base
      |> Keyword.merge(opts)
      |> tracers_opts(tracers)
      |> profile_opts()
      |> Keyword.put(:infer_signatures, infer_signatures(infer_signatures))

    opts =
      if "--no-protocol-consolidation" in args do
        # TODO: Deprecate this flag on Elixir v1.23
        Keyword.put(opts, :consolidate_protocols, false)
      else
        opts ++ Keyword.take(project, [:consolidate_protocols])
      end

    with_logger_app(project, fn ->
      Mix.Project.with_build_lock(project, fn ->
        Mix.Compilers.Elixir.compile(
          manifest,
          srcs,
          dest,
          cache_key,
          Mix.Tasks.Compile.Erlang.manifests(),
          Mix.Tasks.Compile.Erlang.modules(),
          opts
        )
      end)
    end)
  end

  @impl true
  def manifests, do: [manifest()]
  defp manifest, do: Path.join(Mix.Project.manifest_path(), @manifest)

  @impl true
  def diagnostics do
    Mix.Compilers.Elixir.diagnostics(manifest())
  end

  @impl true
  def clean do
    dest = Mix.Project.compile_path()
    Mix.Compilers.Elixir.clean(manifest(), dest)
  end

  defp infer_signatures(list) when is_list(list), do: :lists.usort([:elixir | list])

  defp infer_signatures(false) do
    false
  end

  defp infer_signatures(true) do
    project = Mix.Project.get!()

    properties =
      if function_exported?(project, :application, 0), do: project.application(), else: []

    extra_apps =
      for tuple_or_atom <-
            Keyword.get(properties, :included_applications, []) ++
              Keyword.get(properties, :extra_applications, []) do
        case tuple_or_atom do
          {app, _} -> app
          app when is_atom(app) -> app
        end
      end

    deps_apps =
      for %{app: app, opts: opts} <- Mix.Dep.cached(),
          Keyword.get(opts, :app, true),
          do: app

    :lists.usort([:elixir | extra_apps] ++ deps_apps)
  end

  # Run this operation in compile.elixir as the compiler can be called directly
  defp with_logger_app(config, fun) do
    app = Keyword.fetch!(config, :app)
    logger_config_app = Application.get_env(:logger, :compile_time_application)

    try do
      Application.put_env(:logger, :compile_time_application, app)
      fun.()
    after
      Application.put_env(:logger, :compile_time_application, logger_config_app)
    end
  end

  defp xref_exclude_opts(opts, project) do
    exclude = List.wrap(project[:xref][:exclude])

    if exclude == [] do
      opts
    else
      IO.warn(
        "\"xref: [exclude: ...]\" in your mix.exs file is deprecated, " <>
          "instead use: \"elixirc_options: [no_warn_undefined: ...]\""
      )

      Keyword.update(opts, :no_warn_undefined, exclude, &(List.wrap(&1) ++ exclude))
    end
  end

  defp pop_tracers(opts) do
    case Keyword.pop_values(opts, :tracer) do
      {[], opts} ->
        {[], opts}

      {tracers, opts} ->
        {Enum.map(tracers, &Module.concat([&1])), opts}
    end
  end

  defp tracers_opts(opts, tracers) do
    tracers = tracers ++ Code.get_compiler_option(:tracers)
    Keyword.update(opts, :tracers, tracers, &(tracers ++ &1))
  end

  defp profile_opts(opts) do
    case Keyword.fetch(opts, :profile) do
      {:ok, "time"} -> Keyword.put(opts, :profile, :time)
      {:ok, _} -> Keyword.delete(opts, :profile)
      :error -> opts
    end
  end
end
