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.
This commit is contained in:
João Fernandes
2026-02-11 11:08:39 +00:00
parent 3405e8a1d9
commit dd7970eb93
4 changed files with 127 additions and 33 deletions
+21 -16
View File
@@ -162,7 +162,7 @@ func parseLocalDateTime(b []byte) (LocalDateTime, []byte, error) {
const localDateTimeByteMinLen = 11 const localDateTimeByteMinLen = 11
if len(b) < localDateTimeByteMinLen { 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]) date, err := parseLocalDate(b[:10])
@@ -194,10 +194,10 @@ func parseLocalTime(b []byte) (LocalTime, []byte, error) {
t LocalTime t LocalTime
) )
// check if b matches to have expected format HH:MM:SS[.NNNNNN] // check if b matches to have expected format HH:MM[:SS[.NNNNNN]]
const localTimeByteLen = 8 const localTimeByteMinLen = 5
if len(b) < localTimeByteLen { if len(b) < localTimeByteMinLen {
return t, nil, unstable.NewParserError(b, "times are expected to have the format HH:MM:SS[.NNNNNN]") return t, nil, unstable.NewParserError(b, "times are expected to have the format HH:MM[:SS[.NNNNNN]]")
} }
var err error var err error
@@ -221,20 +221,25 @@ func parseLocalTime(b []byte) (LocalTime, []byte, error) {
if t.Minute > 59 { if t.Minute > 59 {
return t, nil, unstable.NewParserError(b[3:5], "minutes cannot be greater 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]) b = b[5:]
if err != nil {
return t, nil, err
}
if t.Second > 59 { if len(b) >= 1 && b[0] == ':' {
return t, nil, unstable.NewParserError(b[6:8], "seconds cannot be greater than 59") 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] == '.' { if len(b) >= 1 && b[0] == '.' {
frac := 0 frac := 0
+7
View File
@@ -67,6 +67,13 @@ func TestLocalTime_UnmarshalMarshalText(t *testing.T) {
assert.Error(t, err) 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) { func TestLocalTime_RoundTrip(t *testing.T) {
var d struct{ A toml.LocalTime } var d struct{ A toml.LocalTime }
err := toml.Unmarshal([]byte("a=20:12:01.500"), &d) err := toml.Unmarshal([]byte("a=20:12:01.500"), &d)
+8 -16
View File
@@ -420,10 +420,8 @@ func TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoLeads(t *testing.T) {
testgenInvalid(t, input) testgenInvalid(t, input)
} }
func TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoSecs(t *testing.T) { // TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoSecs is removed because
input := "# No seconds in time.\nno-secs = 1987-07-05T17:45Z\n" // TOML v1.1.0 makes seconds optional in date-time values.
testgenInvalid(t, input)
}
func TestTOMLTest_Invalid_Tests_Invalid_Datetime_NoT(t *testing.T) { 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" 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) testgenInvalid(t, input)
} }
func TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoSecs(t *testing.T) { // TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoSecs is removed because
input := "# No seconds in time.\nno-secs = 1987-07-05T17:45\n" // TOML v1.1.0 makes seconds optional in date-time values.
testgenInvalid(t, input)
}
func TestTOMLTest_Invalid_Tests_Invalid_LocalDatetime_NoT(t *testing.T) { 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" 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) testgenInvalid(t, input)
} }
func TestTOMLTest_Invalid_Tests_Invalid_LocalTime_NoSecs(t *testing.T) { // TestTOMLTest_Invalid_Tests_Invalid_LocalTime_NoSecs is removed because
input := "# No seconds in time.\nno-secs = 17:45\n" // TOML v1.1.0 makes seconds optional in time values.
testgenInvalid(t, input)
}
func TestTOMLTest_Invalid_Tests_Invalid_LocalTime_SecondOver(t *testing.T) { 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" 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) testgenInvalid(t, input)
} }
func TestTOMLTest_Invalid_Tests_Invalid_String_BasicByteEscapes(t *testing.T) { // TestTOMLTest_Invalid_Tests_Invalid_String_BasicByteEscapes is removed because
input := "answer = \"\\x33\"\n" // TOML v1.1.0 adds \xHH escape sequence support.
testgenInvalid(t, input)
}
func TestTOMLTest_Invalid_Tests_Invalid_String_BasicMultilineOutOfRangeUnicodeEscape1(t *testing.T) { func TestTOMLTest_Invalid_Tests_Invalid_String_BasicMultilineOutOfRangeUnicodeEscape1(t *testing.T) {
input := "a = \"\"\"\\UFFFFFFFF\"\"\"\n" input := "a = \"\"\"\\UFFFFFFFF\"\"\"\n"
+91 -1
View File
@@ -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", desc: "local-time missing digit",
input: `a = 12:08:0`, input: `a = 12:08:0`,
@@ -3243,7 +3333,7 @@ world'`,
{ {
desc: "bad char between minutes and seconds", desc: "bad char between minutes and seconds",
data: `a = 2021-03-30 21:312:0`, 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", desc: "invalid hour value",