diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs
index 78c6598..15f1ec7 100644
--- a/CaseConverter.Test/CaseConverterTests.cs
+++ b/CaseConverter.Test/CaseConverterTests.cs
@@ -392,4 +392,28 @@ public void ToSnakeCaseShouldStillDropAstralCharactersThatAreNotLetters()
string result = input.ToSnakeCase();
Assert.AreEqual("emoji_here", result);
}
+
+ [TestMethod]
+ [DataRow("don't stop", "DontStop", "dontStop", "dont_stop", "dont-stop", "DONT_STOP")]
+ [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")]
+ public void ApostropheWithinWordShouldNotSplitIt(string input, string pascal, string camel, string snake, string kebab, string macro)
+ {
+ Assert.AreEqual(pascal, input.ToPascalCase());
+ Assert.AreEqual(camel, input.ToCamelCase());
+ Assert.AreEqual(snake, input.ToSnakeCase());
+ Assert.AreEqual(kebab, input.ToKebabCase());
+ Assert.AreEqual(macro, input.ToMacroCase());
+ }
+
+ [TestMethod]
+ [DataRow("'quoted' word", "quoted_word")]
+ [DataRow("\u2019quoted\u2019 word", "quoted_word")]
+ [DataRow("rock 'n' roll", "rock_n_roll")]
+ [DataRow("80's music", "80_s_music")]
+ public void ApostropheNotBetweenLettersShouldStillSeparateWords(string input, string expected)
+ {
+ Assert.AreEqual(expected, input.ToSnakeCase());
+ }
}
diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs
index 4987854..8619a38 100644
--- a/CaseConverter/CaseConverter.cs
+++ b/CaseConverter/CaseConverter.cs
@@ -21,7 +21,8 @@ public static partial class CaseConverter
private static int CodePointLength(string input, int index) => char.IsSurrogatePair(input, index) ? 2 : 1;
///
- /// Replaces every code point that is not a Unicode letter or an ASCII digit with a space.
+ /// Replaces every code point that is not a Unicode letter or an ASCII digit with a space,
+ /// except that an apostrophe between two letters is dropped.
///
/// The string to process.
/// A new string with each non-alphanumeric code point replaced by a space.
@@ -31,30 +32,55 @@ public static partial class CaseConverter
/// rather than as a letter — so each half of a
/// surrogate pair matched and letters outside the Basic Multilingual Plane were silently
/// deleted instead of preserved.
+ ///
+ /// An apostrophe (' or U+2019) between two letters is part of the word, as in
+ /// "don't" or "o'neil", so it is dropped rather than turned into a separator. This
+ /// keeps "don't stop" as two words, matching . An
+ /// apostrophe anywhere else, such as a leading or trailing quote, still separates words.
+ ///
///
private static string ReplaceNonAlphaNumericWithSpace(string input)
{
StringBuilder builder = new(input.Length);
+ int previousStart = -1;
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')
{
builder.Append(input, i, length);
}
- else
+ else if (!IsApostropheWithinWord(input, previousStart, i, nextStart))
{
builder.Append(' ');
}
- i += length;
+ previousStart = i;
+ i = nextStart;
}
return builder.ToString();
}
+ ///
+ /// Determines whether the code point at is an apostrophe with a letter on
+ /// each side of it.
+ ///
+ /// The string being processed.
+ /// The index of the preceding code point, or -1 if there is none.
+ /// The index of the code point to test.
+ /// The index of the following code point, which may be past the end.
+ /// true if the code point is an in-word apostrophe; otherwise, false.
+ private static bool IsApostropheWithinWord(string input, int previousStart, int start, int nextStart) =>
+ input[start] is '\'' or '\u2019'
+ && previousStart >= 0
+ && nextStart < input.Length
+ && char.IsLetter(input, previousStart)
+ && char.IsLetter(input, nextStart);
+
///
/// Inserts a space at each case change, such as transitions from lower to upper or from a
/// letter to a non-letter.