Foundry

Hex.pmDocs

Native executables that feel like Elixir modules.

Foundry makes working with native code (Rust, C/C++) just as ergonomic as writing Elixir. Just add use Foundry to a module, and it handles the build, tracks source changes for automatic recompilation, and generates path helper functionsβ€”all during mix compile.

Perfect for spawning external tools as Erlang Ports, whether you're wrapping a Rust CLI, a C++ image processor, or any standalone executable. No Rust experience requiredβ€”if you can add a dependency, you can use Foundry.

Features

Installation

Add foundry to your dependencies in mix.exs:

def deps do
  [
    {:foundry, "~> 0.1.0"}
  ]
end

Quick Start

Rust (Cargo)

  1. Create a Rust project in native/:
my_app/
β”œβ”€β”€ lib/
β”‚   └── my_app/
β”‚       └── native.ex
β”œβ”€β”€ native/
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   └── src/
β”‚       └── main.rs
└── mix.exs
  1. Add a Native module:
# lib/my_app/native.ex
defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cargo,
    binaries: ["my_binary"]
end
  1. Run mix compile β€” your binary is now in priv/!

  2. Use the generated path helpers:

# Generic lookup
MyApp.Native.bin_path("my_binary")
#=> "/path/to/_build/dev/lib/my_app/priv/my_binary"

# Generated convenience function
MyApp.Native.my_binary_path()
#=> "/path/to/_build/dev/lib/my_app/priv/my_binary"

C/C++ (CMake)

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cmake,
    binaries: ["my_tool"],
    source_path: "c_src"  # default for cmake
end

Spawning as a Port

The typical use case is spawning the binary as an Erlang Port:

defmodule MyApp.Runner do
  def start do
    exe = MyApp.Native.my_binary_path()
    
    Port.open({:spawn_executable, exe}, [
      :binary,
      :exit_status,
      args: ["--some-flag", "value"]
    ])
  end
end

Configuration Options

Common Options

Option Type Default Description
:otp_appatomrequired Your application name
:builderatomrequired Build system (:cargo, :cmake, or custom module)
:binaries[String.t()]required List of binary names to copy
:source_pathString.t()"native" (cargo) / "c_src" (cmake) Path to native source
:profileString.t() Based on MIX_ENV Build profile (any string)
:skip_compilation?booleanfalse Skip build, only copy
:env[{String.t(), String.t()}][] Environment variables for build
:builder_optskeyword()[] Builder-specific options (see below)

Cargo Builder Options

Pass these in builder_opts when using builder: :cargo:

Option Type Default Description
:targetString.t()nil Rust target triple (cross-compile)
:cargo:system | {:bin, path}:system Cargo binary to use
:target_dirString.t()_build/<env>/native/<app>/target Cargo output directory

CMake Builder Options

Pass these in builder_opts when using builder: :cmake:

Option Type Default Description
:targetString.t() First binary CMake target name to build
:args[String.t()][] Extra CMake arguments
:build_dirString.t()_build/<env>/native/<app>/build CMake build directory

Custom Builders

You can implement your own builder by creating a module that implements the Foundry.Builder behaviour:

defmodule MyApp.MakeBuilder do
  @behaviour Foundry.Builder

  @impl true
  def default_source_path, do: "c_src"

  @impl true
  def validate_opts!(_opts), do: :ok

  @impl true
  def build!(source_path, profile, opts) do
    env = Keyword.get(opts, :env, [])
    {_, 0} = System.cmd("make", [profile], cd: source_path, env: env)
    :ok
  end

  @impl true
  def binary_paths(source_path, binaries, _profile, _opts) do
    Map.new(binaries, fn name ->
      {name, Path.join([source_path, "bin", name])}
    end)
  end

  @impl true
  def discover_resources(source_path) do
    Path.wildcard(Path.join(source_path, "**/*.{c,h}"))
  end
end

Then use it:

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: MyApp.MakeBuilder,
    binaries: ["my_tool"]
end

How It Works

Compile-Time Magic

When you use Foundry, the macro:

  1. Runs the build at compile time via cargo build or cmake
  2. Copies binaries from the build output to _build/<env>/lib/<app>/priv/
  3. Registers external resources using @external_resource for automatic recompilation
  4. Generates helper functions for runtime path lookup

Automatic Recompilation

Foundry watches your source files:

Cargo projects:

CMake projects:

When any of these change, Mix knows to recompile your Native module, triggering a fresh native build.

Examples

Cross-Compilation (Rust)

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cargo,
    binaries: ["my_binary"],
    builder_opts: [target: "aarch64-unknown-linux-gnu"]
end

Custom Cargo Location

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cargo,
    binaries: ["my_binary"],
    builder_opts: [cargo: {:bin, "/usr/local/bin/cargo"}]
end

CMake with Extra Arguments

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cmake,
    binaries: ["my_tool"],
    builder_opts: [args: ["-DENABLE_FEATURE=ON", "-DCUSTOM_VAR=value"]]
end

Build Environment Variables

Pass environment variables to the build process via the :env option:

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cmake,
    binaries: ["my_nif"],
    env: [
      {"CC", "clang"},
      {"CXX", "clang++"},
      {"ERL_EI_INCLUDE_DIR", "/usr/lib/erlang/usr/include"},
      {"ERL_EI_LIB_DIR", "/usr/lib/erlang/usr/lib"}
    ]
end

Release Builds

Foundry automatically uses release builds when MIX_ENV=prod:

MIX_ENV=prod mix compile

Or override explicitly:

use Foundry,
  otp_app: :my_app,
  builder: :cargo,
  binaries: ["my_binary"],
  profile: "release"

Custom Profiles

The :profile option accepts any string, so you can use custom Cargo profiles or CMake build types:

# Cargo custom profile (defined in Cargo.toml)
use Foundry,
  otp_app: :my_app,
  builder: :cargo,
  binaries: ["my_binary"],
  profile: "release-lto"

# CMake RelWithDebInfo
use Foundry,
  otp_app: :my_app,
  builder: :cmake,
  binaries: ["my_tool"],
  profile: "RelWithDebInfo"

Multiple Binaries

defmodule MyApp.Native do
  use Foundry,
    otp_app: :my_app,
    builder: :cargo,
    binaries: ["tool_a", "tool_b", "tool_c"]
end

# Generated functions:
MyApp.Native.tool_a_path()
MyApp.Native.tool_b_path()
MyApp.Native.tool_c_path()

Comparison with Other Tools

Tool Use Case Languages Tracks Source Changes Path Helpers Config Required
Foundry Standalone executables as Ports Rust, C/C++, extensible βœ… βœ… Generated functions Minimalβ€”just use Foundry
Rustler Rust NIFs (in-process) Rust only βœ… βœ… Minimal
elixir_make Run make during compile Any (manual) ❌ ❌ Manual Makefile + hooks
bundlex Membrane framework NIFs C/C++ ❌ βœ… Moderate

Why Foundry?

If you need to spawn executables as Ports (not NIFs), Foundry gives you Rustler-level DX:

Unlike elixir_make, you don't write scripts to track dependencies or find compiled binaries. Unlike always-rebuilding tools, you don't wait for unnecessary recompilation.

License

MIT License - see LICENSE for details.