diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6fbef48c..35f31da43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for configuring pins and width for sdmmc on ESP32 - Added support for map comprehensions - Added USB CDC port drivers for ESP32, RP2, and STM32 platforms +- Added `erlang:term_to_binary/2`, `erlang:is_builtin/3` and `erlang:bitstring_to_list/1` ### Changed - Updated network type db() to dbm() to reflect the actual representation of the type diff --git a/libs/estdlib/src/erlang.erl b/libs/estdlib/src/erlang.erl index 760063cec6..317021f73b 100644 --- a/libs/estdlib/src/erlang.erl +++ b/libs/estdlib/src/erlang.erl @@ -52,6 +52,7 @@ erase/0, erase/1, function_exported/3, + is_builtin/3, loaded/0, module_loaded/1, display/1, @@ -71,6 +72,7 @@ binary_to_integer/1, binary_to_integer/2, binary_to_list/1, + bitstring_to_list/1, atom_to_binary/1, atom_to_binary/2, atom_to_list/1, @@ -122,6 +124,7 @@ garbage_collect/1, binary_to_term/1, term_to_binary/1, + term_to_binary/2, split_binary/2, crc32/1, crc32/2, @@ -183,7 +186,8 @@ atomvm_heap_growth_strategy/0, stacktrace/0, stacktrace_extrainfo/0, - raise_stacktrace/0 + raise_stacktrace/0, + term_to_binary_option/0 ]). -type atom_encoding() :: latin1 | utf8 | unicode. @@ -202,6 +206,12 @@ -type demonitor_option() :: flush | {flush, boolean()} | info | {info, boolean()}. +-type term_to_binary_option() :: + compressed + | {compressed, Level :: 0..9} + | deterministic + | {minor_version, Version :: 0..2}. + -type atomvm_heap_growth_strategy() :: bounded_free | minimum @@ -678,6 +688,27 @@ erase(_Key) -> function_exported(_Module, _Function, _Arity) -> erlang:nif_error(undefined). +%%----------------------------------------------------------------------------- +%% @param Module module name +%% @param Function function name +%% @param Arity function arity +%% @returns `true' if `Module:Function/Arity' is a builtin, `false' otherwise. +%% @doc Determine whether a function is implemented natively (as a BIF or a +%% NIF) by AtomVM. As on BEAM, this reflects functions "implemented in +%% C" rather than in Erlang, but it is answered against AtomVM's own +%% registry. It therefore differs from BEAM whenever the two virtual +%% machines disagree on whether a given function is native: for +%% example `erlang:atom_to_binary/1' is native on AtomVM but +%% Erlang-implemented on BEAM, while `erlang:md5/1' is a BEAM BIF that +%% AtomVM implements in Erlang. An arity that no builtin has (including a +%% negative integer) returns `false'. Raises `badarg' if `Module' or +%% `Function' is not an atom, or `Arity' is not an integer. +%% @end +%%----------------------------------------------------------------------------- +-spec is_builtin(Module :: module(), Function :: atom(), Arity :: arity()) -> boolean(). +is_builtin(_Module, _Function, _Arity) -> + erlang:nif_error(undefined). + %%----------------------------------------------------------------------------- %% @returns list of loaded modules %% @doc Returns all loaded modules. @@ -892,6 +923,20 @@ binary_to_integer(_Binary, _Base) -> binary_to_list(_Binary) -> erlang:nif_error(undefined). +%%----------------------------------------------------------------------------- +%% @param Bitstring Bitstring to convert to list +%% @returns a list of bytes from the bitstring +%% @doc Convert a bitstring to a list of bytes. +%% +%% Unlike Erlang/OTP, AtomVM only supports byte-aligned bitstrings (binaries), +%% so the returned list never has a trailing bitstring and this function +%% behaves like `binary_to_list/1'. +%% @end +%%----------------------------------------------------------------------------- +-spec bitstring_to_list(Bitstring :: bitstring()) -> [byte()]. +bitstring_to_list(_Bitstring) -> + erlang:nif_error(undefined). + %%----------------------------------------------------------------------------- %% @param Atom Atom to convert %% @returns a binary with the atom's name @@ -1516,10 +1561,8 @@ garbage_collect(_Pid) -> %%----------------------------------------------------------------------------- %% @returns A term decoded from passed binary %% @param Binary binary to decode -%% @doc Decode a term that was previously encodes with `term_to_binary/1' +%% @doc Decode a term that was previously encoded with `term_to_binary/1'. %% This function should be mostly compatible with its Erlang/OTP counterpart. -%% Unlike modern Erlang/OTP, resources are currently serialized as empty -%% binaries and cannot be unserialized. %% @end %%----------------------------------------------------------------------------- -spec binary_to_term(Binary :: binary()) -> any(). @@ -1531,14 +1574,42 @@ binary_to_term(_Binary) -> %% @param Term term to encode %% @doc Encode a term to a binary that can later be decoded with `binary_to_term/1'. %% This function should be mostly compatible with its Erlang/OTP counterpart. -%% Unlike modern Erlang/OTP, resources are currently serialized as empty -%% binaries. %% @end %%----------------------------------------------------------------------------- -spec term_to_binary(Term :: any()) -> binary(). term_to_binary(_Term) -> erlang:nif_error(undefined). +%%----------------------------------------------------------------------------- +%% @returns A binary encoding passed term. +%% @param Term term to encode +%% @param Options encoding options +%% @doc Encode a term to a binary that can later be decoded with `binary_to_term/1'. +%% +%% The following options are accepted: +%% +%% +%% Unlike Erlang/OTP, the `local' option is not supported: AtomVM never emits +%% the node-local `LOCAL_EXT' encoding. Passing `local', or any other unknown +%% option, raises a `badarg' error. +%% +%% This function should be mostly compatible with its Erlang/OTP counterpart. +%% @end +%%----------------------------------------------------------------------------- +-spec term_to_binary(Term :: any(), Options :: [term_to_binary_option()]) -> binary(). +term_to_binary(_Term, _Options) -> + erlang:nif_error(undefined). + %%----------------------------------------------------------------------------- %% @returns A tuple with two subbinaries %% @param Bin binary to split diff --git a/src/libAtomVM/nifs.c b/src/libAtomVM/nifs.c index 2e3f70f7eb..185af177d4 100644 --- a/src/libAtomVM/nifs.c +++ b/src/libAtomVM/nifs.c @@ -241,6 +241,7 @@ static term nif_erlang_port_to_list(Context *ctx, int argc, term argv[]); static term nif_erlang_ref_to_list(Context *ctx, int argc, term argv[]); static term nif_erlang_fun_to_list(Context *ctx, int argc, term argv[]); static term nif_erlang_function_exported(Context *ctx, int argc, term argv[]); +static term nif_erlang_is_builtin(Context *ctx, int argc, term argv[]); static term nif_erlang_garbage_collect(Context *ctx, int argc, term argv[]); static term nif_erlang_group_leader(Context *ctx, int argc, term argv[]); static term nif_erlang_get_module_info(Context *ctx, int argc, term argv[]); @@ -688,6 +689,10 @@ static const struct Nif function_exported_nif = { .base.type = NIFFunctionType, .nif_ptr = nif_erlang_function_exported }; +static const struct Nif is_builtin_nif = { + .base.type = NIFFunctionType, + .nif_ptr = nif_erlang_is_builtin +}; static const struct Nif garbage_collect_nif = { .base.type = NIFFunctionType, @@ -3439,10 +3444,48 @@ static term nif_erlang_binary_to_term(Context *ctx, int argc, term argv[]) } } +// AtomVM's external term encoding is already deterministic and always uses +// NEW_FLOAT_EXT (minor_version >= 1). `compressed' is accepted but ignored: +// the uncompressed encoding is a valid external term that any +// binary_to_term can read. +static bool is_valid_term_to_binary_option(Context *ctx, term opt) +{ + if (term_is_atom(opt)) { + return globalcontext_is_term_equal_to_atom_string(ctx->global, opt, ATOM_STR("\xD", "deterministic")) + || globalcontext_is_term_equal_to_atom_string(ctx->global, opt, ATOM_STR("\xA", "compressed")); + } + if (term_is_tuple(opt) && term_get_tuple_arity(opt) == 2) { + term name = term_get_tuple_element(opt, 0); + term value = term_get_tuple_element(opt, 1); + if (!term_is_integer(value)) { + return false; + } + avm_int_t int_value = term_to_int(value); + if (globalcontext_is_term_equal_to_atom_string(ctx->global, name, ATOM_STR("\xD", "minor_version"))) { + return int_value >= 0 && int_value <= 2; + } + if (globalcontext_is_term_equal_to_atom_string(ctx->global, name, ATOM_STR("\xA", "compressed"))) { + return int_value >= 0 && int_value <= 9; + } + } + return false; +} + static term nif_erlang_term_to_binary(Context *ctx, int argc, term argv[]) { - UNUSED(argc); term t = argv[0]; + if (argc == 2) { + term options = argv[1]; + while (term_is_nonempty_list(options)) { + if (UNLIKELY(!is_valid_term_to_binary_option(ctx, term_get_list_head(options)))) { + RAISE_ERROR(BADARG_ATOM); + } + options = term_get_list_tail(options); + } + if (UNLIKELY(!term_is_nil(options))) { + RAISE_ERROR(BADARG_ATOM); + } + } term ret = external_term_to_binary(ctx, t); if (term_is_invalid_term(ret)) { RAISE_ERROR(BADARG_ATOM); @@ -4661,6 +4704,36 @@ static term nif_erlang_function_exported(Context *ctx, int argc, term argv[]) return TRUE_ATOM; } +static term nif_erlang_is_builtin(Context *ctx, int argc, term argv[]) +{ + UNUSED(argc); + + term module = argv[0]; + term function = argv[1]; + term arity_term = argv[2]; + + VALIDATE_VALUE(module, term_is_atom); + VALIDATE_VALUE(function, term_is_atom); + VALIDATE_VALUE(arity_term, term_is_integer); + + atom_index_t module_name_ix = term_to_atom_index(module); + atom_index_t function_name_ix = term_to_atom_index(function); + + avm_int_t arity = term_to_int(arity_term); + + char mfa[MAX_MFA_NAME_LEN]; + atom_table_write_mfa(ctx->global->atom_table, mfa, sizeof(mfa), module_name_ix, function_name_ix, arity); + + // A function is a builtin iff it is implemented natively in AtomVM, i.e. + // registered as a BIF or a NIF. Unlike function_exported/3, a function + // exported by a loaded (Erlang) module is not a builtin. + if (bif_registry_get_handler(mfa) != NULL || nifs_get(mfa) != NULL) { + return TRUE_ATOM; + } + + return FALSE_ATOM; +} + static term nif_erlang_garbage_collect(Context *ctx, int argc, term argv[]) { if (argc == 0) { diff --git a/src/libAtomVM/nifs.gperf b/src/libAtomVM/nifs.gperf index c7ce64cf7e..736c579123 100644 --- a/src/libAtomVM/nifs.gperf +++ b/src/libAtomVM/nifs.gperf @@ -65,6 +65,7 @@ erlang:binary_to_float/1, &binary_to_float_nif erlang:binary_to_integer/1, &binary_to_integer_nif erlang:binary_to_integer/2, &binary_to_integer_nif erlang:binary_to_list/1, &binary_to_list_nif +erlang:bitstring_to_list/1, &binary_to_list_nif erlang:binary_to_existing_atom/1, &binary_to_existing_atom_1_nif erlang:delete_element/2, &delete_element_nif erlang:erase/0, &erase_0_nif @@ -133,6 +134,7 @@ erlang:put/2, &put_nif erlang:binary_to_term/1, &binary_to_term_nif erlang:binary_to_term/2, &binary_to_term_nif erlang:term_to_binary/1, &term_to_binary_nif +erlang:term_to_binary/2, &term_to_binary_nif erlang:split_binary/2, &split_binary_nif erlang:throw/1, &throw_nif erlang:raise/3, &raise_nif @@ -142,6 +144,7 @@ erlang:port_to_list/1, &port_to_list_nif erlang:ref_to_list/1, &ref_to_list_nif erlang:fun_to_list/1, &fun_to_list_nif erlang:function_exported/3, &function_exported_nif +erlang:is_builtin/3, &is_builtin_nif erlang:!/2, &send_nif erlang:garbage_collect/0, &garbage_collect_nif erlang:garbage_collect/1, &garbage_collect_nif diff --git a/tests/erlang_tests/CMakeLists.txt b/tests/erlang_tests/CMakeLists.txt index a4b0f82b08..5b68127d51 100644 --- a/tests/erlang_tests/CMakeLists.txt +++ b/tests/erlang_tests/CMakeLists.txt @@ -234,6 +234,7 @@ compile_erlang(test_abs) compile_erlang(test_is_process_alive) compile_erlang(test_is_not_type) compile_erlang(test_is_bitstring_is_binary) +compile_erlang(test_is_builtin) compile_erlang(test_badarith) compile_erlang(test_badarith2) compile_erlang(test_badarith3) @@ -271,6 +272,7 @@ compile_erlang(test_heap_growth) compile_erlang(test_system_flag) compile_erlang(test_system_info) compile_erlang(test_binary_to_term) +compile_erlang(test_bitstring_to_list) compile_erlang(test_selective_receive) compile_erlang(test_timeout_not_integer) compile_erlang(test_undef) @@ -793,6 +795,7 @@ set(erlang_test_beams test_is_process_alive.beam test_is_not_type.beam test_is_bitstring_is_binary.beam + test_is_builtin.beam test_badarith.beam test_badarith2.beam test_badarith3.beam @@ -830,6 +833,7 @@ set(erlang_test_beams test_system_flag.beam test_system_info.beam test_binary_to_term.beam + test_bitstring_to_list.beam test_selective_receive.beam test_timeout_not_integer.beam test_undef.beam diff --git a/tests/erlang_tests/test_binary_to_term.erl b/tests/erlang_tests/test_binary_to_term.erl index 17d507c4a1..80854e2d0b 100644 --- a/tests/erlang_tests/test_binary_to_term.erl +++ b/tests/erlang_tests/test_binary_to_term.erl @@ -157,8 +157,30 @@ start() -> ok = test_safe_option(), ok = test_invalid_export_fun_encoding(), ok = test_atom_utf8_ext_node(), + ok = test_term_to_binary_options(), 0. +test_term_to_binary_options() -> + T = ?MODULE:id({foo, [1, 2, 3], #{a => <<"b">>}, 3.14}), + Plain = erlang:term_to_binary(T), + Plain = erlang:term_to_binary(T, []), + Plain = erlang:term_to_binary(T, [deterministic]), + Plain = erlang:term_to_binary(T, [{minor_version, 2}, deterministic]), + T = erlang:binary_to_term(erlang:term_to_binary(T, [{minor_version, 1}])), + % compressed output may use a different encoding but must round-trip + T = erlang:binary_to_term(erlang:term_to_binary(T, [compressed])), + Big = duplicate(200, T, []), + Big = erlang:binary_to_term(erlang:term_to_binary(Big, [compressed, deterministic])), + Big = erlang:binary_to_term(erlang:term_to_binary(Big, [{compressed, 6}])), + ok = expect_badarg(fun() -> erlang:term_to_binary(T, [bad_option]) end), + ok = expect_badarg(fun() -> erlang:term_to_binary(T, not_a_list) end), + ok = expect_badarg(fun() -> erlang:term_to_binary(T, [{minor_version, 17}]) end), + ok = expect_badarg(fun() -> erlang:term_to_binary(T, [{compressed, 42}]) end), + ok. + +duplicate(0, _T, Acc) -> Acc; +duplicate(N, T, Acc) -> duplicate(N - 1, T, [T | Acc]). + test_reverse(T, Interop) -> test_reverse(T, Interop, []). diff --git a/tests/erlang_tests/test_bitstring_to_list.erl b/tests/erlang_tests/test_bitstring_to_list.erl new file mode 100644 index 0000000000..c499994199 --- /dev/null +++ b/tests/erlang_tests/test_bitstring_to_list.erl @@ -0,0 +1,50 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Paul Guyot +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +-module(test_bitstring_to_list). + +-export([start/0, id/1]). + +-define(ID(X), ?MODULE:id(X)). + +%% AtomVM does not support non-byte-aligned bitstrings, so only byte-aligned +%% binaries are exercised. This matches the compiler's use of +%% erlang:bitstring_to_list/1 (beam_core_to_ssa:pattern_bin/3), which only ever +%% passes byte-aligned binaries. +start() -> + [1, 2, 3] = erlang:bitstring_to_list(?ID(<<1, 2, 3>>)), + [] = erlang:bitstring_to_list(?ID(<<>>)), + [$a, $b, $c] = erlang:bitstring_to_list(?ID(<<"abc">>)), + Bytes = [0, 1, 2, 127, 128, 200, 254, 255], + Bytes = erlang:bitstring_to_list(?ID(<<0, 1, 2, 127, 128, 200, 254, 255>>)), + ok = raises_badarg(fun() -> erlang:bitstring_to_list(?ID([1, 2, 3])) end), + ok = raises_badarg(fun() -> erlang:bitstring_to_list(?ID(not_a_bitstring)) end), + 0. + +raises_badarg(Fun) -> + try Fun() of + Ret -> {unexpected, Ret} + catch + error:badarg -> ok; + C:E -> {unexpected, C, E} + end. + +id(X) -> + X. diff --git a/tests/erlang_tests/test_is_builtin.erl b/tests/erlang_tests/test_is_builtin.erl new file mode 100644 index 0000000000..617064c38e --- /dev/null +++ b/tests/erlang_tests/test_is_builtin.erl @@ -0,0 +1,130 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Paul Guyot +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +-module(test_is_builtin). + +-export([start/0, id/1]). + +%% AtomVM's erlang:is_builtin/3 reports whether a function is implemented +%% natively (as a BIF or NIF) *in AtomVM*, mirroring BEAM's "implemented in C" +%% semantics but against AtomVM's own registry. It therefore diverges from BEAM +%% for functions that one VM implements natively and the other implements in +%% Erlang. +%% +%% This test runs on both BEAM and AtomVM: the parity cases must agree on both +%% VMs, while known_divergences/1 asserts the *expected* per-VM answer. Running +%% it against BEAM as well keeps the documented divergences honest -- if BEAM +%% ever changes which functions it implements natively, or if AtomVM gains a +%% native implementation, the corresponding assertion here will fail and force +%% the divergence list (and the is_builtin/3 documentation in the erlang +%% module) to be revisited. +%% +%% Arguments are funneled through id/1 so the compiler cannot constant-fold the +%% calls using the host BEAM's is_builtin/3 at compile time. +start() -> + ok = builtin_in_both(), + ok = emulated_in_both(), + ok = known_divergences(erlang:system_info(machine)), + ok = not_builtin(), + ok = bad_arguments(), + 0. + +%% Functions that are native BIFs on both BEAM and AtomVM. +builtin_in_both() -> + true = ib(erlang, abs, 1), + true = ib(erlang, length, 1), + true = ib(erlang, is_atom, 1), + true = ib(erlang, byte_size, 1), + true = ib(erlang, hd, 1), + true = ib(erlang, tuple_size, 1), + true = ib(erlang, '+', 2), + true = ib(erlang, function_exported, 3), + true = ib(erlang, is_builtin, 3), + %% cross-module native functions + true = ib(lists, keyfind, 3), + ok. + +%% Functions implemented in Erlang (not native) on both BEAM and AtomVM. +emulated_in_both() -> + false = ib(lists, foldl, 3), + false = ib(lists, map, 2), + false = ib(lists, seq, 2), + false = ib(proplists, get_value, 2), + ok. + +%% Functions where AtomVM and BEAM disagree because one implements them +%% natively while the other emulates them in Erlang. Each case asserts the +%% answer *both* VMs are expected to give, so the divergence is verified rather +%% than merely documented. +known_divergences(Machine) -> + %% erlang:atom_to_binary/1: native in AtomVM, an Erlang wrapper around + %% atom_to_binary/2 on BEAM. + AtomToBinary1 = ib(erlang, atom_to_binary, 1), + %% erlang:md5/1: a native C BIF on BEAM, but on AtomVM it exists only as an + %% Erlang function delegating to crypto, so it is not a BIF. + Md5 = ib(erlang, md5, 1), + %% maps:get/2: a native BIF on BEAM, emulated in Erlang on AtomVM. + MapsGet = ib(maps, get, 2), + case Machine of + "BEAM" -> + false = AtomToBinary1, + true = Md5, + true = MapsGet, + ok; + _ -> + true = AtomToBinary1, + false = Md5, + false = MapsGet, + ok + end. + +%% Names that are not native functions anywhere. +not_builtin() -> + %% bitsize/1 is not a real function; only bit_size/1 exists. + false = ib(erlang, bitsize, 1), + %% wrong arity for an existing BIF + false = ib(erlang, abs, 2), + %% negative arity never matches + false = ib(erlang, abs, -1), + %% unknown module and unknown function + false = ib(no_such_module, no_such_function, 0), + false = ib(erlang, definitely_not_a_bif, 0), + ok. + +%% Type errors raise badarg, matching BEAM. +bad_arguments() -> + ok = expect_badarg(fun() -> ib(123, foo, 0) end), + ok = expect_badarg(fun() -> ib(erlang, 123, 0) end), + ok = expect_badarg(fun() -> ib(erlang, foo, not_an_integer) end), + ok. + +ib(Module, Function, Arity) -> + erlang:is_builtin(?MODULE:id(Module), ?MODULE:id(Function), ?MODULE:id(Arity)). + +expect_badarg(Fun) -> + try Fun() of + Ret -> {unexpected, Ret} + catch + error:badarg -> ok; + C:E -> {unexpected, C, E} + end. + +id(X) -> + X. diff --git a/tests/test.c b/tests/test.c index 342c142fd4..c6287fb543 100644 --- a/tests/test.c +++ b/tests/test.c @@ -200,6 +200,7 @@ struct Test tests[] = { TEST_CASE_EXPECTED(test_is_process_alive, 121), TEST_CASE_EXPECTED(test_is_not_type, 255), TEST_CASE(test_is_bitstring_is_binary), + TEST_CASE(test_is_builtin), TEST_CASE_EXPECTED(test_badarith, -87381), TEST_CASE_EXPECTED(test_badarith2, -87381), TEST_CASE_EXPECTED(test_badarith3, -1365), @@ -410,6 +411,7 @@ struct Test tests[] = { TEST_CASE_EXPECTED(test_ordering_0, 1), TEST_CASE_EXPECTED(test_ordering_1, 1), TEST_CASE(test_binary_to_term), + TEST_CASE(test_bitstring_to_list), TEST_CASE(test_selective_receive), TEST_CASE(test_timeout_not_integer), TEST_CASE(test_undef),