Add ToUpperOrdinal and ToLowerOrdinal casing APIs#130140
Conversation
Implement ordinal (simple, one-to-one) casing on Char, Rune, String, and MemoryExtensions, matching the mappings used by OrdinalIgnoreCase comparisons. String.ToUpperOrdinal/ToLowerOrdinal return the same instance when an all-ASCII string is already in the requested case.
|
Tagging subscribers to this area: @dotnet/area-system-globalization |
There was a problem hiding this comment.
Pull request overview
Adds new ordinal casing APIs across char, Rune, string, and span-based MemoryExtensions, implemented to match the StringComparison.OrdinalIgnoreCase folding behavior (one-to-one mapping, length-preserving), including a new ordinal lower-casing table path alongside the existing upper-casing tables.
Changes:
- Introduces
ToUpperOrdinal/ToLowerOrdinalAPIs onchar,Rune,string, andReadOnlySpan<char> -> Span<char>transforms. - Implements ICU-backed ordinal lower-casing table initialization (native + managed), mirroring the existing upper-casing infrastructure and special-case handling.
- Adds comprehensive
System.Globalization.Testscoverage validating BMP-wide behavior and overload consistency.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/native/libs/System.Globalization.Native/System.Globalization.Native.def | Exports the new native entrypoint for ordinal lower-casing page initialization. |
| src/native/libs/System.Globalization.Native/pal_common.c | Implements GlobalizationNative_InitOrdinalLowerCasingPage using ICU simple lower casing + special cases. |
| src/native/libs/System.Globalization.Native/pal_casing.h | Declares the new native PAL export for ordinal lower-casing page initialization. |
| src/native/libs/System.Globalization.Native/entrypoints.c | Registers the new entrypoint for DllImport resolution. |
| src/libraries/Common/src/Interop/Interop.Casing.cs | Adds managed LibraryImport for GlobalizationNative_InitOrdinalLowerCasingPage. |
| src/libraries/System.Private.CoreLib/src/System/Globalization/OrdinalCasing.Icu.cs | Adds a mirrored lower-casing table + lazy per-page initialization and span-based lower casing. |
| src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs | Adds TextInfo.ToLowerOrdinal(char) routing based on globalization mode (Invariant / NLS / ICU). |
| src/libraries/System.Private.CoreLib/src/System/Globalization/Ordinal.cs | Adds span-based Ordinal.ToLowerOrdinal entrypoint consistent with existing ToUpperOrdinal. |
| src/libraries/System.Private.CoreLib/src/System/MemoryExtensions.Globalization.cs | Adds public span extension methods ToUpperOrdinal / ToLowerOrdinal. |
| src/libraries/System.Private.CoreLib/src/System/String.Manipulation.cs | Adds string.ToUpperOrdinal() / string.ToLowerOrdinal() with ASCII fast-path returning same instance when unchanged. |
| src/libraries/System.Private.CoreLib/src/System/Char.cs | Adds public char.ToUpperOrdinal(char) / char.ToLowerOrdinal(char). |
| src/libraries/System.Private.CoreLib/src/System/Text/Rune.cs | Adds public Rune.ToUpperOrdinal(Rune) / Rune.ToLowerOrdinal(Rune) implementations. |
| src/libraries/System.Runtime/ref/System.Runtime.cs | Adds the new public APIs to the System.Runtime ref surface (char, string, Rune). |
| src/libraries/System.Memory/ref/System.Memory.cs | Adds the new public APIs to the System.Memory ref surface (MemoryExtensions). |
| src/libraries/System.Runtime/tests/System.Globalization.Tests/System/Globalization/OrdinalCasingTests.cs | New tests validating ordinal casing invariants and special-case behavior across the BMP and overloads. |
| src/libraries/System.Runtime/tests/System.Globalization.Tests/System.Globalization.Tests.csproj | Includes the new test file in the test project. |
| src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp | Adds the new native export for WASM/WASI callhelper pinvoke tables. |
| src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp | Adds the new native export for WASM/browser callhelper pinvoke tables. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
CC @lewing to advise if we need to do anything more for WASM with this change? CC @jkotas & @GrabYourPitchforks for awareness. |
… modes Invariant and NLS simple lowering could move characters such as the Kelvin, Ohm and Angstrom signs out of their ordinal upper-casing class, so ToLowerOrdinal stopped being consistent with OrdinalIgnoreCase in those modes. Keep the original character when its simple lower mapping would change its ordinal upper-casing form, and extend the invariant-mode test to cover ToLowerOrdinal across the BMP.
…ecutor Guard the MICRO SIGN and GREEK SMALL FINAL SIGMA fold cases so they only run under ICU and invariant casing, since NLS does not treat them as OrdinalIgnoreCase-equal. Gate OrdinalIgnoreCase_Equivalence_HoldsInInvariantMode on RemoteExecutor.IsSupported so it is skipped on wasm and android instead of throwing PlatformNotSupportedException.
Upper-case the source and lowered spans once each instead of calling TextInfo.ToUpperOrdinal per non-ASCII character, which is expensive under NLS.
Only a fixed set of five BMP scalars (Greek capital theta symbol, Latin capital sharp S, Ohm, Kelvin and Angstrom signs) fall out of their ordinal upper-casing class under simple invariant or NLS lowering. Scan the source once with SearchValues and skip the pass entirely when none are present, instead of upper-casing the whole span on every lowering.
| for (int i = 0; i < source.Length; i++) | ||
| { | ||
| char c = source[i]; | ||
| if (c <= '\u00FF') // optimize ASCII/Latin |
There was a problem hiding this comment.
Can we call Ascii.ToLower first to quickly scan through any leading ASCII?
| int consumed; | ||
|
|
||
| // Fast path: scan the leading run of ASCII characters that is already in the | ||
| // requested case. If the entire string is ASCII and needs no change, return the | ||
| // same instance to avoid an allocation. This mirrors the behavior of | ||
| // TextInfo.ChangeCaseCommon used by ToUpper(Invariant)/ToLower. | ||
| fixed (char* pSource = this) | ||
| { | ||
| nuint currIdx = 0; // in chars | ||
|
|
||
| if (Length >= 2) | ||
| { | ||
| nuint lastIndexWhereCanReadTwoChars = (uint)Length - 2; | ||
| do | ||
| { | ||
| // Read 2 chars (one 32-bit integer) at a time. | ||
| uint tempValue = Unsafe.ReadUnaligned<uint>(pSource + currIdx); | ||
| if (!Utf16Utility.AllCharsInUInt32AreAscii(tempValue) || | ||
| (toUpper | ||
| ? Utf16Utility.UInt32ContainsAnyLowercaseAsciiChar(tempValue) | ||
| : Utf16Utility.UInt32ContainsAnyUppercaseAsciiChar(tempValue))) | ||
| { | ||
| goto MustChange; | ||
| } | ||
|
|
||
| currIdx += 2; | ||
| } while (currIdx <= lastIndexWhereCanReadTwoChars); | ||
| } | ||
|
|
||
| // If there's a single character left, check it now. | ||
| if ((Length & 1) != 0) | ||
| { | ||
| uint tempValue = pSource[currIdx]; | ||
| if (tempValue > 0x7Fu || | ||
| (toUpper | ||
| ? ((tempValue - 'a') <= (uint)('z' - 'a')) | ||
| : ((tempValue - 'A') <= (uint)('Z' - 'A')))) | ||
| { | ||
| goto MustChange; | ||
| } | ||
| } | ||
|
|
||
| // The whole string is ASCII and already in the requested case. | ||
| return this; | ||
|
|
||
| MustChange: | ||
| consumed = (int)currIdx; | ||
| } |
There was a problem hiding this comment.
This seems like it could be written as something like this instead:
int skippedAscii = toUpper
? this.AsSpan().IndexOfAnyExcept(s_asciiExceptLower)
: this.AsSpan().IndexOfAnyExcept(s_asciiExceptUpper);
if (skippedAscii < 0)
{
return this;
}
private const string AsciiExceptLetters = "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F !\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`{|}~\u007F";
private static readonly SearchValues<char> s_asciiExceptLower = SearchValues.Create(AsciiExceptLetters + "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
private static readonly SearchValues<char> s_asciiExceptUpper = SearchValues.Create(AsciiExceptLetters + "abcdefghijklmnopqrstuvwxyz");And avoid the unsafe code
Implements the ordinal casing APIs approved in #90999.
APIs
char.ToUpperOrdinal(char)/char.ToLowerOrdinal(char)Rune.ToUpperOrdinal(Rune)/Rune.ToLowerOrdinal(Rune)string.ToUpperOrdinal()/string.ToLowerOrdinal()MemoryExtensions.ToUpperOrdinal(ReadOnlySpan<char>, Span<char>)/ToLowerOrdinal(ReadOnlySpan<char>, Span<char>)Notes
OrdinalIgnoreCasecomparisons. Two strings areOrdinalIgnoreCase-equal iff theirToUpperOrdinalresults are ordinally equal.OrdinalIgnoreCase.string.ToUpperOrdinal()/string.ToLowerOrdinal()return the same instance when an all-ASCII string is already in the requested case, avoiding an allocation.Tests under System.Globalization.Tests validate the mappings against
OrdinalIgnoreCaseacross the entire BMP.