diff --git a/go.mod b/go.mod index dacb5d33..14fbe54e 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/elliotchance/orderedmap/v3 v3.1.1 github.com/fatih/color v1.19.0 github.com/fsnotify/fsnotify v1.10.1 - github.com/go-task/slim-sprig/v3 v3.0.0 + github.com/go-sprout/sprout v1.0.3 github.com/go-task/template v0.2.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-getter v1.8.6 @@ -43,6 +43,7 @@ require ( cloud.google.com/go/iam v1.13.0 // indirect cloud.google.com/go/monitoring v1.30.0 // indirect cloud.google.com/go/storage v1.64.0 // indirect + dario.cat/mergo v1.0.2 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 // indirect @@ -97,13 +98,16 @@ require ( github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/u-root/u-root v0.16.0 // indirect diff --git a/go.sum b/go.sum index 0a6726e3..1b038730 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSP cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ= @@ -136,6 +138,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -147,8 +151,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-sprout/sprout v1.0.3 h1:LLuz0D3aYazgbVTOwCVuMor3LOUVYinipXRIdjA/D+I= +github.com/go-sprout/sprout v1.0.3/go.mod h1:cFFzpnyGGry3cmN0UNCAM1f7AGok6vPVabeYQzBMBZY= github.com/go-task/template v0.2.0 h1:xW7ek0o65FUSTbKcSNeg2Vyf/I7wYXFgLUznptvviBE= github.com/go-task/template v0.2.0/go.mod h1:dbdoUb6qKnHQi1y6o+IdIrs0J4o/SEhSTA6bbzZmdtc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -198,10 +202,14 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +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= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= @@ -225,6 +233,8 @@ github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvK github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= diff --git a/internal/templater/funcs.go b/internal/templater/funcs.go index 0ffd233d..7622a53c 100644 --- a/internal/templater/funcs.go +++ b/internal/templater/funcs.go @@ -1,150 +1,98 @@ package templater import ( + "log/slog" "maps" - "math/rand/v2" - "net/url" - "os" - "path/filepath" - "runtime" - "strings" - "github.com/davecgh/go-spew/spew" - "github.com/google/uuid" - "go.yaml.in/yaml/v3" - "mvdan.cc/sh/v3/shell" - "mvdan.cc/sh/v3/syntax" + "github.com/go-sprout/sprout" + "github.com/go-sprout/sprout/registry/backward" + "github.com/go-sprout/sprout/registry/checksum" + "github.com/go-sprout/sprout/registry/conversion" + "github.com/go-sprout/sprout/registry/encoding" + "github.com/go-sprout/sprout/registry/env" + "github.com/go-sprout/sprout/registry/filesystem" + sproutmaps "github.com/go-sprout/sprout/registry/maps" + "github.com/go-sprout/sprout/registry/numeric" + "github.com/go-sprout/sprout/registry/random" + "github.com/go-sprout/sprout/registry/reflect" + "github.com/go-sprout/sprout/registry/regexp" + "github.com/go-sprout/sprout/registry/slices" + "github.com/go-sprout/sprout/registry/std" + sproutstrings "github.com/go-sprout/sprout/registry/strings" + sprouttime "github.com/go-sprout/sprout/registry/time" + "github.com/go-sprout/sprout/registry/uniqueid" - sprig "github.com/go-task/slim-sprig/v3" "github.com/go-task/template" + + "github.com/go-task/task/v3/internal/templater/taskfuncs" ) var templateFuncs template.FuncMap +// legacySprigAliases maps the function names Task exposed through slim-sprig +// onto their sprout equivalents. Only names slim-sprig actually shipped are +// listed — sprout carries a wider legacy set, but Task never exposed those and +// should not start now. +var legacySprigAliases = sprout.FunctionAliasMap{ + "dateModify": {"date_modify", "must_date_modify"}, + "dateInZone": {"date_in_zone"}, + "dateAgo": {"ago"}, + "trimAll": {"trimall"}, + "append": {"push", "mustPush"}, + "list": {"tuple"}, + "max": {"biggest"}, + "toUpper": {"upper"}, + "toLower": {"lower"}, + "toTitleCase": {"title"}, + "base64Encode": {"b64enc"}, + "base64Decode": {"b64dec"}, + "base32Encode": {"b32enc"}, + "base32Decode": {"b32dec"}, + "pathBase": {"base"}, + "pathDir": {"dir"}, + "pathExt": {"ext"}, + "pathClean": {"clean"}, + "pathIsAbs": {"isAbs"}, + "expandEnv": {"expandenv"}, + "strSlice": {"toStrings"}, + "toInt": {"int", "atoi"}, + "toInt64": {"int64"}, + "toFloat64": {"float64"}, + "toOctal": {"toDecimal"}, +} + func init() { - taskFuncs := template.FuncMap{ - "OS": goos, - "ARCH": goarch, - "numCPU": runtime.NumCPU, - "catLines": catLines, - "splitLines": splitLines, - "fromSlash": filepath.FromSlash, - "toSlash": filepath.ToSlash, - "exeExt": exeExt, - "shellQuote": shellQuote, - "splitArgs": splitArgs, - "IsSH": IsSH, // Deprecated - "joinPath": filepath.Join, - "joinEnv": joinEnv, - "joinUrl": joinUrl, - "relPath": filepath.Rel, - "absPath": filepath.Abs, - "merge": merge, - "spew": spew.Sdump, - "fromYaml": fromYaml, - "mustFromYaml": mustFromYaml, - "toYaml": toYaml, - "mustToYaml": mustToYaml, - "uuid": uuid.New, - "randIntN": rand.IntN, + handler := sprout.New( + sprout.WithLogger(slog.New(slog.DiscardHandler)), + sprout.WithRegistries( + taskfuncs.NewRegistry(), + backward.NewRegistry(), + checksum.NewRegistry(), + conversion.NewRegistry(), + encoding.NewRegistry(), + env.NewRegistry(), + filesystem.NewRegistry(), + sproutmaps.NewRegistry(), + numeric.NewRegistry(), + random.NewRegistry(), + reflect.NewRegistry(), + regexp.NewRegistry(), + slices.NewRegistry(), + std.NewRegistry(), + sproutstrings.NewRegistry(), + sprouttime.NewRegistry(), + uniqueid.NewRegistry(), + ), + ) + + for original, aliases := range legacySprigAliases { + _ = sprout.WithAlias(original, aliases...)(handler) + for _, alias := range aliases { + _ = sprout.WithNotices(sprout.NewDeprecatedNotice(alias, "please use `"+original+"` instead"))(handler) + } } - // aliases - taskFuncs["q"] = taskFuncs["shellQuote"] - - // Deprecated aliases for renamed functions. - taskFuncs["FromSlash"] = taskFuncs["fromSlash"] - taskFuncs["ToSlash"] = taskFuncs["toSlash"] - taskFuncs["ExeExt"] = taskFuncs["exeExt"] - - templateFuncs = template.FuncMap(sprig.TxtFuncMap()) - maps.Copy(templateFuncs, taskFuncs) -} - -func goos() string { - return runtime.GOOS -} - -func goarch() string { - return runtime.GOARCH -} - -func catLines(s string) string { - s = strings.ReplaceAll(s, "\r\n", " ") - return strings.ReplaceAll(s, "\n", " ") -} - -func splitLines(s string) []string { - s = strings.ReplaceAll(s, "\r\n", "\n") - return strings.Split(s, "\n") -} - -func exeExt() string { - if runtime.GOOS == "windows" { - return ".exe" - } - return "" -} - -func shellQuote(str string) (string, error) { - return syntax.Quote(str, syntax.LangBash) -} - -func splitArgs(s string) ([]string, error) { - return shell.Fields(s, nil) -} - -// Deprecated: now always returns true -func IsSH() bool { - return true -} - -func joinEnv(elem ...string) string { - return strings.Join(elem, string(os.PathListSeparator)) -} - -func joinUrl(elem ...string) (string, error) { - if len(elem) == 0 { - return "", nil - } - // Use net/url.JoinPath rather than path.Join: the latter runs path.Clean, - // which collapses the "//" in a URL scheme (e.g. "http://" -> "http:/"). - return url.JoinPath(elem[0], elem[1:]...) -} - -func merge(base map[string]any, v ...map[string]any) map[string]any { - cap := len(v) - for _, m := range v { - cap += len(m) - } - result := make(map[string]any, cap) - maps.Copy(result, base) - for _, m := range v { - maps.Copy(result, m) - } - return result -} - -func fromYaml(v string) any { - output, _ := mustFromYaml(v) - return output -} - -func mustFromYaml(v string) (any, error) { - var output any - err := yaml.Unmarshal([]byte(v), &output) - return output, err -} - -func toYaml(v any) string { - output, _ := yaml.Marshal(v) - return string(output) -} - -func mustToYaml(v any) (string, error) { - output, err := yaml.Marshal(v) - if err != nil { - return "", err - } - return string(output), nil + templateFuncs = template.FuncMap(handler.Build()) + maps.Copy(templateFuncs, taskfuncs.Overrides()) + maps.Copy(templateFuncs, sprigSignatureShims(handler)) } diff --git a/internal/templater/snapshot_test.go b/internal/templater/snapshot_test.go index c282d401..bd72777a 100644 --- a/internal/templater/snapshot_test.go +++ b/internal/templater/snapshot_test.go @@ -110,6 +110,10 @@ var funcBehaviourGroups = []struct { `{{ dict "a" "b" | hasKey "a" }}`, `{{ list 1 2 | append 3 | toJson }}`, `{{ list 2 3 | prepend 1 | toJson }}`, + `{{ dict "a" "1" "b" "2" | pick "a" | toJson }}`, + `{{ dict "a" "1" "b" "2" | omit "a" | toJson }}`, + `{{ list 1 2 3 | without 2 | toJson }}`, + `{{ list 1 2 3 4 | slice 1 3 | toJson }}`, }, }, { diff --git a/internal/templater/sprigcompat.go b/internal/templater/sprigcompat.go new file mode 100644 index 00000000..383f2577 --- /dev/null +++ b/internal/templater/sprigcompat.go @@ -0,0 +1,191 @@ +package templater + +import ( + "fmt" + "reflect" + + "github.com/go-sprout/sprout" + sproutmaps "github.com/go-sprout/sprout/registry/maps" + "github.com/go-sprout/sprout/registry/slices" +) + +// sprig passed the map or list to operate on as the *first* argument; sprout +// passes it *last* so that the function can be piped into. Taskfiles written +// against slim-sprig use the old order, and feeding them to sprout produces an +// opaque type error rather than a useful message. +// +// The wrappers below accept both orders, detecting the old one by the type of +// the first argument — the two positions never hold the same kind, so the test +// is unambiguous in every realistic case. When the old order is detected the +// call still succeeds, and a deprecation warning is logged. +// +// Only the ten functions whose argument order actually changed are wrapped. +// `dig`, `has`, `chunk` and `merge` kept theirs. + +func sprigSignatureShims(handler sprout.Handler) sprout.FunctionMap { + m := sproutmaps.NewRegistry() + _ = m.LinkHandler(handler) + s := slices.NewRegistry() + _ = s.LinkHandler(handler) + + warn := func(name, oldSig, newSig string) { + handler.Logger(). + With("function", name, "notice", "deprecated"). + Warn(fmt.Sprintf("Template function `%s` was called with the deprecated slim-sprig argument order `%s`; please use `%s` instead.", name, oldSig, newSig)) + } + + return sprout.FunctionMap{ + "get": func(args ...any) (any, error) { + key, dict, err := mapArgs("get", `{{ get $dict "key" }}`, `{{ $dict | get "key" }}`, warn, args) + if err != nil { + return nil, err + } + return m.Get(key, dict) + }, + "hasKey": func(args ...any) (any, error) { + key, dict, err := mapArgs("hasKey", `{{ hasKey $dict "key" }}`, `{{ $dict | hasKey "key" }}`, warn, args) + if err != nil { + return nil, err + } + return m.HasKey(key, dict) + }, + "unset": func(args ...any) (any, error) { + key, dict, err := mapArgs("unset", `{{ unset $dict "key" }}`, `{{ $dict | unset "key" }}`, warn, args) + if err != nil { + return nil, err + } + return m.Unset(key, dict) + }, + "set": func(args ...any) (any, error) { + if len(args) != 3 { + return nil, fmt.Errorf("set requires exactly 3 arguments, got %d", len(args)) + } + if isMap(args[0]) { + warn("set", `{{ set $dict "key" "value" }}`, `{{ $dict | set "key" "value" }}`) + args = []any{args[1], args[2], args[0]} + } + key, ok := args[0].(string) + if !ok { + return nil, fmt.Errorf("set: key must be a string, got %T", args[0]) + } + dict, ok := args[2].(map[string]any) + if !ok { + return nil, fmt.Errorf("set: last argument must be a map, got %T", args[2]) + } + return m.Set(key, args[1], dict) + }, + // sprout's `dig` dropped sprig's default-value argument entirely: + // it is `dig(keys..., dict)` where sprig had `dig(keys..., default, + // dict)`. Both take strings in that position, so the two forms cannot + // be told apart by type — Task keeps sprig's meaning, since that is + // what every existing Taskfile was written against. + "dig": func(args ...any) (any, error) { + if len(args) < 3 { + return m.Dig(args...) + } + dict, ok := args[len(args)-1].(map[string]any) + if !ok { + return nil, fmt.Errorf("dig: last argument must be a map, got %T", args[len(args)-1]) + } + fallback := args[len(args)-2] + lookup := make([]any, 0, len(args)-1) + lookup = append(lookup, args[:len(args)-2]...) + out, err := m.Dig(append(lookup, dict)...) + if err != nil || out == nil { + return fallback, nil + } + return out, nil + }, + "pick": func(args ...any) (any, error) { + return m.Pick(rotateFirstToLast("pick", `{{ pick $dict "key" }}`, `{{ $dict | pick "key" }}`, warn, isMap, args)...) + }, + "omit": func(args ...any) (any, error) { + return m.Omit(rotateFirstToLast("omit", `{{ omit $dict "key" }}`, `{{ $dict | omit "key" }}`, warn, isMap, args)...) + }, + "append": func(args ...any) (any, error) { + v, list, err := listArgs("append", `{{ append $list "value" }}`, `{{ $list | append "value" }}`, warn, args) + if err != nil { + return nil, err + } + return s.Append(v, list) + }, + "prepend": func(args ...any) (any, error) { + v, list, err := listArgs("prepend", `{{ prepend $list "value" }}`, `{{ $list | prepend "value" }}`, warn, args) + if err != nil { + return nil, err + } + return s.Prepend(v, list) + }, + "without": func(args ...any) (any, error) { + return s.Without(rotateFirstToLast("without", `{{ without $list "value" }}`, `{{ $list | without "value" }}`, warn, isList, args)...) + }, + "slice": func(args ...any) (any, error) { + return s.Slice(rotateFirstToLast("slice", `{{ slice $list 1 3 }}`, `{{ $list | slice 1 3 }}`, warn, isList, args)...) + }, + } +} + +type warnFunc func(name, oldSig, newSig string) + +// mapArgs resolves the (key, dict) pair of a two-argument map function written +// in either order. +func mapArgs(name, oldSig, newSig string, warn warnFunc, args []any) (string, map[string]any, error) { + if len(args) != 2 { + return "", nil, fmt.Errorf("%s requires exactly 2 arguments, got %d", name, len(args)) + } + if isMap(args[0]) { + warn(name, oldSig, newSig) + args = []any{args[1], args[0]} + } + key, ok := args[0].(string) + if !ok { + return "", nil, fmt.Errorf("%s: key must be a string, got %T", name, args[0]) + } + dict, ok := args[1].(map[string]any) + if !ok { + return "", nil, fmt.Errorf("%s: last argument must be a map, got %T", name, args[1]) + } + return key, dict, nil +} + +// listArgs resolves the (value, list) pair of a two-argument slice function +// written in either order. +func listArgs(name, oldSig, newSig string, warn warnFunc, args []any) (any, any, error) { + if len(args) != 2 { + return nil, nil, fmt.Errorf("%s requires exactly 2 arguments, got %d", name, len(args)) + } + if isList(args[0]) && !isList(args[1]) { + warn(name, oldSig, newSig) + return args[1], args[0], nil + } + return args[0], args[1], nil +} + +// rotateFirstToLast moves a leading target argument to the end, which is where +// sprout's variadic functions expect it. +func rotateFirstToLast(name, oldSig, newSig string, warn warnFunc, isTarget func(any) bool, args []any) []any { + if len(args) < 2 || !isTarget(args[0]) || isTarget(args[len(args)-1]) { + return args + } + warn(name, oldSig, newSig) + rotated := make([]any, 0, len(args)) + rotated = append(rotated, args[1:]...) + return append(rotated, args[0]) +} + +func isMap(v any) bool { + _, ok := v.(map[string]any) + return ok +} + +func isList(v any) bool { + if v == nil { + return false + } + switch reflect.TypeOf(v).Kind() { + case reflect.Slice, reflect.Array: + return true + default: + return false + } +} diff --git a/internal/templater/sprigcompat_test.go b/internal/templater/sprigcompat_test.go new file mode 100644 index 00000000..8a3a3cab --- /dev/null +++ b/internal/templater/sprigcompat_test.go @@ -0,0 +1,125 @@ +package templater + +import ( + "testing" + + "github.com/go-task/task/v3/taskfile/ast" +) + +// render evaluates a single template expression and returns its output, or the +// error the template engine raised. +func render(t *testing.T, expr string) (string, error) { + t.Helper() + cache := &Cache{Vars: ast.NewVars()} + got := ReplaceWithExtra(expr, cache, nil) + if err := cache.Err(); err != nil { + return "", err + } + return got, nil +} + +// The ten functions whose argument order changed between slim-sprig and sprout +// must accept both, so that existing Taskfiles keep working while new ones can +// use the pipe form. +func TestSprigSignatureShims(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + sprig string + sprout string + want string + }{ + {"get", `{{ get (dict "a" "b") "a" }}`, `{{ dict "a" "b" | get "a" }}`, "b"}, + {"hasKey", `{{ hasKey (dict "a" "b") "a" }}`, `{{ dict "a" "b" | hasKey "a" }}`, "true"}, + {"unset", `{{ unset (dict "a" "b" "c" "d") "a" | toJson }}`, `{{ dict "a" "b" "c" "d" | unset "a" | toJson }}`, `{"c":"d"}`}, + {"set", `{{ set (dict "a" "b") "c" "d" | toJson }}`, `{{ dict "a" "b" | set "c" "d" | toJson }}`, `{"a":"b","c":"d"}`}, + {"pick", `{{ pick (dict "a" "1" "b" "2") "a" | toJson }}`, `{{ dict "a" "1" "b" "2" | pick "a" | toJson }}`, `{"a":"1"}`}, + {"omit", `{{ omit (dict "a" "1" "b" "2") "a" | toJson }}`, `{{ dict "a" "1" "b" "2" | omit "a" | toJson }}`, `{"b":"2"}`}, + {"append", `{{ append (list 1 2) 3 | toJson }}`, `{{ list 1 2 | append 3 | toJson }}`, "[1,2,3]"}, + {"prepend", `{{ prepend (list 2 3) 1 | toJson }}`, `{{ list 2 3 | prepend 1 | toJson }}`, "[1,2,3]"}, + {"without", `{{ without (list 1 2 3) 2 | toJson }}`, `{{ list 1 2 3 | without 2 | toJson }}`, "[1,3]"}, + {"slice", `{{ slice (list 1 2 3 4) 1 3 | toJson }}`, `{{ list 1 2 3 4 | slice 1 3 | toJson }}`, "[2,3]"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + for order, expr := range map[string]string{"slim-sprig": tt.sprig, "sprout": tt.sprout} { + got, err := render(t, expr) + if err != nil { + t.Fatalf("%s order: %s unexpected error: %v", order, expr, err) + } + if got != tt.want { + t.Errorf("%s order: %s = %q; want %q", order, expr, got, tt.want) + } + } + }) + } +} + +// sprout dropped sprig's default-value argument on dig. Task keeps it, since +// the two forms are indistinguishable by type and every existing Taskfile was +// written against sprig's. +func TestDigKeepsSprigDefault(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + expr string + want string + }{ + {"path found", `{{ dig "a" "b" "fallback" (dict "a" (dict "b" "found")) }}`, "found"}, + {"key missing", `{{ dig "a" "missing" "fallback" (dict "a" (dict "b" "found")) }}`, "fallback"}, + {"root missing", `{{ dig "nope" "fallback" (dict "a" 1) }}`, "fallback"}, + {"leaf is not a dict", `{{ dig "a" "b" "c" "fallback" (dict "a" (dict "b" "found")) }}`, "fallback"}, + // sprout splits keys on dots, which sprig did not. Kept: it is an + // improvement and no slim-sprig key could contain a dot anyway. + {"dotted path", `{{ dig "a.b" "fallback" (dict "a" (dict "b" "found")) }}`, "found"}, + // Two arguments is unambiguously sprout's no-default form. + {"no default", `{{ dig "a" (dict "a" "found") }}`, "found"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := render(t, tt.expr) + if err != nil { + t.Fatalf("%s unexpected error: %v", tt.expr, err) + } + if got != tt.want { + t.Errorf("%s = %q; want %q", tt.expr, got, tt.want) + } + }) + } +} + +// Task's own functions must keep winning over the sprout functions and aliases +// that share their name, since their semantics differ. +func TestTaskFuncsShadowSprout(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + expr string + want string + }{ + // sprout's merge is a deep merge that keeps the destination value; + // Task's is a shallow merge where the last map wins. + {"merge overwrites", `{{ merge (dict "a" 1) (dict "a" 0) | toJson }}`, `{"a":0}`}, + // sprout aliases fromYaml/toYaml onto fromYAML/toYAML, which raise + // errors where Task's swallow them. + {"fromYaml swallows errors", `{{ fromYaml "a: :" | toJson }}`, "null"}, + {"toYaml", `{{ toYaml (dict "a" 1) }}`, "a: 1\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := render(t, tt.expr) + if err != nil { + t.Fatalf("%s unexpected error: %v", tt.expr, err) + } + if got != tt.want { + t.Errorf("%s = %q; want %q", tt.expr, got, tt.want) + } + }) + } +} diff --git a/internal/templater/taskfuncs/funcs.go b/internal/templater/taskfuncs/funcs.go new file mode 100644 index 00000000..3db36b9d --- /dev/null +++ b/internal/templater/taskfuncs/funcs.go @@ -0,0 +1,105 @@ +// Package taskfuncs provides the template functions that Task adds on top of +// the generic ones supplied by sprout. +package taskfuncs + +import ( + "maps" + "net/url" + "os" + "runtime" + "strings" + + "go.yaml.in/yaml/v3" + "mvdan.cc/sh/v3/shell" + "mvdan.cc/sh/v3/syntax" +) + +func OS() string { + return runtime.GOOS +} + +func Arch() string { + return runtime.GOARCH +} + +func CatLines(s string) string { + s = strings.ReplaceAll(s, "\r\n", " ") + return strings.ReplaceAll(s, "\n", " ") +} + +func SplitLines(s string) []string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.Split(s, "\n") +} + +func ExeExt() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +func ShellQuote(str string) (string, error) { + return syntax.Quote(str, syntax.LangBash) +} + +func SplitArgs(s string) ([]string, error) { + return shell.Fields(s, nil) +} + +// Deprecated: now always returns true +func IsSH() bool { + return true +} + +func JoinEnv(elem ...string) string { + return strings.Join(elem, string(os.PathListSeparator)) +} + +func JoinURL(elem ...string) (string, error) { + if len(elem) == 0 { + return "", nil + } + // Use net/url.JoinPath rather than path.Join: the latter runs path.Clean, + // which collapses the "//" in a URL scheme (e.g. "http://" -> "http:/"). + return url.JoinPath(elem[0], elem[1:]...) +} + +// Merge shallow-merges maps, later keys winning. It shadows sprout's `merge`, +// which deep-merges and keeps the destination value on conflict. +func Merge(base map[string]any, v ...map[string]any) map[string]any { + cap := len(v) + for _, m := range v { + cap += len(m) + } + result := make(map[string]any, cap) + maps.Copy(result, base) + for _, m := range v { + maps.Copy(result, m) + } + return result +} + +func FromYAML(v string) any { + output, _ := MustFromYAML(v) + return output +} + +func MustFromYAML(v string) (any, error) { + var output any + err := yaml.Unmarshal([]byte(v), &output) + return output, err +} + +func ToYAML(v any) string { + output, _ := yaml.Marshal(v) + return string(output) +} + +func MustToYAML(v any) (string, error) { + output, err := yaml.Marshal(v) + if err != nil { + return "", err + } + return string(output), nil +} diff --git a/internal/templater/funcs_test.go b/internal/templater/taskfuncs/funcs_test.go similarity index 80% rename from internal/templater/funcs_test.go rename to internal/templater/taskfuncs/funcs_test.go index e425b972..46bdcee9 100644 --- a/internal/templater/funcs_test.go +++ b/internal/templater/taskfuncs/funcs_test.go @@ -1,4 +1,4 @@ -package templater +package taskfuncs import "testing" @@ -19,12 +19,12 @@ func TestJoinUrl(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := joinUrl(tt.elem...) + got, err := JoinURL(tt.elem...) if err != nil { - t.Fatalf("joinUrl(%q) unexpected error: %v", tt.elem, err) + t.Fatalf("JoinURL(%q) unexpected error: %v", tt.elem, err) } if got != tt.want { - t.Errorf("joinUrl(%q) = %q; want %q", tt.elem, got, tt.want) + t.Errorf("JoinURL(%q) = %q; want %q", tt.elem, got, tt.want) } }) } diff --git a/internal/templater/taskfuncs/registry.go b/internal/templater/taskfuncs/registry.go new file mode 100644 index 00000000..1317b6a8 --- /dev/null +++ b/internal/templater/taskfuncs/registry.go @@ -0,0 +1,98 @@ +package taskfuncs + +import ( + "math/rand/v2" + "path/filepath" + "runtime" + + "github.com/davecgh/go-spew/spew" + "github.com/go-sprout/sprout" + "github.com/google/uuid" +) + +// Registry exposes Task's own template functions to a sprout handler. +type Registry struct { + handler sprout.Handler +} + +func NewRegistry() *Registry { + return &Registry{} +} + +func (r *Registry) UID() string { + return "go-task/task.taskfuncs" +} + +func (r *Registry) LinkHandler(fh sprout.Handler) error { + r.handler = fh + return nil +} + +func (r *Registry) RegisterFunctions(fnMap sprout.FunctionMap) error { + for name, fn := range functions() { + sprout.AddFunction(fnMap, name, fn) + } + return nil +} + +func (r *Registry) RegisterAliases(aliasMap sprout.FunctionAliasMap) error { + sprout.AddAlias(aliasMap, "shellQuote", "q") + // Deprecated aliases for renamed functions. + sprout.AddAlias(aliasMap, "fromSlash", "FromSlash") + sprout.AddAlias(aliasMap, "toSlash", "ToSlash") + sprout.AddAlias(aliasMap, "exeExt", "ExeExt") + return nil +} + +func (r *Registry) RegisterNotices(notices *[]sprout.FunctionNotice) error { + sprout.AddNotice(notices, sprout.NewDeprecatedNotice("IsSH", "it always returns true and can be removed from your templates")) + sprout.AddNotice(notices, sprout.NewDeprecatedNotice("FromSlash", "please use `fromSlash` instead")) + sprout.AddNotice(notices, sprout.NewDeprecatedNotice("ToSlash", "please use `toSlash` instead")) + sprout.AddNotice(notices, sprout.NewDeprecatedNotice("ExeExt", "please use `exeExt` instead")) + return nil +} + +// Overrides returns the functions that must be re-applied after the handler is +// built. sprout owns `merge`, and its encoding registry aliases `fromYAML` +// and `toYAML` onto the camelCase names Task already uses — and AssignAliases +// overwrites unconditionally, so registration order alone cannot protect them. +// Their semantics differ from Task's (deep merge, and errors raised instead of +// swallowed), hence Task's implementations win. +func Overrides() sprout.FunctionMap { + return sprout.FunctionMap{ + "merge": Merge, + "fromYaml": FromYAML, + "mustFromYaml": MustFromYAML, + "toYaml": ToYAML, + "mustToYaml": MustToYAML, + } +} + +func functions() sprout.FunctionMap { + return sprout.FunctionMap{ + "OS": OS, + "ARCH": Arch, + "numCPU": runtime.NumCPU, + "catLines": CatLines, + "splitLines": SplitLines, + "fromSlash": filepath.FromSlash, + "toSlash": filepath.ToSlash, + "exeExt": ExeExt, + "shellQuote": ShellQuote, + "splitArgs": SplitArgs, + "IsSH": IsSH, // Deprecated + "joinPath": filepath.Join, + "joinEnv": JoinEnv, + "joinUrl": JoinURL, + "relPath": filepath.Rel, + "absPath": filepath.Abs, + "merge": Merge, + "spew": spew.Sdump, + "fromYaml": FromYAML, + "mustFromYaml": MustFromYAML, + "toYaml": ToYAML, + "mustToYaml": MustToYAML, + "uuid": uuid.New, + "randIntN": rand.IntN, + } +} diff --git a/internal/templater/testdata/func_behaviour.golden b/internal/templater/testdata/func_behaviour.golden index 6f98702e..b76cfc21 100644 --- a/internal/templater/testdata/func_behaviour.golden +++ b/internal/templater/testdata/func_behaviour.golden @@ -26,17 +26,25 @@ ## argument order — sprout order (target last) {{ dict "a" "b" | get "a" }} - ! at <"a">: can't handle "a" for arg of type map[string]interface {} + = b {{ dict "a" "b" | set "c" "d" | toJson }} - ! at <"c">: can't handle "c" for arg of type map[string]interface {} + = {"a":"b","c":"d"} {{ dict "a" "b" "c" "d" | unset "a" | toJson }} - ! at <"a">: can't handle "a" for arg of type map[string]interface {} + = {"c":"d"} {{ dict "a" "b" | hasKey "a" }} - ! at <"a">: can't handle "a" for arg of type map[string]interface {} + = true {{ list 1 2 | append 3 | toJson }} - ! at : error calling append: Cannot push on type int + = [1,2,3] {{ list 2 3 | prepend 1 | toJson }} - ! at : error calling prepend: Cannot prepend on type int + = [1,2,3] +{{ dict "a" "1" "b" "2" | pick "a" | toJson }} + = {"a":"1"} +{{ dict "a" "1" "b" "2" | omit "a" | toJson }} + = {"b":"2"} +{{ list 1 2 3 | without 2 | toJson }} + = [1,3] +{{ list 1 2 3 4 | slice 1 3 | toJson }} + = [2,3] ## maps — unchanged signatures @@ -53,7 +61,7 @@ {{ dig "a" "missing" "fallback" (dict "a" (dict "b" "found")) }} = fallback {{ dig "a.b" "fallback" (dict "a" (dict "b" "found")) }} - = fallback + = found {{ merge (dict "a" 1) (dict "b" 2) | toJson }} = {"a":1,"b":2} {{ merge (dict "a" 1) (dict "a" 0) | toJson }} @@ -119,7 +127,7 @@ {{ title "hello world" }} = Hello World {{ title "hello wORLD" }} - = Hello WORLD + = Hello World {{ trunc 3 "foobar" }} = foo {{ trunc -3 "foobar" }} @@ -127,7 +135,7 @@ {{ substr 0 3 "foobar" }} = foo {{ substr 0 -3 "foobar" }} - = foobar + = foo {{ repeat 3 "x" }} = xxx {{ contains "oo" "foobar" }} @@ -190,7 +198,7 @@ {{ atoi "42" }} = 42 {{ atoi "abc" }} - = 0 + ! at : error calling atoi: unable to cast "abc" of type string to int: strconv.ParseInt: parsing "abc": invalid syntax {{ int "42" }} = 42 {{ int64 "42" }} @@ -232,9 +240,9 @@ {{ fromJson "{\"a\":1}" | toJson }} = {"a":1} {{ fromJson "not json" | toJson }} - = null + ! at : error calling fromJson: json decode error: invalid character 'o' in literal null (expecting 'u') {{ mustFromJson "not json" | toJson }} - ! at : error calling mustFromJson: invalid character 'o' in literal null (expecting 'u') + ! at : error calling mustFromJson: json decode error: invalid character 'o' in literal null (expecting 'u') {{ toYaml (dict "a" 1) }} = a: 1\n {{ fromYaml "a: 1" | toJson }} @@ -323,7 +331,7 @@ {{ unixEpoch (toDate "2006-01-02" "2020-01-01") }} = 1577836800 {{ duration 90 }} - = 0s + = 1m30s {{ durationRound "1h35m30s" }} = 1h {{ htmlDate 0 }} diff --git a/internal/templater/testdata/func_names.golden b/internal/templater/testdata/func_names.golden index a756e4bd..c155129b 100644 --- a/internal/templater/testdata/func_names.golden +++ b/internal/templater/testdata/func_names.golden @@ -7,6 +7,9 @@ ToSlash absPath add add1 +add1f +addf +adler32Sum adler32sum ago all @@ -18,7 +21,12 @@ b32enc b64dec b64enc base +base32Decode +base32Encode +base64Decode +base64Encode biggest +capitalize cat catLines ceil @@ -29,33 +37,44 @@ compact concat contains date +dateAgo dateInZone dateModify date_in_zone date_modify +deepCopy deepEqual default dict dig dir div +divf duration durationRound +ellipsis +ellipsisBoth empty env exeExt +expandEnv expandenv ext fail first +flatten +flattenDepth float64 floor +fromJSON fromJson fromSlash +fromYAML fromYaml get getHostByName has +hasField hasKey hasPrefix hasSuffix @@ -64,6 +83,7 @@ htmlDate htmlDateInZone indent initial +initials int int64 isAbs @@ -79,21 +99,28 @@ list lower max maxf +md5Sum +md5sum merge +mergeOverwrite min minf mod mul +mulf mustAppend mustChunk mustCompact mustDateModify +mustDeepCopy mustFirst mustFromJson mustFromYaml mustHas mustInitial mustLast +mustMerge +mustMergeOverwrite mustPrepend mustPush mustRegexFind @@ -114,6 +141,7 @@ mustUniq mustWithout must_date_modify nindent +nospace now numCPU omit @@ -122,6 +150,11 @@ osClean osDir osExt osIsAbs +pathBase +pathClean +pathDir +pathExt +pathIsAbs pick pluck plural @@ -129,10 +162,19 @@ prepend push q quote +randAlpha +randAlphaNum +randAscii +randBytes randInt randIntN +randNumeric regexFind regexFindAll +regexFindAllGroups +regexFindAllNamed +regexFindGroups +regexFindNamed regexMatch regexQuoteMeta regexReplaceAll @@ -146,9 +188,13 @@ reverse round seq set +sha1Sum sha1sum +sha256Sum sha256sum +sha512Sum shellQuote +shuffle slice sortAlpha spew @@ -158,18 +204,45 @@ splitLines splitList splitn squote +strSlice sub +subf substr +swapCase ternary title +toBool +toCamelCase +toConstantCase toDate toDecimal +toDotCase +toDuration +toFloat64 +toIndentYAML +toInt +toInt64 +toJSON toJson +toKebabCase +toLocalDate +toLower +toOctal +toPascalCase +toPathCase +toPrettyJSON toPrettyJson +toRawJSON toRawJson toSlash +toSnakeCase toString toStrings +toTitleCase +toUint +toUint64 +toUpper +toYAML toYaml trim trimAll @@ -181,14 +254,19 @@ tuple typeIs typeIsLike typeOf +uncapitalize uniq unixEpoch unset until untilStep +untitle upper urlJoin urlParse uuid +uuidv4 values without +wrap +wrapWith