diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs
index a80b4dc..d3a67f1 100644
--- a/CaseConverter.Test/CaseConverterTests.cs
+++ b/CaseConverter.Test/CaseConverterTests.cs
@@ -411,6 +411,12 @@ 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("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")]
+ [DataRow("the CEO's office", "TheCeosOffice", "theCeosOffice", "the_ceos_office", "the-ceos-office", "THE_CEOS_OFFICE")]
+ [DataRow("NASA's mission", "NasasMission", "nasasMission", "nasas_mission", "nasas-mission", "NASAS_MISSION")]
+ [DataRow("CEO\u2019s office", "CeosOffice", "ceosOffice", "ceos_office", "ceos-office", "CEOS_OFFICE")]
public void ApostropheWithinWordShouldNotSplitIt(string input, string pascal, string camel, string snake, string kebab, string macro)
{
Assert.AreEqual(pascal, input.ToPascalCase());
diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs
index cc129d7..615b5dc 100644
--- a/CaseConverter/CaseConverter.cs
+++ b/CaseConverter/CaseConverter.cs
@@ -38,24 +38,44 @@ public static partial class CaseConverter
/// keeps "don't stop" as two words, matching . An
/// apostrophe anywhere else, such as a leading or trailing quote, still separates words.
///
+ ///
+ /// When the apostrophe follows a capital and every letter after it is lowercase, as in
+ /// "CEO's", those letters are uppercased. Dropping the apostrophe alone would leave
+ /// "CEOs", which reads as an acronym followed by a
+ /// capitalised word and splits into "CE Os". "CEOS" is one all-caps word, which every
+ /// converter then normalizes the same way as "CEO".
+ ///
///
private static string ReplaceNonAlphaNumericWithSpace(string input)
{
StringBuilder builder = new(input.Length);
int previousStart = -1;
+ bool uppercaseSuffix = false;
for (int i = 0; i < input.Length;)
{
int length = CodePointLength(input, i);
int nextStart = i + length;
- if (char.IsLetter(input, i) || input[i] is >= '0' and <= '9')
+ if (char.IsLetter(input, i))
+ {
+#if NETSTANDARD2_0
+#pragma warning disable IDE0057 // Substring cannot be simplified in netstandard2.0
+ string letter = input.Substring(i, length);
+#pragma warning restore IDE0057
+#else
+ string letter = input[i..nextStart];
+#endif
+ builder.Append(uppercaseSuffix ? letter.ToUpperInvariant() : letter);
+ }
+ else if (IsApostropheWithinWord(input, previousStart, i, nextStart))
{
- builder.Append(input, i, length);
+ uppercaseSuffix = char.IsUpper(input, previousStart) && AreLettersFromIndexLowercase(input, nextStart);
}
- else if (!IsApostropheWithinWord(input, previousStart, i, nextStart))
+ else
{
- builder.Append(' ');
+ builder.Append(input[i] is >= '0' and <= '9' ? input[i] : ' ');
+ uppercaseSuffix = false;
}
previousStart = i;
@@ -65,6 +85,25 @@ private static string ReplaceNonAlphaNumericWithSpace(string input)
return builder.ToString();
}
+ ///
+ /// Determines whether every letter in the run of letters starting at is lowercase.
+ ///
+ /// The string to inspect.
+ /// The index of the first letter of the run.
+ /// true if no letter in the run is uppercase or titlecase; otherwise, false.
+ private static bool AreLettersFromIndexLowercase(string input, int start)
+ {
+ for (int i = start; i < input.Length && char.IsLetter(input, i); i += CodePointLength(input, i))
+ {
+ if (!char.IsLower(input, i))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
///
/// Determines whether the code point at is an apostrophe with a letter on
/// each side of it.