diff --git a/BUILD.bazel b/BUILD.bazel index 725ce79..ea865eb 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -9,6 +9,12 @@ string_flag( visibility = ["//visibility:public"], ) +string_flag( + name = "elixir_platform", + build_setting_default = "", + visibility = ["//visibility:public"], +) + toolchain_type( name = "toolchain_type", visibility = ["//visibility:public"], diff --git a/MODULE.bazel b/MODULE.bazel index d93cd65..95beb73 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -18,6 +18,17 @@ archive_override( url = "https://github.com/discord/rules_erlang/archive/b4b01911b99944fa0c9efb66bf3d9030ee784fa9.tar.gz", ) +# erlang_config is needed so @erlang_config is visible in repos created +# by the elixir_config extension (for wiring the OTP build dependency). +erlang_config = use_extension( + "@rules_erlang//bzlmod:extensions.bzl", + "erlang_config", +) +use_repo( + erlang_config, + "erlang_config", +) + elixir_config = use_extension( "@rules_elixir//bzlmod:extensions.bzl", "elixir_config", @@ -27,9 +38,6 @@ use_repo( "elixir_config", ) -register_toolchains( - "@elixir_config//external:toolchain", -) # Use elixir_packages extension to get hex_pm elixir_packages = use_extension( diff --git a/bzlmod/extensions.bzl b/bzlmod/extensions.bzl index 2ab286b..ab8ecb5 100644 --- a/bzlmod/extensions.bzl +++ b/bzlmod/extensions.bzl @@ -17,18 +17,30 @@ DEFAULT_ELIXIR_VERSION = "1.15.0" DEFAULT_ELIXIR_SHA256 = "0f4df7574a5f300b5c66f54906222cd46dac0df7233ded165bc8e80fd9ffeb7a" def _elixir_config(ctx): + # Resolve the canonical repo name for @erlang_config so we can reference + # it from generated http_archive repos (which don't have it in their + # repo mapping). We use Label().repo_name per Bazel docs rather than + # hardcoding the canonical name format. + erlang_config_repo = Label("@erlang_config").repo_name + types = {} versions = {} urls = {} strip_prefixs = {} sha256s = {} elixir_homes = {} + target_compatible_withs = {} + exec_compatible_withs = {} + erlang_versions = {} for mod in ctx.modules: for elixir in mod.tags.external_elixir_from_path: types[elixir.name] = INSTALLATION_TYPE_EXTERNAL versions[elixir.name] = elixir.version elixir_homes[elixir.name] = elixir.elixir_home + target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] + exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] + for elixir in mod.tags.internal_elixir_from_http_archive: types[elixir.name] = INSTALLATION_TYPE_INTERNAL @@ -36,8 +48,14 @@ def _elixir_config(ctx): urls[elixir.name] = elixir.url strip_prefixs[elixir.name] = elixir.strip_prefix sha256s[elixir.name] = elixir.sha256 - - # Create repository for downloading and building Elixir source + target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] + exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] + if elixir.erlang_version: + erlang_versions[elixir.name] = elixir.erlang_version + + # Create repository for downloading and building Elixir source. + # The http_archive repo doesn't have @erlang_config in its repo + # mapping, so we use the canonical name resolved above. http_archive( name = "elixir_source_{}".format(elixir.name), url = elixir.url, @@ -49,9 +67,13 @@ load("@rules_elixir//private:elixir_build.bzl", "elixir_build") elixir_build( name = "elixir_build", srcs = glob(["**/*"]), + otp = "@@{erlang_config_repo}//{otp}:otp-{otp}", visibility = ["//visibility:public"], ) -""", +""".format( + erlang_config_repo = erlang_config_repo, + otp = elixir.otp, + ), ) for elixir in mod.tags.internal_elixir_from_github_release: @@ -65,8 +87,14 @@ elixir_build( urls[elixir.name] = url strip_prefixs[elixir.name] = strip_prefix sha256s[elixir.name] = elixir.sha256 - - # Create repository for downloading and building Elixir source + target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] + exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] + if elixir.erlang_version: + erlang_versions[elixir.name] = elixir.erlang_version + + # Create repository for downloading and building Elixir source. + # The http_archive repo doesn't have @erlang_config in its repo + # mapping, so we use the canonical name resolved above. http_archive( name = "elixir_source_{}".format(elixir.name), url = url, @@ -78,9 +106,13 @@ load("@rules_elixir//private:elixir_build.bzl", "elixir_build") elixir_build( name = "elixir_build", srcs = glob(["**/*"]), + otp = "@@{erlang_config_repo}//{otp}:otp-{otp}", visibility = ["//visibility:public"], ) -""", +""".format( + erlang_config_repo = erlang_config_repo, + otp = elixir.otp, + ), ) _elixir_config_rule( @@ -91,12 +123,19 @@ elixir_build( strip_prefixs = strip_prefixs, sha256s = sha256s, elixir_homes = elixir_homes, + exec_compatible_withs = exec_compatible_withs, + target_compatible_withs = target_compatible_withs, + erlang_versions = erlang_versions, ) external_elixir_from_path = tag_class(attrs = { "name": attr.string(), "version": attr.string(), "elixir_home": attr.string(), + # It doesn't...really make sense to have an exec_compatible_with for an + # extenral toolchain that's already on the host? + "exec_compatible_with": attr.label_list(default = []), + "target_compatible_with": attr.label_list(default = []), }) internal_elixir_from_http_archive = tag_class(attrs = { @@ -105,6 +144,16 @@ internal_elixir_from_http_archive = tag_class(attrs = { "url": attr.string(), "strip_prefix": attr.string(), "sha256": attr.string(), + "otp": attr.string( + mandatory = True, + doc = "Name of an erlang_config installation to use for building Elixir (e.g. '25_bootstrap').", + ), + "erlang_version": attr.string( + doc = "OTP version string (e.g. '26.3') for generating combined platform constraints. " + + "Must match the version of the OTP installation named by the 'otp' attr.", + ), + "exec_compatible_with": attr.label_list(default = []), + "target_compatible_with": attr.label_list(default = []), }) internal_elixir_from_github_release = tag_class(attrs = { @@ -117,6 +166,17 @@ internal_elixir_from_github_release = tag_class(attrs = { "sha256": attr.string( default = DEFAULT_ELIXIR_SHA256, ), + "otp": attr.string( + mandatory = True, + doc = "Name of an erlang_config installation to use for building Elixir (e.g. '25_bootstrap').", + ), + "erlang_version": attr.string( + doc = "OTP version string (e.g. '26.3') for generating combined platform constraints. " + + "Must match the version of the OTP installation named by the 'otp' attr.", + ), + # NOTE: these should default to the host platform + "exec_compatible_with": attr.label_list(default = []), + "target_compatible_with": attr.label_list(default = []), }) elixir_config = module_extension( diff --git a/defs.bzl b/defs.bzl index b889a2c..7b11e86 100644 --- a/defs.bzl +++ b/defs.bzl @@ -33,7 +33,7 @@ load( load("//private:elixir_sys_config.bzl", _elixir_sys_config = "elixir_sys_config") # mix-backed targets. Generally fairly reliable, and set-and-forget. -def mix_library(*args, **kwargs): +def mix_library(name, app_name, deps = [], **kwargs): """Compiles an Elixir library using Mix. Automatically adds dependencies for hex unless the app_name is "hex", @@ -41,11 +41,9 @@ def mix_library(*args, **kwargs): dependency is relevant in the current compilation context (even though we invoke in offline mode) """ - deps = kwargs.pop("deps", []) - if kwargs.get("app_name") != "hex": - deps.append(Label("@hex_pm//:lib")) - kwargs['deps'] = deps - _mix_library(*args, **kwargs) + if app_name != "hex": + deps = deps + [Label("@hex_pm//:lib")] + _mix_library(name = name, app_name = app_name, deps = deps, **kwargs) def mix_release(*args, **kwargs): _mix_release(*args, **kwargs) diff --git a/private/beam_transitions.bzl b/private/beam_transitions.bzl new file mode 100644 index 0000000..9e554a0 --- /dev/null +++ b/private/beam_transitions.bzl @@ -0,0 +1,42 @@ +"""Configuration transitions for platform-independent BEAM compilation. + +BEAM bytecode (.beam) and .app files are platform-independent artifacts. +This transition normalizes platform-related settings so that compilation +actions get the same configuration hash regardless of target platform, +enabling remote cache hits across platforms. + +When //:elixir_platform is set, the transition uses that platform (which +should carry both elixir_version and erlang_version constraints) for +correct toolchain resolution with multiple versions. + +Falls back to @rules_erlang//:erlang_platform if only the Erlang flag +is set (covers transitive Erlang rule usage). + +When both flags are empty (default), --platforms is cleared to [] — +identical to the previous behavior, safe for single-version setups. +""" + +# Cross-repo reference needs Label() for canonicalization. The str() result +# is used as both the inputs entry and the settings dict key. +_ERLANG_PLATFORM_LABEL = str(Label("@rules_erlang//:erlang_platform")) + +def _platform_independent_impl(settings, attr): + elixir_platform = settings["//:elixir_platform"] + if elixir_platform: + return {"//command_line_option:platforms": [elixir_platform]} + + erlang_platform = settings[_ERLANG_PLATFORM_LABEL] + if erlang_platform: + return {"//command_line_option:platforms": [erlang_platform]} + + return {"//command_line_option:platforms": []} + +platform_independent_transition = transition( + implementation = _platform_independent_impl, + inputs = [ + "//command_line_option:platforms", + "//:elixir_platform", + _ERLANG_PLATFORM_LABEL, + ], + outputs = ["//command_line_option:platforms"], +) diff --git a/private/elixir_build.bzl b/private/elixir_build.bzl index cea8dcd..8f06152 100644 --- a/private/elixir_build.bzl +++ b/private/elixir_build.bzl @@ -7,9 +7,8 @@ load( "http_archive", ) load( - "@rules_erlang//tools:erlang_toolchain.bzl", - "erlang_dirs", - "maybe_install_erlang", + "@rules_erlang//private:erlang_build.bzl", + "OtpInfo", ) ElixirInfo = provider( @@ -21,11 +20,34 @@ ElixirInfo = provider( ], ) +def otp_install_snippet(otp_info): + """Generate shell snippet to extract OTP release tar, like maybe_install_erlang.""" + if otp_info.release_dir_tar == None: + return "" + return """\ +mkdir -p $(dirname "{install_path}") +if mkdir "{install_path}"; then + tar --extract --no-same-owner \\ + --directory "{install_path}" \\ + --file {release_tar} +fi""".format( + release_tar = otp_info.release_dir_tar.path, + install_path = otp_info.install_path, + ) + +def otp_runfiles(ctx, otp_info): + """Build runfiles depset for OTP, like erlang_dirs.""" + if otp_info.release_dir_tar != None: + return ctx.runfiles([otp_info.release_dir_tar, otp_info.version_file]) + else: + return ctx.runfiles([otp_info.version_file]) + def _elixir_build_impl(ctx): + otp_info = ctx.attr.otp[OtpInfo] release_dir = ctx.actions.declare_directory("elixir_release") version_file = ctx.actions.declare_file("elixir_version") - (erlang_home, _, runfiles) = erlang_dirs(ctx) + runfiles = otp_runfiles(ctx, otp_info) ctx.actions.run_shell( inputs = depset( @@ -63,8 +85,8 @@ make cp -r bin $ABS_RELEASE_DIR/ cp -r lib $ABS_RELEASE_DIR/ """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, release_path = release_dir.path, source_files = " ".join([f.path for f in ctx.files.srcs]), first_source_file = ctx.files.srcs[0].path if ctx.files.srcs else "", @@ -88,8 +110,8 @@ export PATH="{erlang_home}"/bin:${{PATH}} "{elixir_home}"/bin/iex --version > {version_file} """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, elixir_home = release_dir.path, version_file = version_file.path, ), @@ -99,7 +121,7 @@ export PATH="{erlang_home}"/bin:${{PATH}} return [ DefaultInfo(files = depset([release_dir, version_file])), - ctx.toolchains["@rules_erlang//tools:toolchain_type"].otpinfo, + otp_info, ElixirInfo( release_dir = release_dir, elixir_home = None, @@ -111,18 +133,24 @@ elixir_build = rule( implementation = _elixir_build_impl, attrs = { "srcs": attr.label_list(allow_files = True), + "otp": attr.label( + mandatory = True, + providers = [OtpInfo], + doc = "An erlang_build target to use for compiling Elixir.", + ), }, - toolchains = ["@rules_erlang//tools:toolchain_type"], ) def _elixir_external_impl(ctx): + otp_info = ctx.attr.otp[OtpInfo] + elixir_home = ctx.attr.elixir_home if elixir_home == "": elixir_home = ctx.attr._elixir_home[BuildSettingInfo].value version_file = ctx.actions.declare_file(ctx.label.name + "_version") - (erlang_home, _, runfiles) = erlang_dirs(ctx) + runfiles = otp_runfiles(ctx, otp_info) ctx.actions.run_shell( inputs = runfiles.files, @@ -135,8 +163,8 @@ export PATH="{erlang_home}"/bin:${{PATH}} "{elixir_home}"/bin/iex --version > {version_file} """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, elixir_home = elixir_home, version_file = version_file.path, ), @@ -148,7 +176,7 @@ export PATH="{erlang_home}"/bin:${{PATH}} DefaultInfo( files = depset([version_file]), ), - ctx.toolchains["@rules_erlang//tools:toolchain_type"].otpinfo, + otp_info, ElixirInfo( release_dir = None, elixir_home = elixir_home, @@ -161,6 +189,10 @@ elixir_external = rule( attrs = { "_elixir_home": attr.label(default = Label("//:elixir_home")), "elixir_home": attr.string(), + "otp": attr.label( + mandatory = True, + providers = [OtpInfo], + doc = "An erlang_build target providing the OTP installation.", + ), }, - toolchains = ["@rules_erlang//tools:toolchain_type"], ) diff --git a/private/elixir_source_build.bzl b/private/elixir_source_build.bzl index 6014baf..6756cab 100644 --- a/private/elixir_source_build.bzl +++ b/private/elixir_source_build.bzl @@ -1,10 +1,20 @@ -load("@rules_erlang//tools:erlang_toolchain.bzl", "erlang_dirs", "maybe_install_erlang") +load( + "@rules_erlang//private:erlang_build.bzl", + "OtpInfo", +) +load( + ":elixir_build.bzl", + "ElixirInfo", + "otp_install_snippet", + "otp_runfiles", +) def _elixir_source_build_impl(ctx): + otp_info = ctx.attr.otp[OtpInfo] release_dir = ctx.actions.declare_directory("elixir_release") version_file = ctx.actions.declare_file("elixir_version") - (erlang_home, _, runfiles) = erlang_dirs(ctx) + runfiles = otp_runfiles(ctx, otp_info) ctx.actions.run_shell( inputs = depset( @@ -42,8 +52,8 @@ make cp -r bin $ABS_RELEASE_DIR/ cp -r lib $ABS_RELEASE_DIR/ """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, release_path = release_dir.path, source_files = " ".join([f.path for f in ctx.files.srcs]), first_source_file = ctx.files.srcs[0].path if ctx.files.srcs else "", @@ -67,8 +77,8 @@ export PATH="{erlang_home}"/bin:${{PATH}} "{elixir_home}"/bin/iex --version > {version_file} """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, elixir_home = release_dir.path, version_file = version_file.path, ), @@ -78,7 +88,7 @@ export PATH="{erlang_home}"/bin:${{PATH}} return [ DefaultInfo(files = depset([release_dir, version_file])), - ctx.toolchains["@rules_erlang//tools:toolchain_type"].otpinfo, + otp_info, ElixirInfo( release_dir = release_dir, elixir_home = None, @@ -90,6 +100,10 @@ elixir_source_build = rule( implementation = _elixir_source_build_impl, attrs = { "srcs": attr.label_list(allow_files = True), + "otp": attr.label( + mandatory = True, + providers = [OtpInfo], + doc = "An erlang_build target to use for compiling Elixir.", + ), }, - toolchains = ["@rules_erlang//tools:toolchain_type"], ) diff --git a/private/mix_library.bzl b/private/mix_library.bzl index 33f0efb..4cd4be0 100644 --- a/private/mix_library.bzl +++ b/private/mix_library.bzl @@ -9,6 +9,7 @@ load( "maybe_install_erlang", ) load("//private:mix_info.bzl", "MixProjectInfo") +load("//private:beam_transitions.bzl", "platform_independent_transition") def _priv_file_dest_relative_path(label, f): """Calculate the relative path for a priv file, preserving directory structure. @@ -39,25 +40,15 @@ def _elixir_erl_libs_args(paths): lib_dirs = " ".join(paths) return "-pa " + lib_dirs -def _mix_library_impl(ctx): +# --- _mix_compile: platform-independent compilation --- + +def _mix_compile_impl(ctx): # TODO: i don't _think_ we need to explicitly pass the output dir in, and # should instead return a Provider that can provide erlang...library info? # TBD # This also needs a better name ebin = ctx.actions.declare_directory(ctx.label.name + "_ebin") - # Create individual priv file symlinks rather than a directory artifact. - # A directory artifact named "priv" causes path doubling (priv/priv/...) - # in any consumer that places priv files under a priv/ directory, because - # additional_file_dest_relative_path returns "priv" which doesn't match - # the startswith("priv/") check. - priv_files = [] - for priv_file in ctx.files.priv: - rel_path = _priv_file_dest_relative_path(ctx.label, priv_file) - out = ctx.actions.declare_file(path_join("priv", rel_path)) - ctx.actions.symlink(output = out, target_file = priv_file) - priv_files.append(out) - erl_libs_dir = ctx.label.name + "_deps" erl_libs_files = erl_libs_contents( @@ -96,8 +87,9 @@ def _mix_library_impl(ctx): (erlang_home, _, erlang_runfiles) = erlang_dirs(ctx) (elixir_home, elixir_runfiles) = elixir_dirs(ctx) - all_deps = flat_deps(ctx.attr.deps) - + # Priv files are copied into the build dir as inputs (needed during + # compilation for NIFs, etc.) but NOT declared as outputs — priv output + # handling is done by _mix_priv in normal platform config. priv_copy_to_build_dir = "" if ctx.files.priv: build_dir_cmds = [] @@ -210,14 +202,96 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} mnemonic = "MIXCOMPILE", ) + return [DefaultInfo(files = depset([ebin]))] + +_mix_compile = rule( + implementation = _mix_compile_impl, + attrs = { + "app_name": attr.string(mandatory = True), + "mix_env": attr.string( + default = "prod", + values = ["prod", "test", "dev"], + doc = "The MIX_ENV to use when compiling (default: prod)", + ), + "env": attr.string_dict(), + "mix_config": attr.label( + allow_single_file = [".exs"], + default = ":mix.exs", + ), + "srcs": attr.label_list( + allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], + ), + "data": attr.label_list( + allow_files = True, + ), + "deps": attr.label_list( + providers = [ErlangAppInfo], + ), + "priv": attr.label_list( + allow_files = True, + ), + "include": attr.label_list( + allow_files = [".hrl"], + ), + "ez_deps": attr.label_list( + allow_files = [".ez"], + ), + }, + toolchains = ["//:toolchain_type"], +) + +# --- _mix_priv: platform-dependent priv file handling --- + +def _mix_priv_impl(ctx): + # Create individual priv file symlinks rather than a directory artifact. + # A directory artifact named "priv" causes path doubling (priv/priv/...) + # in any consumer that places priv files under a priv/ directory, because + # additional_file_dest_relative_path returns "priv" which doesn't match + # the startswith("priv/") check. + priv_files = [] + for priv_file in ctx.files.priv: + rel_path = _priv_file_dest_relative_path(ctx.label, priv_file) + out = ctx.actions.declare_file(path_join("priv", rel_path)) + ctx.actions.symlink(output = out, target_file = priv_file) + priv_files.append(out) + + return [DefaultInfo( + files = depset(priv_files), + runfiles = ctx.runfiles(files = priv_files), + )] + +_mix_priv = rule( + implementation = _mix_priv_impl, + attrs = { + "priv": attr.label_list( + allow_files = True, + ), + }, +) + +# --- _mix_library_info: aggregator providing ErlangAppInfo + MixProjectInfo --- + +def _mix_library_info_impl(ctx): + compile_files = ctx.attr.compile[DefaultInfo].files.to_list() + priv_files = [] + if ctx.attr.priv_target: + priv_files = ctx.attr.priv_target[DefaultInfo].files.to_list() + + all_deps = flat_deps(ctx.attr.deps) + dep_runfiles = [dep[DefaultInfo].default_runfiles for dep in ctx.attr.deps if DefaultInfo in dep] - lib_runfiles = ctx.runfiles(files = [ebin] + priv_files + ctx.files.data) + lib_runfiles = ctx.runfiles(files = compile_files + priv_files + ctx.files.data) + compile_runfiles = ctx.attr.compile[DefaultInfo].default_runfiles + if compile_runfiles: + lib_runfiles = lib_runfiles.merge(compile_runfiles) + if ctx.attr.priv_target and ctx.attr.priv_target[DefaultInfo].default_runfiles: + lib_runfiles = lib_runfiles.merge(ctx.attr.priv_target[DefaultInfo].default_runfiles) for dr in dep_runfiles: lib_runfiles = lib_runfiles.merge(dr) return [ DefaultInfo( - files = depset([ebin] + priv_files), + files = depset(compile_files + priv_files), runfiles = lib_runfiles, ), MixProjectInfo( @@ -250,7 +324,7 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} deps = all_deps, srcs = ctx.attr.srcs, # TODO: beam? - beam = [ebin], + beam = compile_files, # Priv files with preserved directory structure priv = priv_files, # TODO: extra erlang libs to include? @@ -261,44 +335,91 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} ), ] -mix_library = rule( - implementation = _mix_library_impl, +_mix_library_info = rule( + implementation = _mix_library_info_impl, attrs = { - "app_name": attr.string(), - "mix_env": attr.string( - default = "prod", - values = ["prod", "test", "dev"], - doc = "The MIX_ENV to use when compiling (default: prod)", - ), - "env": attr.string_dict(), + "app_name": attr.string(mandatory = True), + "mix_env": attr.string(default = "prod"), "mix_config": attr.label( allow_single_file = [".exs"], default = ":mix.exs", ), - "srcs": attr.label_list( - # TODO: is there a more comprehensive place I can find all - # supported extensions than just adding them as I find them? There - # are a lot of weird extensions... - allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], - ), + "compile": attr.label(mandatory = True), + "priv_target": attr.label(), "data": attr.label_list( allow_files = True, ), "deps": attr.label_list( - # TODO: need to confirm the provider we create also outputs this providers = [ErlangAppInfo], ), - "priv": attr.label_list( - allow_files = True, + "srcs": attr.label_list( + allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], ), "include": attr.label_list( allow_files = [".hrl"], ), - # TODO: we should probably set a default for this? - "ez_deps": attr.label_list( - allow_files = [".ez"], - ), }, - # TODO: confirm(??) (????) - toolchains = ["//:toolchain_type"], ) + +# --- mix_library: composing macro --- + +def mix_library(name, app_name, priv = [], visibility = None, **kwargs): + """Compiles an Elixir library using Mix. + + Splits compilation into platform-independent (.beam/.app) and + platform-dependent (priv/) artifacts for cross-platform cache reuse. + """ + + # Attrs that go to _mix_compile + compile_kwargs = {k: v for k, v in kwargs.items() if k in ( + "mix_env", + "env", + "mix_config", + "srcs", + "data", + "deps", + "include", + "ez_deps", + )} + + # Attrs that go to _mix_library_info + info_kwargs = {k: v for k, v in kwargs.items() if k in ( + "mix_env", + "mix_config", + "data", + "deps", + "srcs", + "include", + )} + + # mix compile runs without a platform transition so that native deps + # resolve with full CPU/OS constraints. Unfortunately that means we + # compile elixir BEAM twice in multi-arch cases, but the redundancy is + # acceptable to also ensure we don't mix and match OTP versions. + _mix_compile( + name = name + "_compile", + app_name = app_name, + priv = priv, + visibility = ["//visibility:private"], + **compile_kwargs + ) + + # Platform-dependent: priv file symlinks + priv_target = None + if priv: + _mix_priv( + name = name + "_priv", + priv = priv, + visibility = ["//visibility:private"], + ) + priv_target = ":" + name + "_priv" + + # Aggregator: provides ErlangAppInfo + MixProjectInfo + _mix_library_info( + name = name, + app_name = app_name, + compile = ":" + name + "_compile", + priv_target = priv_target, + visibility = visibility, + **info_kwargs + ) diff --git a/repositories/BUILD_internal.tpl b/repositories/BUILD_internal.tpl index e25a05e..efe0526 100644 --- a/repositories/BUILD_internal.tpl +++ b/repositories/BUILD_internal.tpl @@ -22,10 +22,10 @@ toolchain( name = "toolchain", exec_compatible_with = [ "//:elixir_internal", - ], +%{EXTRA_EXEC_CONSTRAINTS} ], target_compatible_with = [ "//:elixir_%{ELIXIR_VERSION_ID}", - ], +%{EXTRA_TARGET_CONSTRAINTS} ], toolchain = ":elixir", toolchain_type = "@rules_elixir//:toolchain_type", visibility = ["//visibility:public"], diff --git a/repositories/elixir_config.bzl b/repositories/elixir_config.bzl index 8e249a3..c89828e 100644 --- a/repositories/elixir_config.bzl +++ b/repositories/elixir_config.bzl @@ -12,6 +12,15 @@ _ELIXIR_VERSION_UNKNOWN = "UNKNOWN" INSTALLATION_TYPE_EXTERNAL = "external" INSTALLATION_TYPE_INTERNAL = "internal" +def _format_constraints(constraints): + if not constraints: + return "" + else: + return " " + ",\n ".join([ + ('"%s"' % constraint) + for constraint in constraints + ]) + def _version_identifier(version_string): parts = version_string.split(".", 2) if len(parts) > 1: @@ -20,7 +29,13 @@ def _version_identifier(version_string): return parts[0] def _impl(repository_ctx): - elixir_installations = _default_elixir_dict(repository_ctx) + # Skip host Elixir detection when explicit installations are provided. + # The host probe produces arch-dependent results, making the lockfile + # non-deterministic across architectures. + if len(repository_ctx.attr.types) > 0: + elixir_installations = {} + else: + elixir_installations = _default_elixir_dict(repository_ctx) for name in repository_ctx.attr.types.keys(): if name == _DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME: fail("'{}' is reserved as an elixir name".format( @@ -36,6 +51,9 @@ def _impl(repository_ctx): strip_prefix = repository_ctx.attr.strip_prefixs.get(name, None), sha256 = repository_ctx.attr.sha256s.get(name, None), elixir_home = repository_ctx.attr.elixir_homes.get(name, None), + target_compatible_with = repository_ctx.attr.target_compatible_withs.get(name, None), + exec_compatible_with = repository_ctx.attr.exec_compatible_withs.get(name, None), + erlang_version = repository_ctx.attr.erlang_versions.get(name, None), ) for (name, props) in elixir_installations.items(): @@ -46,6 +64,9 @@ def _impl(repository_ctx): { "%{ELIXIR_HOME}": props.elixir_home, "%{ELIXIR_VERSION_ID}": props.identifier, + # again, i don't...really think it makes sense sense to do + # exec_compatible_with for stuff that's on the host system? + "%{EXTRA_TARGET_CONSTRAINTS}": _format_constraints(props.target_compatible_with), }, False, ) @@ -59,6 +80,8 @@ def _impl(repository_ctx): "%{SHA_256}": props.sha256 or "", "%{ELIXIR_VERSION_ID}": props.identifier, "%{ELIXIR_NAME}": name, + "%{EXTRA_EXEC_CONSTRAINTS}": _format_constraints(props.exec_compatible_with), + "%{EXTRA_TARGET_CONSTRAINTS}": _format_constraints(props.target_compatible_with), }, False, ) @@ -98,6 +121,9 @@ elixir_config = repository_rule( "strip_prefixs": attr.string_dict(), "sha256s": attr.string_dict(), "elixir_homes": attr.string_dict(), + "exec_compatible_withs": attr.string_list_dict(), + "target_compatible_withs": attr.string_list_dict(), + "erlang_versions": attr.string_dict(), }, environ = [ ELIXIR_HOME_ENV_VAR, @@ -160,6 +186,10 @@ def _default_elixir_dict(repository_ctx): version = version, identifier = identifier, elixir_home = elixir_home, + # XXX: this is probably not what we want to commit, but will be + # fine during iteration. + target_compatible_with = None, + exec_compatible_with = None, ), } else: @@ -169,6 +199,10 @@ def _default_elixir_dict(repository_ctx): version = _ELIXIR_VERSION_UNKNOWN, identifier = _ELIXIR_VERSION_UNKNOWN.lower(), elixir_home = elixir_home, + # XXX: this is probably not what we want to commit, but will be + # fine during iteration. + target_compatible_with = None, + exec_compatible_with = None, ), } @@ -195,7 +229,10 @@ constraint_value( """ - default_installation = elixir_installations[_DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME] + if _DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME in elixir_installations: + default_installation = elixir_installations[_DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME] + else: + default_installation = elixir_installations.values()[0] build_file_content += """\ constraint_setting( @@ -205,18 +242,40 @@ constraint_setting( """.format(default_installation.identifier) - unique_identifiers = { - props.identifier: name - for (name, props) in elixir_installations.items() - }.keys() + # Collect unique identifiers and their paired erlang versions. + # When multiple installations share an identifier, the last one wins + # (same as the old behavior for the identifier set). + identifier_erlang_versions = {} + for (name, props) in elixir_installations.items(): + identifier_erlang_versions[props.identifier] = getattr(props, "erlang_version", None) - for identifier in unique_identifiers: + for (identifier, erlang_version) in identifier_erlang_versions.items(): build_file_content += """\ constraint_value( name = "elixir_{identifier}", constraint_setting = ":elixir_version", ) +""".format(identifier = identifier) + + # Generate a platform with both elixir_version and (if known) + # erlang_version constraints. The erlang constraint enables correct + # toolchain resolution when platform_independent_transition replaces + # --platforms with this synthetic platform. + if erlang_version: + erlang_version_id = _version_identifier(erlang_version) + build_file_content += """\ +platform( + name = "elixir_{identifier}_platform", + constraint_values = [ + ":elixir_{identifier}", + "@erlang_config//:erlang_{erlang_version_id}", + ], +) + +""".format(identifier = identifier, erlang_version_id = erlang_version_id) + else: + build_file_content += """\ platform( name = "elixir_{identifier}_platform", constraint_values = [