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
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