defmodule Zig.Module do @moduledoc false # abstraction representing multiple zig nif functions bound into a single # module alias Zig.C alias Zig.Nif alias Zig.Options alias Zig.Resources alias Zig.Type.Error # you'll never see me ever import any other module. import Zig.QuoteErl # for easy access in EEx files @behaviour Access @impl true defdelegate fetch(function, key), to: Map @enforce_keys [:otp_app, :module, :file, :line] defstruct @enforce_keys ++ [ # user options: :precompiled, :module_code_path, :zig_code_path, :version, :easy_c, :dir, :c, :nifs, # generated by zigler :parsed, :manifest, :manifest_module, :sema, # defaulted options release_mode: {:env, :debug}, ignore: [], packages: [], resources: [], callbacks: [], # assembled from collected `opts` fields default_nif_opts: [], # generated by zigler language: Elixir, external_resources: [], attributes: [], # debug options: user-supplied, but unchecked. dump: false, dump_sema: false, dump_build_zig: false ] @type t :: %__MODULE__{ otp_app: atom, module: module, file: Path.t(), line: pos_integer, module_code_path: nil | Path.t(), zig_code_path: nil | Path.t(), manifest: nil | Manifest.t(), manifest_module: nil | module, sema: Sema.t(), parsed: Parser.t(), version: String.t(), easy_c: nil | Path.t(), dir: Path.t(), c: C.t(), nifs: {:auto, [Nif.t()]} | [Nif.t()], parsed: Parser.t(), manifest: nil | Manifest.t(), manifest_module: nil | module, sema: Sema.t(), release_mode: Zig.release_mode(), ignore: [atom], packages: [Zig.package_spec()], resources: [atom], callbacks: [on_load: atom, on_upgrade: atom, on_unload: atom], default_nif_opts: [cleanup: boolean, leak_check: boolean], language: Elixir | :erlang, external_resources: [Path.t()], attributes: keyword, dump: boolean, dump_sema: boolean, dump_build_zig: boolean | :stdout | :stderr | Path.t() } @defaultable_nif_opts ~w[cleanup leak_check]a @affix %{"Elixir": "`use Zig`", erlang: "`zig_opts(...)`"} # this function builds and verifies the module options and turns it into the struct. def new(opts, caller) do # make sure that the caller has declared otp_app here. context = case Keyword.fetch(opts, :otp_app) do :error -> lang = Keyword.fetch!(opts, :language) raise CompileError, description: "(module #{inspect(caller.module)}) you must supply an `otp_app` option to #{@affix[lang]}", file: caller.file, line: caller.line {:ok, otp_app} -> Options.initialize_context(caller, otp_app) end default_nif_opts = Keyword.take(opts, @defaultable_nif_opts) common_values = [ module: caller.module, file: caller.file, line: caller.line, default_nif_opts: default_nif_opts ] opts |> obtain_version |> Options.normalize_kw(:c, %C{}, Options.struct_normalizer(C), context) |> Options.normalize_kw( :callbacks, [], list_normalizer(&normalize_callback/2, "callback"), context ) |> Options.normalize_kw( :packages, [], list_normalizer(&normalize_package/2, "package"), context ) |> Options.normalize_path(:dir, context) |> Options.normalize_path(:module_code_path, context) |> Options.normalize_path(:zig_code_path, context) |> Options.normalize_path(:easy_c, context) |> Options.normalize_kw(:ignore, [], &normalize_atom_or_atomlist/2, context) |> Options.normalize_kw(:resources, [], &normalize_atom_or_atomlist/2, context) |> Options.validate(:release_mode, &validate_release_modes/1, context) |> Options.validate(:cleanup, :boolean, context) |> Options.validate(:leak_check, :boolean, context) |> Keyword.drop(@defaultable_nif_opts) |> Keyword.merge(common_values) |> normalize_nifs(context) |> then(&struct!(__MODULE__, &1)) rescue e in KeyError -> context = Options.initialize_context(caller, nil) lang = Keyword.fetch!(opts, :language) Options.raise_with("#{@affix[lang]} was supplied the invalid option `#{e.key}`", context) end defp obtain_version(opts) do otp_app = Keyword.fetch!(opts, :otp_app) Keyword.put_new_lazy(opts, :version, fn -> cond do # try checking the mix project first (this is if the project is being compiled for the first time) version = function_exported?(Mix.Project, :config, 0) and Mix.Project.config()[:version] -> Version.parse!(version) # try checking the application version (this is if we are hot-patching the so file) tuple = Application.loaded_applications() |> List.keyfind(otp_app, 0) -> tuple |> elem(2) |> to_string() |> Version.parse!() :else -> Version.parse!("0.0.0") end end) end defp list_normalizer(callback, error_msg), do: fn list, context when is_list(list) -> try do Enum.map(list, &callback.(&1, context)) rescue _ in FunctionClauseError -> Options.raise_with( "must be a list of #{error_msg} specifications", list, context ) end other, context -> Options.raise_with( "must be a list of #{error_msg} specifications", other, context ) end @callbacks ~w[on_load on_upgrade on_unload]a defp normalize_callback(callback, _context) when callback in @callbacks, do: {callback, callback} defp normalize_callback({callback, fun}, _context) when callback in @callbacks and is_atom(fun), do: {callback, fun} defp normalize_callback({callback, invalid}, context) when callback in @callbacks, do: Options.raise_with("must be an atom", invalid, Options.push_key(context, callback)) defp normalize_callback(other, context), do: Options.raise_with( "must be a keyword list of callback specs", other, context ) @dependencies_atom_error "must be a list of atoms representing dependencies" @package_tuple_error "must be a tuple of the form `{path, [deps...]}`" defp normalize_package({k, {path, deps}}, context) when is_atom(k) and is_list(deps) do Enum.each(deps, fn dep -> is_atom(dep) or Options.raise_with(@dependencies_atom_error, dep, Options.push_key(context, k)) end) try do {k, {IO.iodata_to_binary(path), deps}} rescue _ -> Options.raise_with( @package_tuple_error, {:tag, "path", path}, Options.push_key(context, k) ) end end defp normalize_package({k, {_, malformed}}, context) when is_atom(k), do: Options.raise_with(@dependencies_atom_error, malformed, Options.push_key(context, k)) defp normalize_package({k, malformed}, context) when is_atom(k), do: Options.raise_with(@package_tuple_error, malformed, Options.push_key(context, k)) defp normalize_package(other, opts), do: Options.raise_with( "must be a list of package specifications", other, opts ) @atom_or_atomlist_error "must be an atom or a list of atoms" defp normalize_atom_or_atomlist(atom, _context) when is_atom(atom), do: [atom] defp normalize_atom_or_atomlist(list, context) when is_list(list) do Enum.each(list, fn item -> is_atom(item) or Options.raise_with(@atom_or_atomlist_error, item, context) end) list rescue _ in FunctionClauseError -> Options.raise_with(@atom_or_atomlist_error, list, context) end defp normalize_atom_or_atomlist(other, context), do: Options.raise_with( @atom_or_atomlist_error, other, context ) defp normalize_nifs(opts, context) do common_values = Keyword.take(opts, ~w[module file line module_code_path zig_code_path]a) Options.normalize_kw( opts, :nifs, {:auto, []}, &normalize_nifs(&1, common_values, &2), context ) end defp normalize_nifs(nifs, common_values, context) when is_list(nifs) do Enum.map(nifs, fn {atom, opts} when is_atom(atom) and is_list(opts) -> Nif.new({atom, common_values ++ opts}, context) atom when is_atom(atom) -> Nif.new({atom, common_values}, context) other -> Options.raise_with("", other, context) end) end defp normalize_nifs({:auto, nifs}, common_values, context) do {:auto, normalize_nifs(nifs, common_values, context)} end defp validate_release_modes(mode) when mode in ~w[debug safe fast small env]a, do: :ok defp validate_release_modes({:env, mode}) when mode in ~w[debug safe fast small]a, do: :ok defp validate_release_modes(other) do {:error, "must be one of `:debug`, `:safe`, `:fast`, `:small`, `:env` or `{:env, mode}`", other} end # CODE RENDERING # zig file rendering require EEx nif = Path.join(__DIR__, "templates/module.zig.eex") EEx.function_from_file(:def, :render_zig, nif, [:assigns]) on_load = Path.join(__DIR__, "templates/on_load.zig.eex") EEx.function_from_file(:def, :render_on_load, on_load, [:assigns]) on_upgrade = Path.join(__DIR__, "templates/on_upgrade.zig.eex") EEx.function_from_file(:def, :render_on_upgrade, on_upgrade, [:assigns]) on_unload = Path.join(__DIR__, "templates/on_unload.zig.eex") EEx.function_from_file(:def, :render_on_unload, on_unload, [:assigns]) # internal helpers for rendering the c defp table_entries(nifs) when is_list(nifs) do nifs |> Enum.flat_map(&Nif.table_entries/1) |> Enum.join(",") end @index_of %{major: 0, minor: 1} defp nif_version(at) do :nif_version |> :erlang.system_info() |> List.to_string() |> String.split(".") |> Enum.at(@index_of[at]) end # ast rendering def render_elixir(module, zig_code) do module_name = "#{module.module}" on_load_code = if {:__on_load__, 0} in Module.definitions_in(module.module) do quote do __on_load__() end else 0 end external_resources = Enum.map(module.external_resources, fn file -> quote do @external_resource unquote(file) end end) load_nif_fn = quote do unquote_splicing(external_resources) def __load_nifs__ do # LOADS the nifs from :code.lib_dir() <> "ebin", which is # a path that has files correctly moved in to release packages. require Logger unquote(module.otp_app) |> :code.priv_dir() |> Path.join("lib") |> Path.join(unquote(module_name)) |> String.to_charlist() |> :erlang.load_nif(unquote(on_load_code)) |> case do :ok -> Logger.debug("loaded module at #{unquote(module_name)}") error = {:error, any} -> Logger.error("loading module #{unquote(module_name)} #{inspect(any)}") end end end function_code = Enum.map(module.nifs, &Nif.render_elixir/1) manifest_code = if Enum.any?(module.nifs, &match?(%Error{}, &1.return.type)) do quote do require Zig.Manifest Zig.Manifest.resolver(unquote(module.manifest), unquote(module.zig_code_path), :defp) end end quote do # these two attribs can be persisted for code inspection. @zigler_module unquote(Macro.escape(module)) @zig_code unquote(zig_code) unquote_splicing(function_code) unquote(load_nif_fn) unquote(manifest_code) def _format_error(_, [{_, _, _, opts} | _rest] = _stacktrace) do if formatted = opts[:zigler_error], do: formatted, else: %{} end end end def render_erlang(module, _zig_code) do otp_app = module.otp_app module_name = Atom.to_charlist(module.module) init_function = quote_erl( """ '__init__'() -> erlang:load_nif(filename:join(code:priv_dir(unquote(otp_app)), unquote(module_id)), []). """, otp_app: otp_app, module_id: ~C'lib/' ++ module_name ) function_code = Enum.map(module.nifs, &Nif.render_erlang/1) Enum.flat_map(function_code, & &1) ++ init_function end # Access behaviour guards @impl true def get_and_update(_, _, _), do: raise("you should not update a module") @impl true def pop(_, _), do: raise("you should not pop a module") end