From 5c15077b9ac87aa6a5137ba27a36c210381358a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:25:12 +0000 Subject: [PATCH] Keep an in-word apostrophe from splitting the word in Pascal/Camel/Snake/Kebab/Macro [patch] ReplaceNonAlphaNumericWithSpace turned every apostrophe into a space, so "don't stop" became "DonTStop" and "don_t_stop", disagreeing with ToTitleCase, which keeps "Don't" as one word. An apostrophe (' or U+2019) with a letter on each side is now dropped instead; one anywhere else still separates words. Fixes #83 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012mvcqKMc35CMobct1fmizc --- CaseConverter.Test/CaseConverterTests.cs | 24 ++++++++++++++++++ CaseConverter/CaseConverter.cs | 32 +++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) 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.