diff --git a/changelog.d/9438-fancy-regex-split.md b/changelog.d/9438-fancy-regex-split.md new file mode 100644 index 0000000000..a09bc7fbbd --- /dev/null +++ b/changelog.d/9438-fancy-regex-split.md @@ -0,0 +1,14 @@ +### Fixed + +- **Regex separators that require fancy-regex now run the same + `RegExp.prototype[Symbol.split]` cursor algorithm as ordinary patterns.** The + old `find_iter` fallback emitted a trailing `""` after a zero-width match at + the end and discarded separator captures. The fancy lane now uses + `captures_from_pos` on the complete subject, performs the spec's sticky + `q`/`p` walk bounded by `q < size`, splices matched and unmatched captures, + and stops as soon as `limit` is reached. + + Coverage includes lookbehind and lookahead separators, captures, start/end + matches, empty subjects, limits, and the multiline `^` / `$` forms rewritten + onto the fancy lane by #9427. The pre-existing runtime test that pinned the + incorrect trailing element now asserts Node's result. diff --git a/changelog.d/9509-date-parse-tail.md b/changelog.d/9509-date-parse-tail.md new file mode 100644 index 0000000000..771ff01e62 --- /dev/null +++ b/changelog.d/9509-date-parse-tail.md @@ -0,0 +1,17 @@ +### Fixed + +- **ISO-shaped date parsing now consumes the complete clock tail instead of + silently discarding it.** Space-separated `AM` / `PM`, the GMT family, and + V8's fixed `EST` / `EDT` / `CST` / `CDT` / `MST` / `MDT` / `PST` / `PDT` + table are applied to the parsed instant; the same zone words work on + date-only and partial `YYYY` / `YYYY-MM` forms. Missing day/month components, + repeated whitespace, numeric offsets, and parenthesized comments retain + Node's measured behavior. + + Every other suffix must now make the parse fail. In particular, + `"2026-09-01 10:30GMT"`, `"...10:30EST"` and `"...10:30PM"` are Invalid + Date, matching Node, rather than plausible but wrong local instants produced + from the `HH:MM` prefix. The expanded #9449 parity fixture and focused + runtime tests cover both 12-hour boundaries, zone-plus-meridiem, all eight US + abbreviations, partial dates, date-only zone words, and invalid attached or + unknown tails with host-zone-independent assertions. diff --git a/crates/perry-runtime/src/date/parse.rs b/crates/perry-runtime/src/date/parse.rs index 22bda19b31..fcec1bbcb3 100644 --- a/crates/perry-runtime/src/date/parse.rs +++ b/crates/perry-runtime/src/date/parse.rs @@ -82,9 +82,216 @@ fn parse_tz_offset(rest: &str) -> Option { }; let h: i64 = hh.parse().ok()?; let m: i64 = mm.parse().ok()?; + if h > 23 || m > 59 { + return None; + } Some(sign * (h * 60 + m)) } +/// V8's fixed legacy timezone-name table. These abbreviations deliberately do +/// not consult the host timezone database: `EST` always means UTC-05:00, even +/// for a date on which a particular location observes daylight time. +fn named_tz_offset(token: &str) -> Option { + let lower = token.to_ascii_lowercase(); + let fixed = match lower.as_str() { + "ut" | "utc" | "gmt" | "z" => Some(0), + "edt" => Some(-4 * 60), + "est" | "cdt" => Some(-5 * 60), + "cst" | "mdt" => Some(-6 * 60), + "mst" | "pdt" => Some(-7 * 60), + "pst" => Some(-8 * 60), + _ => None, + }; + if fixed.is_some() { + return fixed; + } + + // A GMT-family word may carry an attached numeric offset. V8 accepts the + // same spelling after a date-only form and after a clock. + for prefix in ["utc", "gmt", "ut", "z"] { + if lower.starts_with(prefix) && lower.len() > prefix.len() { + let rest = &token[prefix.len()..]; + if rest.starts_with('+') || rest.starts_with('-') { + return parse_tz_offset(rest).filter(|offset| *offset != i64::MAX); + } + } + } + None +} + +#[derive(Clone, Copy)] +struct ParsedClock { + hour: i64, + minute: i64, + second: i64, + millis: i64, + attached_tz: Option, +} + +fn parse_clock_digits(input: &str, index: &mut usize, min: usize, max: usize) -> Option { + let start = *index; + while *index < input.len() && *index - start < max && input.as_bytes()[*index].is_ascii_digit() + { + *index += 1; + } + if *index - start < min { + return None; + } + input[start..*index].parse().ok() +} + +/// Parse a complete clock token and return any numeric/Z designator attached +/// directly to it. In the ISO `T` spelling hour/minute/second fields are two +/// digits; the whitespace-separated legacy spelling also accepts one digit. +/// Alphabetic words are intentionally not accepted as an attached suffix — +/// `10:30 GMT` is valid while `10:30GMT` is not. +fn parse_clock_token(token: &str, strict_iso: bool) -> Option { + let mut index = 0usize; + let field_min = if strict_iso { 2 } else { 1 }; + let hour = parse_clock_digits(token, &mut index, field_min, 2)?; + if token.as_bytes().get(index) != Some(&b':') { + return None; + } + index += 1; + let minute = parse_clock_digits(token, &mut index, field_min, 2)?; + let mut second = 0i64; + let mut millis = 0i64; + + if token.as_bytes().get(index) == Some(&b':') { + index += 1; + // The legacy grammar accepts a trailing colon as an omitted seconds + // field (`10:30:`); ISO requires the two digits. + if index < token.len() && token.as_bytes()[index].is_ascii_digit() { + second = parse_clock_digits(token, &mut index, field_min, 2)?; + } else if strict_iso || index != token.len() { + return None; + } + if token.as_bytes().get(index) == Some(&b'.') { + index += 1; + let fraction_start = index; + while index < token.len() && token.as_bytes()[index].is_ascii_digit() { + index += 1; + } + if fraction_start == index { + return None; + } + millis = normalize_millis(&token[fraction_start..index]); + } + } + + if minute > 59 || second > 59 { + return None; + } + if hour > 24 || (hour == 24 && (minute != 0 || second != 0 || millis != 0)) { + return None; + } + + let attached_tz = if index == token.len() { + None + } else { + let rest = &token[index..]; + if rest.eq_ignore_ascii_case("z") || rest.starts_with('+') || rest.starts_with('-') { + Some(parse_tz_offset(rest).filter(|offset| *offset != i64::MAX)?) + } else { + return None; + } + }; + Some(ParsedClock { + hour, + minute, + second, + millis, + attached_tz, + }) +} + +/// Parse the implementation-defined tail after an ISO-shaped date when it is +/// not the strict `T` clock. Tokens are order-independent like V8's legacy +/// DateParser: a clock, AM/PM and a named/numeric zone may be combined, with a +/// later zone token winning. Parenthesized comments are explicitly consumed; +/// every other word must be recognized or the whole parse fails. +fn parse_legacy_iso_tail(tail: &str) -> Option<(Option, Option)> { + let mut clock: Option = None; + let mut meridiem: Option = None; // true => PM + let mut tz_minutes_east: Option = None; + let mut in_comment = false; + + for token in tail.split_whitespace() { + if in_comment { + if token.ends_with(')') { + in_comment = false; + } + continue; + } + if token.starts_with('(') { + if token.ends_with(')') { + continue; + } + if token.contains(')') { + return None; + } + in_comment = true; + continue; + } + if token.contains(['(', ')']) { + return None; + } + + let lower = token.to_ascii_lowercase(); + if lower == "am" || lower == "pm" { + meridiem = Some(lower == "pm"); + continue; + } + if let Some(offset) = named_tz_offset(token) { + tz_minutes_east = Some(offset); + continue; + } + if (token.starts_with('+') || token.starts_with('-')) && clock.is_some() { + let offset = parse_tz_offset(token)?; + if offset == i64::MAX { + return None; + } + tz_minutes_east = Some(offset); + continue; + } + if let Some(parsed) = parse_clock_token(token, false) { + if clock.is_some() { + return None; + } + if let Some(offset) = parsed.attached_tz { + tz_minutes_east = Some(offset); + } + clock = Some(parsed); + continue; + } + return None; + } + if in_comment { + return None; + } + + if let Some(is_pm) = meridiem { + let parsed = clock.as_mut()?; + // V8 accepts 00:xx AM as midnight, but rejects an hour above 12 when a + // meridiem is present. + if parsed.hour > 12 { + return None; + } + parsed.hour = if is_pm { + if parsed.hour == 12 { + 12 + } else { + parsed.hour + 12 + } + } else if parsed.hour == 12 { + 0 + } else { + parsed.hour + }; + } + Some((clock, tz_minutes_east)) +} + /// Reinterpret an instant that was composed with `make_utc_ms` from /// wall-clock components as LOCAL time: subtract the host's UTC offset in /// effect at that instant. Shared by every grammar here that yields @@ -131,113 +338,61 @@ fn parse_iso8601(s: &str) -> Option { let mut second: i64 = 0; let mut millis: i64 = 0; - // Year only ("YYYY" / "±YYYYYY"). - if s.len() == year_end { - return Some(make_utc_ms( - year, - month1 as i64 - 1, - day, - hour, - minute, - second, - millis, - )); - } - // Require a '-' for month. - if b.get(year_end) != Some(&b'-') { - return None; - } - if b.len() < year_end + 3 { - return None; - } - month1 = s[year_end + 1..year_end + 3].parse().ok()?; - if !(1..=12).contains(&month1) { - return None; - } - let mut idx = year_end + 3; - let mut has_day = false; + let mut idx = year_end; if b.get(idx) == Some(&b'-') { if b.len() < idx + 3 { return None; } - day = s[idx + 1..idx + 3].parse().ok()?; - if !(1..=31).contains(&day) { + month1 = s[idx + 1..idx + 3].parse().ok()?; + if !(1..=12).contains(&month1) { return None; } idx += 3; - has_day = true; + if b.get(idx) == Some(&b'-') { + if b.len() < idx + 3 { + return None; + } + day = s[idx + 1..idx + 3].parse().ok()?; + if !(1..=31).contains(&day) { + return None; + } + idx += 3; + } } - // Time part (after 'T' or ' '). - let mut tz_minutes_east: Option = None; // None => "no offset present" - // #9449: the presence of a time component — not the presence of a zone — - // is what decides the default interpretation below. + // #9509: parsing the tail is explicit and exhaustive. A strict `T` clock + // may carry only its attached numeric/Z designator. The legacy tail used + // by whitespace-separated clocks and date-only zone words is tokenized; + // every token must be recognized. + let mut tz_minutes_east: Option = None; let mut has_time = false; if idx < s.len() { - let sep = b[idx]; - if sep != b'T' && sep != b' ' { - return None; - } - // Month-only "YYYY-MM" cannot carry a time component. - if !has_day { - return None; - } - let time_str = &s[idx + 1..]; - // Split off a trailing zone designator. Scan for the first of - // 'Z', '+', '-' after the HH:MM[:SS[.sss]] body. - let zone_pos = time_str - .char_indices() - .find(|(i, c)| *i > 0 && (*c == 'Z' || *c == '+' || *c == '-')) - .map(|(i, _)| i); - let (clock, zone) = match zone_pos { - Some(p) => (&time_str[..p], &time_str[p..]), - None => (time_str, ""), - }; - // #9449: node also accepts the designator as a trailing, whitespace- - // separated WORD in the space-separated spelling — - // `new Date("2026-09-01 10:30 GMT")` is 10:30 UTC, and so are the - // `UTC` / `UT` / `Z` spellings in either case. The scan above only - // finds `Z`, `+` and `-`, so the word used to be ignored outright; - // that was invisible while every offsetless form was read as UTC and - // becomes a wrong instant the moment they are read as local. A - // parenthesised trailing comment is NOT a designator and stays local. - let (clock, utc_word) = match clock.rsplit_once(char::is_whitespace) { - Some((head, tail)) - if !head.trim().is_empty() - && matches!( - tail.to_ascii_lowercase().as_str(), - "gmt" | "utc" | "ut" | "z" - ) => + if b[idx] == b'T' { + let parsed = parse_clock_token(&s[idx + 1..], true)?; + hour = parsed.hour; + minute = parsed.minute; + second = parsed.second; + millis = parsed.millis; + tz_minutes_east = parsed.attached_tz; + has_time = true; + } else { + let tail = &s[idx..]; + // A clock or token that follows the numeric date must either be + // whitespace-separated or be a directly-attached zone word. + if !tail.as_bytes()[0].is_ascii_whitespace() + && !tail.as_bytes()[0].is_ascii_alphabetic() { - (head.trim_end(), true) - } - _ => (clock, false), - }; - let cb = clock.as_bytes(); - if clock.len() < 5 || cb[2] != b':' { - return None; - } - hour = clock[0..2].parse().ok()?; - minute = clock[3..5].parse().ok()?; - has_time = true; - if clock.len() >= 8 && cb[5] == b':' { - second = clock[6..8].parse().ok()?; - if clock.len() > 9 && cb[8] == b'.' { - let frac = &clock[9..]; - let frac_digits: String = frac.chars().take_while(|c| c.is_ascii_digit()).collect(); - if !frac_digits.is_empty() { - millis = normalize_millis(&frac_digits); - } + return None; } - } - if !zone.is_empty() { - match parse_tz_offset(zone) { - Some(v) if v == i64::MAX => {} - Some(v) => tz_minutes_east = Some(v), - None => return None, + let (parsed_clock, parsed_tz) = parse_legacy_iso_tail(tail)?; + if let Some(parsed) = parsed_clock { + hour = parsed.hour; + minute = parsed.minute; + second = parsed.second; + millis = parsed.millis; + has_time = true; } - } else if utc_word { - tz_minutes_east = Some(0); + tz_minutes_east = parsed_tz; } } let base = make_utc_ms(year, month1 as i64 - 1, day, hour, minute, second, millis); @@ -251,7 +406,6 @@ fn parse_iso8601(s: &str) -> Option { // deliberate asymmetry. This half was already right; it must stay. None => base, }; - let _ = idx; Some(adjusted) } diff --git a/crates/perry-runtime/src/date/tests.rs b/crates/perry-runtime/src/date/tests.rs index 580b12d572..d51e34c4b3 100644 --- a/crates/perry-runtime/src/date/tests.rs +++ b/crates/perry-runtime/src/date/tests.rs @@ -207,6 +207,59 @@ fn test_date_parse_iso_offsetless_datetime_is_local() { assert_eq!(wall("2026-09-01 10:30 (comment)"), (2026, 9, 1, 10, 30, 0)); } +/// #9509: the ISO/space parser must consume its complete tail. V8 accepts a +/// fixed set of zone and meridiem tokens; an unknown or unseparated word is +/// Invalid Date rather than ignored. +#[test] +fn test_date_parse_iso_tail_tokens_are_consumed() { + let wall = |s: &str| { + let ts = parse_date_string(s); + assert!(!ts.is_nan(), "expected a valid date for {s:?}"); + let (y, mo, d, h, mi, sec, _) = timestamp_to_local_components((ts as i64).div_euclid(1000)); + (y, mo, d, h, mi, sec) + }; + + assert_eq!(wall("2026-09 10:30"), (2026, 9, 1, 10, 30, 0)); + assert_eq!(wall("2026-09-01 10:30"), (2026, 9, 1, 10, 30, 0)); + assert_eq!(wall("2026-09-01 10:30 PM"), (2026, 9, 1, 22, 30, 0)); + assert_eq!(wall("2026-09-01 12:30 AM"), (2026, 9, 1, 0, 30, 0)); + assert_eq!(wall("2026-09-01 12:30 PM"), (2026, 9, 1, 12, 30, 0)); + + let midnight = 1_788_220_800_000.0; + for s in ["2026-09-01 GMT", "2026-09-01 Z", "2026-09-01Z"] { + assert_eq!(parse_date_string(s), midnight, "{s:?}"); + } + assert_eq!( + parse_date_string("2026-09-01 EST"), + midnight + 5.0 * 3_600_000.0 + ); + assert_eq!( + parse_date_string("2026-09-01 PDT"), + midnight + 7.0 * 3_600_000.0 + ); + assert_eq!( + parse_date_string("2026-09-01 10:30 PM EST"), + midnight + 27.5 * 3_600_000.0 + ); + assert_eq!( + parse_date_string("2026-09-01 12:30 AM PST"), + midnight + 8.5 * 3_600_000.0 + ); + + for bad in [ + "2026-09-01 10:30GMT", + "2026-09-01 10:30EST", + "2026-09-01 10:30PM", + "2026-09-01 10:30 XYZ", + "2026-09-01 10:30:45oops", + ] { + assert!( + parse_date_string(bad).is_nan(), + "expected Invalid Date for {bad:?}" + ); + } +} + /// #9414: the numeric slash grammar node accepts as its /// implementation-defined format. Measured against /// `node --experimental-strip-types`, not derived from the spec (which diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index c78cfe5106..8e2466bd6c 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -1616,21 +1616,7 @@ pub extern "C" fn js_string_split_regex_n( let parts: Vec> = if let Some(repeat_matcher) = lookup_repeat_matcher(re) { repeat_matcher.split(&str_data, limit) } else if let Some(fre) = lookup_fancy_regex(re) { - // Fancy-regex fallback (lookbehind/backreferences): `fancy_regex` has - // no `split`, so walk non-overlapping matches and slice between them. - // (Captured-group splicing is not reproduced for this engine.) - let mut v: Vec> = Vec::new(); - let mut last = 0usize; - let mut iter = fre.find_iter(&str_data); - while let Some(Ok(m)) = iter.next() { - v.push(Some(str_data[last..m.start()].to_string())); - last = m.end(); - } - v.push(Some(str_data[last..].to_string())); - if limit > 0 && (v.len() as i64) > (limit as i64) { - v.truncate(limit as usize); - } - v + crate::string::spec_fancy_regex_split(&fre, &str_data, limit) } else { // Standard engine: the JS `RegExp.prototype[Symbol.split]` algorithm // (21.2.5.11). The `regex` crate's own `split` diverges from JS for diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index af40de4e86..e44cb45fa7 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -219,15 +219,40 @@ fn fancy_lookbehind_search() { #[test] fn fancy_lookbehind_split() { - // Zero-width lookbehind split: "a1b2c3" → ["a1","b2","c3",""]. + // RegExp.prototype[@@split] never visits q == size, so a zero-width match + // at the end does not open a trailing empty chunk. let re = js_regexp_new(make_string(r"(?<=\d)"), make_string("")); let arr = js_string_split_regex(make_string("a1b2c3"), re); unsafe { - assert_eq!((*arr).length, 4); - let first = crate::array::js_array_get_f64(arr, 0); - let sp = crate::value::js_get_string_pointer_unified(first) as *const StringHeader; - assert_eq!(string_as_str(sp), "a1"); + assert_eq!((*arr).length, 3); } + assert_eq!( + (0..3) + .map(|index| match_capture_text(arr, index)) + .collect::>(), + vec![ + Some("a1".to_string()), + Some("b2".to_string()), + Some("c3".to_string()), + ] + ); + + // Separator captures are interleaved into the result. + let re = js_regexp_new(make_string(r"((?<=a)X)"), make_string("")); + let arr = js_string_split_regex(make_string("aXbXc"), re); + unsafe { + assert_eq!((*arr).length, 3); + } + assert_eq!( + (0..3) + .map(|index| match_capture_text(arr, index)) + .collect::>(), + vec![ + Some("a".to_string()), + Some("X".to_string()), + Some("bXc".to_string()), + ] + ); } #[test] diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 21e7520372..d3ca201fad 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -118,7 +118,7 @@ mod raw; mod slice_ops; mod split; #[cfg(feature = "regex-engine")] -pub(crate) use split::spec_regex_split; +pub(crate) use split::{spec_fancy_regex_split, spec_regex_split}; #[cfg(test)] mod tests; diff --git a/crates/perry-runtime/src/string/split.rs b/crates/perry-runtime/src/string/split.rs index 007879f216..addeb94e55 100644 --- a/crates/perry-runtime/src/string/split.rs +++ b/crates/perry-runtime/src/string/split.rs @@ -100,6 +100,81 @@ pub(crate) fn spec_regex_split(regex: ®ex::Regex, s: &str, limit: i32) -> Vec out } +/// Fancy-regex implementation of the same `RegExp.prototype[Symbol.split]` +/// cursor algorithm as [`spec_regex_split`]. A plain `find_iter` is not enough: +/// the spec performs a sticky probe at each `q`, never probes `q == size`, and +/// splices every separator capture into the result. `captures_from_pos` keeps +/// the complete haystack visible to lookbehind while starting the search at +/// the spec cursor (#9429/#9438). +#[cfg(feature = "regex-engine")] +pub(crate) fn spec_fancy_regex_split( + regex: &fancy_regex::Regex, + s: &str, + limit: i32, +) -> Vec> { + let mut out: Vec> = Vec::new(); + let unbounded = limit < 0; + let push = |out: &mut Vec>, value: Option| -> bool { + out.push(value); + !unbounded && out.len() as i32 >= limit + }; + if limit == 0 { + return out; + } + + let size = s.len(); + if size == 0 { + // Empty subject: `[""]` unless the pattern itself matches empty. + if !matches!(regex.captures_from_pos(s, 0), Ok(Some(_))) { + out.push(Some(String::new())); + } + return out; + } + + let mut p = 0usize; + let mut q = 0usize; + while q < size { + let captures = match regex.captures_from_pos(s, q) { + Ok(Some(captures)) => captures, + Ok(None) | Err(_) => break, + }; + let Some(full) = captures.get(0) else { + break; + }; + if full.start() != q { + // Sticky probing found the next possible match to the right. No + // match exists between q and that position, so jump to it. + q = full.start(); + continue; + } + + let e = full.end().min(size); + if e == p { + // A zero-width match at the pending segment's start is skipped. + q = next_char_boundary(s, q); + continue; + } + if push(&mut out, Some(s[p..q].to_string())) { + return out; + } + for index in 1..captures.len() { + let group = captures + .get(index) + .map(|matched| matched.as_str().to_string()); + if push(&mut out, group) { + return out; + } + } + p = e; + q = p; + } + + if unbounded || (out.len() as i32) < limit { + out.push(Some(s[p..size].to_string())); + } + out +} + /// Split a string by a delimiter /// Returns an array of string pointers (stored as f64 bit patterns) #[no_mangle] diff --git a/test-files/test_gap_9438_fancy_regex_split.ts b/test-files/test_gap_9438_fancy_regex_split.ts new file mode 100644 index 0000000000..ce824b8160 --- /dev/null +++ b/test-files/test_gap_9438_fancy_regex_split.ts @@ -0,0 +1,31 @@ +// #9438: regex patterns that require fancy-regex used a separate split +// fallback which sliced between find_iter matches. That is not +// RegExp.prototype[@@split]: it emitted a trailing empty string for a match at +// the end and discarded every separator capture. + +function row(name: string, value: string[]): void { + console.log(name, JSON.stringify(value)); +} + +// Lookbehind and lookahead, with and without separator captures. +row("lookbehind/end", "a,b,".split(/(?<=,)/)); +row("lookbehind/capture", "aXbXc".split(/((?<=a)X)/)); +row("lookahead/middle", "abc".split(/(?=b)/)); +row("lookahead/capture", "aXbXc".split(/(X(?=b))/)); + +// A zero-width match at either boundary must not open an empty chunk. +row("start", "abc".split(/(?=a)/)); +row("end", "abc".split(/(?<=c)/)); + +// The empty-subject special case distinguishes a matching separator from a +// non-matching one. +row("empty/no-match", "".split(/(?<=a)/)); +row("empty/match", "".split(/(?=)/)); + +// Captures count toward limit just like ordinary chunks. +row("limit", "aXbXc".split(/((?<=a)X)/, 2)); + +// #9427 rewrites multiline anchors to lookaround-bearing patterns, so these +// ordinary /m spellings also exercise the fancy lane. +row("multiline-start", "a\r\nb".split(/^/gm)); +row("multiline-end", "a\r\nb".split(/$/gm)); diff --git a/test-files/test_gap_date_iso_datetime_local_9449.ts b/test-files/test_gap_date_iso_datetime_local_9449.ts index eefed56a89..e96bbd0642 100644 --- a/test-files/test_gap_date_iso_datetime_local_9449.ts +++ b/test-files/test_gap_date_iso_datetime_local_9449.ts @@ -13,7 +13,11 @@ // (which read back the very digits that were written, in any zone) and // compare the instant against a locally-constructed reference `Date` by // equality. -// Every expectation is measured against `node --experimental-strip-types`. +// #9509 extends the same fixture over the parser tail that follows those date +// and clock fields. Perry used to discard any bytes it did not understand, so +// named US zones and AM/PM were ignored while junk glued to a clock was +// accepted. Every expectation is measured against +// `node --experimental-strip-types`. // ---- absolute rows: a zone designator, or no time at all ------------------- function iso(input: string): void { @@ -30,6 +34,23 @@ iso("2026"); iso("+002026-09-01"); iso("-000001-07-01"); +// Node's implementation-defined date-only surface accepts a bare zone word, +// both separated and directly attached. Missing month/day components retain +// the same defaults as the plain ISO spellings above. +iso("2026 GMT"); +iso("2026-09 GMT"); +iso("2026-09-01 GMT"); +iso("2026-09-01 Z"); +iso("2026-09-01Z"); +iso("2026-09-01 EST"); +iso("2026-09-01 EDT"); +iso("2026-09-01 CST"); +iso("2026-09-01 CDT"); +iso("2026-09-01 MST"); +iso("2026-09-01 MDT"); +iso("2026-09-01 PST"); +iso("2026-09-01 PDT"); + // An explicit designator wins in the date-time form, exactly as before. iso("2026-09-01T10:30Z"); iso("2026-09-01T10:30:45Z"); @@ -60,6 +81,29 @@ iso("2026-09-01 10:30 GMT+05:00"); iso("2026-09-01 10:30 +0500"); iso("2026-09-01 10:30:45 +05:00"); +// V8's legacy zone-name table is fixed-offset and deliberately small. These +// rows also prove that the tail is consumed rather than merely classified. +iso("2026-09-01 10:30 EST"); +iso("2026-09-01 10:30 EDT"); +iso("2026-09-01 10:30 CST"); +iso("2026-09-01 10:30 CDT"); +iso("2026-09-01 10:30 MST"); +iso("2026-09-01 10:30 MDT"); +iso("2026-09-01 10:30 PST"); +iso("2026-09-01 10:30 PDT"); +// Meridiem and zone may occur together, in either order. +iso("2026-09-01 10:30 PM EST"); +iso("2026-09-01 10:30 EST PM"); +iso("2026-09-01 12:30 AM PST"); + +// A word must be token-separated from the clock. These used to be accepted +// because only the leading HH:MM bytes were read and the rest was discarded. +iso("2026-09-01 10:30GMT"); +iso("2026-09-01 10:30EST"); +iso("2026-09-01 10:30PM"); +iso("2026-09-01 10:30 XYZ"); +iso("2026-09-01 10:30:45oops"); + // ---- wall-clock rows: a time, no designator => LOCAL ----------------------- function local(input: string): void { const d = new Date(input); @@ -93,6 +137,17 @@ local("2026-09-01 10:30"); local("2026-09-01 10:30:45"); local("2026-09-01 10:30:45.123"); local("2026-09-01 00:00"); +local("2026-09-01 10:30"); +// The implementation-defined partial forms default the missing day/month to +// one before applying the clock. +local("2026-09 10:30"); +local("2026-09T10:30"); +local("2026T10:30"); +// AM/PM is a clock modifier, including the two 12-hour boundary cases. +local("2026-09-01 10:30 AM"); +local("2026-09-01 10:30 PM"); +local("2026-09-01 12:30 AM"); +local("2026-09-01 12:30 PM"); // A January row and a July row: if the conversion used a FIXED offset rather // than the offset in effect at that instant, one of these two would be wrong // in any zone that observes DST. @@ -115,6 +170,13 @@ sameInstant("2026-09-01T10:30:45", new Date(2026, 8, 1, 10, 30, 45, 0)); sameInstant("2026-09-01T10:30:45.123", new Date(2026, 8, 1, 10, 30, 45, 123)); sameInstant("2026-09-01 10:30", new Date(2026, 8, 1, 10, 30, 0, 0)); sameInstant("2026-09-01 10:30:45.123", new Date(2026, 8, 1, 10, 30, 45, 123)); +sameInstant("2026-09-01 10:30", new Date(2026, 8, 1, 10, 30, 0, 0)); +sameInstant("2026-09 10:30", new Date(2026, 8, 1, 10, 30, 0, 0)); +sameInstant("2026-09T10:30", new Date(2026, 8, 1, 10, 30, 0, 0)); +sameInstant("2026T10:30", new Date(2026, 0, 1, 10, 30, 0, 0)); +sameInstant("2026-09-01 10:30 PM", new Date(2026, 8, 1, 22, 30, 0, 0)); +sameInstant("2026-09-01 12:30 AM", new Date(2026, 8, 1, 0, 30, 0, 0)); +sameInstant("2026-09-01 12:30 PM", new Date(2026, 8, 1, 12, 30, 0, 0)); sameInstant("2026-09-01T00:00", new Date(2026, 8, 1, 0, 0, 0, 0)); sameInstant("2026-09-01T24:00", new Date(2026, 8, 2, 0, 0, 0, 0)); sameInstant("2026-01-15T10:30", new Date(2026, 0, 15, 10, 30, 0, 0));