add expr-lang.org support (use with -e)

This commit is contained in:
2026-08-15 00:10:01 +02:00
parent 1bb24327e5
commit b72f8ea9d8
7 changed files with 397 additions and 6 deletions

View File

@@ -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)

View File

@@ -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 {

12
go.mod
View File

@@ -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

10
go.sum
View File

@@ -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=

142
pkg/runexpr/duration.go Normal file
View File

@@ -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()
}

63
pkg/runexpr/run.go Normal file
View File

@@ -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)
}

159
pkg/runexpr/time.go Normal file
View File

@@ -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)
}