diff --git a/assets/js/erlang/erlang.mjs b/assets/js/erlang/erlang.mjs index 9d7ab01c4..e68ff6a02 100644 --- a/assets/js/erlang/erlang.mjs +++ b/assets/js/erlang/erlang.mjs @@ -599,6 +599,876 @@ const Erlang = { // End binary_to_list/1 // Deps: [] + // Start binary_to_term/1 + "binary_to_term/1": async (binary) => { + if (!Type.isBinary(binary)) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg(1, "not a binary"), + ); + } + + // ETF tag constants + const NEW_FLOAT_EXT = 70; + const BIT_BINARY_EXT = 77; + const COMPRESSED = 80; + const NEW_PID_EXT = 88; + const NEW_PORT_EXT = 89; + const NEWER_REFERENCE_EXT = 90; + const SMALL_INTEGER_EXT = 97; + const INTEGER_EXT = 98; + const FLOAT_EXT = 99; + const ATOM_EXT = 100; + const REFERENCE_EXT = 101; + const PORT_EXT = 102; + const PID_EXT = 103; + const SMALL_TUPLE_EXT = 104; + const LARGE_TUPLE_EXT = 105; + const NIL_EXT = 106; + const STRING_EXT = 107; + const LIST_EXT = 108; + const BINARY_EXT = 109; + const SMALL_BIG_EXT = 110; + const LARGE_BIG_EXT = 111; + const EXPORT_EXT = 113; + const NEW_REFERENCE_EXT = 114; + const SMALL_ATOM_EXT = 115; + const MAP_EXT = 116; + const ATOM_UTF8_EXT = 118; + const SMALL_ATOM_UTF8_EXT = 119; + const V4_PORT_EXT = 120; + + // Decompresses zlib-compressed data using native DecompressionStream API + // Returns a Promise that resolves to {data: Uint8Array, bytesRead: number} + // Throws an error if decompression fails + const zlibInflate = async (compressedData) => { + const stream = new ReadableStream({ + start(controller) { + // Ensure we are passing a standard Uint8Array to the stream + // This solves the Buffer/Uint8Array mismatch in Node.js + const data = + compressedData instanceof Uint8Array + ? compressedData + : new Uint8Array(compressedData); + + controller.enqueue(data); + controller.close(); + }, + }); + + const decompressedStream = stream.pipeThrough( + new DecompressionStream("deflate"), + ); + + const reader = decompressedStream.getReader(); + const chunks = []; + + try { + while (true) { + const {done, value} = await reader.read(); + if (done) break; + chunks.push(value); + } + } catch (err) { + // In Node, stream errors often need to be caught during the read loop + throw new Error(`Decompression failed: ${err.message}`); + } + + // NOTE: This is a simplified approach - in a full implementation, + // we would need to parse the zlib stream to determine exact bytes consumed + return { + data: Utils.concatUint8Arrays(chunks), + bytesRead: compressedData.length, + }; + }; + + const decodeTerm = async (dataView, bytes, offset) => { + if (offset >= bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + const tag = dataView.getUint8(offset); + + switch (tag) { + case COMPRESSED: + return await decodeCompressed(dataView, bytes, offset + 1); + + case SMALL_INTEGER_EXT: + return decodeSmallInteger(dataView, offset + 1); + + case INTEGER_EXT: + return decodeInteger(dataView, offset + 1); + + case SMALL_BIG_EXT: + return decodeSmallBig(dataView, bytes, offset + 1); + + case LARGE_BIG_EXT: + return decodeLargeBig(dataView, bytes, offset + 1); + + case ATOM_EXT: + return decodeAtom(dataView, bytes, offset + 1, false); + + case SMALL_ATOM_EXT: + return decodeSmallAtom(dataView, bytes, offset + 1, false); + + case ATOM_UTF8_EXT: + return decodeAtom(dataView, bytes, offset + 1, true); + + case SMALL_ATOM_UTF8_EXT: + return decodeSmallAtom(dataView, bytes, offset + 1, true); + + case BINARY_EXT: + return decodeBinary(dataView, bytes, offset + 1); + + case SMALL_TUPLE_EXT: + return await decodeSmallTuple(dataView, bytes, offset + 1); + + case LARGE_TUPLE_EXT: + return await decodeLargeTuple(dataView, bytes, offset + 1); + + case NIL_EXT: + return {term: Type.list(), newOffset: offset + 1}; + + case STRING_EXT: + return decodeString(dataView, bytes, offset + 1); + + case LIST_EXT: + return await decodeList(dataView, bytes, offset + 1); + + case MAP_EXT: + return await decodeMap(dataView, bytes, offset + 1); + + case NEW_FLOAT_EXT: + return decodeNewFloat(dataView, offset + 1); + + case FLOAT_EXT: + return decodeFloatExt(dataView, bytes, offset + 1); + + case BIT_BINARY_EXT: + return decodeBitBinary(dataView, bytes, offset + 1); + + case REFERENCE_EXT: + return await decodeReference(dataView, bytes, offset + 1); + + case NEW_REFERENCE_EXT: + return await decodeNewReference(dataView, bytes, offset + 1); + + case NEWER_REFERENCE_EXT: + return await decodeNewerReference(dataView, bytes, offset + 1); + + case PID_EXT: + return await decodePid(dataView, bytes, offset + 1); + + case NEW_PID_EXT: + return await decodeNewPid(dataView, bytes, offset + 1); + + case PORT_EXT: + return await decodePort(dataView, bytes, offset + 1); + + case NEW_PORT_EXT: + return await decodeNewPort(dataView, bytes, offset + 1); + + case V4_PORT_EXT: + return await decodeV4Port(dataView, bytes, offset + 1); + + case EXPORT_EXT: + return await decodeExport(dataView, bytes, offset + 1); + + default: + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + }; + + // Compressed term decoder + // Format: COMPRESSED (80) | UncompressedSize (4 bytes, big-endian) | ZlibCompressedData + // The decompressed data contains the ETF representation of the term without the version byte + + const decodeCompressed = async (dataView, bytes, offset) => { + if (offset + 4 > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + const uncompressedSize = dataView.getUint32(offset, false); + const compressedData = bytes.slice(offset + 4); + + try { + // Use native DecompressionStream for zlib decompression + const {data: decompressed, bytesRead} = + await zlibInflate(compressedData); + + if (decompressed.length !== uncompressedSize) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + const decompressedView = new DataView( + decompressed.buffer, + decompressed.byteOffset, + decompressed.byteLength, + ); + + const result = await decodeTerm(decompressedView, decompressed, 0); + + if (result.newOffset !== decompressed.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + // Compute newOffset relative to the original bytes buffer + const newOffset = offset + 4 + bytesRead; + + return { + term: result.term, + newOffset: newOffset, + }; + } catch { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + }; + + // Integer decoders + + const decodeSmallInteger = (dataView, offset) => { + const value = dataView.getUint8(offset); + return { + term: Type.integer(value), + newOffset: offset + 1, + }; + }; + + const decodeInteger = (dataView, offset) => { + const value = dataView.getInt32(offset); + return { + term: Type.integer(value), + newOffset: offset + 4, + }; + }; + + const decodeSmallBig = (dataView, bytes, offset) => { + const n = dataView.getUint8(offset); + const sign = dataView.getUint8(offset + 1); + + if (sign !== 0 && sign !== 1) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + let value = 0n; + for (let i = 0; i < n; i++) { + const byte = BigInt(bytes[offset + 2 + i]); + value += byte << BigInt(i * 8); + } + + if (sign === 1) { + value = -value; + } + + return { + term: Type.integer(value), + newOffset: offset + 2 + n, + }; + }; + + const decodeLargeBig = (dataView, bytes, offset) => { + const n = dataView.getUint32(offset); + const sign = dataView.getUint8(offset + 4); + + if (sign !== 0 && sign !== 1) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + let value = 0n; + for (let i = 0; i < n; i++) { + const byte = BigInt(bytes[offset + 5 + i]); + value += byte << BigInt(i * 8); + } + + if (sign === 1) { + value = -value; + } + + return { + term: Type.integer(value), + newOffset: offset + 5 + n, + }; + }; + + // Atom decoders + + const decodeAtom = (dataView, bytes, offset, isUtf8) => { + const length = dataView.getUint16(offset); + if (offset + 2 + length > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + const atomBytes = bytes.slice(offset + 2, offset + 2 + length); + + const atomString = isUtf8 + ? new TextDecoder("utf-8").decode(atomBytes) + : String.fromCharCode(...atomBytes); + + return { + term: Type.atom(atomString), + newOffset: offset + 2 + length, + }; + }; + + const decodeSmallAtom = (dataView, bytes, offset, isUtf8) => { + const length = dataView.getUint8(offset); + if (offset + 1 + length > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + const atomBytes = bytes.slice(offset + 1, offset + 1 + length); + + const decoder = new TextDecoder(isUtf8 ? "utf-8" : "latin1"); + const atomString = decoder.decode(atomBytes); + + return { + term: Type.atom(atomString), + newOffset: offset + 1 + length, + }; + }; + + // Binary decoder + + const decodeBinary = (dataView, bytes, offset) => { + const length = dataView.getUint32(offset); + if (offset + 4 + length > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + const binaryBytes = bytes.slice(offset + 4, offset + 4 + length); + + return { + term: Bitstring.fromBytes(new Uint8Array(binaryBytes)), + newOffset: offset + 4 + length, + }; + }; + + // Tuple decoders + + const decodeSmallTuple = async (dataView, bytes, offset) => { + const arity = dataView.getUint8(offset); + const elements = []; + let currentOffset = offset + 1; + + for (let i = 0; i < arity; i++) { + const result = await decodeTerm(dataView, bytes, currentOffset); + elements.push(result.term); + currentOffset = result.newOffset; + } + + return { + term: Type.tuple(elements), + newOffset: currentOffset, + }; + }; + + const decodeLargeTuple = async (dataView, bytes, offset) => { + const arity = dataView.getUint32(offset); + const elements = []; + let currentOffset = offset + 4; + + for (let i = 0; i < arity; i++) { + const result = await decodeTerm(dataView, bytes, currentOffset); + elements.push(result.term); + currentOffset = result.newOffset; + } + + return { + term: Type.tuple(elements), + newOffset: currentOffset, + }; + }; + + // List decoders + + const decodeString = (dataView, bytes, offset) => { + const length = dataView.getUint16(offset); + if (offset + 2 + length > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + const elements = []; + + for (let i = 0; i < length; i++) { + const byte = bytes[offset + 2 + i]; + elements.push(Type.integer(byte)); + } + + return { + term: Type.list(elements), + newOffset: offset + 2 + length, + }; + }; + + const decodeList = async (dataView, bytes, offset) => { + const length = dataView.getUint32(offset); + const elements = []; + let currentOffset = offset + 4; + + for (let i = 0; i < length; i++) { + const result = await decodeTerm(dataView, bytes, currentOffset); + elements.push(result.term); + currentOffset = result.newOffset; + } + + // Decode the tail + const tailResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = tailResult.newOffset; + + // If tail is a list, merge it to preserve proper list semantics + if (Type.isList(tailResult.term)) { + const merged = elements.concat(tailResult.term.data); + + return Type.isProperList(tailResult.term) + ? {term: Type.list(merged), newOffset: currentOffset} + : {term: Type.improperList(merged), newOffset: currentOffset}; + } + + // Otherwise, it's an improper list + elements.push(tailResult.term); + return { + term: Type.improperList(elements), + newOffset: currentOffset, + }; + }; + + // Map decoder + + const decodeMap = async (dataView, bytes, offset) => { + const arity = dataView.getUint32(offset); + const entries = []; + let currentOffset = offset + 4; + + for (let i = 0; i < arity; i++) { + const keyResult = await decodeTerm(dataView, bytes, currentOffset); + const valueResult = await decodeTerm( + dataView, + bytes, + keyResult.newOffset, + ); + + entries.push([keyResult.term, valueResult.term]); + currentOffset = valueResult.newOffset; + } + + return { + term: Type.map(entries), + newOffset: currentOffset, + }; + }; + + // Float decoders + + const decodeNewFloat = (dataView, offset) => { + const value = dataView.getFloat64(offset); + return { + term: Type.float(value), + newOffset: offset + 8, + }; + }; + + const decodeFloatExt = (dataView, bytes, offset) => { + // FLOAT_EXT: 31-byte null-terminated string (deprecated format) + if (offset + 31 > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + const floatBytes = bytes.slice(offset, offset + 31); + const decoder = new TextDecoder("latin1"); + const floatString = decoder.decode(floatBytes).replace(/\0.*$/, ""); + const value = parseFloat(floatString); + + if (Number.isNaN(value)) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + return { + term: Type.float(value), + newOffset: offset + 31, + }; + }; + + // Bitstring decoder + + const decodeBitBinary = (dataView, bytes, offset) => { + const length = dataView.getUint32(offset); + const bits = dataView.getUint8(offset + 4); + + if (bits < 1 || bits > 8) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + if (offset + 5 + length > bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + const binaryBytes = bytes.slice(offset + 5, offset + 5 + length); + const bitstring = Bitstring.fromBytes(new Uint8Array(binaryBytes)); + + // Adjust leftoverBitCount based on the bits field + // If bits is 8, all bytes are full (leftoverBitCount = 0) + // If bits is 1-7, the last byte is partial (leftoverBitCount = bits) + if (bits > 0 && bits < 8) { + bitstring.leftoverBitCount = bits; + } + // If bits is 0 or 8, leftoverBitCount is already 0 (all full bytes) + + return { + term: bitstring, + newOffset: offset + 5 + length, + }; + }; + + // Reference decoders + // + // REFERENCE_EXT (tag 101) - Deprecated format for backward compatibility + // Format: Node | ID | Creation + // Where: + // - Node: atom (encoded with various atom formats) + // - ID: single 32-bit word + // - Creation: 8-bit value (only 2 bits significant) + // + // This format was used in older Erlang versions and is kept for + // compatibility with legacy external term format data. + + // Common reference decoder helper + const decodeReferenceWithOptions = async ( + dataView, + bytes, + offset, + options, + ) => { + let currentOffset = offset; + + // Read length prefix if present + let idWordCount = 1; // Default for REFERENCE_EXT + if (options.hasLengthPrefix) { + idWordCount = dataView.getUint16(currentOffset); + currentOffset += 2; + } + + // Decode node name (atom) + const nodeResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = nodeResult.newOffset; + + // For REFERENCE_EXT: read ID words first, then creation + // For NEW/NEWER_REFERENCE_EXT: read creation first, then ID words + let creation, idWords; + + if (options.hasLengthPrefix) { + // NEW_REFERENCE_EXT and NEWER_REFERENCE_EXT: Creation | ID words + creation = + options.creationSize === 4 + ? dataView.getUint32(currentOffset) + : dataView.getUint8(currentOffset); + currentOffset += options.creationSize; + + idWords = []; + for (let i = 0; i < idWordCount; i++) { + idWords.push(dataView.getUint32(currentOffset)); + currentOffset += 4; + } + } else { + // REFERENCE_EXT: ID | Creation + idWords = []; + for (let i = 0; i < idWordCount; i++) { + idWords.push(dataView.getUint32(currentOffset)); + currentOffset += 4; + } + + creation = dataView.getUint8(currentOffset); + currentOffset += 1; + } + + return { + term: Type.reference(nodeResult.term, creation, idWords), + newOffset: currentOffset, + }; + }; + + const decodeReference = async (dataView, bytes, offset) => { + return await decodeReferenceWithOptions(dataView, bytes, offset, { + hasLengthPrefix: false, + creationSize: 1, + }); + }; + + const decodeNewReference = async (dataView, bytes, offset) => { + return await decodeReferenceWithOptions(dataView, bytes, offset, { + hasLengthPrefix: true, + creationSize: 1, + }); + }; + + const decodeNewerReference = async (dataView, bytes, offset) => { + return await decodeReferenceWithOptions(dataView, bytes, offset, { + hasLengthPrefix: true, + creationSize: 4, + }); + }; + + // PID decoders + + const decodePid = async (dataView, bytes, offset) => { + let currentOffset = offset; + + // Decode node name (atom) + const nodeResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = nodeResult.newOffset; + + // Read ID (4 bytes), Serial (4 bytes), Creation (1 byte) + const id = dataView.getUint32(currentOffset); + currentOffset += 4; + + const serial = dataView.getUint32(currentOffset); + currentOffset += 4; + + const creation = dataView.getUint8(currentOffset); + currentOffset += 1; + + return { + term: Type.pid(nodeResult.term, [id, serial, creation]), + newOffset: currentOffset, + }; + }; + + const decodeNewPid = async (dataView, bytes, offset) => { + let currentOffset = offset; + + // Decode node name (atom) + const nodeResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = nodeResult.newOffset; + + // Read ID (4 bytes), Serial (4 bytes), Creation (4 bytes) + const id = dataView.getUint32(currentOffset); + currentOffset += 4; + + const serial = dataView.getUint32(currentOffset); + currentOffset += 4; + + const creation = dataView.getUint32(currentOffset); + currentOffset += 4; + + return { + term: Type.pid(nodeResult.term, [id, serial, creation]), + newOffset: currentOffset, + }; + }; + + // Port decoders + + const decodePort = async (dataView, bytes, offset) => { + let currentOffset = offset; + + // Decode node name (atom) + const nodeResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = nodeResult.newOffset; + + // Read ID (4 bytes), Creation (1 byte) + const id = dataView.getUint32(currentOffset); + currentOffset += 4; + + const creation = dataView.getUint8(currentOffset); + currentOffset += 1; + + return { + term: Type.port(nodeResult.term, [id, creation]), + newOffset: currentOffset, + }; + }; + + const decodeNewPort = async (dataView, bytes, offset) => { + let currentOffset = offset; + + // Decode node name (atom) + const nodeResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = nodeResult.newOffset; + + // Read ID (4 bytes), Creation (4 bytes) + const id = dataView.getUint32(currentOffset); + currentOffset += 4; + + const creation = dataView.getUint32(currentOffset); + currentOffset += 4; + + return { + term: Type.port(nodeResult.term, [id, creation]), + newOffset: currentOffset, + }; + }; + + const decodeV4Port = async (dataView, bytes, offset) => { + let currentOffset = offset; + + // Decode node name (atom) + const nodeResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = nodeResult.newOffset; + + // Read ID (8 bytes as BigUint64), Creation (4 bytes) + const id = dataView.getBigUint64(currentOffset); + currentOffset += 8; + + const creation = dataView.getUint32(currentOffset); + currentOffset += 4; + + return { + term: Type.port(nodeResult.term, [id, creation]), + newOffset: currentOffset, + }; + }; + + // EXPORT_EXT decoder (function capture) + const decodeExport = async (dataView, bytes, offset) => { + let currentOffset = offset; + + // Decode module (atom) + const moduleResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = moduleResult.newOffset; + + // Decode function (atom) + const functionResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = functionResult.newOffset; + + // Decode arity (small integer) + const arityResult = await decodeTerm(dataView, bytes, currentOffset); + currentOffset = arityResult.newOffset; + + const context = Interpreter.buildContext(); + + // Convert arity from BigInt to Number + const arity = Number(arityResult.term.value); + + return { + term: Type.functionCapture( + moduleResult.term.value, + functionResult.term.value, + arity, + [], + context, + ), + newOffset: currentOffset, + }; + }; + + try { + Bitstring.maybeSetBytesFromText(binary); + + const bytes = binary.bytes; + const dataView = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + + // Check ETF version byte (must be 131) + if (dataView.getUint8(0) !== 131) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + + const result = await decodeTerm(dataView, bytes, 1); + if (result.newOffset !== bytes.length) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + return result.term; + } catch (err) { + if (err instanceof RangeError || err instanceof TypeError) { + Interpreter.raiseArgumentError( + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + } + throw err; + } + }, + // End binary_to_term/1 + // Deps: [] + // Start bit_size/1 "bit_size/1": (term) => { if (!Type.isBitstring(term)) { diff --git a/test/elixir/hologram/ex_js_consistency/erlang/erlang_test.exs b/test/elixir/hologram/ex_js_consistency/erlang/erlang_test.exs index 41ecdfaab..10cc26664 100644 --- a/test/elixir/hologram/ex_js_consistency/erlang/erlang_test.exs +++ b/test/elixir/hologram/ex_js_consistency/erlang/erlang_test.exs @@ -1946,6 +1946,553 @@ defmodule Hologram.ExJsConsistency.Erlang.ErlangTest do end end + describe "binary_to_term/1" do + test "decodes small positive integer (SMALL_INTEGER_EXT)" do + binary = :erlang.term_to_binary(42) + assert :erlang.binary_to_term(binary) == 42 + end + + test "decodes small positive integer (max value 255)" do + binary = :erlang.term_to_binary(255) + assert :erlang.binary_to_term(binary) == 255 + end + + test "decodes positive integer (INTEGER_EXT)" do + binary = :erlang.term_to_binary(1000) + assert :erlang.binary_to_term(binary) == 1000 + end + + test "decodes negative integer (INTEGER_EXT)" do + binary = :erlang.term_to_binary(-100) + assert :erlang.binary_to_term(binary) == -100 + end + + test "decodes large positive integer (SMALL_BIG_EXT)" do + binary = :erlang.term_to_binary(1_000_000_000_000) + assert :erlang.binary_to_term(binary) == 1_000_000_000_000 + end + + test "decodes large negative integer (SMALL_BIG_EXT)" do + binary = :erlang.term_to_binary(-1_000_000_000_000) + assert :erlang.binary_to_term(binary) == -1_000_000_000_000 + end + + test "decodes large positive integer (LARGE_BIG_EXT)" do + # LARGE_BIG_EXT requires integers with >255 bytes + + # 2^2048 requires 257 bytes, triggering LARGE_BIG_EXT encoding + large_int = trunc(Integer.pow(2, 2048)) + + binary = :erlang.term_to_binary(large_int) + assert :erlang.binary_to_term(binary) == large_int + end + + test "decodes atom UTF-8 (ATOM_UTF8_EXT)" do + name = "élixir" + binary = <<131, 118, byte_size(name)::16, name::binary>> + assert :erlang.binary_to_term(binary) == :élixir + end + + test "decodes UTF-8 atom (SMALL_ATOM_UTF8_EXT)" do + binary = :erlang.term_to_binary(:test) + assert :erlang.binary_to_term(binary) == :test + end + + test "decodes atom (ATOM_EXT)" do + name = "elixir" + binary = <<131, 100, byte_size(name)::16, name::binary>> + assert :erlang.binary_to_term(binary) == :elixir + end + + test "decodes small atom (SMALL_ATOM_EXT)" do + name = "elixir" + binary = <<131, 115, byte_size(name)::8, name::binary>> + assert :erlang.binary_to_term(binary) == :elixir + end + + test "decodes longer atom" do + binary = :erlang.term_to_binary(:test_atom) + assert :erlang.binary_to_term(binary) == :test_atom + end + + test "decodes true atom" do + binary = :erlang.term_to_binary(true) + assert :erlang.binary_to_term(binary) == true + end + + test "decodes false atom" do + binary = :erlang.term_to_binary(false) + assert :erlang.binary_to_term(binary) == false + end + + test "decodes binary string (BINARY_EXT)" do + binary = :erlang.term_to_binary("hello") + assert :erlang.binary_to_term(binary) == "hello" + end + + test "decodes empty binary" do + binary = :erlang.term_to_binary("") + assert :erlang.binary_to_term(binary) == "" + end + + test "decodes small tuple (SMALL_TUPLE_EXT)" do + binary = :erlang.term_to_binary({1, 2, 3}) + assert :erlang.binary_to_term(binary) == {1, 2, 3} + end + + test "decodes large tuple (LARGE_TUPLE_EXT)" do + arity = 300 + + tuple = + 1..arity + |> Enum.to_list() + |> List.to_tuple() + + binary = :erlang.term_to_binary(tuple) + result = :erlang.binary_to_term(binary) + + assert is_tuple(result) + assert tuple_size(result) == arity + assert elem(result, 0) == 1 + end + + test "decodes empty tuple" do + binary = :erlang.term_to_binary({}) + assert :erlang.binary_to_term(binary) == {} + end + + test "decodes nested tuple" do + binary = :erlang.term_to_binary({1, {2, 3}}) + assert :erlang.binary_to_term(binary) == {1, {2, 3}} + end + + test "decodes empty list (NIL_EXT)" do + binary = :erlang.term_to_binary([]) + assert :erlang.binary_to_term(binary) == [] + end + + test "decodes string list (STRING_EXT)" do + binary = :erlang.term_to_binary([1, 2, 3]) + assert :erlang.binary_to_term(binary) == [1, 2, 3] + end + + test "decodes proper list (LIST_EXT)" do + binary = :erlang.term_to_binary([100, 200, 300]) + assert :erlang.binary_to_term(binary) == [100, 200, 300] + end + + test "decodes improper list (LIST_EXT)" do + binary = :erlang.term_to_binary([1 | [2 | [3 | 4]]]) + assert :erlang.binary_to_term(binary) == [1 | [2 | [3 | 4]]] + end + + test "decodes empty map" do + binary = :erlang.term_to_binary(%{}) + assert :erlang.binary_to_term(binary) == %{} + end + + test "decodes map with atom keys" do + binary = :erlang.term_to_binary(%{a: 1, b: 2}) + assert :erlang.binary_to_term(binary) == %{a: 1, b: 2} + end + + test "decodes NEW_FLOAT_EXT (IEEE 754 double)" do + binary = :erlang.term_to_binary(3.14159) + assert :erlang.binary_to_term(binary) == 3.14159 + end + + test "decodes NEW_FLOAT_EXT for negative float" do + binary = :erlang.term_to_binary(-2.5) + assert :erlang.binary_to_term(binary) == -2.5 + end + + test "decodes NEW_FLOAT_EXT for zero" do + binary = :erlang.term_to_binary(0.0) + assert :erlang.binary_to_term(binary) == 0.0 + end + + test "decodes FLOAT_EXT (deprecated string format)" do + # Manually construct FLOAT_EXT binary + iodata = :io_lib.format("~.20e", [1.5]) + float_str = IO.iodata_to_binary(iodata) + padded = String.pad_trailing(float_str, 31, <<0>>) + binary = <<131, 99, padded::binary>> + + assert :erlang.binary_to_term(binary) == 1.5 + end + + test "decodes BIT_BINARY_EXT with partial byte" do + # Bitstring with 5 bits + bitstring = <<1::5>> + binary = :erlang.term_to_binary(bitstring) + assert :erlang.binary_to_term(binary) == bitstring + end + + test "decodes BIT_BINARY_EXT with full bytes" do + bitstring = <<255, 0>> + binary = :erlang.term_to_binary(bitstring) + assert :erlang.binary_to_term(binary) == bitstring + end + + test "decodes REFERENCE_EXT" do + binary = + <<131, 101, 119, 13, "nonode@nohost", 0, 0, 0, 42, 1>> + + decoded = :erlang.binary_to_term(binary) + assert is_reference(decoded) + end + + test "decodes NEW_REFERENCE_EXT" do + # Create a reference and encode/decode it + ref = make_ref() + binary = :erlang.term_to_binary(ref) + decoded = :erlang.binary_to_term(binary) + assert is_reference(decoded) + end + + test "decodes NEW_REFERENCE_EXT with ATOM_EXT" do + binary = + <<131, 114, 0, 3, 100, 0, 13, "nonode@nohost", 1, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3>> + + decoded = :erlang.binary_to_term(binary) + assert is_reference(decoded) + end + + test "decodes NEWER_REFERENCE_EXT with SMALL_ATOM_UTF8_EXT" do + # Manually construct NEWER_REFERENCE_EXT binary + binary = + <<131, 90, 0, 3, 119, 13, "nonode@nohost", 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, + 3>> + + decoded = :erlang.binary_to_term(binary) + assert is_reference(decoded) + end + + test "decodes NEWER_REFERENCE_EXT with ATOM_EXT" do + # Manually construct NEWER_REFERENCE_EXT binary with ATOM_EXT node + binary = + <<131, 90, 0, 3, 100, 0, 13, "nonode@nohost", 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, + 3>> + + decoded = :erlang.binary_to_term(binary) + assert is_reference(decoded) + end + + test "decodes PID_EXT" do + # Manually construct PID_EXT binary + # 131 - VERSION, 103 - PID_EXT, then node atom, ID, Serial, Creation + binary = + <<131, 103, 119, 13, "nonode@nohost", 0, 0, 0, 100, 0, 0, 0, 50, 0>> + + decoded = :erlang.binary_to_term(binary) + assert is_pid(decoded) + end + + test "decodes NEW_PID_EXT" do + # Create a PID and encode/decode it + pid = self() + binary = :erlang.term_to_binary(pid) + decoded = :erlang.binary_to_term(binary) + assert is_pid(decoded) + end + + test "decodes PORT_EXT" do + # Manually construct PORT_EXT binary + # 131 - VERSION, 102 - PORT_EXT, then node atom, ID, Creation + binary = <<131, 102, 119, 13, "nonode@nohost", 0, 0, 0, 5, 0>> + + decoded = :erlang.binary_to_term(binary) + assert is_port(decoded) + end + + test "decodes NEW_PORT_EXT" do + # Manually construct NEW_PORT_EXT binary + # 131 - VERSION, 89 - NEW_PORT_EXT, then node atom, ID (4 bytes), Creation (4 bytes) + binary = <<131, 89, 119, 13, "nonode@nohost", 0, 0, 0, 5, 0, 0, 0, 0>> + + decoded = :erlang.binary_to_term(binary) + assert is_port(decoded) + end + + test "decodes V4_PORT_EXT" do + # Create a port and encode/decode it (modern Erlang uses V4_PORT_EXT) + port = hd(Port.list()) + binary = :erlang.term_to_binary(port) + decoded = :erlang.binary_to_term(binary) + assert is_port(decoded) + end + + test "decodes EXPORT_EXT (function capture)" do + # Create a function capture and encode/decode it + fun = &Enum.map/2 + binary = :erlang.term_to_binary(fun) + decoded = :erlang.binary_to_term(binary) + assert is_function(decoded, 2) + end + + test "decodes Code.fetch_docs/1 style tuple" do + term = {:docs_v1, 1, :elixir, "text/markdown", %{}, %{}, []} + binary = :erlang.term_to_binary(term) + assert :erlang.binary_to_term(binary) == term + end + + test "decodes complex nested structure" do + term = + {:docs_v1, 1, :elixir, "text/markdown", %{"en" => "Module docs"}, %{since: "1.0.0"}, + [{{:function, :my_func, 2}, 10, ["signature"], %{}, %{}}]} + + binary = :erlang.term_to_binary(term) + assert :erlang.binary_to_term(binary) == term + end + + test "decodes compressed term (COMPRESSED tag 80) with repeated string" do + # Real Erlang-generated compressed term for String.duplicate("hello", 100) + # Generated with: :erlang.term_to_binary(String.duplicate("hello", 100), [compressed: 9]) + binary = + <<131, 80, 0, 0, 1, 249, 120, 218, 203, 101, 96, 96, 252, 146, 145, 154, 147, 147, 63, 74, + 140, 40, 2, 0, 21, 94, 209, 51>> + + expected = String.duplicate("hello", 100) + assert :erlang.binary_to_term(binary) == expected + end + + test "decodes compressed term with list of tuples" do + # Real Erlang-generated compressed term for a list of 50 identical tuples + # Generated with: :erlang.term_to_binary(List.duplicate({:ok, 42}, 50), [compressed: 9]) + binary = + <<131, 80, 0, 0, 1, 150, 120, 218, 203, 97, 96, 96, 48, 202, 96, 42, 103, 202, 207, 78, + 212, 26, 165, 7, 7, 157, 5, 0, 189, 136, 115, 25>> + + expected = List.duplicate({:ok, 42}, 50) + assert :erlang.binary_to_term(binary) == expected + end + + test "decodes compressed term round-trip" do + # Test that we can decode Erlang's compressed format + term = %{ + data: List.duplicate({:item, "value", 123}, 100), + metadata: %{compressed: true, version: 1} + } + + binary = :erlang.term_to_binary(term, compressed: 9) + assert :erlang.binary_to_term(binary) == term + end + + test "raises ArgumentError for compressed term with truncated uncompressed size" do + # COMPRESSED tag but missing uncompressed size bytes + binary = <<131, 80, 0, 0>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError for compressed term with invalid zlib data" do + # COMPRESSED tag with invalid compressed data + binary = <<131, 80, 0, 0, 0, 2, 255, 255, 255>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError for compressed term with size mismatch" do + # COMPRESSED tag where decompressed size doesn't match declared size + binary = <<131, 80, 0, 0, 0, 10, 120, 156, 74, 180, 2, 0, 0, 121, 0, 121>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError if argument is not a binary" do + assert_error ArgumentError, + build_argument_error_msg(1, "not a binary"), + {:erlang, :binary_to_term, [:test]} + end + + test "raises ArgumentError if argument is a non-binary bitstring" do + assert_error ArgumentError, + build_argument_error_msg(1, "not a binary"), + {:erlang, :binary_to_term, [<<1::1, 0::1, 1::1>>]} + end + + test "raises ArgumentError if binary has invalid version byte" do + # Wrong version byte (130 instead of 131) + binary = <<130, 97, 42>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError if binary is truncated" do + # Only version byte, no data + binary = <<131>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError for FLOAT_EXT with truncated data" do + # FLOAT_EXT (tag 99) with only 10 bytes instead of required 31 + binary = <<131, 99, "1.23", 0, 0, 0, 0, 0, 0>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError for malformed SMALL_ATOM_UTF8_EXT with length exceeding data" do + # SMALL_ATOM_UTF8_EXT (119) with length 50 but only 2 bytes + binary = <<131, 119, 50, 65, 66>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError for malformed ATOM_EXT with length exceeding data" do + # ATOM_EXT (100) with length 100 but only 2 bytes + binary = <<131, 100, 0, 100, 65, 66>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + test "raises ArgumentError for malformed STRING_EXT with length exceeding data" do + # STRING_EXT (107) with length 100 but only 2 bytes of data + binary = <<131, 107, 0, 100, 65, 66>> + + assert_error ArgumentError, + build_argument_error_msg(1, "invalid external representation of a term"), + {:erlang, :binary_to_term, [binary]} + end + + # Consistency tests for specific external term formats + + test "STRING_EXT format with various byte values" do + # Test STRING_EXT (tag 107) with different byte ranges + test_cases = [ + [0, 1, 2], + [255, 254, 253], + # ABC + [65, 66, 67], + [128, 129, 130], + # empty string + [] + ] + + for bytes <- test_cases do + binary = :erlang.term_to_binary(bytes) + assert :erlang.binary_to_term(binary) == bytes + end + end + + test "ATOM_EXT format with Latin-1 atoms" do + # Test ATOM_EXT (tag 100) with various atom names + test_atoms = [ + :test, + :hello_world, + :a, + # empty atom + :"", + :"special-atom!@#", + :atom_with_underscores_123 + ] + + for atom <- test_atoms do + # Manually construct ATOM_EXT binary + name = Atom.to_string(atom) + binary = <<131, 100, byte_size(name)::16, name::binary>> + assert :erlang.binary_to_term(binary) == atom + end + end + + test "SMALL_ATOM_UTF8_EXT format with UTF-8 atoms" do + # Test SMALL_ATOM_UTF8_EXT (tag 119) with UTF-8 characters + test_atoms = [ + :élixir, + :café, + :测试, + :"🚀", + :ñoño, + # regular ASCII for comparison + :test + ] + + for atom <- test_atoms do + binary = :erlang.term_to_binary(atom) + assert :erlang.binary_to_term(binary) == atom + end + end + + test "BINARY_EXT format with various binary data" do + # Test BINARY_EXT (tag 109) with different binary content + test_binaries = [ + "", + "hello", + "world with spaces", + "binary\0with\0nulls", + "UTF-8: élixir café 测试 🚀", + <<0, 1, 2, 255, 254, 253>>, + # larger binary + String.duplicate("x", 1000) + ] + + for bin <- test_binaries do + binary = :erlang.term_to_binary(bin) + assert :erlang.binary_to_term(binary) == bin + end + end + + test "BIT_BINARY_EXT format with partial bytes" do + # Test BIT_BINARY_EXT (tag 77) with various bit lengths + test_bitstrings = [ + <<1::1>>, + <<1::3>>, + <<15::4>>, + <<31::5>>, + # full byte + <<255::8>>, + # mixed bytes and bits + <<1, 2, 3::6>>, + # multiple bytes + 1 bit + <<255, 255, 1::1>> + ] + + for bitstring <- test_bitstrings do + binary = :erlang.term_to_binary(bitstring) + assert :erlang.binary_to_term(binary) == bitstring + end + end + + test "FLOAT_EXT format (deprecated string format)" do + # Test FLOAT_EXT (tag 99) - manually constructed deprecated format + test_floats = [ + 1.5, + -2.5, + 3.14159, + 0.0, + -0.0, + 1.0e10, + 1.23e-5 + ] + + for float_val <- test_floats do + # Manually construct FLOAT_EXT binary (31-byte null-terminated string) + iodata = :io_lib.format("~.20e", [float_val]) + float_str = IO.iodata_to_binary(iodata) + padded = String.pad_trailing(float_str, 31, <<0>>) + binary = <<131, 99, padded::binary>> + + decoded = :erlang.binary_to_term(binary) + assert_in_delta decoded, float_val, 1.0e-15 + end + end + end + describe "bit_size/1" do test "bitstring" do assert :erlang.bit_size(<<2::7>>) == 7 diff --git a/test/javascript/erlang/erlang_test.mjs b/test/javascript/erlang/erlang_test.mjs index 50eed6368..c3f0685bd 100644 --- a/test/javascript/erlang/erlang_test.mjs +++ b/test/javascript/erlang/erlang_test.mjs @@ -3,6 +3,7 @@ import { assert, assertBoxedError, + assertBoxedErrorAsync, assertBoxedFalse, assertBoxedStrictEqual, assertBoxedTrue, @@ -2753,6 +2754,1218 @@ describe("Erlang", () => { }); }); + describe("binary_to_term/1", () => { + const binary_to_term = Erlang["binary_to_term/1"]; + + describe("integers", () => { + it("decodes small positive integer (SMALL_INTEGER_EXT)", async () => { + // :erlang.term_to_binary(42) = <<131, 97, 42>> + const binary = Bitstring.fromBytes(new Uint8Array([131, 97, 42])); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(42)); + }); + + it("decodes small positive integer (max value 255)", async () => { + // :erlang.term_to_binary(255) = <<131, 97, 255>> + const binary = Bitstring.fromBytes(new Uint8Array([131, 97, 255])); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(255)); + }); + + it("decodes positive integer (INTEGER_EXT)", async () => { + // :erlang.term_to_binary(1000) = <<131, 98, 0, 0, 3, 232>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 98, 0, 0, 3, 232]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(1000)); + }); + + it("decodes negative integer (INTEGER_EXT)", async () => { + // :erlang.term_to_binary(-100) = <<131, 98, 255, 255, 255, 156>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 98, 255, 255, 255, 156]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(-100)); + }); + + it("decodes large positive integer (SMALL_BIG_EXT)", async () => { + // :erlang.term_to_binary(1000000000000) = <<131, 110, 5, 0, 0, 16, 165, 212, 232>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 110, 5, 0, 0, 16, 165, 212, 232]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(1000000000000n)); + }); + + it("decodes large negative integer (SMALL_BIG_EXT)", async () => { + // :erlang.term_to_binary(-1000000000000) = <<131, 110, 5, 1, 0, 16, 165, 212, 232>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 110, 5, 1, 0, 16, 165, 212, 232]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(-1000000000000n)); + }); + + it("decodes large positive integer (LARGE_BIG_EXT)", async () => { + // 2^2048 requires 257 bytes (bit 2048 is in byte 256), which requires LARGE_BIG_EXT (SMALL_BIG_EXT max is 255 bytes) + // Format: [131, 111, length(4 bytes BE), sign(1 byte), data bytes(LE)] + const bytes = new Uint8Array(1 + 1 + 4 + 1 + 257); + bytes[0] = 131; + bytes[1] = 111; + bytes[2] = 0; // Length: 257 (0x00000101 in big-endian) + bytes[3] = 0; + bytes[4] = 1; + bytes[5] = 1; + bytes[6] = 0; // Sign: 0 (positive) + // 2^2048 in little-endian: byte 256 (index 7+256=263) has bit 0 set (0x01), all others are 0x00 + bytes[263] = 1; // byte at index 7 + 256 = 263 + + const binary = Bitstring.fromBytes(bytes); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.integer(2n ** 2048n)); + }); + }); + + describe("atoms", () => { + it("decodes atom UTF-8 (ATOM_UTF8_EXT)", async () => { + // élixir as UTF-8 atom using ATOM_UTF8_EXT (tag 118) + const name = "élixir"; + const nameBytes = new TextEncoder().encode(name); + // ATOM_UTF8_EXT uses 16-bit big-endian length + const lengthHigh = (nameBytes.length >> 8) & 0xff; + const lengthLow = nameBytes.length & 0xff; + const binary = Bitstring.fromBytes( + new Uint8Array([131, 118, lengthHigh, lengthLow, ...nameBytes]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom(name)); + }); + + it("decodes UTF-8 atom (SMALL_ATOM_UTF8_EXT)", async () => { + // :erlang.term_to_binary(:test) = <<131, 119, 4, 116, 101, 115, 116>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 119, 4, 116, 101, 115, 116]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom("test")); + }); + + it("decodes atom (ATOM_EXT)", async () => { + // elixir as Latin-1 atom using ATOM_EXT (tag 100) + const name = "elixir"; + const nameBytes = new TextEncoder().encode(name); + // ATOM_EXT uses 16-bit big-endian length + const lengthHigh = (nameBytes.length >> 8) & 0xff; + const lengthLow = nameBytes.length & 0xff; + const binary = Bitstring.fromBytes( + new Uint8Array([131, 100, lengthHigh, lengthLow, ...nameBytes]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom(name)); + }); + + it("decodes small atom (SMALL_ATOM_EXT)", async () => { + // elixir as Latin-1 atom using SMALL_ATOM_EXT (tag 115) + const name = "elixir"; + const nameBytes = new TextEncoder().encode(name); + const binary = Bitstring.fromBytes( + new Uint8Array([131, 115, nameBytes.length, ...nameBytes]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom(name)); + }); + + it("decodes longer atom", async () => { + // :erlang.term_to_binary(:test_atom) = <<131, 119, 9, 116, 101, 115, 116, 95, 97, 116, 111, 109>> + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 119, 9, 116, 101, 115, 116, 95, 97, 116, 111, 109, + ]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom("test_atom")); + }); + + it("decodes true atom", async () => { + // :erlang.term_to_binary(true) = <<131, 119, 4, 116, 114, 117, 101>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 119, 4, 116, 114, 117, 101]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom("true")); + }); + + it("decodes false atom", async () => { + // :erlang.term_to_binary(false) = <<131, 119, 5, 102, 97, 108, 115, 101>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 119, 5, 102, 97, 108, 115, 101]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.atom("false")); + }); + }); + + describe("binaries", () => { + it("decodes binary string (BINARY_EXT)", async () => { + // :erlang.term_to_binary("hello") = <<131, 109, 0, 0, 0, 5, 104, 101, 108, 108, 111>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 109, 0, 0, 0, 5, 104, 101, 108, 108, 111]), + ); + const result = await binary_to_term(binary); + assertBoxedStrictEqual(result, Type.bitstring("hello")); + }); + + it("decodes empty binary", async () => { + // :erlang.term_to_binary("") = <<131, 109, 0, 0, 0, 0>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 109, 0, 0, 0, 0]), + ); + const result = await binary_to_term(binary); + assertBoxedStrictEqual(result, Type.bitstring("")); + }); + }); + + describe("tuples", () => { + it("decodes small tuple (SMALL_TUPLE_EXT)", async () => { + // :erlang.term_to_binary({1, 2, 3}) = <<131, 104, 3, 97, 1, 97, 2, 97, 3>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 104, 3, 97, 1, 97, 2, 97, 3]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual( + result, + Type.tuple([Type.integer(1), Type.integer(2), Type.integer(3)]), + ); + }); + + it("decodes large tuple (LARGE_TUPLE_EXT)", async () => { + // Create a tuple with 300 elements using LARGE_TUPLE_EXT (tag 105) + const arity = 300; + // Header: version (131) + LARGE_TUPLE_EXT tag (105) + arity as 32-bit big-endian + const arityBytes = [ + (arity >> 24) & 0xff, + (arity >> 16) & 0xff, + (arity >> 8) & 0xff, + arity & 0xff, + ]; + const header = new Uint8Array([131, 105, ...arityBytes]); + + // Create element bytes: use SMALL_INTEGER_EXT (tag 97) for values <=255, + // INTEGER_EXT (tag 98) for values >255 + const elementBytes = []; + for (let i = 1; i <= arity; i++) { + if (i <= 255) { + elementBytes.push(97, i); // SMALL_INTEGER_EXT + value + } else { + // INTEGER_EXT (tag 98) + 4 bytes big-endian signed integer + elementBytes.push( + 98, + (i >> 24) & 0xff, + (i >> 16) & 0xff, + (i >> 8) & 0xff, + i & 0xff, + ); + } + } + + const binary = Bitstring.fromBytes( + new Uint8Array([...header, ...elementBytes]), + ); + const result = await binary_to_term(binary); + + assert.strictEqual(Type.isTuple(result), true); + assert.strictEqual(result.data.length, arity); + assert.deepStrictEqual(result.data[0], Type.integer(1)); + assert.deepStrictEqual(result.data[255], Type.integer(256)); // Verify no wraparound + assert.deepStrictEqual(result.data[299], Type.integer(300)); // Verify last element + }); + + it("decodes empty tuple", async () => { + // :erlang.term_to_binary({}) = <<131, 104, 0>> + const binary = Bitstring.fromBytes(new Uint8Array([131, 104, 0])); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.tuple([])); + }); + + it("decodes nested tuple", async () => { + // :erlang.term_to_binary({1, {2, 3}}) = <<131, 104, 2, 97, 1, 104, 2, 97, 2, 97, 3>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 104, 2, 97, 1, 104, 2, 97, 2, 97, 3]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual( + result, + Type.tuple([ + Type.integer(1), + Type.tuple([Type.integer(2), Type.integer(3)]), + ]), + ); + }); + }); + + describe("lists", () => { + it("decodes empty list (NIL_EXT)", async () => { + // :erlang.term_to_binary([]) = <<131, 106>> + const binary = Bitstring.fromBytes(new Uint8Array([131, 106])); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.list([])); + }); + + it("decodes string list (STRING_EXT)", async () => { + // :erlang.term_to_binary([1, 2, 3]) = <<131, 107, 0, 3, 1, 2, 3>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 107, 0, 3, 1, 2, 3]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual( + result, + Type.list([Type.integer(1), Type.integer(2), Type.integer(3)]), + ); + }); + + it("decodes proper list (LIST_EXT)", async () => { + // :erlang.term_to_binary([100, 200, 300]) = <<131, 108, 0, 0, 0, 3, 97, 100, 98, 0, 0, 0, 200, 98, 0, 0, 1, 44, 106>> + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 108, 0, 0, 0, 3, 97, 100, 98, 0, 0, 0, 200, 98, 0, 0, 1, 44, + 106, + ]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual( + result, + Type.list([Type.integer(100), Type.integer(200), Type.integer(300)]), + ); + }); + + it("decodes improper list (LIST_EXT)", async () => { + // :erlang.term_to_binary([1 | [2 | [3 | 4]]]) - improper list + // The tail is 4 instead of empty list (NIL_EXT) + const binary = Bitstring.fromBytes( + new Uint8Array([131, 108, 0, 0, 0, 3, 97, 1, 97, 2, 97, 3, 97, 4]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual( + result, + Type.improperList([ + Type.integer(1), + Type.integer(2), + Type.integer(3), + Type.integer(4), + ]), + ); + }); + }); + + describe("maps", () => { + it("decodes empty map", async () => { + // :erlang.term_to_binary(%{}) = <<131, 116, 0, 0, 0, 0>> + const binary = Bitstring.fromBytes( + new Uint8Array([131, 116, 0, 0, 0, 0]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.map([])); + }); + + it("decodes map with atom keys", async () => { + // :erlang.term_to_binary(%{a: 1, b: 2}) = <<131, 116, 0, 0, 0, 2, 119, 1, 97, 97, 1, 119, 1, 98, 97, 2>> + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 116, 0, 0, 0, 2, 119, 1, 97, 97, 1, 119, 1, 98, 97, 2, + ]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual( + result, + Type.map([ + [Type.atom("a"), Type.integer(1)], + [Type.atom("b"), Type.integer(2)], + ]), + ); + }); + }); + + describe("floats", () => { + it("decodes NEW_FLOAT_EXT (IEEE 754 double)", async () => { + // :erlang.term_to_binary(3.14159) with NEW_FLOAT_EXT tag + const binary = Bitstring.fromBytes( + new Uint8Array([131, 70, 64, 9, 33, 249, 240, 27, 134, 110]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(3.14159)); + }); + + it("decodes NEW_FLOAT_EXT for negative float", async () => { + // :erlang.term_to_binary(-2.5) + const binary = Bitstring.fromBytes( + new Uint8Array([131, 70, 192, 4, 0, 0, 0, 0, 0, 0]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(-2.5)); + }); + + it("decodes NEW_FLOAT_EXT for zero", async () => { + // :erlang.term_to_binary(0.0) + const binary = Bitstring.fromBytes( + new Uint8Array([131, 70, 0, 0, 0, 0, 0, 0, 0, 0]), + ); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(0.0)); + }); + + it("decodes FLOAT_EXT (deprecated 31-byte string format) for positive float", async () => { + // FLOAT_EXT: tag 99, followed by 31 bytes (null-terminated string) + // Encoding 3.14159 as "3.14159000000000000000e+00" (31 bytes with null padding) + const floatStr = "3.14159000000000000000e+00"; + const bytes = new Uint8Array(33); + bytes[0] = 131; // ETF version + bytes[1] = 99; // FLOAT_EXT tag + for (let i = 0; i < floatStr.length; i++) { + bytes[2 + i] = floatStr.charCodeAt(i); + } + // Remaining bytes are 0 (null padding) + const binary = Bitstring.fromBytes(bytes); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(3.14159)); + }); + + it("decodes FLOAT_EXT for negative float", async () => { + // Encoding -2.5 as "-2.50000000000000000000e+00" + const floatStr = "-2.50000000000000000000e+00"; + const bytes = new Uint8Array(33); + bytes[0] = 131; + bytes[1] = 99; + for (let i = 0; i < floatStr.length; i++) { + bytes[2 + i] = floatStr.charCodeAt(i); + } + const binary = Bitstring.fromBytes(bytes); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(-2.5)); + }); + + it("decodes FLOAT_EXT for zero", async () => { + // Encoding 0.0 as "0.00000000000000000000e+00" + const floatStr = "0.00000000000000000000e+00"; + const bytes = new Uint8Array(33); + bytes[0] = 131; + bytes[1] = 99; + for (let i = 0; i < floatStr.length; i++) { + bytes[2 + i] = floatStr.charCodeAt(i); + } + const binary = Bitstring.fromBytes(bytes); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(0.0)); + }); + + it("decodes FLOAT_EXT with shorter string (null-terminated)", async () => { + // Encoding 1.5 as "1.5" with null termination + const floatStr = "1.5"; + const bytes = new Uint8Array(33); + bytes[0] = 131; + bytes[1] = 99; + for (let i = 0; i < floatStr.length; i++) { + bytes[2 + i] = floatStr.charCodeAt(i); + } + bytes[2 + floatStr.length] = 0; // null terminator + // Remaining bytes are already 0 + const binary = Bitstring.fromBytes(bytes); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(1.5)); + }); + + it("decodes FLOAT_EXT with negative exponent", async () => { + // Encoding 0.00625 as "6.25000000000000000000e-03" + const floatStr = "6.25000000000000000000e-03"; + const bytes = new Uint8Array(33); + bytes[0] = 131; + bytes[1] = 99; + for (let i = 0; i < floatStr.length; i++) { + bytes[2 + i] = floatStr.charCodeAt(i); + } + const binary = Bitstring.fromBytes(bytes); + const result = await binary_to_term(binary); + assert.deepStrictEqual(result, Type.float(0.00625)); + }); + + it("raises ArgumentError for FLOAT_EXT with truncated data", async () => { + // Only 10 bytes after tag instead of 31 + const bytes = new Uint8Array([ + 131, 99, 49, 46, 50, 51, 0, 0, 0, 0, 0, 0, + ]); + const binary = Bitstring.fromBytes(bytes); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + }); + + describe("bitstrings", () => { + it("decodes BIT_BINARY_EXT with partial byte", async () => { + // Bitstring with 5 bits: <<1::5>> + // 131 - VERSION_NUMBER + // 77 - BIT_BINARY_EXT + // 0, 0, 0, 1 - length (1 byte) + // 5 - bits in last byte + // 0b00001000 (8) - the byte with 5 bits set + const binary = Bitstring.fromBytes( + new Uint8Array([131, 77, 0, 0, 0, 1, 5, 8]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(Bitstring.calculateBitCount(result), 5); + }); + + it("decodes BIT_BINARY_EXT with full bytes (Bits=8)", async () => { + // BIT_BINARY_EXT with 2 bytes, 8 bits used in last byte + // 131 - VERSION_MAGIC + // 77 - BIT_BINARY_EXT + // 0, 0, 0, 2 - length (2 bytes) + // 8 - bits used in last byte (8 = full byte) + // 255, 0 - the actual data bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 77, 0, 0, 0, 2, 8, 255, 0]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(Bitstring.calculateBitCount(result), 16); + }); + }); + + describe("references", () => { + it("decodes NEW_REFERENCE_EXT", async () => { + // NEW_REFERENCE_EXT with SMALL_ATOM_UTF8_EXT node + // 131 - VERSION_NUMBER + // 114 - NEW_REFERENCE_EXT + // 0, 3 - number of ID words (3) + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // 0 - creation (1 byte) + // ID words: 1, 2, 3 (each 4 bytes) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 114, 0, 3, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, + 104, 111, 115, 116, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 0); + assert.deepStrictEqual(result.idWords, [1, 2, 3]); + }); + + it("decodes NEWER_REFERENCE_EXT with SMALL_ATOM_UTF8_EXT", async () => { + // NEWER_REFERENCE_EXT with SMALL_ATOM_UTF8_EXT node + // 131 - VERSION_NUMBER + // 90 - NEWER_REFERENCE_EXT + // 0, 3 - number of ID words (3) + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // 0, 0, 0, 0 - creation (4 bytes) + // ID words: 1, 2, 3 (each 4 bytes) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 90, 0, 3, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, + 104, 111, 115, 116, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 0); + assert.deepStrictEqual(result.idWords, [1, 2, 3]); + }); + + it("decodes NEWER_REFERENCE_EXT with ATOM_EXT", async () => { + // NEWER_REFERENCE_EXT with ATOM_EXT node + // 131 - VERSION_NUMBER + // 90 - NEWER_REFERENCE_EXT + // 0, 3 - number of ID words (3) + // 100 - ATOM_EXT + // 0, 13 - node name length (2 bytes) + // "nonode@nohost" - node name + // 0, 0, 0, 0 - creation (4 bytes) + // ID words: 1, 2, 3 (each 4 bytes) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 90, 0, 3, 100, 0, 13, 110, 111, 110, 111, 100, 101, 64, 110, + 111, 104, 111, 115, 116, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, + 0, 3, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 0); + assert.deepStrictEqual(result.idWords, [1, 2, 3]); + }); + it("decodes REFERENCE_EXT with SMALL_ATOM_UTF8_EXT", async () => { + // REFERENCE_EXT with SMALL_ATOM_UTF8_EXT node (deprecated format) + // 131 - VERSION_NUMBER + // 101 - REFERENCE_EXT + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // 0, 0, 0, 42 - single ID word (32-bit) + // 1 - creation (8-bit) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 101, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 0, 42, 1, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 1); + assert.deepStrictEqual(result.idWords, [42]); + }); + + it("decodes REFERENCE_EXT with ATOM_EXT", async () => { + // REFERENCE_EXT with ATOM_EXT node (deprecated format) + // 131 - VERSION_NUMBER + // 101 - REFERENCE_EXT + // 100 - ATOM_EXT + // 0, 13 - node name length (16-bit) + // "nonode@nohost" - node name + // 0, 0, 0, 123 - single ID word (32-bit) + // 2 - creation (8-bit) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 101, 100, 0, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, + 104, 111, 115, 116, 0, 0, 0, 123, 2, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 2); + assert.deepStrictEqual(result.idWords, [123]); + }); + + it("decodes REFERENCE_EXT with SMALL_ATOM_EXT", async () => { + // REFERENCE_EXT with SMALL_ATOM_EXT node (deprecated format) + // 131 - VERSION_NUMBER + // 101 - REFERENCE_EXT + // 115 - SMALL_ATOM_EXT + // 13 - node name length + // "nonode@nohost" - node name + // 0, 0, 1, 200 - single ID word (32-bit) + // 3 - creation (8-bit) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 101, 115, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 1, 200, 3, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 3); + assert.deepStrictEqual(result.idWords, [456]); + }); + + it("decodes NEW_REFERENCE_EXT with ATOM_EXT", async () => { + // NEW_REFERENCE_EXT with ATOM_EXT node (deprecated format) + // 131 - VERSION_NUMBER + // 114 - NEW_REFERENCE_EXT + // 0, 3 - number of ID words (16-bit big-endian) + // 100 - ATOM_EXT + // 0, 13 - node name length (16-bit) + // "nonode@nohost" - node name + // 1 - creation (8-bit) + // 0, 0, 0, 1 - ID word 1 (32-bit) + // 0, 0, 0, 2 - ID word 2 (32-bit) + // 0, 0, 0, 3 - ID word 3 (32-bit) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 114, 0, 3, 100, 0, 13, 110, 111, 110, 111, 100, 101, 64, 110, + 111, 104, 111, 115, 116, 1, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "reference"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.strictEqual(result.creation, 1); + assert.deepStrictEqual(result.idWords, [1, 2, 3]); + }); + }); + + describe("pids", () => { + it("decodes PID_EXT", async () => { + // PID_EXT format + // 131 - VERSION_NUMBER + // 103 - PID_EXT + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // ID (4 bytes), Serial (4 bytes), Creation (1 byte) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 103, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 0, 100, 0, 0, 0, 50, 0, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "pid"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.deepStrictEqual(result.segments, [100, 50, 0]); + }); + + it("decodes NEW_PID_EXT", async () => { + // NEW_PID_EXT format + // 131 - VERSION_NUMBER + // 88 - NEW_PID_EXT + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // ID (4 bytes), Serial (4 bytes), Creation (4 bytes) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 88, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 0, 100, 0, 0, 0, 50, 0, 0, 0, 0, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "pid"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.deepStrictEqual(result.segments, [100, 50, 0]); + }); + }); + + describe("ports", () => { + it("decodes PORT_EXT", async () => { + // PORT_EXT format + // 131 - VERSION_NUMBER + // 102 - PORT_EXT + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // ID (4 bytes), Creation (1 byte) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 102, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 0, 5, 0, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "port"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.deepStrictEqual(result.segments, [5, 0]); + }); + + it("decodes NEW_PORT_EXT", async () => { + // NEW_PORT_EXT format + // 131 - VERSION_NUMBER + // 89 - NEW_PORT_EXT + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // ID (4 bytes), Creation (4 bytes) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 89, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 0, 5, 0, 0, 0, 0, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "port"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.deepStrictEqual(result.segments, [5, 0]); + }); + + it("decodes V4_PORT_EXT", async () => { + // V4_PORT_EXT format (64-bit port ID) + // 131 - VERSION_NUMBER + // 120 - V4_PORT_EXT + // 119 - SMALL_ATOM_UTF8_EXT + // 13 - node name length + // "nonode@nohost" - node name + // ID (8 bytes as BigUint64), Creation (4 bytes) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 120, 119, 13, 110, 111, 110, 111, 100, 101, 64, 110, 111, 104, + 111, 115, 116, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, + ]), + ); + const result = await binary_to_term(binary); + assert.strictEqual(result.type, "port"); + assert.deepStrictEqual(result.node, Type.atom("nonode@nohost")); + assert.deepStrictEqual(result.segments, [5n, 0]); + }); + }); + + describe("exports (function captures)", () => { + it("decodes EXPORT_EXT (function capture)", async () => { + // &Enum.map/2 + // :erlang.term_to_binary(&Enum.map/2) = + // <<131, 113, 119, 11, 69, 108, 105, 120, 105, 114, 46, 69, 110, 117, 109, 119, 3, 109, 97, 112, 97, 2>> + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 113, 119, 11, 69, 108, 105, 120, 105, 114, 46, 69, 110, 117, + 109, 119, 3, 109, 97, 112, 97, 2, + ]), + ); + const result = await binary_to_term(binary); + + assert.strictEqual(result.type, "anonymous_function"); + assert.strictEqual(result.capturedModule, "Elixir.Enum"); + assert.strictEqual(result.capturedFunction, "map"); + assert.strictEqual(result.arity, 2); + assert.deepStrictEqual(result.clauses, []); + }); + }); + + describe("complex nested structures", () => { + it("decodes Code.fetch_docs/1 style tuple", async () => { + // :erlang.term_to_binary({:docs_v1, 1, :elixir, "text/markdown", %{}, %{}, []}) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 104, 7, 119, 7, 100, 111, 99, 115, 95, 118, 49, 97, 1, 119, 6, + 101, 108, 105, 120, 105, 114, 109, 0, 0, 0, 13, 116, 101, 120, 116, + 47, 109, 97, 114, 107, 100, 111, 119, 110, 116, 0, 0, 0, 0, 116, 0, + 0, 0, 0, 106, + ]), + ); + const result = await binary_to_term(binary); + + assertBoxedStrictEqual( + result, + Type.tuple([ + Type.atom("docs_v1"), + Type.integer(1), + Type.atom("elixir"), + Type.bitstring("text/markdown"), + Type.map([]), + Type.map([]), + Type.list([]), + ]), + ); + }); + }); + + describe("compressed terms", () => { + it("decodes compressed term (COMPRESSED tag 80) with repeated string", async () => { + // Real Erlang-generated compressed term for String.duplicate("hello", 100) + // Generated with: :erlang.term_to_binary(String.duplicate("hello", 100), [compressed: 9]) + // Format: 131 (version) + 80 (COMPRESSED) + uncompressed_size (4 bytes) + zlib compressed data + // Uncompressed size = 505 bytes + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 80, 0, 0, 1, 249, 120, 218, 203, 101, 96, 96, 252, 146, 145, + 154, 147, 147, 63, 74, 140, 40, 2, 0, 21, 94, 209, 51, + ]), + ); + const result = await binary_to_term(binary); + const expected = Type.bitstring("hello".repeat(100)); + assertBoxedStrictEqual(result, expected); + }); + + it("decodes compressed term with list of tuples", async () => { + // Real Erlang-generated compressed term for a list of 50 identical tuples + // Generated with: :erlang.term_to_binary(List.duplicate({:ok, 42}, 50), [compressed: 9]) + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 80, 0, 0, 1, 150, 120, 218, 203, 97, 96, 96, 48, 202, 96, 42, + 103, 202, 207, 78, 212, 26, 165, 7, 7, 157, 5, 0, 189, 136, 115, 25, + ]), + ); + const result = await binary_to_term(binary); + const expectedTuple = Type.tuple([Type.atom("ok"), Type.integer(42)]); + const expectedList = Type.list(Array(50).fill(expectedTuple)); + assertBoxedStrictEqual(result, expectedList); + }); + + it("raises ArgumentError for compressed term with truncated uncompressed size", async () => { + // COMPRESSED tag but missing uncompressed size bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 80, 0, 0]), // Only 2 bytes of size instead of 4 + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for compressed term with invalid zlib data", async () => { + // COMPRESSED tag with invalid compressed data + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, + 80, + 0, + 0, + 0, + 2, // version, COMPRESSED tag, uncompressed size = 2 + 255, + 255, + 255, // invalid zlib data + ]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for compressed term with size mismatch", async () => { + // COMPRESSED tag where decompressed size doesn't match declared size + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, + 80, + 0, + 0, + 0, + 10, // version, COMPRESSED tag, uncompressed size = 10 (wrong!) + 120, + 156, + 74, + 180, + 2, + 0, + 0, + 121, + 0, + 121, // zlib data that decompresses to 2 bytes + ]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + }); + + describe("error handling", () => { + it("raises ArgumentError if argument is not a binary", async () => { + await assertBoxedErrorAsync( + () => binary_to_term(Type.atom("test")), + "ArgumentError", + Interpreter.buildArgumentErrorMsg(1, "not a binary"), + ); + }); + + it("raises ArgumentError if argument is a non-binary bitstring", async () => { + const bits = Bitstring.fromBits([1, 0, 1]); + + await assertBoxedErrorAsync( + () => binary_to_term(bits), + "ArgumentError", + Interpreter.buildArgumentErrorMsg(1, "not a binary"), + ); + }); + + it("raises ArgumentError if binary has invalid version byte", async () => { + const binary = Bitstring.fromBytes(new Uint8Array([130, 97, 42])); // Wrong version + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError if binary is truncated", async () => { + const binary = Bitstring.fromBytes(new Uint8Array([131])); // Only version byte + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for unsupported ETF tag (tag 50)", async () => { + // Tag 50 is not a valid ETF tag + const binary = Bitstring.fromBytes(new Uint8Array([131, 50])); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for unsupported ETF tag (tag 255)", async () => { + // Tag 255 is not a valid ETF tag + const binary = Bitstring.fromBytes(new Uint8Array([131, 255])); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for unsupported ETF tag (tag 1)", async () => { + // Tag 1 is not a valid ETF tag + const binary = Bitstring.fromBytes(new Uint8Array([131, 1])); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed BINARY_EXT with length exceeding data", async () => { + // BINARY_EXT (109) with length 10 but only 2 bytes of data + const binary = Bitstring.fromBytes( + new Uint8Array([131, 109, 0, 0, 0, 10, 65, 66]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed STRING_EXT with length exceeding data", async () => { + // STRING_EXT (107) with length 100 but only 2 bytes of data + const binary = Bitstring.fromBytes( + new Uint8Array([131, 107, 0, 100, 65, 66]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed SMALL_TUPLE_EXT with arity exceeding available data", async () => { + // SMALL_TUPLE_EXT (104) with arity 5 but only 1 element + const binary = Bitstring.fromBytes( + new Uint8Array([131, 104, 5, 97, 1]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed LIST_EXT with length exceeding data", async () => { + // LIST_EXT (108) with length 10 but no elements + const binary = Bitstring.fromBytes( + new Uint8Array([131, 108, 0, 0, 0, 10]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed MAP_EXT with arity exceeding data", async () => { + // MAP_EXT (116) with arity 10 but no key-value pairs + const binary = Bitstring.fromBytes( + new Uint8Array([131, 116, 0, 0, 0, 10]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed ATOM_EXT with length exceeding data", async () => { + // ATOM_EXT (100) with length 100 but only 2 bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 100, 0, 100, 65, 66]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed SMALL_ATOM_UTF8_EXT with length exceeding data", async () => { + // SMALL_ATOM_UTF8_EXT (119) with length 50 but only 2 bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 119, 50, 65, 66]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed SMALL_BIG_EXT with n exceeding data", async () => { + // SMALL_BIG_EXT (110) with n=100 but insufficient bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 110, 100, 0, 1, 2, 3]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed LARGE_BIG_EXT with n exceeding data", async () => { + // LARGE_BIG_EXT (111) with large n but insufficient bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 111, 0, 0, 1, 0, 0, 1, 2, 3]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed BIT_BINARY_EXT with length exceeding data", async () => { + // BIT_BINARY_EXT (77) with length 100 but only 2 bytes + const binary = Bitstring.fromBytes( + new Uint8Array([131, 77, 0, 0, 0, 100, 5, 65, 66]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for malformed NEW_REFERENCE_EXT with len exceeding data", async () => { + // NEW_REFERENCE_EXT (114) with len=10 but insufficient id bytes + const binary = Bitstring.fromBytes( + new Uint8Array([ + 131, 114, 0, 10, 119, 4, 110, 111, 100, 101, 0, 0, 0, 1, + ]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for INTEGER_EXT boundary - value truncated", async () => { + // INTEGER_EXT (98) but only 2 bytes instead of 4 + const binary = Bitstring.fromBytes(new Uint8Array([131, 98, 0, 0])); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for NEW_FLOAT_EXT truncated", async () => { + // NEW_FLOAT_EXT (70) but only 4 bytes instead of 8 + const binary = Bitstring.fromBytes( + new Uint8Array([131, 70, 0, 0, 0, 0]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for nested term truncation", async () => { + // SMALL_TUPLE_EXT with 2 elements, but second element is truncated + // [131, 104, 2, 97, 1, 97] - missing last byte + const binary = Bitstring.fromBytes( + new Uint8Array([131, 104, 2, 97, 1, 97]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + + it("raises ArgumentError for LARGE_TUPLE_EXT with zero arity but missing NIL_EXT", async () => { + // LARGE_TUPLE_EXT (105) with arity 0 - should work, but let's test malformed version + const binary = Bitstring.fromBytes( + new Uint8Array([131, 105, 0, 0, 0, 1]), + ); + await assertBoxedErrorAsync( + () => binary_to_term(binary), + "ArgumentError", + Interpreter.buildArgumentErrorMsg( + 1, + "invalid external representation of a term", + ), + ); + }); + }); + }); + describe("bit_size/1", () => { const bit_size = Erlang["bit_size/1"]; diff --git a/test/javascript/support/helpers.mjs b/test/javascript/support/helpers.mjs index 211198454..248dfd784 100644 --- a/test/javascript/support/helpers.mjs +++ b/test/javascript/support/helpers.mjs @@ -42,27 +42,14 @@ export {h as vnode} from "../../../assets/node_modules/snabbdom/build/index.js"; export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -export function assertBoxedError( - callable, - expectedErrorType, - expectedErrorMessage, -) { +function validateBoxedError(error, expectedErrorType, expectedErrorMessage) { const isRegex = expectedErrorMessage instanceof RegExp; - const expectedMessageDisplay = isRegex ? expectedErrorMessage.toString() : expectedErrorMessage; const failMessagePrefix = `\nexpected:\n${expectedErrorType}: ${expectedMessageDisplay}\n`; - let error; - - try { - callable(); - } catch (e) { - error = e; - } - if (!error) { assert.fail(failMessagePrefix + "but got no error"); } @@ -77,7 +64,6 @@ export function assertBoxedError( const receivedErrorMessage = Interpreter.getErrorMessage(error); const typeMatches = receivedErrorType === expectedErrorType; - const messageMatches = isRegex ? expectedErrorMessage.test(receivedErrorMessage) : Interpreter.isStrictlyEqual( @@ -93,6 +79,34 @@ export function assertBoxedError( } } +export function assertBoxedError( + callable, + expectedErrorType, + expectedErrorMessage, +) { + let error; + try { + callable(); + } catch (e) { + error = e; + } + validateBoxedError(error, expectedErrorType, expectedErrorMessage); +} + +export async function assertBoxedErrorAsync( + asyncCallable, + expectedErrorType, + expectedErrorMessage, +) { + let error; + try { + await asyncCallable(); + } catch (e) { + error = e; + } + validateBoxedError(error, expectedErrorType, expectedErrorMessage); +} + export function assertBoxedFalse(boxed) { assert.isTrue(Type.isFalse(boxed)); }