From 2a217ee08bdf82ff19f515b673e0c848ab069279 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:50:41 -0600 Subject: [PATCH 1/6] feat(js/ts): support Convert.ToHexString, ToHexStringLower and FromHexString Map them to new String.toHexString, toHexStringLower and fromHexString library functions, next to the base64 ones. The offset and length overloads of ToHexString and ToHexStringLower are supported too. As on .NET, FromHexString accepts either case and raises FormatException for an odd length or a non-hex character, and returns a Uint8Array like FromBase64String. Null arguments raise ArgumentNullException and a bad offset or length raises ArgumentOutOfRangeException. The tests' raisedExceptionType helper now also reports ArgumentNullException and FormatException. --- src/Fable.Transforms/Replacements.fs | 7 ++++ src/fable-library-ts/String.ts | 44 ++++++++++++++++++++++ tests/Js/Main/ConvertTests.fs | 56 +++++++++++++++++++++------- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 232c79896..388913b52 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -3458,6 +3458,13 @@ let convert (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr option) ( Helper.LibCall(com, "String", (Naming.lowerFirst i.CompiledName), t, args, i.SignatureArgTypes, ?loc = r) |> Some + | "ToHexString" + | "ToHexStringLower" -> + Helper.LibCall(com, "String", Naming.lowerFirst i.CompiledName, t, args, i.SignatureArgTypes, ?loc = r) + |> Some + | "FromHexString" when List.isSingle args -> + Helper.LibCall(com, "String", "fromHexString", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | _ -> None let console (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = diff --git a/src/fable-library-ts/String.ts b/src/fable-library-ts/String.ts index d12d60b69..15a5aa2c3 100644 --- a/src/fable-library-ts/String.ts +++ b/src/fable-library-ts/String.ts @@ -1,6 +1,7 @@ import { toString as dateToString } from "./Date.ts"; import { compare as numericCompare, divide, isNumeric, isIntegral, multiply, Numeric, toExponential, toFixed, toHex, toPrecision, toRadix } from "./Numeric.ts"; import { escape } from "./RegExp.ts"; +import { ArgumentNullException_$ctor_Z721C83C5, ArgumentOutOfRangeException_$ctor_Z384F8060, ArgumentOutOfRangeException_$ctor_Z721C83C5, FormatException_$ctor_Z721C83C5 } from "./System.ts"; import { toString } from "./Types.ts"; import { Exception } from "./Util.ts"; @@ -683,6 +684,49 @@ export function fromBase64String(b64Encoded: string): number[] { return bytes as any as number[]; } +export function toHexString(inArray: ArrayLike, offset?: number, length?: number): string { + if (inArray == null) { + throw ArgumentNullException_$ctor_Z721C83C5("inArray"); + } + const start = offset ?? 0; + const count = length ?? inArray.length - start; + if (count < 0) { + throw ArgumentOutOfRangeException_$ctor_Z384F8060("length", "Non-negative number required."); + } + if (start < 0) { + throw ArgumentOutOfRangeException_$ctor_Z384F8060("offset", "Non-negative number required."); + } + if (start > inArray.length - count) { + throw ArgumentOutOfRangeException_$ctor_Z721C83C5("offset"); + } + let str = ""; + for (let i = start; i < start + count; i++) { + str += inArray[i].toString(16).padStart(2, "0"); + } + return str.toUpperCase(); +} + +export function toHexStringLower(inArray: ArrayLike, offset?: number, length?: number): string { + return toHexString(inArray, offset, length).toLowerCase(); +} + +export function fromHexString(s: string): number[] { + if (s == null) { + throw ArgumentNullException_$ctor_Z721C83C5("s"); + } + if (s.length % 2 !== 0) { + throw FormatException_$ctor_Z721C83C5("The input is not a valid hex string as its length is not a multiple of 2."); + } + if (!/^[0-9A-Fa-f]*$/.test(s)) { + throw FormatException_$ctor_Z721C83C5("The input is not a valid hex string as it contains a non-hex character."); + } + const bytes = new Uint8Array(s.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(s.substring(2 * i, 2 * i + 2), 16); + } + return bytes as any as number[]; +} + function pad(str: string, len: number, ch?: string, isRight?: boolean) { ch = ch || " "; len = len - str.length; diff --git a/tests/Js/Main/ConvertTests.fs b/tests/Js/Main/ConvertTests.fs index bf7cb8801..b04139546 100644 --- a/tests/Js/Main/ConvertTests.fs +++ b/tests/Js/Main/ConvertTests.fs @@ -2241,19 +2241,49 @@ let tests = let x = "101" Convert.ToString(101.m) |> equal x - // testCase "Convert.ToHexString works" <| fun () -> - // let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] - // Convert.ToHexString(bytes) - // |> equal "FAFBFCFDFE" - - // testCase "Convert.ToHexStringLower works" <| fun () -> - // let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] - // Convert.ToHexStringLower(bytes) - // |> equal "fafbfcfdfe" - - // testCase "Convert.FromHexString works" <| fun () -> - // Convert.FromHexString("fafBFCFDFE") - // |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] + testCase "Convert.ToHexString works" <| fun () -> + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexString(bytes) + |> equal "FAFBFCFDFE" + Convert.ToHexString([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010F10" + Convert.ToHexString([||]) |> equal "" + Convert.ToHexString(bytes, 1, 3) |> equal "FBFCFD" + Convert.ToHexString(bytes, 5, 0) |> equal "" + + testCase "Convert.ToHexStringLower works" <| fun () -> + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexStringLower(bytes) + |> equal "fafbfcfdfe" + Convert.ToHexStringLower([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010f10" + Convert.ToHexStringLower([||]) |> equal "" + Convert.ToHexStringLower(bytes, 1, 3) |> equal "fbfcfd" + + testCase "Convert.ToHexString checks its arguments" <| fun () -> + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + raisedExceptionType (fun () -> Convert.ToHexString(null: byte[])) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexString((null: byte[]), 0, 0)) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 1, -1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 6, 0)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(null: byte[])) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + + testCase "Convert.FromHexString works" <| fun () -> + Convert.FromHexString("fafBFCFDFE") + |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.FromHexString("0123456789abcdefABCDEF") + |> equal [| 1uy; 35uy; 69uy; 103uy; 137uy; 171uy; 205uy; 239uy; 171uy; 205uy; 239uy |] + Convert.FromHexString("") |> equal [||] + + testCase "Convert.FromHexString checks its input" <| fun () -> + raisedExceptionType (fun () -> Convert.FromHexString(null: string)) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.FromHexString("abc")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("zz")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("0x10")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("00 1")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("+1")) |> equal "FormatException" testCase "Convert.ToBase64String works" <| fun () -> let bytes = [| 2uy; 4uy; 6uy; 8uy; 10uy; 12uy; 14uy; 16uy; 18uy; 20uy |] From e22fd3348d84d6cbfa91e6825de1c3f11304ef34 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:52:51 -0600 Subject: [PATCH 2/6] feat(python): support Convert.ToHexString, ToHexStringLower and FromHexString Map them, including the offset and length overloads, to a new fable_library convert module. It lives outside string_.py because it raises .NET exceptions from system.py, which imports string_.py. As on .NET, FromHexString accepts either case and raises FormatException for an odd length or a non-hex character (bytes.fromhex alone would skip whitespace), and returns a byte array. Null arguments raise ArgumentNullException and a bad offset or length raises ArgumentOutOfRangeException. The tests' raisedExceptionType helper now also reports ArgumentNullException and FormatException. --- src/Fable.Transforms/Python/Replacements.fs | 7 ++ src/fable-library-py/fable_library/convert.py | 56 ++++++++++++++++ tests/Python/TestConvert.fs | 64 ++++++++++++++----- tests/Python/Util.fs | 2 + 4 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 src/fable-library-py/fable_library/convert.py diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 0f9ae8899..537e0f631 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -3026,6 +3026,13 @@ let convert (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr option) ( Helper.LibCall(com, "String", (Naming.lowerFirst i.CompiledName), t, args, i.SignatureArgTypes, ?loc = r) |> Some + | "ToHexString" + | "ToHexStringLower" -> + Helper.LibCall(com, "Convert", Naming.lowerFirst i.CompiledName, t, args, i.SignatureArgTypes, ?loc = r) + |> Some + | "FromHexString" when List.isSingle args -> + Helper.LibCall(com, "Convert", "fromHexString", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | _ -> None let console (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = diff --git a/src/fable-library-py/fable_library/convert.py b/src/fable-library-py/fable_library/convert.py new file mode 100644 index 000000000..5116fe965 --- /dev/null +++ b/src/fable-library-py/fable_library/convert.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import SupportsInt + +from .array_ import Array +from .core import byte +from .system import ( + ArgumentNullException__ctor_Z721C83C5, + ArgumentOutOfRangeException__ctor_Z384F8060, + ArgumentOutOfRangeException__ctor_Z721C83C5, + FormatException__ctor_Z721C83C5, +) + + +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") + + +def _hex_input(in_array: Array[byte], offset: SupportsInt | None, length: SupportsInt | None) -> bytes: + """Return the bytes to convert to hex, checking the arguments like .NET.""" + if in_array is None: + raise ArgumentNullException__ctor_Z721C83C5("inArray") + data = bytes(in_array) + start = 0 if offset is None else int(offset) + count = len(data) - start if length is None else int(length) + if count < 0: + raise ArgumentOutOfRangeException__ctor_Z384F8060("length", "Non-negative number required.") + if start < 0: + raise ArgumentOutOfRangeException__ctor_Z384F8060("offset", "Non-negative number required.") + if start > len(data) - count: + raise ArgumentOutOfRangeException__ctor_Z721C83C5("offset") + return data[start : start + count] + + +def to_hex_string(in_array: Array[byte], offset: SupportsInt | None = None, length: SupportsInt | None = None) -> str: + """Convert a byte array, or length bytes of it from offset, to uppercase hex (Convert.ToHexString).""" + return _hex_input(in_array, offset, length).hex().upper() + + +def to_hex_string_lower( + in_array: Array[byte], offset: SupportsInt | None = None, length: SupportsInt | None = None +) -> str: + """Convert a byte array, or length bytes of it from offset, to lowercase hex (Convert.ToHexStringLower).""" + return _hex_input(in_array, offset, length).hex() + + +def from_hex_string(s: str) -> Array[byte]: + """Convert a hex string of either case to a byte array (Convert.FromHexString).""" + if s is None: + raise ArgumentNullException__ctor_Z721C83C5("s") + if len(s) % 2 != 0: + raise FormatException__ctor_Z721C83C5( + "The input is not a valid hex string as its length is not a multiple of 2." + ) + if not _HEX_DIGITS.issuperset(s): + raise FormatException__ctor_Z721C83C5("The input is not a valid hex string as it contains a non-hex character.") + return Array[byte](byte(x) for x in bytes.fromhex(s)) diff --git a/tests/Python/TestConvert.fs b/tests/Python/TestConvert.fs index a77a50fca..ea04995e4 100644 --- a/tests/Python/TestConvert.fs +++ b/tests/Python/TestConvert.fs @@ -1060,22 +1060,54 @@ let ``test System.Convert.ToString Decimal works`` () = let x = "101" Convert.ToString(101.m) |> equal x -// [] -// let ``test Convert.ToHexString works`` () = -// let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] -// Convert.ToHexString(bytes) -// |> equal "FAFBFCFDFE" - -// [] -// let ``test Convert.ToHexStringLower works`` () = -// let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] -// Convert.ToHexStringLower(bytes) -// |> equal "fafbfcfdfe" - -// [] -// let ``test Convert.FromHexString works`` () = -// Convert.FromHexString("fafBFCFDFE") -// |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] +[] +let ``test Convert.ToHexString works`` () = + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexString(bytes) + |> equal "FAFBFCFDFE" + Convert.ToHexString([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010F10" + Convert.ToHexString([||]) |> equal "" + Convert.ToHexString(bytes, 1, 3) |> equal "FBFCFD" + Convert.ToHexString(bytes, 5, 0) |> equal "" + +[] +let ``test Convert.ToHexStringLower works`` () = + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexStringLower(bytes) + |> equal "fafbfcfdfe" + Convert.ToHexStringLower([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010f10" + Convert.ToHexStringLower([||]) |> equal "" + Convert.ToHexStringLower(bytes, 1, 3) |> equal "fbfcfd" + +[] +let ``test Convert.ToHexString checks its arguments`` () = + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + raisedExceptionType (fun () -> Convert.ToHexString(null: byte[])) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexString((null: byte[]), 0, 0)) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 1, -1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 6, 0)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(null: byte[])) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + +[] +let ``test Convert.FromHexString works`` () = + Convert.FromHexString("fafBFCFDFE") + |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.FromHexString("0123456789abcdefABCDEF") + |> equal [| 1uy; 35uy; 69uy; 103uy; 137uy; 171uy; 205uy; 239uy; 171uy; 205uy; 239uy |] + Convert.FromHexString("") |> equal [||] + +[] +let ``test Convert.FromHexString checks its input`` () = + raisedExceptionType (fun () -> Convert.FromHexString(null: string)) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.FromHexString("abc")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("zz")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("0x10")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("00 1")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("+1")) |> equal "FormatException" [] let ``test Convert.ToBase64String works`` () = diff --git a/tests/Python/Util.fs b/tests/Python/Util.fs index 72637b7aa..9eeb542b5 100644 --- a/tests/Python/Util.fs +++ b/tests/Python/Util.fs @@ -113,8 +113,10 @@ let raisedExceptionType (f: unit -> 'a): string = "no exception" with | :? ArgumentOutOfRangeException -> "ArgumentOutOfRangeException" + | :? ArgumentNullException -> "ArgumentNullException" | :? ArgumentException -> "ArgumentException" | :? InvalidOperationException -> "InvalidOperationException" + | :? FormatException -> "FormatException" | e -> "Unexpected exception: " + e.Message let Int32Array = [|1;2|] From 53db07bf49451c36e9c945c62225953a7ef0c529 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:55:28 -0600 Subject: [PATCH 3/6] feat(dart): support Convert.ToHexString, ToHexStringLower and FromHexString Map them, including the offset and length overloads, to new toHexString, toHexStringLower and fromHexString functions in String.dart, which raise the .NET exceptions from System.dart like Async.dart does. As on .NET, FromHexString accepts either case and raises FormatException for an odd length or a non-hex character, and a bad offset or length raises ArgumentOutOfRangeException. The tests' raisedExceptionType helper now also reports ArgumentNullException and FormatException. --- src/Fable.Transforms/Dart/Replacements.fs | 7 ++++ src/fable-library-dart/String.dart | 40 +++++++++++++++++++++++ tests/Dart/src/StringTests.fs | 40 +++++++++++++++++++++++ tests/Dart/src/Tests.Util.fs | 2 ++ 4 files changed, 89 insertions(+) diff --git a/src/Fable.Transforms/Dart/Replacements.fs b/src/Fable.Transforms/Dart/Replacements.fs index ce6705fd0..8e551f28a 100644 --- a/src/Fable.Transforms/Dart/Replacements.fs +++ b/src/Fable.Transforms/Dart/Replacements.fs @@ -2828,6 +2828,13 @@ let convert (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr option) ( ?loc = r ) |> Some + | "ToHexString" + | "ToHexStringLower" -> + Helper.LibCall(com, "String", Naming.lowerFirst i.CompiledName, t, args, i.SignatureArgTypes, ?loc = r) + |> Some + | "FromHexString" when List.isSingle args -> + Helper.LibCall(com, "String", "fromHexString", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | _ -> None let console (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = diff --git a/src/fable-library-dart/String.dart b/src/fable-library-dart/String.dart index 33b593c3c..993afe2f3 100644 --- a/src/fable-library-dart/String.dart +++ b/src/fable-library-dart/String.dart @@ -1,5 +1,7 @@ // ignore_for_file: file_names, constant_identifier_names +import 'System.dart' as system; + class StringComparison { static const CurrentCulture = 0; static const CurrentCultureIgnoreCase = 1; @@ -280,3 +282,41 @@ String mapIndexed(int Function(int, int) mapping, String str) { return mapping(index, rune); })); } + +String toHexStringLower(List inArray, [int? offset, int? length]) { + offset ??= 0; + length ??= inArray.length - offset; + if (length < 0) { + throw system.ArgumentOutOfRangeException_$ctor_Z384F8060( + 'length', 'Non-negative number required.'); + } + if (offset < 0) { + throw system.ArgumentOutOfRangeException_$ctor_Z384F8060( + 'offset', 'Non-negative number required.'); + } + if (offset > inArray.length - length) { + throw system.ArgumentOutOfRangeException_$ctor_Z721C83C5('offset'); + } + return inArray + .sublist(offset, offset + length) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); +} + +String toHexString(List inArray, [int? offset, int? length]) { + return toHexStringLower(inArray, offset, length).toUpperCase(); +} + +List fromHexString(String s) { + if (s.length % 2 != 0) { + throw system.FormatException_$ctor_Z721C83C5( + 'The input is not a valid hex string as its length is not a multiple of 2.'); + } + if (!RegExp(r'^[0-9A-Fa-f]*$').hasMatch(s)) { + throw system.FormatException_$ctor_Z721C83C5( + 'The input is not a valid hex string as it contains a non-hex character.'); + } + return List.generate( + s.length ~/ 2, (i) => int.parse(s.substring(2 * i, 2 * i + 2), radix: 16), + growable: false); +} diff --git a/tests/Dart/src/StringTests.fs b/tests/Dart/src/StringTests.fs index 62f24fa4b..ced331096 100644 --- a/tests/Dart/src/StringTests.fs +++ b/tests/Dart/src/StringTests.fs @@ -1119,6 +1119,46 @@ let tests() = String.filter (fun x -> x <> '.') "" |> equal "" + testCase "Convert.ToHexString works" <| fun () -> + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexString(bytes) + |> equal "FAFBFCFDFE" + Convert.ToHexString([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010F10" + Convert.ToHexString([||]) |> equal "" + Convert.ToHexString(bytes, 1, 3) |> equal "FBFCFD" + Convert.ToHexString(bytes, 5, 0) |> equal "" + + testCase "Convert.ToHexStringLower works" <| fun () -> + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexStringLower(bytes) + |> equal "fafbfcfdfe" + Convert.ToHexStringLower([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010f10" + Convert.ToHexStringLower([||]) |> equal "" + Convert.ToHexStringLower(bytes, 1, 3) |> equal "fbfcfd" + + testCase "Convert.ToHexString checks its arguments" <| fun () -> + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + raisedExceptionType (fun () -> Convert.ToHexString(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 1, -1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 6, 0)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + + testCase "Convert.FromHexString works" <| fun () -> + Convert.FromHexString("fafBFCFDFE") + |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.FromHexString("0123456789abcdefABCDEF") + |> equal [| 1uy; 35uy; 69uy; 103uy; 137uy; 171uy; 205uy; 239uy; 171uy; 205uy; 239uy |] + Convert.FromHexString("") |> equal [||] + + testCase "Convert.FromHexString checks its input" <| fun () -> + raisedExceptionType (fun () -> Convert.FromHexString("abc")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("zz")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("0x10")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("00 1")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("+1")) |> equal "FormatException" + // // TODO: Environment // #if FABLE_COMPILER // testCase "System.Environment.NewLine works" <| fun () -> diff --git a/tests/Dart/src/Tests.Util.fs b/tests/Dart/src/Tests.Util.fs index db6751fa4..fad7e0d47 100644 --- a/tests/Dart/src/Tests.Util.fs +++ b/tests/Dart/src/Tests.Util.fs @@ -29,10 +29,12 @@ let raisedExceptionType (f: unit -> 'a): string = "no exception" with | :? System.ArgumentOutOfRangeException -> "ArgumentOutOfRangeException" + | :? System.ArgumentNullException -> "ArgumentNullException" | :? System.ArgumentException -> "ArgumentException" | :? System.InvalidOperationException -> "InvalidOperationException" | :? System.Collections.Generic.KeyNotFoundException -> "KeyNotFoundException" | :? System.IndexOutOfRangeException -> "IndexOutOfRangeException" + | :? System.FormatException -> "FormatException" | e -> "Unexpected exception: " + e.Message let private run (f: unit -> 'a) = From 03c63285b4b3fe8a2ad12363363cff14207a50b2 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:57:39 -0600 Subject: [PATCH 4/6] feat(beam): support Convert.ToHexString, ToHexStringLower and FromHexString Map them, including the offset and length overloads, to new fable_convert functions that take byte arrays like to_base64, plus arrays kept in the process dictionary (List.toArray), and use binary:encode_hex and binary:decode_hex (OTP 24). The lowercase variant lowercases the result, since binary:encode_hex/2 needs OTP 26. As on .NET, FromHexString accepts either case and raises FormatException for an odd length or a non-hex character, and returns a byte array like from_base64. Null arguments raise ArgumentNullException and a bad offset or length raises ArgumentOutOfRangeException. The tests' raisedExceptionType helper now also reports ArgumentNullException and FormatException. --- src/Fable.Transforms/Beam/Replacements.fs | 8 +++ src/fable-library-beam/fable_convert.erl | 72 +++++++++++++++++++++++ tests/Beam/EncodingTests.fs | 49 +++++++++++++++ tests/Beam/Util.fs | 2 + 4 files changed, 131 insertions(+) diff --git a/src/Fable.Transforms/Beam/Replacements.fs b/src/Fable.Transforms/Beam/Replacements.fs index 11b28ecf4..69ce006d5 100644 --- a/src/Fable.Transforms/Beam/Replacements.fs +++ b/src/Fable.Transforms/Beam/Replacements.fs @@ -1768,6 +1768,14 @@ let private convert | "FromBase64String", [ arg ] -> Helper.LibCall(com, "fable_convert", "from_base64", t, [ arg ], ?loc = r) |> Some + | "ToHexString", ([ _ ] | [ _; _; _ ]) -> + Helper.LibCall(com, "fable_convert", "to_hex_string", t, args, ?loc = r) |> Some + | "ToHexStringLower", ([ _ ] | [ _; _; _ ]) -> + Helper.LibCall(com, "fable_convert", "to_hex_string_lower", t, args, ?loc = r) + |> Some + | "FromHexString", [ arg ] -> + Helper.LibCall(com, "fable_convert", "from_hex_string", t, [ arg ], ?loc = r) + |> Some | _ -> None /// Beam-specific List module replacements. diff --git a/src/fable-library-beam/fable_convert.erl b/src/fable-library-beam/fable_convert.erl index 8e5dd5627..b30f56dbe 100644 --- a/src/fable-library-beam/fable_convert.erl +++ b/src/fable-library-beam/fable_convert.erl @@ -10,6 +10,11 @@ to_string_with_base/3, to_base64/1, from_base64/1, + to_hex_string/1, + to_hex_string/3, + to_hex_string_lower/1, + to_hex_string_lower/3, + from_hex_string/1, boolean_parse/1, boolean_try_parse/2, int_to_string_with_format/2, @@ -28,6 +33,11 @@ -spec to_string_with_base(integer(), integer(), integer()) -> binary(). -spec to_base64(tuple() | list() | binary()) -> binary(). -spec from_base64(binary()) -> tuple(). +-spec to_hex_string(tuple() | list() | binary() | reference() | undefined) -> binary(). +-spec to_hex_string(tuple() | list() | binary() | reference() | undefined, integer(), integer()) -> binary(). +-spec to_hex_string_lower(tuple() | list() | binary() | reference() | undefined) -> binary(). +-spec to_hex_string_lower(tuple() | list() | binary() | reference() | undefined, integer(), integer()) -> binary(). +-spec from_hex_string(binary() | undefined) -> tuple(). -spec boolean_parse(binary()) -> boolean(). -spec boolean_try_parse(binary(), reference()) -> boolean(). -spec int_to_string_with_format(integer(), binary()) -> binary(). @@ -196,6 +206,68 @@ to_base64(Bin) when is_binary(Bin) -> from_base64(Str) when is_binary(Str) -> fable_utils:new_byte_array(binary_to_list(base64:decode(Str))). +%% Hex encoding/decoding, checking the arguments like .NET's Convert.ToHexString and FromHexString +to_hex_string(Bytes) -> + binary:encode_hex(hex_input(Bytes)). + +to_hex_string(Bytes, Offset, Length) -> + binary:encode_hex(hex_input(Bytes, Offset, Length)). + +%% binary:encode_hex/2 with lowercase needs OTP 26 +to_hex_string_lower(Bytes) -> + string:lowercase(to_hex_string(Bytes)). + +to_hex_string_lower(Bytes, Offset, Length) -> + string:lowercase(to_hex_string(Bytes, Offset, Length)). + +hex_input(undefined) -> + argument_null(<<"inArray">>); +hex_input({byte_array, _, _} = BA) -> + list_to_binary(fable_utils:byte_array_to_list(BA)); +hex_input(Bytes) when is_list(Bytes) -> + list_to_binary(Bytes); +hex_input(Bin) when is_binary(Bin) -> + Bin; +hex_input(Ref) when is_reference(Ref) -> + hex_input(get(Ref)). + +hex_input(Bytes, Offset, Length) -> + Bin = hex_input(Bytes), + if + Length < 0 -> argument_out_of_range(<<"length">>, <<"Non-negative number required.">>); + Offset < 0 -> argument_out_of_range(<<"offset">>, <<"Non-negative number required.">>); + Offset > byte_size(Bin) - Length -> + argument_out_of_range(<<"offset">>, <<"Specified argument was out of the range of valid values.">>); + true -> binary:part(Bin, Offset, Length) + end. + +from_hex_string(undefined) -> + argument_null(<<"s">>); +from_hex_string(Str) when byte_size(Str) rem 2 =/= 0 -> + bad_hex_string(<<"The input is not a valid hex string as its length is not a multiple of 2.">>); +from_hex_string(Str) -> + try binary:decode_hex(Str) of + Bin -> fable_utils:new_byte_array(binary_to_list(Bin)) + catch + error:badarg -> + bad_hex_string(<<"The input is not a valid hex string as it contains a non-hex character.">>) + end. + +argument_null(ParamName) -> + erlang:error(#{ + exn_type => argument_null_exception, + message => fable_utils:argument_exception_message(<<"Value cannot be null.">>, ParamName) + }). + +argument_out_of_range(ParamName, Message) -> + erlang:error(#{ + exn_type => argument_out_of_range_exception, + message => fable_utils:argument_exception_message(Message, ParamName) + }). + +bad_hex_string(Message) -> + erlang:error(#{exn_type => format_exception, message => Message}). + %% TryParse: returns bool and sets out-param via put(OutRef, Value). %% F# uses out-parameter pattern: result = TryParse(str, &outRef). try_parse_int(Bin, OutRef) when is_binary(Bin) -> diff --git a/tests/Beam/EncodingTests.fs b/tests/Beam/EncodingTests.fs index de1eaed3f..5a54b9ed1 100644 --- a/tests/Beam/EncodingTests.fs +++ b/tests/Beam/EncodingTests.fs @@ -192,6 +192,55 @@ let ``test Base64 empty array works`` () = let encoded = Convert.ToBase64String(bytes) encoded |> equal "" +// --- Hex --- + +[] +let ``test Convert.ToHexString works`` () = + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexString(bytes) |> equal "FAFBFCFDFE" + Convert.ToHexString([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010F10" + Convert.ToHexString([||]) |> equal "" + Convert.ToHexString(bytes, 1, 3) |> equal "FBFCFD" + Convert.ToHexString(bytes, 5, 0) |> equal "" + Convert.ToHexString(List.toArray [ 200uy; 3uy ]) |> equal "C803" + +[] +let ``test Convert.ToHexStringLower works`` () = + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.ToHexStringLower(bytes) |> equal "fafbfcfdfe" + Convert.ToHexStringLower([| 0uy; 1uy; 15uy; 16uy |]) |> equal "00010f10" + Convert.ToHexStringLower([||]) |> equal "" + Convert.ToHexStringLower(bytes, 1, 3) |> equal "fbfcfd" + +[] +let ``test Convert.ToHexString checks its arguments`` () = + let bytes = [| 250uy; 251uy; 252uy; 253uy; 254uy |] + raisedExceptionType (fun () -> Convert.ToHexString(null: byte[])) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexString((null: byte[]), 0, 0)) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 1, -1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexString(bytes, 6, 0)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(null: byte[])) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, -1, 1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> Convert.ToHexStringLower(bytes, 3, 3)) |> equal "ArgumentOutOfRangeException" + +[] +let ``test Convert.FromHexString works`` () = + Convert.FromHexString("fafBFCFDFE") |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] + Convert.FromHexString("0123456789abcdefABCDEF") + |> equal [| 1uy; 35uy; 69uy; 103uy; 137uy; 171uy; 205uy; 239uy; 171uy; 205uy; 239uy |] + Convert.FromHexString("") |> equal [||] + +[] +let ``test Convert.FromHexString checks its input`` () = + raisedExceptionType (fun () -> Convert.FromHexString(null: string)) |> equal "ArgumentNullException" + raisedExceptionType (fun () -> Convert.FromHexString("abc")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("zz")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("0x10")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("00 1")) |> equal "FormatException" + raisedExceptionType (fun () -> Convert.FromHexString("+1")) |> equal "FormatException" + // --- Guid --- [] diff --git a/tests/Beam/Util.fs b/tests/Beam/Util.fs index 28fd76e22..8a8512c09 100644 --- a/tests/Beam/Util.fs +++ b/tests/Beam/Util.fs @@ -77,7 +77,9 @@ let raisedExceptionType (f: unit -> 'a): string = "no exception" with | :? ArgumentOutOfRangeException -> "ArgumentOutOfRangeException" + | :? ArgumentNullException -> "ArgumentNullException" | :? ArgumentException -> "ArgumentException" | :? InvalidOperationException -> "InvalidOperationException" | :? Collections.Generic.KeyNotFoundException -> "KeyNotFoundException" + | :? FormatException -> "FormatException" | e -> "Unexpected exception: " + e.Message From 9fa53173a82e950c5586fb380a7c88cde9ffa456 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 17:58:36 -0600 Subject: [PATCH 5/6] fix(rust): reject odd-length input in Convert.FromHexString fromHexString dropped a trailing digit, so "abc" gave [| 0xABuy |]. It now panics like it does for a non-hex character, where .NET raises FormatException. --- src/fable-library-rust/src/Convert.rs | 3 +++ tests/Rust/tests/src/ConvertTests.fs | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/fable-library-rust/src/Convert.rs b/src/fable-library-rust/src/Convert.rs index 7aaf7b3e0..3531d764c 100644 --- a/src/fable-library-rust/src/Convert.rs +++ b/src/fable-library-rust/src/Convert.rs @@ -268,6 +268,9 @@ pub mod Convert_ { } } let chars = s.as_bytes(); + if chars.len() % 2 != 0 { + panic!("The input is not a valid hex string as its length is not a multiple of 2."); + } let bytes = chars .chunks_exact(2) .map(|x| decode(x[0]) << 4 | decode(x[1])) diff --git a/tests/Rust/tests/src/ConvertTests.fs b/tests/Rust/tests/src/ConvertTests.fs index cd71ebf03..2626b50b5 100644 --- a/tests/Rust/tests/src/ConvertTests.fs +++ b/tests/Rust/tests/src/ConvertTests.fs @@ -1018,6 +1018,11 @@ let ``Convert.FromHexString works`` () = Convert.FromHexString("fafBFCFDFE") |> equal [| 250uy; 251uy; 252uy; 253uy; 254uy |] +[] +let ``Convert.FromHexString checks its input`` () = + throwsAnyError (fun () -> Convert.FromHexString("abc")) + throwsAnyError (fun () -> Convert.FromHexString("zz")) + [] let ``Convert.ToBase64String works`` () = let bytes = [| 2uy; 4uy; 6uy; 8uy; 10uy; 12uy; 14uy; 16uy; 18uy; 20uy |] From 4d49e68f22bb67f6cf78a1f72d4b2be07eab93ae Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Fri, 30 Oct 2026 21:41:55 +0100 Subject: [PATCH 6/6] fix(js/ts): plain arrays from hex and base64 decoding --- src/Fable.Transforms/Replacements.fs | 16 ++++++++++++++-- src/fable-library-beam/fable_convert.erl | 1 - src/fable-library-py/fable_library/convert.py | 1 - 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 388913b52..f55b2b823 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -3432,6 +3432,13 @@ let bitConvert (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr option Helper.LibCall(com, "BitConverter", memberName, t, args, i.SignatureArgTypes, ?loc = r) |> Some +// The library decodes into a Uint8Array regardless of the typedArrays option +let private decodedBytes (com: ICompiler) r t expr = + if com.Options.TypedArrays then + expr + else + toArray r t expr + let convert (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr option) (args: Expr list) = match i.CompiledName with | "ToSByte" @@ -3456,14 +3463,19 @@ let convert (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr option) ( if not (List.isSingle args) then WarningCodes.base64ArgumentsIgnored |> addWarningWithCode com ctx.InlinePath r - Helper.LibCall(com, "String", (Naming.lowerFirst i.CompiledName), t, args, i.SignatureArgTypes, ?loc = r) - |> Some + let expr = + Helper.LibCall(com, "String", (Naming.lowerFirst i.CompiledName), t, args, i.SignatureArgTypes, ?loc = r) + + match i.CompiledName with + | "FromBase64String" -> decodedBytes com r t expr |> Some + | _ -> Some expr | "ToHexString" | "ToHexStringLower" -> Helper.LibCall(com, "String", Naming.lowerFirst i.CompiledName, t, args, i.SignatureArgTypes, ?loc = r) |> Some | "FromHexString" when List.isSingle args -> Helper.LibCall(com, "String", "fromHexString", t, args, i.SignatureArgTypes, ?loc = r) + |> decodedBytes com r t |> Some | _ -> None diff --git a/src/fable-library-beam/fable_convert.erl b/src/fable-library-beam/fable_convert.erl index b30f56dbe..e2aeebef6 100644 --- a/src/fable-library-beam/fable_convert.erl +++ b/src/fable-library-beam/fable_convert.erl @@ -206,7 +206,6 @@ to_base64(Bin) when is_binary(Bin) -> from_base64(Str) when is_binary(Str) -> fable_utils:new_byte_array(binary_to_list(base64:decode(Str))). -%% Hex encoding/decoding, checking the arguments like .NET's Convert.ToHexString and FromHexString to_hex_string(Bytes) -> binary:encode_hex(hex_input(Bytes)). diff --git a/src/fable-library-py/fable_library/convert.py b/src/fable-library-py/fable_library/convert.py index 5116fe965..a95a600d6 100644 --- a/src/fable-library-py/fable_library/convert.py +++ b/src/fable-library-py/fable_library/convert.py @@ -16,7 +16,6 @@ def _hex_input(in_array: Array[byte], offset: SupportsInt | None, length: SupportsInt | None) -> bytes: - """Return the bytes to convert to hex, checking the arguments like .NET.""" if in_array is None: raise ArgumentNullException__ctor_Z721C83C5("inArray") data = bytes(in_array)