From 4bba715538f7619dbe8d675169dc707229fb319f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:24:23 +0000 Subject: [PATCH] Case-map an astral first letter in ToCamelCase and the first-char helpers ToLowercaseFirstChar and ToUppercaseFirstChar case-mapped input[0], which for a letter outside the Basic Multilingual Plane is only the high surrogate, so the letter came back unchanged. ToCamelCase goes through ToLowercaseFirstChar and inherited this. Map the first code point instead, using the CodePointLength helper the other conversions already rely on. Fixes ktsu-dev/CaseConverter#74 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018AEYBNCRr6chFLiZDv1MVj --- CaseConverter.Test/CaseConverterTests.cs | 33 ++++++++++++++++++++ CaseConverter/CaseConverter.cs | 39 ++++++++++++++++++------ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs index c72694e..217681a 100644 --- a/CaseConverter.Test/CaseConverterTests.cs +++ b/CaseConverter.Test/CaseConverterTests.cs @@ -287,6 +287,39 @@ public void IsAllCapsShouldReturnFalseForAnAstralLowercaseLetter() Assert.IsFalse(result, "A lowercase letter outside the BMP must count as lowercase, not be skipped."); } + [TestMethod] + public void ToCamelCaseShouldLowercaseAnAstralFirstLetter() + { + string input = $"{DeseretCapitalLongI}abc"; + string result = input.ToCamelCase(); + Assert.AreEqual($"{DeseretSmallLongI}abc", result, "A first letter outside the BMP must be lowercased like any other."); + } + + [TestMethod] + public void ToCamelCaseShouldLowercaseAnAstralAllCapsFirstWord() + { + string input = $"{DeseretCapitalLongI}{DeseretCapitalLongI} foo"; + string result = input.ToCamelCase(); + Assert.AreEqual($"{DeseretSmallLongI}{DeseretSmallLongI}Foo", result); + Assert.AreEqual("xxFoo", "XX foo".ToCamelCase(), "The BMP analogue this case is matched against."); + } + + [TestMethod] + public void ToLowercaseFirstCharShouldLowercaseAnAstralFirstLetter() + { + string input = $"{DeseretCapitalLongI}abc"; + string result = input.ToLowercaseFirstChar(); + Assert.AreEqual($"{DeseretSmallLongI}abc", result); + } + + [TestMethod] + public void ToUppercaseFirstCharShouldUppercaseAnAstralFirstLetter() + { + string input = $"{DeseretSmallLongI}abc"; + string result = input.ToUppercaseFirstChar(); + Assert.AreEqual($"{DeseretCapitalLongI}abc", result); + } + [TestMethod] public void ToSnakeCaseShouldStillDropAstralCharactersThatAreNotLetters() { diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs index 01e9877..ea0bea7 100644 --- a/CaseConverter/CaseConverter.cs +++ b/CaseConverter/CaseConverter.cs @@ -126,16 +126,16 @@ private static bool IsWordBoundary(string input, int previousStart, int start, i /// /// The string to convert. /// A new string with the first character converted to lowercase. + /// + /// The first character is the first code point, so a letter outside the Basic Multilingual + /// Plane is case-mapped as a whole rather than through its high surrogate alone, which would + /// leave it unchanged. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercasing the first character is the point of this method.")] public static string ToLowercaseFirstChar(this string input) { Ensure.NotNull(input); -#if NETSTANDARD2_0 -#pragma warning disable IDE0057 // Substring cannot be simplified in netstandard2.0 - return CollapseSpaces(input.Length > 0 ? char.ToLowerInvariant(input[0]) + input.Substring(1) : input).Trim(); -#pragma warning restore IDE0057 -#else - return CollapseSpaces(input.Length > 0 ? char.ToLowerInvariant(input[0]) + input[1..] : input).Trim(); -#endif + return CollapseSpaces(MapFirstCodePoint(input, static first => first.ToLowerInvariant())).Trim(); } /// @@ -143,15 +143,36 @@ public static string ToLowercaseFirstChar(this string input) /// /// The string to convert. /// A new string with the first character converted to uppercase. + /// + /// The first character is the first code point, for the same reason as in + /// . + /// public static string ToUppercaseFirstChar(this string input) { Ensure.NotNull(input); + return CollapseSpaces(MapFirstCodePoint(input, static first => first.ToUpperInvariant())).Trim(); + } + + /// + /// Applies to the first code point of , leaving the rest unchanged. + /// + /// The string to process. + /// The mapping to apply to the first code point, given as a string of one or two UTF-16 code units. + /// A new string with the first code point mapped, or if it is empty. + private static string MapFirstCodePoint(string input, Func map) + { + if (input.Length == 0) + { + return input; + } + + int length = CodePointLength(input, 0); #if NETSTANDARD2_0 #pragma warning disable IDE0057 // Substring cannot be simplified in netstandard2.0 - return CollapseSpaces(input.Length > 0 ? char.ToUpperInvariant(input[0]) + input.Substring(1) : input).Trim(); + return map(input.Substring(0, length)) + input.Substring(length); #pragma warning restore IDE0057 #else - return CollapseSpaces(input.Length > 0 ? char.ToUpperInvariant(input[0]) + input[1..] : input).Trim(); + return map(input[..length]) + input[length..]; #endif }