Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Benchmarks/Benchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,41 @@ public string ConvertToCamelCase()
{
return input.ToCamelCase();
}

/// <summary>
/// Benchmark for the ToPascalCase method.
/// </summary>
[Benchmark]
public string ToPascalCase()
{
return input.ToPascalCase();
}

/// <summary>
/// Benchmark for the ToSnakeCase method.
/// </summary>
[Benchmark]
public string ToSnakeCase()
{
return input.ToSnakeCase();
}

/// <summary>
/// Benchmark for the ToKebabCase method.
/// </summary>
[Benchmark]
public string ToKebabCase()
{
return input.ToKebabCase();
}

/// <summary>
/// Benchmark for the ToTitleCase method.
/// </summary>
[Benchmark]
public string ToTitleCase()
{
return input.ToTitleCase();
}
}
}
314 changes: 313 additions & 1 deletion StringExtension/Casing/Casing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ namespace StringExtension.Casing;
/// </summary>
public static class Casing
{
/// <summary>
/// English words that are conventionally left in lowercase in title case,
/// unless they are the first or last word of the input.
/// </summary>
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",
};

/// <summary>
/// Converts the given string to camel case.
/// </summary>
Expand All @@ -25,6 +35,9 @@ public static string ToCamelCase(this string input)
/// <param name="input">The input characters.</param>
/// <returns>The input converted to camel case.</returns>
/// <remarks>
/// Whitespace, <c>_</c>, and <c>-</c> 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 (<see cref="Rune"/>) 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.
Expand All @@ -49,7 +62,7 @@ public static string ToCamelCase(this ReadOnlySpan<char> input)

foreach (var rune in input.EnumerateRunes())
{
if (Rune.IsWhiteSpace(rune) || rune.Value == '_')
if (IsWordSeparator(rune))
{
shouldCapitalize = true;
continue;
Expand All @@ -76,4 +89,303 @@ public static string ToCamelCase(this ReadOnlySpan<char> input)
}
}
}

/// <summary>
/// Converts the given string to Pascal case.
/// </summary>
/// <param name="input">The input string.</param>
/// <returns>The input string converted to Pascal case.</returns>
public static string ToPascalCase(this string input)
{
return ToPascalCase(input.AsSpan());
}

/// <summary>
/// Converts the given span of characters to Pascal case.
/// </summary>
/// <param name="input">The input characters.</param>
/// <returns>The input converted to Pascal case.</returns>
/// <remarks>
/// Whitespace, <c>_</c>, and <c>-</c> are treated as word separators. Casing
/// transitions already present in the input are not treated as word boundaries.
/// </remarks>
public static string ToPascalCase(this ReadOnlySpan<char> input)
{
if (input.IsEmpty)
{
return string.Empty;
}

char[]? pooledBuffer = null;
Span<char> buffer = (uint)input.Length <= BufferLimits.StackAllocThreshold
? stackalloc char[input.Length]
: (pooledBuffer = ArrayPool<char>.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<char>.Shared.Return(pooledBuffer);
}
}
}

/// <summary>
/// Converts the given string to snake case.
/// </summary>
/// <param name="input">The input string.</param>
/// <returns>The input string converted to snake case.</returns>
public static string ToSnakeCase(this string input)
{
return ToSnakeCase(input.AsSpan());
}

/// <summary>
/// Converts the given span of characters to snake case.
/// </summary>
/// <param name="input">The input characters.</param>
/// <returns>The input converted to snake case.</returns>
/// <remarks>
/// A word boundary is inserted at whitespace, <c>_</c>, <c>-</c>, 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.
/// </remarks>
public static string ToSnakeCase(this ReadOnlySpan<char> input)
{
return ToSeparatedLowerCase(input, '_');
}

/// <summary>
/// Converts the given string to kebab case.
/// </summary>
/// <param name="input">The input string.</param>
/// <returns>The input string converted to kebab case.</returns>
public static string ToKebabCase(this string input)
{
return ToKebabCase(input.AsSpan());
}

/// <summary>
/// Converts the given span of characters to kebab case.
/// </summary>
/// <param name="input">The input characters.</param>
/// <returns>The input converted to kebab case.</returns>
/// <remarks>
/// A word boundary is inserted at whitespace, <c>_</c>, <c>-</c>, 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.
/// </remarks>
public static string ToKebabCase(this ReadOnlySpan<char> input)
{
return ToSeparatedLowerCase(input, '-');
}

/// <summary>
/// Converts the given string to title case.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="useEnglishMinorWordRules">
/// If <see langword="true"/>, common short English words (e.g. "of", "the",
/// "and") are left in lowercase unless they are the first or last word.
/// If <see langword="false"/> (the default), every word is capitalized.
/// </param>
/// <returns>The input string converted to title case.</returns>
public static string ToTitleCase(this string input, bool useEnglishMinorWordRules = false)
{
return ToTitleCase(input.AsSpan(), useEnglishMinorWordRules);
}

/// <summary>
/// Converts the given span of characters to title case.
/// </summary>
/// <param name="input">The input characters.</param>
/// <param name="useEnglishMinorWordRules">
/// If <see langword="true"/>, common short English words (e.g. "of", "the",
/// "and") are left in lowercase unless they are the first or last word.
/// If <see langword="false"/> (the default), every word is capitalized.
/// </param>
/// <returns>The input converted to title case.</returns>
/// <remarks>
/// Words are assumed to be separated by single spaces. <paramref name="useEnglishMinorWordRules"/>
/// applies a fixed, English-specific list of minor words and is not suitable
/// for other languages.
/// </remarks>
public static string ToTitleCase(this ReadOnlySpan<char> input, bool useEnglishMinorWordRules = false)
{
if (input.IsEmpty)
{
return string.Empty;
}

char[]? pooledBuffer = null;
Span<char> buffer = (uint)input.Length <= BufferLimits.StackAllocThreshold
? stackalloc char[input.Length]
: (pooledBuffer = ArrayPool<char>.Shared.Rent(input.Length));

try
{
var count = 0;
var isFirstWord = true;
var lastWordStart = 0;

foreach (Range wordRange in input.Split(' '))
{
ReadOnlySpan<char> 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<char>.Shared.Return(pooledBuffer);
}
}
}

/// <summary>
/// Converts the given span of characters to lowercase, inserting <paramref name="separator"/>
/// at whitespace, <c>_</c>, <c>-</c>, and at any transition from a lowercase
/// letter or digit to an uppercase letter.
/// </summary>
private static string ToSeparatedLowerCase(ReadOnlySpan<char> 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<char> buffer = (uint)maxLength <= BufferLimits.StackAllocThreshold
? stackalloc char[maxLength]
: (pooledBuffer = ArrayPool<char>.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<char>.Shared.Return(pooledBuffer);
}
}
}

private static bool IsWordSeparator(Rune rune)
{
return Rune.IsWhiteSpace(rune) || rune.Value == '_' || rune.Value == '-';
}

private static bool IsEnglishMinorWord(ReadOnlySpan<char> word)
{
foreach (var minorWord in EnglishMinorWords)
{
if (word.Equals(minorWord, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}
}
Loading
Loading