From b72f8ea9d80a6ad590620528bf7d9dfd4bb24992 Mon Sep 17 00:00:00 2001 From: Thomas von Dein Date: Sat, 15 Aug 2026 00:10:01 +0200 Subject: [PATCH] add expr-lang.org support (use with -e) --- cmd/config.go | 9 ++- cmd/root.go | 8 ++ go.mod | 12 +-- go.sum | 10 +++ pkg/runexpr/duration.go | 142 +++++++++++++++++++++++++++++++++++ pkg/runexpr/run.go | 63 ++++++++++++++++ pkg/runexpr/time.go | 159 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 pkg/runexpr/duration.go create mode 100644 pkg/runexpr/run.go create mode 100644 pkg/runexpr/time.go diff --git a/cmd/config.go b/cmd/config.go index 80ab7c8..705fd8b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -93,6 +93,7 @@ type Config struct { Examples bool `koanf:"examples"` Unit bool `koanf:"unit"` Format string `koanf:"format"` + Expr string `koanf:"expr"` Args []string Output io.Writer Mode int @@ -121,8 +122,9 @@ func InitConfig(output io.Writer) (*Config, error) { flagset.BoolP("diff", "d", false, "diff two timestamps") flagset.BoolP("add", "a", false, "add two timestamps") flagset.BoolP("unit", "u", false, "add unit to diff outputs") - flagset.BoolP("examples", "e", false, "show examples of supported inputs") + flagset.BoolP("examples", "E", false, "show examples of supported inputs") flagset.StringP("format", "f", "", "format to print timestamps or diffs") + flagset.StringP("expr", "e", "", "run expr program") if err := flagset.Parse(os.Args[1:]); err != nil { return nil, fmt.Errorf("failed to parse program arguments: %w", err) @@ -161,6 +163,11 @@ func InitConfig(output io.Writer) (*Config, error) { os.Exit(0) } + if conf.Expr != "" { + // ignore args etc + return conf, nil + } + // args are timestamps if len(flagset.Args()) == 0 { return nil, errors.New("no timestamp argument[s] specified.\n" + Usage) diff --git a/cmd/root.go b/cmd/root.go index 818773e..a379cb4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,8 @@ import ( "fmt" "io" "os" + + "codeberg.org/scip/ts/pkg/runexpr" ) func Die(format string, err error) int { @@ -36,6 +38,12 @@ func Main(output io.Writer) int { return Die("failed to initialize", err) } + if conf.Expr != "" { + runexpr.RunExpr(conf.Expr) + + return 0 + } + tp := NewTP(conf) if err := tp.ProcessTimestamps(); err != nil { diff --git a/go.mod b/go.mod index 01637a6..7405e92 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module codeberg.org/scip/ts -go 1.23.0 +go 1.26.0 -toolchain go1.23.5 +toolchain go1.26.4 require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/ijt/go-anytime v1.9.2 github.com/jinzhu/now v1.1.5 - github.com/knadh/koanf/providers/posflag v1.0.1 - github.com/knadh/koanf/v2 v2.3.0 + github.com/knadh/koanf/providers/posflag v1.0.2 + github.com/knadh/koanf/v2 v2.3.6 github.com/rogpeppe/go-internal v1.14.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -17,9 +17,11 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/expr-lang/expr v1.17.8 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect - github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/maps v0.1.3 // indirect + github.com/lestrrat-go/strftime v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index c0ec4c0..0be756e 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoU github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/ijt/go-anytime v1.9.2 h1:DmYgVwUiFPNR+n6c1T5P070tlGATRZG4aYNJs6XDUfU= @@ -13,10 +15,18 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4= +github.com/knadh/koanf/maps v0.1.3/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= +github.com/knadh/koanf/providers/posflag v1.0.2 h1:ky9Yqmoz0EHGfby6/gB6SUXmLs5kjxW/1ekbHRuPwIk= +github.com/knadh/koanf/providers/posflag v1.0.2/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= +github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/lestrrat-go/strftime v1.2.0 h1:8fAUYOeaJKCuLzNvUWBAo8t6I6hkFfodDTndEzJIun0= +github.com/lestrrat-go/strftime v1.2.0/go.mod h1:GtsIA/7ddIGJjEdfadUafEb1sbutvlvpMdPCMglykYo= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= diff --git a/pkg/runexpr/duration.go b/pkg/runexpr/duration.go new file mode 100644 index 0000000..77d1ede --- /dev/null +++ b/pkg/runexpr/duration.go @@ -0,0 +1,142 @@ +package runexpr + +import ( + "fmt" + "log" + "math" + "strconv" + "time" +) + +const maxDuration time.Duration = 1<<63 - 1 + +// humanDuration represents a ES duration with day support +type humanDuration struct { + days, hours, minutes, seconds int +} + +func parseDuration(duration string) time.Duration { + var seconds int64 + + for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) { + if len(match) == 3 { + durationvalue, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + log.Fatal(err) + } + + switch match[2][0] { + case 'y': + seconds += durationvalue * 31536000 + case 'd': + seconds += durationvalue * 86400 + case 'h': + seconds += durationvalue * 3600 + case 'm': + seconds += durationvalue * 60 + case 's': + seconds += durationvalue + } + } + } + + if seconds >= int64(maxDuration.Seconds()) { + log.Fatal("duration overflow, choose a smaller duration") + } + + dur := time.Duration(seconds) * time.Second + + return dur +} + +// extractDuration parses a ES duration and returns an esDuration +func extractDuration(duration string) humanDuration { + esd := humanDuration{} + + for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) { + if len(match) == 3 { + durationvalue, _ := strconv.Atoi(match[1]) + + switch match[2][0] { + case 'd': + esd.days = durationvalue + case 'h': + esd.hours = durationvalue + case 'm': + esd.minutes = durationvalue + case 's': + esd.seconds = durationvalue + } + } + } + + return esd +} + +// formatDays returns days string, unless days is larger than 365, +// then it returns years and days, e.g. 3y/34d +func formatDays(days float64) string { + if days > 365 { + years := days / 365 + days := math.Remainder(days, 365) + return fmt.Sprintf("%dy/%dd", int64(years), int64(days)) + } + + return fmt.Sprintf("%dd", int64(days)) +} + +// formatDuration converts a time.Duration to a prettier human +// readable form, like 156h6m15.095s-6d/12h/6m => 6d/12h/6m +func formatDuration(val time.Duration) string { + esd := extractDuration(val.String()) + + daysF := float64(esd.hours) / 24.0 + days := math.Floor(daysF) + hoursLeft := (daysF - days) * 24 + + switch { + case val.Minutes() < 1: + return fmt.Sprintf("%ds", int64(esd.seconds)) + + case val.Hours() < 1: + return fmt.Sprintf("%dm", int64(esd.minutes)) + + case val.Hours() < 24: + if esd.minutes == 0 { + return fmt.Sprintf("%dh", esd.hours) + } + + return fmt.Sprintf("%dh/%dm", esd.hours, int64(esd.minutes)) + + default: + if esd.minutes == 0 && hoursLeft == 0 { + return formatDays(days) + } + + return fmt.Sprintf("%s/%dh/%dm", + formatDays(days), + int64(hoursLeft), + esd.minutes, + ) + } +} + +func toYears(d time.Duration) float64 { + return d.Hours() / 24 / 365 +} + +func toDays(d time.Duration) float64 { + return d.Hours() / 24 +} + +func toHours(d time.Duration) float64 { + return d.Hours() +} + +func toMinutes(d time.Duration) float64 { + return d.Minutes() +} + +func toSeconds(d time.Duration) float64 { + return d.Seconds() +} diff --git a/pkg/runexpr/run.go b/pkg/runexpr/run.go new file mode 100644 index 0000000..d92a83c --- /dev/null +++ b/pkg/runexpr/run.go @@ -0,0 +1,63 @@ +package runexpr + +import ( + "fmt" + "time" + + "github.com/expr-lang/expr" +) + +type Env struct { + Now func() time.Time + Dur, D func(string) time.Duration + Strf func(any, ...any) string + Diff func(a, b time.Time) time.Duration + Add func(time.Time, time.Duration) time.Time + Time, T func(string, ...string) time.Time + Human func(time.Duration) string + Years, Days, Hours, Minutes, Seconds func(time.Duration) float64 + Clock func(time.Time) string + Unix func(time.Time) int64 + TZ func(time.Time, any) time.Time +} + +func RunExpr(code string) { + env := Env{ + Now: time.Now, + Dur: parseDuration, + D: parseDuration, + Strf: strfTime, + Diff: timeDiff, + Add: timeAdd, + Time: newTime, + T: newTime, + Human: formatDuration, + Years: toYears, + Days: toDays, + Hours: toHours, + Minutes: toMinutes, + Seconds: toSeconds, + Clock: toClock, + Unix: toUnix, + TZ: toTZ, + } + + options := []expr.Option{ + expr.Env(Env{}), + expr.Operator("-", "Diff"), // custom operator to diff two time.Time's + expr.Operator("+", "Add"), // custom operator to add time.Duration to a time.Time + } + + program, err := expr.Compile(code, options...) + if err != nil { + fmt.Printf("%v", err) + return + } + + output, err := expr.Run(program, env) + if err != nil { + panic(err) + } + + fmt.Println(output) +} diff --git a/pkg/runexpr/time.go b/pkg/runexpr/time.go new file mode 100644 index 0000000..269c31a --- /dev/null +++ b/pkg/runexpr/time.go @@ -0,0 +1,159 @@ +package runexpr + +import ( + "fmt" + "log" + "reflect" + "regexp" + "time" + + "github.com/lestrrat-go/strftime" +) + +var ( + // matchDuration is a regex which matches ES durations, which + // supports days (in contrast to time.Duration which does not) + matchDuration = regexp.MustCompile(`(\d+)([ydhms])`) + + timeFormats = []string{ + time.RFC3339, + time.RFC1123Z, + time.RFC1123, + time.RFC850, + time.RFC822Z, + time.RFC822, + time.RubyDate, + time.UnixDate, + time.DateTime, + time.DateOnly, + time.TimeOnly, + "02.01.2006 15:04:05", // german + "02/01/2006 15:04:05", + "20060102", + "02.01.2006", // german + "20060102150405", + "15:04:05", + "15:04", + } +) + +// setDefault# either returns the first element if item if set or +// replace. Types must match expect. +func setDefault(item []any, replace any, expect reflect.Type) any { + if reflect.TypeOf(replace) != expect { + log.Fatal("setDefault(): invalid types called") + } + + if len(item) == 1 { + return item[0] + } else { + return replace + } +} + +// strfTime formats a time.Time. Args are any,any to be able to use it in a pipe. +// +// eg: +// +// now | strftime("%D") +// strftime(now, "%D") +// "%d.%m" | strftime(now) +func strfTime(fst any, snd ...any) string { + var ts time.Time + var format string + + switch firstVal := fst.(type) { + case string: + format = firstVal + ts = setDefault(snd, time.Now(), reflect.TypeFor[time.Time]()).(time.Time) + case time.Time: + ts = firstVal + format = setDefault(snd, "%D", reflect.TypeFor[string]()).(string) + } + + if format == "" || ts.IsZero() { + log.Fatal("strftime(): invalid args") + } + + formatted, err := strftime.Format(format, ts) + if err != nil { + log.Fatal(err) + } + + return formatted +} + +// timeDiff returns the time.Duration diff between time.Time a and +// b. It syncs timezones first and exchanges a+b if a>b +func timeDiff(a, b time.Time) time.Duration { + if a.Location() != b.Location() { + b = b.In(a.Location()) + } + + if a.After(b) { + a, b = b, a + } + + return b.Sub(a) +} + +// newTime returns a new tim.Time object if ts matches one of the +// predefined formats. If format is set, use this. +func newTime(ts string, format ...string) time.Time { + if len(format) > 0 { + t, err := time.Parse(format[0], ts) + if err == nil { + return t + } + } + for _, format := range timeFormats { + t, err := time.Parse(format, ts) + if err == nil { + return t + } + } + + log.Fatal("new(): failed to parse timestamp") + + return time.Time{} +} + +// toClock returns the HH:MM:SS time part of a time.Time +func toClock(t time.Time) string { + h, m, s := t.Clock() + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +// timeAdd adds d to t +func timeAdd(t time.Time, d time.Duration) time.Time { + return t.Add(d) +} + +// toUnix returns the epoch of t +func toUnix(t time.Time) int64 { + return t.Unix() +} + +// toTZ converts t to given time zone +func toTZ(t time.Time, tz any) time.Time { + var z *time.Location + var err error + + switch val := tz.(type) { + case string: + z, err = time.LoadLocation(val) + if err != nil { + log.Fatal(err) + } + + case int: + f := fmt.Sprintf("UTC+%d", val) + if val < 0 { + f = fmt.Sprintf("UTC%d", val) + } + + z = time.FixedZone(f, val*60*60) + } + + return t.In(z) +}