feat(templater): migrate the template engine from slim-sprig to sprout

slim-sprig is a fork of the unmaintained Masterminds/sprig that Task keeps
alive itself. sprout is its maintained successor, and its registry system
gives the same trimmed function set without a fork to maintain.

Task's own functions move into a sprout registry. Ten functions changed
argument order between the two libraries, and dig lost its default-value
argument; wrappers accept both forms so existing Taskfiles keep working.
merge and fromYaml/toYaml are re-applied after the handler is built,
because sprout's namesakes have different semantics.

Closes #1638.
This commit is contained in:
Valentin Maerten
2026-08-10 21:09:22 +02:00
parent 1750f4560b
commit 3d2fb43227
11 changed files with 726 additions and 155 deletions

6
go.mod
View File

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

14
go.sum
View File

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

View File

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

View File

@@ -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 }}`,
},
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <append 3>: error calling append: Cannot push on type int
= [1,2,3]
{{ list 2 3 | prepend 1 | toJson }}
! at <prepend 1>: 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 <atoi "abc">: 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 <fromJson "not json">: error calling fromJson: json decode error: invalid character 'o' in literal null (expecting 'u')
{{ mustFromJson "not json" | toJson }}
! at <mustFromJson "not json">: error calling mustFromJson: invalid character 'o' in literal null (expecting 'u')
! at <mustFromJson "not json">: 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 }}

View File

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