defmodule Mix.Tasks.Firebird.Target do @moduledoc """ Configure and compile an Elixir project's WASM target. This task provides a complete workflow for compiling Elixir modules to WebAssembly. It discovers all modules with `@wasm true` annotations, compiles them through the Firebird pipeline, and produces `.wasm` binaries. ## Usage # Discover and compile all @wasm annotated modules mix firebird.target # Compile specific files mix firebird.target --files lib/math.ex,lib/crypto.ex # Target specific directories mix firebird.target --dirs lib/wasm_modules # Output to custom directory mix firebird.target --output priv/wasm # WAT only (no binary compilation) mix firebird.target --wat-only # Verbose output showing WAT and compilation details mix firebird.target --verbose # Verify compiled WASM modules load correctly mix firebird.target --verify # Clean WASM build artifacts mix firebird.target --clean # Show compilation stats mix firebird.target --stats # Profile compilation phases mix firebird.target --profile # Link all modules into a single WASM binary mix firebird.target --link --link-name MyApp # Watch for changes and recompile mix firebird.target --watch # Force recompile (ignore incremental cache) mix firebird.target --force ## Configuration Add to your `mix.exs`: def project do [ firebird: [ wasm_output: "_build/wasm", wasm_sources: ["lib/wasm_modules"], wat_only: false ] ] end ## Module Annotations Mark functions for WASM compilation with `@wasm true`: defmodule MyMath do @wasm true def add(a, b), do: a + b @wasm true def fibonacci(0), do: 0 def fibonacci(1), do: 1 def fibonacci(n), do: fibonacci(n - 1) + fibonacci(n - 2) end If no `@wasm` annotations are present in a file, all public functions will be compiled. ## Compilation Pipeline Elixir Source (.ex) → Elixir AST (Code.string_to_quoted) → Firebird IR (normalized intermediate representation) → Type Inference (i64/f64 type assignment) → Validation (check compilable subset) → WAT (WebAssembly Text Format) → WASM binary (via wat2wasm) ## Supported Elixir Subset - Integer arithmetic: `+`, `-`, `*`, `div`, `rem` - Comparison: `==`, `!=`, `<`, `>`, `<=`, `>=` - Boolean: `and`, `or`, `not` - Control flow: `if/else`, `cond`, `case` - Pattern matching on integer literals - Multi-clause functions with guards - Recursive and tail-recursive functions ## Limitations - Only numeric types (i64 integers, f64 floats) - No lists, maps, tuples, strings, or atoms - No IO, processes, or OTP features - No standard library functions - No hot code reloading """ use Mix.Task @shortdoc "Compile Elixir project to WebAssembly target" @switches [ output: :string, files: :string, dirs: :string, wat_only: :boolean, verbose: :boolean, verify: :boolean, clean: :boolean, stats: :boolean, force: :boolean, optimize: :boolean, tco: :boolean, inline: :boolean, profile: :boolean, link: :boolean, link_name: :string, link_prefix: :boolean, watch: :boolean, source_map: :boolean, dry_run: :boolean, json: :boolean ] @aliases [ o: :output, f: :files, d: :dirs, v: :verbose ] @impl Mix.Task def run(args) do {opts, _rest, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases) if Keyword.get(opts, :clean, false) do clean(opts) else if Keyword.get(opts, :watch, false) do watch(opts) else compile(opts) end end end @doc """ Compile WASM target with the given options. """ @spec compile(keyword()) :: :ok | {:error, term()} def compile(opts) do # Merge project config with CLI opts project_config = get_project_config() opts = merge_config(project_config, opts) # Validate config validate_config(opts) output_dir = Keyword.get(opts, :output, "_build/wasm") wat_only = Keyword.get(opts, :wat_only, false) verbose = Keyword.get(opts, :verbose, false) verify = Keyword.get(opts, :verify, false) show_stats = Keyword.get(opts, :stats, false) optimize = Keyword.get(opts, :optimize, false) tco = Keyword.get(opts, :tco, false) do_inline = Keyword.get(opts, :inline, false) profile = Keyword.get(opts, :profile, false) do_link = Keyword.get(opts, :link, false) force = Keyword.get(opts, :force, false) dry_run = Keyword.get(opts, :dry_run, false) # For dry run, skip prerequisites check unless dry_run do check_prerequisites!(wat_only) end # Discover source files sources = discover_sources(opts) if Enum.empty?(sources) do Mix.shell().info("🔥 Firebird WASM Target: No compilable sources found") Mix.shell().info(" Add @wasm true annotations to your Elixir functions") :ok else if dry_run do run_dry_run(sources) else # Filter to only stale sources unless --force sources_to_compile = if force do sources else filter_stale(sources, output_dir) end if Enum.empty?(sources_to_compile) and not do_link do Mix.shell().info("🔥 Firebird WASM Target: All #{length(sources)} file(s) up to date") Mix.shell().info(" Use --force to recompile") :ok else Mix.shell().info("🔥 Firebird WASM Target") Mix.shell().info( " Sources: #{length(sources_to_compile)} file(s)#{if not force and length(sources_to_compile) < length(sources), do: " (#{length(sources) - length(sources_to_compile)} cached)", else: ""}" ) Mix.shell().info(" Output: #{output_dir}/") Mix.shell().info("") File.mkdir_p!(output_dir) start_time = System.monotonic_time(:millisecond) source_map = Keyword.get(opts, :source_map, false) compile_opts = [optimize: optimize, tco: tco, inline: do_inline, source_map: source_map] results = Enum.map(sources_to_compile, fn source -> compile_source(source, output_dir, wat_only, verbose, verify, compile_opts, profile) end) elapsed = System.monotonic_time(:millisecond) - start_time successes = Enum.filter(results, &match?({:ok, _}, &1)) failures = Enum.filter(results, &match?({:error, _}, &1)) # Update manifest update_manifest(successes, output_dir) Mix.shell().info("") # JSON output mode json_mode = Keyword.get(opts, :json, false) if json_mode do summary = Firebird.Compiler.Summary.generate(results) summary_with_time = Map.put(summary, :elapsed_ms, elapsed) Mix.shell().info(Firebird.Compiler.Summary.to_json(summary_with_time)) else if show_stats do print_stats(successes) end Mix.shell().info( "📊 Results: #{length(successes)} compiled, #{length(failures)} failed (#{elapsed}ms)" ) end # Link if requested link_result = if do_link and Enum.any?(successes) do link_modules(sources, opts) else :ok end cond do Enum.any?(failures) -> {:error, {:compilation_failed, length(failures)}} link_result != :ok -> link_result true -> :ok end end end # dry_run else end end @doc """ Run a dry-run analysis without compiling. Shows which files would be compiled and analyzes each for compilability. """ @spec run_dry_run([String.t()]) :: :ok def run_dry_run(sources) do Mix.shell().info("🔥 Firebird WASM Target — Dry Run") Mix.shell().info(" Would compile #{length(sources)} file(s):") Mix.shell().info("") Enum.each(sources, fn source -> Mix.shell().info(" 📄 #{source}") case File.read(source) do {:ok, code} -> case Firebird.Compiler.Analyzer.analyze_source(code) do {:ok, analysis} -> if analysis.compilable do fns = length(analysis.functions) Mix.shell().info(" ✅ #{analysis.module}: #{fns} function(s) compilable") else Mix.shell().info(" ⚠ #{analysis.module}: has issues") Enum.each(analysis.issues, fn issue -> Mix.shell().info(" • #{issue}") end) end Enum.each(analysis.suggestions, fn sug -> Mix.shell().info(" 💡 #{sug}") end) {:error, reason} -> Mix.shell().error(" ❌ Analysis failed: #{inspect(reason)}") end {:error, reason} -> Mix.shell().error(" ❌ Cannot read: #{reason}") end end) Mix.shell().info("") :ok end @doc """ Watch for source file changes and recompile. """ @spec watch(keyword()) :: no_return() def watch(opts) do Mix.shell().info("🔥 Firebird WASM Target — Watch Mode") Mix.shell().info(" Watching for changes... (Ctrl+C to stop)") Mix.shell().info("") # Initial compile compile(Keyword.delete(opts, :watch)) # Poll loop watch_loop(opts, snapshot_mtimes(opts)) end defp watch_loop(opts, prev_mtimes) do Process.sleep(1_000) current_mtimes = snapshot_mtimes(opts) changed = Enum.filter(current_mtimes, fn {path, mtime} -> Map.get(prev_mtimes, path) != mtime end) |> Enum.map(fn {path, _} -> path end) if Enum.any?(changed) do Mix.shell().info("") Mix.shell().info("🔄 Changes detected in #{length(changed)} file(s):") Enum.each(changed, fn f -> Mix.shell().info(" #{f}") end) Mix.shell().info("") compile(opts |> Keyword.delete(:watch) |> Keyword.put(:files, Enum.join(changed, ","))) end watch_loop(opts, current_mtimes) end defp snapshot_mtimes(opts) do sources = discover_sources(opts) Map.new(sources, fn path -> mtime = case File.stat(path) do {:ok, %{mtime: mtime}} -> mtime _ -> nil end {path, mtime} end) end @doc """ Clean WASM build artifacts. """ @spec clean(keyword()) :: :ok def clean(opts) do output_dir = Keyword.get(opts, :output, "_build/wasm") if File.dir?(output_dir) do files = Path.wildcard(Path.join(output_dir, "*.{wat,wasm}")) Enum.each(files, &File.rm/1) Mix.shell().info("🧹 Cleaned #{length(files)} WASM artifact(s) from #{output_dir}/") else Mix.shell().info("🧹 Nothing to clean (#{output_dir}/ doesn't exist)") end # Also clean the compiler manifest Mix.Compilers.FirebirdWasm.clean() :ok end @doc """ Validate project configuration and print warnings for common mistakes. """ @spec validate_config(keyword()) :: :ok def validate_config(opts) do # Check output directory output = Keyword.get(opts, :output, "_build/wasm") if String.starts_with?(output, "/") and not File.dir?(Path.dirname(output)) do Mix.shell().info("⚠ Output directory parent doesn't exist: #{Path.dirname(output)}") end # Check source dirs exist case Keyword.get(opts, :dirs) do nil -> :ok dirs_str -> dirs_str |> String.split(",", trim: true) |> Enum.each(fn dir -> unless File.dir?(dir) do Mix.shell().info("⚠ Source directory not found: #{dir}") end end) end # Check files exist case Keyword.get(opts, :files) do nil -> :ok files_str -> files_str |> String.split(",", trim: true) |> Enum.each(fn file -> unless File.regular?(file) do Mix.shell().info("⚠ Source file not found: #{file}") end end) end :ok end @doc """ Link multiple modules into a single WASM binary. """ @spec link_modules([String.t()], keyword()) :: :ok | {:error, term()} def link_modules(sources, opts) do output_dir = Keyword.get(opts, :output, "_build/wasm") link_name = Keyword.get(opts, :link_name, "Combined") prefix = Keyword.get(opts, :link_prefix, false) wat_only = Keyword.get(opts, :wat_only, false) Mix.shell().info("") Mix.shell().info("🔗 Linking #{length(sources)} module(s) into #{link_name}.wasm") combiner_opts = [ name: link_name, prefix: prefix, output_dir: output_dir, wat_only: wat_only, optimize: Keyword.get(opts, :optimize, false), tco: Keyword.get(opts, :tco, false), inline: Keyword.get(opts, :inline, false) ] case Firebird.Compiler.Combiner.combine(sources, combiner_opts) do {:ok, result} -> export_count = length(result.exports || []) Mix.shell().info(" ✅ Linked: #{export_count} export(s)") if result.wasm do Mix.shell().info(" 📦 #{link_name}.wasm: #{byte_size(result.wasm)} bytes") end :ok {:error, reason} -> Mix.shell().error(" ❌ Link failed: #{inspect(reason)}") {:error, {:link_failed, reason}} end end # Filter to only stale sources by checking file mtimes against output defp filter_stale(sources, output_dir) do Enum.filter(sources, fn source -> source_mtime = case File.stat(source) do {:ok, %{mtime: mtime}} -> mtime _ -> {{2000, 1, 1}, {0, 0, 0}} end # Check if any output file is newer than source basename = Path.basename(source, Path.extname(source)) wat_path = Path.join(output_dir, "*.wat") wasm_path = Path.join(output_dir, "*.wasm") output_files = (Path.wildcard(wat_path) ++ Path.wildcard(wasm_path)) |> Enum.filter(fn f -> String.contains?(Path.basename(f), basename) end) if Enum.empty?(output_files) do true else # Source is stale if it's newer than any output Enum.any?(output_files, fn output -> case File.stat(output) do {:ok, %{mtime: output_mtime}} -> :calendar.datetime_to_gregorian_seconds(source_mtime) > :calendar.datetime_to_gregorian_seconds(output_mtime) _ -> true end end) end end) end defp update_manifest(successes, _output_dir) do entries = Enum.map(successes, fn {:ok, result} -> %{ module: result.module, compiled_at: :calendar.datetime_to_gregorian_seconds(:calendar.local_time()) } end) # Store in a simple ETS-like approach via manifest file manifest_path = Path.join(Mix.Project.manifest_path(), "firebird_target.manifest") try do File.mkdir_p!(Path.dirname(manifest_path)) File.write!(manifest_path, :erlang.term_to_binary(entries)) rescue _ -> :ok end end # Discover source files from CLI options or project config defp discover_sources(opts) do files = case Keyword.get(opts, :files) do nil -> [] files_str -> String.split(files_str, ",", trim: true) end dirs = case Keyword.get(opts, :dirs) do nil -> if Enum.empty?(files) do Keyword.get(opts, :wasm_sources, ["lib"]) else [] end dirs_str -> String.split(dirs_str, ",", trim: true) end explicit_files = Enum.filter(files, &File.regular?/1) discovered = dirs |> Enum.flat_map(fn dir -> if File.dir?(dir) do Path.wildcard(Path.join(dir, "**/*.{ex,exs}")) else [] end end) |> Enum.filter(&has_wasm_content?/1) (explicit_files ++ discovered) |> Enum.sort() |> Enum.uniq() end defp has_wasm_content?(path) do case File.read(path) do {:ok, content} -> # Either has @wasm annotations or is in a wasm_modules directory String.contains?(content, "@wasm true") or String.contains?(path, "wasm_modules") _ -> false end end defp compile_source(source, output_dir, wat_only, verbose, verify, compile_opts, profile) do Mix.shell().info(" 📄 #{source}") compiler_opts = Keyword.merge([output_dir: output_dir, wat_only: wat_only], compile_opts) result = if profile do compile_with_profile(source, compiler_opts, verbose, verify) else compile_without_profile(source, compiler_opts, verbose, verify) end # Generate source map if requested case result do {:ok, compile_result} -> source_map_enabled = Keyword.get(compile_opts, :source_map, false) if source_map_enabled, do: generate_source_map(source, compile_result, output_dir) result _ -> result end end defp generate_source_map(source_path, compile_result, output_dir) do case File.read(source_path) do {:ok, code} -> with {:ok, ast} <- Firebird.Compiler.parse(code), {:ok, ir} <- Firebird.Compiler.IRGen.generate(ast), {:ok, sm} <- Firebird.Compiler.SourceMap.generate(ir, source_path) do json = Firebird.Compiler.SourceMap.to_json(sm) base = Atom.to_string(compile_result.module) |> String.replace(".", "_") map_path = Path.join(output_dir, "#{base}.source_map.json") File.write!(map_path, Jason.encode!(json, pretty: true)) Mix.shell().info(" 📍 Source map: #{map_path}") end _ -> :ok end rescue _ -> :ok end defp compile_without_profile(source, compiler_opts, verbose, verify) do case Firebird.Compiler.compile(source, compiler_opts) do {:ok, result} -> print_verbose(result, verbose) maybe_verify(result, verify) Mix.shell().info(" ✅ #{result.module}") {:ok, result} {:error, reason} -> Mix.shell().error(" ❌ #{format_error(reason)}") {:error, {source, reason}} end end defp compile_with_profile(source, compiler_opts, verbose, verify) do # Time each phase individually phases = [] # Phase 1: Read file {read_us, source_code} = :timer.tc(fn -> case File.read(source) do {:ok, code} -> code {:error, reason} -> {:error, {:file_error, reason, source}} end end) phases = [{:read, read_us} | phases] if is_tuple(source_code) and elem(source_code, 0) == :error do Mix.shell().error(" ❌ #{format_error(source_code)}") {:error, {source, source_code}} else # Phase 2: Parse {parse_us, parse_result} = :timer.tc(fn -> Firebird.Compiler.parse(source_code) end) phases = [{:parse, parse_us} | phases] case parse_result do {:ok, ast} -> # Phase 3: IR Generation {ir_us, ir_result} = :timer.tc(fn -> Firebird.Compiler.IRGen.generate(ast) end) phases = [{:ir_gen, ir_us} | phases] case ir_result do {:ok, module_ir} -> # Phase 4: Type Inference {type_us, type_result} = :timer.tc(fn -> with :ok <- Firebird.Compiler.Validator.validate(module_ir), {:ok, typed} <- Firebird.Compiler.TypeInference.infer(module_ir) do {:ok, typed} end end) phases = [{:type_infer, type_us} | phases] case type_result do {:ok, typed_ir} -> # Phase 5: WAT Generation {wat_us, wat_result} = :timer.tc(fn -> Firebird.Compiler.WATGen.generate(typed_ir) end) phases = [{:wat_gen, wat_us} | phases] case wat_result do {:ok, wat} -> # Phase 6: WASM binary (if not wat_only) {wasm_us, wasm_result} = if Keyword.get(compiler_opts, :wat_only, false) do {0, {:ok, nil}} else :timer.tc(fn -> Firebird.Compiler.wat_to_wasm(wat) end) end phases = [{:wat2wasm, wasm_us} | phases] case wasm_result do {:ok, wasm_binary} -> result = %{ wat: wat, wasm: wasm_binary, module: typed_ir.name } # Write output output_dir = Keyword.get(compiler_opts, :output_dir) if output_dir do File.mkdir_p!(output_dir) base = Atom.to_string(result.module) |> String.replace(".", "_") File.write!(Path.join(output_dir, "#{base}.wat"), wat) if wasm_binary do File.write!(Path.join(output_dir, "#{base}.wasm"), wasm_binary) end end print_verbose(result, verbose) maybe_verify(result, verify) print_profile(phases) Mix.shell().info(" ✅ #{result.module}") {:ok, result} {:error, reason} -> print_profile(phases) Mix.shell().error(" ❌ #{format_error(reason)}") {:error, {source, reason}} end {:error, reason} -> print_profile(phases) Mix.shell().error(" ❌ #{format_error(reason)}") {:error, {source, reason}} end {:error, reason} -> print_profile(phases) Mix.shell().error(" ❌ #{format_error(reason)}") {:error, {source, reason}} end {:error, reason} -> print_profile(phases) Mix.shell().error(" ❌ #{format_error(reason)}") {:error, {source, reason}} end {:error, reason} -> print_profile(phases) Mix.shell().error(" ❌ #{format_error(reason)}") {:error, {source, reason}} end end end defp print_profile(phases) do phases = Enum.reverse(phases) total = Enum.map(phases, fn {_, us} -> us end) |> Enum.sum() Mix.shell().info(" ⏱ Profile:") Enum.each(phases, fn {phase, us} -> pct = if total > 0, do: Float.round(us / total * 100, 1), else: 0.0 name = phase |> Atom.to_string() |> String.pad_trailing(12) time = format_microseconds(us) |> String.pad_leading(10) Mix.shell().info(" #{name} #{time} (#{pct}%)") end) Mix.shell().info( " #{"total" |> String.pad_trailing(12)} #{format_microseconds(total) |> String.pad_leading(10)}" ) end defp format_microseconds(us) when us < 1000, do: "#{us}μs" defp format_microseconds(us) when us < 1_000_000, do: "#{Float.round(us / 1000, 1)}ms" defp format_microseconds(us), do: "#{Float.round(us / 1_000_000, 2)}s" defp print_verbose(result, false), do: result defp print_verbose(result, true) do Mix.shell().info(" Module: #{result.module}") Mix.shell().info(" WAT: #{byte_size(result.wat)} bytes") if result.wasm do Mix.shell().info(" WASM: #{byte_size(result.wasm)} bytes") ratio = Float.round(byte_size(result.wasm) / byte_size(result.wat) * 100, 1) Mix.shell().info(" Compression: #{ratio}% of WAT size") end Mix.shell().info("") Mix.shell().info(" WAT output:") result.wat |> String.split("\n") |> Enum.take(20) |> Enum.each(fn line -> Mix.shell().info(" #{line}") end) if String.split(result.wat, "\n") |> length() > 20 do Mix.shell().info(" ... (truncated)") end Mix.shell().info("") result end defp maybe_verify(_result, false), do: :ok defp maybe_verify(result, true) do if result.wasm do verify_module(result) end end defp verify_module(result) do case Firebird.load(result.wasm) do {:ok, instance} -> exports = Firebird.exports(instance) Mix.shell().info(" 🔍 Verified: exports #{inspect(exports)}") Firebird.stop(instance) {:error, reason} -> Mix.shell().error(" ⚠ Verification failed: #{inspect(reason)}") end end defp print_stats(successes) do results = Enum.map(successes, fn {:ok, r} -> r end) total_wat = Enum.map(results, fn r -> byte_size(r.wat) end) |> Enum.sum() total_wasm = results |> Enum.filter(& &1.wasm) |> Enum.map(fn r -> byte_size(r.wasm) end) |> Enum.sum() modules = Enum.map(results, fn r -> r.module end) Mix.shell().info("") Mix.shell().info("📈 Compilation Statistics:") Mix.shell().info(" ┌────────────────────────────┬──────────────┬──────────────┐") Mix.shell().info(" │ Module │ WAT (bytes) │ WASM (bytes) │") Mix.shell().info(" ├────────────────────────────┼──────────────┼──────────────┤") Enum.each(results, fn r -> name = inspect(r.module) |> String.pad_trailing(26) |> String.slice(0, 26) wat_sz = byte_size(r.wat) |> to_string() |> String.pad_leading(12) wasm_sz = if(r.wasm, do: byte_size(r.wasm), else: 0) |> to_string() |> String.pad_leading(12) Mix.shell().info(" │ #{name} │ #{wat_sz} │ #{wasm_sz} │") end) Mix.shell().info(" ├────────────────────────────┼──────────────┼──────────────┤") total_wat_s = total_wat |> to_string() |> String.pad_leading(12) total_wasm_s = total_wasm |> to_string() |> String.pad_leading(12) Mix.shell().info( " │ #{"TOTAL" |> String.pad_trailing(26)} │ #{total_wat_s} │ #{total_wasm_s} │" ) Mix.shell().info(" └────────────────────────────┴──────────────┴──────────────┘") if total_wat > 0 and total_wasm > 0 do ratio = Float.round(total_wasm / total_wat * 100, 1) Mix.shell().info(" Ratio: #{ratio}% (WASM/WAT)") end Mix.shell().info(" Modules: #{length(modules)}") Mix.shell().info("") end defp check_prerequisites!(wat_only) do unless wat_only do unless Firebird.Compiler.wat2wasm_available?() do Mix.raise(""" wat2wasm not found! Install the WebAssembly Binary Toolkit (wabt): # macOS brew install wabt # Ubuntu/Debian apt-get install wabt # Or use --wat-only to skip binary compilation """) end end end defp get_project_config do case Mix.Project.config() do config when is_list(config) -> Keyword.get(config, :firebird, []) _ -> [] end rescue _ -> [] end defp merge_config(project_config, cli_opts) do Keyword.merge(project_config, cli_opts) end 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