From 857d528cab5cecd055ec8f944e4f2bf502b89a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:33:22 +0000 Subject: [PATCH 1/3] Keep a capitalised apostrophe name like "O'Neil" in one word [patch] Dropping an in-word apostrophe left "O'Neil" as "ONeil", which the case-change split then broke into "O Neil", so it converted to o_neil while "o'neil" and "O'NEIL" both gave oneil. Lowercase a capital straight after the apostrophe when the letters around it are lowercase: the letter after it when there is one, otherwise the letter before the apostrophe. "O'NEIL" and "DON'T" keep their capitals. Fixes ktsu-dev/CaseConverter#96 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AtF6Ynq3K6Km6HSRHaKuX8 --- CaseConverter.Test/CaseConverterTests.cs | 18 +++++++++++ CaseConverter/CaseConverter.cs | 41 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs index f6bc99d..023d6c0 100644 --- a/CaseConverter.Test/CaseConverterTests.cs +++ b/CaseConverter.Test/CaseConverterTests.cs @@ -436,6 +436,14 @@ 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("O'Neil", "Oneil", "oneil", "oneil", "oneil", "ONEIL")] + [DataRow("O\u2019Neil", "Oneil", "oneil", "oneil", "oneil", "ONEIL")] + [DataRow("O'NEIL", "Oneil", "oneil", "oneil", "oneil", "ONEIL")] + [DataRow("D'Angelo", "Dangelo", "dangelo", "dangelo", "dangelo", "DANGELO")] + [DataRow("d'angelo", "Dangelo", "dangelo", "dangelo", "dangelo", "DANGELO")] + [DataRow("Don'T stop", "DontStop", "dontStop", "dont_stop", "dont-stop", "DONT_STOP")] + [DataRow("O'Neil's car", "OneilsCar", "oneilsCar", "oneils_car", "oneils-car", "ONEILS_CAR")] + [DataRow("McDonald's menu", "McDonaldsMenu", "mcDonaldsMenu", "mc_donalds_menu", "mc-donalds-menu", "MC_DONALDS_MENU")] [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")] @@ -461,6 +469,16 @@ public void ApostropheNotBetweenLettersShouldStillSeparateWords(string input, st Assert.AreEqual(expected, input.ToSnakeCase()); } + [TestMethod] + [DataRow("O'Neil", "o'neil")] + [DataRow("D'Angelo", "d'angelo")] + [DataRow("Don'T", "don't")] + public void ACapitalisedApostropheNameShouldConvertLikeItsLowercaseSpelling(string input, string lowercase) + { + Assert.AreEqual(lowercase.ToSnakeCase(), input.ToSnakeCase()); + Assert.AreEqual(input.ToSnakeCase(), input.ToTitleCase().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. diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs index 599cd27..1311b8f 100644 --- a/CaseConverter/CaseConverter.cs +++ b/CaseConverter/CaseConverter.cs @@ -45,12 +45,20 @@ public static partial class CaseConverter /// capitalised word and splits into "CE Os". "CEOS" is one all-caps word, which every /// converter then normalizes the same way as "CEO". /// + /// + /// Otherwise, a capital straight after the apostrophe is lowercased when the letters around it are + /// lowercase, as in "O'Neil" or "Don'T". Dropping the apostrophe alone would leave + /// "ONeil", which splits into "O Neil", so + /// "O'Neil" would convert differently from "o'neil" and "O'NEIL". + /// /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "The letter after an in-word apostrophe is lowercased so it does not start a new word.")] private static string ReplaceNonAlphaNumericWithSpace(string input) { StringBuilder builder = new(input.Length); int previousStart = -1; bool uppercaseSuffix = false; + bool lowercaseNextLetter = false; for (int i = 0; i < input.Length;) { @@ -66,11 +74,18 @@ private static string ReplaceNonAlphaNumericWithSpace(string input) #else string letter = input[i..nextStart]; #endif + if (lowercaseNextLetter) + { + letter = letter.ToLowerInvariant(); + lowercaseNextLetter = false; + } + builder.Append(uppercaseSuffix ? letter.ToUpperInvariant() : letter); } else if (IsApostropheWithinWord(input, previousStart, i, nextStart)) { uppercaseSuffix = char.IsUpper(input, previousStart) && AreLettersFromIndexLowercase(input, nextStart); + lowercaseNextLetter = !uppercaseSuffix && IsCapitalAfterApostropheInLowercaseContext(input, previousStart, nextStart); } else { @@ -85,6 +100,32 @@ private static string ReplaceNonAlphaNumericWithSpace(string input) return builder.ToString(); } + /// + /// Determines whether the letter after an in-word apostrophe is a capital that the letters around it + /// show should be lowercase. + /// + /// The string to inspect. + /// The index of the letter before the apostrophe. + /// The index of the letter after the apostrophe. + /// true if the letter after the apostrophe should be lowercased; otherwise, false. + /// + /// The letter follows the case of the letter after it when there is one, so "O'Neil" lowers the + /// "N" while "O'NEIL" keeps it. At the end of the word it follows the letter before the + /// apostrophe instead, so "Don'T" lowers the "T" while "DON'T" keeps it. + /// + private static bool IsCapitalAfterApostropheInLowercaseContext(string input, int previousStart, int nextStart) + { + if (!char.IsUpper(input, nextStart)) + { + return false; + } + + int afterNextStart = nextStart + CodePointLength(input, nextStart); + return afterNextStart < input.Length && char.IsLetter(input, afterNextStart) + ? char.IsLower(input, afterNextStart) + : !char.IsUpper(input, previousStart); + } + /// /// Determines whether every letter in the run of letters starting at is lowercase. /// From a106955f6db69265d1071972dd5b1156725f8012 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:42:30 +0000 Subject: [PATCH 2/3] Lower the letter after an apostrophe with a ternary to keep cognitive complexity in bounds Sonar S3776 flagged ReplaceNonAlphaNumericWithSpace at 17 against the allowed 15 after the nested if was added. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AtF6Ynq3K6Km6HSRHaKuX8 --- CaseConverter/CaseConverter.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs index 1311b8f..0f6971f 100644 --- a/CaseConverter/CaseConverter.cs +++ b/CaseConverter/CaseConverter.cs @@ -74,12 +74,8 @@ private static string ReplaceNonAlphaNumericWithSpace(string input) #else string letter = input[i..nextStart]; #endif - if (lowercaseNextLetter) - { - letter = letter.ToLowerInvariant(); - lowercaseNextLetter = false; - } - + letter = lowercaseNextLetter ? letter.ToLowerInvariant() : letter; + lowercaseNextLetter = false; builder.Append(uppercaseSuffix ? letter.ToUpperInvariant() : letter); } else if (IsApostropheWithinWord(input, previousStart, i, nextStart)) From 807b55cffcbeb1c286c08290beded6fdb78b642f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:52:16 +0000 Subject: [PATCH 3/3] Move the post-apostrophe lowercasing into a helper to satisfy S3776 A ternary nested in the loop still costs Sonar's nesting increment, so the method stayed at 17 against the allowed 15. Moving it into LowercaseIf brings it back under the limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AtF6Ynq3K6Km6HSRHaKuX8 --- CaseConverter/CaseConverter.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs index 0f6971f..8b481cd 100644 --- a/CaseConverter/CaseConverter.cs +++ b/CaseConverter/CaseConverter.cs @@ -52,7 +52,6 @@ public static partial class CaseConverter /// "O'Neil" would convert differently from "o'neil" and "O'NEIL". /// /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "The letter after an in-word apostrophe is lowercased so it does not start a new word.")] private static string ReplaceNonAlphaNumericWithSpace(string input) { StringBuilder builder = new(input.Length); @@ -74,7 +73,7 @@ private static string ReplaceNonAlphaNumericWithSpace(string input) #else string letter = input[i..nextStart]; #endif - letter = lowercaseNextLetter ? letter.ToLowerInvariant() : letter; + letter = LowercaseIf(letter, lowercaseNextLetter); lowercaseNextLetter = false; builder.Append(uppercaseSuffix ? letter.ToUpperInvariant() : letter); } @@ -96,6 +95,15 @@ private static string ReplaceNonAlphaNumericWithSpace(string input) return builder.ToString(); } + /// + /// Returns lowercased when is set, otherwise unchanged. + /// + /// The letter, as a string of one or two UTF-16 code units. + /// Whether to lowercase it. + /// The letter, lowercased if asked. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "The letter after an in-word apostrophe is lowercased so it does not start a new word.")] + private static string LowercaseIf(string letter, bool lowercase) => lowercase ? letter.ToLowerInvariant() : letter; + /// /// Determines whether the letter after an in-word apostrophe is a capital that the letters around it /// show should be lowercase.