Foundry
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
- π¦ Cargo support β Build Rust projects
- π§ CMake support β Build C/C++ projects
-
β»οΈ Smart recompilation β Tracks source files and only rebuilds when they change (not on every
mix compile) - π Path helpers β Generated functions to locate your binaries
-
π Zero configuration β Sensible defaults, just add
use Foundry
Installation
Add foundry to your dependencies in mix.exs:
def deps do
[
{:foundry, "~> 0.1.0"}
]
endQuick Start
Rust (Cargo)
-
Create a Rust project in
native/:
my_app/
βββ lib/
β βββ my_app/
β βββ native.ex
βββ native/
β βββ Cargo.toml
β βββ src/
β βββ main.rs
βββ mix.exs- Add a Native module:
# lib/my_app/native.ex
defmodule MyApp.Native do
use Foundry,
otp_app: :my_app,
builder: :cargo,
binaries: ["my_binary"]
endRun
mix compileβ your binary is now inpriv/!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
endSpawning 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
endConfiguration Options
Common Options
| Option | Type | Default | Description |
|---|---|---|---|
:otp_app | atom | required | Your application name |
:builder | atom | required |
Build system (:cargo, :cmake, or custom module) |
:binaries | [String.t()] | required | List of binary names to copy |
:source_path | String.t() | "native" (cargo) / "c_src" (cmake) | Path to native source |
:profile | String.t() |
Based on MIX_ENV | Build profile (any string) |
:skip_compilation? | boolean | false | Skip build, only copy |
:env | [{String.t(), String.t()}] | [] | Environment variables for build |
:builder_opts | keyword() | [] | Builder-specific options (see below) |
Cargo Builder Options
Pass these in builder_opts when using builder: :cargo:
| Option | Type | Default | Description |
|---|---|---|---|
:target | String.t() | nil | Rust target triple (cross-compile) |
:cargo | :system | {:bin, path} | :system | Cargo binary to use |
:target_dir | String.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 |
|---|---|---|---|
:target | String.t() | First binary | CMake target name to build |
:args | [String.t()] | [] | Extra CMake arguments |
:build_dir | String.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
endThen use it:
defmodule MyApp.Native do
use Foundry,
otp_app: :my_app,
builder: MyApp.MakeBuilder,
binaries: ["my_tool"]
endHow It Works
Compile-Time Magic
When you use Foundry, the macro:
- Runs the build at compile time via
cargo buildorcmake - Copies binaries from the build output to
_build/<env>/lib/<app>/priv/ - Registers external resources using
@external_resourcefor automatic recompilation - Generates helper functions for runtime path lookup
Automatic Recompilation
Foundry watches your source files:
Cargo projects:
Cargo.toml,Cargo.lock-
All
*.rsfiles (excludingtarget/)
CMake projects:
CMakeLists.txt,CMakePresets.json-
All
src/**/*.{c,cpp,h,hpp}files
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"]
endCustom 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"}]
endCMake 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"]]
endBuild 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"}
]
endRelease Builds
Foundry automatically uses release builds when MIX_ENV=prod:
MIX_ENV=prod mix compileOr 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:
- Rebuilds only when needed β Foundry tracks your native source files (
.rs,.c,Cargo.toml, etc.) and only triggers a rebuild when they change. No wasted time on everymix compile. - Generated path helpers β Get type-safe functions to locate your binaries at runtime. No manual path concatenation.
- Zero-config defaults β Just
use Foundry. No Makefiles, no manual dependency tracking, no custom Mix tasks.
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.