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
18 changes: 18 additions & 0 deletions CaseConverter.Test/CaseConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,14 @@ public void ToSnakeCaseShouldStillDropAstralCharactersThatAreNotLetters()
[DataRow("don\u2019t stop", "DontStop", "dontStop", "dont_stop", "dont-stop", "DONT_STOP")]
[DataRow("o'neil", "Oneil", "oneil", "oneil", "oneil", "ONEIL")]
[DataRow("o\u2019neil", "Oneil", "oneil", "oneil", "oneil", "ONEIL")]
[DataRow("O'Neil", "Oneil", "oneil", "oneil", "oneil", "ONEIL")]
[DataRow("O\u2019Neil", "Oneil", "oneil", "oneil", "oneil", "ONEIL")]
[DataRow("O'NEIL", "Oneil", "oneil", "oneil", "oneil", "ONEIL")]
[DataRow("D'Angelo", "Dangelo", "dangelo", "dangelo", "dangelo", "DANGELO")]
[DataRow("d'angelo", "Dangelo", "dangelo", "dangelo", "dangelo", "DANGELO")]
[DataRow("Don'T stop", "DontStop", "dontStop", "dont_stop", "dont-stop", "DONT_STOP")]
[DataRow("O'Neil's car", "OneilsCar", "oneilsCar", "oneils_car", "oneils-car", "ONEILS_CAR")]
[DataRow("McDonald's menu", "McDonaldsMenu", "mcDonaldsMenu", "mc_donalds_menu", "mc-donalds-menu", "MC_DONALDS_MENU")]
[DataRow("DON'T stop", "DontStop", "dontStop", "dont_stop", "dont-stop", "DONT_STOP")]
[DataRow("API's", "Apis", "apis", "apis", "apis", "APIS")]
[DataRow("CEO's office", "CeosOffice", "ceosOffice", "ceos_office", "ceos-office", "CEOS_OFFICE")]
Expand All @@ -461,6 +469,16 @@ public void ApostropheNotBetweenLettersShouldStillSeparateWords(string input, st
Assert.AreEqual(expected, input.ToSnakeCase());
}

[TestMethod]
[DataRow("O'Neil", "o'neil")]
[DataRow("D'Angelo", "d'angelo")]
[DataRow("Don'T", "don't")]
public void ACapitalisedApostropheNameShouldConvertLikeItsLowercaseSpelling(string input, string lowercase)
{
Assert.AreEqual(lowercase.ToSnakeCase(), input.ToSnakeCase());
Assert.AreEqual(input.ToSnakeCase(), input.ToTitleCase().ToSnakeCase());
}

// "ß" and "fi" are lowercase letters with no single-character uppercase form, so they survive
// ToMacroCase. They used to read as lowercase when that output was converted again, which split
// "STRAßE" into "STR Aß E" and kept IsAllCaps from recognizing it.
Expand Down
44 changes: 44 additions & 0 deletions CaseConverter/CaseConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,19 @@ public static partial class CaseConverter
/// capitalised word and splits into <c>"CE Os"</c>. <c>"CEOS"</c> is one all-caps word, which every
/// converter then normalizes the same way as <c>"CEO"</c>.
/// </para>
/// <para>
/// Otherwise, a capital straight after the apostrophe is lowercased when the letters around it are
/// lowercase, as in <c>"O'Neil"</c> or <c>"Don'T"</c>. Dropping the apostrophe alone would leave
/// <c>"ONeil"</c>, which <see cref="SplitOnCaseChange(string)"/> splits into <c>"O Neil"</c>, so
/// <c>"O'Neil"</c> would convert differently from <c>"o'neil"</c> and <c>"O'NEIL"</c>.
/// </para>
/// </remarks>
private static string ReplaceNonAlphaNumericWithSpace(string input)
{
StringBuilder builder = new(input.Length);
int previousStart = -1;
bool uppercaseSuffix = false;
bool lowercaseNextLetter = false;

for (int i = 0; i < input.Length;)
{
Expand All @@ -90,11 +97,14 @@ private static string ReplaceNonAlphaNumericWithSpace(string input)
#else
string letter = input[i..nextStart];
#endif
letter = LowercaseIf(letter, lowercaseNextLetter);
lowercaseNextLetter = false;
builder.Append(uppercaseSuffix ? ToUpperInvariantFull(letter) : letter);
}
else if (IsApostropheWithinWord(input, previousStart, i, nextStart))
{
uppercaseSuffix = char.IsUpper(input, previousStart) && AreLettersFromIndexLowercase(input, nextStart);
lowercaseNextLetter = !uppercaseSuffix && IsCapitalAfterApostropheInLowercaseContext(input, previousStart, nextStart);
}
else
{
Expand All @@ -109,6 +119,40 @@ private static string ReplaceNonAlphaNumericWithSpace(string input)
return builder.ToString();
}

/// <summary>
/// Returns <paramref name="letter"/> lowercased when <paramref name="lowercase"/> is set, otherwise unchanged.
/// </summary>
/// <param name="letter">The letter, as a string of one or two UTF-16 code units.</param>
/// <param name="lowercase">Whether to lowercase it.</param>
/// <returns>The letter, lowercased if asked.</returns>
private static string LowercaseIf(string letter, bool lowercase) => lowercase ? ToLowerInvariantFull(letter) : letter;

/// <summary>
/// Determines whether the letter after an in-word apostrophe is a capital that the letters around it
/// show should be lowercase.
/// </summary>
/// <param name="input">The string to inspect.</param>
/// <param name="previousStart">The index of the letter before the apostrophe.</param>
/// <param name="nextStart">The index of the letter after the apostrophe.</param>
/// <returns><c>true</c> if the letter after the apostrophe should be lowercased; otherwise, <c>false</c>.</returns>
/// <remarks>
/// The letter follows the case of the letter after it when there is one, so <c>"O'Neil"</c> lowers the
/// <c>"N"</c> while <c>"O'NEIL"</c> keeps it. At the end of the word it follows the letter before the
/// apostrophe instead, so <c>"Don'T"</c> lowers the <c>"T"</c> while <c>"DON'T"</c> keeps it.
/// </remarks>
private static bool IsCapitalAfterApostropheInLowercaseContext(string input, int previousStart, int nextStart)
{
if (!char.IsUpper(input, nextStart))
{
return false;
}

int afterNextStart = nextStart + CodePointLength(input, nextStart);
return afterNextStart < input.Length && char.IsLetter(input, afterNextStart)
? char.IsLower(input, afterNextStart)
: !char.IsUpper(input, previousStart);
}

/// <summary>
/// Determines whether every letter in the run of letters starting at <paramref name="start"/> is lowercase.
/// </summary>
Expand Down
Loading