Current section
Files
Jump to
Current section
Files
lib/zig/_module.ex
defmodule Zig.Module do
@moduledoc false
# abstraction representing multiple zig nif functions bound into a single
# module
alias Zig.Builder
alias Zig.C
alias Zig.DepsModule
alias Zig.BuildModule
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: [],
# 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],
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
if not is_binary(url),
do: Options.raise_with("url must be a binary", Options.push_key(context, :precompiled))
if not (is_binary(shasum) or is_list(shasum)),
do:
Options.raise_with(
"shasum must be a binary or keyword list",
Options.push_key(context, :precompiled)
)
if shasum = normalize_shasum(shasum) do
case Base.decode16(shasum, case: :mixed) do
{:ok, _} ->
if System.get_env("ZIGLER_PRECOMPILE_FORCE_RECOMPILE", "false") != "true",
do: {:web, substitute_url(url, context), shasum}
:error ->
Options.raise_with(
"shasum must be base-16 encoded",
Options.push_key(context, :precompiled)
)
end
end
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 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
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
)
function_code = Enum.map(module.nifs, &Nif.render_erlang/1)
Enum.flat_map(function_code, & &1) ++ init_function
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}.linkSystemLibrary(\"#{libname}\");"
path ->
"#{mode}.addObjectFile(.{.cwd_relative = \"#{path}\"});"
end)
c_srcs =
Enum.map(c.src, fn
{c_src, flags} ->
"""
#{mode}.addCSourceFiles(.{
.root = .{.cwd_relative = "#{Path.dirname(c_src)}"},
.files = &.{"#{Path.basename(c_src)}"},
.flags = &.{ #{render_flags(flags)}},
});
"""
end)
link_libs ++ 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
!Zig._errors_available?() ->
Keyword.put(opts, :error_tracing, false)
!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