TOML: map date/time values to Temporal, round-trip them through stringify - #37018
Conversation
… as date/time literals
TOML.parse previously returned all four TOML date/time types as strings
of their source text. They now map 1:1 onto Temporal:
- offset date-time -> Temporal.Instant (an offset date-time specifies an
instant; the written offset normalizes to UTC)
- local date-time -> Temporal.PlainDateTime (including TOML 1.1's space
separator and omitted seconds)
- local date -> Temporal.PlainDate
- local time -> Temporal.PlainTime
The parser now produces a dedicated E::DateTime AST node carrying the
kind and source text (fractional seconds truncated to Temporal's 9-digit
limit, as the TOML spec directs). All three sinks of the TOML AST stay
consistent: Bun.TOML.parse and import/require construct the Temporal
object through the same JSC code paths Temporal.*.from(string) uses, and
the bundler lowers the node to a Temporal.*.from("...") call over a real
unbound Temporal symbol so chunk renaming protects the global reference
and unused date exports stay tree-shakable.
TOML.stringify now emits Temporal.Instant, PlainDateTime, PlainDate, and
PlainTime as unquoted TOML date/time literals, so stringify(parse(doc))
round-trips date/times instead of re-quoting them as strings.
Temporal.ZonedDateTime emits its offset form (the time-zone annotation
has no TOML representation), non-ISO calendar annotations are dropped
the same way, and values outside TOML's 4-digit years throw like Date
already did. PlainYearMonth, PlainMonthDay, and Duration have no TOML
form and throw. Date is unchanged.
With BUN_JSC_useTemporal=0 a date/time value now throws a TypeError
(and a TOML module import fails with that exception instead of
panicking).
The toml-test conformance suite is regenerated: expectations compare
Temporal class + canonical toString, since Temporal instances have no
own properties for toEqual to see.
WalkthroughTOML date/time literals now retain their specific kind, parse into corresponding Temporal objects, and serialize back to TOML-compatible values. Bundler output, runtime conversion, error propagation, tests, and documentation were updated for the new behavior. ChangesTOML Temporal date/time handling
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
The TOML AST never enters the JS visit/transform passes (SLazyExport is not visited; the runtime import and Bun.TOML.parse convert the raw expr directly), so a dedicated expression variant is not needed: a toml_datetime tag on EString carries the same information, and only the TOML sinks (expr_to_js, data_to_js, to_lazy_export_ast, the printer's EString arm) check it. The bundler still rewrites tagged strings into Temporal.*.from calls over an unbound Temporal symbol for rename safety and tree shaking; behavior is unchanged and the test suite is identical.
…hter comments, docs/types wording for truncation, annotations, year bounds
…xport unwrap parse_data_loader hand-builds the module without a symbol table, so a top-level TOML key that shadows a global the printed code needs cannot be handled the way the bundler does. Renaming vars by string comparison was open-ended; a key named globalThis next to a date/time on this path now fails at evaluation with a TypeError instead.
…sc::TemporalType Main's WebKit already provides JSC::TemporalType / JSC::temporalType(), so drop the hand-rolled inherits<> chain and the TOML-private enum in favor of Bun__JSValue__temporalType -> bun_jsc::TemporalType, the same surface the Temporal console formatting work uses.
There was a problem hiding this comment.
I reviewed the latest revision (through 0a4b019) and the automated bug-hunting pass found no issues; my earlier inline nits on ParseTask.rs and the deep-header test have all been addressed. Given the scope — new user-facing API surface (breaking change to TOML.parse return types, Date output format tweak), new C++ Temporal bindings with uncheckedDowncast, and the deliberate --no-bundle limitation for a top-level Temporal key — a maintainer sign-off is still warranted.
What was reviewed:
- Exception handling in
Bun__Temporal__fromDateTimeLiteral/toTOMLDateTime— throw scopes andRETURN_IF_EXCEPTIONare placed correctly;uncheckedDowncastis guarded by the caller'stemporalTypeclassification. - The 64-byte stack buffer vs. worst-case Temporal string length (35 bytes for a 9-frac-digit offset date-time) —
RELEASE_ASSERT(length <= bufLen)holds. tomlOffsetForInstantInt128 arithmetic and the year-edge offset selection round-trip cases.EStringlayout change (newOption<TomlDateTimeKind>field) propagated throughDefault,init, and theexpr.rsclone path.
Extended reasoning...
Overview
This PR changes Bun.TOML.parse to return Temporal objects for TOML's four date/time types instead of source-text strings, and teaches TOML.stringify to emit Temporal (and Date) values as unquoted TOML date/time literals so parse↔stringify round-trips. It touches 25 files across the TOML parser (src/parsers/toml.rs), AST (src/ast/e.rs, expr.rs), three consumption sinks (runtime expr_jsc.rs, bundler parse_entry.rs, printer lib.rs), new C++ JSC bindings (bindings.cpp — Bun__Temporal__fromDateTimeLiteral, Bun__Temporal__toTOMLDateTime, Bun__JSValue__temporalType, tomlOffsetForInstant), the TOML.stringify implementation, docs, types, and ~1000 lines of test changes including a regenerated 708-case conformance suite.
Security risks
None identified. Input is TOML source text validated by the existing scanner before reaching Temporal construction; the new truncate_fractional_seconds bounds its allocation by input length. The C++ toTOMLDateTime writes into a caller-supplied 64-byte stack buffer with a RELEASE_ASSERT(length <= bufLen) guard; the longest possible output (9999-12-31T23:59:59.999999999-23:59, 35 bytes) fits comfortably. uncheckedDowncast on Temporal cells is safe because temporalType was already computed on the same value and has_toml_form gates the switch arms.
Level of scrutiny
High. This is a breaking API change (date/times were strings, now Temporal objects), adds new C++↔Rust FFI surface with manual buffer handling, embeds an API design decision (offset date-time → Instant rather than ZonedDateTime; year-edge instants pick a fitting offset rather than throwing; .000Z → Z for Date), and accepts a known --no-bundle limitation (top-level Temporal key shadows the printed global). These are exactly the kind of decisions REVIEW.md says need maintainer agreement, not automated approval.
Other factors
The PR has been through extensive iteration: earlier rounds fixed iterative-walk stack safety, converted all new_lazy_export_ast .unwrap() sites, dropped an open-ended globalThis mangling scheme, and addressed docs/types wording. All 40+ review threads are marked resolved, and dylan-conway has been actively engaged resolving the latest round. Test coverage is thorough (conformance suite round-trip lap, bundler symbol-collision tests, both useTemporal=0 paths, GC stress, year-edge boundary cases). No bugs were found on this revision. The remaining question is maintainer sign-off on the API shape and the --no-bundle trade-off, which is not something an automated review should decide.
|
CI on 0a4b019 (build 93779): no failing job. The red status is two |
There was a problem hiding this comment.
I reviewed this PR and the bug-hunting pass found no issues. Given it's a user-facing API change (TOML date/times now return Temporal objects instead of strings) that spans the parser, printer, bundler lazy-export path, and new JSC C++ bindings, a maintainer sign-off on the API mapping and the --no-bundle shadowing trade-off would still be worthwhile.
What was reviewed:
- The three sinks (JSC materialization, bundler lowering, printer) stay consistent; the unbound
Temporalsymbol lets the chunk renamer handle collisions. Bun__Temporal__toTOMLDateTimebuffer bound: longest output (9999-12-31T23:59:59.999999999-23:59, 35 bytes) fits the 64-byte stack buffer, guarded byRELEASE_ASSERT.tomlOffsetForInstantInt128 arithmetic and the year-edge offset selection round-trip against the parse-side test cases.- Exception handling in the new C++ bindings uses
RETURN_IF_EXCEPTIONafter every fallible Temporal call; theuseTemporal=0path throws instead of reaching the missing structures.
Extended reasoning...
Overview
This PR changes Bun.TOML.parse to return Temporal objects (Instant/PlainDateTime/PlainDate/PlainTime) for TOML's four date/time types instead of source-text strings, and teaches TOML.stringify to emit all serializable Temporal types as unquoted TOML literals. It touches 25 files across the AST (toml_datetime tag on EString), the TOML parser, the JS printer, the bundler's lazy-export lowering (lower_date_time_literals in parse_entry.rs), the runtime expr_to_js converter, and ~130 lines of new C++ in bindings.cpp (Bun__Temporal__fromDateTimeLiteral, Bun__Temporal__toTOMLDateTime, tomlOffsetForInstant). The net diff is ~+1540/-770, plus a regenerated 708-case conformance suite and extensive hand-written tests.
Security risks
None identified. The only untrusted-input surface is the TOML scanner, which already validated date/time syntax; the new truncate_fractional_seconds operates on that validated ASCII slice. The C++ toTOMLDateTime writes into a caller-provided stack buffer with a RELEASE_ASSERT(length <= bufLen) bound; the longest possible output is well under 64 bytes. No auth, crypto, or filesystem paths are involved.
Level of scrutiny
High. This is a user-facing behavioral change to a documented API — code that previously received strings from TOML.parse will now receive Temporal objects. The PR encodes several API design decisions a maintainer should ratify: offset date-time → Instant (offset normalizes away) rather than preserving the written offset; ZonedDateTime and non-ISO calendars silently drop annotations on stringify; the --no-bundle path deliberately fails at runtime with a TypeError if a top-level TOML key is literally Temporal (the earlier string-mangling approach was removed in c45d52f after three review rounds). It also adds new C++ JSC bindings that use uncheckedDowncast after a Rust-side type discrimination — correct as written, but the kind of code REVIEW.md flags for careful review.
Other factors
The PR has been through many review iterations: eight prior inline findings from this system (all resolved with code changes — iterative worklist instead of recursion, .ok_or(ParserError)? on all 14 new_lazy_export_ast sites, concurrent stderr draining in tests), plus CodeRabbit and comment-cop passes. dylan-conway has been actively pushing fixes and resolving threads, so a human is already engaged, but no independent approval is on the thread. Test coverage is thorough (conformance suite round-trips, boundary years, sub-minute LMT offsets, GC stress, useTemporal=0 on both parse and import paths, bundler symbol-renaming tests). CI is green on all lanes that ran. Given the API-design surface and the C++ bindings, deferring rather than auto-approving.
TOML.parse returned all four TOML date/time types as strings of their source text. They now map 1:1 and losslessly onto Temporal, which is enabled by default on main:
1979-05-27T00:32:00-07:00)Temporal.Instant1979-05-27T07:32:00, 1.1's1979-05-27 07:32)Temporal.PlainDateTime1979-05-27)Temporal.PlainDate07:32:00,07:32)Temporal.PlainTimePer the spec an offset date-time "specifies an instant", so it is
Temporal.Instantand the written offset normalizes to UTC. Sub-second digits are preserved (Temporal carries nanoseconds; fractional seconds beyond 9 digits are truncated as the TOML spec directs, since Temporal rejects them). Leap-second:60clamps to:59through Temporal's own ISO parsing.How
E::Stringit produces with the date/time kind (toml_datetimeonEString); the lexer already distinguished the four kinds. The TOML AST never enters the JS visit/transform passes, so only the TOML sinks check the tag.Bun.TOML.parseandimport/requireof.tomlconstruct the object through the same JSC code pathsTemporal.*.from(string)uses (new bindings inbindings.cpp).Temporal.*.from("...")call over a real unboundTemporalsymbol, so the chunk renamer renames a user binding namedTemporalinstead of letting it capture the reference, and the calls are pure-annotated so unused exports tree-shake.bun build --no-bundleof a.tomlfile (which prints the data AST directly, without a symbol table) prints the tagged string as a bareTemporal.*.from("...")call. That path turns each top-level key into a module-scopevar, so a document with both a date/time and a top-level key literally namedTemporalfails at evaluation with aTypeError; renaming without a symbol table was tried and dropped as open-ended.TOML.stringifyemitsTemporal.Instant/PlainDateTime/PlainDate/PlainTimeas unquoted TOML literals, sostringify(parse(doc))round-trips date/times.Temporal.ZonedDateTimeis also accepted and emits the offset at that instant, dropping the[Time/Zone]annotation (TOML has no zone syntax); non-ISO[u-ca=...]calendar annotations are dropped the same way (the stored ISO fields are emitted).PlainYearMonth/PlainMonthDay/Durationhave no TOML form and throw. Values TOML's 4-digit years cannot spell throw likeDatealready did, with one refinement for instants: an offset date-time at a year edge (0000-01-01T00:00:00+01:00is valid TOML but its UTC year is -1) is emitted with the nearest offset whose local year fits, so everythingparseaccepts also stringifies; only instants a day or more outside 0000..9999 throw.Dateoutput now trims trailing fraction zeros (Zrather than.000Z) soDateandInstantspell the same instant identically; otherwiseDatehandling is unchanged.BUN_JSC_useTemporal=0, a date/time value throws aTypeError("Date/time values require Temporal, which is disabled in this process"); the module-import path previously would have panicked on any conversion error and now fails the load with the pending exception.Everything else about parse/stringify (error messages, layout, numbers) is byte-for-byte unchanged.
Tests
toml-test-suite.test.ts(708 cases) from the same pinned toml-test commit: datetime expectations construct the Temporal value and compare with plaintoEqual(deepEquals learned Temporal objects in Compare Temporal objects by value in Bun.deepEquals and toEqual #37024). All pass, including theparse(stringify(parse(input)))lap each valid case asserts.toml.test.ts: the four mappings, instant semantics, TOML spellings Temporal does not print (space separator, lowercaset/z, omitted seconds, leap second), fraction truncation, nesting, bothuseTemporal=0behaviors, stringify of all eight Temporal types, year bounds, calendar/zone annotation dropping, array layout.test/js/bun/resolve/toml) extended with a[dates]table; bundler tests assert the emittedTemporal.*.frommodules run and that a uservar Temporalin the same bundle is renamed instead of captured.Types and docs updated (
bun.d.tsJSDoc,docs/runtime/toml.mdx).[review] gate passed · iteration 3 · 25 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 7 passed · 0 rejected · iteration 3
evidence per changed file