From 0b9154a979321a6d065d012ce4eb7ec429bc5277 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:27:53 +0000 Subject: [PATCH] Normalize an all-caps word per word, not per string [minor] ToTitleCase tested IsAllCaps against the whole string, so an all-caps word was normalized only when nothing else in the string was lowercase. ToPascalCase and ToCamelCase inherit that through it, so the same token converted two different ways depending on its neighbours: "MAX_SIZE" gave "MaxSize" but "set MAX_SIZE" gave "SetMAXSIZE", and "URL" gave "Url" but "my URL handler" gave "MyURLHandler". The macro and snake paths never had the dependence. Lowercase each all-caps word individually before handing the string to TextInfo.ToTitleCase. A word now converts the same way whatever else is in the string, and the title-case-derived converters agree with the macro/snake ones on word boundaries. This changes output for existing callers: an all-caps word beside a lowercase one is now normalized rather than preserved as an acronym, so "parse HTTP header" Pascal-cases to "ParseHttpHeader". That is the direction README.md already documented - it shows "API_response_URL" giving "ApiResponseUrl", which the library did not actually produce until now - and it matches .NET naming guidance. The two tests that pinned "FOX" surviving in a mixed string are updated, and a test pins the README example. Fixes ktsu-dev/CaseConverter#72 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WDrz1L5y2hSRD2xci1QNDh --- CaseConverter.Test/CaseConverterTests.cs | 52 ++++++++++++++++++++- CaseConverter/CaseConverter.cs | 58 +++++++++++++++++++++--- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs index 1457aa2..c72694e 100644 --- a/CaseConverter.Test/CaseConverterTests.cs +++ b/CaseConverter.Test/CaseConverterTests.cs @@ -43,7 +43,9 @@ public void ToTitleCaseShouldHandleMultipleSpaces() { string input = " the quick brown FOX "; string result = input.ToTitleCase(); - Assert.AreEqual("The Quick Brown FOX", result); + + // "FOX" is normalized rather than kept as an acronym, the same as it would be on its own. + Assert.AreEqual("The Quick Brown Fox", result); } [TestMethod] @@ -83,7 +85,53 @@ public void ToTitleCaseShouldConvertToTitleCase() { string input = "the quick Brown FOX"; string result = input.ToTitleCase(); - Assert.AreEqual("The Quick Brown FOX", result); + + // "FOX" is normalized rather than kept as an acronym, the same as it would be on its own. + Assert.AreEqual("The Quick Brown Fox", result); + } + + // An all-caps word used to be normalized only when the entire string was all caps, because + // ToTitleCase tested IsAllCaps against the whole string. The same token therefore converted two + // different ways depending on its neighbours: "MAX_SIZE" gave "MaxSize" but "set MAX_SIZE" gave + // "SetMAXSIZE". Each pair below measures a word alone and beside a lowercase one, so a regression + // to whole-string reasoning fails the second assertion of the pair while the first still passes. + + [TestMethod] + public void ToTitleCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours() + { + Assert.AreEqual("Http", "HTTP".ToTitleCase()); + Assert.AreEqual("Parse Http Header", "parse HTTP header".ToTitleCase()); + } + + [TestMethod] + public void ToPascalCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours() + { + Assert.AreEqual("MaxSize", "MAX_SIZE".ToPascalCase()); + Assert.AreEqual("SetMaxSize", "set MAX_SIZE".ToPascalCase()); + } + + [TestMethod] + public void ToCamelCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours() + { + Assert.AreEqual("url", "URL".ToCamelCase()); + Assert.AreEqual("myUrlHandler", "my URL handler".ToCamelCase()); + } + + [TestMethod] + public void ToPascalCaseShouldMatchTheAcronymExampleInTheReadme() + { + // README.md has documented this result since before the neighbour dependence was found, while + // the library actually produced "APIResponseURL". Pinning it keeps the two from drifting again. + Assert.AreEqual("ApiResponseUrl", "API_response_URL".ToPascalCase()); + } + + [TestMethod] + public void ToPascalCaseShouldAgreeWithToMacroCaseOnWordBoundaries() + { + // The macro and snake paths never had the neighbour dependence, so they are the reference the + // title-case-derived converters are brought back into agreement with. + Assert.AreEqual("SET_MAX_SIZE", "set MAX_SIZE".ToMacroCase()); + Assert.AreEqual("SetMaxSize", "set MAX_SIZE".ToPascalCase()); } [TestMethod] diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs index 33c437b..01e9877 100644 --- a/CaseConverter/CaseConverter.cs +++ b/CaseConverter/CaseConverter.cs @@ -155,11 +155,47 @@ public static string ToUppercaseFirstChar(this string input) #endif } + /// + /// Lowercases every space separated word whose letters are all uppercase, leaving the rest alone. + /// + /// The string to process, already split into space separated words. + /// A new string with each all-caps word lowercased. + /// + /// preserves a word that is all caps, on the assumption + /// that it is an acronym. Lowering such a word first is what normalizes it instead, and deciding + /// this per word rather than for the whole string is what keeps a word's result independent of its + /// neighbours. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercasing is the point: TextInfo.ToTitleCase then capitalizes the first letter of each word.")] + private static string LowercaseAllCapsWords(string input) + { + string[] words = input.Split(' '); + StringBuilder builder = new(input.Length); + + for (int i = 0; i < words.Length; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + string word = words[i]; + builder.Append(IsAllCaps(word) ? word.ToLowerInvariant() : word); + } + + return builder.ToString(); + } + /// /// Returns a copy of this string converted to Title Case. Example: "the quick brown fox" becomes "The Quick Brown Fox". /// /// The string to convert. /// A new string in Title Case. + /// + /// An all-caps word is normalized rather than preserved as an acronym, so "HTTP" becomes + /// "Http" and "parse HTTP header" becomes "Parse Http Header". The decision is + /// made per word, so a word converts the same way whatever else is in the string. + /// public static string ToTitleCase(this string input) { Ensure.NotNull(input); @@ -168,12 +204,10 @@ public static string ToTitleCase(this string input) output = SplitOnCaseChange(output); output = CollapseSpaces(output).Trim(); - // If the input is all caps, we want to convert it to lowercase before converting to title case, - // as TextInfo.ToTitleCase preserves words that are all caps assuming they are acronyms. - if (IsAllCaps(output)) - { - output = output.ToLowerInvariant(); - } + // TextInfo.ToTitleCase preserves words that are all caps assuming they are acronyms, so lowercase + // them first. This is done per word rather than only when the whole string is all caps, because + // otherwise the same word converts two different ways depending on its neighbours. + output = LowercaseAllCapsWords(output); return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(output); } @@ -230,6 +264,12 @@ private static string CollapseSpaces(string output) /// /// The string to convert. /// A new string in PascalCase. + /// + /// An all-caps word is normalized rather than preserved as an acronym, so "MAX_SIZE" and + /// "set MAX_SIZE" become "MaxSize" and "SetMaxSize". The decision is made per + /// word by , so a word converts the same way whatever else is in + /// the string. + /// public static string ToPascalCase(this string input) { Ensure.NotNull(input); @@ -252,6 +292,12 @@ public static string ToPascalCase(this string input) /// /// The string to convert. /// A new string in camelCase. + /// + /// An all-caps word is normalized rather than preserved as an acronym, so "URL" and + /// "my URL handler" become "url" and "myUrlHandler". The decision is made per + /// word by , so a word converts the same way whatever else is in + /// the string. + /// public static string ToCamelCase(this string input) { Ensure.NotNull(input);