Skip to main content

mix.exs

defmodule Rx.MixProject do
  use Mix.Project

  @source_url "https://github.com/pklonowski/rx"
  @version "0.1.0"

  def project do
    [
      app: :rx,
      version: @version,
      elixir: "~> 1.18",
      description: description(),
      source_url: @source_url,
      homepage_url: @source_url,
      start_permanent: Mix.env() == :prod,
      compilers: compilers(),
      deps: deps(),
      docs: docs(),
      package: package()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      mod: {Rx.Application, []},
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      {:jason, "~> 1.4"},
      {:explorer, "~> 0.11", optional: true},
      {:plotly_ex, "~> 0.1", optional: true},
      {:kino, "~> 0.19.0", optional: true},
      {:ex_doc, "~> 0.40.3", only: :dev, runtime: false}
    ]
  end

  defp description do
    "Drive R from Elixir through a persistent Rscript backend, with an experimental embedded native backend."
  end

  defp docs do
    [
      main: "readme",
      extras: ["README.md"],
      source_ref: "v#{@version}",
      source_url: @source_url
    ]
  end

  defp package do
    [
      licenses: ["MIT"],
      links: %{
        "GitHub" => @source_url
      },
      files: [
        ".formatter.exs",
        "LICENSE",
        "Makefile",
        "README.md",
        "native/c_src",
        "lib",
        "mix.exs",
        "native/rx_rust_nif/Cargo.toml",
        "native/rx_rust_nif/Cargo.lock",
        "native/rx_rust_nif/src",
        "notebooks/iris_classification_r_guide.livemd",
        "notebooks/port_arrow_native_benchmark.livemd",
        "notebooks/renv_process_backend_smoke.livemd",
        "notebooks/rx_tour.livemd",
        "priv/rx_backend.R"
      ]
    ]
  end

  defp compilers do
    case native_gate() do
      {:ok, :c} -> Mix.compilers() ++ [:rx_nif]
      {:ok, :rust} -> Mix.compilers() ++ [:rx_rust_nif]
      :disabled -> Mix.compilers()
      {:error, message} -> Mix.raise(message)
    end
  end

  defp native_gate do
    case {native_gate_enabled?("RX_BUILD_NIF"), native_gate_enabled?("RX_BUILD_RUST_NIF")} do
      {true, false} -> {:ok, :c}
      {false, true} -> {:ok, :rust}
      {false, false} -> :disabled
      {true, true} -> {:error, native_gate_mutual_exclusion_message()}
    end
  end

  defp native_gate_enabled?(name), do: System.get_env(name) == "1"

  defp native_gate_mutual_exclusion_message do
    "RX_BUILD_NIF=1 and RX_BUILD_RUST_NIF=1 cannot both be set; set exactly one native implementation gate. " <>
      "The C and Rust native implementations " <>
      "both load as priv/rx_nif.so and cannot be active in the same BEAM"
  end
end

defmodule Mix.Tasks.Compile.RxNif do
  @moduledoc false
  use Mix.Task.Compiler

  @impl true
  def run(_args) do
    app_path = Mix.Project.app_path()

    erts_include_dir =
      :code.root_dir() |> Path.join("erts-#{:erlang.system_info(:version)}/include")

    {output, status} =
      System.cmd("make", ["-B"],
        env: [
          {"MIX_APP_PATH", app_path},
          {"ERTS_INCLUDE_DIR", erts_include_dir}
        ],
        stderr_to_stdout: true
      )

    IO.write(output)

    if status == 0 do
      {:ok, []}
    else
      {:error, []}
    end
  end
end

defmodule Mix.Tasks.Compile.RxRustNif do
  @moduledoc false
  use Mix.Task.Compiler

  @crate_dir Path.join(["native", "rx_rust_nif"])

  @rust_install_instructions """
  RX_BUILD_RUST_NIF=1 requested the Rust native NIF, but cargo is missing or unusable.

  Install Rust with rustup:

      curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
      . "$HOME/.cargo/env"
      rustc --version
      cargo --version

  On Debian/Ubuntu-style systems, also install native build tools:

      sudo apt-get update
      sudo apt-get install -y build-essential pkg-config
  """

  @impl true
  def run(_args) do
    cargo = System.get_env("RX_RUST_NIF_CARGO") || "cargo"

    with :ok <- ensure_cargo!(cargo),
         :ok <- build_crate(cargo),
         :ok <- copy_artifact() do
      {:ok, []}
    else
      {:error, message} ->
        Mix.shell().error(message)
        {:error, []}
    end
  end

  defp ensure_cargo!(cargo) do
    case System.cmd(cargo, ["--version"], stderr_to_stdout: true) do
      {output, 0} ->
        IO.puts(String.trim(output))
        :ok

      {_output, _status} ->
        {:error, @rust_install_instructions}
    end
  rescue
    ErlangError -> {:error, @rust_install_instructions}
  end

  defp build_crate(cargo) do
    {output, status} =
      System.cmd(cargo, ["build", "--release"],
        cd: @crate_dir,
        stderr_to_stdout: true
      )

    IO.write(output)

    if status == 0 do
      :ok
    else
      {:error, "cargo build --release failed for #{@crate_dir}"}
    end
  end

  defp copy_artifact do
    app_path = Mix.Project.app_path()
    priv_dir = Path.join(app_path, "priv")
    File.mkdir_p!(priv_dir)

    source = rust_artifact_path()
    destination = Path.join(priv_dir, "rx_nif.so")

    File.rm(destination)
    File.cp!(source, destination)

    IO.puts("Copied Rust NIF artifact from #{source} to #{destination}")
    :ok
  end

  defp rust_artifact_path do
    extension =
      case :os.type() do
        {:unix, :darwin} -> "dylib"
        {:win32, _name} -> "dll"
        _other -> "so"
      end

    Path.join([@crate_dir, "target", "release", "librx_rust_nif.#{extension}"])
  end
end