Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c820de1
TOML: parse date/time values as Temporal objects, stringify them back…
robobun Aug 6, 2026
da85b78
Tag EString with the TOML date/time kind instead of adding an AST node
robobun Aug 6, 2026
aafc927
Tighten code comments
robobun Aug 6, 2026
9ac6b36
Route the transform-path Temporal reference through globalThis
robobun Aug 6, 2026
4a6ffe8
Share the Temporal literal FFI dispatch, cover the sub-minute offset …
robobun Aug 6, 2026
e52bf10
Keep EString's doc comment attached to the struct
robobun Aug 6, 2026
258b0c6
Address review: typed discriminants, classify once, Date precision pa…
robobun Aug 6, 2026
e966563
Mangle a globalThis TOML key's var on the transform path
robobun Aug 6, 2026
4af3b09
Share one Expr to JSValue converter between TOML.parse and imports
robobun Aug 6, 2026
b0f49a0
Walk the TOML AST iteratively; fail lazy-export parse errors gracefully
robobun Aug 6, 2026
27274c7
Use an absolute entry path and drain stderr in the deep-header test
robobun Aug 6, 2026
5be6ab7
Merge main to pick up Temporal-aware deepEquals (#37024)
robobun Aug 6, 2026
7978392
Compare conformance-suite date/times with plain toEqual
robobun Aug 6, 2026
627f3e3
Convert the remaining lazy-export unwraps; gate the datetime walk to …
robobun Aug 6, 2026
b7a674d
Merge remote-tracking branch 'origin/main' into farm/76e35552/toml-te…
dylan-conway Aug 12, 2026
ca941ea
Gate the datetime lowering walk to TOML lazy exports; downcast pre-cl…
dylan-conway Aug 13, 2026
25187c8
TOML.stringify: spell year-edge instants with an in-range offset inst…
dylan-conway Aug 13, 2026
1dd8b4f
Address review: single out-of-range sentinel from toTOMLDateTime, tig…
dylan-conway Aug 13, 2026
c45d52f
Drop the --no-bundle globalThis key mangling; convert the last lazy-e…
dylan-conway Aug 13, 2026
902aef3
Print TOML date/times on the --no-bundle path as bare Temporal.*.from…
dylan-conway Aug 13, 2026
0a4b019
Classify Temporal values through JSC::temporalType and a shared bun_j…
dylan-conway Aug 13, 2026
a3168a5
Merge remote-tracking branch 'origin/main' into farm/76e35552/toml-te…
dylan-conway Aug 14, 2026
48b6af9
Merge remote-tracking branch 'origin/main' into farm/76e35552/toml-te…
dylan-conway Aug 14, 2026
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
37 changes: 31 additions & 6 deletions docs/runtime/toml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Bun's TOML parser implements the full [TOML v1.1.0 specification](https://github
- **Integers**: decimal, hex (`0x`), octal (`0o`), and binary (`0b`). Integers that cannot be represented losslessly as a JavaScript number — outside ±(2^53 - 1) — throw
- **Floats**: including `inf` and `nan`
- **Booleans**: `true` and `false`
- **Date/times**: offset date-time, local date-time, local date, and local time, returned as strings of their source text
- **Date/times**: returned as [Temporal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal) objects — offset date-time as `Temporal.Instant`, local date-time as `Temporal.PlainDateTime`, local date as `Temporal.PlainDate`, and local time as `Temporal.PlainTime`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **Arrays**: including mixed types and nested arrays
- **Tables**: standard (`[table]`) and inline (`{ key = "value" }`), including TOML 1.1 multi-line inline tables
- **Array of tables**: `[[array]]`
Expand Down Expand Up @@ -81,6 +81,25 @@ role = "backend"
`);
```

#### Date/times

Each of TOML's four date/time types maps 1:1 onto a Temporal type, losslessly (Temporal carries nanosecond precision):
Comment thread
dylan-conway marked this conversation as resolved.
Outdated

```ts
const doc = Bun.TOML.parse(`
created = 1979-05-27T00:32:00-07:00 # offset date-time
meeting = 1979-05-27T07:32:00 # local date-time
birthday = 1979-05-27 # local date
opens = 07:32:00 # local time
`);

doc.created; // Temporal.Instant (an offset date-time specifies an instant;
// the written offset normalizes away: 1979-05-27T07:32:00Z)
doc.meeting; // Temporal.PlainDateTime
doc.birthday; // Temporal.PlainDate
doc.opens; // Temporal.PlainTime
```

#### Error Handling

`Bun.TOML.parse()` throws a `SyntaxError` if the TOML is invalid:
Expand Down Expand Up @@ -118,11 +137,17 @@ Bun.TOML.stringify({
// x = 2
```

The top-level value must be an object — a TOML document is a table. `Date`
values become TOML offset date-times. Because TOML cannot represent them,
`null` values, `BigInt`, and circular structures throw; `undefined`,
function, and symbol properties are skipped (inside arrays they throw,
since TOML arrays cannot have holes).
The top-level value must be an object — a TOML document is a table.
`Temporal.Instant`, `Temporal.PlainDateTime`, `Temporal.PlainDate`, and
`Temporal.PlainTime` values become the corresponding TOML date/time
literals, so `stringify(parse(doc))` round-trips date/time types.
`Temporal.ZonedDateTime` becomes an offset date-time (its time-zone
annotation has no TOML form and is dropped), and `Date` becomes an offset
date-time. Because TOML cannot represent them, `null` values, `BigInt`,
Comment thread
dylan-conway marked this conversation as resolved.
Outdated
circular structures, `Temporal.PlainYearMonth`, `Temporal.PlainMonthDay`,
and `Temporal.Duration` throw; `undefined`, function, and symbol
properties are skipped (inside arrays they throw, since TOML arrays
cannot have holes).
Comment thread
dylan-conway marked this conversation as resolved.
Outdated

---

Expand Down
23 changes: 16 additions & 7 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,9 +780,12 @@ declare module "bun" {
/**
* Parse a TOML (v1.1.0) document into a JavaScript object.
*
* Date/time values parse as strings of their source text. Integers
* outside `Number.MAX_SAFE_INTEGER` throw, since they cannot be
* represented losslessly as JavaScript numbers.
* Date/time values parse as Temporal objects: offset date-times as
* `Temporal.Instant`, local date-times as `Temporal.PlainDateTime`,
* local dates as `Temporal.PlainDate`, and local times as
* `Temporal.PlainTime`. Integers outside `Number.MAX_SAFE_INTEGER`
* throw, since they cannot be represented losslessly as JavaScript
* numbers.
*
* @category Utilities
*
Expand All @@ -798,10 +801,16 @@ declare module "bun" {
* Serialize a JavaScript object to a TOML document.
*
* The top-level value must be an object (a TOML document is a table).
* `Date` values become TOML offset date-times. `null`, `BigInt`, and
* circular structures throw, since TOML cannot represent them;
* `undefined`, function, and symbol properties are skipped (inside
* arrays they throw, since TOML arrays cannot have holes).
* `Temporal.Instant`, `Temporal.PlainDateTime`, `Temporal.PlainDate`,
* and `Temporal.PlainTime` values become the corresponding TOML
* date/time literals, `Temporal.ZonedDateTime` becomes an offset
* date-time (dropping its time-zone annotation), and `Date` becomes an
* offset date-time. `null`, `BigInt`, circular structures, and Temporal
* types with no TOML form (`Temporal.PlainYearMonth`,
* `Temporal.PlainMonthDay`, `Temporal.Duration`) throw, since TOML
* cannot represent them; `undefined`, function, and symbol properties
* are skipped (inside arrays they throw, since TOML arrays cannot have
* holes).
*
* @category Utilities
*
Expand Down
49 changes: 49 additions & 0 deletions src/ast/e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,35 @@ pub struct Spread {
// `data` (the only field needing a static relocation) at offset 0; `align(8)`
// keeps the struct itself 8-aligned. `EString` is arena-stored (never inline
// in `Expr`), so this does not affect `Expr` size.
/// Which of the four TOML date/time kinds a tagged `EString` literal is, and
/// the Temporal class it materializes as. Discriminants cross the FFI
/// boundary (`Bun__Temporal__fromDateTimeLiteral`) — keep them in sync with
/// the C++ switch.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[repr(u8)]
pub enum TomlDateTimeKind {
/// `1979-05-27T00:32:00-07:00` → `Temporal.Instant`
OffsetDateTime = 1,
/// `1979-05-27T07:32:00` → `Temporal.PlainDateTime`
LocalDateTime = 2,
/// `1979-05-27` → `Temporal.PlainDate`
LocalDate = 3,
/// `07:32:00` → `Temporal.PlainTime`
LocalTime = 4,
}

impl TomlDateTimeKind {
/// Unqualified Temporal class name (`Instant`, `PlainDateTime`, …).
pub fn temporal_class(self) -> &'static [u8] {
match self {
TomlDateTimeKind::OffsetDateTime => b"Instant",
TomlDateTimeKind::LocalDateTime => b"PlainDateTime",
TomlDateTimeKind::LocalDate => b"PlainDate",
TomlDateTimeKind::LocalTime => b"PlainTime",
}
}
}

#[repr(C, align(8))]
Comment thread
robobun marked this conversation as resolved.
pub struct EString {
// A version of this where `utf8` and `value` are stored in a packed union, with len as a single u32 was attempted.
Expand All @@ -1600,6 +1629,12 @@ pub struct EString {
pub rope_len: u32,
pub prefer_template: bool,
pub is_utf16: bool,
/// Set only by the TOML parser, on a date/time literal whose `data` is
/// the (ASCII, pre-validated) source text. The TOML AST never enters the
/// JS visit/transform passes; the sinks that materialize or print it
/// (`expr_to_js`, `data_to_js`, `to_lazy_export_ast`, the printer) check
/// this tag and produce a Temporal value instead of a string.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub toml_datetime: Option<TomlDateTimeKind>,
}
// Also exported as `String`; `EString` avoids colliding with bun_core::String.
pub use EString as String;
Expand All @@ -1613,6 +1648,7 @@ impl Default for EString {
end: None,
rope_len: 0,
is_utf16: false,
toml_datetime: None,
}
}
}
Expand Down Expand Up @@ -1660,6 +1696,7 @@ impl EString {
end: None,
rope_len: 0,
is_utf16: false,
toml_datetime: None,
}
}
/// `data` is arena-owned (source text or `Expr.Data.Store` / bump arena)
Expand All @@ -1670,6 +1707,17 @@ impl EString {
..Default::default()
}
}

/// A TOML date/time literal: `data` is its ASCII source text (fractional
/// seconds pre-truncated to the 9 digits Temporal carries), accepted
/// verbatim by `Temporal.*.from`.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn init_toml_datetime(data: &[u8], kind: TomlDateTimeKind) -> Self {
Self {
data: Str::new(data),
toml_datetime: Some(kind),
..Default::default()
}
}
/// Construct from a UTF-16 slice (arena-owned). The `data` slice's `.len()`
/// stores the **u16 element count** (not byte count); `slice16()` and
/// friends rely on this. The pointer is reinterpreted to `*const u8` for
Expand Down Expand Up @@ -1896,6 +1944,7 @@ impl EString {
end: self.end,
rope_len: self.rope_len,
is_utf16: self.is_utf16,
toml_datetime: self.toml_datetime,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/ast/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2153,6 +2153,7 @@ impl Data {
end: el.end,
rope_len: el.rope_len,
is_utf16: el.is_utf16,
toml_datetime: el.toml_datetime,
});
Ok(Data::EString(StoreRef::from_bump(item)))
}
Expand Down
89 changes: 89 additions & 0 deletions src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,14 @@ impl<'a> Parser<'a> {

let mut final_expr = expr;

// Date/time literals (produced by the TOML parser) materialize as
// `Temporal.*.from("...")` calls. Bundled modules share one scope, so
// the reference must be a real unbound `Temporal` symbol: the chunk
// renamer then reserves the name and renames a user binding called
// `Temporal` instead of letting it capture these calls.
Comment thread
robobun marked this conversation as resolved.
Outdated
let mut temporal_ref: Option<js_ast::Ref> = None;
lower_date_time_literals(p, &mut final_expr, &mut temporal_ref)?;

// Optionally call a runtime API function to transform the expression
if !runtime_api_call.is_empty() {
let args_slice: &mut [Expr] = p.arena.alloc_slice_fill_with(1, |_| expr);
Expand Down Expand Up @@ -604,7 +612,88 @@ impl<'a> Parser<'a> {
b"",
)?))
}
}

/// Rewrites every `toml_datetime`-tagged `E::String` in `expr` (in place)
/// into the `Temporal.<Class>.from("<text>")` call it prints as, referencing
/// an unbound `Temporal` symbol declared on first use. The calls are
/// annotated as removable-if-unused: constructing a Temporal value from a
/// validated literal has no observable side effects, so tree shaking may
/// drop unused exports.
Comment thread
robobun marked this conversation as resolved.
Outdated
fn lower_date_time_literals<'a>(
p: &mut JavaScriptParser<'a>,
expr: &mut Expr,
temporal_ref: &mut Option<js_ast::Ref>,
) -> Result<(), Error> {
Comment thread
robobun marked this conversation as resolved.
match expr.data {
js_ast::ExprData::EString(str) if str.toml_datetime.is_some() => {
let ref_ = match *temporal_ref {
Some(ref_) => ref_,
None => {
let ref_ =
p.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"Temporal")?;
*temporal_ref = Some(ref_);
ref_
}
};
let (class, text) = {
let str = str.get();
let kind = str.toml_datetime.expect("infallible: guard checked");
(kind.temporal_class(), str.slice8())
};
let loc = expr.loc;
p.record_usage(ref_);
let namespace = p.new_expr(E::Identifier::init(ref_), loc);
let class_dot = p.new_expr(
E::Dot {
target: namespace,
name: E::Str::new(class),
name_loc: loc,
can_be_removed_if_unused: true,
..Default::default()
},
loc,
);
let from_dot = p.new_expr(
E::Dot {
target: class_dot,
name: E::Str::new(b"from"),
name_loc: loc,
can_be_removed_if_unused: true,
..Default::default()
},
loc,
);
let arg = p.new_expr(E::String::init(text), loc);
let args_slice: &mut [Expr] = p.arena.alloc_slice_fill_with(1, |_| arg);
*expr = p.new_expr(
E::Call {
target: from_dot,
args: Vec::from_arena_slice(args_slice),
can_be_unwrapped_if_unused: E::CallUnwrap::IfUnused,
..Default::default()
},
loc,
);
}
js_ast::ExprData::EArray(mut arr) => {
for item in arr.items.slice_mut() {
lower_date_time_literals(p, item, temporal_ref)?;
}
}
js_ast::ExprData::EObject(mut obj) => {
for property in obj.properties.slice_mut() {
if let Some(value) = &mut property.value {
lower_date_time_literals(p, value, temporal_ref)?;
}
}
}
_ => {}
}
Ok(())
}

impl<'a> Parser<'a> {
fn _parse<const TS: bool>(self) -> Result<crate::Result<'a>, Error> {
// `Source.path` is `Path<'static>`, so
// `path.text` satisfies `Action::Parse(&'static [u8])` directly.
Expand Down
18 changes: 17 additions & 1 deletion src/js_parser_jsc/expr_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,23 @@ fn data_to_js_with_check(
ExprData::EObject(e) => object_to_js(e, global, stack_check),
ExprData::EObjectJSON(e) => object_json_to_js(e, global, stack_check),
ExprData::EArrayJSON(e) => array_json_to_js(e, global, stack_check),
ExprData::EString(e) => string_to_js(e, global),
ExprData::EString(e) => {
if let Some(kind) = e.toml_datetime {
let text = e.slice8();
// SAFETY: `text` is an arena-owned ASCII slice that outlives
// the call.
return unsafe {
bun_jsc::cpp::Bun__Temporal__fromDateTimeLiteral(
global,
text.as_ptr(),
text.len(),
kind as u8,
)
}
.map_err(js_err);
}
string_to_js(e, global)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ExprData::ENull(_) => Ok(JSValue::NULL),
ExprData::EUndefined(_) => Ok(JSValue::UNDEFINED),
ExprData::EBoolean(boolean) | ExprData::EBranchBoolean(boolean) => Ok(if boolean.value {
Expand Down
23 changes: 23 additions & 0 deletions src/js_printer/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3683,6 +3683,29 @@ pub(crate) mod __gated_printer {
}
}
ExprData::EString(e) => {
// A TOML date/time literal has no JS literal form; it
// prints as the `Temporal.*.from` call that reconstructs
// the value. (The bundler path rewrites these in
// `to_lazy_export_ast` instead and never gets here.)
Comment thread
robobun marked this conversation as resolved.
Outdated
if let Some(kind) = e.toml_datetime {
let wrap = level.gte(Level::New) || flags.contains(ExprFlag::ForbidCall);
if wrap {
self.print(b"(");
}
self.print_space_before_identifier();
self.add_source_mapping(expr.loc);
self.print(b"Temporal.");
self.print(kind.temporal_class());
self.print(b".from(\"");
// Always ASCII (validated by the TOML scanner); no escaping.
self.print(e.slice8());
self.print(b"\")");
if wrap {
self.print(b")");
}
return;
}

let mut e = *e;
e.resolve_rope_if_needed(self.bump);
self.add_source_mapping(expr.loc);
Expand Down
Loading
Loading