Skip to content

Add net11 ordinal casing APIs - #585

Merged
SimonCropp merged 1 commit into
mainfrom
ordinal-casing
Sep 9, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
ordinal-casing

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Adds the ordinal casing family from net11:

  • char.ToLowerOrdinal(char) and char.ToUpperOrdinal(char)
  • string.ToLowerOrdinal() and string.ToUpperOrdinal()
  • ReadOnlySpan<char>.ToLowerOrdinal(Span<char>) and ToUpperOrdinal
  • Rune.ToLowerOrdinal(Rune), Rune.ToUpperOrdinal(Rune) and Rune.Equals(Rune, StringComparison)

API count 1038 to 1047.

Ordinal casing is not invariant casing

Worth stating up front, because the obvious implementation is wrong. Ordinal casing declines any mapping that would move a character out of its ordinal upper-casing class, so that it stays consistent with OrdinalIgnoreCase. Six BMP code points differ from invariant casing:

Code point Ordinal Invariant
U+212A KELVIN SIGN unchanged k
U+2126 OHM SIGN unchanged omega
U+212B ANGSTROM SIGN unchanged a-ring
U+1E9E CAPITAL SHARP S unchanged sharp s
U+03F4 GREEK CAPITAL THETA SYMBOL unchanged theta
U+017F LATIN SMALL LETTER LONG S (upper) unchanged S

The implementation follows the algorithm the runtime itself uses when the ICU ordinal table is not available (TextInfo.PreserveOrdinalLowerCasingClass): invariant casing, with the lower case mapping discarded when it would change the character's ordinal upper case form, plus the long s exception on the upper case side.

Verified against net11 over the whole BMP: it agrees on every code point, for both directions.

Two further behaviours were pinned against the runtime rather than assumed:

  • Casing applies per scalar, not per char, so surrogate pairs are mapped. "\U00010400".ToLowerOrdinal() returns U+10428.
  • The span overloads return the number of chars written, return -1 when the destination is too small, and throw InvalidOperationException when the buffers overlap. The overlap check runs first.

Divergence

The mapping is derived from the running framework's invariant casing table, so it follows that framework's Unicode version rather than the one net11 is built against. Scripts added in a newer Unicode version than the host will not be cased. Recorded as a //Note: on each member.

Also fixed

char.Equals(char, StringComparison) was already polyfilled and used ToUpperInvariant for OrdinalIgnoreCase, so it reported the long s as equal to s, where net11 reports them as not equal. Now routed through ordinal casing.

Scope

Rune does not exist before netcoreapp3.0, and is not part of netstandard2.1, so those three members are gated to NETCOREAPP3_0_OR_GREATER. The char, string and span members are available on every target framework.

TextInfo.ToLower(Rune) and ToUpper(Rune) are left out. They are culture aware casing rather than ordinal, so they do not belong with this set.

Verification

  • Solution builds clean in Release. Consume builds clean in Debug across all 22 target frameworks, which covers the non-FeatureMemory and pre-Rune paths.
  • Tests green on net11.0 (1620), net462 (1597), net8.0 (1617) and net10.0 (1620), plus PublicTests, EmbeddedTests, UnsafeTests, NoRefsTests and NoExtrasTests.
  • The same assertions run against the BCL on net11 and against the polyfill on earlier target frameworks.

Adds the ordinal casing family from net11:

* char.ToLowerOrdinal(char) and char.ToUpperOrdinal(char)
* string.ToLowerOrdinal() and string.ToUpperOrdinal()
* ReadOnlySpan<char>.ToLowerOrdinal(Span<char>) and ToUpperOrdinal
* Rune.ToLowerOrdinal(Rune), Rune.ToUpperOrdinal(Rune) and
  Rune.Equals(Rune, StringComparison)

Ordinal casing is not invariant casing. It deliberately declines any
mapping that would move a character out of its ordinal upper-casing
class, so that it stays consistent with OrdinalIgnoreCase. The Kelvin,
Ohm and Angstrom signs, the capital sharp s, the Greek capital theta
symbol and the long s are all left uncased where invariant casing would
map them.

The implementation follows the algorithm the runtime itself uses when the
ICU ordinal table is unavailable: invariant casing, with the lower case
mapping discarded when it would change the character's ordinal upper case
form. Verified against net11 over the whole BMP, where it now agrees on
every code point.

Casing is applied per scalar rather than per char, so surrogate pairs are
mapped, matching net11.

The Rune members are only available from netcoreapp3.0, since Rune does
not exist earlier and is not part of netstandard2.1.

Also fixes char.Equals(char, StringComparison), which used invariant
casing for OrdinalIgnoreCase and so reported the long s as equal to s
where net11 reports it as not equal.
@SimonCropp SimonCropp added this to the 11.3.0 milestone Sep 9, 2026
@SimonCropp
SimonCropp merged commit 9f002ab into main Sep 9, 2026
4 of 6 checks passed
@SimonCropp
SimonCropp deleted the ordinal-casing branch September 9, 2026 12:13
This was referenced Sep 10, 2026
This was referenced Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant