mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-29 03:04:38 +08:00
* Move str casing into rustpython-unicode::case and drop icu from vm
Add code-point simple mappings (simple_lowercase/uppercase/titlecase/fold),
casing predicates (is_lowercase/is_uppercase/is_titlecase/is_cased/
is_case_ignorable), and the string-level capitalize/title/swapcase/casefold
helpers to crates/unicode/src/case.rs, moving the titlecase segmentation and
final-sigma logic out of vm/builtins/str.rs verbatim. str.rs and anystr.rs now
call through the crate; the is_cased kernel takes plain fn(char)->bool
predicates instead of icu BinaryProperty generics.
Remove icu_casemap/icu_locale/icu_properties/writeable from crates/vm and the
now-unused VecFmtWriter helper. crates/unicode is the only workspace member
with a direct icu dependency. str.lower/upper keep using Wtf8::to_lowercase/
to_uppercase.
Assisted-by: Claude
* Route sre is_uni_space through unicode::classify::is_space
SRE_UNI_IS_SPACE is Py_UNICODE_ISSPACE. Replace the hand-rolled BMP code-point
list with classify::is_space, which is differential-tested against CPython. A
full-range sweep confirms the two agree on every code point.
Assisted-by: Claude
* Use simple case mappings and the Cased property for sre IGNORECASE
lower_unicode/upper_unicode took the first char of the full case mapping, so
code points with a full mapping but no simple one were miscased (e.g.
upper_unicode('ß') returned 'S'). Route them through case::simple_lowercase/
simple_uppercase, matching Py_UNICODE_TOLOWER/TOUPPER.
_sre.unicode_iscased derived casedness from those mappings, which only held
while the full mapping was used; with simple mappings a cased code point that
maps to itself (e.g. the ſt/st ligatures, U+FB05/06) read as uncased and lost its
_casefix equivalence. Query the Cased property directly via case::is_cased.
Assisted-by: Claude
* Sweep casing predicates and simple lowercase mapping against CPython
Extend the differential harness to cover is_lowercase/is_uppercase/is_titlecase/
is_cased over the full scalar range, sourced from str.islower/isupper, the Lt
category, and _sre.unicode_iscased. Add a parallel sweep of the simple lowercase
mapping (Py_UNICODE_TOLOWER via _sre.unicode_tolower) with its own version-skew
allow-list; CPython exposes no simple-uppercase oracle, so toupper stays on the
SRE unit tests.
Record the U+0295 Ll->Lo recategorization (Unicode 16.0.0 -> 17.0.0) as a known
reverse-direction divergence so the skew regenerator still rejects genuine
regressions.
Assisted-by: Claude
425 lines
17 KiB
Rust
425 lines
17 KiB
Rust
//! Differential sweep of the classification predicates over the full scalar
|
|
//! range `0..0x110000` against a committed CPython reference dataset.
|
|
//!
|
|
//! CPython 3.14 ships Unicode 16.0.0 while the Rust standard library / icu4x
|
|
//! build used here may be a later release. Code points whose classification
|
|
//! changed between those Unicode versions are expected to differ; those are
|
|
//! recorded in `data/version_skew_cpython3.14.txt` as an explicit allow-list.
|
|
//! Any divergence outside that list fails the test — a real regression, not a
|
|
//! version bump.
|
|
//!
|
|
//! Both data files use the same run-length format: one `predicate` line per
|
|
//! str method, followed by comma-separated hex `start:end` inclusive ranges.
|
|
|
|
// spell-checker:ignore recategorized recategorizations
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
extern crate alloc;
|
|
|
|
use alloc::collections::{BTreeMap, BTreeSet};
|
|
|
|
use rustpython_unicode::{case, classify};
|
|
|
|
const MAX: u32 = 0x110000;
|
|
const REFERENCE: &str = include_str!("data/cpython3.14_predicates.txt");
|
|
const VERSION_SKEW: &str = include_str!("data/version_skew_cpython3.14.txt");
|
|
const MAPPINGS: &str = include_str!("data/cpython3.14_mappings.txt");
|
|
const MAPPING_SKEW: &str = include_str!("data/version_skew_mappings_cpython3.14.txt");
|
|
|
|
fn crate_predicate(name: &str, cp: u32) -> bool {
|
|
let Some(c) = char::from_u32(cp) else {
|
|
// Lone surrogates are not scalars; every str predicate is false.
|
|
return false;
|
|
};
|
|
match name {
|
|
"isalpha" => classify::is_alpha(c),
|
|
"isalnum" => classify::is_alnum(c),
|
|
"isdecimal" => classify::is_decimal(c),
|
|
"isdigit" => classify::is_digit(c),
|
|
"isnumeric" => classify::is_numeric(c),
|
|
"isspace" => classify::is_space(c),
|
|
"isprintable" => classify::is_printable(c),
|
|
"isidentifier" => {
|
|
// str.isidentifier is a whole-string predicate; for a single char it
|
|
// is "may start an identifier".
|
|
classify_is_identifier_char(c)
|
|
}
|
|
"is_lowercase" => case::is_lowercase(c),
|
|
"is_uppercase" => case::is_uppercase(c),
|
|
"is_titlecase" => case::is_titlecase(c),
|
|
"is_cased" => case::is_cased(c),
|
|
other => panic!("unknown predicate {other}"),
|
|
}
|
|
}
|
|
|
|
/// The crate's simple case mapping for `name` at `cp`, as a code point.
|
|
fn crate_mapping(name: &str, cp: u32) -> u32 {
|
|
let Some(c) = char::from_u32(cp) else {
|
|
return cp;
|
|
};
|
|
match name {
|
|
"tolower" => case::simple_lowercase(c) as u32,
|
|
other => panic!("unknown mapping {other}"),
|
|
}
|
|
}
|
|
|
|
fn classify_is_identifier_char(c: char) -> bool {
|
|
rustpython_unicode::identifier::is_start(c)
|
|
}
|
|
|
|
/// Parse a `name -> sorted set of code points` map from a run-length file.
|
|
///
|
|
/// Each non-comment line is `predicate start:end,start:end,...` with inclusive
|
|
/// hex ranges; a predicate with no members is a bare `predicate`.
|
|
fn parse_ranges(text: &str) -> BTreeMap<String, BTreeSet<u32>> {
|
|
let mut map = BTreeMap::new();
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let (name, packed) = match line.split_once(' ') {
|
|
Some((name, packed)) => (name, packed.trim()),
|
|
None => (line, ""),
|
|
};
|
|
let mut set = BTreeSet::new();
|
|
if !packed.is_empty() {
|
|
for run in packed.split(',') {
|
|
let (s, e) = run.split_once(':').expect("run is start:end");
|
|
let start = u32::from_str_radix(s, 16).unwrap();
|
|
let end = u32::from_str_radix(e, 16).unwrap();
|
|
for cp in start..=end {
|
|
set.insert(cp);
|
|
}
|
|
}
|
|
}
|
|
map.insert(name.to_string(), set);
|
|
}
|
|
map
|
|
}
|
|
|
|
/// Parse a `name -> {code point -> mapped code point}` table.
|
|
///
|
|
/// Each non-comment line is `name cp:mapped,cp:mapped,...` listing only the
|
|
/// code points whose mapping differs from identity.
|
|
fn parse_mappings(text: &str) -> BTreeMap<String, BTreeMap<u32, u32>> {
|
|
let mut map = BTreeMap::new();
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let (name, packed) = match line.split_once(' ') {
|
|
Some((name, packed)) => (name, packed.trim()),
|
|
None => (line, ""),
|
|
};
|
|
let mut table = BTreeMap::new();
|
|
if !packed.is_empty() {
|
|
for pair in packed.split(',') {
|
|
let (cp, mapped) = pair.split_once(':').expect("pair is cp:mapped");
|
|
table.insert(
|
|
u32::from_str_radix(cp, 16).unwrap(),
|
|
u32::from_str_radix(mapped, 16).unwrap(),
|
|
);
|
|
}
|
|
}
|
|
map.insert(name.to_string(), table);
|
|
}
|
|
map
|
|
}
|
|
|
|
/// Collapse a sorted code-point set into inclusive `start:end` runs.
|
|
fn encode_ranges(set: &BTreeSet<u32>) -> String {
|
|
let mut runs = Vec::new();
|
|
let mut iter = set.iter().copied();
|
|
if let Some(first) = iter.next() {
|
|
let (mut start, mut end) = (first, first);
|
|
for cp in iter {
|
|
if cp == end + 1 {
|
|
end = cp;
|
|
} else {
|
|
runs.push((start, end));
|
|
start = cp;
|
|
end = cp;
|
|
}
|
|
}
|
|
runs.push((start, end));
|
|
}
|
|
runs.iter()
|
|
.map(|(s, e)| format!("{s:X}:{e:X}"))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
}
|
|
|
|
/// Recompute the full divergence set. Every entry is a `(predicate, code
|
|
/// point)` where the crate and the CPython reference disagree.
|
|
fn all_divergences(reference: &BTreeMap<String, BTreeSet<u32>>) -> Vec<(String, u32, bool)> {
|
|
let mut out = Vec::new();
|
|
for (name, truth) in reference {
|
|
for cp in 0..MAX {
|
|
let expected = truth.contains(&cp);
|
|
let actual = crate_predicate(name, cp);
|
|
if expected != actual {
|
|
out.push((name.clone(), cp, expected));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Documented `cpython=true/crate=false` divergences: a code point that had a
|
|
/// property in Unicode 16.0.0 but lost it in the release icu4x ships, because
|
|
/// the code point was recategorized (not a regression in this crate).
|
|
///
|
|
/// * U+0295 LATIN LETTER PHARYNGEAL VOICED FRICATIVE was general category `Ll`
|
|
/// in Unicode 16.0.0 and `Lo` from 17.0.0, so it is no longer `Lowercase`.
|
|
const KNOWN_RECATEGORIZATIONS: &[(&str, u32)] = &[("is_lowercase", 0x0295)];
|
|
|
|
/// Regenerate `data/version_skew_cpython3.14.txt` from the current toolchain.
|
|
///
|
|
/// Run with `RUSTPYTHON_UNICODE_REGEN_SKEW=1 cargo test -p rustpython-unicode
|
|
/// --test differential` after bumping the Rust/icu toolchain. Divergences are
|
|
/// normally one-directional (crate=true, cpython=false) — newly-assigned code
|
|
/// points from a later Unicode release. A `cpython=true, crate=false` entry
|
|
/// means a code point lost a property; that is a real regression unless it is
|
|
/// an explicit entry in `KNOWN_RECATEGORIZATIONS`, so this refuses to record
|
|
/// any other reverse-direction divergence.
|
|
#[test]
|
|
fn regen_version_skew() {
|
|
if std::env::var_os("RUSTPYTHON_UNICODE_REGEN_SKEW").is_none() {
|
|
return;
|
|
}
|
|
let reference = parse_ranges(REFERENCE);
|
|
let divergences = all_divergences(&reference);
|
|
|
|
let regressions: Vec<_> = divergences
|
|
.iter()
|
|
.filter(|(name, cp, expected)| {
|
|
*expected && !KNOWN_RECATEGORIZATIONS.contains(&(name.as_str(), *cp))
|
|
})
|
|
.collect();
|
|
assert!(
|
|
regressions.is_empty(),
|
|
"refusing to record {} cpython=true/crate=false divergence(s) — these are \
|
|
regressions, not version skew: {:?}",
|
|
regressions.len(),
|
|
®ressions[..regressions.len().min(20)]
|
|
);
|
|
|
|
let mut by_predicate: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
|
|
for (name, cp, _) in &divergences {
|
|
by_predicate.entry(name.clone()).or_default().insert(*cp);
|
|
}
|
|
|
|
let mut body = String::from(
|
|
"# Code points whose classification differs between CPython 3.14 (Unicode 16.0.0)\n\
|
|
# and the Rust std / icu4x build used here (a later Unicode release assigns them).\n\
|
|
# Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1.\n\
|
|
# Format: `predicate start:end,...` with inclusive hex ranges.\n",
|
|
);
|
|
for (name, set) in &by_predicate {
|
|
body.push_str(&format!("{name} {}\n", encode_ranges(set)));
|
|
}
|
|
let path = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/data/version_skew_cpython3.14.txt"
|
|
);
|
|
std::fs::write(path, body).unwrap();
|
|
eprintln!(
|
|
"wrote {} skew code points across {} predicates to {path}",
|
|
divergences.len(),
|
|
by_predicate.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn predicates_match_cpython_except_documented_version_skew() {
|
|
let reference = parse_ranges(REFERENCE);
|
|
let skew = parse_ranges(VERSION_SKEW);
|
|
|
|
let allowed = |name: &str, cp: u32| skew.get(name).is_some_and(|set| set.contains(&cp));
|
|
|
|
let mut unexpected: Vec<(String, u32, bool, bool)> = Vec::new();
|
|
|
|
for (name, truth) in &reference {
|
|
for cp in 0..MAX {
|
|
let expected = truth.contains(&cp);
|
|
let actual = crate_predicate(name, cp);
|
|
if expected != actual && !allowed(name, cp) {
|
|
unexpected.push((name.clone(), cp, expected, actual));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also flag stale allow-list entries: code points that no longer diverge.
|
|
let mut stale: Vec<(String, u32)> = Vec::new();
|
|
for (name, set) in &skew {
|
|
let Some(truth) = reference.get(name) else {
|
|
continue;
|
|
};
|
|
for &cp in set {
|
|
let expected = truth.contains(&cp);
|
|
let actual = crate_predicate(name, cp);
|
|
if expected == actual {
|
|
stale.push((name.clone(), cp));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !unexpected.is_empty() || !stale.is_empty() {
|
|
let mut msg = String::new();
|
|
if !unexpected.is_empty() {
|
|
msg.push_str(&format!(
|
|
"{} undocumented divergence(s) from CPython:\n",
|
|
unexpected.len()
|
|
));
|
|
for (name, cp, expected, actual) in unexpected.iter().take(50) {
|
|
msg.push_str(&format!(
|
|
" {name} U+{cp:04X}: cpython={expected} crate={actual}\n"
|
|
));
|
|
}
|
|
}
|
|
if !stale.is_empty() {
|
|
msg.push_str(&format!(
|
|
"{} stale version_skew_cpython3.14.txt entries that now agree:\n",
|
|
stale.len()
|
|
));
|
|
for (name, cp) in stale.iter().take(50) {
|
|
msg.push_str(&format!(" {name} U+{cp:04X}\n"));
|
|
}
|
|
}
|
|
panic!("{msg}");
|
|
}
|
|
}
|
|
|
|
/// All `(mapping, code point)` where the crate and CPython map differently.
|
|
fn all_mapping_divergences(
|
|
reference: &BTreeMap<String, BTreeMap<u32, u32>>,
|
|
) -> Vec<(String, u32)> {
|
|
let mut out = Vec::new();
|
|
for (name, table) in reference {
|
|
for cp in 0..MAX {
|
|
let expected = table.get(&cp).copied().unwrap_or(cp);
|
|
if crate_mapping(name, cp) != expected {
|
|
out.push((name.clone(), cp));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Regenerate `data/version_skew_mappings_cpython3.14.txt` from the current
|
|
/// toolchain (`RUSTPYTHON_UNICODE_REGEN_SKEW=1`).
|
|
///
|
|
/// Divergences are normally the crate gaining a mapping a later Unicode
|
|
/// release assigns. A code point that CPython maps but the crate leaves
|
|
/// unmapped is a regression, not version skew, so this refuses to record it.
|
|
#[test]
|
|
fn regen_mapping_version_skew() {
|
|
if std::env::var_os("RUSTPYTHON_UNICODE_REGEN_SKEW").is_none() {
|
|
return;
|
|
}
|
|
let reference = parse_mappings(MAPPINGS);
|
|
let divergences = all_mapping_divergences(&reference);
|
|
|
|
let regressions: Vec<_> = divergences
|
|
.iter()
|
|
.filter(|(name, cp)| {
|
|
let expected = reference.get(name).and_then(|t| t.get(cp)).copied();
|
|
expected.is_some_and(|e| e != *cp) && crate_mapping(name, *cp) == *cp
|
|
})
|
|
.collect();
|
|
assert!(
|
|
regressions.is_empty(),
|
|
"refusing to record {} cpython-maps/crate-unmapped divergence(s) — these are \
|
|
regressions, not version skew: {:?}",
|
|
regressions.len(),
|
|
®ressions[..regressions.len().min(20)]
|
|
);
|
|
|
|
let mut by_mapping: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
|
|
for (name, cp) in &divergences {
|
|
by_mapping.entry(name.clone()).or_default().insert(*cp);
|
|
}
|
|
|
|
let mut body = String::from(
|
|
"# Code points whose simple case mapping differs between CPython 3.14\n\
|
|
# (Unicode 16.0.0) and the icu4x build used here (a later Unicode release\n\
|
|
# assigns the pair). Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1.\n\
|
|
# Format: `mapping start:end,...` with inclusive hex ranges.\n",
|
|
);
|
|
for (name, set) in &by_mapping {
|
|
body.push_str(&format!("{name} {}\n", encode_ranges(set)));
|
|
}
|
|
let path = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/data/version_skew_mappings_cpython3.14.txt"
|
|
);
|
|
std::fs::write(path, body).unwrap();
|
|
eprintln!(
|
|
"wrote {} skew code points across {} mappings to {path}",
|
|
divergences.len(),
|
|
by_mapping.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn simple_mappings_match_cpython_except_documented_version_skew() {
|
|
let reference = parse_mappings(MAPPINGS);
|
|
let skew = parse_ranges(MAPPING_SKEW);
|
|
|
|
let allowed = |name: &str, cp: u32| skew.get(name).is_some_and(|set| set.contains(&cp));
|
|
|
|
let mut unexpected: Vec<(String, u32, u32, u32)> = Vec::new();
|
|
for (name, table) in &reference {
|
|
for cp in 0..MAX {
|
|
let expected = table.get(&cp).copied().unwrap_or(cp);
|
|
let actual = crate_mapping(name, cp);
|
|
if expected != actual && !allowed(name, cp) {
|
|
unexpected.push((name.clone(), cp, expected, actual));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut stale: Vec<(String, u32)> = Vec::new();
|
|
for (name, set) in &skew {
|
|
for &cp in set {
|
|
let expected = reference
|
|
.get(name)
|
|
.and_then(|t| t.get(&cp))
|
|
.copied()
|
|
.unwrap_or(cp);
|
|
if crate_mapping(name, cp) == expected {
|
|
stale.push((name.clone(), cp));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !unexpected.is_empty() || !stale.is_empty() {
|
|
let mut msg = String::new();
|
|
if !unexpected.is_empty() {
|
|
msg.push_str(&format!(
|
|
"{} undocumented mapping divergence(s) from CPython:\n",
|
|
unexpected.len()
|
|
));
|
|
for (name, cp, expected, actual) in unexpected.iter().take(50) {
|
|
msg.push_str(&format!(
|
|
" {name} U+{cp:04X}: cpython=U+{expected:04X} crate=U+{actual:04X}\n"
|
|
));
|
|
}
|
|
}
|
|
if !stale.is_empty() {
|
|
msg.push_str(&format!(
|
|
"{} stale version_skew_mappings_cpython3.14.txt entries that now agree:\n",
|
|
stale.len()
|
|
));
|
|
for (name, cp) in stale.iter().take(50) {
|
|
msg.push_str(&format!(" {name} U+{cp:04X}\n"));
|
|
}
|
|
}
|
|
panic!("{msg}");
|
|
}
|
|
}
|
|
}
|