From 50e4013364a050e76602b9ac8279e8e856a25530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 02:00:30 +0200 Subject: [PATCH 1/2] fix(intl): apply locale and timezone data --- changelog.d/8903-intl-locale-timezone.md | 9 ++ crates/perry-runtime/Cargo.toml | 6 +- crates/perry-runtime/src/date.rs | 65 +++++++++- crates/perry-runtime/src/intl.rs | 5 +- .../perry-runtime/src/intl/date_collator.rs | 3 +- crates/perry-runtime/src/intl/icu_dtf.rs | 22 +++- crates/perry-runtime/src/intl/time_zone.rs | 121 +++++++++--------- .../compile/collect_modules/feature_detect.rs | 13 +- crates/perry/src/commands/compile/types.rs | 9 +- .../test_gap_issue_8903_intl_locale_data.ts | 24 ++++ 10 files changed, 200 insertions(+), 77 deletions(-) create mode 100644 changelog.d/8903-intl-locale-timezone.md create mode 100644 test-files/test_gap_issue_8903_intl_locale_data.ts diff --git a/changelog.d/8903-intl-locale-timezone.md b/changelog.d/8903-intl-locale-timezone.md new file mode 100644 index 0000000000..3d68569dd2 --- /dev/null +++ b/changelog.d/8903-intl-locale-timezone.md @@ -0,0 +1,9 @@ +### Fixed + +- `Intl.DateTimeFormat` now resolves arbitrary named `timeZone` options through + Perry's compiled IANA database, including daylight-saving transitions, + instead of silently formatting non-host zones as UTC. Weekday-only formats + now use the requested locale's CLDR data rather than falling back to English. +- Auto-optimized `Intl.Collator` builds now retain the Unicode normalization + tables used by locale-aware comparison, instead of silently degrading to + codepoint order. diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 1f368e96ef..20b797d077 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -210,8 +210,9 @@ proc-ipc = [] # runtime carries no duplicate tables. A hand-rolled fallback covers the off # case for size-optimized builds. intl-locale = ["dep:icu_locale", "dep:icu_locale_core"] -# CLDR-accurate Intl.DateTimeFormat / toLocaleString date-time patterns. -intl-datetime = ["dep:icu_datetime", "dep:icu_time", "dep:icu_calendar", "dep:icu_locale_core"] +# CLDR-accurate Intl.DateTimeFormat / toLocaleString date-time patterns and a +# compiled IANA database for explicit named `timeZone` options. +intl-datetime = ["dep:icu_datetime", "dep:icu_time", "dep:icu_calendar", "dep:icu_locale_core", "dep:timezone_provider"] # `full` only opt-ins the small Node-API helpers (os.hostname / os.homedir). # `postgres`, `redis`, `whoami` were previously listed here but were either # unimported (postgres, whoami) or only used by a now-deleted `redis_client.rs` @@ -313,6 +314,7 @@ perry-diagnostics = { path = "../perry-diagnostics", optional = true } # output binary. Perry supplies its own CF-free host system in `temporal::now` # (clock via `SystemTime`, zone via `crate::date::host_time_zone_name`). temporal_rs = { version = "0.2.3", default-features = false, features = ["std", "compiled_data"], optional = true } +timezone_provider = { version = "0.2.6", default-features = false, features = ["tzif"], optional = true } serde.workspace = true serde_json.workspace = true diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index 07cc28322f..58dcb36f84 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -320,15 +320,51 @@ fn parse_fixed_offset(tz: &str) -> Option { Some(sign * (h * 3600 + m * 60)) } +#[cfg(feature = "intl-datetime")] +fn compiled_tzdb() -> &'static timezone_provider::tzif::CompiledTzdbProvider { + use std::sync::OnceLock; + static PROVIDER: OnceLock = OnceLock::new(); + PROVIDER.get_or_init(Default::default) +} + +/// Resolve a named zone through the compiled IANA database and return its +/// canonical identifier. This is also the membership check used by +/// `Intl.DateTimeFormat`: a structurally plausible but unknown name must not be +/// reported from `resolvedOptions()` as though Perry can format it. +#[cfg(feature = "intl-datetime")] +pub(crate) fn canonicalize_tzdb_name(tz: &str) -> Option { + use timezone_provider::provider::TimeZoneProvider; + + let provider = compiled_tzdb(); + let id = provider.get(tz.as_bytes()).ok()?; + let canonical = provider.canonicalized(id).ok()?; + provider + .identifier(canonical) + .ok() + .map(|name| name.into_owned()) +} + +#[cfg(feature = "intl-datetime")] +fn compiled_zone_offset_seconds(tz: &str, secs: i64) -> Option { + use timezone_provider::provider::TimeZoneProvider; + + let provider = compiled_tzdb(); + let id = provider.get(tz.as_bytes()).ok()?; + let epoch_ns = i128::from(secs).checked_mul(1_000_000_000)?; + provider + .transition_nanoseconds_for_utc_epoch_nanoseconds(id, epoch_ns) + .ok() + .map(|offset| offset.0) +} + /// UTC offset (seconds east of UTC) for time-zone `tz` at instant `secs`, /// DST-aware, matching the OS tz database — the amount to add to a UTC timestamp /// to get the wall-clock time in `tz`. `UTC`/`GMT`/empty are 0; a fixed numeric /// offset is parsed directly; the process's own host zone is read straight from -/// libc (thread-safe — it uses the process `TZ`). A named zone that is NOT the -/// host zone can't be resolved without mutating the global libc `TZ` state -/// (unsafe in a threaded runtime), so it falls back to 0 (UTC) — callers that -/// need arbitrary named zones should gate a tzdb path. The common cases — -/// default (host) zone, explicit host zone, UTC, and numeric offsets — are exact. +/// libc (thread-safe — it uses the process `TZ`). `intl-datetime` builds resolve +/// every other named zone through the compiled IANA database, without mutating +/// process-global state. Minimal builds without that feature retain the UTC +/// fallback for non-host named zones. pub fn zone_offset_seconds(tz: &str, secs: i64) -> i64 { if tz.is_empty() || tz.eq_ignore_ascii_case("UTC") @@ -346,6 +382,10 @@ pub fn zone_offset_seconds(tz: &str, secs: i64) -> i64 { // the correct (DST-aware) offset for `secs`. return timestamp_to_local_components(secs).6; } + #[cfg(feature = "intl-datetime")] + if let Some(offset) = compiled_zone_offset_seconds(tz, secs) { + return offset; + } 0 } @@ -1720,6 +1760,21 @@ mod tests { assert_eq!((y, m, d, h, min, s), (2024, 1, 15, 12, 30, 45)); } + #[cfg(feature = "intl-datetime")] + #[test] + fn compiled_tzdb_resolves_named_zone_and_dst() { + assert_eq!( + canonicalize_tzdb_name("europe/berlin").as_deref(), + Some("Europe/Berlin") + ); + assert_eq!(canonicalize_tzdb_name("Mars/Olympus"), None); + + // 2026-01-07T06:05Z is CET (+01:00); 2026-09-07T06:05Z is + // CEST (+02:00). Both are explicit non-host zone lookups. + assert_eq!(zone_offset_seconds("Europe/Berlin", 1_767_765_900), 3_600); + assert_eq!(zone_offset_seconds("Europe/Berlin", 1_788_761_100), 7_200); + } + #[test] fn utc_getters_ignore_process_timezone() { const CHILD_MARKER: &str = "PERRY_DATE_UTC_GETTER_CHILD"; diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index a046153627..6afc81f2f8 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -1159,8 +1159,9 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // and an explicit invalid zone is a RangeError while an unrecognized // host default falls back to UTC. `resolved_date_time_zone` is the // single source of that logic (it canonicalizes offsets to `±HH:mm` - // for FormatOffsetTimeZoneIdentifier and validates named zones - // structurally, Perry having no tz database). + // for FormatOffsetTimeZoneIdentifier and validates/canonicalizes + // named zones against the compiled IANA database when the + // `intl-datetime` feature is present). let time_zone = resolved_date_time_zone(current_options()); set_internal_field_from_raw_handle( &obj_handle, diff --git a/crates/perry-runtime/src/intl/date_collator.rs b/crates/perry-runtime/src/intl/date_collator.rs index 6fd39575ee..948e0e6c3b 100644 --- a/crates/perry-runtime/src/intl/date_collator.rs +++ b/crates/perry-runtime/src/intl/date_collator.rs @@ -1075,6 +1075,7 @@ fn format_components( use_24h: bool, ) -> String { let has_date = year_opt.is_some() || month_opt.is_some() || day_opt.is_some(); + let has_weekday = weekday_opt.is_some(); let has_time = hour_opt.is_some() || minute_opt.is_some() || second_opt.is_some() @@ -1087,7 +1088,7 @@ fn format_components( // combos, and we skip it when era/fractional-second options are in play // (unmodeled) or a time part is present (hour-cycle handling stays on the // fallback) — so those fall through unchanged. - if has_date && !has_time && era_opt.is_none() && fractional_digits.is_none() { + if (has_date || has_weekday) && !has_time && era_opt.is_none() && fractional_digits.is_none() { if let Some(s) = icu_components( locale, year, diff --git a/crates/perry-runtime/src/intl/icu_dtf.rs b/crates/perry-runtime/src/intl/icu_dtf.rs index 429ad1f4d0..4b0627cd8b 100644 --- a/crates/perry-runtime/src/intl/icu_dtf.rs +++ b/crates/perry-runtime/src/intl/icu_dtf.rs @@ -260,18 +260,33 @@ pub(crate) fn format_components(req: &CompReq) -> Option { } let prefs = prefs(req.locale, req.hour_cycle, req.hour12)?; + let has_time = time_precision.is_some(); let mut builder = FieldSetBuilder::default(); builder.date_fields = date_fields; // A spelled month wins the length; else the weekday's; else Medium. builder.length = month_len.or(weekday_len).or(Some(Length::Medium)); builder.time_precision = time_precision; - let fieldset = builder.build_composite_datetime().ok()?; let date = Date::try_new_iso(req.year, req.month.into(), req.day.into()).ok()?; let time = Time::try_new(req.hour, req.minute, req.second, 0).ok()?; let dt = DateTime { date, time }; - let dtf = DateTimeFormatter::try_new(prefs, fieldset).ok()?; - Some(normalize(&dtf.format(&dt).to_string())) + let formatted = match (has_date, has_time) { + (true, true) => { + let dtf = + DateTimeFormatter::try_new(prefs, builder.build_date_and_time().ok()?).ok()?; + dtf.format(&dt).to_string() + } + (true, false) => { + let dtf = DateTimeFormatter::try_new(prefs, builder.build_date().ok()?).ok()?; + dtf.format(&dt.date).to_string() + } + (false, true) => { + let dtf = DateTimeFormatter::try_new(prefs, builder.build_time().ok()?).ok()?; + dtf.format(&dt.time).to_string() + } + (false, false) => return None, + }; + Some(normalize(&formatted)) } #[cfg(test)] @@ -437,6 +452,7 @@ mod tests { ), ("ja", n, Some("long"), n, None, "2026年1月5日"), ("fr", None, Some("long"), n, Some("long"), "lundi 5 janvier"), + ("de", None, None, None, Some("long"), "Montag"), ("de", None, Some("long"), n, None, "5. Januar"), ("ko", n, Some("long"), n, None, "2026년 1월 5일"), ("en-GB", None, Some("short"), n, Some("short"), "Mon 5 Jan"), diff --git a/crates/perry-runtime/src/intl/time_zone.rs b/crates/perry-runtime/src/intl/time_zone.rs index 108b61097f..970187c6ba 100644 --- a/crates/perry-runtime/src/intl/time_zone.rs +++ b/crates/perry-runtime/src/intl/time_zone.rs @@ -32,72 +32,79 @@ pub(crate) fn resolved_date_time_zone(options: f64) -> String { } } -/// Structurally validate + canonicalize a named IANA time zone. Perry has no -/// tz database, so this checks the identifier shape (and a table of legacy -/// single-component zones) rather than membership. Returns `None` for a -/// malformed / unrecognized identifier. +/// Validate and canonicalize a named IANA time zone. `intl-datetime` builds use +/// Perry's compiled database; minimal builds retain the structural fallback +/// (including legacy single-component names). Returns `None` for a malformed or +/// unrecognized identifier. pub(crate) fn canonicalize_named_time_zone(tz: &str) -> Option { if tz.eq_ignore_ascii_case("UTC") || tz.eq_ignore_ascii_case("Etc/UTC") { return Some("UTC".to_string()); } - if !tz.is_ascii() { - return None; + #[cfg(feature = "intl-datetime")] + { + return crate::date::canonicalize_tzdb_name(tz); } - // Legacy single-component IANA zones / links that carry no '/'. - const SINGLE_WORD_ZONES: &[&str] = &[ - "GMT", - "GMT0", - "Zulu", - "Universal", - "UCT", - "Greenwich", - "Navajo", - "Eire", - "Iceland", - "Cuba", - "Egypt", - "Hongkong", - "Iran", - "Israel", - "Japan", - "Jamaica", - "Libya", - "Poland", - "Portugal", - "PRC", - "Singapore", - "Turkey", - "ROC", - "ROK", - "W-SU", - "Factory", - "EST", - "MST", - "HST", - "EST5EDT", - "CST6CDT", - "MST7MDT", - "PST8PDT", - ]; - if SINGLE_WORD_ZONES.iter().any(|z| z.eq_ignore_ascii_case(tz)) { - return Some(tz.to_string()); - } - let segments: Vec<&str> = tz.split('/').collect(); - if segments.len() < 2 { - return None; - } - let mut has_alpha = false; - for seg in &segments { - if seg.is_empty() { + #[cfg(not(feature = "intl-datetime"))] + { + if !tz.is_ascii() { + return None; + } + // Legacy single-component IANA zones / links that carry no '/'. + const SINGLE_WORD_ZONES: &[&str] = &[ + "GMT", + "GMT0", + "Zulu", + "Universal", + "UCT", + "Greenwich", + "Navajo", + "Eire", + "Iceland", + "Cuba", + "Egypt", + "Hongkong", + "Iran", + "Israel", + "Japan", + "Jamaica", + "Libya", + "Poland", + "Portugal", + "PRC", + "Singapore", + "Turkey", + "ROC", + "ROK", + "W-SU", + "Factory", + "EST", + "MST", + "HST", + "EST5EDT", + "CST6CDT", + "MST7MDT", + "PST8PDT", + ]; + if SINGLE_WORD_ZONES.iter().any(|z| z.eq_ignore_ascii_case(tz)) { + return Some(tz.to_string()); + } + let segments: Vec<&str> = tz.split('/').collect(); + if segments.len() < 2 { return None; } - for b in seg.bytes() { - if b.is_ascii_alphabetic() { - has_alpha = true; - } else if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'+' || b == b'-') { + let mut has_alpha = false; + for seg in &segments { + if seg.is_empty() { return None; } + for b in seg.bytes() { + if b.is_ascii_alphabetic() { + has_alpha = true; + } else if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'+' || b == b'-') { + return None; + } + } } + has_alpha.then(|| tz.to_string()) } - has_alpha.then(|| tz.to_string()) } diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index e529a29c0a..eecf04ae2e 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -61,7 +61,11 @@ fn debug_hir_uses_string_normalization(hir_debug: &str) -> bool { // `localeCompare` has several static/dynamic HIR spellings. A bare match // deliberately over-includes the tables for a same-named user identifier; // feature detection permits size-only false positives, not false negatives. - hir_debug.contains("property: \"normalize\"") || hir_debug.contains("localeCompare") + // Intl.Collator uses the same normalization tables for canonical + // equivalence and locale-primary weights. + hir_debug.contains("property: \"normalize\"") + || hir_debug.contains("localeCompare") + || hir_debug.contains("property: \"Collator\"") } fn imports_fs_promises_glob(hir_module: &perry_hir::Module) -> bool { @@ -351,8 +355,8 @@ pub(super) fn detect_optional_feature_usage( } } - // Detect `String.prototype.normalize` / `localeCompare` (both need - // `unicode-normalization`, ~113 KB) and `Intl.Segmenter` (gates + // Detect `String.prototype.normalize` / `localeCompare` / `Intl.Collator` + // (all need `unicode-normalization`, ~113 KB) and `Intl.Segmenter` (gates // `unicode-segmentation`, ~73 KB). // `normalize` and `Segmenter` lower to nodes carrying the name as a // `property`, so those use the exact `property: ""` token. @@ -633,6 +637,9 @@ mod tests { assert!(debug_hir_uses_string_normalization( r#"StringMethod { method: "localeCompare" }"# )); + assert!(debug_hir_uses_string_normalization( + r#"PropertyGet { property: "Collator" }"# + )); assert!(!debug_hir_uses_string_normalization( r#"StringMethod { method: "toLowerCase" }"# )); diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 1e54e8decd..6e61df85ee 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -799,10 +799,11 @@ pub struct CompilationContext { /// URL parsing is otherwise hand-rolled, so a program with no URL API links /// none of the host-canonicalization/IDNA machinery. pub uses_url: bool, - /// Whether any TS module calls `String.prototype.normalize` or - /// `String.prototype.localeCompare`. Gates `perry-runtime/string-normalize` - /// (`unicode-normalization`, ~113 KB of NFC/NFD/NFKC/NFKD tables); locale - /// comparison needs NFC to honor canonical equivalence. + /// Whether any TS module calls `String.prototype.normalize`, + /// `String.prototype.localeCompare`, or constructs `Intl.Collator`. Gates + /// `perry-runtime/string-normalize` (`unicode-normalization`, ~113 KB of + /// NFC/NFD/NFKC/NFKD tables); collation needs normalization for canonical + /// equivalence and locale-primary weights. pub uses_string_normalize: bool, /// Whether any TS module constructs an `Intl.Segmenter`. Gates /// `perry-runtime/intl-segmenter` (`unicode-segmentation`, ~73 KB of UAX #29 diff --git a/test-files/test_gap_issue_8903_intl_locale_data.ts b/test-files/test_gap_issue_8903_intl_locale_data.ts new file mode 100644 index 0000000000..d75d5978f6 --- /dev/null +++ b/test-files/test_gap_issue_8903_intl_locale_data.ts @@ -0,0 +1,24 @@ +// Issue #8903: DateTimeFormat and Collator must use the locale and time-zone +// data they report from resolvedOptions(). The fixed instant is in CEST so an +// implementation that silently formats it as UTC is two hours behind. +const instant = new Date("2026-09-07T06:05:00Z"); + +const mediumDate = new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeZone: "Europe/Berlin", +}); +const weekday = new Intl.DateTimeFormat("de-DE", { + weekday: "long", + timeZone: "Europe/Berlin", +}); +const berlinTime = new Intl.DateTimeFormat("de-DE", { + timeStyle: "short", + timeZone: "Europe/Berlin", +}); + +console.log(mediumDate.resolvedOptions().locale, mediumDate.format(instant)); +console.log(weekday.resolvedOptions().locale, weekday.format(instant)); +console.log(berlinTime.resolvedOptions().timeZone, berlinTime.format(instant)); +console.log( + ["Zubehör", "Ärger", "Apfel"].sort(new Intl.Collator("de-DE").compare).join(", "), +); From 5bc2ef43a7ca0eb58e13edb708a226b405d19b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 02:17:29 +0200 Subject: [PATCH 2/2] chore(changelog): PR-key the fragment () --- changelog.d/8995-compiled-package-regexp.md | 4 ++++ changelog.d/8996-elements-inline-push.md | 3 +++ ...3-intl-locale-timezone.md => 8997-intl-locale-timezone.md} | 0 3 files changed, 7 insertions(+) create mode 100644 changelog.d/8995-compiled-package-regexp.md create mode 100644 changelog.d/8996-elements-inline-push.md rename changelog.d/{8903-intl-locale-timezone.md => 8997-intl-locale-timezone.md} (100%) diff --git a/changelog.d/8995-compiled-package-regexp.md b/changelog.d/8995-compiled-package-regexp.md new file mode 100644 index 0000000000..e3550e89a5 --- /dev/null +++ b/changelog.d/8995-compiled-package-regexp.md @@ -0,0 +1,4 @@ +Compiled packages retain RegExp method behavior when they receive a regular +expression created by application code, including on macOS allocations below +2 TB. This covers schema-library paths such as zod regex and datetime checks +when compiling with the full prebuilt stdlib. diff --git a/changelog.d/8996-elements-inline-push.md b/changelog.d/8996-elements-inline-push.md new file mode 100644 index 0000000000..aadcfcf649 --- /dev/null +++ b/changelog.d/8996-elements-inline-push.md @@ -0,0 +1,3 @@ +### Changed + +- `sub.push(v)` on a `class X extends Array` instance takes the inline append tier: the receiver's meta record resolves the elements store and the ordinary room/integrity tests and inline store run on it, instead of calling the runtime entry whose only job was to follow that pointer. Growth, forwarded receivers and every exotic flag keep the runtime path. diff --git a/changelog.d/8903-intl-locale-timezone.md b/changelog.d/8997-intl-locale-timezone.md similarity index 100% rename from changelog.d/8903-intl-locale-timezone.md rename to changelog.d/8997-intl-locale-timezone.md