Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions internal/vm/date_bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ func (vm *VM) registerDate() {
vm.consts["DateTime"] = vm.cDateTime

vm.registerDateConstructors()
vm.registerStrictDateParsers()
vm.registerDateAccessors()
vm.registerDateArithmetic()
vm.registerDateFormat()
Expand Down
153 changes: 153 additions & 0 deletions internal/vm/strict_parsers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

package vm

import (
"strconv"
stdtime "time"

date "github.com/go-ruby-date/date"

"github.com/go-embedded-ruby/ruby/internal/object"
)

// This file installs the *strict* class-method parsers of Time, Date and
// DateTime — the "require 'time'" / "require 'date'" companions of the lenient
// Time.parse / Date.parse. Unlike the heuristic parsers, each of these accepts
// ONLY its own wire format and raises on anything else: Time.iso8601 the
// ISO-8601 / XML-Schema instant, Time.rfc2822 the RFC 2822 mail date, and
// Time.httpdate the three date forms RFC 2616 permits (RFC 1123, the obsolete
// RFC 850 and asctime). The Date / DateTime siblings reuse the very same
// scanners, then rebuild the calendar value through the go-ruby-date
// constructors so the reform, JDN core and formatters stay authoritative.
//
// Each scanner is a fixed set of Go reference layouts tried in order; Go's
// time.Parse is itself strict for a given layout, so the whole grammar is
// expressed declaratively. A whole-second value is kept exact; a fractional
// second survives on Time (which carries nanoseconds) and is dropped on
// DateTime (whose constructor, like MRI's own DateTime, takes whole seconds).
//
// NOTE (deferred): Date.jisx0301 / DateTime.jisx0301 need the Japanese-era
// table (M/T/S/H/R base years), which go-ruby-date keeps unexported; they are
// left for a follow-up that exposes or mirrors that table. A zone-less
// Time.iso8601 / Time.rfc3339 value is resolved to UTC here (deterministic),
// where MRI resolves it to the machine's local zone — an intentional divergence
// on the one inherently non-deterministic case.

// Reference layouts. isoTimeLayouts require the "T"-separated wall clock (with
// an optional fractional part and either "Z" or a "±HH:MM" offset, or none);
// isoDateLayouts are the bare calendar day, dashed or compact. rfc2822Layouts
// is the RFC 2822 mail date; httpdateLayouts are the three RFC 2616 forms.
var (
isoTimeLayouts = []string{"2006-01-02T15:04:05.999999999Z07:00", "2006-01-02T15:04:05"}
isoDateLayouts = []string{"2006-01-02", "20060102"}
rfc2822Layouts = []string{"Mon, 02 Jan 2006 15:04:05 -0700"}
httpdateLayouts = []string{
"Mon, 02 Jan 2006 15:04:05 GMT", // RFC 1123
"Monday, 02-Jan-06 15:04:05 GMT", // obsolete RFC 850
"Mon Jan _2 15:04:05 2006", // obsolete asctime
}
)

// parseStrictInstant tries each reference layout in turn, returning the first
// that parses cleanly. It is the single scanner behind every strict parser.
func parseStrictInstant(s string, layouts []string) (stdtime.Time, bool) {
for _, layout := range layouts {
if t, err := stdtime.Parse(layout, s); err == nil {
return t, true
}
}
return stdtime.Time{}, false
}

// isoParseLayouts is the full ISO-8601 acceptance set for Date / DateTime, which
// (unlike Time) also admit a bare calendar day. A fresh slice is returned so the
// package-level layout slices are never mutated by append.
func isoParseLayouts() []string {
out := make([]string, 0, len(isoTimeLayouts)+len(isoDateLayouts))
out = append(out, isoTimeLayouts...)
out = append(out, isoDateLayouts...)
return out
}

// registerStrictTimeParsers installs Time.iso8601 / .xmlschema / .rfc2822 /
// .rfc822 / .httpdate — the strict class-method parsers returning a Time.
func (vm *VM) registerStrictTimeParsers() {
sm := func(name string, fn NativeFn) {
vm.cTime.smethods[name] = &Method{name: name, owner: vm.cTime, native: fn}
}
strictTime := func(layouts []string, msg string) NativeFn {
return func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
s := strArg(args[0])
t, ok := parseStrictInstant(s, layouts)
if !ok {
raise("ArgumentError", msg, strconv.Quote(s))
}
return &Time{t: t}
}
}
iso := strictTime(isoTimeLayouts, "invalid xmlschema format: %s")
sm("iso8601", iso)
sm("xmlschema", iso)
rfc := strictTime(rfc2822Layouts, "not RFC 2822 compliant date: %s")
sm("rfc2822", rfc)
sm("rfc822", rfc)
// httpdate always denotes a GMT instant, so the value is re-homed onto UTC.
sm("httpdate", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
s := strArg(args[0])
t, ok := parseStrictInstant(s, httpdateLayouts)
if !ok {
raise("ArgumentError", "not RFC 2616 compliant date: %s", strconv.Quote(s))
}
return &Time{t: t.UTC()}
})
}

// registerStrictDateParsers installs the Date and DateTime strict parsers. A
// Date value keeps only the calendar day; a DateTime keeps the wall clock and
// offset. A malformed string raises Date::Error "invalid date", exactly as MRI.
func (vm *VM) registerStrictDateParsers() {
sm := func(cls *RClass, name string, fn NativeFn) {
cls.smethods[name] = &Method{name: name, owner: cls, native: fn}
}
// dateOnly rebuilds a plain Date from the scanned calendar day.
dateOnly := func(layouts []string) NativeFn {
return func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
t, ok := parseStrictInstant(strArg(args[0]), layouts)
if !ok {
raise("Date::Error", "invalid date")
}
return payloadDate(date.NewDate(t.Year(), int(t.Month()), t.Day()))
}
}
// dateTime rebuilds a DateTime from the scanned day, wall clock and offset.
dateTime := func(layouts []string) NativeFn {
return func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
t, ok := parseStrictInstant(strArg(args[0]), layouts)
if !ok {
raise("Date::Error", "invalid date")
}
_, off := t.Zone()
return payloadDate(date.NewDateTime(t.Year(), int(t.Month()), t.Day(),
t.Hour(), t.Minute(), t.Second(), off))
}
}
// Format → layout set. rfc3339 requires the full wall clock (no bare day).
specs := []struct {
name string
layouts []string
}{
{"iso8601", isoParseLayouts()},
{"xmlschema", isoParseLayouts()},
{"rfc3339", isoTimeLayouts},
{"rfc2822", rfc2822Layouts},
{"rfc822", rfc2822Layouts},
{"httpdate", httpdateLayouts},
}
for _, s := range specs {
sm(vm.cDate, s.name, dateOnly(s.layouts))
sm(vm.cDateTime, s.name, dateTime(s.layouts))
}
}
153 changes: 153 additions & 0 deletions internal/vm/strict_parsers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

package vm_test

import "testing"

// TestStrictTimeParsers covers the strict "require 'time'" class-method parsers
// Time.iso8601 / .xmlschema / .rfc2822 / .rfc822 / .httpdate: each accepts only
// its own wire format (round-tripping through the matching instance formatter)
// and raises ArgumentError with MRI's exact message on anything else. The fixed
// instant 2026-06-21T12:34:56Z = 1782045296; the +02:00 wall clock of the same
// second is 1782038096.
func TestStrictTimeParsers(t *testing.T) {
for _, c := range []struct{ src, want string }{
// iso8601 / xmlschema: the "Z" UTC instant, a fixed offset (round-tripped),
// a fractional second (round-tripped at 3 digits) and the zone-less form
// (resolved to UTC here — the one deterministic divergence from MRI's local).
{`require "time"; p Time.iso8601("2026-06-21T12:34:56Z").to_i`, "1782045296\n"},
{`require "time"; p Time.iso8601("2026-06-21T12:34:56+02:00").iso8601`, "\"2026-06-21T12:34:56+02:00\"\n"},
{`require "time"; p Time.iso8601("2026-06-21T12:34:56.789+02:00").iso8601(3)`, "\"2026-06-21T12:34:56.789+02:00\"\n"},
{`require "time"; p Time.iso8601("2026-06-21T12:34:56").to_i`, "1782045296\n"},
{`require "time"; p Time.xmlschema("2026-06-21T12:34:56Z").xmlschema`, "\"2026-06-21T12:34:56Z\"\n"},
{`require "time"; p Time.iso8601("2026-06-21T12:34:56Z").class`, "Time\n"},
// rfc2822 / rfc822: a fixed-offset mail date (round-tripped) and the "-0000"
// unknown-zone form (which denotes UTC).
{`require "time"; p Time.rfc2822("Sun, 21 Jun 2026 12:34:56 +0200").to_i`, "1782038096\n"},
{`require "time"; p Time.rfc2822("Sun, 21 Jun 2026 12:34:56 +0200").rfc2822`, "\"Sun, 21 Jun 2026 12:34:56 +0200\"\n"},
{`require "time"; p Time.rfc822("Sun, 21 Jun 2026 12:34:56 -0000").to_i`, "1782045296\n"},
// httpdate: RFC 1123 (round-tripped), the obsolete RFC 850 and asctime forms.
{`require "time"; p Time.httpdate("Sun, 21 Jun 2026 12:34:56 GMT").to_i`, "1782045296\n"},
{`require "time"; p Time.httpdate("Sun, 21 Jun 2026 12:34:56 GMT").httpdate`, "\"Sun, 21 Jun 2026 12:34:56 GMT\"\n"},
{`require "time"; p Time.httpdate("Sunday, 21-Jun-26 12:34:56 GMT").to_i`, "1782045296\n"},
{`require "time"; p Time.httpdate("Sun Jun 21 12:34:56 2026").to_i`, "1782045296\n"},
// Malformed input → ArgumentError with the exact MRI message per format.
{`require "time"; begin; Time.iso8601("nope"); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"invalid xmlschema format: \\\"nope\\\"\"]\n"},
{`require "time"; begin; Time.xmlschema("2026-06-21 12:34"); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"invalid xmlschema format: \\\"2026-06-21 12:34\\\"\"]\n"},
{`require "time"; begin; Time.rfc2822("garbage"); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"not RFC 2822 compliant date: \\\"garbage\\\"\"]\n"},
{`require "time"; begin; Time.httpdate("2026-06-21T12:34:56Z"); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"not RFC 2616 compliant date: \\\"2026-06-21T12:34:56Z\\\"\"]\n"},
} {
if got := eval(t, c.src); got != c.want {
t.Errorf("src=%q got=%q want=%q", c.src, got, c.want)
}
}
}

// TestStrictDateParsers covers the strict Date / DateTime class-method parsers
// (iso8601 / xmlschema / rfc3339 / rfc2822 / rfc822 / httpdate). A Date keeps
// only the calendar day; a DateTime keeps the wall clock and offset; each
// round-trips through the matching instance formatter and raises Date::Error
// "invalid date" (MRI's own class and message) on a malformed string.
func TestStrictDateParsers(t *testing.T) {
for _, c := range []struct{ src, want string }{
// Date.iso8601 / .xmlschema accept a bare day (dashed or compact) and a full
// datetime (dropping the time), always returning a Date.
{`require "date"; p Date.iso8601("2026-06-21").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p Date.iso8601("2026-06-21").class`, "Date\n"},
{`require "date"; p Date.iso8601("2026-06-21T12:34:56+02:00").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p Date.iso8601("2026-06-21T12:34:56").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p Date.iso8601("20260621").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p Date.iso8601("2026-06-21").iso8601`, "\"2026-06-21\"\n"},
{`require "date"; p Date.xmlschema("2026-06-21").to_s`, "\"2026-06-21\"\n"},
// DateTime.iso8601 / .xmlschema keep the wall clock and offset; a bare day
// becomes midnight UTC.
{`require "date"; p DateTime.iso8601("2026-06-21T12:34:56+02:00").to_s`, "\"2026-06-21T12:34:56+02:00\"\n"},
{`require "date"; p DateTime.iso8601("2026-06-21T12:34:56+02:00").class`, "DateTime\n"},
{`require "date"; p DateTime.iso8601("2026-06-21").to_s`, "\"2026-06-21T00:00:00+00:00\"\n"},
{`require "date"; p DateTime.xmlschema("2026-06-21T12:34:56+02:00").xmlschema`, "\"2026-06-21T12:34:56+02:00\"\n"},
// rfc3339 requires the full wall clock (no bare day).
{`require "date"; p Date.rfc3339("2026-06-21T12:34:56+02:00").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p DateTime.rfc3339("2026-06-21T12:34:56+02:00").to_s`, "\"2026-06-21T12:34:56+02:00\"\n"},
// rfc2822 / rfc822 mail date.
{`require "date"; p Date.rfc2822("Sun, 21 Jun 2026 00:00:00 +0000").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p Date.rfc822("Sun, 21 Jun 2026 00:00:00 +0000").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p DateTime.rfc2822("Sun, 21 Jun 2026 12:34:56 +0200").to_s`, "\"2026-06-21T12:34:56+02:00\"\n"},
// httpdate (RFC 2616 / GMT).
{`require "date"; p Date.httpdate("Sun, 21 Jun 2026 00:00:00 GMT").to_s`, "\"2026-06-21\"\n"},
{`require "date"; p DateTime.httpdate("Sun, 21 Jun 2026 12:34:56 GMT").to_s`, "\"2026-06-21T12:34:56+00:00\"\n"},
// Malformed → Date::Error "invalid date" on both a Date and a DateTime parser.
{`require "date"; begin; Date.iso8601("garbage"); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"Date::Error\", \"invalid date\"]\n"},
{`require "date"; begin; DateTime.rfc2822("x"); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"Date::Error\", \"invalid date\"]\n"},
} {
if got := eval(t, c.src); got != c.want {
t.Errorf("src=%q got=%q want=%q", c.src, got, c.want)
}
}
}

// TestComparableResiduals guards the Comparable module (clamp / between? and the
// <=>-derived operators) — implemented in the prelude — against MRI 4.0.5: the
// two-argument and Range (including beginless / endless) clamp forms, the
// exclusive-range / reversed-bound / non-Range ArgumentError & TypeError, and
// the "comparison of X with Y failed" error the operators raise when <=> is nil.
func TestComparableResiduals(t *testing.T) {
// A tiny Comparable value wrapping an Integer, plus a peer that is incomparable
// with it (its <=> always returns nil) — the fixture for the operator errors.
const prelude = `
class N
include Comparable
attr_reader :v
def initialize(v); @v = v; end
def <=>(o); o.is_a?(N) ? (v <=> o.v) : nil; end
def inspect; "N(#{v})"; end
end
`
for _, c := range []struct{ src, want string }{
// clamp: two-argument, Range, beginless / endless Range, and one-sided nil.
{`p 5.clamp(1, 10)`, "5\n"},
{`p 15.clamp(1, 10)`, "10\n"},
{`p 0.clamp(1, 10)`, "1\n"},
{`p 5.clamp(1..10)`, "5\n"},
{`p 5.clamp(..3)`, "3\n"},
{`p 5.clamp(8..)`, "8\n"},
{`p 5.clamp(nil, 3)`, "3\n"},
{`p 5.clamp(8, nil)`, "8\n"},
// clamp error cases: exclusive range, reversed bounds, non-Range single arg.
{`begin; 5.clamp(1...10); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"cannot clamp with an exclusive range\"]\n"},
{`begin; 5.clamp(10, 1); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"min argument must be less than or equal to max argument\"]\n"},
{`begin; 5.clamp(1); rescue => e; p [e.class.to_s, e.message]; end`,
"[\"TypeError\", \"wrong argument type Integer (expected Range)\"]\n"},
// between?.
{`p 5.between?(1, 10)`, "true\n"},
{`p 0.between?(1, 10)`, "false\n"},
{`p 15.between?(1, 10)`, "false\n"},
// The <=>-derived operators on a mixed-in Comparable value.
{prelude + `p(N.new(1) < N.new(2))`, "true\n"},
{prelude + `p(N.new(2) <= N.new(2))`, "true\n"},
{prelude + `p(N.new(3) > N.new(2))`, "true\n"},
{prelude + `p(N.new(2) >= N.new(3))`, "false\n"},
{prelude + `p(N.new(2) == N.new(2))`, "true\n"},
// == is lenient: an incomparable pair (<=> nil) is simply unequal.
{prelude + `p(N.new(2) == 2)`, "false\n"},
// The ordering operators raise the exact rb_cmperr message when <=> is nil —
// rendering an immediate via #inspect and a non-immediate by its class name.
{prelude + `begin; N.new(2) < 5; rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"comparison of N with 5 failed\"]\n"},
{prelude + `begin; N.new(2) < "x"; rescue => e; p [e.class.to_s, e.message]; end`,
"[\"ArgumentError\", \"comparison of N with String failed\"]\n"},
} {
if got := eval(t, c.src); got != c.want {
t.Errorf("src=%q got=%q want=%q", c.src, got, c.want)
}
}
}
3 changes: 3 additions & 0 deletions internal/vm/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ func (vm *VM) registerTime() {
sm("strptime", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
return payloadTime(gotime.Parse(rubyLayout(strArg(args[1])), strArg(args[0])))
})
// The strict "require 'time'" parsers (iso8601 / xmlschema / rfc2822 / rfc822
// / httpdate), each accepting only its own wire format.
vm.registerStrictTimeParsers()

d := func(name string, fn NativeFn) { vm.cTime.define(name, fn) }
self := func(v object.Value) *Time { return v.(*Time) }
Expand Down
2 changes: 1 addition & 1 deletion scripts/conformance/rubyspec/FLOOR
Original file line number Diff line number Diff line change
@@ -1 +1 @@
12351
12352
Loading