diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ea94d51 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,33 @@ +name: main + +on: + workflow_dispatch: + inputs: + package-version: + type: string + description: Package version + required: false + deploy: + type: boolean + description: Deploy package + required: false + default: false + push: + branches: + - prime + tags: + - "*" + pull_request: + branches: + - prime + +jobs: + main: + uses: Tyrrrz/.github/.github/workflows/nuget.yml@prime + with: + deploy: ${{ inputs.deploy || github.ref_type == 'tag' }} + package-version: ${{ inputs.package-version || (github.ref_type == 'tag' && github.ref_name) || format('0.0.0-ci-{0}', github.sha) }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20d4f31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# User-specific files +.vs/ +.idea/ +*.suo +*.user + +# Build results +bin/ +obj/ + +# Test results +TestResults/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0822769 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,31 @@ + + + 0.0.0-dev + Tyrrrz + Copyright (C) Oleksii Holub + preview + enable + true + false + + + + + annotations + + + + + false + false + + + + $(Company) + Collection of utilities and extensions for rapid .NET development + utils extensions utilities source + https://github.com/Tyrrrz/PowerKit + https://github.com/Tyrrrz/PowerKit/releases + MIT + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..04d12ca --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,15 @@ + + + true + + + + + + + + + + + + diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..68c3d52 --- /dev/null +++ b/License.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Oleksii Holub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..5a40e48 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs new file mode 100644 index 0000000..64a317b --- /dev/null +++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs @@ -0,0 +1,54 @@ +using System; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class AggregateExceptionExtensionsTests +{ + [Fact] + public void TryGetSingle_Test() + { + // Arrange + var inner = new Exception("only"); + + // Act & assert + new AggregateException(inner).TryGetSingle().Should().BeSameAs(inner); + } + + [Fact] + public void TryGetSingle_Multiple_Test() + { + // Act & assert + new AggregateException(new Exception("a"), new Exception("b")) + .TryGetSingle() + .Should() + .BeNull(); + } + + [Fact] + public void TryGetSingle_Nested_Test() + { + // Arrange + var leaf = new Exception("leaf"); + + // Act & assert + new AggregateException(new AggregateException(leaf)) + .TryGetSingle() + .Should() + .BeSameAs(leaf); + } + + [Fact] + public void TryGetSingle_NestedMultiple_Test() + { + // Act & assert + new AggregateException( + new AggregateException(new Exception("a"), new Exception("b")) + ) + .TryGetSingle() + .Should() + .BeNull(); + } +} diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs new file mode 100644 index 0000000..fdba27c --- /dev/null +++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class AsyncEnumerableExtensionsTests +{ + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) + { + await Task.Yield(); + yield return item; + } + } + + [Fact] + public async Task TakeAsync_Test() + { + // Act & assert + (await ToAsyncEnumerable([1, 2, 3, 4, 5]).TakeAsync(3).ToListAsync()) + .Should() + .Equal(1, 2, 3); + + (await ToAsyncEnumerable([1, 2, 3]).TakeAsync(0).ToListAsync()) + .Should() + .BeEmpty(); + + (await ToAsyncEnumerable([1, 2, 3]).TakeAsync(10).ToListAsync()) + .Should() + .Equal(1, 2, 3); + } + + [Fact] + public async Task SelectManyAsync_Test() + { + // Act & assert + (await ToAsyncEnumerable(["ab", "cd"]).SelectManyAsync(s => s.ToCharArray()).ToListAsync()) + .Should() + .Equal('a', 'b', 'c', 'd'); + } + + [Fact] + public async Task ToListAsync_Test() + { + // Act & assert + (await ToAsyncEnumerable([1, 2, 3]).ToListAsync()) + .Should() + .Equal(1, 2, 3); + } + + [Fact] + public async Task GetAwaiter_Test() + { + // Act & assert + (await ToAsyncEnumerable([10, 20, 30])) + .Should() + .Equal(10, 20, 30); + } +} diff --git a/PowerKit.Tests/ComparableExtensionsTests.cs b/PowerKit.Tests/ComparableExtensionsTests.cs new file mode 100644 index 0000000..c66747c --- /dev/null +++ b/PowerKit.Tests/ComparableExtensionsTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class ComparableExtensionsTests +{ + [Fact] + public void Clamp_Test() + { + // Act & assert + 5.Clamp(1, 10).Should().Be(5); + 1.Clamp(3, 10).Should().Be(3); + 20.Clamp(1, 7).Should().Be(7); + 3.14.Clamp(0.0, 3.0).Should().Be(3.0); + "banana".Clamp("apple", "cherry").Should().Be("banana"); + } + + [Fact] + public void Min_Test() + { + // Act & assert + 5.Min(3).Should().Be(3); + 2.Min(7).Should().Be(2); + 4.Min(4).Should().Be(4); + } + + [Fact] + public void Max_Test() + { + // Act & assert + 5.Max(3).Should().Be(5); + 2.Max(7).Should().Be(7); + 4.Max(4).Should().Be(4); + } +} diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs new file mode 100644 index 0000000..3ac1edd --- /dev/null +++ b/PowerKit.Tests/DisposableTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using PowerKit; +using Xunit; + +namespace PowerKit.Tests; + +public class DisposableTests +{ + [Fact] + public void Create_Test() + { + // Arrange + var invoked = false; + var disposable = Disposable.Create(() => invoked = true); + + // Act & assert + invoked.Should().BeFalse(); + disposable.Dispose(); + invoked.Should().BeTrue(); + } + + [Fact] + public void Merge_Test() + { + // Arrange + var order = new List(); + var disposables = Enumerable + .Range(0, 3) + .Select(i => Disposable.Create(() => order.Add(i))) + .ToArray(); + + // Act + Disposable.Merge(disposables).Dispose(); + + // Assert + order.Should().Equal(0, 1, 2); + } + + [Fact] + public void Merge_Exception_Test() + { + // Arrange + var disposed = new List(); + var disposables = new[] + { + Disposable.Create(() => disposed.Add(0)), + Disposable.Create(() => + { + disposed.Add(1); + throw new InvalidOperationException("fail"); + }), + Disposable.Create(() => disposed.Add(2)), + }; + + // Act + var ex = Assert.Throws(() => Disposable.Merge(disposables).Dispose()); + + // Assert + disposed.Should().Equal(0, 1, 2); + ex.InnerExceptions.Should().ContainSingle(); + ex.InnerExceptions[0].Should().BeOfType(); + } +} diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs new file mode 100644 index 0000000..c32dc15 --- /dev/null +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class EnumerableExtensionsTests +{ + [Fact] + public void ToSingletonEnumerable_Test() + { + // Act & assert + 42.ToSingletonEnumerable().ToArray().Should().Equal(42); + "hello".ToSingletonEnumerable().ToArray().Should().Equal("hello"); + ((string?)null).ToSingletonEnumerable().ToArray().Should().Equal((string?)null); + } + + [Fact] + public void WhereNotNull_Test() + { + // Act & assert + new string?[] { "a", null, "b", null, "c" }.WhereNotNull().Should().Equal("a", "b", "c"); + new int?[] { 1, null, 2, null, 3 }.WhereNotNull().Should().Equal(1, 2, 3); + Array.Empty().WhereNotNull().Should().BeEmpty(); + } + + [Fact] + public void WhereNotNullOrEmpty_Test() + { + // Act & assert + new string?[] { "hello", null, "", "world" } + .WhereNotNullOrEmpty() + .Should() + .Equal("hello", "world"); + new string?[] { "hello", " ", "", null } + .WhereNotNullOrEmpty() + .Should() + .Equal("hello", " "); + Array.Empty().WhereNotNullOrEmpty().Should().BeEmpty(); + } + + [Fact] + public void WhereNotNullOrWhiteSpace_Test() + { + // Act & assert + new string?[] { "hello", null, " ", "", "world" } + .WhereNotNullOrWhiteSpace() + .Should() + .Equal("hello", "world"); + } + + [Fact] + public void FirstOrNull_Test() + { + // Act & assert + new[] { 5, 10, 15 }.FirstOrNull().Should().Be(5); + new[] { 42 }.FirstOrNull().Should().Be(42); + Array.Empty().FirstOrNull().Should().BeNull(); + } + + [Fact] + public void LastOrNull_Test() + { + // Act & assert + new[] { 5, 10, 15 }.LastOrNull().Should().Be(15); + new[] { 42 }.LastOrNull().Should().Be(42); + Array.Empty().LastOrNull().Should().BeNull(); + } + + [Fact] + public void ElementAtOrNull_Test() + { + // Act & assert + new[] { 10, 20, 30 }.ElementAtOrNull(1).Should().Be(20); + new[] { 10, 20, 30 }.ElementAtOrNull(0).Should().Be(10); + new[] { 10, 20, 30 }.ElementAtOrNull(10).Should().BeNull(); + new[] { 10, 20, 30 }.ElementAtOrNull(-1).Should().BeNull(); + Array.Empty().ElementAtOrNull(0).Should().BeNull(); + } +} diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs new file mode 100644 index 0000000..c3a46ad --- /dev/null +++ b/PowerKit.Tests/ExceptionExtensionsTests.cs @@ -0,0 +1,66 @@ +using System; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class ExceptionExtensionsTests +{ + [Fact] + public void GetSelfAndDescendants_Test() + { + // Arrange + var ex = new Exception("root"); + + // Act & assert + ex.GetSelfAndDescendants().Should().Equal(ex); + } + + [Fact] + public void GetSelfAndDescendants_Chain_Test() + { + // Arrange + var inner = new Exception("inner"); + var outer = new Exception("outer", inner); + + // Act & assert + outer.GetSelfAndDescendants().Should().Equal(outer, inner); + } + + [Fact] + public void GetSelfAndDescendants_DeepChain_Test() + { + // Arrange + var leaf = new Exception("leaf"); + var middle = new Exception("middle", leaf); + var root = new Exception("root", middle); + + // Act & assert + root.GetSelfAndDescendants().Should().Equal(root, middle, leaf); + } + + [Fact] + public void GetSelfAndDescendants_Aggregate_Test() + { + // Arrange + var inner1 = new Exception("inner1"); + var inner2 = new Exception("inner2"); + var aggregate = new AggregateException("aggregate", inner1, inner2); + + // Act & assert + aggregate.GetSelfAndDescendants().Should().Equal(aggregate, inner1, inner2); + } + + [Fact] + public void GetSelfAndDescendants_NestedAggregate_Test() + { + // Arrange + var leaf = new Exception("leaf"); + var inner = new AggregateException("inner", leaf); + var root = new AggregateException("root", inner); + + // Act & assert + root.GetSelfAndDescendants().Should().Equal(root, inner, leaf); + } +} diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs new file mode 100644 index 0000000..ee9c76c --- /dev/null +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -0,0 +1,53 @@ +using System; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class FunctionalExtensionsTests +{ + [Fact] + public void Pipe_Test() + { + // Act & assert + 5.Pipe(x => x * 2).Should().Be(10); + "hello".Pipe(s => s.ToUpper()).Pipe(s => s + "!").Should().Be("HELLO!"); + } + + [Fact] + public void NullIf_Test() + { + // Act & assert + 0.NullIf(v => v == 0).Should().BeNull(); + 5.NullIf(v => v == 0).Should().Be(5); + } + + [Fact] + public void NullIfDefault_Test() + { + // Act & assert + 0.NullIfDefault().Should().BeNull(); + 42.NullIfDefault().Should().Be(42); + Guid.Empty.NullIfDefault().Should().BeNull(); + Guid.NewGuid().NullIfDefault().Should().NotBeNull(); + } + + [Fact] + public void NullIfEmpty_Test() + { + // Act & assert + "hello".NullIfEmpty().Should().Be("hello"); + "".NullIfEmpty().Should().BeNull(); + " ".NullIfEmpty().Should().Be(" "); + } + + [Fact] + public void NullIfWhiteSpace_Test() + { + // Act & assert + "hello".NullIfWhiteSpace().Should().Be("hello"); + " ".NullIfWhiteSpace().Should().BeNull(); + "".NullIfWhiteSpace().Should().BeNull(); + } +} diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs new file mode 100644 index 0000000..44efd7d --- /dev/null +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -0,0 +1,54 @@ +using System.IO; +using System.Linq; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class PathExtensionsTests +{ + [Fact] + public void GetInvalidFileNameChars_Test() + { + // Act & assert + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('/'); + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\\'); + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\0'); + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\x01'); + Path.GetInvalidFileNameChars(crossPlatform: false) + .Should() + .BeEquivalentTo(Path.GetInvalidFileNameChars()); + } + + [Fact] + public void GetInvalidPathChars_Test() + { + // Act & assert + Path.GetInvalidPathChars(crossPlatform: true).Should().Contain('\0'); + Path.GetInvalidPathChars(crossPlatform: true).Should().Contain('|'); + Path.GetInvalidPathChars(crossPlatform: true).Should().NotContain('/'); + Path.GetInvalidPathChars(crossPlatform: true).Should().NotContain('\\'); + Path.GetInvalidPathChars(crossPlatform: false) + .Should() + .BeEquivalentTo(Path.GetInvalidPathChars()); + } + + [Fact] + public void EscapeFileName_Test() + { + // Act & assert + Path.EscapeFileName("hello world.txt").Should().Be("hello world.txt"); + Path.EscapeFileName("a/b").Should().Be("a_b"); + Path.EscapeFileName("a\\b").Should().Be("a_b"); + Path.EscapeFileName("C:drive").Should().Be("C_drive"); + Path.EscapeFileName("a\0b/c\\d:e*f?g\"h + + + net10.0 + enable + false + + + + + + + + + + + + + + + + + + diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs new file mode 100644 index 0000000..5930c6d --- /dev/null +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Gress; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class StreamExtensionsTests +{ + [Fact] + public async Task CopyToAsync_AutoFlush_Test() + { + // Arrange + var data = new byte[] { 1, 2, 3, 4, 5 }; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + // Act + await source.CopyToAsync(destination, autoFlush: true); + + // Assert + destination.ToArray().Should().Equal(data); + } + + [Fact] + public async Task CopyToAsync_Progress_Test() + { + // Arrange + var data = new byte[1024]; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + var progress = new ProgressCollector(); + + // Act + await source.CopyToAsync(destination, progress: progress); + + // Assert + var reports = progress.GetValues().ToArray(); + reports.Should().NotBeEmpty(); + reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); + reports[^1].Should().BeApproximately(1.0, precision: 1e-5); + } + +} diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs new file mode 100644 index 0000000..a75cf84 --- /dev/null +++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs @@ -0,0 +1,31 @@ +using System.Text; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class StringBuilderExtensionsTests +{ + [Fact] + public void AppendIfNotEmpty_Test() + { + // Act & assert + new StringBuilder().AppendIfNotEmpty(',').ToString().Should().Be(""); + new StringBuilder("hello").AppendIfNotEmpty(',').ToString().Should().Be("hello,"); + new StringBuilder("a").AppendIfNotEmpty(',').Append("b").ToString().Should().Be("a,b"); + } + + [Fact] + public void Trim_Test() + { + // Act & assert + new StringBuilder(" hello ").Trim().ToString().Should().Be("hello"); + new StringBuilder(" hello").Trim().ToString().Should().Be("hello"); + new StringBuilder("hello ").Trim().ToString().Should().Be("hello"); + new StringBuilder("hello").Trim().ToString().Should().Be("hello"); + new StringBuilder().Trim().ToString().Should().Be(""); + new StringBuilder(" ").Trim().ToString().Should().Be(""); + new StringBuilder(" hello ").Trim().Append("!").ToString().Should().Be("hello!"); + } +} diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000..a60e97a --- /dev/null +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class StringExtensionsTests +{ + [Fact] + public void SubstringUntil_Test() + { + // Act & assert + "hello world".SubstringUntil(" ").Should().Be("hello"); + "hello".SubstringUntil("x").Should().Be("hello"); + "xhello".SubstringUntil("x").Should().Be(""); + } + + [Fact] + public void SubstringUntilLast_Test() + { + // Act & assert + "hello world foo".SubstringUntilLast(" ").Should().Be("hello world"); + "hello".SubstringUntilLast("x").Should().Be("hello"); + "a.b.c".SubstringUntilLast(".").Should().Be("a.b"); + } + + [Fact] + public void SubstringAfter_Test() + { + // Act & assert + "hello world".SubstringAfter(" ").Should().Be("world"); + "hello".SubstringAfter("x").Should().Be(""); + "hellox".SubstringAfter("x").Should().Be(""); + } + + [Fact] + public void SubstringAfterLast_Test() + { + // Act & assert + "hello world foo".SubstringAfterLast(" ").Should().Be("foo"); + "hello".SubstringAfterLast("x").Should().Be(""); + "a.b.c".SubstringAfterLast(".").Should().Be("c"); + } + + [Fact] + public void Truncate_Test() + { + // Act & assert + "hi".Truncate(10).Should().Be("hi"); + "hello".Truncate(5).Should().Be("hello"); + "hello".Truncate(3).Should().Be("hel"); + } + + [Fact] + public void SeparateWords_Test() + { + // Act & assert + "HelloWorld".SeparateWords(' ').Should().Be("Hello World"); + "Hello".SeparateWords(' ').Should().Be("Hello"); + "hello".SeparateWords(' ').Should().Be("hello"); + "".SeparateWords(' ').Should().Be(""); + "FooBarBaz".SeparateWords(' ').Should().Be("Foo Bar Baz"); + "FooBarBaz".SeparateWords('-').Should().Be("Foo-Bar-Baz"); + } + + [Fact] + public void ToKebabCase_Test() + { + // Act & assert + "HelloWorld".ToKebabCase().Should().Be("hello-world"); + "FooBarBaz".ToKebabCase().Should().Be("foo-bar-baz"); + "Hello".ToKebabCase().Should().Be("hello"); + "hello".ToKebabCase().Should().Be("hello"); + "".ToKebabCase().Should().Be(""); + } + + [Fact] + public void ToSnakeCase_Test() + { + // Act & assert + "HelloWorld".ToSnakeCase().Should().Be("hello_world"); + "FooBarBaz".ToSnakeCase().Should().Be("foo_bar_baz"); + "Hello".ToSnakeCase().Should().Be("hello"); + "hello".ToSnakeCase().Should().Be("hello"); + "".ToSnakeCase().Should().Be(""); + } +} diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs new file mode 100644 index 0000000..2ec67a3 --- /dev/null +++ b/PowerKit.Tests/TextReaderExtensionsTests.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Threading.Tasks; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests; + +public class TextReaderExtensionsTests +{ + [Fact] + public async Task ReadLinesAsync_Test() + { + // Arrange + using var reader = new StringReader("line1\nline2\nline3"); + + // Act + var result = await reader.ReadLinesAsync().ToListAsync(); + + // Assert + result.Should().Equal("line1", "line2", "line3"); + } + + [Fact] + public async Task ReadLinesAsync_Empty_Test() + { + // Arrange + using var reader = new StringReader(""); + + // Act + var result = await reader.ReadLinesAsync().ToListAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task ReadLinesAsync_SingleLine_Test() + { + // Arrange + using var reader = new StringReader("hello"); + + // Act + var result = await reader.ReadLinesAsync().ToListAsync(); + + // Assert + result.Should().Equal("hello"); + } +} diff --git a/PowerKit.slnx b/PowerKit.slnx new file mode 100644 index 0000000..eb54d4e --- /dev/null +++ b/PowerKit.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/PowerKit/Disposable.cs b/PowerKit/Disposable.cs new file mode 100644 index 0000000..eb2179f --- /dev/null +++ b/PowerKit/Disposable.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace PowerKit; + +internal partial class Disposable(Action dispose) : IDisposable +{ + public void Dispose() => dispose(); +} + +internal partial class Disposable +{ + public static IDisposable Null { get; } = Create(() => { }); + + public static IDisposable Create(Action dispose) => new Disposable(dispose); + + public static IDisposable Merge(params IEnumerable disposables) => + Create(() => + { + List? exceptions = null; + + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + (exceptions ??= []).Add(ex); + } + } + + if (exceptions?.Count > 0) + { + throw new AggregateException(exceptions); + } + }); +} diff --git a/PowerKit/Extensions/AggregateExceptionExtensions.cs b/PowerKit/Extensions/AggregateExceptionExtensions.cs new file mode 100644 index 0000000..c0d0fea --- /dev/null +++ b/PowerKit/Extensions/AggregateExceptionExtensions.cs @@ -0,0 +1,12 @@ +using System; + +namespace PowerKit.Extensions; + +internal static class AggregateExceptionExtensions +{ + extension(AggregateException exception) + { + public Exception? TryGetSingle() => + exception.Flatten().InnerExceptions is [var single] ? single : null; + } +} diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs new file mode 100644 index 0000000..b3ac565 --- /dev/null +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerKit.Extensions; + +internal static class AsyncEnumerableExtensions +{ + extension(IAsyncEnumerable source) + { + public async IAsyncEnumerable TakeAsync( + int count, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + if (count <= 0) + yield break; + + var currentCount = 0; + + await using var enumerator = source.GetAsyncEnumerator(cancellationToken); + + while ( + currentCount < count + && await enumerator.MoveNextAsync().ConfigureAwait(false) + ) + { + yield return enumerator.Current; + currentCount++; + } + } + + public async IAsyncEnumerable SelectManyAsync( + Func> transform, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + await foreach ( + var item in source + .WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) + { + foreach (var result in transform(item)) + { + yield return result; + } + } + } + + public async ValueTask> ToListAsync( + CancellationToken cancellationToken = default + ) + { + var list = new List(); + + await foreach ( + var item in source + .WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) + { + list.Add(item); + } + + return list; + } + + public ValueTaskAwaiter> GetAwaiter() => source.ToListAsync().GetAwaiter(); + } +} diff --git a/PowerKit/Extensions/ComparableExtensions.cs b/PowerKit/Extensions/ComparableExtensions.cs new file mode 100644 index 0000000..b8e0639 --- /dev/null +++ b/PowerKit/Extensions/ComparableExtensions.cs @@ -0,0 +1,24 @@ +using System; + +namespace PowerKit.Extensions; + +internal static class ComparableExtensions +{ + extension(T value) where T : IComparable + { + public T Clamp(T min, T max) + { + if (value.CompareTo(min) < 0) + return min; + + if (value.CompareTo(max) > 0) + return max; + + return value; + } + + public T Min(T other) => value.CompareTo(other) <= 0 ? value : other; + + public T Max(T other) => value.CompareTo(other) >= 0 ? value : other; + } +} diff --git a/PowerKit/Extensions/EnumerableExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..378c712 --- /dev/null +++ b/PowerKit/Extensions/EnumerableExtensions.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; + +namespace PowerKit.Extensions; + +internal static class EnumerableExtensions +{ + extension(T obj) + { + public IEnumerable ToSingletonEnumerable() + { + yield return obj; + } + } + + extension(IEnumerable source) + where T : class + { + public IEnumerable WhereNotNull() + { + foreach (var item in source) + { + if (item is not null) + { + yield return item; + } + } + } + } + + extension(IEnumerable source) + where T : struct + { + public IEnumerable WhereNotNull() + { + foreach (var item in source) + { + if (item is not null) + { + yield return item.Value; + } + } + } + } + + extension(IEnumerable source) + { + public IEnumerable WhereNotNullOrEmpty() + { + foreach (var item in source) + { + if (!string.IsNullOrEmpty(item)) + { + yield return item!; + } + } + } + + public IEnumerable WhereNotNullOrWhiteSpace() + { + foreach (var item in source) + { + if (!string.IsNullOrWhiteSpace(item)) + { + yield return item!; + } + } + } + } + + extension(IEnumerable source) + where T : struct + { + public T? FirstOrNull() + { + foreach (var item in source) + { + return item; + } + + return null; + } + + public T? LastOrNull() + { + if (source is IReadOnlyList list) + { + return list.Count > 0 ? list[list.Count - 1] : null; + } + + var last = default(T?); + + foreach (var item in source) + { + last = item; + } + + return last; + } + + public T? ElementAtOrNull(int index) + { + var list = source as IReadOnlyList ?? source.ToArray(); + return index >= 0 && index < list.Count ? list[index] : null; + } + } +} diff --git a/PowerKit/Extensions/ExceptionExtensions.cs b/PowerKit/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..ab721f7 --- /dev/null +++ b/PowerKit/Extensions/ExceptionExtensions.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace PowerKit.Extensions; + +internal static class ExceptionExtensions +{ + extension(Exception exception) + { + public IReadOnlyList GetSelfAndDescendants() + { + static void PopulateDescendants(Exception ex, ICollection result) + { + if (ex is AggregateException aggregateException) + { + foreach (var innerException in aggregateException.InnerExceptions) + { + result.Add(innerException); + PopulateDescendants(innerException, result); + } + } + else if (ex.InnerException is not null) + { + result.Add(ex.InnerException); + PopulateDescendants(ex.InnerException, result); + } + } + + var result = new List { exception }; + PopulateDescendants(exception, result); + return result; + } + } +} diff --git a/PowerKit/Extensions/FunctionalExtensions.cs b/PowerKit/Extensions/FunctionalExtensions.cs new file mode 100644 index 0000000..5e40566 --- /dev/null +++ b/PowerKit/Extensions/FunctionalExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace PowerKit.Extensions; + +internal static class FunctionalExtensions +{ + extension(TIn input) + { + public TOut Pipe(Func transform) => transform(input); + } + + extension(T value) + where T : struct + { + public T? NullIf(Func predicate) => !predicate(value) ? value : null; + + public T? NullIfDefault() => + value.NullIf(v => EqualityComparer.Default.Equals(v, default)); + } + + extension(string value) + { + public string? NullIfEmpty() => !string.IsNullOrEmpty(value) ? value : null; + + public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(value) ? value : null; + } +} diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs new file mode 100644 index 0000000..003ddc7 --- /dev/null +++ b/PowerKit/Extensions/PathExtensions.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace PowerKit.Extensions; + +file static class PathEx +{ + // Characters that are invalid in file names across all major filesystems + // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what + // the OS-specific Path.GetInvalidFileNameChars() returns. + // This is useful when working with files that may be accessed from + // different operating systems, such as NTFS drives on Linux. + public static readonly char[] CrossPlatformInvalidFileNameChars = + [ + '\0', // Null character - invalid on all filesystems + '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - + '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', // invalid on Windows + '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', // (NTFS/FAT32) + '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F', + '/', // Path separator on Unix and Windows + '\\', // Path separator on Windows + ':', // Reserved on Windows (drive letters, NTFS streams) + '*', // Wildcard on Windows + '?', // Wildcard on Windows + '"', // Reserved on Windows + '<', // Redirection on Windows + '>', // Redirection on Windows + '|', // Pipe on Windows + ]; + + // Path chars are the same as file name chars, except path separators + // and the colon (drive letter separator) are valid in paths. + public static readonly char[] CrossPlatformInvalidPathChars = + CrossPlatformInvalidFileNameChars + .Where(ch => ch != '/' && ch != '\\' && ch != ':') + .ToArray(); +} + +internal static class PathExtensions +{ + extension(Path) + { + public static char[] GetInvalidFileNameChars(bool crossPlatform) => + crossPlatform + ? PathEx.CrossPlatformInvalidFileNameChars + : Path.GetInvalidFileNameChars(); + + public static char[] GetInvalidPathChars(bool crossPlatform) => + crossPlatform + ? PathEx.CrossPlatformInvalidPathChars + : Path.GetInvalidPathChars(); + + public static string EscapeFileName(string fileName, bool crossPlatform = true) + { + var invalidChars = new HashSet(Path.GetInvalidFileNameChars(crossPlatform)); + var buffer = new StringBuilder(fileName.Length); + + foreach (var ch in fileName) + { + buffer.Append(!invalidChars.Contains(ch) ? ch : '_'); + } + + // File names cannot end with a dot or whitespace (invalid on Windows, ambiguous on other filesystems) + while (buffer.Length > 0 && (buffer[buffer.Length - 1] == '.' || char.IsWhiteSpace(buffer[buffer.Length - 1]))) + { + buffer.Remove(buffer.Length - 1, 1); + } + + return buffer.ToString(); + } + } +} diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs new file mode 100644 index 0000000..e392e83 --- /dev/null +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -0,0 +1,90 @@ +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerKit.Extensions; + +internal static class StreamExtensions +{ + extension(Stream source) + { + public async Task CopyToAsync( + Stream destination, + bool autoFlush, + CancellationToken cancellationToken = default + ) + { + using var buffer = MemoryPool.Shared.Rent(81920); + + while (true) + { + var bytesRead = await source + .ReadAsync(buffer.Memory, cancellationToken) + .ConfigureAwait(false); + + if (bytesRead <= 0) + { + break; + } + + await destination + .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) + .ConfigureAwait(false); + + if (autoFlush) + { + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + public async ValueTask CopyToAsync( + Stream destination, + long contentLength, + IProgress? progress, + CancellationToken cancellationToken = default + ) + { + using var buffer = MemoryPool.Shared.Rent(81920); + + var totalBytesRead = 0L; + + while (true) + { + var bytesRead = await source + .ReadAsync(buffer.Memory, cancellationToken) + .ConfigureAwait(false); + + if (bytesRead <= 0) + { + break; + } + + await destination + .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) + .ConfigureAwait(false); + + totalBytesRead += bytesRead; + + if (progress is not null && contentLength > 0) + { + progress.Report(1.0 * totalBytesRead / contentLength); + } + } + } + + public async ValueTask CopyToAsync( + Stream destination, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var contentLength = source.CanSeek ? source.Length : -1; + await source + .CopyToAsync(destination, contentLength, progress, cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/PowerKit/Extensions/StringBuilderExtensions.cs b/PowerKit/Extensions/StringBuilderExtensions.cs new file mode 100644 index 0000000..d8e73df --- /dev/null +++ b/PowerKit/Extensions/StringBuilderExtensions.cs @@ -0,0 +1,39 @@ +using System.Text; + +namespace PowerKit.Extensions; + +internal static class StringBuilderExtensions +{ + extension(StringBuilder builder) + { + public StringBuilder AppendIfNotEmpty(char value) => + builder.Length > 0 ? builder.Append(value) : builder; + + public StringBuilder Trim() + { + var start = 0; + while (start < builder.Length && char.IsWhiteSpace(builder[start])) + { + start++; + } + + var end = builder.Length - 1; + while (end >= start && char.IsWhiteSpace(builder[end])) + { + end--; + } + + if (end < builder.Length - 1) + { + builder.Remove(end + 1, builder.Length - end - 1); + } + + if (start > 0) + { + builder.Remove(0, start); + } + + return builder; + } + } +} diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs new file mode 100644 index 0000000..2320da9 --- /dev/null +++ b/PowerKit/Extensions/StringExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Text; + +namespace PowerKit.Extensions; + +internal static class StringExtensions +{ + extension(string str) + { + public string SubstringUntil( + string sub, + StringComparison comparison = StringComparison.Ordinal + ) => + str.IndexOf(sub, comparison) switch + { + >= 0 and var index => str[..index], + _ => str, + }; + + public string SubstringUntilLast( + string sub, + StringComparison comparison = StringComparison.Ordinal + ) => + str.LastIndexOf(sub, comparison) switch + { + >= 0 and var index => str[..index], + _ => str, + }; + + public string SubstringAfter( + string sub, + StringComparison comparison = StringComparison.Ordinal + ) => + str.IndexOf(sub, comparison) switch + { + >= 0 and var index => str[(index + sub.Length)..], + _ => "", + }; + + public string SubstringAfterLast( + string sub, + StringComparison comparison = StringComparison.Ordinal + ) => + str.LastIndexOf(sub, comparison) switch + { + >= 0 and var index => str[(index + sub.Length)..], + _ => "", + }; + + public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str; + + public string SeparateWords(char separator) + { + var builder = new StringBuilder(str.Length * 2); + + foreach (var ch in str) + { + if (char.IsUpper(ch) && builder.Length > 0) + { + builder.Append(separator); + } + + builder.Append(ch); + } + + return builder.ToString(); + } + + public string ToKebabCase() => str.SeparateWords('-').ToLowerInvariant(); + + public string ToSnakeCase() => str.SeparateWords('_').ToLowerInvariant(); + } +} diff --git a/PowerKit/Extensions/TextReaderExtensions.cs b/PowerKit/Extensions/TextReaderExtensions.cs new file mode 100644 index 0000000..08e1323 --- /dev/null +++ b/PowerKit/Extensions/TextReaderExtensions.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace PowerKit.Extensions; + +internal static class TextReaderExtensions +{ + extension(TextReader reader) + { + public async IAsyncEnumerable ReadLinesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + while ( + await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line + ) + { + yield return line; + } + } + } +} diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj new file mode 100644 index 0000000..eddfde8 --- /dev/null +++ b/PowerKit/PowerKit.csproj @@ -0,0 +1,35 @@ + + + netstandard2.0 + true + + + + true + false + true + + + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +}