diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f3f811cf..d07704f75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,14 @@ - Fixed the JSON schema rejecting more keys the Taskfile parser accepts: `ignore_error` on a `task:` call, and `if`, `set` and `shopt` on a command inside a `for` loop (#2967 by @vmaerten). +- Migrated the templating engine from `slim-sprig` to + [sprout](https://docs.atom.codes/sprout). Every function name Task exposed + still resolves, and the ten functions whose argument order changed accept both + the old and the new form, reporting the old one under `--verbose`. Some + long-standing sprig bugs are fixed as a result, and functions that used to + swallow errors now report them — see + [Migrating from slim-sprig](https://taskfile.dev/docs/reference/templating#migrating-from-slim-sprig) + (#1638, #2006 by @42atomys, by @vmaerten). ## v3.52.0 - 2026-07-02 diff --git a/go.mod b/go.mod index dacb5d33e8..14fbe54e89 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 0a6726e3e8..1b038730a8 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 0ffd233dc0..5d8b7d1a12 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 -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, - } - - // 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) +// 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 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) +func init() { + handler := sprout.New( + sprout.WithLogger(slog.New(noticeHandler{})), + 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) + } } - 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/notices.go b/internal/templater/notices.go new file mode 100644 index 0000000000..5f03baf9d2 --- /dev/null +++ b/internal/templater/notices.go @@ -0,0 +1,57 @@ +package templater + +import ( + "context" + "log/slog" + "sync" + "sync/atomic" +) + +// NoticeFunc receives the deprecation notices that sprout emits when a +// template calls a deprecated function name or uses a deprecated argument +// order. +type NoticeFunc func(format string, args ...any) + +// noticeSink is set once the Executor has built its logger. Until then — and +// the function map is built in an init(), long before that — notices are +// dropped rather than written to sprout's default stdout handler, which would +// corrupt the JSON and group output styles. +var noticeSink atomic.Pointer[NoticeFunc] + +// SetNoticeSink installs the destination for template deprecation notices. +// Passing nil silences them again. +func SetNoticeSink(fn NoticeFunc) { + noticeSeen.Clear() + if fn == nil { + noticeSink.Store(nil) + return + } + noticeSink.Store(&fn) +} + +type noticeHandler struct{} + +func (noticeHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= slog.LevelWarn && noticeSink.Load() != nil +} + +// noticeSeen keeps each distinct notice to a single line. Task renders the +// same templates several times per run — once per compilation pass — so +// without this a lone deprecated call would be reported over and over. +var noticeSeen sync.Map + +func (noticeHandler) Handle(_ context.Context, record slog.Record) error { + fn := noticeSink.Load() + if fn == nil { + return nil + } + if _, dup := noticeSeen.LoadOrStore(record.Message, struct{}{}); dup { + return nil + } + (*fn)("task: %s\n", record.Message) + return nil +} + +func (h noticeHandler) WithAttrs([]slog.Attr) slog.Handler { return h } + +func (h noticeHandler) WithGroup(string) slog.Handler { return h } diff --git a/internal/templater/snapshot_test.go b/internal/templater/snapshot_test.go new file mode 100644 index 0000000000..bd72777a18 --- /dev/null +++ b/internal/templater/snapshot_test.go @@ -0,0 +1,329 @@ +package templater + +import ( + "fmt" + "maps" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/sebdah/goldie/v2" + + "github.com/go-task/task/v3/taskfile/ast" +) + +// The two tests in this file snapshot the entire template surface: the set of +// function names, and the rendered output of a representative expression per +// function. They exist to make any change of templating library auditable — +// the golden diff is the exhaustive list of what changed for users. +// +// Expressions must stay deterministic and platform independent, so anything +// depending on the clock, the filesystem, the environment or randomness is +// deliberately absent (`now`, `uuid`, `randInt`, `env`, `os*`, `exeExt`, `OS`, +// `ARCH`, `spew`, `getHostByName`). Those are covered by unit tests instead. + +// TestMain pins the local timezone so that the date expressions below render +// identically on every machine. A consequence is that this golden cannot show +// the local-vs-UTC difference between sprig's `date`/`toDate` and sprout's — +// that one belongs in the migration documentation. +func TestMain(m *testing.M) { + time.Local = time.UTC + os.Exit(m.Run()) +} + +func TestFuncNames(t *testing.T) { + t.Parallel() + + names := slices.Sorted(maps.Keys(templateFuncs)) + + g := goldie.New(t) + g.Assert(t, "func_names", []byte(strings.Join(names, "\n")+"\n")) +} + +func TestFuncBehaviour(t *testing.T) { + t.Parallel() + + var b strings.Builder + for _, group := range funcBehaviourGroups { + fmt.Fprintf(&b, "## %s\n\n", group.name) + for _, expr := range group.exprs { + cache := &Cache{Vars: ast.NewVars()} + got := ReplaceWithExtra(expr, cache, nil) + fmt.Fprintf(&b, "%s\n", expr) + if err := cache.Err(); err != nil { + fmt.Fprintf(&b, "\t! %s\n", normalizeTemplateError(err)) + } else { + fmt.Fprintf(&b, "\t= %s\n", strings.ReplaceAll(got, "\n", "\\n")) + } + } + b.WriteString("\n") + } + + g := goldie.New(t) + g.Assert(t, "func_behaviour", []byte(b.String())) +} + +// normalizeTemplateError strips the position prefix that text/template adds to +// execution errors, which is noise in the golden and shifts whenever an +// expression is added to a group. +func normalizeTemplateError(err error) string { + s := err.Error() + if _, after, found := strings.Cut(s, `at <`); found { + return "at <" + after + } + return s +} + +var funcBehaviourGroups = []struct { + name string + exprs []string +}{ + { + // The ten functions whose argument order differs between sprig and + // sprout. Written here in sprig order, which is what users' Taskfiles + // contain today. + "argument order — sprig order (target first)", + []string{ + `{{ get (dict "a" "b") "a" }}`, + `{{ set (dict "a" "b") "c" "d" | toJson }}`, + `{{ unset (dict "a" "b" "c" "d") "a" | toJson }}`, + `{{ hasKey (dict "a" "b") "a" }}`, + `{{ pick (dict "a" "1" "b" "2") "a" | toJson }}`, + `{{ omit (dict "a" "1" "b" "2") "a" | toJson }}`, + `{{ append (list 1 2) 3 | toJson }}`, + `{{ push (list 1 2) 3 | toJson }}`, + `{{ prepend (list 2 3) 1 | toJson }}`, + `{{ without (list 1 2 3) 2 | toJson }}`, + `{{ slice (list 1 2 3 4) 1 3 | toJson }}`, + }, + }, + { + // Same ten functions in sprout order. Today these mostly fail; after + // the migration both forms must work. + "argument order — sprout order (target last)", + []string{ + `{{ dict "a" "b" | get "a" }}`, + `{{ dict "a" "b" | set "c" "d" | toJson }}`, + `{{ dict "a" "b" "c" "d" | unset "a" | toJson }}`, + `{{ 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 }}`, + }, + }, + { + "maps — unchanged signatures", + []string{ + `{{ dict "a" 1 "b" 2 | toJson }}`, + `{{ keys (dict "b" 1 "a" 2) | sortAlpha | toJson }}`, + `{{ values (dict "a" 1) | toJson }}`, + `{{ pluck "a" (dict "a" 1) (dict "a" 2) | toJson }}`, + `{{ dig "a" "b" "fallback" (dict "a" (dict "b" "found")) }}`, + `{{ dig "a" "missing" "fallback" (dict "a" (dict "b" "found")) }}`, + `{{ dig "a.b" "fallback" (dict "a" (dict "b" "found")) }}`, + `{{ merge (dict "a" 1) (dict "b" 2) | toJson }}`, + `{{ merge (dict "a" 1) (dict "a" 0) | toJson }}`, + }, + }, + { + "lists", + []string{ + `{{ list 1 2 3 | toJson }}`, + `{{ tuple 1 2 3 | toJson }}`, + `{{ first (list 1 2 3) }}`, + `{{ last (list 1 2 3) }}`, + `{{ rest (list 1 2 3) | toJson }}`, + `{{ initial (list 1 2 3) | toJson }}`, + `{{ reverse (list 1 2 3) | toJson }}`, + `{{ uniq (list 1 1 2) | toJson }}`, + `{{ compact (list 1 "" 2) | toJson }}`, + `{{ concat (list 1) (list 2) | toJson }}`, + `{{ chunk 2 (list 1 2 3) | toJson }}`, + `{{ has 2 (list 1 2 3) }}`, + `{{ sortAlpha (list "b" "a") | toJson }}`, + `{{ splitList "," "a,b,c" | toJson }}`, + `{{ toStrings (list 1 2) | toJson }}`, + `{{ until 3 | toJson }}`, + `{{ untilStep 0 6 2 | toJson }}`, + `{{ seq 1 3 }}`, + `{{ join "," (list "a" "b") }}`, + }, + }, + { + "strings", + []string{ + `{{ trim " x " }}`, + `{{ trimAll "-" "-x-" }}`, + `{{ trimall "-" "-x-" }}`, + `{{ trimPrefix "a" "ab" }}`, + `{{ trimSuffix "b" "ab" }}`, + `{{ upper "abc" }}`, + `{{ lower "ABC" }}`, + `{{ title "hello world" }}`, + `{{ title "hello wORLD" }}`, + `{{ trunc 3 "foobar" }}`, + `{{ trunc -3 "foobar" }}`, + `{{ substr 0 3 "foobar" }}`, + `{{ substr 0 -3 "foobar" }}`, + `{{ repeat 3 "x" }}`, + `{{ contains "oo" "foobar" }}`, + `{{ hasPrefix "foo" "foobar" }}`, + `{{ hasSuffix "bar" "foobar" }}`, + `{{ quote "x" }}`, + `{{ squote "x" }}`, + `{{ cat "a" "b" }}`, + `{{ indent 2 "x" }}`, + `{{ nindent 2 "x" }}`, + `{{ replace "a" "b" "aa" }}`, + `{{ plural "one" "many" 2 }}`, + `{{ split "," "a,b" | toJson }}`, + `{{ splitn "," 2 "a,b,c" | toJson }}`, + `{{ toString 42 }}`, + }, + }, + { + "numbers", + []string{ + `{{ add 1 2 }}`, + `{{ add1 1 }}`, + `{{ sub 5 2 }}`, + `{{ mul 2 3 }}`, + `{{ div 6 2 }}`, + `{{ mod 5 3 }}`, + `{{ max 1 5 3 }}`, + `{{ min 1 5 3 }}`, + `{{ biggest 1 5 3 }}`, + `{{ maxf 1.5 2.5 }}`, + `{{ minf 1.5 2.5 }}`, + `{{ ceil 1.1 }}`, + `{{ floor 1.9 }}`, + `{{ round 1.55 1 }}`, + `{{ atoi "42" }}`, + `{{ atoi "abc" }}`, + `{{ int "42" }}`, + `{{ int64 "42" }}`, + `{{ float64 "1.5" }}`, + `{{ toDecimal "0777" }}`, + }, + }, + { + "defaults and flow", + []string{ + `{{ default "d" "" }}`, + `{{ default "d" "x" }}`, + `{{ empty "" }}`, + `{{ empty 0 }}`, + `{{ coalesce "" "x" }}`, + `{{ all 1 1 }}`, + `{{ any 0 1 }}`, + `{{ ternary "y" "n" true }}`, + `{{ fail "boom" }}`, + }, + }, + { + "encoding", + []string{ + `{{ toJson (dict "a" 1) }}`, + `{{ toPrettyJson (dict "a" 1) }}`, + `{{ toRawJson (dict "a" "") }}`, + `{{ fromJson "{\"a\":1}" | toJson }}`, + `{{ fromJson "not json" | toJson }}`, + `{{ mustFromJson "not json" | toJson }}`, + `{{ toYaml (dict "a" 1) }}`, + `{{ fromYaml "a: 1" | toJson }}`, + `{{ mustFromYaml "a: :" | toJson }}`, + `{{ b64enc "hello" }}`, + `{{ b64dec "aGVsbG8=" }}`, + `{{ b32enc "hello" }}`, + `{{ b32dec "NBSWY3DP" }}`, + }, + }, + { + "regex", + []string{ + `{{ regexMatch "^a" "abc" }}`, + `{{ regexFind "[0-9]+" "abc123" }}`, + `{{ regexFindAll "[0-9]" "a1b2" -1 | toJson }}`, + `{{ regexReplaceAll "[0-9]" "abc123" "#" }}`, + `{{ regexReplaceAllLiteral "[0-9]" "abc123" "#" }}`, + `{{ regexSplit "," "a,b" -1 | toJson }}`, + `{{ regexQuoteMeta "a.b" }}`, + `{{ mustRegexFind "[" "abc" }}`, + }, + }, + { + "reflection", + []string{ + `{{ typeOf 1 }}`, + `{{ typeIs "int" 1 }}`, + `{{ typeIsLike "int" 1 }}`, + `{{ kindOf 1 }}`, + `{{ kindOf (list 1) }}`, + `{{ kindIs "int" 1 }}`, + `{{ deepEqual (list 1) (list 1) }}`, + }, + }, + { + "checksums", + []string{ + `{{ sha1sum "x" }}`, + `{{ sha256sum "x" }}`, + `{{ adler32sum "x" }}`, + }, + }, + { + // path.* semantics — slash based, therefore identical on every platform. + "paths", + []string{ + `{{ base "/foo/bar.txt" }}`, + `{{ dir "/foo/bar.txt" }}`, + `{{ ext "/foo/bar.txt" }}`, + `{{ clean "/foo//bar" }}`, + `{{ isAbs "/foo" }}`, + }, + }, + { + "dates — fixed epoch, explicit zone", + []string{ + `{{ dateInZone "2006-01-02T15:04:05" 0 "UTC" }}`, + `{{ date "2006-01-02T15:04:05" 0 }}`, + `{{ dateModify "1h" (toDate "2006-01-02T15:04:05Z07:00" "2020-01-01T00:00:00Z") }}`, + `{{ date_modify "1h" (toDate "2006-01-02T15:04:05Z07:00" "2020-01-01T00:00:00Z") }}`, + `{{ toDate "2006-01-02" "2020-01-01" }}`, + `{{ unixEpoch (toDate "2006-01-02" "2020-01-01") }}`, + `{{ duration 90 }}`, + `{{ durationRound "1h35m30s" }}`, + `{{ htmlDate 0 }}`, + }, + }, + { + "urls", + []string{ + `{{ urlParse "http://example.com/a?b=c" | toJson }}`, + `{{ urlJoin (dict "scheme" "http" "host" "example.com" "path" "/a") }}`, + }, + }, + { + "task's own functions", + []string{ + `{{ numCPU | kindOf }}`, + `{{ catLines "a\nb" }}`, + `{{ splitLines "a\nb" | toJson }}`, + `{{ toSlash "a/b" }}`, + `{{ fromSlash "a/b" | kindOf }}`, + `{{ ToSlash "a/b" }}`, + `{{ shellQuote "a b" }}`, + `{{ q "a b" }}`, + `{{ splitArgs "a b c" | toJson }}`, + `{{ IsSH }}`, + `{{ joinUrl "http://localhost" "a" "b" }}`, + `{{ mustToYaml (dict "a" 1) }}`, + `{{ randIntN 1 }}`, + }, + }, +} diff --git a/internal/templater/sprigcompat.go b/internal/templater/sprigcompat.go new file mode 100644 index 0000000000..383f2577a7 --- /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 0000000000..8a3a3cabcc --- /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 0000000000..3db36b9d89 --- /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 e425b972ac..46bdcee9b2 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 0000000000..1317b6a819 --- /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 new file mode 100644 index 0000000000..b76cfc212f --- /dev/null +++ b/internal/templater/testdata/func_behaviour.golden @@ -0,0 +1,375 @@ +## argument order — sprig order (target first) + +{{ get (dict "a" "b") "a" }} + = b +{{ set (dict "a" "b") "c" "d" | toJson }} + = {"a":"b","c":"d"} +{{ unset (dict "a" "b" "c" "d") "a" | toJson }} + = {"c":"d"} +{{ hasKey (dict "a" "b") "a" }} + = true +{{ pick (dict "a" "1" "b" "2") "a" | toJson }} + = {"a":"1"} +{{ omit (dict "a" "1" "b" "2") "a" | toJson }} + = {"b":"2"} +{{ append (list 1 2) 3 | toJson }} + = [1,2,3] +{{ push (list 1 2) 3 | toJson }} + = [1,2,3] +{{ prepend (list 2 3) 1 | toJson }} + = [1,2,3] +{{ without (list 1 2 3) 2 | toJson }} + = [1,3] +{{ slice (list 1 2 3 4) 1 3 | toJson }} + = [2,3] + +## argument order — sprout order (target last) + +{{ dict "a" "b" | get "a" }} + = b +{{ dict "a" "b" | set "c" "d" | toJson }} + = {"a":"b","c":"d"} +{{ dict "a" "b" "c" "d" | unset "a" | toJson }} + = {"c":"d"} +{{ dict "a" "b" | hasKey "a" }} + = true +{{ list 1 2 | append 3 | toJson }} + = [1,2,3] +{{ list 2 3 | prepend 1 | toJson }} + = [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 + +{{ dict "a" 1 "b" 2 | toJson }} + = {"a":1,"b":2} +{{ keys (dict "b" 1 "a" 2) | sortAlpha | toJson }} + = ["a","b"] +{{ values (dict "a" 1) | toJson }} + = [1] +{{ pluck "a" (dict "a" 1) (dict "a" 2) | toJson }} + = [1,2] +{{ dig "a" "b" "fallback" (dict "a" (dict "b" "found")) }} + = found +{{ dig "a" "missing" "fallback" (dict "a" (dict "b" "found")) }} + = fallback +{{ dig "a.b" "fallback" (dict "a" (dict "b" "found")) }} + = found +{{ merge (dict "a" 1) (dict "b" 2) | toJson }} + = {"a":1,"b":2} +{{ merge (dict "a" 1) (dict "a" 0) | toJson }} + = {"a":0} + +## lists + +{{ list 1 2 3 | toJson }} + = [1,2,3] +{{ tuple 1 2 3 | toJson }} + = [1,2,3] +{{ first (list 1 2 3) }} + = 1 +{{ last (list 1 2 3) }} + = 3 +{{ rest (list 1 2 3) | toJson }} + = [2,3] +{{ initial (list 1 2 3) | toJson }} + = [1,2] +{{ reverse (list 1 2 3) | toJson }} + = [3,2,1] +{{ uniq (list 1 1 2) | toJson }} + = [1,2] +{{ compact (list 1 "" 2) | toJson }} + = [1,2] +{{ concat (list 1) (list 2) | toJson }} + = [1,2] +{{ chunk 2 (list 1 2 3) | toJson }} + = [[1,2],[3]] +{{ has 2 (list 1 2 3) }} + = true +{{ sortAlpha (list "b" "a") | toJson }} + = ["a","b"] +{{ splitList "," "a,b,c" | toJson }} + = ["a","b","c"] +{{ toStrings (list 1 2) | toJson }} + = ["1","2"] +{{ until 3 | toJson }} + = [0,1,2] +{{ untilStep 0 6 2 | toJson }} + = [0,2,4] +{{ seq 1 3 }} + = 1 2 3 +{{ join "," (list "a" "b") }} + = a,b + +## strings + +{{ trim " x " }} + = x +{{ trimAll "-" "-x-" }} + = x +{{ trimall "-" "-x-" }} + = x +{{ trimPrefix "a" "ab" }} + = b +{{ trimSuffix "b" "ab" }} + = a +{{ upper "abc" }} + = ABC +{{ lower "ABC" }} + = abc +{{ title "hello world" }} + = Hello World +{{ title "hello wORLD" }} + = Hello World +{{ trunc 3 "foobar" }} + = foo +{{ trunc -3 "foobar" }} + = bar +{{ substr 0 3 "foobar" }} + = foo +{{ substr 0 -3 "foobar" }} + = foo +{{ repeat 3 "x" }} + = xxx +{{ contains "oo" "foobar" }} + = true +{{ hasPrefix "foo" "foobar" }} + = true +{{ hasSuffix "bar" "foobar" }} + = true +{{ quote "x" }} + = "x" +{{ squote "x" }} + = 'x' +{{ cat "a" "b" }} + = a b +{{ indent 2 "x" }} + = x +{{ nindent 2 "x" }} + = \n x +{{ replace "a" "b" "aa" }} + = bb +{{ plural "one" "many" 2 }} + = many +{{ split "," "a,b" | toJson }} + = {"_0":"a","_1":"b"} +{{ splitn "," 2 "a,b,c" | toJson }} + = {"_0":"a","_1":"b,c"} +{{ toString 42 }} + = 42 + +## numbers + +{{ add 1 2 }} + = 3 +{{ add1 1 }} + = 2 +{{ sub 5 2 }} + = 3 +{{ mul 2 3 }} + = 6 +{{ div 6 2 }} + = 3 +{{ mod 5 3 }} + = 2 +{{ max 1 5 3 }} + = 5 +{{ min 1 5 3 }} + = 1 +{{ biggest 1 5 3 }} + = 5 +{{ maxf 1.5 2.5 }} + = 2.5 +{{ minf 1.5 2.5 }} + = 1.5 +{{ ceil 1.1 }} + = 2 +{{ floor 1.9 }} + = 1 +{{ round 1.55 1 }} + = 1.6 +{{ atoi "42" }} + = 42 +{{ atoi "abc" }} + ! at : error calling atoi: unable to cast "abc" of type string to int: strconv.ParseInt: parsing "abc": invalid syntax +{{ int "42" }} + = 42 +{{ int64 "42" }} + = 42 +{{ float64 "1.5" }} + = 1.5 +{{ toDecimal "0777" }} + = 511 + +## defaults and flow + +{{ default "d" "" }} + = d +{{ default "d" "x" }} + = x +{{ empty "" }} + = true +{{ empty 0 }} + = true +{{ coalesce "" "x" }} + = x +{{ all 1 1 }} + = true +{{ any 0 1 }} + = true +{{ ternary "y" "n" true }} + = y +{{ fail "boom" }} + ! at : error calling fail: boom + +## encoding + +{{ toJson (dict "a" 1) }} + = {"a":1} +{{ toPrettyJson (dict "a" 1) }} + = {\n "a": 1\n} +{{ toRawJson (dict "a" "") }} + = {"a":""} +{{ fromJson "{\"a\":1}" | toJson }} + = {"a":1} +{{ fromJson "not json" | toJson }} + ! at : error calling fromJson: json decode error: invalid character 'o' in literal null (expecting 'u') +{{ mustFromJson "not json" | toJson }} + ! 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 }} + = {"a":1} +{{ mustFromYaml "a: :" | toJson }} + ! at : error calling mustFromYaml: yaml: mapping values are not allowed in this context +{{ b64enc "hello" }} + = aGVsbG8= +{{ b64dec "aGVsbG8=" }} + = hello +{{ b32enc "hello" }} + = NBSWY3DP +{{ b32dec "NBSWY3DP" }} + = hello + +## regex + +{{ regexMatch "^a" "abc" }} + = true +{{ regexFind "[0-9]+" "abc123" }} + = 123 +{{ regexFindAll "[0-9]" "a1b2" -1 | toJson }} + = ["1","2"] +{{ regexReplaceAll "[0-9]" "abc123" "#" }} + = abc### +{{ regexReplaceAllLiteral "[0-9]" "abc123" "#" }} + = abc### +{{ regexSplit "," "a,b" -1 | toJson }} + = ["a","b"] +{{ regexQuoteMeta "a.b" }} + = a\.b +{{ mustRegexFind "[" "abc" }} + ! at : error calling mustRegexFind: error parsing regexp: missing closing ]: `[` + +## reflection + +{{ typeOf 1 }} + = int +{{ typeIs "int" 1 }} + = true +{{ typeIsLike "int" 1 }} + = true +{{ kindOf 1 }} + = int +{{ kindOf (list 1) }} + = slice +{{ kindIs "int" 1 }} + = true +{{ deepEqual (list 1) (list 1) }} + = true + +## checksums + +{{ sha1sum "x" }} + = 11f6ad8ec52a2984abaafd7c3b516503785c2072 +{{ sha256sum "x" }} + = 2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881 +{{ adler32sum "x" }} + = 7929977 + +## paths + +{{ base "/foo/bar.txt" }} + = bar.txt +{{ dir "/foo/bar.txt" }} + = /foo +{{ ext "/foo/bar.txt" }} + = .txt +{{ clean "/foo//bar" }} + = /foo/bar +{{ isAbs "/foo" }} + = true + +## dates — fixed epoch, explicit zone + +{{ dateInZone "2006-01-02T15:04:05" 0 "UTC" }} + = 1970-01-01T00:00:00 +{{ date "2006-01-02T15:04:05" 0 }} + = 1970-01-01T00:00:00 +{{ dateModify "1h" (toDate "2006-01-02T15:04:05Z07:00" "2020-01-01T00:00:00Z") }} + = 2020-01-01 01:00:00 +0000 UTC +{{ date_modify "1h" (toDate "2006-01-02T15:04:05Z07:00" "2020-01-01T00:00:00Z") }} + = 2020-01-01 01:00:00 +0000 UTC +{{ toDate "2006-01-02" "2020-01-01" }} + = 2020-01-01 00:00:00 +0000 UTC +{{ unixEpoch (toDate "2006-01-02" "2020-01-01") }} + = 1577836800 +{{ duration 90 }} + = 1m30s +{{ durationRound "1h35m30s" }} + = 1h +{{ htmlDate 0 }} + = 1970-01-01 + +## urls + +{{ urlParse "http://example.com/a?b=c" | toJson }} + = {"fragment":"","host":"example.com","hostname":"example.com","opaque":"","path":"/a","query":"b=c","scheme":"http","userinfo":""} +{{ urlJoin (dict "scheme" "http" "host" "example.com" "path" "/a") }} + = http://example.com/a + +## task's own functions + +{{ numCPU | kindOf }} + = int +{{ catLines "a\nb" }} + = a b +{{ splitLines "a\nb" | toJson }} + = ["a","b"] +{{ toSlash "a/b" }} + = a/b +{{ fromSlash "a/b" | kindOf }} + = string +{{ ToSlash "a/b" }} + = a/b +{{ shellQuote "a b" }} + = 'a b' +{{ q "a b" }} + = 'a b' +{{ splitArgs "a b c" | toJson }} + = ["a","b","c"] +{{ IsSH }} + = true +{{ joinUrl "http://localhost" "a" "b" }} + = http://localhost/a/b +{{ mustToYaml (dict "a" 1) }} + = a: 1\n +{{ randIntN 1 }} + = 0 + diff --git a/internal/templater/testdata/func_names.golden b/internal/templater/testdata/func_names.golden new file mode 100644 index 0000000000..c155129b3e --- /dev/null +++ b/internal/templater/testdata/func_names.golden @@ -0,0 +1,272 @@ +ARCH +ExeExt +FromSlash +IsSH +OS +ToSlash +absPath +add +add1 +add1f +addf +adler32Sum +adler32sum +ago +all +any +append +atoi +b32dec +b32enc +b64dec +b64enc +base +base32Decode +base32Encode +base64Decode +base64Encode +biggest +capitalize +cat +catLines +ceil +chunk +clean +coalesce +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 +hello +htmlDate +htmlDateInZone +indent +initial +initials +int +int64 +isAbs +join +joinEnv +joinPath +joinUrl +keys +kindIs +kindOf +last +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 +mustRegexFindAll +mustRegexMatch +mustRegexReplaceAll +mustRegexReplaceAllLiteral +mustRegexSplit +mustRest +mustReverse +mustSlice +mustToDate +mustToJson +mustToPrettyJson +mustToRawJson +mustToYaml +mustUniq +mustWithout +must_date_modify +nindent +nospace +now +numCPU +omit +osBase +osClean +osDir +osExt +osIsAbs +pathBase +pathClean +pathDir +pathExt +pathIsAbs +pick +pluck +plural +prepend +push +q +quote +randAlpha +randAlphaNum +randAscii +randBytes +randInt +randIntN +randNumeric +regexFind +regexFindAll +regexFindAllGroups +regexFindAllNamed +regexFindGroups +regexFindNamed +regexMatch +regexQuoteMeta +regexReplaceAll +regexReplaceAllLiteral +regexSplit +relPath +repeat +replace +rest +reverse +round +seq +set +sha1Sum +sha1sum +sha256Sum +sha256sum +sha512Sum +shellQuote +shuffle +slice +sortAlpha +spew +split +splitArgs +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 +trimPrefix +trimSuffix +trimall +trunc +tuple +typeIs +typeIsLike +typeOf +uncapitalize +uniq +unixEpoch +unset +until +untilStep +untitle +upper +urlJoin +urlParse +uuid +uuidv4 +values +without +wrap +wrapWith diff --git a/setup.go b/setup.go index f24f8b8eb3..b705d67597 100644 --- a/setup.go +++ b/setup.go @@ -18,6 +18,7 @@ import ( "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" + "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" @@ -192,6 +193,9 @@ func (e *Executor) setupLogger() { AssumeYes: e.AssumeYes, AssumeTerm: e.AssumeTerm, } + templater.SetNoticeSink(func(format string, args ...any) { + e.Logger.VerboseErrf(logger.Yellow, format, args...) + }) } func (e *Executor) setupOutput() error { diff --git a/website/src/docs/deprecations/template-functions.md b/website/src/docs/deprecations/template-functions.md index 6437418881..18e119360c 100644 --- a/website/src/docs/deprecations/template-functions.md +++ b/website/src/docs/deprecations/template-functions.md @@ -25,3 +25,37 @@ listed besides the function being removed. | `FromSlash` | `fromSlash` | | `ToSlash` | `toSlash` | | `ExeExt` | `exeExt` | + +## Functions renamed by sprout + +Task's generic template functions moved from slim-sprig to +[sprout](https://docs.atom.codes/sprout), which renamed a number of them. The +old names still work as aliases, and Task reports their use when run with +`--verbose`. + +| Deprecated function | Replaced by | +| --------------------------------- | -------------------------------- | +| `upper` | `toUpper` | +| `lower` | `toLower` | +| `title` | `toTitleCase` | +| `atoi`, `int` | `toInt` | +| `int64` | `toInt64` | +| `float64` | `toFloat64` | +| `toDecimal` | `toOctal` | +| `toStrings` | `strSlice` | +| `b64enc`, `b64dec` | `base64Encode`, `base64Decode` | +| `b32enc`, `b32dec` | `base32Encode`, `base32Decode` | +| `base`, `dir`, `ext` | `pathBase`, `pathDir`, `pathExt` | +| `clean`, `isAbs` | `pathClean`, `pathIsAbs` | +| `expandenv` | `expandEnv` | +| `ago` | `dateAgo` | +| `trimall` | `trimAll` | +| `push`, `mustPush` | `append` | +| `tuple` | `list` | +| `biggest` | `max` | +| `date_in_zone` | `dateInZone` | +| `date_modify`, `must_date_modify` | `dateModify` | + +Ten functions also changed argument order, and the old order is deprecated. See +[Migrating from slim-sprig](../reference/templating.md#migrating-from-slim-sprig) +for the full list and for the behaviour changes that came with the move. diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index a78b26d495..d28acc1e3f 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -486,7 +486,7 @@ includes: Vars declared in the included Taskfile have preference over the variables in the including Taskfile! If you want a variable in an included Taskfile to be overridable, use the -[default function](https://sprig.taskfile.dev/defaults.html): +[default function](https://docs.atom.codes/sprout/registries/std#default): `MY_VAR: '{{.MY_VAR | default "my-default-value"}}'`. ::: diff --git a/website/src/docs/reference/templating.md b/website/src/docs/reference/templating.md index ccf968c062..2bea3a3f58 100644 --- a/website/src/docs/reference/templating.md +++ b/website/src/docs/reference/templating.md @@ -12,7 +12,7 @@ Task's templating engine uses Go's [text/template](https://pkg.go.dev/text/template) package to interpolate values. This reference covers the main features and all available functions for creating dynamic Taskfiles. Most of the provided functions come from the -[slim-sprig](https://sprig.taskfile.dev/) library. +[sprout](https://docs.atom.codes/sprout) library. ## Basic Usage @@ -444,7 +444,7 @@ tasks: vars: ITEMS: [a, b, c, d, e] cmds: - - echo "{{slice .ITEMS 1 3}}" # [b c] + - echo "{{.ITEMS | slice 1 3}}" # [b c] ``` ### String Functions @@ -540,8 +540,8 @@ tasks: cmds: - echo "{{.NUMBERS | uniq}}" # [3, 1, 4, 5, 9] - echo "{{.NUMBERS | sortAlpha}}" # [1, 1, 1, 3, 4, 5, 9] - - echo"'{{append .FRUITS "cherry"}}"" # ["apple", "banana", "cherry"] - - echo "{{ without .NUMBERS 1}}" # [3, 4, 5, 9] + - echo "{{.FRUITS | append "cherry"}}" # ["apple", "banana", "cherry"] + - echo "{{.NUMBERS | without 1}}" # [3, 4, 5, 9] - echo "{{.NUMBERS | has 5}}" # true ``` @@ -704,11 +704,10 @@ tasks: port: 5432 ssl: true cmds: - - echo "Database {{get .CONFIG "database"}}" - - echo "Database {{"database" | get .CONFIG}}" + - echo "Database {{.CONFIG | get "database"}}" - echo "Keys {{.CONFIG | keys}}" - echo "Keys {{keys .CONFIG }}" - - echo "Has SSL {{hasKey .CONFIG "ssl"}}" + - echo "Has SSL {{.CONFIG | hasKey "ssl"}}" - echo "{{dict "env" "prod" "debug" false}}" ``` @@ -858,3 +857,82 @@ tasks: - echo '{{printf "Version %s.%d" .VERSION .BUILD}}' - echo '{{println "With newline"}}' ``` + +## Migrating from slim-sprig + +Task's template functions used to come from +[slim-sprig](https://sprig.taskfile.dev/), a fork of the unmaintained +[sprig](https://masterminds.github.io/sprig/) library. They now come from +[sprout](https://docs.atom.codes/sprout), its maintained successor. + +Every function name that slim-sprig provided still resolves, so existing +Taskfiles keep working. Run Task with `--verbose` to see which of your templates +rely on a deprecated name or argument order. + +### Renamed functions + +The old names are kept as deprecated aliases. Prefer the new ones. + +| Old name | New name | +| ---------------------------- | ------------------------------- | +| `upper` | `toUpper` | +| `lower` | `toLower` | +| `title` | `toTitleCase` | +| `atoi`, `int` | `toInt` | +| `int64` | `toInt64` | +| `float64` | `toFloat64` | +| `toDecimal` | `toOctal` | +| `toStrings` | `strSlice` | +| `b64enc`, `b64dec` | `base64Encode`, `base64Decode` | +| `b32enc`, `b32dec` | `base32Encode`, `base32Decode` | +| `base`, `dir`, `ext` | `pathBase`, `pathDir`, `pathExt` | +| `clean`, `isAbs` | `pathClean`, `pathIsAbs` | +| `expandenv` | `expandEnv` | +| `ago` | `dateAgo` | +| `trimall` | `trimAll` | +| `push`, `mustPush` | `append` | +| `tuple` | `list` | +| `biggest` | `max` | +| `date_in_zone` | `dateInZone` | +| `date_modify`, `must_date_modify` | `dateModify` | + +### Changed argument order + +Ten functions now take the map or list they operate on as their **last** +argument, so that they can be piped into. Task accepts both orders, warning +about the old one under `--verbose`, but the old order will eventually be +removed. + +| Function | Old | New | +| ------------------------------ | ---------------------------- | -------------------------------- | +| `get`, `hasKey`, `unset` | `{{ get $dict "key" }}` | `{{ $dict \| get "key" }}` | +| `set` | `{{ set $dict "k" "v" }}` | `{{ $dict \| set "k" "v" }}` | +| `pick`, `omit` | `{{ pick $dict "key" }}` | `{{ $dict \| pick "key" }}` | +| `append`, `prepend` | `{{ append $list "v" }}` | `{{ $list \| append "v" }}` | +| `without` | `{{ without $list "v" }}` | `{{ $list \| without "v" }}` | +| `slice` | `{{ slice $list 1 3 }}` | `{{ $list \| slice 1 3 }}` | + +`dig`, `has`, `chunk` and `merge` kept their argument order. + +### Behaviour changes + +These are corrections of long-standing sprig bugs, and they are not opt-in. + +- Functions that used to swallow an error or panic now report it, failing the + task instead of rendering an empty string. `{{ atoi "abc" }}` and + `{{ fromJson "not json" }}` are the common cases; their `must` variants + behaved this way already. +- `substr` handles negative indices correctly: `{{ substr 0 -3 "foobar" }}` now + yields `foo` rather than `foobar`. +- `title` applies Unicode title casing: `{{ title "hello wORLD" }}` now yields + `Hello World` rather than `Hello WORLD`. +- `duration` accepts a number of seconds: `{{ duration 90 }}` now yields `1m30s` + rather than `0s`. +- `dig` splits its keys on dots, so `{{ dig "a.b" "fallback" $dict }}` walks + into `a` then `b` rather than looking for a literal `a.b` key. +- `date` and `toDate` interpret a timestamp in UTC rather than in the machine's + local timezone. Use `dateInZone` to be explicit. + +`merge`, `fromYaml`, `toYaml`, `mustFromYaml` and `mustToYaml` are Task's own +implementations and are unaffected, even though sprout ships functions of the +same name.