diff --git a/.gitea/workflows/publish-nuget.yaml b/.gitea/workflows/publish-nuget.yaml index 37792c4..e382161 100644 --- a/.gitea/workflows/publish-nuget.yaml +++ b/.gitea/workflows/publish-nuget.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup .NET - uses: https://github.com/actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.x diff --git a/.gitea/workflows/test-dotnet.yaml b/.gitea/workflows/test-dotnet.yaml index e2ce90f..e233e01 100644 --- a/.gitea/workflows/test-dotnet.yaml +++ b/.gitea/workflows/test-dotnet.yaml @@ -6,6 +6,7 @@ on: tags-ignore: - '**' paths-ignore: + - 'LICENSE' - 'README.md' - '.gitea/workflows/publish-*.yaml' pull_request: @@ -31,7 +32,7 @@ jobs: 10.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore --disable-parallel - name: Build .NET 10.0 run: dotnet build --no-restore --framework net10.0 --configuration Release ./Core.Tests/Core.Tests.csproj diff --git a/.vscode/settings.json b/.vscode/settings.json index b3259f8..1c7de17 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { - "dotnet.defaultSolution": "JustDotNet.Core.sln", + "dotnet.defaultSolution": "JustDotNet.Core.slnx", + "omnisharp.enableEditorConfigSupport": true, "dotnetAcquisitionExtension.enableTelemetry": false, - "dotnet.testWindow.useTestingPlatformProtocol": true + "dotnet.testWindow.useTestingPlatformProtocol": true, + "dotnet.formatting.organizeImportsOnFormat": true } diff --git a/Core.Tests/Base32Conversions/Decode.cs b/Core.Tests/Base32Conversions/Decode.cs index f474c04..5030d85 100644 --- a/Core.Tests/Base32Conversions/Decode.cs +++ b/Core.Tests/Base32Conversions/Decode.cs @@ -3,11 +3,31 @@ namespace Just.Core.Tests.Base32Conversions; public class Decode { [Theory] - [InlineData(15243)] - [InlineData(812010)] - [InlineData(97331334)] - [InlineData(20354)] - public void WhenEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed) + [InlineData(15243, Base32EncodeOptions.None)] + [InlineData(15243, Base32EncodeOptions.LowerCase)] + [InlineData(15243, Base32EncodeOptions.NoPadding)] + [InlineData(15243, Base32EncodeOptions.LowerCaseNoPadding)] + [InlineData(812010, Base32EncodeOptions.None)] + [InlineData(812010, Base32EncodeOptions.LowerCase)] + [InlineData(812010, Base32EncodeOptions.NoPadding)] + [InlineData(812010, Base32EncodeOptions.LowerCaseNoPadding)] + [InlineData(97331334, Base32EncodeOptions.None)] + [InlineData(97331334, Base32EncodeOptions.LowerCase)] + [InlineData(97331334, Base32EncodeOptions.NoPadding)] + [InlineData(97331334, Base32EncodeOptions.LowerCaseNoPadding)] + [InlineData(20354, Base32EncodeOptions.None)] + [InlineData(20354, Base32EncodeOptions.LowerCase)] + [InlineData(20354, Base32EncodeOptions.NoPadding)] + [InlineData(20354, Base32EncodeOptions.LowerCaseNoPadding)] + [InlineData(33409, Base32EncodeOptions.None)] + [InlineData(33409, Base32EncodeOptions.LowerCase)] + [InlineData(33409, Base32EncodeOptions.NoPadding)] + [InlineData(33409, Base32EncodeOptions.LowerCaseNoPadding)] + [InlineData(59113, Base32EncodeOptions.None)] + [InlineData(59113, Base32EncodeOptions.LowerCase)] + [InlineData(59113, Base32EncodeOptions.NoPadding)] + [InlineData(59113, Base32EncodeOptions.LowerCaseNoPadding)] + public void WhenEncodedToString_ShouldBeDecodedToTheSameByteArray(int seed, Base32EncodeOptions options) { var rng = new Random(seed); @@ -16,7 +36,7 @@ public class Decode var testBytes = new byte[i]; rng.NextBytes(testBytes); - var resultString = Base32.Encode(testBytes); + var resultString = Base32.Encode(testBytes, options); var resultBytes = Base32.Decode(resultString); resultBytes.ShouldBeEquivalentTo(testBytes); @@ -25,10 +45,18 @@ public class Decode [Theory] [InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })] + [InlineData("fg4m3zqm3tvwdmbup5l7n7v3js7kbm2e", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })] [InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ====", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })] + [InlineData("wxyeoquzulmcy6zqtdoltruzzmkq====", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })] [InlineData("2IO2HTALCXZWCBD2", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, })] [InlineData("ZFXJMF5N", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })] + [InlineData("zfxjmf5n", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })] [InlineData("CPIKTMY=", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })] + [InlineData("EFCDEAA=", new byte[] { 0x21, 0x44, 0x32, 0x00, })] + [InlineData("EFCDEAI=", new byte[] { 0x21, 0x44, 0x32, 0x01, })] + [InlineData("EFCDEAQ=", new byte[] { 0x21, 0x44, 0x32, 0x02, })] + [InlineData("EFCDEAY=", new byte[] { 0x21, 0x44, 0x32, 0x03, })] + [InlineData("EFCDEBA=", new byte[] { 0x21, 0x44, 0x32, 0x04, })] [InlineData("JVNJA===", new byte[] { 0b01001101, 0b01011010, 0b10010000, })] [InlineData("74OQ====", new byte[] { 0b11111111, 0b00011101, })] public void WhenCalledWithValidString_ShouldReturnValidByteArray(string str, byte[] expected) @@ -59,14 +87,41 @@ public class Decode [Theory] [InlineData(" ")] - [InlineData("hg2515i3215")] + [InlineData("hg2515i3215q")] [InlineData("hg712)21")] - [InlineData("hg712f 21")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1806:Do not ignore method results", Justification = "Test case")] + [InlineData("hg712f 211")] + [InlineData("AEBAGB^F")] public void WhenCalledWithNotValidString_ShouldThrowFormatException(string testString) { - Action action = () => Base32.Decode(testString); - action.ShouldThrow(); + Action action = () => _ = Base32.Decode(testString); + action.ShouldThrow() + .WithMessage("Provided string contains invalid characters."); + } + + [Theory] + [InlineData("A")] + [InlineData("AAA")] + [InlineData("AAAAAA")] + [InlineData("AEBAGBAFA")] + [InlineData("AEBAGBAFAAA")] + [InlineData("AEBAGBAFAAAAAA")] + public void WhenCalledWithInvalidStringLength_ShouldThrowFormatException(string testString) + { + Action action = () => _ = Base32.Decode(testString); + action.ShouldThrow() + .WithMessage("Invalid Base32 string length."); + } + + [Theory] + [InlineData("ABCDEFG")] + [InlineData("AAAAAAB")] + [InlineData("EFCDF5J")] + [InlineData("EFCDF5P")] + public void WhenCalledWithInvalidLastByteEncoding_ShouldThrowFormatException(string testString) + { + Action action = () => _ = Base32.Decode(testString); + action.ShouldThrow() + .WithMessage("Invalid Base32 string. Inconsistent tail bits."); } [Theory] @@ -76,4 +131,26 @@ public class Decode { Base32.Decode(testString).ShouldBeEmpty(); } + + [Theory] + [InlineData("====")] + [InlineData("============")] + public void WhenCalledWithOnlyPadding_ShouldReturnEmptyArray(string testString) + { + Base32.Decode(testString).ShouldBeEmpty(); + } + + [Theory] + [InlineData("AEBAGBAF", 1)] + [InlineData("AEBAGBAF", 2)] + [InlineData("AEBAGBAF", 3)] + [InlineData("AEBAGBAFAY", 5)] + [InlineData("AEBAGBAFAYDQ", 6)] + public void SpanTooSmall_ThrowsArgumentException(string input, int outputLength) + { + byte[] output = new byte[outputLength]; + Action action = () => _ = Base32.Decode(input, output); + action.ShouldThrow() + .WithMessage("Decoded input can not fit in output span. (Parameter 'output')"); + } } diff --git a/Core.Tests/Base32Conversions/Encode.cs b/Core.Tests/Base32Conversions/Encode.cs index 230afc0..d19ace3 100644 --- a/Core.Tests/Base32Conversions/Encode.cs +++ b/Core.Tests/Base32Conversions/Encode.cs @@ -4,6 +4,7 @@ public class Encode { [Theory] [InlineData("TQ======")] + [InlineData("CE======")] [InlineData("3X3A====")] [InlineData("426G6===")] [InlineData("C3V3Y===")] @@ -30,8 +31,10 @@ public class Encode { var resultBytes = Base32.Decode(testString); var resultString = Base32.Encode(resultBytes); + var resultStringLowerCase = Base32.Encode(resultBytes, Base32EncodeOptions.LowerCase); resultString.ShouldBe(testString); + resultStringLowerCase.ShouldBe(testString.ToLowerInvariant()); } [Theory] @@ -47,7 +50,29 @@ public class Encode public void WhenCalledWithNotEmptyByteArray_ShouldReturnValidString(string expected, byte[] testArray) { var str = Base32.Encode(testArray); + var strLowerCase = Base32.Encode(testArray, Base32EncodeOptions.LowerCase); + str.ShouldBe(expected); + strLowerCase.ShouldBe(expected.ToLowerInvariant()); + } + + [Theory] + [InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })] + [InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })] + [InlineData("2IO2HTALCXZWCBD2AAAAAAAAAAAA", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, })] + [InlineData("2IO2HTALCXZWCBD2AAAAAAAA", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, })] + [InlineData("2IO2HTALCXZWCBD2", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, })] + [InlineData("ZFXJMF5N", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })] + [InlineData("CPIKTMY", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })] + [InlineData("JVNJA", new byte[] { 0b01001101, 0b01011010, 0b10010000, })] + [InlineData("74OQ", new byte[] { 0b11111111, 0b00011101, })] + public void WhenCalledWithNotEmptyByteArray_ShouldReturnValidStringWithNoPadding(string expected, byte[] testArray) + { + var str = Base32.Encode(testArray, Base32EncodeOptions.NoPadding); + var strLowerCase = Base32.Encode(testArray, Base32EncodeOptions.NoPadding | Base32EncodeOptions.LowerCase); + + str.ShouldBe(expected); + strLowerCase.ShouldBe(expected.ToLowerInvariant()); } [Theory] @@ -71,4 +96,20 @@ public class Encode charsWritten.ShouldBe(0); output.ShouldBe(['1', '2', '3', '4']); } + + [Theory] + [InlineData(new byte[] { 1, 2, 3 }, 3)] + [InlineData(new byte[] { 1, 2, 3 }, 4)] + [InlineData(new byte[] { 1, 2, 3 }, 5)] + [InlineData(new byte[] { 1, 2, 3 }, 6)] + [InlineData(new byte[] { 1, 2, 3 }, 7)] + [InlineData(new byte[] { 1, 2, 3, 4, 5, 6 }, 15)] + [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, 23)] + public void SpanTooSmall_ThrowsArgumentException(byte[] input, int outputLength) + { + var output = new char[outputLength]; + Action action = () => _ = Base32.Encode(input, output); + action.ShouldThrow() + .WithMessage("Encoded input can not fit in output span. (Parameter 'output')"); + } } diff --git a/Core.Tests/Core.Tests.csproj b/Core.Tests/Core.Tests.csproj index acb1688..3a71c44 100644 --- a/Core.Tests/Core.Tests.csproj +++ b/Core.Tests/Core.Tests.csproj @@ -16,8 +16,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Core.Tests/SeqIdTests/NextId.cs b/Core.Tests/SeqIdTests/NextId.cs index 8d55af0..b99f17e 100644 --- a/Core.Tests/SeqIdTests/NextId.cs +++ b/Core.Tests/SeqIdTests/NextId.cs @@ -127,15 +127,18 @@ public class NextId public void DefaultInstance_NextId_ShouldUseDefaultEpoch() { // Arrange - var now = DateTime.UtcNow; - var defaultEpoch = SeqId.DefaultEpoch; - long expectedTimestamp = ((long)(now - defaultEpoch).TotalMilliseconds) & TimestampMask; // Mask handles overflow + var now = new DateTime(2026, 6, 3, 21, 11, 1, DateTimeKind.Utc); + SeqId.Default.UnsafeReplaceDefaultTimeFactory(() => now); // Act long id = SeqId.NextId(); // Assert + long expectedTimestamp = GetExpectedTimestamp(now); + long timestampPart = (id >> TimestampShift) & TimestampMask; timestampPart.ShouldBeInRange(expectedTimestamp, expectedTimestamp + 1); + + static long GetExpectedTimestamp(DateTime now) => ((long)(now - SeqId.DefaultEpoch).TotalMilliseconds) & TimestampMask; // Mask handles overflow } } \ No newline at end of file diff --git a/Core/Base32.cs b/Core/Base32.cs index 816b961..9b3514a 100644 --- a/Core/Base32.cs +++ b/Core/Base32.cs @@ -1,28 +1,37 @@ namespace Just.Core; +public enum Base32EncodeOptions +{ + None = 0x00, + LowerCase = 0x01, + NoPadding = 0x02, + LowerCaseNoPadding = 0x03, +} + public static class Base32 { public const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + public const string AlphabetLower = "abcdefghijklmnopqrstuvwxyz234567"; public const char Padding = '='; public const int MaxBytesStack = 250; [Pure] - public static string Encode(ReadOnlySpan input) + public static string Encode(ReadOnlySpan input, Base32EncodeOptions options = Base32EncodeOptions.None) { if (input.IsEmpty) return string.Empty; int outLength = 8 * ((input.Length + 4) / 5); - Span output = input.Length <= MaxBytesStack - ? stackalloc char[outLength] - : new char[outLength]; + Span output = input.Length > MaxBytesStack + ? new char[outLength] + : stackalloc char[outLength]; - var size = Encode(input, output); + var size = Encode(input, output, options); return new string(output[..size]); } [Pure] - public static int Encode(ReadOnlySpan input, Span output) + public static int Encode(ReadOnlySpan input, Span output, Base32EncodeOptions options = Base32EncodeOptions.None) { if (input.IsEmpty) return 0; @@ -34,25 +43,63 @@ public static class Base32 output = output[..outputLength]; - int i = 0; - ReadOnlySpan alphabet = Alphabet; + ReadOnlySpan alphabet = (options & Base32EncodeOptions.LowerCase) == Base32EncodeOptions.LowerCase + ? AlphabetLower + : Alphabet; Span alphabetKeys = stackalloc byte[8]; + int i = 0; for (int offset = 0; offset < input.Length;) { alphabetKeys.Clear(); int numCharsToOutput = GetNextGroup(input, ref offset, alphabetKeys); - output[i++] = (numCharsToOutput > 0) ? alphabet[alphabetKeys[0]] : Padding; - output[i++] = (numCharsToOutput > 1) ? alphabet[alphabetKeys[1]] : Padding; - output[i++] = (numCharsToOutput > 2) ? alphabet[alphabetKeys[2]] : Padding; - output[i++] = (numCharsToOutput > 3) ? alphabet[alphabetKeys[3]] : Padding; - output[i++] = (numCharsToOutput > 4) ? alphabet[alphabetKeys[4]] : Padding; - output[i++] = (numCharsToOutput > 5) ? alphabet[alphabetKeys[5]] : Padding; - output[i++] = (numCharsToOutput > 6) ? alphabet[alphabetKeys[6]] : Padding; - output[i++] = (numCharsToOutput > 7) ? alphabet[alphabetKeys[7]] : Padding; + output[i++] = alphabet[alphabetKeys[0]]; + output[i++] = alphabet[alphabetKeys[1]]; + + if (numCharsToOutput < 3) + { + i = FillWithPadding(output, i, numCharsToOutput, options); + break; + } + output[i++] = alphabet[alphabetKeys[2]]; + output[i++] = alphabet[alphabetKeys[3]]; + + if (numCharsToOutput < 5) + { + i = FillWithPadding(output, i, numCharsToOutput, options); + break; + } + output[i++] = alphabet[alphabetKeys[4]]; + + if (numCharsToOutput < 6) + { + i = FillWithPadding(output, i, numCharsToOutput, options); + break; + } + output[i++] = alphabet[alphabetKeys[5]]; + output[i++] = alphabet[alphabetKeys[6]]; + + if (numCharsToOutput < 8) + { + i = FillWithPadding(output, i, numCharsToOutput, options); + break; + } + output[i++] = alphabet[alphabetKeys[7]]; } + return i; + + static int FillWithPadding(Span output, int i, int numCharsToOutput, Base32EncodeOptions options) + { + if ((options & Base32EncodeOptions.NoPadding) == Base32EncodeOptions.NoPadding) + { + return i; + } + var pad = 8 - numCharsToOutput; + output[i..(i+pad)].Fill(Padding); + return i + pad; + } } [Pure] @@ -61,10 +108,10 @@ public static class Base32 input = input.TrimEnd(Padding); if (input.IsEmpty) return []; - var outputLength = 5 * ((input.Length + 7) / 8); - Span output = outputLength <= MaxBytesStack - ? stackalloc byte[outputLength] - : new byte[outputLength]; + var outputLength = 5 * input.Length / 8; + Span output = outputLength > MaxBytesStack + ? new byte[outputLength] + : stackalloc byte[outputLength]; var size = Decode(input, output); @@ -76,7 +123,13 @@ public static class Base32 { input = input.TrimEnd(Padding); - var outputLength = 5 * ((input.Length + 7) / 8); + var rem = input.Length % 8; + if (rem == 1 || rem == 3 || rem == 6) + { + throw new FormatException("Invalid Base32 string length."); + } + + var outputLength = 5 * input.Length / 8; if (output.Length < outputLength) { throw new ArgumentException("Decoded input can not fit in output span.", nameof(output)); @@ -85,9 +138,9 @@ public static class Base32 output = output[..outputLength]; output.Clear(); - Span inputspan = outputLength <= MaxBytesStack - ? stackalloc char[input.Length] - : new char[input.Length]; + Span inputspan = outputLength > MaxBytesStack + ? new char[input.Length] + : stackalloc char[input.Length]; input.ToUpperInvariant(inputspan); int bitIndex = 0; @@ -127,10 +180,17 @@ public static class Base32 inputIndex++; bitIndex = 0; } - else if (inputIndex == input.Length -1) break; + else if (inputIndex == input.Length -1) + { + if ((byteIndex & ~(0x1f << (bitPos - bits))) > 0) + { + throw new FormatException("Invalid Base32 string. Inconsistent tail bits."); + } + break; + } } - return outputIndex + (outputBits + 7) / 8; + return outputIndex; } // returns the number of bytes that were output @@ -145,7 +205,8 @@ public static class Base32 4 => 7, _ => 8, }; - uint b1 = (offset < input.Length) ? input[offset++] : 0U; + + uint b1 = input[offset++]; uint b2 = (offset < input.Length) ? input[offset++] : 0U; uint b3 = (offset < input.Length) ? input[offset++] : 0U; uint b4 = (offset < input.Length) ? input[offset++] : 0U; diff --git a/Core/Collections/DataMap.cs b/Core/Collections/DataMap.cs index cabf336..a590f7e 100644 --- a/Core/Collections/DataMap.cs +++ b/Core/Collections/DataMap.cs @@ -1,6 +1,5 @@ using System.Numerics; using System.Runtime.InteropServices; -using Just.Core.Extensions; namespace Just.Core.Collections; @@ -122,14 +121,14 @@ public static class DataMap where T : unmanaged, IComparisonOperators, IEqualityOperators { byte[] headBytes = new byte[HeaderSize]; - await stream.PopulateAsync(headBytes, cancellationToken); + await stream.ReadExactlyAsync(headBytes, cancellationToken); var head = MemoryMarshal.Read
(headBytes); var bodySize = DataMap.ElementSize * head.Width * head.Height; if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch."); byte[] bodyBytes = new byte[bodySize]; - await stream.PopulateAsync(bodyBytes, cancellationToken); + await stream.ReadExactlyAsync(bodyBytes, cancellationToken); T[] body = MemoryMarshal.Cast(bodyBytes).ToArray(); return new DataMap((int)head.Width, (int)head.Height, body); @@ -157,7 +156,7 @@ public static class DataMap { Header head = default; var headSpan = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref head, 1)); - stream.Populate(headSpan); + stream.ReadExactly(headSpan); var bodySize = DataMap.ElementSize * head.Width * head.Height; if (bodySize != head.BodySize) throw new InvalidOperationException("Can not read DataMap. Element size mismatch."); @@ -165,7 +164,7 @@ public static class DataMap T[] body = new T[bodySize]; var bodySpan = MemoryMarshal.AsBytes(body.AsSpan()); - stream.Populate(bodySpan); + stream.ReadExactly(bodySpan); return new DataMap((int)head.Width, (int)head.Height, body); } diff --git a/Core/Collections/ImmutableSequence.cs b/Core/Collections/ImmutableSequence.cs index 1fe1463..12f3f08 100644 --- a/Core/Collections/ImmutableSequence.cs +++ b/Core/Collections/ImmutableSequence.cs @@ -3,6 +3,22 @@ using System.Collections.Immutable; namespace Just.Core.Collections; +/// +/// Represents an immutable, ordered sequence of items with value‑equality semantics. +/// +/// The type of elements in the sequence. +/// +/// +/// This class is a thin wrapper around that implements +/// , , and value‑based equality. +/// All modifications return new instances, leaving the +/// original unchanged. +/// +/// +/// Subclasses may override to ensure mutation methods return +/// the correct derived type. +/// +/// public class ImmutableSequence : IEnumerable, IReadOnlyList, @@ -11,17 +27,61 @@ public class ImmutableSequence : private static readonly Func CompareItem = EqualityComparer.Default.Equals; private readonly ImmutableList _values; + /// + /// Initializes a new empty instance of the class. + /// public ImmutableSequence() => _values = []; + + /// + /// Initializes a new instance of the class that + /// wraps the specified . + /// + /// The immutable list to wrap. public ImmutableSequence(ImmutableList values) => _values = values; + + /// + /// Initializes a new instance of the class with + /// the elements from the provided enumerable sequence. + /// + /// The items to include in the sequence. public ImmutableSequence(IEnumerable values) => _values = [..values]; + + /// + /// Initializes a new instance of the class with + /// the elements from the provided read‑only span. + /// + /// The items to include in the sequence. public ImmutableSequence(ReadOnlySpan values) : this(ImmutableList.Create(values)) { } + /// + /// Gets a value indicating whether the sequence contains any elements. + /// public bool IsEmpty => _values.IsEmpty; + + /// + /// Gets the number of elements in the sequence. + /// public int Count => _values.Count; + + /// + /// Gets the element at the specified zero‑based index. + /// + /// The zero‑based index of the element to get. + /// The element at the specified index. public T this[int index] => _values[index]; + /// + /// Gets the element at the specified position from the start or end of the sequence. + /// + /// An value (e.g., ^1 for the last element). + /// The element at the specified position. public T this[Index index] => _values[index]; + /// + /// Gets a new containing the elements in the specified range. + /// + /// The range of elements to include. + /// A new sequence representing the slice. public ImmutableSequence this[Range range] { get @@ -31,25 +91,60 @@ public class ImmutableSequence : } } + /// + /// Creates a new from the provided immutable list. + /// Subclasses can override this to return instances of a more specific type. + /// + /// The immutable list that will become the internal storage. + /// A new sequence containing the given items. protected virtual ImmutableSequence ConstructNew(ImmutableList values) => [..values]; + /// + /// Returns a new sequence with the specified value appended to the end. + /// + /// The value to add. + /// A new sequence containing the original items followed by . public ImmutableSequence Add(T value) => ConstructNew(_values.Add(value)); + /// + /// Returns a new sequence with the specified value inserted at the beginning. + /// + /// The value to add. + /// A new sequence that starts with and then contains the original items. public ImmutableSequence AddFront(T value) => ConstructNew(_values.Insert(0, value)); + /// + /// Returns an enumerator that iterates through the sequence. + /// + /// A value type enumerator. public ImmutableList.Enumerator GetEnumerator() => _values.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); public override string ToString() => string.Join(Environment.NewLine, _values); + /// + /// Determines whether this sequence is equal to another . + /// Equality is based on the number of elements and the element‑wise equality comparison + /// using the default equality comparer for . + /// + /// The sequence to compare with this instance. Can be null. + /// + /// true if the sequences have the same length and all elements are equal; + /// false otherwise. + /// public virtual bool Equals([NotNullWhen(true)] ImmutableSequence? other) { + if (other is null) + { + return false; + } + if (ReferenceEquals(this, other)) { return true; } - if (_values.Count != other?._values.Count) + if (_values.Count != other._values.Count) { return false; } @@ -65,7 +160,16 @@ public class ImmutableSequence : return true; } + /// + /// Determines whether the specified object is equal to the current sequence. + /// + /// The object to compare with the current sequence. + /// true if is an and equals this instance; otherwise false. public override bool Equals([NotNullWhen(true)] object? obj) => Equals(obj as ImmutableSequence); + /// + /// Serves as a hash function for the sequence. + /// + /// A hash code that incorporates all elements in order. public override int GetHashCode() { HashCode hash = new(); @@ -78,6 +182,18 @@ public class ImmutableSequence : return hash.ToHashCode(); } + /// + /// Determines whether two sequences are equal. + /// + /// The first sequence to compare. + /// The second sequence to compare. + /// true if both sequences are null or they are considered equal; otherwise false. public static bool operator ==(ImmutableSequence? left, ImmutableSequence? right) => left is null ? right is null : left.Equals(right); + /// + /// Determines whether two sequences are not equal. + /// + /// The first sequence to compare. + /// The second sequence to compare. + /// true if the sequences are not equal; otherwise false. public static bool operator !=(ImmutableSequence? left, ImmutableSequence? right) => !(left == right); } diff --git a/Core/Collections/Map.cs b/Core/Collections/Map.cs index 86ed521..fddb368 100644 --- a/Core/Collections/Map.cs +++ b/Core/Collections/Map.cs @@ -68,8 +68,8 @@ public class Map : IMap { get { - x = Math.Clamp(x, 0, Width); - y = Math.Clamp(y, 0, Height); + x = Math.Clamp(x, 0, Width - 1); + y = Math.Clamp(y, 0, Height - 1); return ref _values[(y * Width) + x]; } } diff --git a/Core/Core.csproj b/Core/Core.csproj index 710389b..6c689ef 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -10,7 +10,7 @@ Small .Net library with useful helper classes, functions and extensions. extensions;helpers;helper-functions JustFixMe - Copyright (c) 2023-2025 JustFixMe + Copyright (c) 2023-2026 JustFixMe LICENSE README.md https://github.com/JustFixMe/Just.Core/ diff --git a/Core/Extensions/SystemIOStreamExtensions.cs b/Core/Extensions/SystemIOStreamExtensions.cs index 258b09c..9a6384d 100644 --- a/Core/Extensions/SystemIOStreamExtensions.cs +++ b/Core/Extensions/SystemIOStreamExtensions.cs @@ -3,6 +3,7 @@ namespace Just.Core.Extensions; /// /// Provides extension methods for to fully populate buffers. /// +[Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static class SystemIOStreamExtensions { /// @@ -16,6 +17,7 @@ public static class SystemIOStreamExtensions /// Thrown when or is invalid /// Thrown if the stream ends before filling the buffer /// Thrown for I/O errors during reading + [Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static void Populate(this Stream stream, byte[] buffer, int offset, int length) => stream.Populate(buffer.AsSpan(offset, length)); /// @@ -26,6 +28,7 @@ public static class SystemIOStreamExtensions /// Thrown when is null /// Thrown if the stream ends before filling the buffer /// Thrown for I/O errors during reading + [Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static void Populate(this Stream stream, byte[] buffer) => stream.Populate(buffer.AsSpan()); /// @@ -36,6 +39,7 @@ public static class SystemIOStreamExtensions /// Thrown when is null /// Thrown if the stream ends before filling the buffer /// Thrown for I/O errors during reading + [Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static void Populate(this Stream stream, Span buffer) { ArgumentNullException.ThrowIfNull(stream); @@ -64,6 +68,7 @@ public static class SystemIOStreamExtensions /// Thrown if the stream ends before filling the buffer /// Thrown if canceled via cancellation token /// Thrown for I/O errors during reading + [Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken = default) => stream.PopulateAsync(buffer.AsMemory(), cancellationToken); /// @@ -80,6 +85,7 @@ public static class SystemIOStreamExtensions /// Thrown if the stream ends before filling the buffer /// Thrown if canceled via cancellation token /// Thrown for I/O errors during reading + [Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, int offset, int length, CancellationToken cancellationToken = default) => stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken); /// @@ -93,6 +99,7 @@ public static class SystemIOStreamExtensions /// Thrown if the stream ends before filling the buffer /// Thrown if canceled via cancellation token /// Thrown for I/O errors during reading + [Obsolete("Stream.ReadExactly and Stream.ReadExactlyAsync have the same functionality. Will be removed in the next version.")] public static async ValueTask PopulateAsync(this Stream stream, Memory buffer, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); diff --git a/Core/GuidV8.cs b/Core/GuidV8.cs index 0e1fab4..e31f7dc 100644 --- a/Core/GuidV8.cs +++ b/Core/GuidV8.cs @@ -7,6 +7,7 @@ public static class GuidV8 private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ExcludeFromCodeCoverage] public static Guid NewGuid(RngEntropy entropy = RngEntropy.Strong) => NewGuid(DateTime.UtcNow, entropy); public static Guid NewGuid(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong) diff --git a/Core/SeqId.cs b/Core/SeqId.cs index ab1af6f..a85b30f 100644 --- a/Core/SeqId.cs +++ b/Core/SeqId.cs @@ -51,7 +51,7 @@ public sealed class SeqId(DateTime epoch) /// Thrown if more than 255 IDs generated in 1ms /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(DateTime.UtcNow, entropy); + public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(entropy); #if NET9_0_OR_GREATER private readonly Lock _lock = new(); @@ -62,6 +62,7 @@ public sealed class SeqId(DateTime epoch) private readonly DateTime _epoch = epoch; private int _seqId = 0; private long _lastTimestamp = -1L; + private Func _defaultTimeFactory = static () => DateTime.UtcNow; /// /// Generates next ID using current UTC time @@ -72,7 +73,7 @@ public sealed class SeqId(DateTime epoch) /// Thrown if more than 255 IDs generated in 1ms /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(DateTime.UtcNow, entropy); + public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(_defaultTimeFactory(), entropy); /// /// Generates next ID with explicit timestamp @@ -104,7 +105,7 @@ public sealed class SeqId(DateTime epoch) throw new InvalidOperationException("Refused to create new SeqId. Last timestamp is in the future."); } - if (_seqId == SeqMask) + if (_seqId > SeqMask) { throw new IndexOutOfRangeException("Refused to create new SeqId. Sequence exhausted."); } @@ -118,4 +119,6 @@ public sealed class SeqId(DateTime epoch) return timestamp | currentSeq | currentRand; } + + internal void UnsafeReplaceDefaultTimeFactory(Func timeFactory) => _defaultTimeFactory = timeFactory; } diff --git a/JustDotNet.Core.sln b/JustDotNet.Core.sln deleted file mode 100644 index a9adb91..0000000 --- a/JustDotNet.Core.sln +++ /dev/null @@ -1,28 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{C709D8C9-FE18-4B70-ABE0-57A1850C3398}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Tests", "Core.Tests\Core.Tests.csproj", "{CBA236E8-5CAC-4587-AD7C-A480CD998EB8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C709D8C9-FE18-4B70-ABE0-57A1850C3398}.Release|Any CPU.Build.0 = Release|Any CPU - {CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CBA236E8-5CAC-4587-AD7C-A480CD998EB8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/JustDotNet.Core.slnx b/JustDotNet.Core.slnx new file mode 100644 index 0000000..192eaa8 --- /dev/null +++ b/JustDotNet.Core.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/LICENSE b/LICENSE index ded1dcf..cf771b2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023-2025 JustFixMe +Copyright (c) 2023-2026 JustFixMe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal