TOML: map date/time values to Temporal, round-trip them through stringify

Merged
RO
3mo ago
+1494-757

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:

TOMLJS
offset date-time (1979-05-27T00:32:00-07:00)Temporal.Instant
local date-time (1979-05-27T07:32:00, 1.1's 1979-05-27 07:32)Temporal.PlainDateTime
local date (1979-05-27)Temporal.PlainDate
local time (07:32:00, 07:32)Temporal.PlainTime

Per the spec an offset date-time "specifies an instant", so it is Temporal.Instant and 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 :60 clamps to :59 through Temporal's own ISO parsing.

const doc = Bun.TOML.parse("d = 1979-05-27T00:32:00-07:00");
doc.d instanceof Temporal.Instant; // true
doc.d.toString(); // "1979-05-27T07:32:00Z"
Bun.TOML.stringify(doc); // "d = 1979-05-27T07:32:00Z\n" (unquoted date-time literal)

How

  • The TOML parser tags the E::String it produces with the date/time kind (toml_datetime on EString); 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.
  • All three sinks of the TOML AST stay consistent:
    • Bun.TOML.parse and import/require of .toml construct the object through the same JSC code paths Temporal.*.from(string) uses (new bindings in bindings.cpp).
    • The bundler lowers the node to a Temporal.*.from("...") call over a real unbound Temporal symbol, so the chunk renamer renames a user binding named Temporal instead of letting it capture the reference, and the calls are pure-annotated so unused exports tree-shake.
    • bun build --no-bundle of a .toml file (which prints the data AST directly, without a symbol table) prints the tagged string as a bare Temporal.*.from("...") call. That path turns each top-level key into a module-scope var, so a document with both a date/time and a top-level key literally named Temporal fails at evaluation with a TypeError; renaming without a symbol table was tried and dropped as open-ended.
  • TOML.stringify emits Temporal.Instant/PlainDateTime/PlainDate/PlainTime as unquoted TOML literals, so stringify(parse(doc)) round-trips date/times. Temporal.ZonedDateTime is 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/Duration have no TOML form and throw. Values TOML's 4-digit years cannot spell throw like Date already did, with one refinement for instants: an offset date-time at a year edge (0000-01-01T00:00:00+01:00 is valid TOML but its UTC year is -1) is emitted with the nearest offset whose local year fits, so everything parse accepts also stringifies; only instants a day or more outside 0000..9999 throw. Date output now trims trailing fraction zeros (Z rather than .000Z) so Date and Instant spell the same instant identically; otherwise Date handling is unchanged.
  • With BUN_JSC_useTemporal=0, a date/time value throws a TypeError ("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.
  • bunfig.toml uses no date values, so config parsing is unaffected.

Everything else about parse/stringify (error messages, layout, numbers) is byte-for-byte unchanged.

Tests

  • Regenerated toml-test-suite.test.ts (708 cases) from the same pinned toml-test commit: datetime expectations construct the Temporal value and compare with plain toEqual (deepEquals learned Temporal objects in #37024). All pass, including the parse(stringify(parse(input))) lap each valid case asserts.
  • Hand-written coverage in toml.test.ts: the four mappings, instant semantics, TOML spellings Temporal does not print (space separator, lowercase t/z, omitted seconds, leap second), fraction truncation, nesting, both useTemporal=0 behaviors, stringify of all eight Temporal types, year bounds, calendar/zone annotation dropping, array layout.
  • Import fixtures (test/js/bun/resolve/toml) extended with a [dates] table; bundler tests assert the emitted Temporal.*.from modules run and that a user var Temporal in the same bundle is renamed instead of captured.

Types and docs updated (bun.d.ts JSDoc, docs/runtime/toml.mdx).


[review] gate passed · iteration 3 · 25 files touched

fails on main (without fix)
ASAN without fix: 53 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_loader.test.ts test/js/bun/resolve/toml/toml.test.js test/js/bun/toml/toml-test-suite.test.ts test/js/bun/toml/toml.test.ts
bun test v1.4.0 (48b6af953)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [1013.43ms]
(pass) bundler > bun loader > bun/loader-text-file [412.02ms]
(pass) bundler > bun loader > bun/loader-json-file [489.14ms]
(pass) bundler > bun loader > bun/loader-toml-file [429.12ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [413.44ms]
runtime failed file: /tmp/bun-build-tests/bun-avOXit/bun/loader-toml-datetime-imported-temporal-binding/out.js
stdout output:
polyfill false 1979-05-27
---
expected stdout:
polyfill true 1979-05-27
---
1843 |               console.log(`---`);
1844 |               console.log(`expected ${name}:`);
1845 |               console.log(expected);
1846 |               console.log(`---`);
1847 |             }
1848 |             expect(result).toBe(expected);
                                  ^
error: exp
... (truncated)

release without fix: 53 FAILED
bun test v1.4.0-canary.1 (b7a043103)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [31.84ms]
(pass) bundler > bun loader > bun/loader-text-file [14.09ms]
(pass) bundler > bun loader > bun/loader-json-file [18.21ms]
(pass) bundler > bun loader > bun/loader-toml-file [13.89ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [22.56ms]
runtime failed file: /tmp/bun-build-tests/bun-Rboeq6/bun/loader-toml-datetime-imported-temporal-binding/out.js
stdout output:
polyfill false 1979-05-27
---
expected stdout:
polyfill true 1979-05-27
---
1843 |               console.log(`---`);
1844 |               console.log(`expected ${name}:`);
1845 |               console.log(expected);
1846 |               console.log(`---`);
1847 |             }
1848 |             expect(result).toBe(expected);
                                  ^
error: expect(received).toBe(expected)

Expected: "polyfill true 1979-05-27"
Received: "polyfill false 1979-05-27"

      at <anonymous> (/workspace/bun/test/bundler/expectBundled.ts:1848:28)
(fail) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [18.93ms]
93
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_loader.test.ts test/js/bun/resolve/toml/toml.test.js test/js/bun/toml/toml-test-suite.test.ts test/js/bun/toml/toml.test.ts
bun test v1.4.0 (48b6af953)

test/bundler/bundler_loader.test.ts:
(pass) bundler > bun loader > bun/loader-yaml-file [1011.62ms]
(pass) bundler > bun loader > bun/loader-text-file [508.58ms]
(pass) bundler > bun loader > bun/loader-json-file [360.53ms]
(pass) bundler > bun loader > bun/loader-toml-file [381.91ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-shadowed-temporal-global [387.83ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-imported-temporal-binding [377.21ms]
(pass) bundler > bun loader > bun/loader-toml-datetime-no-bundle [464.49ms]
(pass) bundler > bun loader > bun/loader-toml-datetime [433.16ms]
(pass) bundler > bun loader > bun/loader-text-file [398.02ms]
(pass) bundler > bun loader > bun/loader-xml-file [432.77ms]
(pass) bundler > node loader > bun/loader-yaml-file [427.98ms]
(pass) bundler > node loader > bun/loader-text-file [385.65ms]
(pass) bundler > 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 652ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[2/23] gen cpp.rs (cppbind)
[3/23] gen JS modules (bundle-modules)
Preprocess modules (8539ms)
Bundle modules (64ms)
Postprocesss modules (324ms)
Bundle Functions (858ms)
Generate Code (29ms)

[9.83s] Bundled "src/js" for production
  2632 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

   Compiling bun_ast v0.0.0 (/workspace/bun/src/ast)
   Compiling bun_install_types v0.0.0 (/workspace/bun/src/install_types)
   Compiling bun_parsers v0.0.0 (/workspace/bun/src/parsers)
   Compiling bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
   Compiling bun_css v0.0.0 (/workspace/bun/src/
... (truncated)
diff hotspot
docs/runtime/toml.mdx                          |  38 +-
 packages/bun-types/bun.d.ts                    |  20 +-
 src/ast/e.rs                                   |  45 ++
 src/ast/expr.rs                                |   1 +
 src/bundler/ParseTask.rs                       |  30 +-
 src/bundler/bundle_v2.rs                       |   2 +-
 src/bundler/transpiler.rs                      |   1 +
 src/codegen/cppbind.ts                         |   1 +
 src/js_parser/parse/parse_entry.rs             | 117 +++
 src/js_parser_jsc/expr_jsc.rs                  |  26 +-
 src/js_parser_jsc/lib.rs                       |   3 +-
 src/js_printer/lib.rs                          |  21 +
 src/jsc/JSValue.rs                             |  19 +
 src/jsc/bindings/bindings.cpp                  | 155 ++++
 src/jsc/lib.rs                                 |   1 +
 src/parsers/toml.rs                            | 129 ++--
 src/runtime/api.rs                             |  63 +-
 src/runtime/api/TOMLObject.rs                  | 150 +++-
 test/bundler/bundler_loader.test.ts            |  67 ++
 test/js/bun/resolve/toml/toml-fixture.toml     |   6 +
 test/js/bun/resolve/toml/toml-fixture.toml.txt |   6 +
 test/js/bun/resolve/toml/toml.test.js          |   9 +
 test/js/bun/toml/generate_toml_test_suite.ts   |  93 +--
 test/js/bun/toml/toml-test-suite.test.ts       | 951 ++++++++++++-------------
 test/js/bun/toml/toml.test.ts                  | 297 ++++++--
 25 files changed, 1494 insertions(+), 757 deletions(-)

gate history · 7 passed · 0 rejected · iteration 3

evidence per changed file
file                                reads  edits  tests
docs/runtime/toml.mdx                   1      1      0
packages/bun-types/bun.d.ts             1      1      0
src/ast/e.rs                            6      8      0
src/ast/expr.rs                         5      6      0
src/bundler/ParseTask.rs                2      1      0
src/bundler/bundle_v2.rs                1      1      0
src/bundler/transpiler.rs               5      7      0
src/codegen/cppbind.ts                  0      0      0
src/js_parser/parse/parse_entry.rs      4     10      0
src/js_parser_jsc/expr_jsc.rs           4      7      0
src/js_parser_jsc/lib.rs                1      2      0
src/js_printer/lib.rs                   3      7      0
src/jsc/JSValue.rs                      2      4      0
src/jsc/bindings/bindings.cpp           7     11      0
src/jsc/lib.rs                          0      0      0
src/parsers/toml.rs                     5      7      0
(+ 9 more files)