From b16d3e52a2ce745f579d64533dae89b2efcb56ef Mon Sep 17 00:00:00 2001 From: Antyss77 Date: Sun, 9 Aug 2026 13:09:28 +0200 Subject: [PATCH 1/2] feat: add ToPascalCase, ToSnakeCase, ToKebabCase, ToTitleCase ToCamelCase and ToPascalCase now also recognize '-' as a word separator, for consistency with the new kebab-case method. --- StringExtension/Casing/Casing.cs | 314 +++++++++++++++++++++++++++++- UnitTests/StringExtensionTests.cs | 97 +++++++++ 2 files changed, 410 insertions(+), 1 deletion(-) diff --git a/StringExtension/Casing/Casing.cs b/StringExtension/Casing/Casing.cs index 0e53036..1dbd981 100644 --- a/StringExtension/Casing/Casing.cs +++ b/StringExtension/Casing/Casing.cs @@ -9,6 +9,16 @@ namespace StringExtension.Casing; /// public static class Casing { + /// + /// English words that are conventionally left in lowercase in title case, + /// unless they are the first or last word of the input. + /// + private static readonly string[] EnglishMinorWords = + { + "a", "an", "and", "as", "at", "but", "by", "for", "from", "if", "in", + "into", "nor", "of", "on", "onto", "or", "over", "the", "to", "with", + }; + /// /// Converts the given string to camel case. /// @@ -25,6 +35,9 @@ public static string ToCamelCase(this string input) /// The input characters. /// The input converted to camel case. /// + /// Whitespace, _, and - are treated as word separators. Casing + /// transitions already present in the input (e.g. the "W" in "helloWorld") + /// are not treated as word boundaries. /// 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. @@ -49,7 +62,7 @@ public static string ToCamelCase(this ReadOnlySpan input) foreach (var rune in input.EnumerateRunes()) { - if (Rune.IsWhiteSpace(rune) || rune.Value == '_') + if (IsWordSeparator(rune)) { shouldCapitalize = true; continue; @@ -76,4 +89,303 @@ public static string ToCamelCase(this ReadOnlySpan input) } } } + + /// + /// Converts the given string to Pascal case. + /// + /// The input string. + /// The input string converted to Pascal case. + public static string ToPascalCase(this string input) + { + return ToPascalCase(input.AsSpan()); + } + + /// + /// Converts the given span of characters to Pascal case. + /// + /// The input characters. + /// The input converted to Pascal case. + /// + /// Whitespace, _, and - are treated as word separators. Casing + /// transitions already present in the input are not treated as word boundaries. + /// + public static string ToPascalCase(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 = true; + + foreach (var rune in input.EnumerateRunes()) + { + if (IsWordSeparator(rune)) + { + shouldCapitalize = true; + continue; + } + + var cased = shouldCapitalize ? Rune.ToUpperInvariant(rune) : Rune.ToLowerInvariant(rune); + count += cased.EncodeToUtf16(buffer[count..]); + shouldCapitalize = false; + } + + return new string(buffer[..count]); + } + finally + { + if (pooledBuffer is not null) + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + } + + /// + /// Converts the given string to snake case. + /// + /// The input string. + /// The input string converted to snake case. + public static string ToSnakeCase(this string input) + { + return ToSnakeCase(input.AsSpan()); + } + + /// + /// Converts the given span of characters to snake case. + /// + /// The input characters. + /// The input converted to snake case. + /// + /// A word boundary is inserted at whitespace, _, -, and at any + /// transition from a lowercase letter or digit to an uppercase letter (e.g. + /// "helloWorld" gives "hello_world"). Consecutive uppercase letters (as in an + /// acronym, e.g. "HTTPServer") are treated as a single word rather than being + /// split individually. + /// + public static string ToSnakeCase(this ReadOnlySpan input) + { + return ToSeparatedLowerCase(input, '_'); + } + + /// + /// Converts the given string to kebab case. + /// + /// The input string. + /// The input string converted to kebab case. + public static string ToKebabCase(this string input) + { + return ToKebabCase(input.AsSpan()); + } + + /// + /// Converts the given span of characters to kebab case. + /// + /// The input characters. + /// The input converted to kebab case. + /// + /// A word boundary is inserted at whitespace, _, -, and at any + /// transition from a lowercase letter or digit to an uppercase letter (e.g. + /// "helloWorld" gives "hello-world"). Consecutive uppercase letters (as in an + /// acronym, e.g. "HTTPServer") are treated as a single word rather than being + /// split individually. + /// + public static string ToKebabCase(this ReadOnlySpan input) + { + return ToSeparatedLowerCase(input, '-'); + } + + /// + /// Converts the given string to title case. + /// + /// The input string. + /// + /// If , common short English words (e.g. "of", "the", + /// "and") are left in lowercase unless they are the first or last word. + /// If (the default), every word is capitalized. + /// + /// The input string converted to title case. + public static string ToTitleCase(this string input, bool useEnglishMinorWordRules = false) + { + return ToTitleCase(input.AsSpan(), useEnglishMinorWordRules); + } + + /// + /// Converts the given span of characters to title case. + /// + /// The input characters. + /// + /// If , common short English words (e.g. "of", "the", + /// "and") are left in lowercase unless they are the first or last word. + /// If (the default), every word is capitalized. + /// + /// The input converted to title case. + /// + /// Words are assumed to be separated by single spaces. + /// applies a fixed, English-specific list of minor words and is not suitable + /// for other languages. + /// + public static string ToTitleCase(this ReadOnlySpan input, bool useEnglishMinorWordRules = false) + { + 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 isFirstWord = true; + var lastWordStart = 0; + + foreach (Range wordRange in input.Split(' ')) + { + ReadOnlySpan word = input[wordRange]; + + if (word.IsEmpty) + { + continue; + } + + if (count > 0) + { + buffer[count++] = ' '; + } + + lastWordStart = count; + + var keepLowercase = useEnglishMinorWordRules && !isFirstWord && IsEnglishMinorWord(word); + var isFirstRuneOfWord = true; + + foreach (var rune in word.EnumerateRunes()) + { + var cased = isFirstRuneOfWord && !keepLowercase + ? Rune.ToUpperInvariant(rune) + : Rune.ToLowerInvariant(rune); + + count += cased.EncodeToUtf16(buffer[count..]); + isFirstRuneOfWord = false; + } + + isFirstWord = false; + } + + // The last word is always capitalized regardless of the minor-word + // list, matching standard title case conventions (e.g. "... of the + // Rings", not "... of the rings"). + if (useEnglishMinorWordRules && lastWordStart < count) + { + Rune.DecodeFromUtf16(buffer[lastWordStart..count], out var firstRuneOfLastWord, out _); + Rune.ToUpperInvariant(firstRuneOfLastWord).EncodeToUtf16(buffer[lastWordStart..count]); + } + + return new string(buffer[..count]); + } + finally + { + if (pooledBuffer is not null) + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + } + + /// + /// Converts the given span of characters to lowercase, inserting + /// at whitespace, _, -, and at any transition from a lowercase + /// letter or digit to an uppercase letter. + /// + private static string ToSeparatedLowerCase(ReadOnlySpan input, char separator) + { + if (input.IsEmpty) + { + return string.Empty; + } + + // Worst case, a separator is inserted before nearly every character + // (e.g. alternating case input), so the output can be up to twice as long + // as the input. + var maxLength = input.Length * 2; + + char[]? pooledBuffer = null; + Span buffer = (uint)maxLength <= BufferLimits.StackAllocThreshold + ? stackalloc char[maxLength] + : (pooledBuffer = ArrayPool.Shared.Rent(maxLength)); + + try + { + var count = 0; + var atWordStart = true; + var previousWasLowerOrDigit = false; + + foreach (var rune in input.EnumerateRunes()) + { + if (IsWordSeparator(rune)) + { + if (count > 0) + { + atWordStart = true; + } + + previousWasLowerOrDigit = false; + continue; + } + + var isUpper = Rune.IsUpper(rune); + var isNewWord = atWordStart + ? count > 0 + : isUpper && previousWasLowerOrDigit; + + if (isNewWord) + { + buffer[count++] = separator; + } + + count += Rune.ToLowerInvariant(rune).EncodeToUtf16(buffer[count..]); + previousWasLowerOrDigit = !isUpper; + atWordStart = false; + } + + return new string(buffer[..count]); + } + finally + { + if (pooledBuffer is not null) + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + } + + private static bool IsWordSeparator(Rune rune) + { + return Rune.IsWhiteSpace(rune) || rune.Value == '_' || rune.Value == '-'; + } + + private static bool IsEnglishMinorWord(ReadOnlySpan word) + { + foreach (var minorWord in EnglishMinorWords) + { + if (word.Equals(minorWord, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } } \ No newline at end of file diff --git a/UnitTests/StringExtensionTests.cs b/UnitTests/StringExtensionTests.cs index f8424e1..19f81b3 100644 --- a/UnitTests/StringExtensionTests.cs +++ b/UnitTests/StringExtensionTests.cs @@ -232,4 +232,101 @@ public void TestConvertToCamelCase_SurrogatePair() string result = input.ToCamelCase(); Assert.That(result, Is.EqualTo(expected)); } + + /// + /// Tests that ToCamelCase treats a hyphen as a word separator. + /// + [Test] + public void TestConvertToCamelCase_HyphenSeparator() + { + string input = "hello-world"; + string expected = "helloWorld"; + string result = input.ToCamelCase(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests the ToPascalCase method. + /// + [Test] + public void TestToPascalCase() + { + string input = "hello_world"; + string expected = "HelloWorld"; + string result = input.ToPascalCase(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests the ToSnakeCase method with camelCase input. + /// + [Test] + public void TestToSnakeCase_CamelCaseInput() + { + string input = "helloWorld"; + string expected = "hello_world"; + string result = input.ToSnakeCase(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests the ToSnakeCase method with space-separated input. + /// + [Test] + public void TestToSnakeCase_SpaceSeparatedInput() + { + string input = "hello world"; + string expected = "hello_world"; + string result = input.ToSnakeCase(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests the ToKebabCase method with camelCase input. + /// + [Test] + public void TestToKebabCase_CamelCaseInput() + { + string input = "helloWorld"; + string expected = "hello-world"; + string result = input.ToKebabCase(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests the ToTitleCase method with the default behavior (every word capitalized). + /// + [Test] + public void TestToTitleCase_Default() + { + string input = "the lord of the rings"; + string expected = "The Lord Of The Rings"; + string result = input.ToTitleCase(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests the ToTitleCase method with English minor-word rules enabled. + /// + [Test] + public void TestToTitleCase_EnglishMinorWordRules() + { + string input = "the lord of the rings"; + string expected = "The Lord of the Rings"; + string result = input.ToTitleCase(useEnglishMinorWordRules: true); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests that ToTitleCase always capitalizes the last word, even if it is + /// normally a minor word. + /// + [Test] + public void TestToTitleCase_LastWordAlwaysCapitalized() + { + string input = "what are you waiting for"; + string expected = "What Are You Waiting For"; + string result = input.ToTitleCase(useEnglishMinorWordRules: true); + Assert.That(result, Is.EqualTo(expected)); + } } \ No newline at end of file From 94df8e9a0998fabb927692f76a715d045bc4de7e Mon Sep 17 00:00:00 2001 From: Antyss77 Date: Sun, 9 Aug 2026 13:17:34 +0200 Subject: [PATCH 2/2] test: add benchmarks for ToPascalCase, ToSnakeCase, ToKebabCase, ToTitleCase --- Benchmarks/Benchmark.cs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Benchmarks/Benchmark.cs b/Benchmarks/Benchmark.cs index 1afebcc..786add2 100644 --- a/Benchmarks/Benchmark.cs +++ b/Benchmarks/Benchmark.cs @@ -104,5 +104,41 @@ public string ConvertToCamelCase() { return input.ToCamelCase(); } + + /// + /// Benchmark for the ToPascalCase method. + /// + [Benchmark] + public string ToPascalCase() + { + return input.ToPascalCase(); + } + + /// + /// Benchmark for the ToSnakeCase method. + /// + [Benchmark] + public string ToSnakeCase() + { + return input.ToSnakeCase(); + } + + /// + /// Benchmark for the ToKebabCase method. + /// + [Benchmark] + public string ToKebabCase() + { + return input.ToKebabCase(); + } + + /// + /// Benchmark for the ToTitleCase method. + /// + [Benchmark] + public string ToTitleCase() + { + return input.ToTitleCase(); + } } } \ No newline at end of file