From 713c6e7c9a2d8eb347b86046fbd3a15651e7a6e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:27:37 +0000 Subject: [PATCH] Keep a possessive acronym in one piece in Pascal/Camel/Snake/Kebab/Macro [patch] Dropping the in-word apostrophe from "CEO's" left "CEOs", which the acronym-tail rule in IsWordBoundary split into "CE Os", so "CEO's office" became ce_os_office and "API's" became ApIs. When the apostrophe follows a capital and the letters after it are all lowercase, uppercase them, so the word is the single all-caps word "CEOS". Fixes ktsu-dev/CaseConverter#88 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N3tMVtLPuNwsQF7rUEBTMQ --- CaseConverter.Test/CaseConverterTests.cs | 6 +++ CaseConverter/CaseConverter.cs | 47 ++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) 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.