Skip to content

feat(intl): implement Intl.DurationFormat (#5344)#5405

Merged
proggeramlug merged 2 commits into
mainfrom
feat/intl-durationformat
Jun 18, 2026
Merged

feat(intl): implement Intl.DurationFormat (#5344)#5405
proggeramlug merged 2 commits into
mainfrom
feat/intl-durationformat

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the Intl.DurationFormat constructor — the other missing ECMA-402 constructor behind the intl402 "undefined is not a constructor" cluster (#5344), following Intl.Locale (#5359). Lives in a new intl/duration_format submodule wired into the existing formatter machinery (make_instance + install_constructor).

What's implemented

  • Constructor + options resolved in spec order (localeMatcher, numberingSystem, style, then the ten units + their *Display slots, then fractionalDigits) via a faithful GetDurationUnitOptions:
    • numericfractional promotion for milliseconds/microseconds/nanoseconds,
    • prevStyle-driven 2-digit propagation onto minutes/seconds,
    • per-unit displayDefault rules,
    • resolvedOptions reports an internal fractional style as numeric (per spec).
  • Validation: out-of-range style/*Display/numberingSystem/fractionalDigits, and null (which GetOption coerces via ToString, unlike the lenient shared reader) → RangeError.
  • format / formatToParts validate the duration argument per ToDurationRecord + IsValidDuration (non-object → TypeError; non-integral fields / mixed signs / years·months·weeks > 2³²−1 / normalized seconds ≥ 2⁵³ → RangeError), then render output.
  • resolvedOptions with the full key set and order.

Results

scripts/test262_subset.py --dir intl402/DurationFormat --all-features (node v26): 0 → 45/110 (41%).

Remaining failures are out of scope or known gaps:

  • ~40 exact-output tests (style-*-en, formatToParts-*-en, negative-*) — localized output uses a data-free English rendering; matching CLDR needs NumberFormat {style:"unit"} + ListFormat + CLDR unit patterns Perry doesn't carry.
  • ~6 Temporal.Duration-argument cases (reading fields off a Temporal cell / ISO duration-string parsing).
  • newTarget (no-new) detection and inherited Symbol.toStringTag — pre-existing runtime gaps shared by the other Intl formatters.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Intl.DurationFormat API to format durations with locale-aware settings. Supports format, formatToParts, and resolvedOptions methods for flexible duration formatting and localization.

Adds the `Intl.DurationFormat` constructor — the other missing ECMA-402
constructor behind the intl402 "undefined is not a constructor" cluster
(#5344). Lives in a new `intl/duration_format` submodule wired into the
existing formatter machinery (`make_instance` + `install_constructor`).

Implemented:
- Constructor + option resolution in spec order (localeMatcher,
  numberingSystem, style, then the ten units + their Display slots, then
  fractionalDigits), via a faithful `GetDurationUnitOptions`: the
  `numeric`→`fractional` promotion for milliseconds/microseconds/nanoseconds,
  the `prevStyle`-driven `2-digit` propagation onto minutes/seconds, and the
  per-unit `displayDefault` rules. `resolvedOptions` reports an internal
  `fractional` style as `numeric`, matching the spec.
- Structural validation: out-of-range style/display/numberingSystem/
  fractionalDigits values and `null` (which `GetOption` coerces via ToString,
  unlike the lenient shared reader) throw `RangeError`.
- `format` / `formatToParts` validate the duration argument per
  `ToDurationRecord` + `IsValidDuration` (non-object → TypeError; non-integral
  fields, mixed signs, years/months/weeks > 2^32-1, or normalized seconds
  >= 2^53 → RangeError), then render a deterministic English string.
- `resolvedOptions` with the full key set + order.

Localized output uses a data-free English rendering: full CLDR unit patterns
and list grouping need `NumberFormat {style:"unit"}` + `ListFormat` + CLDR
data Perry doesn't carry, so the exact-output `style-*-en` / `formatToParts-*`
tests still fail, as do `Temporal.Duration`-argument cases, the `newTarget`
(no-`new`) detection, and inherited `Symbol.toStringTag` (a pre-existing gap
shared by the other Intl formatters).

test262 intl402/DurationFormat: 0 -> 45/110 (41%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a full Intl.DurationFormat runtime implementation to the perry-runtime crate. A new duration_format module is declared and wired into the existing Intl namespace plumbing. The module implements option resolution, duration record validation, deterministic English rendering, and format/formatToParts/resolvedOptions thunks.

Changes

Intl.DurationFormat

Layer / File(s) Summary
Module declaration and KIND discriminator
crates/perry-runtime/src/intl.rs
Declares the duration_format module and adds the KIND_DURATION_FORMAT string discriminator used by instance construction dispatch.
Unit/style tables and internal-field helpers
crates/perry-runtime/src/intl/duration_format.rs
Defines unit/style configuration tables, HMS-vs-sub-second predicates, GetOption-like helpers, RangeError-throwing enum validation, and numberingSystem subtag validation.
GetDurationUnitOptions and configure
crates/perry-runtime/src/intl/duration_format.rs
Implements per-unit style/display resolution with spec-order conflict rules, numericfractional promotion, and prevStyle-driven propagation; then the top-level configure that sequences all constructor option validation and installs bound instance methods.
resolvedOptions serialization
crates/perry-runtime/src/intl/duration_format.rs
Constructs the JS-visible resolvedOptions object by reading stored internal fields back into locale, numberingSystem, style, per-unit display, and optional fractionalDigits properties.
ToDurationRecord / IsValidDuration
crates/perry-runtime/src/intl/duration_format.rs
Collects the ten duration unit fields from the input object, enforces integer/finite values, rejects mixed signs, errors on all-undefined input, and applies calendar and total magnitude bounds.
Rendering, thunks, and constructor
crates/perry-runtime/src/intl/duration_format.rs
Derives pluralized/abbreviated unit labels, renders validated durations as comma-separated English text, exports format_thunk/to_parts_thunk/resolved_options_thunk, and adds the constructor_thunk creating KIND_DURATION_FORMAT instances.
Namespace installation
crates/perry-runtime/src/intl.rs
Extends install_intl_namespace to register the Intl.DurationFormat constructor, dispatch configure on KIND_DURATION_FORMAT instances, and attach prototype methods.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐰 A duration ticked by, in hours and days,
Now DurationFormat knows its ways!
With fractional digits and prevStyle in tow,
The weeks and the months put on quite a show.
"format" and "parts" render English so fine—
This bunny hops onward, one spec at a time! 🕐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(intl): implement Intl.DurationFormat (#5344)' accurately and concisely describes the main change: implementing a new Intl.DurationFormat constructor.
Description check ✅ Passed The PR description is comprehensive, covering summary, implementation details, test results, and known limitations. All required template sections are present and filled out appropriately.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/intl-durationformat

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/intl/duration_format.rs`:
- Around line 204-215: The issue is that when `numberingSystem: null` is passed,
`df_get_option_string` converts it to the string "null", which then passes the
`valid_numbering_system` check because "null" is a valid alphanumeric subtag.
This bypasses proper null validation. Fix this by adding an explicit check after
retrieving the numbering system value but before calling
`valid_numbering_system`: if the returned string value is literally "null",
immediately throw a RangeError with an appropriate message before performing the
syntax validation. This ensures null is properly rejected as an invalid value
for the numberingSystem option.
- Around line 431-434: The saturating cast of n to i64 in both format! calls
within the style check (numeric/2-digit and the else branch) causes valid large
microseconds/nanoseconds values to be truncated when n exceeds i64::MAX. Replace
the as i64 casts with either the original type of n if it can represent the full
range of valid sub-second values, or cast to u64 if the values are guaranteed to
be non-negative. This applies to both instances where n is cast in the two
pieces.push() calls.
- Around line 240-248: The get_option_number function incorrectly treats both
undefined and null as absent, but per ECMA-402 spec only undefined should select
the default while null must be coerced through ToNumber and validated. Fix the
fractionalDigits option handling by either creating a numeric equivalent to
df_get_option_string that only treats undefined as absent and coerces all other
values (including null) through the same validation logic, or refactor
get_option_number to distinguish between undefined and null, ensuring that null
values are processed through the validation checks rather than silently
defaulting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a595b34-529c-4918-97b1-db1c5ad9cfb7

📥 Commits

Reviewing files that changed from the base of the PR and between 54ebb6c and d8b27c6.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/intl.rs
  • crates/perry-runtime/src/intl/duration_format.rs

Comment on lines +204 to +215
let numbering = match df_get_option_string(options, "numberingSystem") {
Some(ns) => {
if !valid_numbering_system(&ns) {
throw_range_error(&format!(
"Value {ns} out of range for Intl.DurationFormat options property numberingSystem"
));
}
ns
}
None => "latn".to_string(),
};
set_internal_field(obj, KEY_DF_NUMBERING, string_value(&numbering));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject numberingSystem: null before the syntax check.

df_get_option_string stringifies null to "null", and valid_numbering_system("null") currently accepts it because it is a 4-letter alphanumeric subtag. That bypasses the PR’s null-as-RangeError validation contract for numberingSystem.

🐛 Proposed fix
     let numbering = match df_get_option_string(options, "numberingSystem") {
         Some(ns) => {
-            if !valid_numbering_system(&ns) {
+            if ns == "null" || !valid_numbering_system(&ns) {
                 throw_range_error(&format!(
                     "Value {ns} out of range for Intl.DurationFormat options property numberingSystem"
                 ));
             }
             ns
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let numbering = match df_get_option_string(options, "numberingSystem") {
Some(ns) => {
if !valid_numbering_system(&ns) {
throw_range_error(&format!(
"Value {ns} out of range for Intl.DurationFormat options property numberingSystem"
));
}
ns
}
None => "latn".to_string(),
};
set_internal_field(obj, KEY_DF_NUMBERING, string_value(&numbering));
let numbering = match df_get_option_string(options, "numberingSystem") {
Some(ns) => {
if ns == "null" || !valid_numbering_system(&ns) {
throw_range_error(&format!(
"Value {ns} out of range for Intl.DurationFormat options property numberingSystem"
));
}
ns
}
None => "latn".to_string(),
};
set_internal_field(obj, KEY_DF_NUMBERING, string_value(&numbering));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/intl/duration_format.rs` around lines 204 - 215, The
issue is that when `numberingSystem: null` is passed, `df_get_option_string`
converts it to the string "null", which then passes the `valid_numbering_system`
check because "null" is a valid alphanumeric subtag. This bypasses proper null
validation. Fix this by adding an explicit check after retrieving the numbering
system value but before calling `valid_numbering_system`: if the returned string
value is literally "null", immediately throw a RangeError with an appropriate
message before performing the syntax validation. This ensures null is properly
rejected as an invalid value for the numberingSystem option.

Comment on lines +240 to +248
// fractionalDigits: integer in [0, 9], else RangeError. Read last.
if let Some(n) = get_option_number(options, "fractionalDigits") {
if !n.is_finite() || n.fract() != 0.0 || !(0.0..=9.0).contains(&n) {
throw_range_error(
"Value out of range for Intl.DurationFormat options property fractionalDigits",
);
}
set_internal_field(obj, KEY_DF_FRACTIONAL, n);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect DurationFormat fractionalDigits parsing and the shared number option helper.
# Expectation: `fractionalDigits: null` is not treated as absent or accepted as 0.

rg -n -C5 'fn get_option_number|get_option_number\(|fractionalDigits' crates/perry-runtime/src/intl.rs crates/perry-runtime/src/intl/duration_format.rs

Repository: PerryTS/perry

Length of output: 7314


🏁 Script executed:

#!/bin/bash
# Search for df_get_option_string implementation and how it handles null
rg -n -C8 'fn df_get_option_string|fn get_option_string' crates/perry-runtime/src/intl.rs crates/perry-runtime/src/intl/duration_format.rs

Repository: PerryTS/perry

Length of output: 2872


🏁 Script executed:

#!/bin/bash
# Search for any PR, spec comments, or test files that mention fractionalDigits null behavior
rg -n 'fractionalDigits.*null|null.*fractionalDigits|fractionalDigits.*RangeError' crates/perry-runtime/src/intl/ --type rs

Repository: PerryTS/perry

Length of output: 84


🏁 Script executed:

#!/bin/bash
# Check for test files that might document expected behavior
find crates/perry-runtime -name '*test*' -o -name '*spec*' | head -20

Repository: PerryTS/perry

Length of output: 856


Use a spec-compliant get_option_number implementation that coerces null through validation, not treat it as absent.

The string options correctly use df_get_option_string, which only treats undefined as absent and coerces all other values (including null) to trigger validation. However, fractionalDigits relies on the shared get_option_number, which treats both undefined and null as None, silently defaulting when null is passed. This violates the ECMA-402 GetOption specification: only undefined should select the default; null must be coerced through ToNumber and validated, raising a RangeError for non-integer or out-of-range values. Either provide a numeric equivalent to df_get_option_string or refactor get_option_number to match the spec-compliant behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/intl/duration_format.rs` around lines 240 - 248, The
get_option_number function incorrectly treats both undefined and null as absent,
but per ECMA-402 spec only undefined should select the default while null must
be coerced through ToNumber and validated. Fix the fractionalDigits option
handling by either creating a numeric equivalent to df_get_option_string that
only treats undefined as absent and coerces all other values (including null)
through the same validation logic, or refactor get_option_number to distinguish
between undefined and null, ensuring that null values are processed through the
validation checks rather than silently defaulting.

Comment on lines +431 to +434
if style == "numeric" || style == "2-digit" {
pieces.push(format!("{}", n as i64));
} else {
pieces.push(format!("{} {}", n as i64, unit_label(unit, &style, n)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid saturating valid large sub-second values through as i64.

to_duration_record allows large microseconds/nanoseconds values when normalized seconds stay below 2^53, but n as i64 saturates once the raw field exceeds i64::MAX, producing the wrong formatted value for valid inputs.

🐛 Proposed fix
+fn format_integral_duration_number(n: f64) -> String {
+    if n == 0.0 {
+        "0".to_string()
+    } else {
+        format!("{n:.0}")
+    }
+}
+
 /// Build the rendered segments for a validated duration. Best-effort English;
 /// the concatenation is what `format` returns and `formatToParts` mirrors.
 fn render(obj: *const ObjectHeader, vals: &[f64]) -> Vec<(&'static str, String)> {
@@
         let style = get_string_field(obj, &style_key(unit)).unwrap_or_else(|| "short".to_string());
         if style == "numeric" || style == "2-digit" {
-            pieces.push(format!("{}", n as i64));
+            pieces.push(format_integral_duration_number(n));
         } else {
-            pieces.push(format!("{} {}", n as i64, unit_label(unit, &style, n)));
+            pieces.push(format!(
+                "{} {}",
+                format_integral_duration_number(n),
+                unit_label(unit, &style, n)
+            ));
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/intl/duration_format.rs` around lines 431 - 434, The
saturating cast of n to i64 in both format! calls within the style check
(numeric/2-digit and the else branch) causes valid large
microseconds/nanoseconds values to be truncated when n exceeds i64::MAX. Replace
the as i64 casts with either the original type of n if it can represent the full
range of valid sub-second values, or cast to u64 if the values are guaranteed to
be non-negative. This applies to both instances where n is cast in the two
pieces.push() calls.

@proggeramlug proggeramlug merged commit 22d18d2 into main Jun 18, 2026
15 checks passed
@proggeramlug proggeramlug deleted the feat/intl-durationformat branch June 18, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant