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 }