From 63ddfb5d9cf0d271b1c6409f140a79575b835140 Mon Sep 17 00:00:00 2001 From: Antyss77 Date: Sun, 9 Aug 2026 00:40:03 +0200 Subject: [PATCH 1/3] refactor: split extensions into StringExtension/Validation/Linguistics/Casing namespaces, rewrite letter-classifying methods with Rune for correct Unicode handling BREAKING CHANGE: IsValidEmail, IsValidPhoneNumber now require 'using StringExtension.Validation;'. IsPalindrome, CountLetters now require 'using StringExtension.Linguistics;'. ToCamelCase now requires 'using StringExtension.Casing;'. --- Benchmarks/Benchmark.cs | 3 + StringExtension/Casing/Casing.cs | 79 ++++++++ StringExtension/Internal/BufferLimits.cs | 14 ++ StringExtension/Linguistics/Linguistics.cs | 114 +++++++++++ StringExtension/StringExtension.cs | 214 +-------------------- StringExtension/Validation/Validation.cs | 63 ++++++ UnitTests/StringExtensionTests.cs | 48 +++++ 7 files changed, 326 insertions(+), 209 deletions(-) create mode 100644 StringExtension/Casing/Casing.cs create mode 100644 StringExtension/Internal/BufferLimits.cs create mode 100644 StringExtension/Linguistics/Linguistics.cs create mode 100644 StringExtension/Validation/Validation.cs diff --git a/Benchmarks/Benchmark.cs b/Benchmarks/Benchmark.cs index d970a85..1afebcc 100644 --- a/Benchmarks/Benchmark.cs +++ b/Benchmarks/Benchmark.cs @@ -3,6 +3,9 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Running; using StringExtension; +using StringExtension.Casing; +using StringExtension.Linguistics; +using StringExtension.Validation; BenchmarkRunner.Run( ManualConfig.Create(DefaultConfig.Instance.WithOptions(ConfigOptions.DisableOptimizationsValidator))); diff --git a/StringExtension/Casing/Casing.cs b/StringExtension/Casing/Casing.cs new file mode 100644 index 0000000..0e53036 --- /dev/null +++ b/StringExtension/Casing/Casing.cs @@ -0,0 +1,79 @@ +using System.Buffers; +using System.Text; +using StringExtension.Internal; + +namespace StringExtension.Casing; + +/// +/// Provides extension methods for converting the casing of strings. +/// +public static class Casing +{ + /// + /// Converts the given string to camel case. + /// + /// The input string. + /// The input string converted to camel case. + public static string ToCamelCase(this string input) + { + return ToCamelCase(input.AsSpan()); + } + + /// + /// Converts the given span of characters to camel case. + /// + /// The input characters. + /// The input converted to camel case. + /// + /// Cases whole Unicode scalar values () rather than UTF-16 + /// code units, so characters outside the Basic Multilingual Plane are handled + /// correctly instead of having casing applied to unpaired surrogate halves. + /// + public static string ToCamelCase(this ReadOnlySpan input) + { + if (input.IsEmpty) + { + return string.Empty; + } + + char[]? pooledBuffer = null; + Span buffer = (uint)input.Length <= BufferLimits.StackAllocThreshold + ? stackalloc char[input.Length] + : (pooledBuffer = ArrayPool.Shared.Rent(input.Length)); + + try + { + var count = 0; + var shouldCapitalize = false; + var isFirstWrittenRune = true; + + foreach (var rune in input.EnumerateRunes()) + { + if (Rune.IsWhiteSpace(rune) || rune.Value == '_') + { + shouldCapitalize = true; + continue; + } + + // The very first character is always lowercase, even if the input + // started with a separator (e.g. "_hello" gives "hello", not "Hello"). + var cased = isFirstWrittenRune || !shouldCapitalize + ? Rune.ToLowerInvariant(rune) + : Rune.ToUpperInvariant(rune); + + count += cased.EncodeToUtf16(buffer[count..]); + shouldCapitalize = false; + isFirstWrittenRune = false; + } + + return new string(buffer[..count]); + } + finally + { + if (pooledBuffer is not null) + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + } +} \ No newline at end of file diff --git a/StringExtension/Internal/BufferLimits.cs b/StringExtension/Internal/BufferLimits.cs new file mode 100644 index 0000000..9a6a543 --- /dev/null +++ b/StringExtension/Internal/BufferLimits.cs @@ -0,0 +1,14 @@ +namespace StringExtension.Internal; + +/// +/// Shared implementation constants. Not part of the public API. +/// +internal static class BufferLimits +{ + /// + /// Above this length, buffers are rented from + /// instead of stack-allocated, to avoid excessive stack usage for large inputs. 512 chars + /// (1024 bytes) matches the safe stackalloc limit used throughout the .NET base class library. + /// + internal const int StackAllocThreshold = 512; +} \ No newline at end of file diff --git a/StringExtension/Linguistics/Linguistics.cs b/StringExtension/Linguistics/Linguistics.cs new file mode 100644 index 0000000..c1ec2c1 --- /dev/null +++ b/StringExtension/Linguistics/Linguistics.cs @@ -0,0 +1,114 @@ +using System.Buffers; +using System.Text; +using StringExtension.Internal; + +namespace StringExtension.Linguistics; + +/// +/// Provides extension methods for linguistic analysis of strings. +/// +public static class Linguistics +{ + /// + /// Determines if the given string is a palindrome. + /// + /// The input string. + /// true if the string is a palindrome; otherwise, false. + public static bool IsPalindrome(this string input) + { + return IsPalindrome(input.AsSpan()); + } + + /// + /// Determines if the given span of characters is a palindrome. + /// + /// The input characters. + /// true if the characters form a palindrome; otherwise, false. + /// + /// Compares whole Unicode scalar values () rather than UTF-16 + /// code units, so multi-char characters (e.g. characters outside the Basic + /// Multilingual Plane, such as many emoji) are compared correctly instead of + /// being split into unpaired surrogate halves. + /// + public static bool IsPalindrome(this ReadOnlySpan input) + { + if (input.IsEmpty) + { + return false; + } + + Rune[]? pooledBuffer = null; + Span letters = input.Length <= BufferLimits.StackAllocThreshold + ? stackalloc Rune[input.Length] + : (pooledBuffer = ArrayPool.Shared.Rent(input.Length)); + + try + { + var count = 0; + foreach (var rune in input.EnumerateRunes()) + { + if (Rune.IsLetter(rune)) + { + letters[count++] = rune; + } + } + + var left = 0; + var right = count - 1; + + while (left < right) + { + if (Rune.ToLowerInvariant(letters[left]) != Rune.ToLowerInvariant(letters[right])) + { + return false; + } + + left++; + right--; + } + + return true; + } + finally + { + if (pooledBuffer is not null) + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + } + + /// + /// Counts the number of letters in the given string. + /// + /// The input string. + /// The number of letters in the input string. + public static int CountLetters(this string input) + { + return CountLetters(input.AsSpan()); + } + + /// + /// Counts the number of letters in the given span of characters. + /// + /// The input characters. + /// The number of letters in the input. + /// + /// Classifies whole Unicode scalar values () rather than UTF-16 + /// code units, so letters outside the Basic Multilingual Plane are counted + /// correctly instead of being missed as unpaired surrogate halves. + /// + public static int CountLetters(this ReadOnlySpan input) + { + var count = 0; + foreach (var rune in input.EnumerateRunes()) + { + if (Rune.IsLetter(rune)) + { + count++; + } + } + + return count; + } +} \ No newline at end of file diff --git a/StringExtension/StringExtension.cs b/StringExtension/StringExtension.cs index 84b9aa4..4421662 100644 --- a/StringExtension/StringExtension.cs +++ b/StringExtension/StringExtension.cs @@ -1,34 +1,13 @@ using System.Buffers; -using System.Text.RegularExpressions; +using StringExtension.Internal; namespace StringExtension; /// -/// Provides extension methods for string manipulation. +/// Provides general-purpose extension methods for string manipulation. /// -public static partial class StringExtension +public static class StringExtension { - /// - /// Above this length, buffers are rented from instead - /// of stack-allocated, to avoid excessive stack usage for large inputs. 512 chars - /// (1024 bytes) matches the safe stackalloc limit used throughout the BCL. - /// - private const int StackAllocThreshold = 512; - - /// - /// Represents a regular expression that can be used to validate an email address. - /// - /// A regular expression that can be used to validate an email address. - [GeneratedRegex(@"[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+")] - private static partial Regex MailAddressRegex(); - - /// - /// Represents a regular expression that can be used to validate a phone number. - /// - /// A regular expression that can be used to validate a phone number. - [GeneratedRegex(@"^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$")] - private static partial Regex PhoneNumberRegex(); - /// /// Removes specified characters from the given string. /// @@ -60,7 +39,7 @@ public static string RemoveCharacters(this ReadOnlySpan input, ReadOnlySpa } char[]? pooledBuffer = null; - Span buffer = (uint)input.Length <= StackAllocThreshold + Span buffer = (uint)input.Length <= BufferLimits.StackAllocThreshold ? stackalloc char[input.Length] : (pooledBuffer = ArrayPool.Shared.Rent(input.Length)); @@ -86,46 +65,6 @@ public static string RemoveCharacters(this ReadOnlySpan input, ReadOnlySpa } } - /// - /// Validates the given email address. - /// - /// The email address to validate. - /// true if the given email address is valid; otherwise, false. - public static bool IsValidEmail(this string email) - { - return !string.IsNullOrEmpty(email) && MailAddressRegex().IsMatch(email); - } - - /// - /// Validates the given email address. - /// - /// The email address to validate. - /// true if the given email address is valid; otherwise, false. - public static bool IsValidEmail(this ReadOnlySpan email) - { - return !email.IsEmpty && MailAddressRegex().IsMatch(email); - } - - /// - /// Validates the given phone number. - /// - /// The phone number to validate. - /// true if the given phone number is valid; otherwise, false. - public static bool IsValidPhoneNumber(this string phoneNumber) - { - return !string.IsNullOrEmpty(phoneNumber) && PhoneNumberRegex().IsMatch(phoneNumber); - } - - /// - /// Validates the given phone number. - /// - /// The phone number to validate. - /// true if the given phone number is valid; otherwise, false. - public static bool IsValidPhoneNumber(this ReadOnlySpan phoneNumber) - { - return !phoneNumber.IsEmpty && PhoneNumberRegex().IsMatch(phoneNumber); - } - /// /// Counts the number of occurrences of a substring in the given string. /// @@ -188,87 +127,6 @@ public static string ReverseWords(this string input) }); } - /// - /// Determines if the given string is a palindrome. - /// - /// The input string. - /// true if the string is a palindrome; otherwise, false. - public static bool IsPalindrome(this string input) - { - return IsPalindrome(input.AsSpan()); - } - - /// - /// Determines if the given span of characters is a palindrome. - /// Zero-allocation: compares from both ends inward, skipping non-letters. - /// - /// The input characters. - /// true if the characters form a palindrome; otherwise, false. - public static bool IsPalindrome(this ReadOnlySpan input) - { - if (input.IsEmpty) - { - return false; - } - - var left = 0; - var right = input.Length - 1; - - while (left < right) - { - if (!char.IsLetter(input[left])) - { - left++; - continue; - } - - if (!char.IsLetter(input[right])) - { - right--; - continue; - } - - if (char.ToLowerInvariant(input[left]) != char.ToLowerInvariant(input[right])) - { - return false; - } - - left++; - right--; - } - - return true; - } - - /// - /// Counts the number of letters in the given string. - /// - /// The input string. - /// The number of letters in the input string. - public static int CountLetters(this string input) - { - return CountLetters(input.AsSpan()); - } - - /// - /// Counts the number of letters in the given span of characters. - /// - /// The input characters. - /// The number of letters in the input. - public static int CountLetters(this ReadOnlySpan input) - { - var count = 0; - foreach (var c in input) - { - if (char.IsLetter(c)) - { - count++; - } - } - - return count; - } - /// /// Removes duplicate characters from the given string. /// @@ -299,7 +157,7 @@ public static string RemoveDuplicateCharacters(this ReadOnlySpan input) } char[]? pooledBuffer = null; - Span buffer = (uint)input.Length <= StackAllocThreshold + Span buffer = (uint)input.Length <= BufferLimits.StackAllocThreshold ? stackalloc char[input.Length] : (pooledBuffer = ArrayPool.Shared.Rent(input.Length)); @@ -326,66 +184,4 @@ public static string RemoveDuplicateCharacters(this ReadOnlySpan input) } } } - - /// - /// Converts the given string to camel case. - /// - /// The input string. - /// The input string converted to camel case. - public static string ToCamelCase(this string input) - { - return ToCamelCase(input.AsSpan()); - } - - /// - /// Converts the given span of characters to camel case. - /// - /// The input characters. - /// The input converted to camel case. - public static string ToCamelCase(this ReadOnlySpan input) - { - if (input.IsEmpty) - { - return string.Empty; - } - - char[]? pooledBuffer = null; - Span buffer = (uint)input.Length <= StackAllocThreshold - ? stackalloc char[input.Length] - : (pooledBuffer = ArrayPool.Shared.Rent(input.Length)); - - try - { - var count = 0; - var shouldCapitalize = false; - - foreach (var c in input) - { - if (char.IsWhiteSpace(c) || c == '_') - { - shouldCapitalize = true; - continue; - } - - buffer[count++] = shouldCapitalize ? char.ToUpperInvariant(c) : char.ToLowerInvariant(c); - shouldCapitalize = false; - } - - // The very first character is always lowercase, even if the input - // started with a separator (e.g. "_hello" gives "hello", not "Hello"). - if (count > 0) - { - buffer[0] = char.ToLowerInvariant(buffer[0]); - } - - return new string(buffer[..count]); - } - finally - { - if (pooledBuffer is not null) - { - ArrayPool.Shared.Return(pooledBuffer); - } - } - } } \ No newline at end of file diff --git a/StringExtension/Validation/Validation.cs b/StringExtension/Validation/Validation.cs new file mode 100644 index 0000000..814f603 --- /dev/null +++ b/StringExtension/Validation/Validation.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; + +namespace StringExtension.Validation; + +/// +/// Provides extension methods for validating common string formats. +/// +public static partial class Validation +{ + /// + /// Represents a regular expression that can be used to validate an email address. + /// + /// A regular expression that can be used to validate an email address. + [GeneratedRegex(@"[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+")] + private static partial Regex MailAddressRegex(); + + /// + /// Represents a regular expression that can be used to validate a phone number. + /// + /// A regular expression that can be used to validate a phone number. + [GeneratedRegex(@"^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$")] + private static partial Regex PhoneNumberRegex(); + + /// + /// Validates the given email address. + /// + /// The email address to validate. + /// true if the given email address is valid; otherwise, false. + public static bool IsValidEmail(this string email) + { + return !string.IsNullOrEmpty(email) && MailAddressRegex().IsMatch(email); + } + + /// + /// Validates the given email address. + /// + /// The email address to validate. + /// true if the given email address is valid; otherwise, false. + public static bool IsValidEmail(this ReadOnlySpan email) + { + return !email.IsEmpty && MailAddressRegex().IsMatch(email); + } + + /// + /// Validates the given phone number. + /// + /// The phone number to validate. + /// true if the given phone number is valid; otherwise, false. + public static bool IsValidPhoneNumber(this string phoneNumber) + { + return !string.IsNullOrEmpty(phoneNumber) && PhoneNumberRegex().IsMatch(phoneNumber); + } + + /// + /// Validates the given phone number. + /// + /// The phone number to validate. + /// true if the given phone number is valid; otherwise, false. + public static bool IsValidPhoneNumber(this ReadOnlySpan phoneNumber) + { + return !phoneNumber.IsEmpty && PhoneNumberRegex().IsMatch(phoneNumber); + } +} \ No newline at end of file diff --git a/UnitTests/StringExtensionTests.cs b/UnitTests/StringExtensionTests.cs index e26bcd1..f8424e1 100644 --- a/UnitTests/StringExtensionTests.cs +++ b/UnitTests/StringExtensionTests.cs @@ -1,4 +1,7 @@ using StringExtension; +using StringExtension.Casing; +using StringExtension.Linguistics; +using StringExtension.Validation; namespace UnitTests; @@ -125,6 +128,22 @@ public void TestIsPalindrome_NullInput() Assert.That(result, Is.False); } + /// + /// Tests that IsPalindrome correctly compares characters outside the Basic + /// Multilingual Plane (encoded as UTF-16 surrogate pairs) as whole units. + /// "\U0001D49C" and "\U0001D4B7" are two different mathematical script + /// letters; treating each surrogate half as a separate "non-letter" character + /// (the pre-Rune behavior) would have skipped them entirely and produced a + /// false positive. + /// + [Test] + public void TestIsPalindrome_SurrogatePairLetters() + { + string input = "\U0001D49Cb\U0001D4B7"; + bool result = input.IsPalindrome(); + Assert.That(result, Is.False); + } + /// /// Tests the CountLetters method. /// @@ -148,6 +167,21 @@ public void TestCountLetters_NullInput() Assert.That(result, Is.EqualTo(0)); } + /// + /// Tests that CountLetters correctly counts a letter outside the Basic + /// Multilingual Plane. "\U0001D49C" (MATHEMATICAL SCRIPT CAPITAL A) is + /// encoded as a UTF-16 surrogate pair; the pre-Rune implementation counted + /// it as 0 letters, since neither surrogate half is classified as a letter + /// on its own. + /// + [Test] + public void TestCountLetters_SurrogatePairLetter() + { + string input = "\U0001D49C"; + int result = input.CountLetters(); + Assert.That(result, Is.EqualTo(1)); + } + /// /// Tests the RemoveDuplicateCharacters method. /// @@ -184,4 +218,18 @@ public void TestConvertToCamelCase_LeadingSeparator() string result = input.ToCamelCase(); Assert.That(result, Is.EqualTo(expected)); } + + /// + /// Tests that ToCamelCase preserves a character outside the Basic Multilingual + /// Plane (a surrogate pair) intact, instead of splitting or corrupting it. + /// "\U0001F389" is the party popper emoji (🎉). + /// + [Test] + public void TestConvertToCamelCase_SurrogatePair() + { + string input = "hello_\U0001F389_world"; + string expected = "hello\U0001F389World"; + string result = input.ToCamelCase(); + Assert.That(result, Is.EqualTo(expected)); + } } \ No newline at end of file From 0be52fe77f39d21073402bc2641897784e4a80c1 Mon Sep 17 00:00:00 2001 From: Antyss77 Date: Sun, 9 Aug 2026 00:41:47 +0200 Subject: [PATCH 2/3] docs: update README for namespace split and Rune-based Unicode support --- README.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 04e40da..e5a7369 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,26 @@ A string processing library for the C# programming language. It provides feature ## Features +> **Breaking change in v2.0.0**: methods are now split across namespaces by category. You'll need to add the relevant `using` statements below instead of a single `using StringExtension;`. + +`using StringExtension;` - RemoveCharacters - removes the specified characters from a string -- IsValidEmail - validate an e-mail address -- IsValidPhoneNumber - validate a phone number - CountSubstring - counts the number of occurrences of a string in the specified string - ReverseWords - reverses the order of words in the specified string +- RemoveDuplicateCharacters - remove duplicate characters from a given string + +`using StringExtension.Validation;` +- IsValidEmail - validate an e-mail address +- IsValidPhoneNumber - validate a phone number + +`using StringExtension.Linguistics;` - IsPalindrome - determines if a given string is a [palindrome](https://en.wikipedia.org/wiki/Palindrome) -- CountLetters - counts the number of letters in a given string. -- RemoveDuplicateCharacters - remove duplicate characters from a given string. -- ToCamelCase - converts a given string to camel case. +- CountLetters - counts the number of letters in a given string + +`using StringExtension.Casing;` +- ToCamelCase - converts a given string to camel case + +All methods that classify or compare characters (`IsPalindrome`, `CountLetters`, `ToCamelCase`) operate on whole Unicode scalar values (via [`Rune`](https://learn.microsoft.com/en-us/dotnet/api/system.text.rune)), so characters outside the Basic Multilingual Plane (such as many emoji) are handled correctly. ## Installation From ee8b0b1bec253ea922ec0d6db209149b24891bb1 Mon Sep 17 00:00:00 2001 From: Antyss77 Date: Sun, 9 Aug 2026 00:41:47 +0200 Subject: [PATCH 3/3] chore: bump version to 2.0.0 --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 832f365..ec9030f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.5.7 + 2.0.0 - + \ No newline at end of file