defmodule Zig.Module do @moduledoc false # abstraction representing multiple zig nif functions bound into a single # module alias Zig.Builder alias Zig.BuildModule alias Zig.C alias Zig.DepsModule alias Zig.Nif alias Zig.Options alias Zig.Resources alias Zig.Sema alias Zig.Type.Error # you'll never see me ever import any other module. import Zig.QuoteErl @enforce_keys [:otp_app, :module, :file, :line, :optimize, :error_tracing] defstruct @enforce_keys ++ [ # user options: :module_code_path, :zig_code_path, :version, :easy_c, :fingerprint, :dir, :c, :nifs, :build_files_dir, :precompiled, # generated by zigler :parsed, :manifest, :manifest_module, :sema, :sema_json, :libc_txt, # defaulted options ignore: [], extra_modules: [], dependencies: [], resources: [], callbacks: [], build_flags: [], # 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(), version: String.t(), easy_c: nil | Path.t(), fingerprint: String.t(), manifest: nil | Manifest.t(), manifest_module: nil | module, dir: Path.t(), c: C.t(), nifs: {:auto, [Nif.t()]} | [Nif.t()], build_files_dir: nil | Path.t(), precompiled: nil | Path.t() | term, parsed: Parser.t(), manifest: nil | Manifest.t(), manifest_module: nil | module, sema: Sema.info(), sema_json: String.t(), libc_txt: nil | Path.t(), optimize: Zig.optimize(), error_tracing: boolean, ignore: [atom], extra_modules: [BuildModule.t()], dependencies: keyword(), resources: [atom], callbacks: [on_load: atom, on_upgrade: atom, on_unload: atom], build_flags: [String.t()], 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 use Zig.Builder, template: "templates/build_mod.zig.eex" @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( :extra_modules, [], list_normalizer(&normalize_extra_modules/2, "module"), context ) |> Options.normalize_kw( :dependencies, [], list_normalizer(&normalize_dependency/2, "dependency"), context ) |> validate_dependency_modules!(context) |> Options.normalize(:precompiled, &normalize_precompiled/2, context) |> Options.normalize(:optimize, &normalize_optimize/2, context) |> Options.normalize_path(:dir, context) |> Options.normalize_path(:module_code_path, context) |> Options.normalize_path(:zig_code_path, context) |> normalize_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(:cleanup, :boolean, context) |> Options.validate(:leak_check, :boolean, context) |> Options.validate(:error_tracing, :boolean, context) |> Keyword.drop(@defaultable_nif_opts) |> Keyword.merge(common_values) |> normalize_nifs(context) |> set_default_optimize() |> set_error_tracing() |> 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.flat_map(list, &List.wrap(if d = callback.(&1, context), do: d)) 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" @module_tuple_error "must be a tuple of the form `{path, [deps...]}`" defp normalize_extra_modules({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 %BuildModule{ name: k, path: Zig._normalize_path(path, Path.dirname(context.file)), deps: deps } rescue _ -> Options.raise_with( @module_tuple_error, {:tag, "path", path}, Options.push_key(context, k) ) end end defp normalize_extra_modules({k, {dep, module}}, _context) when is_atom(dep) and is_atom(module) do %DepsModule{dep: dep, src_mod: module, dst_mod: k} end defp normalize_extra_modules({k, {_, malformed}}, context) when is_atom(k), do: Options.raise_with(@dependencies_atom_error, malformed, Options.push_key(context, k)) defp normalize_extra_modules({k, malformed}, context) when is_atom(k), do: Options.raise_with(@module_tuple_error, malformed, Options.push_key(context, k)) defp normalize_extra_modules(other, opts), do: Options.raise_with( "must be a list of module specifications", other, opts ) defp normalize_dependency({dep_name, path}, context) do path |> Zig._normalize_path(Path.dirname(context.file)) |> then(&{dep_name, &1}) end defp validate_dependency_modules!(opts, context) do dependencies = Keyword.get(opts, :dependencies, []) for %DepsModule{dep: dep, dst_mod: dst_mod} <- Keyword.get(opts, :extra_modules, []) do if not Keyword.has_key?(dependencies, dep) do context = context |> Options.push_key(:extra_modules) |> Options.push_key(dst_mod) Options.raise_with("requires the `#{dep}` dependency", dependencies, context) end end opts end defp normalize_precompiled(file, context) when is_binary(file) do File.exists?(file) or Options.raise_with("file #{file} does not exist", Options.push_key(context, :precompiled)) if System.get_env("ZIGLER_PRECOMPILE_FORCE_RECOMPILE", "false") != "true", do: file end defp normalize_precompiled({:web, url, shasum}, context) do precompiled_context = Options.push_key(context, :precompiled) unless is_binary(url), do: Options.raise_with("url must be a binary", precompiled_context) unless is_binary(shasum) or is_list(shasum), do: Options.raise_with("shasum must be a binary or keyword list", precompiled_context) validate_and_build_precompiled(url, normalize_shasum(shasum), context, precompiled_context) end defp normalize_precompiled(_, context) do Options.raise_with( "must be a path or `{:web, url, shasum}` tuple", Options.push_key(context, :precompiled) ) end defp validate_and_build_precompiled(_url, nil, _context, _precompiled_context), do: nil defp validate_and_build_precompiled(url, shasum, context, precompiled_context) do unless valid_shasum?(shasum), do: Options.raise_with("shasum must be base-16 encoded", precompiled_context) if System.get_env("ZIGLER_PRECOMPILE_FORCE_RECOMPILE", "false") != "true", do: {:web, substitute_url(url, context), shasum} end # Validate shasum format - handles both plain string and map format for Windows defp valid_shasum?(%{dll: dll_sha, pdb: pdb_sha}) do valid_hex?(dll_sha) and valid_hex?(pdb_sha) end defp valid_shasum?(shasum) when is_binary(shasum), do: valid_hex?(shasum) defp valid_hex?(str) do case Base.decode16(str, case: :mixed) do {:ok, _} -> true :error -> false end end defp substitute_url(url, context) do url |> String.replace("#VERSION", "#{Application.spec(context.otp_app, :vsn)}") |> String.replace("#TRIPLE", "#{sysarch_to_key()}") |> String.replace("#EXT", if(match?({_, :nt}, :os.type()), do: "dll", else: "so")) end defp normalize_easy_c(opts, context) do if Keyword.has_key?(opts, :easy_c) do Keyword.update!(opts, :easy_c, &IO.iodata_to_binary/1) else opts end rescue _ -> Options.raise_with("must be a path", opts[:easy_c], Options.push_key(context, :easy_c)) end @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 normalize_shasum(shasum) when is_binary(shasum), do: shasum # Handle new map format with :dll and :pdb keys for Windows targets defp normalize_shasum(%{dll: _dll_sha} = map), do: map defp normalize_shasum(kwl) do Keyword.get(kwl, sysarch_to_key()) end @modes ~w[debug safe fast small]a @modes_str Enum.map(@modes, &"#{&1}") defguardp is_env_mode(mode) when mode == :env or (is_tuple(mode) and tuple_size(mode) == 2 and elem(mode, 0) == :env) defp normalize_optimize(mode, _context) when mode in @modes, do: mode defp normalize_optimize(other, context) when is_env_mode(other) do case System.get_env("ZIGLER_RELEASE_MODE") do mode when mode in @modes_str -> String.to_existing_atom(mode) null when null in [nil, ""] -> default_optimize(other) other -> Options.raise_with("must be one of #{inspect(@modes)}", other, context) end end defp normalize_optimize(other, context) do Options.raise_with( "must be one of `:debug`, `:safe`, `:fast`, `:small`, `:env`, or `{:env, mode}`", other, context ) end defp default_optimize(:env), do: nil defp default_optimize({:env, mode}), do: mode # 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 ) spec_code = module.nifs |> Enum.filter(& &1.spec) |> Enum.flat_map(&Nif.render_erlang_spec/1) |> Enum.map(&parse_erlang_spec/1) function_code = Enum.map(module.nifs, &Nif.render_erlang/1) spec_code ++ Enum.flat_map(function_code, & &1) ++ init_function end defp parse_erlang_spec(spec_string) do {:ok, tokens, _} = :erl_scan.string(String.to_charlist(spec_string)) {:ok, form} = :erl_parse.parse_form(tokens) form end # EEX Helper functions defp render_optimize_mode(build) do case build.optimize do :debug -> ".Debug" :safe -> ".ReleaseSafe" :fast -> ".ReleaseFast" :small -> ".ReleaseSmall" end end def render_c(nil, _), do: "" def render_c(%DepsModule{}, _), do: "" def render_c(%BuildModule{} = m, mode), do: render_c(m.c, mode) def render_c(%C{} = c, mode) do link_libs = Enum.map(c.link_lib, fn {:system, libname} -> "#{mode}.root_module.linkSystemLibrary(\"#{libname}\", .{});" path -> "#{mode}.root_module.addObjectFile(.{.cwd_relative = \"#{path}\"});" end) rpaths = Enum.map(c.rpaths, fn {:special, path} -> "#{mode}.root_module.addRPathSpecial(\"#{escape(path)}\");" path -> "#{mode}.root_module.addRPath(.{.cwd_relative = \"#{escape(path)}\"});" end) c_srcs = Enum.map(c.src, fn {c_src, flags} -> """ #{mode}.root_module.addCSourceFiles(.{ .root = .{.cwd_relative = "#{Path.dirname(c_src)}"}, .files = &.{"#{Path.basename(c_src)}"}, .flags = &.{ #{render_flags(flags)}}, }); """ end) link_libs ++ rpaths ++ c_srcs end defp render_flags(flags) do Enum.map_join(flags, ", ", &~s("#{&1}\")) end defp escape(string) do String.replace(string, "\"", "\\\"") end defp sysarch_to_key do :system_architecture |> :erlang.system_info() |> IO.iodata_to_binary() |> String.split("-") |> case do [arch, "apple" | _] -> :"#{arch}-macos-none" [arch, _, "freebsd" | _] -> :"#{arch}-freebsd-none" ["i386", _vendor, os, abi] -> :"x86-#{os}-#{abi}" ["i686", _, os, abi] -> :"x86-#{os}-#{abi}" [arch, _vendor, os, abi] -> :"#{arch}-#{os}-#{abi}" end end defp set_default_optimize(opts) do if opts[:optimize] do opts else Keyword.put(opts, :optimize, default_optimize()) end end defp default_optimize do if function_exported?(Mix, :env, 0) and Mix.env() in ~w[dev test]a, do: :debug, else: :safe end defp set_error_tracing(opts) do cond do !Code.compiler_options()[:debug_info] -> Keyword.put(opts, :error_tracing, false) Keyword.has_key?(opts, :error_tracing) -> opts :else -> should_trace = Keyword.fetch!(opts, :optimize) in ~w[debug safe]a Keyword.put(opts, :error_tracing, should_trace) end end end