Files
RustPython/crates/unicode/build.rs
Jeong, YunWon 55c012cffd unicode crate (#8211)
* Extract unicodedata core into rustpython-unicode crate

Move the vm-free Unicode character database access — the generated UCD 3.2.0 /
latest tables, their build-time generation, and the icu4x/unicode_names2 lookups
for category/bidirectional/combining/east_asian_width/mirrored/decomposition/
normalize/is_normalized/digit/decimal/numeric/name/lookup — into a new leaf crate
over char/CodePoint/&Wtf8. stdlib/unicodedata.rs keeps the UCD pyclass binding: it
extracts the code point, boxes results, and maps errors. The unicode data files,
build.rs table generation, and the icu_properties/icu_normalizer/unicode_names2
dependencies move out of stdlib.

Assisted-by: Claude

* Route str predicates, casefold, and printable through rustpython-unicode

Add classify/case/identifier modules to the shared crate over char/&str/&Wtf8:
isalpha/isalnum/isdecimal/isdigit/isnumeric/isspace/isprintable classification,
XID identifier predicates, and full-mapping casefold. vm/builtins/str.rs and
literal/char.rs now call these instead of icu_properties/icu_casemap directly.
String-level iteration with final-sigma handling (lower/upper/title/capitalize,
islower/isupper) stays in the runtime.

Assisted-by: Claude

* Route sre_engine char classes through rustpython-unicode

Add the regex module with the SRE character-class and case predicates
(is_word/is_space/is_digit, is_uni_* Unicode variants, ascii/locale/unicode case
folding). sre_engine/string.rs now forwards to it, keeping its public API and
behavior identical — including the ASCII-only is_uni_digit and the hardcoded
is_uni_space table.

Assisted-by: Claude

* Route \N{} name lookups through rustpython-unicode

codegen/string_parser.rs (\N{...} escapes) and common/encodings.rs (the
namereplace error handler) now resolve character names via rustpython-unicode
instead of depending on unicode_names2 directly, leaving the crate as the sole
owner of the name database.

Assisted-by: Claude

* Add differential Unicode sweep and shared-crate snippet test

tests/differential.rs sweeps the full 0..0x110000 range and compares each str
classification predicate against a committed CPython 3.14 reference dataset
(tests/data/cpython3.14_predicates.txt, produced by generate_reference.py). Code
points that differ only because the Rust std / icu4x build ships a later Unicode
release than CPython 3.14's 16.0.0 are recorded in
tests/data/version_skew_cpython3.14.txt, regenerable via
RUSTPYTHON_UNICODE_REGEN_SKEW=1; the regen refuses to record any
cpython=true/crate=false divergence, so only newly-assigned code points are
allowed. Any other divergence fails. Both data files use a run-length
`predicate start:end,...` encoding.

extra_tests/snippets/stdlib_unicode_shared.py exercises the routed surface end to
end (str predicates, casefold, identifiers, unicodedata, normalize, \N{}, and re
character classes) and passes identically on CPython 3.14 and RustPython.

Assisted-by: Claude

* Match regex \d to Unicode decimal digits

is_uni_digit previously matched only ASCII 0-9, so re's \d in Unicode mode
missed decimal digits like ٥ and ५. SRE_UNI_IS_DIGIT matches Py_UNICODE_ISDECIMAL
(category Nd), so route it through classify::is_decimal. Unmasks test_bug_6561 in
test_re and extends the shared-crate snippet with the Nd/Nl/No cases.

Assisted-by: Claude

* Replace shallow Unicode wrappers with re-exports

Address review feedback on the extraction.

Functions that only forwarded to the shared crate become `use ... as`
re-exports rather than hand-written wrappers:

- literal: is_printable is dropped; escape.rs calls
  rustpython_unicode::classify::is_repr_printable directly.
- unicode::identifier::is_continue re-exports is_xid_continue.
- unicode::data::lookup_character re-exports unicode_names2::character.
- Drop the unused repr(u8) on DecompositionType.

The SRE character-class and case predicates move back into
sre_engine::string (their pre-extraction home) instead of living in a
unicode::regex module that only sre_engine and the vm _sre binding used;
is_uni_digit/is_uni_alnum still delegate to rustpython_unicode::classify.
engine.rs and _sre consume them from sre_engine::string again.

The unicode crate's tests move from a single root tests module in lib.rs
into per-module test submodules (case, classify, data, identifier,
normalize).

Assisted-by: Claude

* no-std

* Address review feedback: test module, dead variant, WTF-8 normalization

- differential.rs: move the sweep tests into a `mod tests` block and drop
  the file-level allow of clippy::tests_outside_test_module and
  std_instead_of_alloc; the test uses alloc collections instead.
- data.rs: remove the never-constructed DecompositionType::Canonical variant
  and its allow(unused); compatibility decomposition never produces it and
  canonical decomposition is handled through icu4x.
- normalize.rs: is_normalized now takes &Wtf8 and checks each UTF-8 run,
  skipping lone surrogates, matching normalize's run-wise behavior.
- unicodedata.rs: pass as_wtf8() to is_normalized.

Assisted-by: Claude
2026-07-07 09:19:25 +09:00

613 lines
20 KiB
Rust

// spell-checker:ignore decomp DECOMP
extern crate alloc;
use core::num::NonZeroUsize;
use alloc::collections::{BTreeMap, BTreeSet};
use std::{
env,
fs::{self, File},
io::{self, BufRead, BufReader, BufWriter, Write},
path::{Path, PathBuf},
thread,
};
use icu_properties::props::{EnumeratedProperty, GeneralCategory, NumericType};
fn generate_unicode_3_2() {
let path = PathBuf::from(env::var("OUT_DIR").unwrap())
.join("generated")
.join("unicode_3_2.rs");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut writer = BufWriter::new(File::create(&path).unwrap());
let base = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("unicode")
.join("ucd32");
write_derived(
&base,
"DerivedGeneralCategory-3.2.0.txt",
"GENERAL_CATEGORY",
"(u32, u32, GeneralCategory)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, id, _| {
let id = parse_general(id);
if id != GeneralCategory::Unassigned {
Some((start, end, id))
} else {
None
}
},
|writer, mut values| {
values.sort_unstable_by_key(|(start, _, _)| *start);
write!(writer, "[").unwrap();
for (start, end, id) in values {
write!(writer, "({start}, {end}, GeneralCategory::{id:?}),").unwrap();
}
write!(writer, "];").unwrap();
},
);
write_derived(
&base,
"DerivedEastAsianWidth-3.2.0.txt",
"EAST_ASIAN_WIDTH",
"(u32, u32, EastAsianWidth)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, id, _| {
let id = parse_eaw(id);
if id != "EastAsianWidth::Neutral" {
Some((start, end, id))
} else {
None
}
},
|writer, mut values| {
values.sort_unstable_by_key(|(start, _, _)| *start);
write!(writer, "[").unwrap();
for (start, end, id) in values {
write!(writer, "({start}, {end}, {id}),").unwrap();
}
write!(writer, "];").unwrap();
},
);
write_derived(
&base,
"DerivedBidiClass-3.2.0.txt",
"BIDI_CLASS",
"(u32, u32, BidiClass)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, id, _| {
let id = parse_bidi(id);
if id != "BidiClass::LeftToRight" {
Some((start, end, id))
} else {
None
}
},
|writer, mut values| {
values.sort_unstable_by_key(|(start, _, _)| *start);
write!(writer, "[").unwrap();
for (start, end, id) in values {
write!(writer, "({start}, {end}, {id}),").unwrap();
}
write!(writer, "];").unwrap();
},
);
write_derived(
&base,
"DerivedBinaryProperties-3.2.0.txt",
"BIDI_MIRRORED",
"(u32, u32)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, id, _| {
assert_eq!(
"Bidi_Mirrored",
id.trim(),
"DerivedBinaryProperties-3.2.0 only has Bidi_Mirrored"
);
Some((start, end))
},
|writer, mut values| {
values.sort_unstable_by_key(|(start, _)| *start);
writeln!(writer, "{values:?};").unwrap();
},
);
write_derived(
&base,
"DerivedCombiningClass-3.2.0.txt",
"COMBINING_CLASS",
"(u32, u32, CanonicalCombiningClass)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, id, _| {
let id: u8 = id.parse().unwrap();
if id == 0 {
return None;
}
Some((start, end, id))
},
|writer, mut values| {
values.sort_unstable_by_key(|(start, _, _)| *start);
write!(writer, "[").unwrap();
for (start, end, id) in values {
write!(
writer,
"({start}, {end}, CanonicalCombiningClass::from_icu4c_value({id})),"
)
.unwrap();
}
writeln!(writer, "];").unwrap();
},
);
}
fn generate_numeric_type() {
let path = PathBuf::from(env::var("OUT_DIR").unwrap())
.join("generated")
.join("unicode_num_type.rs");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut writer = BufWriter::new(File::create(&path).unwrap());
let base = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("unicode")
.join("ucd32");
write_derived(
&base,
"DerivedNumericType-3.2.0.txt",
"NUMERIC_TYPE_DIFF",
"(u32, u32, NumericType)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, id, _| {
let id = parse_numeric_type_str(id);
let differs = (start..=end).any(|c| match char::from_u32(c) {
Some(c) => {
let modern = parse_numeric_type_val(NumericType::for_char(c));
modern != id
}
None => true,
});
if differs {
Some((start, end, id))
} else {
None
}
},
|writer, mut values| {
values.sort_unstable_by_key(|(start, _, _)| *start);
write!(writer, "[").unwrap();
for (start, end, id) in values {
write!(writer, "({start}, {end}, {id}),").unwrap();
}
writeln!(writer, "];").unwrap();
},
);
}
fn generate_numeric_value() {
let path = PathBuf::from(env::var("OUT_DIR").unwrap())
.join("generated")
.join("unicode_numeric_value.rs");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut writer = BufWriter::new(File::create(&path).unwrap());
// Ideally, this would store the diffs between the two tables. However, we need 3.2.0
// membership as well as different chars. The final tables are both smaller than storing the
// full 3.2.0 value table.
let ucd32 = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("unicode")
.join("ucd32");
let mut ucd32_diffs = BTreeMap::new();
let mut ucd32_member = BTreeSet::new();
let numeric_32 =
BufReader::new(File::open(ucd32.join("DerivedNumericValues-3.2.0.txt")).unwrap());
parse_unicode_3_2(
numeric_32,
NonZeroUsize::new(1).unwrap(),
&mut io::empty(),
|start, end, value, _| {
let value: f64 = value
.parse()
.expect("Unicode data contains valid properties");
ucd32_diffs.insert((start, end), value);
ucd32_member.insert((start, end));
Option::<()>::None
},
|_writer, _values| {},
);
let ucd_latest = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("unicode")
.join("latest");
write_derived(
&ucd_latest,
"DerivedNumericValues.txt",
"NUMERIC_VALUES",
"(u32, u32, f64)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, end, value, _| {
let value: f64 = value
.parse()
.expect("Unicode data contains valid properties");
if ucd32_diffs
.get(&(start, end))
.is_some_and(|old_v| *old_v == value)
{
ucd32_diffs.remove(&(start, end));
}
Some((start, end, value))
},
|writer, mut values| {
values.sort_unstable_by_key(|(ch, _, _)| *ch);
writeln!(writer, "{values:?};").unwrap();
},
);
// TODO: More flexible parser
writeln!(
writer,
"static NUMERIC_VALUES_DIFF: &[(u32, u32, f64)] = &["
)
.unwrap();
for ((start, end), value) in ucd32_diffs {
write!(writer, "({start}, {end}, {value:?}),").unwrap();
}
writeln!(writer, "];").unwrap();
// Compress membership table
let mut iter = ucd32_member.iter();
let &(mut start_prev, mut end_prev) = iter.next().unwrap();
let mut membership = Vec::new();
for &(start, end) in iter {
if start <= end_prev + 1 {
end_prev = end_prev.max(end);
} else {
membership.push((start_prev, end_prev));
start_prev = start;
end_prev = end;
}
}
membership.push((start_prev, end_prev));
membership.sort_unstable_by_key(|&(start, _)| start);
writeln!(writer, "static NUMERIC_VAL_EXISTS_32: &[(u32, u32)] = &").unwrap();
write!(writer, "{membership:?};").unwrap();
}
fn generate_unicode_latest() {
let path = PathBuf::from(env::var("OUT_DIR").unwrap())
.join("generated")
.join("unicode_latest.rs");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut writer = BufWriter::new(File::create(&path).unwrap());
let base = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("unicode")
.join("latest");
// NOTE:
// This ONLY parses compatibility decomposition because Python exposes the tags. The tags are
// the "<square>", "<circle>", et cetera bits before the decomposition. Thus, we can save space
// by using icu4x's CanonicalDecomposer for non-compatibility decomposition.
let mut decomp_ranges = Vec::new();
write_derived(
&base,
"UnicodeData.txt",
"DECOMP_COMPAT",
"(u32, DecompositionType, usize)",
NonZeroUsize::new(5).unwrap(),
&mut writer,
|start, _end, value, _| {
// We're building a sparse array. Most characters don't decompose, so we don't
// need to literally store a row for each char.
if value.is_empty() {
return None;
}
let (dtype, decomp) = value.split_once('>').map(|(dtype, decomp)| {
let dtype = dtype.strip_prefix('<').unwrap_or_else(|| {
panic!("Compatibility decomp; expected <tag>\n\tgot: {value}")
});
(
parse_decomp_type(dtype),
decomp
.split_whitespace()
.map(|s| u32::from_str_radix(s, 16).unwrap()),
)
})?;
decomp_ranges.extend(decomp);
let end = decomp_ranges.len();
Some((start, dtype, end))
},
|writer, values| {
// UnicodeData.txt should already be sorted
write!(writer, "[").unwrap();
for (start, dtype, end) in values {
write!(writer, "({start}, DecompositionType::{dtype:?}, {end}),").unwrap();
}
writeln!(writer, "];").unwrap();
},
);
writeln!(writer, "static DECOMP_RANGE: &[u32] = &{decomp_ranges:?};").unwrap();
// Normalization corrections is super small - only a handful chars at the time of writing.
write_derived(
&base,
"NormalizationCorrections.txt",
"DECOMP_UPDATES",
"(u32, u32)",
NonZeroUsize::new(1).unwrap(),
&mut writer,
|start, _end, value, line| {
let original = u32::from_str_radix(value.trim(), 16).unwrap_or_else(|e| {
panic!("field 2 of decomp corrections should be a char in hex: {value} {e}")
});
let version = line
.rsplit(';')
.next()
.unwrap_or_else(|| {
panic!("field 4 of decomp corrections should be a UCD version: {line}")
})
.split_once('#')
.unwrap()
.0
.trim();
// `version` = when the char was updated. Therefore, we use the incorrect chars past
// 3.2.0 but skip the chars fixed in 3.2.0 because they'll already be right.
if version != "3.2.0" {
Some((start, original))
} else {
None
}
},
|writer, mut values| {
values.sort_unstable_by_key(|(c, _)| *c);
write!(writer, "{values:?};").unwrap();
},
);
}
#[expect(clippy::too_many_arguments)]
fn write_derived<W, P, FW, T>(
base: &Path,
file_name: &str,
static_name: &str,
array_type: &str,
field: NonZeroUsize,
writer: &mut W,
parse: P,
write_vec: FW,
) where
W: Write,
P: FnMut(u32, u32, &str, &str) -> Option<T>,
FW: FnMut(&mut W, Vec<T>),
{
let path = base.join(file_name);
let reader = BufReader::new(File::open(path).unwrap());
writeln!(writer, "static {static_name}: &[{array_type}] = &").unwrap();
parse_unicode_3_2(reader, field, writer, parse, write_vec);
}
/// Parse Unicode 3.2.0 property files.
fn parse_unicode_3_2<W, P, FW, T>(
reader: impl BufRead,
field: NonZeroUsize,
writer: &mut W,
mut parse: P,
mut write_vec: FW,
) where
W: Write,
P: FnMut(u32, u32, &str, &str) -> Option<T>,
FW: FnMut(&mut W, Vec<T>),
{
let mut parsed = Vec::new();
for line in reader.lines().map(Result::unwrap) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split(';');
let range = fields.next().expect("Unicode data is missing a char range");
let id = fields
.nth(field.get().saturating_sub(1))
.expect("Unicode data is missing a property");
let (start, end) = match range.split_once("..") {
Some((left, right)) => {
let start = u32::from_str_radix(left.trim(), 16).unwrap();
let end = u32::from_str_radix(right.trim(), 16).unwrap();
(start, end)
}
None => {
let start = u32::from_str_radix(range.trim(), 16).unwrap();
(start, start)
}
};
let id = id.split_once('#').map_or(id, |(left, _)| left).trim();
if let Some(val) = parse(start, end, id, line) {
parsed.push(val);
}
}
write_vec(writer, parsed);
}
fn parse_general(id: &str) -> GeneralCategory {
match id.trim() {
"Cn" => GeneralCategory::Unassigned,
"Lu" => GeneralCategory::UppercaseLetter,
"Ll" => GeneralCategory::LowercaseLetter,
"Lt" => GeneralCategory::TitlecaseLetter,
"Lm" => GeneralCategory::ModifierLetter,
"Lo" => GeneralCategory::OtherLetter,
"Mn" => GeneralCategory::NonspacingMark,
"Mc" => GeneralCategory::SpacingMark,
"Me" => GeneralCategory::EnclosingMark,
"Nd" => GeneralCategory::DecimalNumber,
"Nl" => GeneralCategory::LetterNumber,
"No" => GeneralCategory::OtherNumber,
"Zs" => GeneralCategory::SpaceSeparator,
"Zl" => GeneralCategory::LineSeparator,
"Zp" => GeneralCategory::ParagraphSeparator,
"Cc" => GeneralCategory::Control,
"Cf" => GeneralCategory::Format,
"Co" => GeneralCategory::PrivateUse,
"Cs" => GeneralCategory::Surrogate,
"Pd" => GeneralCategory::DashPunctuation,
"Ps" => GeneralCategory::OpenPunctuation,
"Pe" => GeneralCategory::ClosePunctuation,
"Pc" => GeneralCategory::ConnectorPunctuation,
"Pi" => GeneralCategory::InitialPunctuation,
"Pf" => GeneralCategory::FinalPunctuation,
"Po" => GeneralCategory::OtherPunctuation,
"Sm" => GeneralCategory::MathSymbol,
"Sc" => GeneralCategory::CurrencySymbol,
"Sk" => GeneralCategory::ModifierSymbol,
"So" => GeneralCategory::OtherSymbol,
invalid => unreachable!("Unicode data contains valid properties: {invalid}"),
}
}
fn parse_eaw(id: &str) -> &'static str {
match id.trim() {
"N" => "EastAsianWidth::Neutral",
"A" => "EastAsianWidth::Ambiguous",
"H" => "EastAsianWidth::Halfwidth",
"F" => "EastAsianWidth::Fullwidth",
"Na" => "EastAsianWidth::Narrow",
"W" => "EastAsianWidth::Wide",
invalid => unreachable!("Unicode data contains valid properties: {invalid}"),
}
}
fn parse_bidi(id: &str) -> &'static str {
match id.trim() {
"L" => "BidiClass::LeftToRight",
"R" => "BidiClass::RightToLeft",
"EN" => "BidiClass::EuropeanNumber",
"ES" => "BidiClass::EuropeanSeparator",
"ET" => "BidiClass::EuropeanTerminator",
"AN" => "BidiClass::ArabicNumber",
"CS" => "BidiClass::CommonSeparator",
"B" => "BidiClass::ParagraphSeparator",
"S" => "BidiClass::SegmentSeparator",
"WS" => "BidiClass::WhiteSpace",
"ON" => "BidiClass::OtherNeutral",
"LRE" => "BidiClass::LeftToRightEmbedding",
"LRO" => "BidiClass::LeftToRightOverride",
"AL" => "BidiClass::ArabicLetter",
"RLE" => "BidiClass::RightToLeftEmbedding",
"RLO" => "BidiClass::RightToLeftOverride",
"PDF" => "BidiClass::PopDirectionalFormat",
"NSM" => "BidiClass::NonspacingMark",
"BN" => "BidiClass::BoundaryNeutral",
"FSI" => "BidiClass::FirstStrongIsolate",
"LRI" => "BidiClass::LeftToRightIsolate",
"RLI" => "BidiClass::RightToLeftIsolate",
"PDI" => "BidiClass::PopDirectionalIsolate",
invalid => unreachable!("Unicode data contains valid properties: {invalid}"),
}
}
fn parse_numeric_type_val(val: NumericType) -> &'static str {
match val {
NumericType::None => "none",
NumericType::Decimal => "decimal",
NumericType::Digit => "digit",
NumericType::Numeric => "numeric",
_ => unreachable!("Unicode data contains valid properties"),
}
}
fn parse_numeric_type_str(id: &str) -> &'static str {
match id {
"none" => "NumericType::None",
"decimal" => "NumericType::Decimal",
"digit" => "NumericType::Digit",
"numeric" => "NumericType::Numeric",
invalid => unreachable!("Unicode data contains valid properties: {invalid}"),
}
}
#[derive(Debug, Default)]
enum DecompositionType {
#[default]
Canonical,
Compat,
Circle,
Final,
Font,
Fraction,
Initial,
Isolated,
Medial,
Narrow,
Nobreak,
Small,
Square,
Sub,
Super,
Vertical,
Wide,
}
fn parse_decomp_type(id: &str) -> DecompositionType {
match id {
"canonical" => DecompositionType::Canonical,
"compat" => DecompositionType::Compat,
"circle" => DecompositionType::Circle,
"final" => DecompositionType::Final,
"font" => DecompositionType::Font,
"fraction" => DecompositionType::Fraction,
"initial" => DecompositionType::Initial,
"isolated" => DecompositionType::Isolated,
"medial" => DecompositionType::Medial,
"narrow" => DecompositionType::Narrow,
"noBreak" => DecompositionType::Nobreak,
"small" => DecompositionType::Small,
"square" => DecompositionType::Square,
"sub" => DecompositionType::Sub,
"super" => DecompositionType::Super,
"vertical" => DecompositionType::Vertical,
"wide" => DecompositionType::Wide,
invalid => unreachable!("Unicode data contains valid properties: {invalid}"),
}
}
fn main() {
println!("cargo:rerun-if-changed=unicode/ucd32");
println!("cargo:rerun-if-changed=unicode/latest");
let t_32 = thread::spawn(generate_unicode_3_2);
let t_numeric_type = thread::spawn(generate_numeric_type);
let t_numeric_value = thread::spawn(generate_numeric_value);
let t_latest = thread::spawn(generate_unicode_latest);
t_32.join().unwrap();
t_numeric_type.join().unwrap();
t_numeric_value.join().unwrap();
t_latest.join().unwrap();
}