diff --git a/src/parsers/toml.rs b/src/parsers/toml.rs index dc1731fac672..27fc992685a2 100644 --- a/src/parsers/toml.rs +++ b/src/parsers/toml.rs @@ -110,12 +110,6 @@ impl<'a> TOML<'a> { self.lexer.next()?; Ok(Some(self.e(E::String::init(b"true"), loc))) } - // what we see as a number here could actually be a string - T::t_numeric_literal => { - let literal = self.lexer.raw(); - self.lexer.next()?; - Ok(Some(self.e(E::String::init(literal), loc))) - } _ => Ok(None), } @@ -123,47 +117,85 @@ impl<'a> TOML<'a> { #[allow(clippy::mut_from_ref)] pub fn parse_key(&mut self, bump: &'a Bump) -> Result<&'a mut Rope, bun_core::Error> { - // Allocate from the caller-provided bump and return `&mut Rope` - // borrowed from it. - let rope: &mut Rope = bump.alloc(Rope { - head: match self.parse_key_segment()? { - Some(seg) => seg, - None => { - self.lexer.expected_string(b"key")?; - return Err(bun_core::err!("SyntaxError")); - } - }, - next: core::ptr::null_mut(), - }); - let head: *mut Rope = rope; - let mut rope: *mut Rope = rope; - // Hard cap on dotted-key segments. The rope is consumed by `set_rope`, // `get_or_put_array`, and `get_or_put_object`, each of which recurses // once per `rope.next` link with no stack guard of their own. const MAX_DOTTED_KEY_SEGMENTS: usize = 512; - let mut segments: usize = 1; - while self.lexer.token == T::t_dot { - self.lexer.next()?; + // Allocated from the caller-provided bump; the returned `&mut Rope` + // borrows from it. + let mut head: *mut Rope = core::ptr::null_mut(); + let mut tail: *mut Rope = core::ptr::null_mut(); + let mut segments: usize = 0; + // Whether the previous iteration consumed a standalone `.` token. A + // `.`-prefixed numeric literal after one is `a..5`, not `a.5`. + let mut after_explicit_dot = false; + + loop { + let loc = self.lexer.loc(); - let Some(seg) = self.parse_key_segment()? else { - break; + // A bare key cannot contain `.`, so a numeric-literal token in key + // position carries implicit dot separators: `3.14159 = "pi"` is the + // dotted key `3` . `14159`, and the `.1` continuing `a.1` is `.` + // followed by the segment `1` (the numeric lexer consumes any `.` + // that is immediately followed by a digit). + let numeric_literal: Option<&'a [u8]> = match self.lexer.token { + T::t_numeric_literal => Some(self.lexer.raw()), + _ => None, }; - segments += 1; + + if let Some(raw) = numeric_literal { + let mut parts = raw.split(|&c| c == b'.'); + // The leading `.` of a continuation is the separator itself, + // not an empty segment. An explicit `.` token before it + // (`a..5`) or a `.`-leading first token (`.5 = 1`) both leave + // the empty part in place so it is rejected below. + if !head.is_null() && !after_explicit_dot && raw.first() == Some(&b'.') { + parts.next(); + } + for part in parts { + if part.is_empty() { + self.lexer.expected_string(b"key")?; + return Err(bun_core::err!("SyntaxError")); + } + segments += 1; + rope_append( + &mut head, + &mut tail, + self.e(E::String::init(part), loc), + bump, + )?; + } + self.lexer.next()?; + } else if let Some(seg) = self.parse_key_segment()? { + segments += 1; + rope_append(&mut head, &mut tail, seg, bump)?; + } else { + self.lexer.expected_string(b"key")?; + return Err(bun_core::err!("SyntaxError")); + } + if segments > MAX_DOTTED_KEY_SEGMENTS { self.lexer .add_default_error(b"Dotted key has too many segments")?; return Err(bun_core::err!("SyntaxError")); } - // SAFETY: `rope` points into `bump` and is live for this call; we are - // the sole mutator. Raw pointers used to avoid stacked &mut reborrows. - unsafe { - rope = (*rope).append(seg, bump)?; + + after_explicit_dot = false; + match self.lexer.token { + T::t_dot => { + self.lexer.next()?; + after_explicit_dot = true; + } + // A `.`-prefixed numeric literal (the `.1` in `a.1`) continues + // the key; the implicit-separator handling above consumes it. + T::t_numeric_literal if self.lexer.raw().first() == Some(&b'.') => {} + _ => break, } } - // SAFETY: `head` was just allocated from `bump` above and is non-null. + // SAFETY: every path through the loop body either appends at least one + // segment (making `head` a live bump allocation) or returns `Err`. Ok(unsafe { &mut *head }) } @@ -462,3 +494,26 @@ impl<'a> TOML<'a> { } } } + +/// Append `seg` to the dotted-key rope rooted at `*head`, allocating the node +/// from `bump`. Initializes `*head`/`*tail` on the first call. +fn rope_append( + head: &mut *mut Rope, + tail: &mut *mut Rope, + seg: Expr, + bump: &Bump, +) -> Result<(), bun_core::Error> { + if (*head).is_null() { + let rope: *mut Rope = bump.alloc(Rope { + head: seg, + next: core::ptr::null_mut(), + }); + *head = rope; + *tail = rope; + return Ok(()); + } + // SAFETY: `*tail` points into `bump` and is live for this call; we are the + // sole mutator. Raw pointers used to avoid stacked &mut reborrows. + *tail = unsafe { (**tail).append(seg, bump)? }; + Ok(()) +} diff --git a/src/parsers/toml/lexer.rs b/src/parsers/toml/lexer.rs index cfe8c61a5cad..bd29a00fa636 100644 --- a/src/parsers/toml/lexer.rs +++ b/src/parsers/toml/lexer.rs @@ -150,6 +150,34 @@ impl<'a> Lexer<'a> { fn parse_numeric_literal_or_dot(&mut self) -> Result<(), Error> { // Number or dot; let first = self.code_point; + + // TOML date-times (`1979-05-27T07:32:00Z`) and local times (`07:32:00`) + // also begin with a digit; detect them before the numeric scan consumes + // the leading digits and leaves `-` / `:` behind as stray tokens. + // The AST has no date-time node, so the value surfaces as a string. + if ('0' as CodePoint..='9' as CodePoint).contains(&first) { + if let Some(len) = scan_date_time(&self.source.contents[self.start..]) { + self.token = T::t_string_literal; + self.string_literal_is_ascii = true; + self.string_literal_slice = &self.source.contents[self.start..self.start + len]; + // Re-sync the cursor to the byte just past the date-time. + self.current = self.start + len; + self.step(); + return Ok(()); + } + } + + // A `.` immediately followed by a complete date-time is the dotted-key + // separator of e.g. `a.2001-02-08 = 1` (toml-test valid/key/like-date), + // not the start of a fraction. + if first == '.' as CodePoint + && scan_date_time(&self.source.contents[self.start + 1..]).is_some() + { + self.step(); + self.token = T::t_dot; + return Ok(()); + } + self.step(); // Dot without a digit after it; @@ -168,7 +196,6 @@ impl<'a> Lexer<'a> { let mut is_legacy_octal_literal = false; - // Assume this is a number, but potentially change to a date/time later; self.token = T::t_numeric_literal; // Check for binary, octal, or hexadecimal literal; @@ -198,6 +225,9 @@ impl<'a> Lexer<'a> { if base != 0.0 { // Integer literal; + let radix = base as u64; + let mut int_value: u64 = 0; + let mut int_overflow = false; let mut is_first = true; let mut is_invalid_legacy_octal_literal = false; self.number = 0.0; @@ -206,6 +236,7 @@ impl<'a> Lexer<'a> { } 'integer_literal: loop { + let mut digit: Option = None; match self.code_point { c if c == '_' as CodePoint => { // Cannot have multiple underscores in a row; @@ -223,16 +254,14 @@ impl<'a> Lexer<'a> { } c if c == '0' as CodePoint || c == '1' as CodePoint => { - self.number = - self.number * base as f64 + float64(self.code_point - '0' as CodePoint); + digit = Some((c - '0' as CodePoint) as u64); } c if ('2' as CodePoint..='7' as CodePoint).contains(&c) => { if base == 2.0 { self.syntax_error()?; } - self.number = - self.number * base as f64 + float64(self.code_point - '0' as CodePoint); + digit = Some((c - '0' as CodePoint) as u64); } c if c == '8' as CodePoint || c == '9' as CodePoint => { if is_legacy_octal_literal { @@ -240,23 +269,20 @@ impl<'a> Lexer<'a> { } else if base < 10.0 { self.syntax_error()?; } - self.number = - self.number * base as f64 + float64(self.code_point - '0' as CodePoint); + digit = Some((c - '0' as CodePoint) as u64); } c if ('A' as CodePoint..='F' as CodePoint).contains(&c) => { if base != 16.0 { self.syntax_error()?; } - self.number = self.number * base as f64 - + float64(self.code_point + 10 - 'A' as CodePoint); + digit = Some((c + 10 - 'A' as CodePoint) as u64); } c if ('a' as CodePoint..='f' as CodePoint).contains(&c) => { if base != 16.0 { self.syntax_error()?; } - self.number = self.number * base as f64 - + float64(self.code_point + 10 - 'a' as CodePoint); + digit = Some((c + 10 - 'a' as CodePoint) as u64); } _ => { // The first digit must exist; @@ -268,6 +294,16 @@ impl<'a> Lexer<'a> { } } + if let Some(digit) = digit { + match int_value + .checked_mul(radix) + .and_then(|v| v.checked_add(digit)) + { + Some(v) => int_value = v, + None => int_overflow = true, + } + } + self.step(); is_first = false; } @@ -315,6 +351,9 @@ impl<'a> Lexer<'a> { } } } + } else { + // Prefixed literals cannot carry a sign, so the bound is i64::MAX. + self.number = self.check_exact_integer(int_value, int_overflow, i64::MAX as u64)?; } } else { // Floating-point literal; @@ -324,25 +363,6 @@ impl<'a> Lexer<'a> { // Initial digits; loop { if self.code_point < '0' as CodePoint || self.code_point > '9' as CodePoint { - match self.code_point { - // '-' => { - // if (lexer.raw().len == 5) { - // // Is this possibly a datetime literal that begins with a 4 digit year? - // lexer.step(); - // while (!lexer.has_newline_before) { - // switch (lexer.code_point) { - // ',' => { - // lexer.string_literal_slice = lexer.raw(); - // lexer.token = T.t_string_literal; - // break; - // }, - // } - // } - // } - // }, - c if c == '_' as CodePoint => {} - _ => break, - } if self.code_point != '_' as CodePoint { break; } @@ -446,13 +466,36 @@ impl<'a> Lexer<'a> { text = bytes; } - if !has_dot_or_exponent && self.end - self.start < 10 { - // Parse a 32-bit integer (very fast path); - let mut number: u32 = 0; - for &c in text { - number = number * 10 + u32::from(c - b'0'); + if !has_dot_or_exponent { + // Decimal integer. `text` is all `0-9` here: the initial-digits + // loop only accepts digits and `_`, and `_` is filtered above. + if text.len() < 16 { + // Fewer than 16 decimal digits is < 10^15 < 2^53: always + // exactly representable (very fast path); + let mut number: u64 = 0; + for &c in text { + number = number * 10 + u64::from(c - b'0'); + } + self.number = number as f64; + } else { + let mut int_value: u64 = 0; + let mut int_overflow = false; + for &c in text { + match int_value + .checked_mul(10) + .and_then(|v| v.checked_add(u64::from(c - b'0'))) + { + Some(v) => int_value = v, + None => { + int_overflow = true; + break; + } + } + } + // The sign is a separate token, so the magnitude of + // i64::MIN (2^63) must lex; `parse_value` applies the minus. + self.number = self.check_exact_integer(int_value, int_overflow, 1u64 << 63)?; } - self.number = number as f64; } else { // Parse a double-precision floating-point number; match bun_core::wtf::parse_double(text) { @@ -469,6 +512,40 @@ impl<'a> Lexer<'a> { Ok(()) } + /// TOML integers are 64-bit and must round-trip exactly through the + /// IEEE-754 double that backs a JavaScript number: "If an integer cannot + /// be represented losslessly, an error must be thrown" (TOML 1.0 + /// §Integer). Silently rounding `9223372036854775807` to + /// `9223372036854776000` corrupts the document. + fn check_exact_integer( + &mut self, + value: u64, + overflowed: bool, + max: u64, + ) -> Result { + let raw = bstr::BStr::new(self.raw()); + if overflowed || value > max { + self.add_syntax_error( + self.start, + format_args!( + "Integer \"{}\" is outside the 64-bit range allowed by TOML", + raw + ), + )?; + } + let as_float = value as f64; + if as_float as u64 != value { + self.add_syntax_error( + self.start, + format_args!( + "Integer \"{}\" cannot be represented exactly as a JavaScript number; quote it to load it as a string", + raw + ), + )?; + } + Ok(as_float) + } + #[inline] pub fn expect(&mut self, token: T) -> Result<(), Error> { if self.token != token { @@ -639,13 +716,16 @@ impl<'a> Lexer<'a> { } if is_multiline_string_literal { + // A newline immediately following the opening ''' is trimmed. + let content_start = + multiline_content_start(&self.source.contents, start + 2); loop { match self.code_point { -1 => { self.add_default_error(b"Unterminated string literal")?; } c if c == '\'' as CodePoint => { - let end = self.end; + let mut end = self.end; self.step(); if self.code_point != '\'' as CodePoint { continue; @@ -655,9 +735,17 @@ impl<'a> Lexer<'a> { continue; } self.step(); + // Up to two extra quotes next to the closing + // delimiter belong to the content (`''''x''''`). + let mut extra: usize = 0; + while extra < 2 && self.code_point == '\'' as CodePoint { + end += 1; + extra += 1; + self.step(); + } self.token = T::t_string_literal; self.string_literal_slice = - &self.source.contents[start + 2..end]; + &self.source.contents[content_start..end]; return Ok(()); } _ => {} @@ -721,6 +809,9 @@ impl<'a> Lexer<'a> { let slice_lo: usize; let slice_hi: usize; if is_multiline_string_literal { + // A newline immediately following the opening """ is trimmed. + let content_start = + multiline_content_start(&self.source.contents, start + 2); loop { match self.code_point { -1 => { @@ -735,7 +826,7 @@ impl<'a> Lexer<'a> { } } c if c == '"' as CodePoint => { - let end = self.end; + let mut end = self.end; self.step(); if self.code_point != '"' as CodePoint { continue; @@ -745,15 +836,23 @@ impl<'a> Lexer<'a> { continue; } self.step(); + // Up to two extra quotes next to the closing + // delimiter belong to the content (`""""x""""`). + let mut extra: usize = 0; + while extra < 2 && self.code_point == '"' as CodePoint { + end += 1; + extra += 1; + self.step(); + } self.token = T::t_string_literal; if needs_slow_pass { - slice_lo = start + 2; + slice_lo = content_start; slice_hi = end; break; } self.string_literal_slice = - &self.source.contents[start + 2..end]; + &self.source.contents[content_start..end]; return Ok(()); } _ => {} @@ -885,7 +984,7 @@ impl<'a> Lexer<'a> { let width2 = iter.width; match c2 { - // https://mathiasbynens.be/notes/javascript-escapes#single + // TOML §String: \b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX c if c == 'b' as CodePoint => { buf.push(8); continue; @@ -899,12 +998,6 @@ impl<'a> Lexer<'a> { buf.push(10); continue; } - c if c == 'v' as CodePoint => { - // Vertical tab is invalid JSON - // We're going to allow it. - buf.push(11); - continue; - } c if c == 't' as CodePoint => { // Horizontal tab: U+0009 buf.push(9); @@ -914,81 +1007,10 @@ impl<'a> Lexer<'a> { buf.push(13); continue; } - - // legacy octal literals - c if ('0' as CodePoint..='7' as CodePoint).contains(&c) => { - let octal_start = (iter.i as usize + width2 as usize).saturating_sub(2); - - // 1-3 digit octal - let mut is_bad = false; - let mut value: i64 = (c2 - '0' as CodePoint) as i64; - let mut restore = iter; - - if !iterator.next(&mut iter) { - if value == 0 { - buf.push(0); - return Ok(()); - } - - self.syntax_error()?; - return Ok(()); - } - - let c3: CodePoint = iter.c; - - match c3 { - c if ('0' as CodePoint..='7' as CodePoint).contains(&c) => { - value = value * 8 + (c3 - '0' as CodePoint) as i64; - restore = iter; - if !iterator.next(&mut iter) { - return self.syntax_error(); - } - - let c4 = iter.c; - match c4 { - c if ('0' as CodePoint..='7' as CodePoint).contains(&c) => { - let temp = value * 8 + (c4 - '0' as CodePoint) as i64; - if temp < 256 { - value = temp; - } else { - iter = restore; - } - } - c if c == '8' as CodePoint || c == '9' as CodePoint => { - is_bad = true; - } - _ => { - iter = restore; - } - } - } - c if c == '8' as CodePoint || c == '9' as CodePoint => { - is_bad = true; - } - _ => { - iter = restore; - } - } - - iter.c = i32::try_from(value).expect("int cast"); - if is_bad { - self.add_range_error( - bun_ast::Range { - loc: bun_ast::Loc { - start: i32::try_from(octal_start).expect("int cast"), - }, - len: i32::try_from(iter.i as usize - octal_start) - .expect("int cast"), - }, - format_args!("Invalid legacy octal literal"), - ) - .expect("unreachable"); - } - } - c if c == '8' as CodePoint || c == '9' as CodePoint => { + c if c == '"' as CodePoint || c == '\\' as CodePoint => { iter.c = c2; } - // 2-digit hexadecimal + // 2-digit hexadecimal (not TOML 1.0; kept for compatibility) c if c == 'x' as CodePoint => { if ALLOW_MULTILINE { self.end = @@ -1030,7 +1052,9 @@ impl<'a> Lexer<'a> { iter.c = value; } - c if c == 'u' as CodePoint => { + // Unicode escapes: `\uXXXX` (4 hex digits) and + // `\UXXXXXXXX` (8 hex digits). + c if c == 'u' as CodePoint || c == 'U' as CodePoint => { // We're going to make this an i64 so we don't risk integer overflows // when people do weird things let mut value: i64 = 0; @@ -1041,8 +1065,8 @@ impl<'a> Lexer<'a> { let mut c3 = iter.c; let mut width3 = iter.width; - // variable-length - if c3 == '{' as CodePoint { + // `\u{…}` variable-length (not TOML; kept for compatibility) + if c2 == 'u' as CodePoint && c3 == '{' as CodePoint { let hex_start = (iter.i as usize) .saturating_sub(width as usize) .saturating_sub(width2 as usize) @@ -1096,12 +1120,11 @@ impl<'a> Lexer<'a> { )?; return Ok(()); } - - // fixed-length } else { // Fixed-length + let n_digits: usize = if c2 == 'U' as CodePoint { 8 } else { 4 }; let mut j: usize = 0; - while j < 4 { + while j < n_digits { match hex_digit_value_u32(c3 as u32) { Some(d) => value = (value * 16) | d as i64, None => { @@ -1111,7 +1134,7 @@ impl<'a> Lexer<'a> { } } - if j < 3 { + if j < n_digits - 1 { if !iterator.next(&mut iter) { return self.syntax_error(); } @@ -1121,6 +1144,20 @@ impl<'a> Lexer<'a> { } j += 1; } + + // TOML §String: the escape must name a Unicode + // scalar value. Surrogates and anything past + // U+10FFFF are errors, not replacement output. + if value > 0x0010_FFFF || (0xD800..=0xDFFF).contains(&value) { + self.end = + (start + iter.i as usize).saturating_sub(width3 as usize); + self.add_syntax_error( + self.end, + format_args!( + "Unicode escape sequence is not a Unicode scalar value" + ), + )?; + } } iter.c = value as CodePoint; // @truncate @@ -1132,8 +1169,8 @@ impl<'a> Lexer<'a> { self.add_default_error(b"Unexpected end of line")?; } - // Ignore line continuations. A line continuation is not an escaped newline. - // Match the JS lexer (js_parser/lexer.rs:660-661, 937-939): guard on + // Line-ending backslash. Match the JS lexer + // (js_parser/lexer.rs:660-661, 937-939): guard on // the index we actually read (`iter.i + 1`), not `iter.i`. Without // this, a multiline basic string ending in `\` right before `"""` // reads `text[len]` and panics even in release (slice bounds checks @@ -1143,19 +1180,71 @@ impl<'a> Lexer<'a> { // Make sure Windows CRLF counts as a single newline iter.i += 1; } + skip_line_continuation_whitespace(text, &iterator, &mut iter); continue; } c if c == '\n' as CodePoint || c == 0x2028 || c == 0x2029 => { - // Ignore line continuations. A line continuation is not an escaped newline. + // Line-ending backslash. if !ALLOW_MULTILINE { self.end = (start + iter.i as usize).saturating_sub(width2 as usize); self.add_default_error(b"Unexpected end of line")?; } + skip_line_continuation_whitespace(text, &iterator, &mut iter); + continue; + } + // TOML §String: "when the last non-whitespace character on + // a line is an unescaped \, it will be trimmed along with + // all whitespace (including newlines) up to the next + // non-whitespace character" — `\` followed only by + // spaces/tabs until the end of the line is also a + // line-ending backslash (ABNF `mlb-escaped-nl`). + c if ALLOW_MULTILINE + && (c == ' ' as CodePoint || c == '\t' as CodePoint) => + { + let mut probe = iter; + let reaches_newline = loop { + if !iterator.next(&mut probe) { + break false; + } + match probe.c { + c if c == ' ' as CodePoint || c == '\t' as CodePoint => {} + c if c == '\n' as CodePoint => break true, + c if c == '\r' as CodePoint => { + // Only as the CR of a CRLF. + let ni = probe.i as usize + 1; + break ni < text.len() && text[ni] == b'\n'; + } + _ => break false, + } + }; + if !reaches_newline { + self.end = + (start + iter.i as usize).saturating_sub(width2 as usize); + self.add_default_error(b"Invalid escape sequence")?; + } + // `probe` sits on the newline (or the CR of a CRLF). + iter = probe; + if iter.c == '\r' as CodePoint { + iter.i += 1; + } + skip_line_continuation_whitespace(text, &iterator, &mut iter); continue; } _ => { - iter.c = c2; + // TOML §String: "All other escape sequences [...] + // are reserved; if they are used, TOML should + // produce an error." Silently emitting the literal + // character turned `"\U000003B4"` into `U000003B4`. + self.end = (start + iter.i as usize).saturating_sub(width2 as usize); + self.add_syntax_error( + self.end, + format_args!( + "Invalid escape sequence \"\\{}\" in TOML string", + char::from_u32(c2 as u32) + .unwrap_or(char::REPLACEMENT_CHARACTER) + ), + )?; } } } @@ -1282,7 +1371,167 @@ pub(crate) fn is_identifier_part(code_point: CodePoint) -> bool { // The `(0..=127)` bound is required for the byte cast above to be sound. } +/// A newline immediately following a multi-line string's opening delimiter +/// (`"""` / `'''`) is trimmed (TOML §String). `after_delim` is the byte index +/// just past the opening delimiter; returns the index the content starts at. +#[inline] +fn multiline_content_start(contents: &[u8], after_delim: usize) -> usize { + match contents.get(after_delim).copied() { + Some(b'\n') => after_delim + 1, + Some(b'\r') if contents.get(after_delim + 1) == Some(&b'\n') => after_delim + 2, + _ => after_delim, + } +} + +/// After a line-ending backslash has consumed its newline, consume the run of +/// spaces, tabs, and newlines that follows. TOML §String: the backslash "will +/// be trimmed along with all whitespace (including newlines) up to the next +/// non-whitespace character or closing delimiter". +fn skip_line_continuation_whitespace( + text: &[u8], + iterator: &strings::CodepointIterator<'_>, + iter: &mut strings::Cursor, +) { + loop { + let mut probe = *iter; + if !iterator.next(&mut probe) { + return; + } + match probe.c { + c if c == ' ' as CodePoint || c == '\t' as CodePoint || c == '\n' as CodePoint => {} + c if c == '\r' as CodePoint => { + let next_i = probe.i as usize + 1; + if next_i < text.len() && text[next_i] == b'\n' { + probe.i += 1; + } else { + // A bare CR is not a TOML newline; leave it for the caller. + return; + } + } + _ => return, + } + *iter = probe; + } +} + #[inline] -fn float64(num: CodePoint) -> f64 { - num as f64 +fn two_digits(a: u8, b: u8) -> u32 { + u32::from(a - b'0') * 10 + u32::from(b - b'0') +} + +/// `full-date = 4DIGIT "-" 2DIGIT "-" 2DIGIT` with month `01-12` and day +/// `01-31` (RFC 3339 via TOML §Offset Date-Time). Calendar validity (leap +/// years, days per month) is intentionally not checked. +fn is_full_date(b: &[u8]) -> bool { + if b.len() < 10 + || !b[0..4].iter().all(u8::is_ascii_digit) + || b[4] != b'-' + || !b[5].is_ascii_digit() + || !b[6].is_ascii_digit() + || b[7] != b'-' + || !b[8].is_ascii_digit() + || !b[9].is_ascii_digit() + { + return false; + } + (1..=12).contains(&two_digits(b[5], b[6])) && (1..=31).contains(&two_digits(b[8], b[9])) +} + +/// `partial-time = 2DIGIT ":" 2DIGIT ":" 2DIGIT ["." 1*DIGIT]`, with hour +/// `00-23`, minute `00-59`, and second `00-60` (leap second). Returns the +/// byte length of the match. +fn scan_partial_time(b: &[u8]) -> Option { + if b.len() < 8 + || !b[0].is_ascii_digit() + || !b[1].is_ascii_digit() + || b[2] != b':' + || !b[3].is_ascii_digit() + || !b[4].is_ascii_digit() + || b[5] != b':' + || !b[6].is_ascii_digit() + || !b[7].is_ascii_digit() + { + return None; + } + if two_digits(b[0], b[1]) > 23 || two_digits(b[3], b[4]) > 59 || two_digits(b[6], b[7]) > 60 { + return None; + } + let mut i: usize = 8; + if b.get(i) == Some(&b'.') { + i += 1; + let frac_start = i; + while b.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + // At least one fractional digit is required after the `.`. + if i == frac_start { + return None; + } + } + Some(i) +} + +/// Byte length of the TOML 1.0 date-time value that starts at `bytes[0]`, or +/// `None` if there isn't one. Matches all four RFC 3339 shapes: offset +/// date-time, local date-time, local date (`1979-05-27`), and local time +/// (`07:32:00`). The match must end at a token boundary so inputs like +/// `2020-01-01x` or `1997-09-0909:09:09` fall through to the number path +/// (which then rejects them). +fn scan_date_time(bytes: &[u8]) -> Option { + let len = if is_full_date(bytes) { + let mut i: usize = 10; + // `time-delim = "T" / "t" / %x20`. A space only counts as the + // delimiter when a time actually follows it. + let has_time = match bytes.get(i).copied() { + Some(b'T' | b't') => { + i += 1; + true + } + Some(b' ') + if bytes.len() >= i + 4 + && bytes[i + 1].is_ascii_digit() + && bytes[i + 2].is_ascii_digit() + && bytes[i + 3] == b':' => + { + i += 1; + true + } + _ => false, + }; + if has_time { + i += scan_partial_time(&bytes[i..])?; + // `time-offset = "Z" / ("+" / "-") 2DIGIT ":" 2DIGIT`, optional + // (its absence makes this a local date-time). + match bytes.get(i).copied() { + Some(b'Z' | b'z') => i += 1, + Some(b'+' | b'-') => { + let off = bytes.get(i + 1..i + 6)?; + if !(off[0].is_ascii_digit() + && off[1].is_ascii_digit() + && off[2] == b':' + && off[3].is_ascii_digit() + && off[4].is_ascii_digit()) + || two_digits(off[0], off[1]) > 23 + || two_digits(off[3], off[4]) > 59 + { + return None; + } + i += 6; + } + _ => {} + } + } + i + } else { + scan_partial_time(bytes)? + }; + // Require a token boundary so that anything that could extend a number, + // bare key, or malformed date (`2020-01-01x`, `1997-09-0909:09:09`) never + // half-matches. A `.` is a valid boundary: a fractional second is consumed + // by `scan_partial_time`, so a trailing `.` can only be the dotted-key + // separator of e.g. `2001-02-11.a = 1` (toml-test valid/key/like-date). + match bytes.get(len).copied() { + Some(c) if c.is_ascii_alphanumeric() || matches!(c, b'-' | b':' | b'+' | b'_') => None, + _ => Some(len), + } } diff --git a/test/js/bun/resolve/toml/toml-parse.test.ts b/test/js/bun/resolve/toml/toml-parse.test.ts index 45f2c48550b9..3dbb5c2d0e24 100644 --- a/test/js/bun/resolve/toml/toml-parse.test.ts +++ b/test/js/bun/resolve/toml/toml-parse.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; test("Bun.TOML.parse with non-string input throws", () => { expect(() => Bun.TOML.parse(SharedArrayBuffer as any)).toThrow(); @@ -87,3 +87,369 @@ test("Bun.TOML.parse rejects array values without comma separators (#31252)", () // Trailing comma is legal TOML. expect(Bun.TOML.parse("a = [1, 2,]")).toEqual({ a: [1, 2] }); }); + +// `\UXXXXXXXX` (8 hex digits) is one of the two Unicode escapes TOML defines +// (§String). The lexer only implemented `\u`, so `\U` fell into the catch-all +// "unknown escape emits the literal next character" branch and `"\U000003B4"` +// silently became the 9-character string `U000003B4` instead of `δ`. +// Reference: toml-test valid/string/unicode-escape, valid/string/multibyte-escape. +describe("TOML \\U unicode escapes", () => { + test("8-digit \\U escapes decode to the code point", () => { + expect(Bun.TOML.parse('a = "\\U000003B4"')).toEqual({ a: "δ" }); + expect(Bun.TOML.parse('a = "\\U00000063"')).toEqual({ a: "c" }); + expect(Bun.TOML.parse('a = "\\U000000E9"')).toEqual({ a: "é" }); + // Astral plane (needs a surrogate pair in the JS string). + expect(Bun.TOML.parse('a = "\\U00010AF1"')).toEqual({ a: "\u{10AF1}" }); + // Inside multiline basic strings too. + expect(Bun.TOML.parse('a = """\\U000003B4"""')).toEqual({ a: "δ" }); + // `\u` and `\U` spellings of the same code point agree. + expect(Bun.TOML.parse('a = "\\u03B4"').a).toBe(Bun.TOML.parse('a = "\\U000003B4"').a); + // NOTE: `\U00000000` is decoded correctly too, but Bun.TOML.parse cannot + // surface it: its AST-to-JS path prints JSON text and `print_json` does + // not yet escape U+0000-U+001F, so `JSON.parse` rejects the output. That + // is a separate, pre-existing bug (`Bun.TOML.parse('a = "\\u0000"')` + // already throws "Failed to parse JSON"). The module loader path handles + // it; see toml.test.js. + }); + + test("\\U with fewer than 8 hex digits is rejected", () => { + // toml-test invalid/string/bad-uni-esc-03 / bad-uni-esc-04 + expect(() => Bun.TOML.parse('a = "\\U0000000"')).toThrow(); + expect(() => Bun.TOML.parse('a = "\\U0000"')).toThrow(); + expect(() => Bun.TOML.parse('a = "\\Ugggggggg"')).toThrow(); + expect(() => Bun.TOML.parse('a = "\\Ux"')).toThrow(); + }); + + test("escapes outside the Unicode scalar value range are rejected", () => { + // toml-test invalid/string/basic-out-of-range-unicode-escape-01 / -02 + expect(() => Bun.TOML.parse('a = "\\UFFFFFFFF"')).toThrow("Unicode scalar value"); + expect(() => Bun.TOML.parse('a = "\\U00D80000"')).toThrow("Unicode scalar value"); + // toml-test invalid/string/bad-uni-esc-06: lone surrogate. + expect(() => Bun.TOML.parse('a = "\\uD801"')).toThrow("Unicode scalar value"); + expect(() => Bun.TOML.parse('a = "\\udfff"')).toThrow("Unicode scalar value"); + }); +}); + +// TOML §String: the valid escapes are `\b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX`. +// "All other escape sequences [...] are reserved; if they are used, TOML should +// produce an error." The lexer instead emitted the literal character after the +// backslash, so e.g. `"\a"` loaded as `"a"` and `"C:\Users"` as `"C:Users"`. +// Reference: toml-test invalid/string/bad-escape-01, basic-unknown-escape, +// bad-slash-escape, multiline-bad-escape-01. +test("Bun.TOML.parse rejects reserved escape sequences instead of dropping the backslash", () => { + for (const escape of ["a", "e", "q", "/", "'", "0", "7", "8", "9", "v", "?", "U5"]) { + expect(() => Bun.TOML.parse(`a = "\\${escape}x"`)).toThrow(); + expect(() => Bun.TOML.parse(`a = """\\${escape}x"""`)).toThrow(); + } + // The TOML escapes all still work. + expect(Bun.TOML.parse('a = "|\\b.\\t.\\n.\\f.\\r.\\".\\\\.\\u007F."')).toEqual({ + a: '|\b.\t.\n.\f.\r.".\\.\u007F.', + }); +}); + +// TOML §String: "A newline immediately following the opening delimiter will be +// trimmed." The lexer sliced the content starting right after the `"""`/`'''`, +// so `"""\nX"""` loaded as `"\nX"` instead of `"X"`. +// Reference: toml-test valid/string/multiline, valid/string/raw-multiline. +test("Bun.TOML.parse trims the newline immediately after an opening multiline delimiter", () => { + expect(Bun.TOML.parse('a = """\nX"""')).toEqual({ a: "X" }); + expect(Bun.TOML.parse('a = """\r\nX"""')).toEqual({ a: "X" }); + expect(Bun.TOML.parse("a = '''\nX'''")).toEqual({ a: "X" }); + expect(Bun.TOML.parse("a = '''\r\nX'''")).toEqual({ a: "X" }); + // Only the first newline is trimmed. + expect(Bun.TOML.parse('a = """\n\nX"""')).toEqual({ a: "\nX" }); + expect(Bun.TOML.parse("a = '''\n\nX'''")).toEqual({ a: "\nX" }); + // An empty body after the trim is fine. + expect(Bun.TOML.parse('a = """\n"""')).toEqual({ a: "" }); + expect(Bun.TOML.parse("a = '''\n'''")).toEqual({ a: "" }); + // No newline right after the delimiter: nothing is trimmed. + expect(Bun.TOML.parse('a = """ \nX"""')).toEqual({ a: " \nX" }); +}); + +// TOML §String: "When the last non-whitespace character on a line is an +// unescaped `\`, it will be trimmed along with all whitespace (including +// newlines) up to the next non-whitespace character or closing delimiter." +// The lexer dropped only the `\` and its newline, so the indentation of the +// continued line leaked into the value (`"""a \\\n b"""` became `"a b"`). +// Reference: toml-test valid/string/multiline, valid/string/ends-in-whitespace-escape. +describe("TOML line-ending backslash", () => { + test("consumes the following whitespace and newlines", () => { + expect(Bun.TOML.parse('a = """a \\\n b"""')).toEqual({ a: "a b" }); + expect(Bun.TOML.parse('a = """a\\\n b"""')).toEqual({ a: "ab" }); + // Blank lines after the continuation are consumed too. + expect(Bun.TOML.parse('a = """\nThe quick brown \\\n\n\n fox."""')).toEqual({ + a: "The quick brown fox.", + }); + // Whitespace *before* the backslash is kept (toml-test keep-ws-before). + expect(Bun.TOML.parse('a = """a \t\\\n b"""')).toEqual({ a: "a \tb" }); + }); + + test("allows trailing whitespace between the backslash and the newline", () => { + // ABNF `mlb-escaped-nl = escape ws newline *( wschar / newline )`. + expect(Bun.TOML.parse('a = """a \\ \n b"""')).toEqual({ a: "a b" }); + expect(Bun.TOML.parse('a = """a \\\t\n b"""')).toEqual({ a: "a b" }); + // toml-test valid/string/ends-in-whitespace-escape. + expect(Bun.TOML.parse('a = """\nheeee\ngeeee\\ \n\n\n """')).toEqual({ + a: "heeee\ngeeee", + }); + }); + + test("an escaped backslash is not a line continuation", () => { + // toml-test valid/string/multiline escape-bs-1/2/3. + expect(Bun.TOML.parse('a = """a \\\\\nb"""')).toEqual({ a: "a \\\nb" }); + expect(Bun.TOML.parse('a = """a \\\\\\\nb"""')).toEqual({ a: "a \\b" }); + expect(Bun.TOML.parse('a = """a \\\\\\\\\n b"""')).toEqual({ a: "a \\\\\n b" }); + }); + + test("a backslash followed by non-whitespace on the same line is an error", () => { + // toml-test invalid/string/multiline-bad-escape-02/-03, multiline-escape-space-01/-02. + expect(() => Bun.TOML.parse('a = """t\\ t"""')).toThrow(); + expect(() => Bun.TOML.parse('a = """t\\ """')).toThrow(); + expect(() => Bun.TOML.parse('a = """\nhee \\\n\ngee \\ """')).toThrow(); + // `\` is never valid in a single-line basic string. + expect(() => Bun.TOML.parse('a = "t\\ t"')).toThrow(); + }); +}); + +// One or two quote characters directly against the closing `"""`/`'''` belong +// to the content, not the delimiter. The lexer closed at the first run of three +// quotes, leaving the extras behind as a stray token. +// Reference: toml-test valid/string/multiline-quotes, valid/string/raw-multiline. +test("Bun.TOML.parse keeps quotes adjacent to a closing multiline delimiter", () => { + expect(Bun.TOML.parse('a = """"x""""')).toEqual({ a: '"x"' }); + expect(Bun.TOML.parse('a = """""x"""""')).toEqual({ a: '""x""' }); + expect(Bun.TOML.parse("a = ''''x''''")).toEqual({ a: "'x'" }); + expect(Bun.TOML.parse("a = '''''x'''''")).toEqual({ a: "''x''" }); + // toml-test valid/string/multiline-quotes `escaped`: `"""lol\""""""`. + expect(Bun.TOML.parse('a = """lol\\""""""')).toEqual({ a: 'lol"""' }); + expect(Bun.TOML.parse('a = """\nClosing with five quotes\n"""""')).toEqual({ + a: 'Closing with five quotes\n""', + }); + // toml-test valid/string/raw-multiline `this-str-has-apostrophes`. + expect(Bun.TOML.parse("a = '''' there's one already\n'' two more\n'''''")).toEqual({ + a: "' there's one already\n'' two more\n''", + }); +}); + +// TOML §Keys: a bare key is `A-Za-z0-9_-`, so a dotted key made of digit-only +// segments written without spaces (`3.14159 = "pi"`) is the two-segment key +// `3` . `14159`. The lexer tokenizes it as one float and the key parser used +// the raw token text, producing the single key `"3.14159"`. The same lexer +// behavior also made `a.1 = 1` a hard parse error: the `.1` becomes a numeric +// literal, not a `.` followed by the segment `1`. +// Reference: toml-test valid/key/numeric-02 (`1.2 = true` -> {"1":{"2":true}}). +test("Bun.TOML.parse splits float-looking bare keys on the dot", () => { + expect(Bun.TOML.parse('3.14159 = "pi"')).toEqual({ "3": { "14159": "pi" } }); + expect(Bun.TOML.parse("1.2 = true")).toEqual({ "1": { "2": true } }); + expect(Bun.TOML.parse("[3.14]\nx = 1")).toEqual({ "3": { "14": { x: 1 } } }); + // toml-test valid/key/numeric-04: leading zeros are preserved verbatim. + expect(Bun.TOML.parse("01.23 = true")).toEqual({ "01": { "23": true } }); + // A digit segment *continuing* a dotted key. + expect(Bun.TOML.parse("a.1 = 1")).toEqual({ a: { "1": 1 } }); + expect(Bun.TOML.parse("a.1.5 = 1")).toEqual({ a: { "1": { "5": 1 } } }); + expect(Bun.TOML.parse('x.3.14159 = "pi"')).toEqual({ x: { "3": { "14159": "pi" } } }); + // Whitespace around the dot already worked; it must agree with the no-space form. + expect(Bun.TOML.parse('3 . 14159 = "pi"')).toEqual(Bun.TOML.parse('3.14159 = "pi"')); + expect(Bun.TOML.parse("a . 1 = 1")).toEqual(Bun.TOML.parse("a.1 = 1")); + // A *quoted* key is never split. + expect(Bun.TOML.parse('"3.14159" = "pi"')).toEqual({ "3.14159": "pi" }); + // Digit-only bare keys without a dot are unaffected. + expect(Bun.TOML.parse("1 = true\n10e3 = 1\n2018_10 = 2")).toEqual({ + "1": true, + "10e3": 1, + "2018_10": 2, + }); + // Empty bare segments are invalid (toml-test invalid/key/dot). + expect(() => Bun.TOML.parse(". = 1")).toThrow(); + expect(() => Bun.TOML.parse(".5 = 1")).toThrow(); + expect(() => Bun.TOML.parse("a..5 = 1")).toThrow(); + expect(() => Bun.TOML.parse("3. = 1")).toThrow(); +}); + +// TOML §Integer: "Arbitrary 64-bit signed integers should be accepted and +// handled losslessly. If an integer cannot be represented losslessly, an error +// must be thrown." Bun's TOML values are JavaScript numbers (IEEE-754 doubles), +// so integers past 2^53 silently lost their low digits: +// `9223372036854775807` loaded as `9223372036854776000`. +// Reference: toml-test valid/integer/long. +describe("TOML 64-bit integers", () => { + test("integers that a JS number cannot represent exactly are an error", () => { + expect(() => Bun.TOML.parse("a = 9223372036854775807")).toThrow("cannot be represented exactly"); + // 2^53 + 1: the first integer an f64 cannot hold. + expect(() => Bun.TOML.parse("a = 9007199254740993")).toThrow("cannot be represented exactly"); + expect(() => Bun.TOML.parse("a = -9007199254740993")).toThrow("cannot be represented exactly"); + // Hexadecimal / octal / binary spellings are covered by the same check. + expect(() => Bun.TOML.parse("a = 0x7FFFFFFFFFFFFFFF")).toThrow("cannot be represented exactly"); + expect(() => Bun.TOML.parse("a = 0o777777777777777777777")).toThrow(); + // Out of the 64-bit range entirely. + expect(() => Bun.TOML.parse("a = 99999999999999999999999999")).toThrow("64-bit range"); + expect(() => Bun.TOML.parse("a = 0xFFFFFFFFFFFFFFFF")).toThrow(); + expect(() => Bun.TOML.parse("a = 0xFFFFFFFFFFFFFFFFF")).toThrow(); + }); + + test("every exactly-representable integer still parses", () => { + expect(Bun.TOML.parse("a = 9007199254740992").a).toBe(2 ** 53); + expect(Bun.TOML.parse("a = -9007199254740992").a).toBe(-(2 ** 53)); + // 10^17 > 2^53 but is still exact (it only needs 40 significant bits). + expect(Bun.TOML.parse("a = 100000000000000000").a).toBe(1e17); + // i64::MIN is a power of two, so it round-trips exactly. + expect(Bun.TOML.parse("a = -9223372036854775808").a).toBe(-(2 ** 63)); + expect(Bun.TOML.parse("a = 1_000_000_000_000_000").a).toBe(1e15); + expect(Bun.TOML.parse("a = 0xDEADBEEF").a).toBe(0xdeadbeef); + expect(Bun.TOML.parse("a = 0").a).toBe(0); + }); + + test("floats are unaffected: they round like any other IEEE-754 double", () => { + expect(Bun.TOML.parse("a = 9223372036854775807.0").a).toBe(9223372036854775807.0); + expect(Bun.TOML.parse("a = 9007199254740993e0").a).toBe(9007199254740992); + expect(Bun.TOML.parse("a = 3.141592653589793").a).toBe(Math.PI); + }); +}); + +// TOML §Offset Date-Time / §Local Date-Time / §Local Date / §Local Time. The +// lexer had no date-time branch at all: `1979-05-27T07:32:00Z` tokenized as the +// integer 1979 followed by a stray `-`, so every document containing a date was +// rejected with "Expected key but found -". There is no date-time node in the +// TOML AST, so the value surfaces as the verbatim RFC 3339 string. +// Reference: toml-test valid/datetime/*. +describe("TOML date-times", () => { + test("all four RFC 3339 shapes parse as their source text", () => { + expect( + Bun.TOML.parse( + ["offset = 1979-05-27T07:32:00Z", "local = 1987-07-05T17:45:00", "date = 1979-05-27", "time = 07:32:00"].join( + "\n", + ), + ), + ).toEqual({ + offset: "1979-05-27T07:32:00Z", + local: "1987-07-05T17:45:00", + date: "1979-05-27", + time: "07:32:00", + }); + }); + + test("delimiter case, fractional seconds, and numeric offsets", () => { + // toml-test valid/datetime/datetime, milliseconds, timezone, local-time. + expect( + Bun.TOML.parse( + [ + "space = 1987-07-05 17:45:00Z", + "lower = 1987-07-05t17:45:00z", + "milli = 1977-12-21T10:32:00.555", + "wita = 1987-07-05T17:45:56.6+08:00", + "pdt = 1987-07-05T17:45:56-05:00", + "ms = 10:32:00.555", + "edge = 0001-01-01 00:00:00Z", + ].join("\n"), + ), + ).toEqual({ + space: "1987-07-05 17:45:00Z", + lower: "1987-07-05t17:45:00z", + milli: "1977-12-21T10:32:00.555", + wita: "1987-07-05T17:45:56.6+08:00", + pdt: "1987-07-05T17:45:56-05:00", + ms: "10:32:00.555", + edge: "0001-01-01 00:00:00Z", + }); + }); + + test("date-times work inside arrays and inline tables", () => { + expect(Bun.TOML.parse("a = [1979-05-27, 07:32:00, 1]")).toEqual({ + a: ["1979-05-27", "07:32:00", 1], + }); + expect(Bun.TOML.parse("a = { d = 1979-05-27 }")).toEqual({ a: { d: "1979-05-27" } }); + }); + + test("a date-time shaped bare key is a key, not a value", () => { + // toml-test valid/key/like-date: `-` is a valid bare-key character. + expect(Bun.TOML.parse("2024-05-27 = 1")).toEqual({ "2024-05-27": 1 }); + expect(Bun.TOML.parse("a.2001-02-08 = 7")).toEqual({ a: { "2001-02-08": 7 } }); + expect(Bun.TOML.parse("2001-02-11.a.2001-02-12 = 9")).toEqual({ + "2001-02-11": { a: { "2001-02-12": 9 } }, + }); + expect(Bun.TOML.parse("[2002-01-02.2024-01-03]\nk = 11")).toEqual({ + "2002-01-02": { "2024-01-03": { k: 11 } }, + }); + }); + + test("a date quoted as a string stays a string", () => { + // toml-test valid/datetime/invalid-date-in-string. + expect(Bun.TOML.parse("s = '2020-01-01x'")).toEqual({ s: "2020-01-01x" }); + expect(Bun.TOML.parse('s = "1979-05-27"')).toEqual({ s: "1979-05-27" }); + }); + + // https://github.com/oven-sh/bun/issues/22426 + // The example document from https://toml.io/en/v1.0.0 was rejected at the + // `dob` date-time. + test("the toml.io v1.0.0 example document parses", () => { + const doc = [ + "# This is a TOML document", + "", + 'title = "TOML Example"', + "", + "[owner]", + 'name = "Tom Preston-Werner"', + "dob = 1979-05-27T07:32:00-08:00", + "", + "[database]", + "enabled = true", + "ports = [ 8000, 8001, 8002 ]", + 'data = [ ["delta", "phi"], [3.14] ]', + "temp_targets = { cpu = 79.5, case = 72.0 }", + "", + "[servers]", + "", + "[servers.alpha]", + 'ip = "10.0.0.1"', + 'role = "frontend"', + "", + "[servers.beta]", + 'ip = "10.0.0.2"', + 'role = "backend"', + "", + ].join("\n"); + expect(Bun.TOML.parse(doc)).toEqual({ + title: "TOML Example", + owner: { name: "Tom Preston-Werner", dob: "1979-05-27T07:32:00-08:00" }, + database: { + enabled: true, + ports: [8000, 8001, 8002], + data: [["delta", "phi"], [3.14]], + temp_targets: { cpu: 79.5, case: 72.0 }, + }, + servers: { + alpha: { ip: "10.0.0.1", role: "frontend" }, + beta: { ip: "10.0.0.2", role: "backend" }, + }, + }); + }); + + test("malformed date-times are still rejected", () => { + // toml-test invalid/datetime/*: missing leading zeros, missing seconds, + // missing `T`, trailing garbage, out-of-range components, y10k. + for (const bad of [ + "a = 1987-7-05T17:45:00Z", + "a = 1987-07-5T17:45:00.12Z", + "a = 1987-07-05T17:45Z", + "a = 1987-07-0517:45:00Z", + "a = 1997-09-0909:09:09", + "a = 2020-01-01x", + "a = 2023-10-01T1:32:00Z", + "a = 1997-09-09T09:09:09.", + "a = 1997-09-09T09:09:09.09+09:9", + "a = 1997-09-09T09:09:09.09+0909", + "a = 1997-09-09T09:09:09.09+09", + "a = 2006-13-01T00:00:00Z", + "a = 2006-01-32T00:00:00Z", + "a = 2006-01-00T00:00:00Z", + "a = 2006-01-01T24:00:00Z", + "a = 2006-01-01T00:60:00Z", + "a = 2006-01-01T00:00:61Z", + "a = 1985-06-18 17:04:07+25:00", + "a = 10000-01-01", + "a = 02026-05-07", + ]) { + expect(() => Bun.TOML.parse(bad)).toThrow(); + } + }); +}); diff --git a/test/js/bun/resolve/toml/toml.test.js b/test/js/bun/resolve/toml/toml.test.js index c244e1be31ef..c65b7630e252 100644 --- a/test/js/bun/resolve/toml/toml.test.js +++ b/test/js/bun/resolve/toml/toml.test.js @@ -1,4 +1,5 @@ import { expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import emptyToml from "./toml-empty.toml"; import tomlFromCustomTypeAttribute from "./toml-fixture.toml.txt" with { type: "toml" }; @@ -104,6 +105,63 @@ q1 = 1 expect(parsed.items).toEqual([{ q1: 1 }]); }); +// The module loader (`import x from "./f.toml"`, `with { type: "toml" }`) and +// `Bun.TOML.parse` share one parser but convert the AST to JS differently (the +// loader prints JS source; `Bun.TOML.parse` round-trips through JSON). This +// exercises the loader end to end for the value classes the parser used to +// silently corrupt (`\U` escapes, multiline-string trimming, line-ending +// backslashes, dotted float-looking keys) or reject (RFC 3339 date-times, +// which the loader surfaces as strings). The per-class coverage lives in +// toml-parse.test.ts. +it("the toml loader decodes escapes, multiline strings, dotted keys, and date-times", async () => { + const specToml = [ + 'unicode = "\\U000003B4 \\u03B4 \\U00010AF1"', + 'firstnl = """', + 'X"""', + 'joined = """a \\', + ' b"""', + '3.14159 = "pi"', + "odt = 1979-05-27T07:32:00Z", + "date = 1979-05-27", + "time = 07:32:00", + "", + ].join("\n"); + using dir = tempDir("toml-loader-spec", { + "spec.toml": specToml, + // The same document reached via `with { type: "toml" }` on a non-.toml extension. + "spec.txt": specToml, + "index.ts": ` + import withExtension from "./spec.toml"; + import withAttribute from "./spec.txt" with { type: "toml" }; + if (!Bun.deepEquals(withExtension, withAttribute, true)) { + throw new Error("extension and import-attribute loaders disagree"); + } + console.log(JSON.stringify(withExtension)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode, toml: stdout.trim() && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + toml: { + unicode: "δ δ \u{10AF1}", + firstnl: "X", + joined: "a b", + "3": { "14159": "pi" }, + odt: "1979-05-27T07:32:00Z", + date: "1979-05-27", + time: "07:32:00", + }, + }); +}); + it("Bun.TOML.parse throws on deeply nested inline tables instead of crashing", () => { // Calibrated to exhaust the 18 MB main-thread stack at the smallest expected // per-recursion frame size (~100 B in release builds). Previously 25_000.