Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ mix test --exclude integration # skip the device-dependent ones
- **The release scripts assume `~/code/otp` exists** with the right cross-compile
output. The patches in `scripts/release/patches/` are applied automatically
by `xcompile_ios_device.sh`, idempotently — re-running is safe.
- **The application-build Zig version is exact.** `MobDev.Toolchain` embeds the
root `.tool-versions` pin and both native build preflight and `mob.doctor`
reject any other version. `mob.adopt` deliberately does not rewrite an
existing Phoenix project's toolchain file: it installs `build.zig`-bearing
native trees, then the preflight reports a missing or conflicting pin with
the exact mise command. Changing an adopted app's existing language/runtime
pins without its owner's consent would be destructive.
- **`mob.add_nif` is the entry point for new NIFs.** Don't add `:static_nifs`
entries by hand to `mob.exs` — the task already does the AST-aware append,
generates the Elixir stub via Igniter, and re-runs `mob.regen_driver_tab`
Expand Down Expand Up @@ -138,6 +145,8 @@ narrowing functions). Don't make them private:
- `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3`
- `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2`
- `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern)
- `Mix.Tasks.Mob.Doctor.__zig_install_fix__/0`
- `Mix.Tasks.Mob.Doctor.__zig_check_result__/1`
- `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1`
- `Emulators.parse_simctl_json/1`, `find_emulator_binary/1`
- `Provision.diagnose_xcodebuild_failure/1`
Expand Down
40 changes: 26 additions & 14 deletions lib/mix/tasks/mob.doctor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Mix.Tasks.Mob.Doctor do
use Mix.Task

alias MobDev.NdkVersion
alias MobDev.Toolchain

@shortdoc "Check your environment for common Mob setup issues"

Expand Down Expand Up @@ -227,22 +228,33 @@ defmodule Mix.Tasks.Mob.Doctor do
end

defp check_zig do
case System.find_executable("zig") do
nil ->
{:warn, "zig",
"not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration",
"Install zig 0.15.x:\n macOS: brew install zig\n Linux/asdf: asdf plugin add zig && asdf install zig 0.15.2\n manual: https://ziglang.org/download/"}
Toolchain.zig_status()
|> __zig_check_result__()
end

_ ->
case System.cmd("zig", ["version"], stderr_to_stdout: true) do
{out, 0} ->
version = String.trim(out)
{:ok, "zig", version, nil}
@doc false
@spec __zig_install_fix__() :: String.t()
def __zig_install_fix__, do: Toolchain.zig_install_instructions()

_ ->
{:warn, "zig", "found but `zig version` failed", nil}
end
end
@doc false
@spec __zig_check_result__(Toolchain.zig_status()) ::
{:ok | :warn | :fail, String.t(), String.t(), String.t() | nil}
def __zig_check_result__(:missing) do
{:warn, "zig",
"not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration",
__zig_install_fix__()}
end

def __zig_check_result__({:ok, version}), do: {:ok, "zig", version, nil}

def __zig_check_result__({:version_mismatch, actual}) do
{:fail, "zig",
"version #{actual} found; Mob requires exactly #{Toolchain.required_zig_version()}",
__zig_install_fix__()}
end

def __zig_check_result__({:version_command_failed, output, exit_status}) do
{:fail, "zig", "`zig version` exited #{exit_status}: #{output}", __zig_install_fix__()}
end

defp check_xcrun do
Expand Down
58 changes: 36 additions & 22 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule MobDev.NativeBuild do
alias MobDev.Release
alias MobDev.Toolchain

@moduledoc """
Builds native binaries (APK for Android, .app bundle for iOS simulator)
Expand Down Expand Up @@ -196,7 +197,7 @@ defmodule MobDev.NativeBuild do
# so on a current mob the fallback is a dead end; see zig_build_plan/3.
legacy_c = Path.join(mob_dir, "android/jni/mob_nif.c")

case zig_build_plan(File.exists?(build_zig), zig_available?(), File.exists?(legacy_c)) do
case zig_build_plan(File.exists?(build_zig), Toolchain.zig_status(), File.exists?(legacy_c)) do
:skip_no_build_zig ->
:ok

Expand All @@ -207,8 +208,8 @@ defmodule MobDev.NativeBuild do

:ok

:zig_required ->
{:error, zig_required_message()}
{:zig_required, zig_status} ->
{:error, zig_required_message(zig_status)}

:run_zig ->
driver_tab = resolve_driver_tab_android(mob_dir)
Expand Down Expand Up @@ -298,48 +299,63 @@ defmodule MobDev.NativeBuild do
# a native build can succeed at all:
#
# build_zig? does the project ship jni/build.zig?
# zig? is `zig` on PATH (so build.zig can actually run)?
# zig_status does `zig version` match Mob's exact build toolchain?
# legacy_c? does the mob dep still ship the C JNI source the CMake
# fallback would compile (android/jni/mob_nif.c)?
#
# Outcomes:
# :skip_no_build_zig no build.zig, nothing for this step to do.
# :run_zig zig present, drive the real build.zig path.
# :run_zig exact Zig present, drive the real build.zig path.
# :legacy_cmake no zig, but the mob dep still has the C sources,
# so CMake can compile them directly (old mob).
# :zig_required no zig AND no C sources (mob 0.7+): the build
# cannot succeed, so fail fast with a clear cause
# instead of limping into a cryptic CMake error.
# {:zig_required, status}
# zig is absent with no C fallback, mismatched, or
# broken; fail fast instead of invoking the build.
@doc false
@spec zig_build_plan(boolean(), boolean(), boolean()) ::
:skip_no_build_zig | :run_zig | :legacy_cmake | :zig_required
def zig_build_plan(build_zig?, zig?, legacy_c?)
def zig_build_plan(false, _zig?, _legacy_c?), do: :skip_no_build_zig
def zig_build_plan(true, true, _legacy_c?), do: :run_zig
def zig_build_plan(true, false, true), do: :legacy_cmake
def zig_build_plan(true, false, false), do: :zig_required
@spec zig_build_plan(boolean(), Toolchain.zig_status(), boolean()) ::
:skip_no_build_zig | :run_zig | :legacy_cmake | {:zig_required, Toolchain.zig_status()}
def zig_build_plan(build_zig?, zig_status, legacy_c?)
def zig_build_plan(false, _zig_status, _legacy_c?), do: :skip_no_build_zig
def zig_build_plan(true, {:ok, _version}, _legacy_c?), do: :run_zig
def zig_build_plan(true, :missing, true), do: :legacy_cmake
def zig_build_plan(true, zig_status, _legacy_c?), do: {:zig_required, zig_status}

# The actionable error shown when an Android native build needs `zig` but
# it is not on PATH and the mob dep no longer ships the C fallback sources.
# Public so the test suite can pin the guidance without driving a build.
@doc false
@spec zig_required_message() :: String.t()
def zig_required_message do
def zig_required_message, do: zig_required_message(:missing)

@doc false
@spec zig_required_message(Toolchain.zig_status()) :: String.t()
def zig_required_message(zig_status) do
"""
zig is not on your PATH, and this project's Android native build needs it.
#{zig_status_message(zig_status)}

mob 0.7+ compiles the Android JNI layer with build.zig. The legacy CMake
fallback would reference C sources (deps/mob/android/jni/mob_nif.c) that no
longer ship with mob, so the build cannot succeed without zig.

Install zig 0.15.x, then re-run `mix mob.deploy --native --android`:
asdf: asdf plugin add zig && asdf install zig 0.15.2 && asdf global zig 0.15.2
manual: https://ziglang.org/download/ (then put `zig` on your PATH)
#{Toolchain.zig_install_instructions()}

Then re-run `mix mob.deploy --native --android`.
Verify your toolchain any time with `mix mob.doctor`.\
"""
end

defp zig_status_message(:missing) do
"zig is not on your PATH, and this project's Android native build needs it."
end

defp zig_status_message({:version_mismatch, actual}) do
"zig version mismatch: found #{actual}, but Mob requires #{Toolchain.required_zig_version()}."
end

defp zig_status_message({:version_command_failed, output, exit_status}) do
"`zig version` exited #{exit_status}: #{output}"
end

# True if the app's build.zig handles `abi`. mob_dev builds all of
# arm64-v8a/armeabi-v7a/x86_64 by default, but an app's app-owned build.zig
# (copied at `mix mob.new` time) may predate x86_64 support (mob_new < 0.4.5)
Expand Down Expand Up @@ -542,8 +558,6 @@ defmodule MobDev.NativeBuild do
end
end

defp zig_available?, do: not is_nil(System.find_executable("zig"))

# Downloads Chaquopy's CPython distribution iff Pythonx is a dep.
# Returns `{:ok, nil}` for projects without Pythonx so the rest of the
# Android pipeline runs unchanged.
Expand Down
57 changes: 57 additions & 0 deletions lib/mob_dev/toolchain.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule MobDev.Toolchain do
@moduledoc false

@tool_versions_path Path.expand("../../.tool-versions", __DIR__)
@external_resource @tool_versions_path
@required_zig_version @tool_versions_path
|> File.read!()
|> String.split("\n")
|> Enum.find_value(fn line ->
case String.split(line) do
["zig", version] -> version
_ -> nil
end
end) || raise("zig is not pinned in #{@tool_versions_path}")

@type zig_status ::
:missing
| {:ok, String.t()}
| {:version_mismatch, String.t()}
| {:version_command_failed, String.t(), non_neg_integer()}

@spec required_zig_version() :: String.t()
def required_zig_version, do: @required_zig_version

@spec zig_status() :: zig_status()
def zig_status do
case System.find_executable("zig") do
nil ->
:missing

executable ->
zig_status_from_result(System.cmd(executable, ["version"], stderr_to_stdout: true))
end
end

@spec zig_status_from_result({String.t(), non_neg_integer()}) :: zig_status()
def zig_status_from_result({output, 0}) do
version = String.trim(output)

if version == @required_zig_version do
{:ok, version}
else
{:version_mismatch, version}
end
end

def zig_status_from_result({output, exit_status}) do
{:version_command_failed, String.trim(output), exit_status}
end

@spec zig_install_instructions() :: String.t()
def zig_install_instructions do
"Install the exact Zig build toolchain with mise:\n" <>
" mise install zig@#{@required_zig_version}\n" <>
" mise use zig@#{@required_zig_version}"
end
end
46 changes: 46 additions & 0 deletions test/mix/tasks/mob_doctor_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,52 @@
defmodule Mix.Tasks.Mob.DoctorTest do
use ExUnit.Case, async: true

describe "__zig_install_fix__/0" do
test "pins the exact application-build Zig version" do
fix = Mix.Tasks.Mob.Doctor.__zig_install_fix__()

assert fix =~ MobDev.Toolchain.required_zig_version()
refute fix =~ "zig 0.15"
refute fix =~ "ziglang.org/download"
end
end

describe "__zig_check_result__/1" do
test "accepts only the exact version" do
version = MobDev.Toolchain.required_zig_version()

assert {:ok, "zig", ^version, nil} =
Mix.Tasks.Mob.Doctor.__zig_check_result__({:ok, version})
end

test "fails an installed 0.15.x version" do
assert {:fail, "zig", detail, fix} =
Mix.Tasks.Mob.Doctor.__zig_check_result__({:version_mismatch, "0.15.2"})

assert detail =~ "0.15.2"
assert fix =~ MobDev.Toolchain.required_zig_version()
end

test "fails another nightly" do
assert {:fail, "zig", detail, _fix} =
Mix.Tasks.Mob.Doctor.__zig_check_result__(
{:version_mismatch, "0.17.0-dev.270+different"}
)

assert detail =~ "0.17.0-dev.270+different"
end

test "fails when `zig version` fails" do
assert {:fail, "zig", detail, _fix} =
Mix.Tasks.Mob.Doctor.__zig_check_result__(
{:version_command_failed, "dyld failure", 127}
)

assert detail =~ "exited 127"
assert detail =~ "dyld failure"
end
end

describe "__missing_plugin_options__/2 (pre-plugin build.zig detection)" do
# The real declaration shape every template uses.
@declared """
Expand Down
48 changes: 38 additions & 10 deletions test/mob_dev/native_build_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1918,22 +1918,34 @@ defmodule MobDev.NativeBuildTest do

describe "zig_build_plan/3 (fail fast when the JNI build can't succeed)" do
test "no build.zig: nothing to do, regardless of zig or C sources" do
assert NativeBuild.zig_build_plan(false, false, false) == :skip_no_build_zig
assert NativeBuild.zig_build_plan(false, true, true) == :skip_no_build_zig
assert NativeBuild.zig_build_plan(false, false, true) == :skip_no_build_zig
assert NativeBuild.zig_build_plan(false, :missing, false) == :skip_no_build_zig

exact = {:ok, MobDev.Toolchain.required_zig_version()}
assert NativeBuild.zig_build_plan(false, exact, true) == :skip_no_build_zig

assert NativeBuild.zig_build_plan(false, {:version_mismatch, "old"}, true) ==
:skip_no_build_zig
end

test "zig present: drive the real build.zig path (C-source presence irrelevant)" do
assert NativeBuild.zig_build_plan(true, true, false) == :run_zig
assert NativeBuild.zig_build_plan(true, true, true) == :run_zig
test "exact Zig present: drive the real build.zig path" do
exact = {:ok, MobDev.Toolchain.required_zig_version()}
assert NativeBuild.zig_build_plan(true, exact, false) == :run_zig
end

test "no zig but the mob dep still ships C sources: CMake fallback can compile them" do
assert NativeBuild.zig_build_plan(true, false, true) == :legacy_cmake
assert NativeBuild.zig_build_plan(true, :missing, true) == :legacy_cmake
end

test "no zig AND no C sources (mob 0.7+): obvious failure, so signal :zig_required" do
assert NativeBuild.zig_build_plan(true, false, false) == :zig_required
assert NativeBuild.zig_build_plan(true, :missing, false) == {:zig_required, :missing}
end

test "wrong or broken Zig fails even when legacy C sources exist" do
mismatch = {:version_mismatch, "0.15.2"}
failed = {:version_command_failed, "dyld failure", 127}

assert NativeBuild.zig_build_plan(true, mismatch, true) == {:zig_required, mismatch}
assert NativeBuild.zig_build_plan(true, failed, true) == {:zig_required, failed}
end
end

Expand All @@ -1944,11 +1956,27 @@ defmodule MobDev.NativeBuildTest do
# the cause: zig missing + the vanished C fallback source
assert msg =~ "zig is not on your PATH"
assert msg =~ "mob_nif.c"
# the fix: the version mob.doctor pins, plus how to verify
assert msg =~ "zig 0.15"
# the fix: the exact application-build version, plus how to verify
assert msg =~ MobDev.Toolchain.required_zig_version()
refute msg =~ "zig 0.15"
refute msg =~ "ziglang.org/download"
assert msg =~ "mix mob.doctor"
end

test "reports an installed version mismatch" do
msg = NativeBuild.zig_required_message({:version_mismatch, "0.15.2"})

assert msg =~ "found 0.15.2"
assert msg =~ MobDev.Toolchain.required_zig_version()
end

test "reports a failed version command" do
msg = NativeBuild.zig_required_message({:version_command_failed, "dyld failure", 127})

assert msg =~ "exited 127"
assert msg =~ "dyld failure"
end

test "stays in plain prose (no em dashes leaking into user-facing output)" do
refute NativeBuild.zig_required_message() =~ "—"
end
Expand Down
Loading
Loading