diff --git a/.golangci.yaml b/.golangci.yaml index 8d6b696..7064a27 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,5 +1,5 @@ --- -# Thanks to @ccoVeille for the configuration template from +# Thanks to @ccoVeille for the configuration template from # https://github.com/ccoVeille/golangci-lint-config-examples linters: enable: @@ -9,6 +9,7 @@ linters: - gofumpt - gosimple - govet + - importas - ineffassign - staticcheck - misspell @@ -19,6 +20,12 @@ linters: - usestdlibvars linters-settings: + importas: + alias: + # prevent conflicts with first level std packages + - pkg: "[a-z][0-9a-z]+" + alias: "" + gofumpt: module-path: github.com/go-sprout/sprout misspell: diff --git a/benchmarks/comparison_test.go b/benchmarks/comparison_test.go index c9de91f..7013fff 100644 --- a/benchmarks/comparison_test.go +++ b/benchmarks/comparison_test.go @@ -9,7 +9,7 @@ import ( "sync" "testing" "text/template" - gotime "time" + "time" "github.com/Masterminds/sprig/v3" "github.com/go-sprout/sprout" @@ -29,7 +29,7 @@ import ( "github.com/go-sprout/sprout/registry/slices" "github.com/go-sprout/sprout/registry/std" "github.com/go-sprout/sprout/registry/strings" - "github.com/go-sprout/sprout/registry/time" + rtime "github.com/go-sprout/sprout/registry/time" "github.com/go-sprout/sprout/registry/uniqueid" "github.com/go-sprout/sprout/sprigin" "github.com/stretchr/testify/assert" @@ -48,8 +48,8 @@ var data = map[string]any{ "object": struct{ Name string }{"example object"}, "func": func() string { return "example function" }, "error": fmt.Errorf("example error"), - "time": gotime.Now(), - "duration": 5 * gotime.Second, + "time": time.Now(), + "duration": 5 * time.Second, "channel": make(chan any), "json": `{"foo": "bar"}`, "yaml": "foo: bar", @@ -136,7 +136,7 @@ func sproutBench(templatePath string) { semver.NewRegistry(), backward.NewRegistry(), reflect.NewRegistry(), - time.NewRegistry(), + rtime.NewRegistry(), strings.NewRegistry(), random.NewRegistry(), checksum.NewRegistry(), diff --git a/docs/registries/conversion.md b/docs/registries/conversion.md index 5846fa6..1cb0bf7 100644 --- a/docs/registries/conversion.md +++ b/docs/registries/conversion.md @@ -192,7 +192,7 @@ See more about Golang Layout on the [official documentation](https://go.dev/src/ toLocalDate converts a string to a time.Time object based on a format specification and the local timezone. -
NameValue
Signature
ToLocalDate(fmt, timezone, str string) (time.Time, error)
+
NameValue
Signature
ToLocalDate(layout, timezone, value string) (time.Time, error)
 
{% tabs %} diff --git a/docs/registries/time.md b/docs/registries/time.md index 92a6194..1b703d4 100644 --- a/docs/registries/time.md +++ b/docs/registries/time.md @@ -19,7 +19,7 @@ import "github.com/go-sprout/sprout/registry/time" The function formats a given date or the current time into a specified format string. -
NameValue
Signature
 Date(fmt string, date any) (string, error)
+
NameValue
Signature
 Date(layout string, date any) (string, error)
 
{% tabs %} @@ -34,7 +34,7 @@ The function formats a given date or the current time into a specified format st The function formats a given date or the current time into a specified format string for a specified timezone. -
NameValue
Signature
DateInZone(fmt string, date any, zone string) (string, error)
+
NameValue
Signature
DateInZone(layout string, date any, zone string) (string, error)
 
{% tabs %} @@ -121,7 +121,7 @@ The function returns the Unix epoch timestamp for a given date. The function adjusts a given date by a specified duration, returning the modified date. If the duration format is incorrect, it returns the original date without any changes, in case of must version, an error is returned. -
NameValue
Signature
DateModify(fmt string, date time.Time) (time.Time, error)
+
NameValue
Signature
DateModify(layout string, date time.Time) (time.Time, error)
 
{% tabs %} diff --git a/handler.go b/handler.go index f873b58..5757e7f 100644 --- a/handler.go +++ b/handler.go @@ -3,7 +3,7 @@ package sprout import ( "log/slog" "slices" - gostrings "strings" + "strings" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -259,7 +259,7 @@ func safeFuncName(name string) string { return "" } - var b gostrings.Builder + var b strings.Builder b.Grow(len(name) + 4) b.WriteString("safe") diff --git a/pesticide/time_test_helpers.go b/pesticide/time_test_helpers.go new file mode 100644 index 0000000..df38288 --- /dev/null +++ b/pesticide/time_test_helpers.go @@ -0,0 +1,15 @@ +package pesticide + +import ( + "testing" + "time" +) + +// ForceTimeLocal temporarily sets [time.Local] for test purpose. +func ForceTimeLocal(t *testing.T, local *time.Location) { + t.Helper() + + originalLocal := time.Local + time.Local = local + t.Cleanup(func() { time.Local = originalLocal }) +} diff --git a/registry/conversion/functions.go b/registry/conversion/functions.go index ffac425..c488813 100644 --- a/registry/conversion/functions.go +++ b/registry/conversion/functions.go @@ -170,7 +170,7 @@ func (cr *ConversionRegistry) ToString(value any) string { // // Parameters: // -// fmt string - the date format string. +// layout string - the date format string. // value string - the date string to parse. // // Returns: @@ -181,8 +181,8 @@ func (cr *ConversionRegistry) ToString(value any) string { // For an example of this function in a Go template, refer to [Sprout Documentation: toDate]. // // [Sprout Documentation: toDate]: https://docs.atom.codes/sprout/registries/conversion#todate -func (cr *ConversionRegistry) ToDate(fmt, value string) (time.Time, error) { - return time.ParseInLocation(fmt, value, time.Local) +func (cr *ConversionRegistry) ToDate(layout, value string) (time.Time, error) { + return time.ParseInLocation(layout, value, time.Local) } // ToLocalDate converts a string to a time.Time object based on a format specification @@ -190,7 +190,7 @@ func (cr *ConversionRegistry) ToDate(fmt, value string) (time.Time, error) { // // Parameters: // -// fmt string - the date format string. +// layout string - the date format string. // value string - the date string to parse. // // Returns: @@ -201,13 +201,13 @@ func (cr *ConversionRegistry) ToDate(fmt, value string) (time.Time, error) { // For an example of this function in a Go template, refer to [Sprout Documentation: toLocalDate]. // // [Sprout Documentation: toLocalDate]: https://docs.atom.codes/sprout/registries/conversion#tolocaldate -func (cr *ConversionRegistry) ToLocalDate(fmt, timezone, value string) (time.Time, error) { +func (cr *ConversionRegistry) ToLocalDate(layout, timezone, value string) (time.Time, error) { location, err := time.LoadLocation(timezone) if err != nil { return time.Time{}, err } - return time.ParseInLocation(fmt, value, location) + return time.ParseInLocation(layout, value, location) } // ToDuration converts a value to a time.Duration. diff --git a/registry/conversion/functions_test.go b/registry/conversion/functions_test.go index fb040b0..b62e83e 100644 --- a/registry/conversion/functions_test.go +++ b/registry/conversion/functions_test.go @@ -3,6 +3,9 @@ package conversion_test import ( "fmt" "testing" + "time" + + "github.com/stretchr/testify/require" "github.com/go-sprout/sprout/pesticide" "github.com/go-sprout/sprout/registry/conversion" @@ -120,34 +123,152 @@ func TestToString(t *testing.T) { } func TestToDate(t *testing.T) { - tc := []pesticide.TestCase{ - { - Name: "TestDate", - Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, - Data: map[string]any{"V": "2024-05-09"}, - ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC", - }, - { - Name: "TestDate", - Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`, - Data: map[string]any{"V": "2024-05-09 00:00:00 UTC"}, - ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC", - }, - { - Name: "TestInvalidValue", - Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, - Data: map[string]any{"V": ""}, - ExpectedErr: "cannot parse \"\" as \"2006\"", - }, - { - Name: "TestInvalidLayout", - Input: `{{$v := toDate "invalid" .V }}{{typeOf $v}}-{{$v}}`, - Data: map[string]any{"V": "2024-05-09"}, - ExpectedErr: "cannot parse \"2024-05-09\" as \"invalid\"", - }, - } + t.Run("dates with numeric timezone offset", func(t *testing.T) { + // Please refer to https://pkg.go.dev/time#Parse + // When parsing a time with a zone offset like -0700, + // if the offset corresponds to a time zone used by the current location (Local), + // then Parse uses that location and zone in the returned time. + // Otherwise it records the time as being in a fabricated location with time fixed at the given zone offset. - pesticide.RunTestCases(t, conversion.NewRegistry(), tc) + // So we have to temporarily force time.Local a known timezone + // to validate the behavior of toDate function + local, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + // temporarily force time.Local to New York + pesticide.ForceTimeLocal(t, local) + + tc := []pesticide.TestCase{ + { + Name: "date with UTC timezone", + Input: `{{$v := toDate "2006-01-02 15:04:05 -0700" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 00:00:00 +0000"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 +0000", + }, + { + Name: "date with non-UTC timezone equal to local timezone", + Input: `{{$v := toDate "2006-01-02 15:04:05 -0700" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 00:00:00 -0400"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0400 EDT", + }, + { + Name: "date with non-UTC timezone different than local", + Input: `{{$v := toDate "2006-01-02 15:04:05 -0700" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 00:00:00 -0700"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0700 -0700", + }, + } + + pesticide.RunTestCases(t, conversion.NewRegistry(), tc) + }) + + t.Run("dates with abbreviated timezone", func(t *testing.T) { + // Please refer to https://pkg.go.dev/time#Parse + // When parsing a time with a zone abbreviation like MST, + // if the zone abbreviation has a defined offset in the current location, + // then that offset is used. + // The zone abbreviation "UTC" is recognized as UTC regardless of location. + // To avoid such problems, prefer time layouts that use a numeric zone offset, or use ParseInLocation. + + // So we have to temporarily force time.Local a known timezone + // to validate the behavior of toDate function + local, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + // temporarily force time.Local to New York + pesticide.ForceTimeLocal(t, local) + + tc := []pesticide.TestCase{ + { + Name: "date with UTC timezone", + Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 00:00:00 UTC"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC", + }, + { + Name: "date with non-UTC timezone equal to local timezone", + Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 00:00:00 EDT"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0400 EDT", + }, + { + Name: "date with non-UTC timezone different than local", + Input: `{{$v := toDate "2006-01-02 15:04:05 MST" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 00:00:00 MST"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 MST", + }, + } + + pesticide.RunTestCases(t, conversion.NewRegistry(), tc) + }) + + t.Run("dates without timezone (local time should be assumed)", func(t *testing.T) { + t.Run("UTC", func(t *testing.T) { + // temporarily force time.Local to UTC + pesticide.ForceTimeLocal(t, time.UTC) + + tc := []pesticide.TestCase{ + { + Name: "short date", + Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 +0000 UTC", + }, + { + Name: "datetime ", + Input: `{{$v := toDate "2006-01-02 15:04:05" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 01:02:03"}, + ExpectedOutput: "time.Time-2024-05-09 01:02:03 +0000 UTC", + }, + } + + pesticide.RunTestCases(t, conversion.NewRegistry(), tc) + }) + + t.Run("New York timezone", func(t *testing.T) { + local, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + // temporarily force time.Local to New York + pesticide.ForceTimeLocal(t, local) + + tc := []pesticide.TestCase{ + { + Name: "short date", + Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09"}, + ExpectedOutput: "time.Time-2024-05-09 00:00:00 -0400 EDT", + }, + { + Name: "datetime ", + Input: `{{$v := toDate "2006-01-02 15:04:05" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09 01:02:03"}, + ExpectedOutput: "time.Time-2024-05-09 01:02:03 -0400 EDT", + }, + } + + pesticide.RunTestCases(t, conversion.NewRegistry(), tc) + }) + }) + + t.Run("invalid layout", func(t *testing.T) { + tc := []pesticide.TestCase{ + { + Name: "TestInvalidValue", + Input: `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": ""}, + ExpectedErr: `cannot parse "" as "2006"`, + }, + { + Name: "TestInvalidLayout", + Input: `{{$v := toDate "invalid" .V }}{{typeOf $v}}-{{$v}}`, + Data: map[string]any{"V": "2024-05-09"}, + ExpectedErr: `cannot parse "2024-05-09" as "invalid"`, + }, + } + + pesticide.RunTestCases(t, conversion.NewRegistry(), tc) + }) } func TestToLocalDate(t *testing.T) { diff --git a/registry/time/functions.go b/registry/time/functions.go index 7e0ad2f..6b846e0 100644 --- a/registry/time/functions.go +++ b/registry/time/functions.go @@ -10,8 +10,8 @@ import ( // // Parameters: // -// fmt string - the format string. -// date any - the date to format or the current time if not a date type. +// layout string - the format string. +// date any - the date to format or the current time if not a date type. // // Returns: // @@ -21,16 +21,21 @@ import ( // For an example of this function in a Go template, refer to [Sprout Documentation: date]. // // [Sprout Documentation: date]: https://docs.atom.codes/sprout/registries/time#date -func (tr *TimeRegistry) Date(fmt string, date any) (string, error) { - return tr.DateInZone(fmt, date, "Local") +func (tr *TimeRegistry) Date(layout string, date any) (string, error) { + t := computeTimeFromFormat(date) + + // compute the timezone from the date if it has one + loc := time.FixedZone(t.Zone()) + + return t.In(loc).Format(layout), nil } // DateInZone formats a given date or current time into a specified format string in a specified timezone. // // Parameters: // -// fmt string - the format string. -// date any - the date to format, in various acceptable formats. +// layout string - the format string. +// date any - the date to format, in various acceptable formats. // zone string - the timezone name. // // Returns: @@ -41,30 +46,14 @@ func (tr *TimeRegistry) Date(fmt string, date any) (string, error) { // For an example of this function in a Go template, refer to [Sprout Documentation: dateInZone]. // // [Sprout Documentation: dateInZone]: https://docs.atom.codes/sprout/registries/time#dateinzone -func (tr *TimeRegistry) DateInZone(fmt string, date any, zone string) (string, error) { - // TODO: Change signature - var t time.Time - switch date := date.(type) { - default: - t = time.Now() - case time.Time: - t = date - case *time.Time: - t = *date - case int64: - t = time.Unix(date, 0) - case int: - t = time.Unix(int64(date), 0) - case int32: - t = time.Unix(int64(date), 0) - } - +func (tr *TimeRegistry) DateInZone(layout string, date any, zone string) (string, error) { + t := computeTimeFromFormat(date) loc, err := time.LoadLocation(zone) if err != nil { - return t.In(time.UTC).Format(fmt), err + return t.In(time.UTC).Format(layout), err } - return t.In(loc).Format(fmt), nil + return t.In(loc).Format(layout), nil } // Duration converts seconds into a human-readable duration string. @@ -169,7 +158,7 @@ func (tr *TimeRegistry) UnixEpoch(date time.Time) string { // // Parameters: // -// fmt string - the duration string to add to the date, such as "2h" for two hours. +// layout string - the duration string to add to the date, such as "2h" for two hours. // date time.Time - the date to modify. // // Returns: @@ -180,8 +169,8 @@ func (tr *TimeRegistry) UnixEpoch(date time.Time) string { // For an example of this function in a Go template, refer to [Sprout Documentation: dateModify]. // // [Sprout Documentation: dateModify]: https://docs.atom.codes/sprout/registries/time#datemodify -func (tr *TimeRegistry) DateModify(fmt string, date time.Time) (time.Time, error) { - d, err := time.ParseDuration(fmt) +func (tr *TimeRegistry) DateModify(layout string, date time.Time) (time.Time, error) { + d, err := time.ParseDuration(layout) if err != nil { return time.Time{}, err } diff --git a/registry/time/functions_test.go b/registry/time/functions_test.go index 70430a3..76faef7 100644 --- a/registry/time/functions_test.go +++ b/registry/time/functions_test.go @@ -2,27 +2,103 @@ package time_test import ( "testing" - goTime "time" + "time" + + "github.com/stretchr/testify/require" "github.com/go-sprout/sprout/pesticide" - "github.com/go-sprout/sprout/registry/time" + rtime "github.com/go-sprout/sprout/registry/time" ) func TestDate(t *testing.T) { - timeTest := goTime.Date(2024, 5, 7, 15, 4, 5, 0, goTime.UTC) + t.Run("UTC", func(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + tc := []pesticide.TestCase{ + {Name: "TestTimeObject", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": timeTest}}, + {Name: "TestTimeObjectPointer", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": &timeTest}}, + } - tc := []pesticide.TestCase{ - {Name: "TestTimeObject", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": timeTest}}, - {Name: "TestTimeObjectPointer", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": &timeTest}}, - {Name: "TestTimeObjectUnix", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": timeTest.Unix()}}, - {Name: "TestTimeObjectUnixInt", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": int(timeTest.Unix())}}, - } + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) + }) + + t.Run("New York timezone", func(t *testing.T) { + local, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, local) + + tc := []pesticide.TestCase{ + {Name: "TestTimeObject", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": timeTest}}, + {Name: "TestTimeObjectPointer", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": &timeTest}}, + } + + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) + }) + + t.Run("New York offset", func(t *testing.T) { + timeTest, err := time.Parse("02 Jan 06 15:04 -0700", "07 May 24 15:04 -0400") + require.NoError(t, err) + + tc := []pesticide.TestCase{ + {Name: "TestTimeObject", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": timeTest}}, + {Name: "TestTimeObjectPointer", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": &timeTest}}, + } + + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) + }) + + t.Run("New York timezone", func(t *testing.T) { + local, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, local) + + tc := []pesticide.TestCase{ + {Name: "TestTimeObject", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": timeTest}}, + {Name: "TestTimeObjectPointer", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": &timeTest}}, + } + + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) + }) + + t.Run("unixtime", func(t *testing.T) { + t.Run("UTC", func(t *testing.T) { + // temporarily force time.Local to UTC + pesticide.ForceTimeLocal(t, time.UTC) + + // here we are simulating a [gotime.Now] + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + tc := []pesticide.TestCase{ + {Name: "TestTimeObjectUnix", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": timeTest.Unix()}}, + {Name: "TestTimeObjectUnixInt", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": int(timeTest.Unix())}}, + } + + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) + }) + + t.Run("New York timezone", func(t *testing.T) { + local, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + // temporarily force time.Local to New York + pesticide.ForceTimeLocal(t, local) + + // here we are simulating a [gotime.Now] call + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, local) + + tc := []pesticide.TestCase{ + {Name: "TestTimeObject", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": timeTest}}, + {Name: "TestTimeObjectPointer", Input: `{{ .V | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 15:04 -0400", Data: map[string]any{"V": &timeTest}}, + } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) + }) + }) } func TestDateInZone(t *testing.T) { - timeTest := goTime.Date(2024, 5, 7, 15, 4, 5, 0, goTime.UTC) + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) tc := []pesticide.TestCase{ {Name: "TestTimeObject", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": timeTest}}, @@ -30,11 +106,11 @@ func TestDateInZone(t *testing.T) { {Name: "TestTimeObjectUnix", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": timeTest.Unix()}}, {Name: "TestTimeObjectUnixInt", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": int(timeTest.Unix())}}, {Name: "TestTimeObjectUnixInt", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, ExpectedOutput: "07 May 24 15:04 +0000", Data: map[string]any{"V": int32(timeTest.Unix())}}, - {Name: "TestWithInvalidInput", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, ExpectedOutput: goTime.Now().Format("02 Jan 06 15:04 -0700"), Data: map[string]any{"V": "invalid"}}, + {Name: "TestWithInvalidInput", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, ExpectedOutput: time.Now().UTC().Format("02 Jan 06 15:04 -0700"), Data: map[string]any{"V": "invalid"}}, {Name: "TestWithInvalidZone", Input: `{{ dateInZone "02 Jan 06 15:04 -0700" .V "invalid" }}`, ExpectedErr: "unknown time zone invalid", Data: map[string]any{"V": timeTest}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestDuration(t *testing.T) { @@ -48,11 +124,11 @@ func TestDuration(t *testing.T) { {Name: "TestDurationWithInvalidType", Input: `{{ .V | duration }}`, ExpectedOutput: "0s", Data: map[string]any{"V": make(chan int)}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestDateAgo(t *testing.T) { - timeTest := goTime.Now().Add(-goTime.Hour * 24) + timeTest := time.Now().Add(-time.Hour * 24) tc := []pesticide.TestCase{ {Name: "TestTimeObject", Input: `{{ .V | dateAgo | substr 0 5 }}`, ExpectedOutput: "24h0m", Data: map[string]any{"V": timeTest}}, @@ -63,29 +139,29 @@ func TestDateAgo(t *testing.T) { {Name: "TestWithInvalidInput", Input: `{{ .V | dateAgo }}`, ExpectedOutput: "0s", Data: map[string]any{"V": "invalid"}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestNow(t *testing.T) { tc := []pesticide.TestCase{ - {Name: "TestNow", Input: `{{ now | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: goTime.Now().Format("02 Jan 06 15:04 -0700")}, + {Name: "TestNow", Input: `{{ now | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: time.Now().Format("02 Jan 06 15:04 -0700")}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestUnixEpoch(t *testing.T) { - timeTest := goTime.Date(2024, 5, 7, 15, 4, 5, 0, goTime.UTC) + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) tc := []pesticide.TestCase{ {Name: "TestUnixEpoch", Input: `{{ .V | unixEpoch }}`, ExpectedOutput: "1715094245", Data: map[string]any{"V": timeTest}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestDateModify(t *testing.T) { - timeTest := goTime.Date(2024, 5, 7, 15, 4, 5, 0, goTime.UTC) + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) tc := []pesticide.TestCase{ {Name: "AddOneHour", Input: `{{ .V | mustDateModify "1h" | date "02 Jan 06 15:04 -0700" }}`, ExpectedOutput: "07 May 24 16:04 +0000", Data: map[string]any{"V": timeTest}}, @@ -97,7 +173,7 @@ func TestDateModify(t *testing.T) { {Name: "WithInvalidInput", Input: `{{ .V | mustDateModify "zz" }}`, Data: map[string]any{"V": timeTest}, ExpectedErr: "invalid duration"}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestDurationRound(t *testing.T) { @@ -106,20 +182,20 @@ func TestDurationRound(t *testing.T) { {Name: "RoundToHour", Input: `{{ .V | durationRound }}`, ExpectedOutput: "2h", Data: map[string]any{"V": "2h5s"}}, {Name: "RoundToDay", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1d", Data: map[string]any{"V": "24h5s"}}, {Name: "RoundToMonth", Input: `{{ .V | durationRound }}`, ExpectedOutput: "3mo", Data: map[string]any{"V": "2400h5s"}}, - {Name: "RoundToMinute", Input: `{{ .V | durationRound }}`, ExpectedOutput: "45m", Data: map[string]any{"V": int64(45*goTime.Minute + 30*goTime.Second)}}, - {Name: "RoundToSecond", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1s", Data: map[string]any{"V": int64(1*goTime.Second + 500*goTime.Millisecond)}}, - {Name: "RoundaDuration", Input: `{{ .V | durationRound }}`, ExpectedOutput: "2s", Data: map[string]any{"V": 2 * goTime.Second}}, - {Name: "RoundToYear", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1y", Data: map[string]any{"V": int64(365*24*goTime.Hour + 12*goTime.Hour)}}, - {Name: "RoundToYearNegative", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1y", Data: map[string]any{"V": goTime.Now().Add(-365*24*goTime.Hour - 72*goTime.Hour)}}, + {Name: "RoundToMinute", Input: `{{ .V | durationRound }}`, ExpectedOutput: "45m", Data: map[string]any{"V": int64(45*time.Minute + 30*time.Second)}}, + {Name: "RoundToSecond", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1s", Data: map[string]any{"V": int64(1*time.Second + 500*time.Millisecond)}}, + {Name: "RoundaDuration", Input: `{{ .V | durationRound }}`, ExpectedOutput: "2s", Data: map[string]any{"V": 2 * time.Second}}, + {Name: "RoundToYear", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1y", Data: map[string]any{"V": int64(365*24*time.Hour + 12*time.Hour)}}, + {Name: "RoundToYearNegative", Input: `{{ .V | durationRound }}`, ExpectedOutput: "1y", Data: map[string]any{"V": time.Now().Add(-365*24*time.Hour - 72*time.Hour)}}, {Name: "InvalidInput", Input: `{{ .V | durationRound }}`, ExpectedOutput: "0s", Data: map[string]any{"V": make(chan int)}}, {Name: "RoundToHourNegative", Input: `{{ .V | durationRound }}`, ExpectedOutput: "-1h", Data: map[string]any{"V": "-1h01s"}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestHtmlDate(t *testing.T) { - timeTest := goTime.Date(2024, 5, 7, 15, 4, 5, 0, goTime.UTC) + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) tc := []pesticide.TestCase{ {Name: "TestTimeObject", Input: `{{ .V | htmlDate }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": timeTest}}, @@ -128,14 +204,14 @@ func TestHtmlDate(t *testing.T) { {Name: "TestTimeObjectUnixInt", Input: `{{ .V | htmlDate }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": int(timeTest.Unix())}}, {Name: "TestTimeObjectUnixInt32", Input: `{{ .V | htmlDate }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": int32(timeTest.Unix())}}, {Name: "TestZeroValue", Input: `{{ .V | htmlDate }}`, ExpectedOutput: "1970-01-01", Data: map[string]any{"V": 0}}, - {Name: "TestWithInvalidInput", Input: `{{ .V | htmlDate }}`, ExpectedOutput: goTime.Now().Format("2006-01-02"), Data: map[string]any{"V": make(chan int)}}, + {Name: "TestWithInvalidInput", Input: `{{ .V | htmlDate }}`, ExpectedOutput: time.Now().Format("2006-01-02"), Data: map[string]any{"V": make(chan int)}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } func TestHtmlDateInZone(t *testing.T) { - timeTest := goTime.Date(2024, 5, 7, 15, 4, 5, 0, goTime.UTC) + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) tc := []pesticide.TestCase{ {Name: "TestTimeObject", Input: `{{ htmlDateInZone .V "UTC" }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": timeTest}}, @@ -143,8 +219,8 @@ func TestHtmlDateInZone(t *testing.T) { {Name: "TestTimeObjectUnix", Input: `{{ htmlDateInZone .V "UTC" }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": timeTest.Unix()}}, {Name: "TestTimeObjectUnixInt", Input: `{{ htmlDateInZone .V "UTC" }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": int(timeTest.Unix())}}, {Name: "TestTimeObjectUnixInt32", Input: `{{ htmlDateInZone .V "UTC" }}`, ExpectedOutput: "2024-05-07", Data: map[string]any{"V": int32(timeTest.Unix())}}, - {Name: "TestWithInvalidInput", Input: `{{ htmlDateInZone .V "UTC" }}`, ExpectedOutput: goTime.Now().Format("2006-01-02"), Data: map[string]any{"V": make(chan int)}}, + {Name: "TestWithInvalidInput", Input: `{{ htmlDateInZone .V "UTC" }}`, ExpectedOutput: time.Now().Format("2006-01-02"), Data: map[string]any{"V": make(chan int)}}, } - pesticide.RunTestCases(t, time.NewRegistry(), tc) + pesticide.RunTestCases(t, rtime.NewRegistry(), tc) } diff --git a/registry/time/helpers.go b/registry/time/helpers.go new file mode 100644 index 0000000..36317c9 --- /dev/null +++ b/registry/time/helpers.go @@ -0,0 +1,24 @@ +package time + +import ( + "time" +) + +// computeTimeFromFormat returns a time.Time object from the given date. +func computeTimeFromFormat(date any) time.Time { + switch date := date.(type) { + case time.Time: + return date + case *time.Time: + return *date + case int64: + return time.Unix(date, 0) + case int: + return time.Unix(int64(date), 0) + case int32: + return time.Unix(int64(date), 0) + } + + // otherwise, fallback to the current time + return time.Now().Local() +} diff --git a/registry/time/helpers_test.go b/registry/time/helpers_test.go new file mode 100644 index 0000000..c5ec12f --- /dev/null +++ b/registry/time/helpers_test.go @@ -0,0 +1,58 @@ +package time + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestComputeTimeFromFormat(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + date any + want time.Time + }{ + { + name: "time.Time", + date: now, + want: now, + }, + { + name: "*time.Time", + date: &now, + want: now, + }, + { + name: "int64", + date: int64(1643723900), + want: time.Unix(1643723900, 0), + }, + { + name: "int", + date: 1643723900, + want: time.Unix(int64(1643723900), 0), + }, + { + name: "int32", + date: int32(1643723900), + want: time.Unix(int64(1643723900), 0), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeTimeFromFormat(tt.date) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("invalid format", func(t *testing.T) { + // computeTimeFromFormat generates the current time if the format is invalid + got := computeTimeFromFormat("invalid date") + + // so we can only guess the date is close to the current time + assert.Less(t, time.Since(got), 10*time.Millisecond) + }) +} diff --git a/sprigin/sprig_backward_compatibility.go b/sprigin/sprig_backward_compatibility.go index d01ec9c..511ce7e 100644 --- a/sprigin/sprig_backward_compatibility.go +++ b/sprigin/sprig_backward_compatibility.go @@ -3,7 +3,7 @@ package sprigin import ( htemplate "html/template" "log/slog" - gostrings "strings" + "strings" ttemplate "text/template" "github.com/go-sprout/sprout" @@ -23,7 +23,7 @@ import ( "github.com/go-sprout/sprout/registry/semver" "github.com/go-sprout/sprout/registry/slices" "github.com/go-sprout/sprout/registry/std" - "github.com/go-sprout/sprout/registry/strings" + rstrings "github.com/go-sprout/sprout/registry/strings" "github.com/go-sprout/sprout/registry/time" "github.com/go-sprout/sprout/registry/uniqueid" ) @@ -164,7 +164,7 @@ func (sh *SprigHandler) Build() sprout.FunctionMap { backward.NewRegistry(), reflect.NewRegistry(), time.NewRegistry(), - strings.NewRegistry(), + rstrings.NewRegistry(), random.NewRegistry(), checksum.NewRegistry(), conversion.NewRegistry(), @@ -197,7 +197,7 @@ func (sh *SprigHandler) Build() sprout.FunctionMap { // BACKWARDS COMPATIBILITY // Ensure error handling is consistent with sprig functions for funcName, fn := range sh.funcsMap { - if !gostrings.HasPrefix(funcName, "must") { + if !strings.HasPrefix(funcName, "must") { sh.funcsMap[funcName] = func(args ...any) (any, error) { out, _ := runtime.SafeCall(fn, args...) return out, nil