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
52 changes: 50 additions & 2 deletions CaseConverter.Test/CaseConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ public void ToTitleCaseShouldHandleMultipleSpaces()
{
string input = " the quick brown FOX ";
string result = input.ToTitleCase();
Assert.AreEqual("The Quick Brown FOX", result);

// "FOX" is normalized rather than kept as an acronym, the same as it would be on its own.
Assert.AreEqual("The Quick Brown Fox", result);
}

[TestMethod]
Expand Down Expand Up @@ -83,7 +85,53 @@ public void ToTitleCaseShouldConvertToTitleCase()
{
string input = "the quick Brown FOX";
string result = input.ToTitleCase();
Assert.AreEqual("The Quick Brown FOX", result);

// "FOX" is normalized rather than kept as an acronym, the same as it would be on its own.
Assert.AreEqual("The Quick Brown Fox", result);
}

// An all-caps word used to be normalized only when the entire string was all caps, because
// ToTitleCase tested IsAllCaps against the whole string. The same token therefore converted two
// different ways depending on its neighbours: "MAX_SIZE" gave "MaxSize" but "set MAX_SIZE" gave
// "SetMAXSIZE". Each pair below measures a word alone and beside a lowercase one, so a regression
// to whole-string reasoning fails the second assertion of the pair while the first still passes.

[TestMethod]
public void ToTitleCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours()
{
Assert.AreEqual("Http", "HTTP".ToTitleCase());
Assert.AreEqual("Parse Http Header", "parse HTTP header".ToTitleCase());
}

[TestMethod]
public void ToPascalCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours()
{
Assert.AreEqual("MaxSize", "MAX_SIZE".ToPascalCase());
Assert.AreEqual("SetMaxSize", "set MAX_SIZE".ToPascalCase());
}

[TestMethod]
public void ToCamelCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours()
{
Assert.AreEqual("url", "URL".ToCamelCase());
Assert.AreEqual("myUrlHandler", "my URL handler".ToCamelCase());
}

[TestMethod]
public void ToPascalCaseShouldMatchTheAcronymExampleInTheReadme()
{
// README.md has documented this result since before the neighbour dependence was found, while
// the library actually produced "APIResponseURL". Pinning it keeps the two from drifting again.
Assert.AreEqual("ApiResponseUrl", "API_response_URL".ToPascalCase());
}

[TestMethod]
public void ToPascalCaseShouldAgreeWithToMacroCaseOnWordBoundaries()
{
// The macro and snake paths never had the neighbour dependence, so they are the reference the
// title-case-derived converters are brought back into agreement with.
Assert.AreEqual("SET_MAX_SIZE", "set MAX_SIZE".ToMacroCase());
Assert.AreEqual("SetMaxSize", "set MAX_SIZE".ToPascalCase());
}

[TestMethod]
Expand Down
58 changes: 52 additions & 6 deletions CaseConverter/CaseConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,47 @@ public static string ToUppercaseFirstChar(this string input)
#endif
}

/// <summary>
/// Lowercases every space separated word whose letters are all uppercase, leaving the rest alone.
/// </summary>
/// <param name="input">The string to process, already split into space separated words.</param>
/// <returns>A new string with each all-caps word lowercased.</returns>
/// <remarks>
/// <see cref="TextInfo.ToTitleCase(string)"/> preserves a word that is all caps, on the assumption
/// that it is an acronym. Lowering such a word first is what normalizes it instead, and deciding
/// this per word rather than for the whole string is what keeps a word's result independent of its
/// neighbours.
/// </remarks>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercasing is the point: TextInfo.ToTitleCase then capitalizes the first letter of each word.")]
private static string LowercaseAllCapsWords(string input)
{
string[] words = input.Split(' ');
StringBuilder builder = new(input.Length);

for (int i = 0; i < words.Length; i++)
{
if (i > 0)
{
builder.Append(' ');
}

string word = words[i];
builder.Append(IsAllCaps(word) ? word.ToLowerInvariant() : word);
}

return builder.ToString();
}

/// <summary>
/// Returns a copy of this string converted to Title Case. Example: "the quick brown fox" becomes "The Quick Brown Fox".
/// </summary>
/// <param name="input">The string to convert.</param>
/// <returns>A new string in Title Case.</returns>
/// <remarks>
/// An all-caps word is normalized rather than preserved as an acronym, so <c>"HTTP"</c> becomes
/// <c>"Http"</c> and <c>"parse HTTP header"</c> becomes <c>"Parse Http Header"</c>. The decision is
/// made per word, so a word converts the same way whatever else is in the string.
/// </remarks>
public static string ToTitleCase(this string input)
{
Ensure.NotNull(input);
Expand All @@ -168,12 +204,10 @@ public static string ToTitleCase(this string input)
output = SplitOnCaseChange(output);
output = CollapseSpaces(output).Trim();

// If the input is all caps, we want to convert it to lowercase before converting to title case,
// as TextInfo.ToTitleCase preserves words that are all caps assuming they are acronyms.
if (IsAllCaps(output))
{
output = output.ToLowerInvariant();
}
// TextInfo.ToTitleCase preserves words that are all caps assuming they are acronyms, so lowercase
// them first. This is done per word rather than only when the whole string is all caps, because
// otherwise the same word converts two different ways depending on its neighbours.
output = LowercaseAllCapsWords(output);

return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(output);
}
Expand Down Expand Up @@ -230,6 +264,12 @@ private static string CollapseSpaces(string output)
/// </summary>
/// <param name="input">The string to convert.</param>
/// <returns>A new string in PascalCase.</returns>
/// <remarks>
/// An all-caps word is normalized rather than preserved as an acronym, so <c>"MAX_SIZE"</c> and
/// <c>"set MAX_SIZE"</c> become <c>"MaxSize"</c> and <c>"SetMaxSize"</c>. The decision is made per
/// word by <see cref="ToTitleCase(string)"/>, so a word converts the same way whatever else is in
/// the string.
/// </remarks>
public static string ToPascalCase(this string input)
{
Ensure.NotNull(input);
Expand All @@ -252,6 +292,12 @@ public static string ToPascalCase(this string input)
/// </summary>
/// <param name="input">The string to convert.</param>
/// <returns>A new string in camelCase.</returns>
/// <remarks>
/// An all-caps word is normalized rather than preserved as an acronym, so <c>"URL"</c> and
/// <c>"my URL handler"</c> become <c>"url"</c> and <c>"myUrlHandler"</c>. The decision is made per
/// word by <see cref="ToTitleCase(string)"/>, so a word converts the same way whatever else is in
/// the string.
/// </remarks>
public static string ToCamelCase(this string input)
{
Ensure.NotNull(input);
Expand Down
Loading