Packages

Run WebAssembly from Elixir. Load WASM modules in Rust, Go, C — call them like native functions.

Current section

Files

Jump to
firebird lib mix tasks firebird.target.compile.ex
Raw

lib/mix/tasks/firebird.target.compile.ex

defmodule Mix.Tasks.Firebird.Target.Compile do
@moduledoc """
Compile Elixir project to WebAssembly via the Firebird WASM target.
This is the primary entry point for compiling Elixir modules to WASM.
It integrates with Mix's compilation pipeline and supports incremental
builds, parallel compilation, and detailed diagnostics.
## Usage
# Compile all @wasm annotated modules
mix firebird.target.compile
# Compile specific files
mix firebird.target.compile --files lib/math.ex,lib/crypto.ex
# Compile with optimizations
mix firebird.target.compile --optimize --tco
# Compile with LICM (loop-invariant code motion, requires --optimize --tco)
mix firebird.target.compile --optimize --tco --licm
# WAT only (no binary)
mix firebird.target.compile --wat-only
# Force full recompilation
mix firebird.target.compile --force
# Show diagnostics
mix firebird.target.compile --diagnostics
# Emit compilation manifest
mix firebird.target.compile --manifest
## MIX_TARGET Integration
Set `MIX_TARGET=wasm` to automatically trigger WASM compilation:
MIX_TARGET=wasm mix compile
## Configuration
In `mix.exs`:
def project do
[
firebird: [
wasm_output: "_build/wasm",
wasm_sources: ["lib/wasm_modules"],
optimize: true,
tco: true
]
]
end
## Compilation Diagnostics
With `--diagnostics`, the compiler reports:
- Unsupported language features found in source files
- Type inference warnings
- Optimization opportunities
- Binary size analysis
## Exit Codes
- 0: All modules compiled successfully
- 1: One or more modules failed to compile
"""
use Mix.Task
@shortdoc "Compile Elixir modules to WebAssembly"
@switches [
output: :string,
files: :string,
dirs: :string,
wat_only: :boolean,
verbose: :boolean,
verify: :boolean,
force: :boolean,
optimize: :boolean,
tco: :boolean,
inline: :boolean,
cse: :boolean,
licm: :boolean,
diagnostics: :boolean,
manifest: :boolean,
parallel: :boolean,
json: :boolean,
quiet: :boolean
]
@aliases [
o: :output,
f: :files,
d: :dirs,
v: :verbose,
q: :quiet
]
@impl Mix.Task
def run(args) do
{opts, _rest, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases)
config = build_config(opts)
quiet = Keyword.get(opts, :quiet, false)
show_diagnostics = Keyword.get(opts, :diagnostics, false)
emit_manifest = Keyword.get(opts, :manifest, false)
json_output = Keyword.get(opts, :json, false)
unless quiet do
Mix.shell().info("đŸ”Ĩ Firebird WASM Target Compiler")
Mix.shell().info("")
end
# Validate prerequisites
case validate_environment(config) do
:ok -> :ok
{:error, message} -> Mix.raise(message)
end
# Discover sources
sources = Firebird.Target.discover_sources(config)
if Enum.empty?(sources) do
unless quiet do
Mix.shell().info(" No compilable sources found.")
Mix.shell().info(" Add @wasm true annotations to your modules.")
end
:ok
else
# Build compiler opts fingerprint for content-hash staleness detection
compiler_opts = [
optimize: config.optimize,
tco: config.tco,
inline: config.inline,
cse: Map.get(config, :cse, false),
licm: Map.get(config, :licm, false),
wat_only: config.wat_only
]
# Filter stale sources unless --force
sources_to_compile =
if config.force do
sources
else
filter_stale_sources(sources, config.output_dir, compiler_opts)
end
if Enum.empty?(sources_to_compile) do
unless quiet do
Mix.shell().info(
" All #{length(sources)} file(s) up to date. Use --force to recompile."
)
end
:ok
else
# Run diagnostics pre-flight if requested
if show_diagnostics do
run_diagnostics(sources_to_compile)
end
# Compile (parallel or sequential)
parallel = Keyword.get(opts, :parallel, false)
start = System.monotonic_time(:millisecond)
results =
if parallel do
compile_parallel(sources_to_compile, config, quiet)
else
compile_all(sources_to_compile, config, quiet)
end
elapsed = System.monotonic_time(:millisecond) - start
successes = Enum.filter(results, &match?({:ok, _}, &1))
failures = Enum.filter(results, &match?({:error, _}, &1))
# Emit manifest if requested
if emit_manifest do
write_compilation_manifest(successes, config, elapsed)
end
# Report results
if json_output do
report_json(successes, failures, elapsed)
else
unless quiet do
report_results(successes, failures, elapsed, sources, sources_to_compile)
end
end
# Persist content hashes for successfully compiled sources
# so the next build can use accurate content-based staleness detection
if Enum.any?(successes) do
compiled_sources =
Enum.map(successes, fn {:ok, r} -> Map.get(r, :source_file) end)
|> Enum.filter(&is_binary/1)
if Enum.any?(compiled_sources) do
update_content_hashes(compiled_sources, compiler_opts: compiler_opts)
end
end
if Enum.any?(failures) do
exit({:shutdown, 1})
end
:ok
end
end
end
@doc """
Build a Target.Config from CLI options merged with project config.
"""
@spec build_config(keyword()) :: Firebird.Target.Config.t()
def build_config(opts) do
base = Firebird.Target.Config.from_mix_project()
overrides =
opts
|> Keyword.take([
:optimize,
:tco,
:inline,
:cse,
:licm,
:wat_only,
:verify,
:verbose,
:force
])
|> Keyword.merge(
case Keyword.get(opts, :output) do
nil -> []
out -> [output_dir: out]
end
)
|> Keyword.merge(
case Keyword.get(opts, :dirs) do
nil -> []
dirs -> [sources: String.split(dirs, ",", trim: true)]
end
)
|> Keyword.merge(
case Keyword.get(opts, :files) do
nil -> []
files -> [files: String.split(files, ",", trim: true), sources: []]
end
)
Firebird.Target.Config.merge(base, overrides)
end
@doc """
Validate that the compilation environment is properly set up.
"""
@spec validate_environment(Firebird.Target.Config.t()) :: :ok | {:error, String.t()}
def validate_environment(config) do
cond do
not config.wat_only and not Firebird.Compiler.wat2wasm_available?() ->
{:error,
"""
wat2wasm not found! Install the WebAssembly Binary Toolkit (wabt):
# macOS
brew install wabt
# Ubuntu/Debian
apt-get install wabt
# Or use --wat-only to generate WAT text only
"""}
true ->
:ok
end
end
@doc """
Filter sources to only those that are stale (content changed since last compile).
Uses content hashing for accurate change detection - touching a file
without changing its content won't trigger recompilation.
Falls back to mtime-based detection if hash manifest is unavailable.
"""
@spec filter_stale_sources([String.t()], String.t()) :: [String.t()]
def filter_stale_sources(sources, output_dir) do
filter_stale_sources(sources, output_dir, [])
end
@doc """
Filter sources with compiler options awareness.
Uses content hashing (SHA-256 of file contents + compiler options) as the
primary staleness check. Falls back to mtime-based detection when the
content hash manifest doesn't exist yet (first build).
This means:
- Touching a file without changing content does NOT trigger recompilation
- Changing compiler flags (e.g. --optimize, --tco) DOES trigger recompilation
- First build uses mtime, subsequent builds use content hashes
"""
@spec filter_stale_sources([String.t()], String.t(), keyword()) :: [String.t()]
def filter_stale_sources(sources, output_dir, compiler_opts) do
alias Firebird.Target.ContentHash
hash_manifest_path = ContentHash.manifest_path()
case ContentHash.read_manifest(hash_manifest_path) do
{:ok, cached} when map_size(cached) > 0 ->
# Content hash manifest exists — use accurate content-based detection
ContentHash.filter_stale(sources, cached, compiler_opts)
_ ->
# No manifest yet (first build) — fall back to mtime
filter_stale_by_mtime(sources, output_dir)
end
end
@doc """
Filter sources using content hashing for more accurate change detection.
Unlike mtime-based checking, this detects actual content changes,
so touching a file without changing it won't trigger recompilation.
## Options
- `:hash_manifest` - Path to read/write hash manifest (default: auto)
- `:compiler_opts` - Compiler options to include in hash (default: [])
"""
@spec filter_stale_by_content([String.t()], keyword()) :: [String.t()]
def filter_stale_by_content(sources, opts \\ []) do
alias Firebird.Target.ContentHash
hash_manifest_path = Keyword.get(opts, :hash_manifest, ContentHash.manifest_path())
compiler_opts = Keyword.get(opts, :compiler_opts, [])
cached =
case ContentHash.read_manifest(hash_manifest_path) do
{:ok, m} -> m
_ -> %{}
end
ContentHash.filter_stale(sources, cached, compiler_opts)
end
@doc """
Update the content hash manifest after successful compilation.
"""
@spec update_content_hashes([String.t()], keyword()) :: :ok
def update_content_hashes(sources, opts \\ []) do
alias Firebird.Target.ContentHash
hash_manifest_path = Keyword.get(opts, :hash_manifest, ContentHash.manifest_path())
compiler_opts = Keyword.get(opts, :compiler_opts, [])
new_hashes = ContentHash.build_manifest(sources, compiler_opts)
existing =
case ContentHash.read_manifest(hash_manifest_path) do
{:ok, m} -> m
_ -> %{}
end
merged = Map.merge(existing, new_hashes)
ContentHash.write_manifest(hash_manifest_path, merged)
:ok
rescue
_ -> :ok
end
defp filter_stale_by_mtime(sources, output_dir) do
all_outputs = Path.wildcard(Path.join(output_dir, "*.{wat,wasm}"))
Enum.filter(sources, fn source ->
source_mtime = get_mtime(source)
basename = Path.basename(source, Path.extname(source)) |> String.downcase()
output_files =
Enum.filter(all_outputs, fn f ->
output_base = Path.basename(f) |> String.downcase()
String.contains?(output_base, basename)
end)
if Enum.empty?(output_files) do
true
else
Enum.all?(output_files, fn output ->
output_mtime = get_mtime(output)
source_mtime > output_mtime
end)
end
end)
end
@doc """
Run pre-compilation diagnostics on source files.
"""
@spec run_diagnostics([String.t()]) :: :ok
def run_diagnostics(sources) do
Mix.shell().info("📋 Pre-compilation Diagnostics")
Mix.shell().info("")
Enum.each(sources, fn source ->
case File.read(source) do
{:ok, code} ->
diagnostics = analyze_for_diagnostics(code, source)
print_diagnostics(source, diagnostics)
{:error, _} ->
Mix.shell().error(" ❌ Cannot read #{source}")
end
end)
Mix.shell().info("")
end
# Private
defp compile_all(sources, config, quiet) do
File.mkdir_p!(config.output_dir)
compiler_opts = [
output_dir: config.output_dir,
optimize: config.optimize,
tco: config.tco,
inline: config.inline,
cse: Map.get(config, :cse, false),
licm: Map.get(config, :licm, false),
wat_only: config.wat_only,
source_map: config.source_map
]
results =
Enum.map(sources, fn source ->
unless quiet do
Mix.shell().info(" 📄 #{Path.relative_to_cwd(source)}")
end
case Firebird.Compiler.compile(source, compiler_opts) do
{:ok, result} ->
result = Map.put(result, :source_file, source)
unless quiet do
Mix.shell().info(" ✅ #{result.module}")
if config.verbose do
Mix.shell().info(" WAT: #{byte_size(result.wat)} bytes")
if result.wasm,
do: Mix.shell().info(" WASM: #{byte_size(result.wasm)} bytes")
end
end
if config.verify and result.wasm do
verify_module(result, quiet)
end
{:ok, result}
{:error, reason} ->
unless quiet do
Mix.shell().error(" ❌ #{format_error(reason)}")
end
{:error, {source, reason}}
end
end)
results
end
defp compile_parallel(sources, config, quiet) do
File.mkdir_p!(config.output_dir)
unless quiet do
Mix.shell().info(" ⚡ Parallel compilation (#{System.schedulers_online()} workers)")
end
case Firebird.Target.Parallel.compile(sources, config) do
{:ok, results} ->
unless quiet do
Enum.each(results, fn r ->
Mix.shell().info(" ✅ #{r.module}")
end)
end
Enum.map(results, fn r -> {:ok, r} end)
{:error, {:partial_failure, details}} ->
successes = Keyword.get(details, :successes, [])
failures = Keyword.get(details, :failures, [])
unless quiet do
Enum.each(successes, fn r ->
Mix.shell().info(" ✅ #{r.module}")
end)
Enum.each(failures, fn {:error, {source, reason}} ->
Mix.shell().error(" ❌ #{Path.basename(source)}: #{inspect(reason)}")
end)
end
Enum.map(successes, fn r -> {:ok, r} end) ++ failures
{:error, reason} ->
unless quiet do
Mix.shell().error(" ❌ Parallel compilation failed: #{inspect(reason)}")
end
[{:error, reason}]
end
end
defp verify_module(result, quiet) do
case Firebird.load(result.wasm) do
{:ok, instance} ->
exports = Firebird.exports(instance)
unless quiet do
Mix.shell().info(" 🔍 Verified: #{length(exports)} export(s)")
end
Firebird.stop(instance)
{:error, reason} ->
unless quiet do
Mix.shell().error(" ⚠ Verification failed: #{inspect(reason)}")
end
end
end
defp report_results(successes, failures, elapsed, all_sources, compiled_sources) do
cached = length(all_sources) - length(compiled_sources)
Mix.shell().info("")
Mix.shell().info("📊 Compilation Summary")
Mix.shell().info(" ✅ Compiled: #{length(successes)}")
if cached > 0 do
Mix.shell().info(" đŸ“Ļ Cached: #{cached}")
end
if Enum.any?(failures) do
Mix.shell().info(" ❌ Failed: #{length(failures)}")
end
total_wat =
successes
|> Enum.map(fn {:ok, r} -> byte_size(r.wat) end)
|> Enum.sum()
total_wasm =
successes
|> Enum.filter(fn {:ok, r} -> r.wasm != nil end)
|> Enum.map(fn {:ok, r} -> byte_size(r.wasm) end)
|> Enum.sum()
Mix.shell().info(" 📏 Total WAT: #{format_bytes(total_wat)}")
if total_wasm > 0 do
Mix.shell().info(" đŸ“Ļ Total WASM: #{format_bytes(total_wasm)}")
end
Mix.shell().info(" ⏱ Time: #{elapsed}ms")
Mix.shell().info("")
# Print failure details
Enum.each(failures, fn {:error, {source, reason}} ->
Mix.shell().error(" Failed: #{source}")
Mix.shell().error(" #{format_error(reason)}")
end)
end
defp report_json(successes, failures, elapsed) do
data = %{
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
elapsed_ms: elapsed,
compiled: length(successes),
failed: length(failures),
modules:
Enum.map(successes, fn {:ok, r} ->
%{
module: to_string(r.module),
wat_size: byte_size(r.wat),
wasm_size: if(r.wasm, do: byte_size(r.wasm), else: nil),
source: Map.get(r, :source_file)
}
end),
errors:
Enum.map(failures, fn {:error, {source, reason}} ->
%{source: source, error: inspect(reason)}
end)
}
Mix.shell().info(Jason.encode!(data, pretty: true))
end
defp write_compilation_manifest(successes, config, elapsed) do
manifest = %{
version: 1,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
elapsed_ms: elapsed,
output_dir: config.output_dir,
options: %{
optimize: config.optimize,
tco: config.tco,
inline: config.inline,
wat_only: config.wat_only
},
modules:
Enum.map(successes, fn {:ok, r} ->
base = Atom.to_string(r.module) |> String.replace(".", "_")
%{
module: to_string(r.module),
wat_file: "#{base}.wat",
wasm_file: if(r.wasm, do: "#{base}.wasm"),
wat_size: byte_size(r.wat),
wasm_size: if(r.wasm, do: byte_size(r.wasm), else: 0),
source: Map.get(r, :source_file)
}
end)
}
manifest_path = Path.join(config.output_dir, "firebird_manifest.json")
File.mkdir_p!(config.output_dir)
File.write!(manifest_path, Jason.encode!(manifest, pretty: true))
Mix.shell().info(" 📋 Manifest: #{manifest_path}")
end
defp analyze_for_diagnostics(code, source) do
diagnostics = %{
warnings: [],
info: [],
source: source
}
# Check for unsupported features
diagnostics =
if String.contains?(code, "spawn") or String.contains?(code, "send") do
update_in(
diagnostics.warnings,
&["Process operations (spawn/send) not supported in WASM" | &1]
)
else
diagnostics
end
diagnostics =
if String.contains?(code, "IO.") or String.contains?(code, ":io.") do
update_in(diagnostics.warnings, &["IO operations not available in WASM" | &1])
else
diagnostics
end
diagnostics =
if String.contains?(code, "GenServer") or String.contains?(code, "Agent") do
update_in(diagnostics.warnings, &["OTP behaviors not supported in WASM target" | &1])
else
diagnostics
end
diagnostics =
if Regex.match?(~r/\[.*\|.*\]/, code) or String.contains?(code, "Enum.") do
update_in(
diagnostics.warnings,
&["List operations not supported - only numeric types available" | &1]
)
else
diagnostics
end
diagnostics =
if String.contains?(code, "%{") or String.contains?(code, "Map.") do
update_in(diagnostics.warnings, &["Map operations not supported in WASM target" | &1])
else
diagnostics
end
diagnostics =
if String.contains?(code, "String.") do
update_in(diagnostics.warnings, &["String operations not supported in WASM target" | &1])
else
diagnostics
end
# Check for optimization opportunities
diagnostics =
if Regex.match?(~r/def \w+\(.*\).*\w+\(.*\)\s*\+/, code) do
update_in(diagnostics.info, &["Consider --tco flag for tail-recursive functions" | &1])
else
diagnostics
end
# Count functions
func_count = Regex.scan(~r/@wasm true/, code) |> length()
diagnostics =
if func_count > 0 do
update_in(diagnostics.info, &["#{func_count} @wasm annotated function(s)" | &1])
else
update_in(
diagnostics.info,
&["No @wasm annotations - all public functions will be compiled" | &1]
)
end
diagnostics
end
defp print_diagnostics(source, diagnostics) do
basename = Path.relative_to_cwd(source)
Mix.shell().info(" 📄 #{basename}")
Enum.each(diagnostics.info, fn msg ->
Mix.shell().info(" â„šī¸ #{msg}")
end)
Enum.each(diagnostics.warnings, fn msg ->
Mix.shell().info(" âš ī¸ #{msg}")
end)
Mix.shell().info("")
end
defp get_mtime(path) do
case File.stat(path) do
{:ok, %{mtime: mtime}} ->
:calendar.datetime_to_gregorian_seconds(mtime)
_ ->
0
end
end
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
defp format_error({:parse_error, {line, col}, msg, token}) do
"Parse error at #{line}:#{col}: #{msg}#{token}"
end
defp format_error({:validation_errors, errors}) do
details = Enum.map(errors, fn {func, errs} -> " #{func}: #{Enum.join(errs, ", ")}" end)
"Validation errors:\n#{Enum.join(details, "\n")}"
end
defp format_error({:wat2wasm_error, output}), do: "wat2wasm: #{output}"
defp format_error({:file_error, reason, path}), do: "Cannot read #{path}: #{reason}"
defp format_error(other), do: inspect(other)
end