Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/8995-compiled-package-regexp.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions changelog.d/8996-elements-inline-push.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions changelog.d/8997-intl-locale-timezone.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
65 changes: 60 additions & 5 deletions crates/perry-runtime/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,51 @@ fn parse_fixed_offset(tz: &str) -> Option<i64> {
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<timezone_provider::tzif::CompiledTzdbProvider> = 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<String> {
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<i64> {
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")
Expand All @@ -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
}

Expand Down Expand Up @@ -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";
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-runtime/src/intl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion crates/perry-runtime/src/intl/date_collator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions crates/perry-runtime/src/intl/icu_dtf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,18 +260,33 @@ pub(crate) fn format_components(req: &CompReq) -> Option<String> {
}

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)]
Expand Down Expand Up @@ -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"),
Expand Down
121 changes: 64 additions & 57 deletions crates/perry-runtime/src/intl/time_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "<name>"` token.
Expand Down Expand Up @@ -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" }"#
));
Expand Down
9 changes: 5 additions & 4 deletions crates/perry/src/commands/compile/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading