From dd7970eb93444c1ae2c46ff9cb0774c71d81d3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Fernandes?= Date: Wed, 11 Feb 2026 11:08:39 +0000 Subject: [PATCH] feat: make seconds optional in datetime and time values TOML v1.1.0 allows times to be specified as HH:MM without the seconds component (previously HH:MM:SS was required). This applies to local times, local datetimes, and offset datetimes. --- decode.go | 37 ++++++++++-------- localtime_test.go | 7 ++++ toml_testgen_test.go | 24 ++++-------- unmarshaler_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 33 deletions(-) diff --git a/decode.go b/decode.go index f3f14ef..09da50a 100644 --- a/decode.go +++ b/decode.go @@ -162,7 +162,7 @@ func parseLocalDateTime(b []byte) (LocalDateTime, []byte, error) { const localDateTimeByteMinLen = 11 if len(b) < localDateTimeByteMinLen { - return dt, nil, unstable.NewParserError(b, "local datetimes are expected to have the format YYYY-MM-DDTHH:MM:SS[.NNNNNNNNN]") + return dt, nil, unstable.NewParserError(b, "local datetimes are expected to have the format YYYY-MM-DDTHH:MM[:SS[.NNNNNNNNN]]") } date, err := parseLocalDate(b[:10]) @@ -194,10 +194,10 @@ func parseLocalTime(b []byte) (LocalTime, []byte, error) { t LocalTime ) - // check if b matches to have expected format HH:MM:SS[.NNNNNN] - const localTimeByteLen = 8 - if len(b) < localTimeByteLen { - return t, nil, unstable.NewParserError(b, "times are expected to have the format HH:MM:SS[.NNNNNN]") + // check if b matches to have expected format HH:MM[:SS[.NNNNNN]] + const localTimeByteMinLen = 5 + if len(b) < localTimeByteMinLen { + return t, nil, unstable.NewParserError(b, "times are expected to have the format HH:MM[:SS[.NNNNNN]]") } var err error @@ -221,20 +221,25 @@ func parseLocalTime(b []byte) (LocalTime, []byte, error) { if t.Minute > 59 { return t, nil, unstable.NewParserError(b[3:5], "minutes cannot be greater 59") } - if b[5] != ':' { - return t, nil, unstable.NewParserError(b[5:6], "expecting colon between minutes and seconds") - } - t.Second, err = parseDecimalDigits(b[6:8]) - if err != nil { - return t, nil, err - } + b = b[5:] - if t.Second > 59 { - return t, nil, unstable.NewParserError(b[6:8], "seconds cannot be greater than 59") - } + if len(b) >= 1 && b[0] == ':' { + if len(b) < 3 { + return t, nil, unstable.NewParserError(b, "incomplete seconds") + } - b = b[8:] + t.Second, err = parseDecimalDigits(b[1:3]) + if err != nil { + return t, nil, err + } + + if t.Second > 59 { + return t, nil, unstable.NewParserError(b[1:3], "seconds cannot be greater than 59") + } + + b = b[3:] + } if len(b) >= 1 && b[0] == '.' { frac := 0 diff --git a/localtime_test.go b/localtime_test.go index 7377038..25bdada 100644 --- a/localtime_test.go +++ b/localtime_test.go @@ -67,6 +67,13 @@ func TestLocalTime_UnmarshalMarshalText(t *testing.T) { assert.Error(t, err) } +func TestLocalTime_UnmarshalText_WithoutSeconds(t *testing.T) { + d := toml.LocalTime{} + err := d.UnmarshalText([]byte("14:15")) + assert.NoError(t, err) + assert.Equal(t, toml.LocalTime{14, 15, 0, 0, 0}, d) +} + func TestLocalTime_RoundTrip(t *testing.T) { var d struct{ A toml.LocalTime } err := toml.Unmarshal([]byte("a=20:12:01.500"), &d) diff --git a/toml_testgen_test.go b/toml_testgen_test.go index 57890d4..f98f7fb 100644 --- a/toml_testgen_test.go +++ b/toml_testgen_test.go @@ -420,10 +420,8 @@ func TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoLeads(t *testing.T) { testgenInvalid(t, input) } -func TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoSecs(t *testing.T) { - input := "# No seconds in time.\nno-secs = 1987-07-05T17:45Z\n" - testgenInvalid(t, input) -} +// TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoSecs is removed because +// TOML v1.1.0 makes seconds optional in date-time values. func TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoT(t *testing.T) { input := "# No \"t\" or \"T\" between the date and time.\nno-t = 1987-07-0517:45:00Z\n" @@ -1325,10 +1323,8 @@ func TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoLeads(t *testing.T) { testgenInvalid(t, input) } -func TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoSecs(t *testing.T) { - input := "# No seconds in time.\nno-secs = 1987-07-05T17:45\n" - testgenInvalid(t, input) -} +// TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoSecs is removed because +// TOML v1.1.0 makes seconds optional in date-time values. func TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoT(t *testing.T) { input := "# No \"t\" or \"T\" between the date and time.\nno-t = 1987-07-0517:45:00\n" @@ -1360,10 +1356,8 @@ func TestTOMLTest_Invalid_Tests_Invalid_LocalTime_MinuteOver(t *testing.T) { testgenInvalid(t, input) } -func TestTOMLTest_Invalid_Tests_Invalid_LocalTime_NoSecs(t *testing.T) { - input := "# No seconds in time.\nno-secs = 17:45\n" - testgenInvalid(t, input) -} +// TestTOMLTest_Invalid_Tests_Invalid_LocalTime_NoSecs is removed because +// TOML v1.1.0 makes seconds optional in time values. func TestTOMLTest_Invalid_Tests_Invalid_LocalTime_SecondOver(t *testing.T) { input := "# time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second\n# ; rules\nd = 00:00:61\n" @@ -1515,10 +1509,8 @@ func TestTOMLTest_Invalid_Tests_Invalid_String_BadUniEsc7(t *testing.T) { testgenInvalid(t, input) } -func TestTOMLTest_Invalid_Tests_Invalid_String_BasicByteEscapes(t *testing.T) { - input := "answer = \"\\x33\"\n" - testgenInvalid(t, input) -} +// TestTOMLTest_Invalid_Tests_Invalid_String_BasicByteEscapes is removed because +// TOML v1.1.0 adds \xHH escape sequence support. func TestTOMLTest_Invalid_Tests_Invalid_String_BasicMultilineOutOfRangeUnicodeEscape1(t *testing.T) { input := "a = \"\"\"\\UFFFFFFFF\"\"\"\n" diff --git a/unmarshaler_test.go b/unmarshaler_test.go index 6ddbb32..0a9d679 100644 --- a/unmarshaler_test.go +++ b/unmarshaler_test.go @@ -600,6 +600,96 @@ foo = "bar"`, } }, }, + { + desc: "local-time without seconds", + input: `a = 14:15`, + gen: func() test { + var v map[string]interface{} + + return test{ + target: &v, + expected: &map[string]interface{}{ + "a": toml.LocalTime{Hour: 14, Minute: 15}, + }, + } + }, + }, + { + desc: "local-datetime without seconds using T", + input: `a = 2010-02-03T14:15`, + gen: func() test { + var v map[string]interface{} + + return test{ + target: &v, + expected: &map[string]interface{}{ + "a": toml.LocalDateTime{ + LocalDate: toml.LocalDate{2010, 2, 3}, + LocalTime: toml.LocalTime{Hour: 14, Minute: 15}, + }, + }, + } + }, + }, + { + desc: "local-datetime without seconds using space", + input: `a = 2010-02-03 14:15`, + gen: func() test { + var v map[string]interface{} + + return test{ + target: &v, + expected: &map[string]interface{}{ + "a": toml.LocalDateTime{ + LocalDate: toml.LocalDate{2010, 2, 3}, + LocalTime: toml.LocalTime{Hour: 14, Minute: 15}, + }, + }, + } + }, + }, + { + desc: "datetime without seconds with Z", + input: `a = 2010-02-03T14:15Z`, + gen: func() test { + var v map[string]time.Time + + return test{ + target: &v, + expected: &map[string]time.Time{ + "a": time.Date(2010, 2, 3, 14, 15, 0, 0, time.UTC), + }, + } + }, + }, + { + desc: "datetime without seconds with offset", + input: `a = 2010-02-03T14:15+05:00`, + gen: func() test { + var v map[string]time.Time + + return test{ + target: &v, + expected: &map[string]time.Time{ + "a": time.Date(2010, 2, 3, 14, 15, 0, 0, time.FixedZone("", 5*3600)), + }, + } + }, + }, + { + desc: "local-time with seconds and fractional regression", + input: `a = 14:15:30.123`, + gen: func() test { + var v map[string]interface{} + + return test{ + target: &v, + expected: &map[string]interface{}{ + "a": toml.LocalTime{Hour: 14, Minute: 15, Second: 30, Nanosecond: 123000000, Precision: 3}, + }, + } + }, + }, { desc: "local-time missing digit", input: `a = 12:08:0`, @@ -3243,7 +3333,7 @@ world'`, { desc: "bad char between minutes and seconds", data: `a = 2021-03-30 21:312:0`, - msg: `expecting colon between minutes and seconds`, + msg: `extra characters at the end of a local date time`, }, { desc: "invalid hour value",