diff --git a/Directory.Build.props b/Directory.Build.props index a819bc2..5504ed8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -21,7 +21,6 @@ True MIT Avatar.png - false diff --git a/Kotz.Extensions/INumberExt.cs b/Kotz.Extensions/INumberExt.cs new file mode 100644 index 0000000..20b4dae --- /dev/null +++ b/Kotz.Extensions/INumberExt.cs @@ -0,0 +1,225 @@ +using System.Diagnostics; +using System.Globalization; +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace Kotz.Extensions; + +/// +/// Provides methods for converting numbers between base 10 and other numeric bases. +/// +public static class NumberBaseExt +{ + /// + /// Converts the string representation of a number in the specified to an integer of type . + /// + /// The integer type to convert the string to. + /// The string representing the number. + /// The base of the representation in the string. + /// Negative numbers from certain bases cannot be converted and will throw an . + /// The converted to an integer of type . + /// + /// Occurs when is not representable in . + /// Occurs when is not representable in . + /// Occurs when contains characters that are not valid ASCII letters or digits. + /// + /// Occurs when a negative number could not be converted to decimal. + /// Occurs when the equivalent of .MaxValue or .MinValue are provided. + /// Occurs when contains invalid characters for the given base. + public static T FromDigits(this string number, int @base) where T : struct, IBinaryInteger + { + if (!number.All(char.IsAsciiLetterOrDigit)) + throw new ArgumentException("The string must contain ASCII letters or digits only.", nameof(number)); + + if (number.Any(x => (char.IsAsciiDigit(x)) ? x >= '0' + @base : (char.IsAsciiLetterUpper(x)) ? x >= 'A' + @base - 10 : x >= 'a' + @base - 10)) + throw new ArgumentException($"The number '{number}' cannot be represented in base {@base}", nameof(@base)); + + if (number.Length > FractionalDigits(Unsafe.SizeOf(), @base)) + throw new ArgumentException($"The number '{number}({@base})' cannot be represented as a {nameof(T)} type.", nameof(number)); + + if (number.All(x => x is '0')) + return T.Zero; + + if (@base is 10) + return T.Parse(number, NumberStyles.Integer, null); + + var positiveValue = FromPositiveDigits(number, @base); + + // I could not get this to work consistently for negative numbers + // If you have a solution, please submit a pull request! + return (number.Equals(positiveValue.ToDigits(@base), StringComparison.OrdinalIgnoreCase)) + ? positiveValue + : throw new InvalidOperationException($"The negative number '{number}' could not be converted from base {@base}."); + + // If the number is negative, subtract from the maximum value representable + // var maxRepresentableValue = T.CreateTruncating(Math.Pow(@base, number.Length)); + + // Calculate the two's complement value (negative) + // return positiveValue - maxRepresentableValue; + } + + /// + /// Converts the specified to its representation in the given . + /// + /// The numeric type of the . + /// The number to convert. + /// The base for the conversion. + /// A string representing the in the specified . + /// Occurs when is less than 2 or greater than 36. + /// Occurs when is 10 and returns from >. + /// Occurs when .MinValue is provided. + public static string ToDigits(this T number, int @base) where T : struct, IBinaryInteger + { + if (@base is 10) + return number.ToString() ?? throw new InvalidOperationException($"Type {nameof(T)} returned null for ToString()."); + + if (T.IsZero(number)) + return "0"; + + // Representations can span from 0-9 (base 2 to 10) to A-Z (base 11 to 36). + if (@base is < 2 or > 36) + throw new ArgumentOutOfRangeException(nameof(@base), @base, "Base must be between 2 and 36."); + + Span buffer = stackalloc char[FractionalDigits(Unsafe.SizeOf(), @base)]; + + var result = (int.IsPow2(@base)) + ? ToArbitraryBaseOptimized(number, @base, buffer) + : ToArbitraryBase(number, @base, buffer); + + return (result.Length > 1) + ? result.TrimStart('0').ToString() + : result.ToString(); + } + + /// + /// Converts a to the specified base, which must be a power of two. + /// If is negative, it will be represented using Two's Complement. + /// + /// The numeric type of the . + /// The number to convert. + /// The base (power of two) for the conversion. + /// The buffer that will store the digits of the converted number. + /// A span containing the digits of in the specified . + private static Span ToArbitraryBaseOptimized(T number, int @base, Span buffer) where T : IBinaryInteger + { + var truncatedBase = T.CreateTruncating(@base); + var counter = buffer.Length; + var bits = int.Log2(@base); + + do + { + var nextChar = '0' + uint.CreateTruncating((number & (truncatedBase - T.One))); + buffer[--counter] = (char)((nextChar > '9') ? nextChar + 7 : nextChar); + number >>>= bits; + } while (!T.IsZero(number)); + + return buffer[counter..]; + } + + /// + /// Converts a to the specified . + /// + /// The numeric type of the . + /// The number to convert. + /// The base for the conversion. + /// The buffer that will store the digits of the converted number. + /// A span containing the digits of in the specified . + /// Occurs when .MinValue is provided. + private static Span ToArbitraryBase(T number, int @base, Span buffer) where T : IBinaryInteger + { + if (T.IsNegative(number)) + return ToTwosComplement(number, @base, buffer); + + var truncatedBase = T.CreateTruncating(@base); + var counter = buffer.Length; + + do + { + var nextChar = '0' + uint.CreateTruncating(number % truncatedBase); + buffer[--counter] = (char)((nextChar > '9') ? nextChar + 7 : nextChar); + number /= @truncatedBase; + + } while (!T.IsZero(number)); + + return buffer[counter..]; + } + + /// + /// Converts a negative to the specified base using Two's Complement representation. + /// + /// The numeric type of the . + /// The negative number to convert. + /// The base (power of two) for the conversion. + /// The buffer that will store the digits of the converted number. + /// + /// A span containing the digits of in the specified , + /// represented using Two's Complement. + /// + /// Occurs when .MinValue is provided. + private static Span ToTwosComplement(T number, int @base, Span buffer) where T : IBinaryInteger + { + var truncatedNumber = T.Abs(number); + var truncatedBase = T.CreateTruncating(@base); + + // Convert number to @base while complementing it. + for (var index = buffer.Length - 1; index >= 0; index--) + { + var nextChar = '0' + uint.CreateTruncating((truncatedBase - T.One) - (truncatedNumber % truncatedBase)); + buffer[index] = (char)((nextChar > '9') ? nextChar + 7 : nextChar); + truncatedNumber /= truncatedBase; + } + + // Add 1 to the result in @base; + for (var index = buffer.Length - 1; index >= 0; index--) + { + if (++buffer[index] != @base + '0') + break; + + buffer[index] = '0'; + } + + return buffer; + } + + /// + /// Converts a positive from the specified to a . + /// + /// The numeric type of the . + /// The negative number to convert. + /// The base for the conversion. + /// The decimal representation of the . + /// Occurs when contains invalid digits. + private static T FromPositiveDigits(string number, int @base) where T : IBinaryInteger + { + var result = T.Zero; + var truncatedBase = T.CreateTruncating(@base); + var digitOffset = T.CreateTruncating('0'); + var upperOffset = T.CreateTruncating('A' - 10); + var lowerOffset = T.CreateTruncating('a' - 10); + + foreach (var digit in number) + { + var offset = digit switch + { + >= '0' and <= '9' => digitOffset, + >= 'A' and <= 'Z' => upperOffset, + >= 'a' and <= 'z' => lowerOffset, + _ => throw new UnreachableException($"Invalid character: {digit}") + }; + + result = (result * truncatedBase) + T.CreateTruncating(digit) - offset; + } + + return result; + } + + /// + /// Calculates the maximum amount of fractional digits a number can have for + /// a given base and type size. + /// + /// The size of the type, in bytes. + /// The base to calculate the digits for. + /// The amount of fractional digits in the number. + private static int FractionalDigits(int typeSize, int targetBase) + => (int)Math.Ceiling((typeSize * 8 * Math.Log(2)) / Math.Log(targetBase)); +} \ No newline at end of file diff --git a/Kotz.Extensions/README.md b/Kotz.Extensions/README.md index e37024f..269aa8d 100644 --- a/Kotz.Extensions/README.md +++ b/Kotz.Extensions/README.md @@ -57,6 +57,9 @@ Defines the following extension methods: - **List\ Extensions** - AsSpan: Gets the current list as a `Span` object. - AsReadOnlySpan: Gets the current list as a `ReadOnlySpan` object. +- **Number Extensions** + - FromBase: Converts the string representation of a number in the specified base to an integer of the specified type. + - ToBase: Converts the specified number to its representation in the given base. - **Object Extensions** - EqualsAny: Checks whether the current object equals any of the specified objects. - **ReadOnlySpan\ Extensions** diff --git a/Kotz.Tests/Extensions/NumberBaseExtTests.cs b/Kotz.Tests/Extensions/NumberBaseExtTests.cs new file mode 100644 index 0000000..9bba545 --- /dev/null +++ b/Kotz.Tests/Extensions/NumberBaseExtTests.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; + +namespace Kotz.Tests.Extensions; + +public sealed class NumberBaseExtTests +{ + [Theory] + [InlineData(2146483647, 2147483647, 2)] + [InlineData(2146483647, 2147483647, 8)] + [InlineData(2146483647, 2147483647, 16)] + [InlineData(0, 10000, 2)] + [InlineData(0, 10000, 8)] + [InlineData(0, 10000, 16)] + [InlineData(-10000, 0, 2)] + [InlineData(-10000, 0, 8)] + [InlineData(-10000, 0, 16)] + [InlineData(-2147483648, -2146483648, 2)] + [InlineData(-2147483648, -2146483648, 8)] + [InlineData(-2147483648, -2146483648, 16)] + internal void FromDigitsTest(int min, int max, int @base) + { + while (min < max) + { + var number = Convert.ToString(min, @base); + Assert.Equal($"{min}: {Convert.ToInt32(number, @base)}", $"{min}: {number.FromDigits(@base)}"); + min++; + } + } + + [Theory] + [InlineData(2146483647, 2147483647)] + [InlineData(0, 10000)] + [InlineData(-10000, 0)] + [InlineData(-2147483648, -2146483648)] + internal void ToBinaryTest(int min, int max) + { + while (min < max) + { + Assert.Equal($"{min}: {Convert.ToString(min, 2).ToUpperInvariant()}", $"{min}: {min.ToDigits(2)}"); + min++; + } + } + + [Theory] + [InlineData(2146483647, 2147483647)] + [InlineData(0, 10000)] + [InlineData(-10000, 0)] + [InlineData(-2147483648, -2146483648)] + internal void ToOctalTest(int min, int max) + { + while (min < max) + { + Assert.Equal($"{min}: {Convert.ToString(min, 8).ToUpperInvariant()}", $"{min}: {min.ToDigits(8)}"); + min++; + } + } + + [Theory] + [InlineData(2146483647, 2147483647)] + [InlineData(0, 10000)] + [InlineData(-10000, 0)] + [InlineData(-2147483648, -2146483648)] + internal void ToHexadecimalTest(int min, int max) + { + while (min < max) + { + Assert.Equal($"{min}: {Convert.ToString(min, 16).ToUpperInvariant()}", $"{min}: {min.ToDigits(16)}"); + min++; + } + } + + [Theory] + [InlineData(2147482647, 2147483647)] + [InlineData(0, 1000)] + // [InlineData(-10000, 0)] + // [InlineData(-2147483648, -2146483648)] + internal void CrossBaseTest(int min, int max) + { + for (var @base = 2; @base <= 36; @base++) + { + if (int.IsPow2(@base)) + continue; + + for (var number = min; number < max; number++) + { + var convertedNumber = number.ToDigits(@base); + Assert.Equal($"{number}({@base}): {convertedNumber}", $"{number}({@base}): {convertedNumber.FromDigits(@base).ToDigits(@base)}"); + } + } + } +} \ No newline at end of file