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
41 changes: 41 additions & 0 deletions CaseConverter.Test/CaseConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -429,4 +429,45 @@ public void ApostropheNotBetweenLettersShouldStillSeparateWords(string input, st
{
Assert.AreEqual(expected, input.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.

[TestMethod]
[DataRow("straße")]
[DataRow("maßnahmeLimit")]
[DataRow("MAX_GRÖßE")]
[DataRow("file")]
public void ToMacroCaseShouldBeIdempotentForLowercaseLettersWithNoUppercaseForm(string input)
{
string once = input.ToMacroCase();
Assert.AreEqual(once, once.ToMacroCase());
}

[TestMethod]
public void AnAllCapsWordContainingSharpSShouldStayOneWord()
{
Assert.AreEqual("STRAßE", "straße".ToMacroCase());
Assert.AreEqual("straße", "STRAßE".ToSnakeCase());
Assert.AreEqual("Straße", "STRAßE".ToPascalCase());
Assert.AreEqual("Straße", "STRAßE".ToTitleCase());
Assert.AreEqual("maßnahme", "MAßNAHME".ToSnakeCase());
Assert.AreEqual("maxGröße", "MAX_GRÖßE".ToCamelCase());
Assert.AreEqual("maßnahme_limit", "maßnahmeLimit".ToMacroCase().ToSnakeCase());
Assert.AreEqual("fiLE", "file".ToMacroCase().ToMacroCase());
}

[TestMethod]
public void ALowercaseWordEndingInSharpSShouldStillSplitBeforeACapital()
{
Assert.AreEqual("groß_foo", "großFoo".ToSnakeCase());
}

[TestMethod]
public void IsAllCapsShouldIgnoreLowercaseLettersWithNoUppercaseForm()
{
Assert.IsTrue("STRAßE".IsAllCaps());
Assert.IsFalse("Straße".IsAllCaps());
}
}
90 changes: 87 additions & 3 deletions CaseConverter/CaseConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,19 @@ private static bool IsWordBoundary(string input, int previousStart, int start, i
bool previousIsUpper = char.IsUpper(input, previousStart);
bool currentIsUpper = char.IsUpper(input, start);

// A lowercase letter with no uppercase form, such as "ß" or "fi", survives uppercasing, so it
// takes the case of the nearest letter that has one: "STRAßE" is a single all-caps word.
int casedNextStart = SkipLowercaseWithNoUppercase(input, nextStart);

// The tail of an acronym run that begins a new word: "XMLDoc" breaks before the "D".
if (previousIsUpper && currentIsUpper && nextStart < input.Length && char.IsLower(input, nextStart))
if (previousIsUpper && currentIsUpper && casedNextStart < input.Length && char.IsLower(input, casedNextStart))
{
return true;
}

// The start of a capitalised word: "fooBar" breaks before the "B". Only a letter or digit can
// end the word before it, so "(Hello" and "don'T" do not split away from their punctuation.
if (!previousIsUpper && currentIsUpper && (previousIsLetter || char.IsDigit(input, previousStart)))
if (EndsWordThatIsNotUppercase(input, previousStart) && currentIsUpper && (previousIsLetter || char.IsDigit(input, previousStart)))
{
return true;
}
Expand All @@ -170,6 +174,84 @@ private static bool IsWordBoundary(string input, int previousStart, int start, i
return breakBeforeAnyNonLetter || char.IsDigit(input, start);
}

/// <summary>
/// Determines whether the code point at <paramref name="index"/> is a lowercase letter that
/// uppercasing leaves unchanged, such as <c>"ß"</c>, <c>"fi"</c> or <c>"ʼn"</c>.
/// </summary>
/// <param name="input">The string to inspect.</param>
/// <param name="index">The index of the first code unit of the code point.</param>
/// <returns><c>true</c> if the code point is lowercase and has no uppercase form; otherwise, <c>false</c>.</returns>
/// <remarks>
/// Such a letter is still present, and still lowercase, in the output of
/// <see cref="ToMacroCase(string)"/>, so it must not count as lowercase when the words of an
/// all-caps string are found again.
/// </remarks>
private static bool IsLowercaseWithNoUppercase(string input, int index)
{
if (!char.IsLower(input, index))
{
return false;
}

#if NETSTANDARD2_0
#pragma warning disable IDE0057 // Substring cannot be simplified in netstandard2.0
string codePoint = input.Substring(index, CodePointLength(input, index));
#pragma warning restore IDE0057
#else
string codePoint = input[index..(index + CodePointLength(input, index))];
#endif
return string.Equals(codePoint.ToUpperInvariant(), codePoint, StringComparison.Ordinal);
}

/// <summary>
/// Returns the index of the first code point at or after <paramref name="index"/> that is not a
/// lowercase letter with no uppercase form.
/// </summary>
/// <param name="input">The string to inspect.</param>
/// <param name="index">The index to start from, which may be past the end.</param>
/// <returns>The index found, or the length of <paramref name="input"/> if there is none.</returns>
private static int SkipLowercaseWithNoUppercase(string input, int index)
{
while (index < input.Length && IsLowercaseWithNoUppercase(input, index))
{
index += CodePointLength(input, index);
}

return index;
}

/// <summary>
/// Determines whether the code point at <paramref name="index"/> ends a word that is not uppercase,
/// so that a capital after it starts a new word.
/// </summary>
/// <param name="input">The string to inspect.</param>
/// <param name="index">The index of the first code unit of the code point before the capital.</param>
/// <returns><c>true</c> if a capital after the code point starts a new word; otherwise, <c>false</c>.</returns>
/// <remarks>
/// A lowercase letter with no uppercase form takes the case of the nearest letter before it in the
/// same word, so <c>"STRAßE"</c> does not break before the <c>"E"</c> while <c>"großFoo"</c> still
/// breaks before the <c>"F"</c>. With no such letter, as in <c>"fiLE"</c>, it does not end a word.
/// </remarks>
private static bool EndsWordThatIsNotUppercase(string input, int index)
{
if (!IsLowercaseWithNoUppercase(input, index))
{
return !char.IsUpper(input, index);
}

for (int i = index; i > 0;)
{
i -= i >= 2 && char.IsSurrogatePair(input, i - 2) ? 2 : 1;

if (!IsLowercaseWithNoUppercase(input, i))
{
return char.IsLetter(input, i) && !char.IsUpper(input, i);
}
}

return false;
}

/// <summary>
/// Returns a copy of this string, trimmed and with runs of spaces collapsed to one, with the first character converted to lowercase.
/// </summary>
Expand Down Expand Up @@ -335,7 +417,9 @@ public static bool IsAllCaps(this string output)
{
int length = CodePointLength(output, i);

if (char.IsLetter(output, i) && !char.IsUpper(output, i))
// A lowercase letter with no uppercase form, such as "ß", is left as it is by uppercasing,
// so it does not stop "STRAßE" from being all caps.
if (char.IsLetter(output, i) && !char.IsUpper(output, i) && !IsLowercaseWithNoUppercase(output, i))
{
return false;
}
Expand Down
Loading