From b05ac0604267ed7961b124d81278bbb3756f1b33 Mon Sep 17 00:00:00 2001 From: Daniel Ostrow Date: Mon, 2 Feb 2026 17:08:31 -0500 Subject: [PATCH] Add fn:duration:parse for Go-style duration string parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a new function fn:duration:parse that parses Go-style duration strings using time.ParseDuration. The supported format is: [+-][...] Where units are: h (hours), m (minutes), s (seconds), ms (milliseconds), us/µs (microseconds), ns (nanoseconds). Examples: - "1h30m" -> 1 hour 30 minutes - "2h45m30s" -> 2 hours 45 minutes 30 seconds - "500ms" -> 500 milliseconds - "1.5h" -> 1.5 hours (decimal values supported) - "-30m" -> negative 30 minutes Note: Days ('d') are not supported by Go's time.ParseDuration. --- builtin/builtin.go | 1 + functional/functional.go | 16 ++++++++++ functional/functional_test.go | 59 +++++++++++++++++++++++++++++++++++ go.sum | 13 ++++++++ symbols/symbols.go | 4 +++ 5 files changed, 93 insertions(+) diff --git a/builtin/builtin.go b/builtin/builtin.go index 9f2045d..e6a24c8 100644 --- a/builtin/builtin.go +++ b/builtin/builtin.go @@ -133,6 +133,7 @@ var ( symbols.DurationFromHours: symbols.NewFunType(ast.DurationBound /* <= */, ast.Float64Bound), symbols.DurationFromMinutes: symbols.NewFunType(ast.DurationBound /* <= */, ast.Float64Bound), symbols.DurationFromSeconds: symbols.NewFunType(ast.DurationBound /* <= */, ast.Float64Bound), + symbols.DurationParse: symbols.NewFunType(ast.DurationBound /* <= */, ast.StringBound), } // ReducerFunctions has those built-in functions with are reducers. diff --git a/functional/functional.go b/functional/functional.go index e00fb84..5cec4d8 100644 --- a/functional/functional.go +++ b/functional/functional.go @@ -839,6 +839,22 @@ func EvalApplyFn(applyFn ast.ApplyFn, subst ast.Subst) (ast.Constant, error) { } return ast.Duration(int64(s * float64(time.Second))), nil + case symbols.DurationParse.Symbol: + if l := len(evaluatedArgs); l != 1 { + return ast.Constant{}, fmt.Errorf("fn:duration:parse expected 1 argument, got %d", l) + } + str, err := evaluatedArgs[0].StringValue() + if err != nil { + return ast.Constant{}, fmt.Errorf("fn:duration:parse argument must be string: %w", err) + } + // Go's time.ParseDuration supports: h, m, s, ms, us (or µs), ns + // Examples: "1h30m", "500ms", "-2h45m30s", "1.5h" + d, err := time.ParseDuration(str) + if err != nil { + return ast.Constant{}, fmt.Errorf("fn:duration:parse failed: %w", err) + } + return ast.Duration(int64(d)), nil + default: return EvalNumericApplyFn(applyFn, subst) } diff --git a/functional/functional_test.go b/functional/functional_test.go index ea4190a..2a78d35 100644 --- a/functional/functional_test.go +++ b/functional/functional_test.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "testing" + "time" "github.com/google/mangle/ast" "github.com/google/mangle/parse" @@ -1503,3 +1504,61 @@ func TestDurationFromUnits(t *testing.T) { }) } } + +func TestDurationParse(t *testing.T) { + tests := []struct { + name string + input string + want ast.Constant + wantErr bool + }{ + // Basic single-unit durations + {"hours", "2h", ast.Duration(2 * int64(time.Hour)), false}, + {"minutes", "30m", ast.Duration(30 * int64(time.Minute)), false}, + {"seconds", "45s", ast.Duration(45 * int64(time.Second)), false}, + {"milliseconds", "500ms", ast.Duration(500 * int64(time.Millisecond)), false}, + {"microseconds_us", "100us", ast.Duration(100 * int64(time.Microsecond)), false}, + {"microseconds_µs", "100µs", ast.Duration(100 * int64(time.Microsecond)), false}, + {"nanoseconds", "1000ns", ast.Duration(1000), false}, + + // Combined durations (Go-style) + {"hours_minutes", "1h30m", ast.Duration(int64(time.Hour) + 30*int64(time.Minute)), false}, + {"hours_minutes_seconds", "2h45m30s", ast.Duration(2*int64(time.Hour) + 45*int64(time.Minute) + 30*int64(time.Second)), false}, + {"minutes_seconds", "5m30s", ast.Duration(5*int64(time.Minute) + 30*int64(time.Second)), false}, + + // Decimal values + {"decimal_hours", "1.5h", ast.Duration(int64(1.5 * float64(time.Hour))), false}, + {"decimal_seconds", "2.5s", ast.Duration(int64(2.5 * float64(time.Second))), false}, + + // Negative durations + {"negative_hours", "-2h", ast.Duration(-2 * int64(time.Hour)), false}, + {"negative_combined", "-1h30m", ast.Duration(-int64(time.Hour) - 30*int64(time.Minute)), false}, + + // Zero + {"zero", "0s", ast.Duration(0), false}, + + // Error cases + {"invalid_unit", "5d", ast.Duration(0), true}, // 'd' (days) not supported by Go + {"invalid_format", "abc", ast.Duration(0), true}, + {"empty_string", "", ast.Duration(0), true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + expr := ast.ApplyFn{symbols.DurationParse, []ast.BaseTerm{ast.String(test.input)}} + got, err := EvalApplyFn(expr, ast.ConstSubstMap{}) + if test.wantErr { + if err == nil { + t.Errorf("EvalApplyFn(%v) expected error, got %v", expr, got) + } + return + } + if err != nil { + t.Fatalf("EvalApplyFn(%v) failed with %v", expr, err) + } + if !got.Equals(test.want) { + t.Errorf("EvalApplyFn(%v) = %v, want %v", expr, got, test.want) + } + }) + } +} diff --git a/go.sum b/go.sum index d663345..5997658 100644 --- a/go.sum +++ b/go.sum @@ -2,17 +2,30 @@ bitbucket.org/creachadair/stringset v0.0.11 h1:6Sv4CCv14Wm+OipW4f3tWOb0SQVpBDLW0 bitbucket.org/creachadair/stringset v0.0.11/go.mod h1:wh0BHewFe+j0HrzWz7KcGbSNpFzWwnpmgPRlB57U5jU= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +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/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/symbols/symbols.go b/symbols/symbols.go index e85e1f7..a7aee6a 100644 --- a/symbols/symbols.go +++ b/symbols/symbols.go @@ -254,6 +254,10 @@ var ( DurationFromMinutes = ast.FunctionSym{"fn:duration:from_minutes", 1} // DurationFromSeconds creates a duration from seconds. DurationFromSeconds = ast.FunctionSym{"fn:duration:from_seconds", 1} + // DurationParse parses a Go-style duration string like "1h30m", "500ms", "2h45m30s". + // Format: [+-][...] + // Units: h (hours), m (minutes), s (seconds), ms (milliseconds), us/µs (microseconds), ns (nanoseconds) + DurationParse = ast.FunctionSym{"fn:duration:parse", 1} // PairType is a constructor for a pair type. PairType = ast.FunctionSym{"fn:Pair", 2}