Current section
Files
Jump to
Current section
Files
lib/mix/compilers/firebird_wasm.ex
defmodule Mix.Compilers.FirebirdWasm do
@moduledoc """
Mix compiler integration for Firebird WASM target.
This compiler scans for Elixir source files containing `@wasm true`
annotations and compiles them to WebAssembly. It integrates with the
Mix build system for incremental compilation.
## Usage in mix.exs
def project do
[
compilers: Mix.compilers() ++ [:firebird_wasm],
firebird: [
wasm_output: "_build/wasm",
wasm_sources: ["lib/wasm_modules"],
wat_only: false
]
]
end
"""
@manifest_file "firebird_wasm.manifest"
@doc """
Run the Firebird WASM compiler.
Called by Mix as part of the compilation pipeline when `:firebird_wasm`
is in the project's compilers list.
"""
@spec run(keyword()) :: :ok | :noop | {:error, term()}
def run(_args \\ []) do
project = Mix.Project.config()
firebird_opts = Keyword.get(project, :firebird, [])
output_dir = Keyword.get(firebird_opts, :wasm_output, "_build/wasm")
source_dirs = Keyword.get(firebird_opts, :wasm_sources, ["lib"])
wat_only = Keyword.get(firebird_opts, :wat_only, false)
# Check for wat2wasm unless wat_only
unless wat_only do
unless Firebird.Compiler.wat2wasm_available?() do
Mix.shell().error("wat2wasm not found. Install wabt or use wat_only: true")
{:error, :wat2wasm_not_found}
end
end
manifest_path = manifest_path()
old_manifest = read_manifest(manifest_path)
# Find all source files
sources = find_wasm_sources(source_dirs)
# Find files that need recompilation
stale = stale_sources(sources, old_manifest)
if Enum.empty?(stale) do
:noop
else
File.mkdir_p!(output_dir)
Mix.shell().info("Firebird WASM: compiling #{length(stale)} file(s)")
results =
Enum.map(stale, fn source_path ->
compile_source(source_path, output_dir, wat_only)
end)
successes = Enum.filter(results, &match?({:ok, _}, &1))
failures = Enum.filter(results, &match?({:error, _}, &1))
# Update manifest with successful compilations
new_entries = Enum.map(successes, fn {:ok, entry} -> entry end)
# Keep non-stale entries from old manifest
stale_paths = MapSet.new(stale)
kept = Enum.reject(old_manifest, fn entry -> entry.source in stale_paths end)
write_manifest(manifest_path, kept ++ new_entries)
for {:ok, entry} <- successes do
Mix.shell().info(" ✅ #{Path.basename(entry.source)} → #{entry.module}")
end
for {:error, {path, reason}} <- failures do
Mix.shell().error(" ❌ #{Path.basename(path)}: #{inspect(reason)}")
end
if Enum.any?(failures) do
{:error, Enum.map(failures, fn {:error, r} -> r end)}
else
:ok
end
end
end
@doc """
Clean compiled WASM artifacts.
"""
@spec clean() :: :ok
def clean do
manifest_path = manifest_path()
manifest = read_manifest(manifest_path)
for entry <- manifest do
if entry.wat_path, do: File.rm(entry.wat_path)
if entry.wasm_path, do: File.rm(entry.wasm_path)
end
File.rm(manifest_path)
:ok
end
@doc """
Return the path to the manifest file.
"""
@spec manifest_path() :: String.t()
def manifest_path do
Path.join(Mix.Project.manifest_path(), @manifest_file)
end
@doc """
Find all Elixir source files that contain WASM annotations.
"""
@spec find_wasm_sources([String.t()]) :: [String.t()]
def find_wasm_sources(dirs) do
dirs
|> Enum.flat_map(fn dir ->
if File.dir?(dir) do
Path.wildcard(Path.join(dir, "**/*.{ex,exs}"))
else
if File.regular?(dir), do: [dir], else: []
end
end)
|> Enum.filter(&has_wasm_annotation?/1)
|> Enum.sort()
|> Enum.uniq()
end
@doc """
Check if a source file contains @wasm annotations.
"""
@spec has_wasm_annotation?(String.t()) :: boolean()
def has_wasm_annotation?(path) do
case File.read(path) do
{:ok, content} -> String.contains?(content, "@wasm true")
_ -> false
end
end
# Find sources that need recompilation based on content hash + mtime
defp stale_sources(sources, manifest) do
manifest_map =
Map.new(manifest, fn entry ->
{entry.source,
%{compiled_at: entry.compiled_at, content_hash: Map.get(entry, :content_hash)}}
end)
Enum.filter(sources, fn source ->
case Map.get(manifest_map, source) do
nil ->
true
%{content_hash: cached_hash} when is_binary(cached_hash) ->
# Use content hash if available (more accurate than mtime)
current_hash = Firebird.Target.ContentHash.hash_file(source)
current_hash != cached_hash
%{compiled_at: compiled_at} when not is_nil(compiled_at) ->
# Fall back to mtime comparison
case File.stat(source) do
{:ok, %{mtime: mtime}} ->
mtime_seconds = :calendar.datetime_to_gregorian_seconds(mtime)
mtime_seconds > compiled_at
_ ->
true
end
_ ->
true
end
end)
end
defp compile_source(source_path, output_dir, wat_only) do
case Firebird.Compiler.compile(source_path, output_dir: output_dir, wat_only: wat_only) do
{:ok, result} ->
base = Atom.to_string(result.module) |> String.replace(".", "_")
entry = %{
source: source_path,
module: result.module,
wat_path: Path.join(output_dir, "#{base}.wat"),
wasm_path: if(result.wasm, do: Path.join(output_dir, "#{base}.wasm"), else: nil),
compiled_at: :calendar.datetime_to_gregorian_seconds(:calendar.local_time()),
wat_size: byte_size(result.wat),
wasm_size: if(result.wasm, do: byte_size(result.wasm), else: 0),
content_hash: Firebird.Target.ContentHash.hash_file(source_path)
}
{:ok, entry}
{:error, reason} ->
{:error, {source_path, reason}}
end
end
defp read_manifest(path) do
case File.read(path) do
{:ok, content} ->
content
|> :erlang.binary_to_term()
|> case do
entries when is_list(entries) -> entries
_ -> []
end
_ ->
[]
end
rescue
_ -> []
end
defp write_manifest(path, entries) do
File.mkdir_p!(Path.dirname(path))
File.write!(path, :erlang.term_to_binary(entries))
end
end