Case mapping is full Unicode, with options instead of a locale¶
Level: 201 · anyone uppercasing, lowercasing or comparing without case
One line: upcase turns ß into SS, downcase(:fold) folds for comparison, and the only way to get Turkish dotless-i rules is to pass :turkic — but casecmp is still ASCII-only, and downcase does not apply the Greek final-sigma rule that Python's lower does.
Unicode case mapping is not a table of one character to one character: a lowercase letter can uppercase to two letters, and a few letters change depending on their neighbours. The Encodings library's Case is not a per-character operation ↗ has the background. Ruby's upcase, downcase, capitalize and swapcase apply Unicode's full mappings, and take options — :ascii, :turkic, :lithuanian, :fold — where other languages consult a locale.
Verified output of case_mapping_is_full_unicode_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
Full mappings: one character can become two
"stra\u{df}e".upcase "STRASSE"
"stra\u{df}e".upcase.downcase "strasse"
"\u{FB00}".upcase "FF"
"\u{130}".downcase.length 2
"\u{1C6}".capitalize "Dž"
"\u{3A3}\u{391}\u{3A3}".downcase "σασ"
Options instead of a locale
"I".downcase "i"
"I".downcase(:turkic) "ı"
"i".upcase(:turkic) "İ"
"\u{C9}COLE".downcase(:ascii) "École"
"stra\u{df}e".downcase(:fold) "strasse"
Comparing without case
"stra\u{df}e".casecmp?("STRASSE") true
"stra\u{df}e".casecmp("STRASSE") 1
"\u{C9}cole".casecmp?("\u{E9}COLE") true
"\u{C9}cole".casecmp("\u{E9}COLE") -1
"STRASSE".match?(/stra\u{df}e/i) true
Reading the output¶
- One character can become two.
ßuppercases toSS, so the round trip givesstrasse. The ligature U+FB00 uppercases toFF, andİlowercases toiplus U+0307, two code points. capitalizeuses titlecase.džbecomesDž— one code point that is neither the uppercaseDŽnor the lowercasedž.- No final sigma. Greek writes
σinside a word andςat the end of one. Ruby'sdowncasegivesσασ; Ruby's case mapping guide ↗ says context-dependent case mapping is not supported. - Options.
:turkicmapsItoıanditoİ;:asciitouches onlyA–Z, soÉstays;:foldis Unicode case folding, meant for comparison rather than display. casecmp?folds;casecmpdoes not.casecmp?matchesßwithSSandÉwithé.casecmpcompares ASCII case only, and ordersÉcolebeforeéCOLEwith-1. A regex with/ifolds likecasecmp?.
What to do¶
Compare without case using casecmp?, or downcase(:fold) on both sides — not casecmp. Store downcase(:fold) as the key for a case-insensitive lookup. Pass :turkic when displaying text you know is Turkish or Azerbaijani.
If you are coming from another language¶
Perl gets ß right only when it treats the string as Unicode, and for a string with no character above U+00FF that takes use feature 'unicode_strings' — which use v5.12 and later turn on:
Verified output of case_mapping_is_full_unicode_pl.pl — regenerated by tools/run_examples.py, never hand-typed.
uc($word), no feature STRAßE
uc("$word \x{263A}"), no feature STRASSE ☺
uc($word), unicode_strings STRASSE
fc($word) eq fc('STRASSE'), unicode_strings true
The second row is the notorious one: adding ☺ to the end of the string changes how the ß in the middle of it is uppercased. The Perl library calls it the Unicode bug ↗, and its fc is how to compare without case ↗ is casecmp? seen from Perl.
Python applies the same full mappings, and the final-sigma rule too:
Verified output of case_mapping_is_full_unicode_py.py — regenerated by tools/run_examples.py, never hand-typed.
Java's toLowerCase() with no argument uses the JVM's default locale — the Java text library's Case is locale-sensitive ↗. The Python library's Lowercasing is not folding ↗ is :fold's counterpart, and the Rust library's Wrong, but not unsafe ↗ includes a sigma bug of its own.