From 1750f4560bd222bf8a9883dbe93a7e8c2206ea19 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 10 Aug 2026 21:03:56 +0200 Subject: [PATCH 1/4] test(templater): snapshot the template function surface and behaviour Golden tests capturing the sorted list of template function names and the rendered output of a representative expression per function. Generated against the current slim-sprig implementation so that any change of templating library produces an auditable diff. --- internal/templater/snapshot_test.go | 325 ++++++++++++++++ .../templater/testdata/func_behaviour.golden | 367 ++++++++++++++++++ internal/templater/testdata/func_names.golden | 194 +++++++++ 3 files changed, 886 insertions(+) create mode 100644 internal/templater/snapshot_test.go create mode 100644 internal/templater/testdata/func_behaviour.golden create mode 100644 internal/templater/testdata/func_names.golden diff --git a/internal/templater/snapshot_test.go b/internal/templater/snapshot_test.go new file mode 100644 index 0000000000..c282d401f1 --- /dev/null +++ b/internal/templater/snapshot_test.go @@ -0,0 +1,325 @@ +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 }}`, + }, + }, + { + "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/testdata/func_behaviour.golden b/internal/templater/testdata/func_behaviour.golden new file mode 100644 index 0000000000..6f98702eb2 --- /dev/null +++ b/internal/templater/testdata/func_behaviour.golden @@ -0,0 +1,367 @@ +## 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" }} + ! at <"a">: can't handle "a" for arg of type map[string]interface {} +{{ dict "a" "b" | set "c" "d" | toJson }} + ! at <"c">: can't handle "c" for arg of type map[string]interface {} +{{ dict "a" "b" "c" "d" | unset "a" | toJson }} + ! at <"a">: can't handle "a" for arg of type map[string]interface {} +{{ dict "a" "b" | hasKey "a" }} + ! at <"a">: can't handle "a" for arg of type map[string]interface {} +{{ list 1 2 | append 3 | toJson }} + ! at : error calling append: Cannot push on type int +{{ list 2 3 | prepend 1 | toJson }} + ! at : error calling prepend: Cannot prepend on type int + +## 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")) }} + = fallback +{{ 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" }} + = foobar +{{ 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" }} + = 0 +{{ 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 }} + = null +{{ mustFromJson "not json" | toJson }} + ! at : error calling mustFromJson: 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 }} + = 0s +{{ 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..a756e4bd88 --- /dev/null +++ b/internal/templater/testdata/func_names.golden @@ -0,0 +1,194 @@ +ARCH +ExeExt +FromSlash +IsSH +OS +ToSlash +absPath +add +add1 +adler32sum +ago +all +any +append +atoi +b32dec +b32enc +b64dec +b64enc +base +biggest +cat +catLines +ceil +chunk +clean +coalesce +compact +concat +contains +date +dateInZone +dateModify +date_in_zone +date_modify +deepEqual +default +dict +dig +dir +div +duration +durationRound +empty +env +exeExt +expandenv +ext +fail +first +float64 +floor +fromJson +fromSlash +fromYaml +get +getHostByName +has +hasKey +hasPrefix +hasSuffix +hello +htmlDate +htmlDateInZone +indent +initial +int +int64 +isAbs +join +joinEnv +joinPath +joinUrl +keys +kindIs +kindOf +last +list +lower +max +maxf +merge +min +minf +mod +mul +mustAppend +mustChunk +mustCompact +mustDateModify +mustFirst +mustFromJson +mustFromYaml +mustHas +mustInitial +mustLast +mustPrepend +mustPush +mustRegexFind +mustRegexFindAll +mustRegexMatch +mustRegexReplaceAll +mustRegexReplaceAllLiteral +mustRegexSplit +mustRest +mustReverse +mustSlice +mustToDate +mustToJson +mustToPrettyJson +mustToRawJson +mustToYaml +mustUniq +mustWithout +must_date_modify +nindent +now +numCPU +omit +osBase +osClean +osDir +osExt +osIsAbs +pick +pluck +plural +prepend +push +q +quote +randInt +randIntN +regexFind +regexFindAll +regexMatch +regexQuoteMeta +regexReplaceAll +regexReplaceAllLiteral +regexSplit +relPath +repeat +replace +rest +reverse +round +seq +set +sha1sum +sha256sum +shellQuote +slice +sortAlpha +spew +split +splitArgs +splitLines +splitList +splitn +squote +sub +substr +ternary +title +toDate +toDecimal +toJson +toPrettyJson +toRawJson +toSlash +toString +toStrings +toYaml +trim +trimAll +trimPrefix +trimSuffix +trimall +trunc +tuple +typeIs +typeIsLike +typeOf +uniq +unixEpoch +unset +until +untilStep +upper +urlJoin +urlParse +uuid +values +without From 3d2fb43227fa2ab2e3feb7a205a7cf5208c75fee Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 10 Aug 2026 21:09:22 +0200 Subject: [PATCH 2/4] 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. --- go.mod | 6 +- go.sum | 14 +- internal/templater/funcs.go | 216 +++++++----------- internal/templater/snapshot_test.go | 4 + internal/templater/sprigcompat.go | 191 ++++++++++++++++ internal/templater/sprigcompat_test.go | 125 ++++++++++ internal/templater/taskfuncs/funcs.go | 105 +++++++++ .../templater/{ => taskfuncs}/funcs_test.go | 8 +- internal/templater/taskfuncs/registry.go | 98 ++++++++ .../templater/testdata/func_behaviour.golden | 34 +-- internal/templater/testdata/func_names.golden | 78 +++++++ 11 files changed, 725 insertions(+), 154 deletions(-) create mode 100644 internal/templater/sprigcompat.go create mode 100644 internal/templater/sprigcompat_test.go create mode 100644 internal/templater/taskfuncs/funcs.go rename internal/templater/{ => taskfuncs}/funcs_test.go (80%) create mode 100644 internal/templater/taskfuncs/registry.go 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..7622a53c40 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(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) + } } - return result -} -func fromYaml(v string) any { - output, _ := mustFromYaml(v) - return output -} - -func mustFromYaml(v string) (any, error) { - var output any - err := yaml.Unmarshal([]byte(v), &output) - return output, err -} - -func toYaml(v any) string { - output, _ := yaml.Marshal(v) - return string(output) -} - -func mustToYaml(v any) (string, error) { - output, err := yaml.Marshal(v) - if err != nil { - return "", err - } - return string(output), nil + templateFuncs = template.FuncMap(handler.Build()) + maps.Copy(templateFuncs, taskfuncs.Overrides()) + maps.Copy(templateFuncs, sprigSignatureShims(handler)) } diff --git a/internal/templater/snapshot_test.go b/internal/templater/snapshot_test.go index c282d401f1..bd72777a18 100644 --- a/internal/templater/snapshot_test.go +++ b/internal/templater/snapshot_test.go @@ -110,6 +110,10 @@ var funcBehaviourGroups = []struct { `{{ dict "a" "b" | hasKey "a" }}`, `{{ list 1 2 | append 3 | toJson }}`, `{{ list 2 3 | prepend 1 | toJson }}`, + `{{ dict "a" "1" "b" "2" | pick "a" | toJson }}`, + `{{ dict "a" "1" "b" "2" | omit "a" | toJson }}`, + `{{ list 1 2 3 | without 2 | toJson }}`, + `{{ list 1 2 3 4 | slice 1 3 | toJson }}`, }, }, { diff --git a/internal/templater/sprigcompat.go b/internal/templater/sprigcompat.go new file mode 100644 index 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 index 6f98702eb2..b76cfc212f 100644 --- a/internal/templater/testdata/func_behaviour.golden +++ b/internal/templater/testdata/func_behaviour.golden @@ -26,17 +26,25 @@ ## argument order — sprout order (target last) {{ dict "a" "b" | get "a" }} - ! at <"a">: can't handle "a" for arg of type map[string]interface {} + = b {{ dict "a" "b" | set "c" "d" | toJson }} - ! at <"c">: can't handle "c" for arg of type map[string]interface {} + = {"a":"b","c":"d"} {{ dict "a" "b" "c" "d" | unset "a" | toJson }} - ! at <"a">: can't handle "a" for arg of type map[string]interface {} + = {"c":"d"} {{ dict "a" "b" | hasKey "a" }} - ! at <"a">: can't handle "a" for arg of type map[string]interface {} + = true {{ list 1 2 | append 3 | toJson }} - ! at : error calling append: Cannot push on type int + = [1,2,3] {{ list 2 3 | prepend 1 | toJson }} - ! at : error calling prepend: Cannot prepend on type int + = [1,2,3] +{{ dict "a" "1" "b" "2" | pick "a" | toJson }} + = {"a":"1"} +{{ dict "a" "1" "b" "2" | omit "a" | toJson }} + = {"b":"2"} +{{ list 1 2 3 | without 2 | toJson }} + = [1,3] +{{ list 1 2 3 4 | slice 1 3 | toJson }} + = [2,3] ## maps — unchanged signatures @@ -53,7 +61,7 @@ {{ dig "a" "missing" "fallback" (dict "a" (dict "b" "found")) }} = fallback {{ dig "a.b" "fallback" (dict "a" (dict "b" "found")) }} - = fallback + = found {{ merge (dict "a" 1) (dict "b" 2) | toJson }} = {"a":1,"b":2} {{ merge (dict "a" 1) (dict "a" 0) | toJson }} @@ -119,7 +127,7 @@ {{ title "hello world" }} = Hello World {{ title "hello wORLD" }} - = Hello WORLD + = Hello World {{ trunc 3 "foobar" }} = foo {{ trunc -3 "foobar" }} @@ -127,7 +135,7 @@ {{ substr 0 3 "foobar" }} = foo {{ substr 0 -3 "foobar" }} - = foobar + = foo {{ repeat 3 "x" }} = xxx {{ contains "oo" "foobar" }} @@ -190,7 +198,7 @@ {{ atoi "42" }} = 42 {{ atoi "abc" }} - = 0 + ! at : error calling atoi: unable to cast "abc" of type string to int: strconv.ParseInt: parsing "abc": invalid syntax {{ int "42" }} = 42 {{ int64 "42" }} @@ -232,9 +240,9 @@ {{ fromJson "{\"a\":1}" | toJson }} = {"a":1} {{ fromJson "not json" | toJson }} - = null + ! at : error calling fromJson: json decode error: invalid character 'o' in literal null (expecting 'u') {{ mustFromJson "not json" | toJson }} - ! at : error calling mustFromJson: invalid character 'o' in literal null (expecting 'u') + ! at : error calling mustFromJson: json decode error: invalid character 'o' in literal null (expecting 'u') {{ toYaml (dict "a" 1) }} = a: 1\n {{ fromYaml "a: 1" | toJson }} @@ -323,7 +331,7 @@ {{ unixEpoch (toDate "2006-01-02" "2020-01-01") }} = 1577836800 {{ duration 90 }} - = 0s + = 1m30s {{ durationRound "1h35m30s" }} = 1h {{ htmlDate 0 }} diff --git a/internal/templater/testdata/func_names.golden b/internal/templater/testdata/func_names.golden index a756e4bd88..c155129b3e 100644 --- a/internal/templater/testdata/func_names.golden +++ b/internal/templater/testdata/func_names.golden @@ -7,6 +7,9 @@ ToSlash absPath add add1 +add1f +addf +adler32Sum adler32sum ago all @@ -18,7 +21,12 @@ b32enc b64dec b64enc base +base32Decode +base32Encode +base64Decode +base64Encode biggest +capitalize cat catLines ceil @@ -29,33 +37,44 @@ compact concat contains date +dateAgo dateInZone dateModify date_in_zone date_modify +deepCopy deepEqual default dict dig dir div +divf duration durationRound +ellipsis +ellipsisBoth empty env exeExt +expandEnv expandenv ext fail first +flatten +flattenDepth float64 floor +fromJSON fromJson fromSlash +fromYAML fromYaml get getHostByName has +hasField hasKey hasPrefix hasSuffix @@ -64,6 +83,7 @@ htmlDate htmlDateInZone indent initial +initials int int64 isAbs @@ -79,21 +99,28 @@ list lower max maxf +md5Sum +md5sum merge +mergeOverwrite min minf mod mul +mulf mustAppend mustChunk mustCompact mustDateModify +mustDeepCopy mustFirst mustFromJson mustFromYaml mustHas mustInitial mustLast +mustMerge +mustMergeOverwrite mustPrepend mustPush mustRegexFind @@ -114,6 +141,7 @@ mustUniq mustWithout must_date_modify nindent +nospace now numCPU omit @@ -122,6 +150,11 @@ osClean osDir osExt osIsAbs +pathBase +pathClean +pathDir +pathExt +pathIsAbs pick pluck plural @@ -129,10 +162,19 @@ prepend push q quote +randAlpha +randAlphaNum +randAscii +randBytes randInt randIntN +randNumeric regexFind regexFindAll +regexFindAllGroups +regexFindAllNamed +regexFindGroups +regexFindNamed regexMatch regexQuoteMeta regexReplaceAll @@ -146,9 +188,13 @@ reverse round seq set +sha1Sum sha1sum +sha256Sum sha256sum +sha512Sum shellQuote +shuffle slice sortAlpha spew @@ -158,18 +204,45 @@ splitLines splitList splitn squote +strSlice sub +subf substr +swapCase ternary title +toBool +toCamelCase +toConstantCase toDate toDecimal +toDotCase +toDuration +toFloat64 +toIndentYAML +toInt +toInt64 +toJSON toJson +toKebabCase +toLocalDate +toLower +toOctal +toPascalCase +toPathCase +toPrettyJSON toPrettyJson +toRawJSON toRawJson toSlash +toSnakeCase toString toStrings +toTitleCase +toUint +toUint64 +toUpper +toYAML toYaml trim trimAll @@ -181,14 +254,19 @@ tuple typeIs typeIsLike typeOf +uncapitalize uniq unixEpoch unset until untilStep +untitle upper urlJoin urlParse uuid +uuidv4 values without +wrap +wrapWith From b0c792f09ee504a5fbecea869c9068af6588a1d2 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 10 Aug 2026 21:11:49 +0200 Subject: [PATCH 3/4] feat(templater): report template deprecations through the verbose logger sprout logs its deprecation notices to stdout by default, which would corrupt the JSON and group output styles. Route them to the Executor's logger at verbose level instead, deduplicated so that a single deprecated call is not reported once per compilation pass. --- internal/templater/funcs.go | 2 +- internal/templater/notices.go | 57 +++++++++++++++++++++++++++++++++++ setup.go | 4 +++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 internal/templater/notices.go diff --git a/internal/templater/funcs.go b/internal/templater/funcs.go index 7622a53c40..5d8b7d1a12 100644 --- a/internal/templater/funcs.go +++ b/internal/templater/funcs.go @@ -63,7 +63,7 @@ var legacySprigAliases = sprout.FunctionAliasMap{ func init() { handler := sprout.New( - sprout.WithLogger(slog.New(slog.DiscardHandler)), + sprout.WithLogger(slog.New(noticeHandler{})), sprout.WithRegistries( taskfuncs.NewRegistry(), backward.NewRegistry(), 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/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 { From fc8fd0f62e0386d32c54c0615d89833b1ca5324d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Mon, 10 Aug 2026 21:17:33 +0200 Subject: [PATCH 4/4] docs(templating): document the move from slim-sprig to sprout Add a migration section listing the renamed functions, the ten argument order changes and the behaviour differences, extend the template function deprecation page with the new aliases, and point the sprig links at the sprout documentation. --- CHANGELOG.md | 8 ++ .../docs/deprecations/template-functions.md | 34 +++++++ website/src/docs/guide.md | 2 +- website/src/docs/reference/templating.md | 92 +++++++++++++++++-- 4 files changed, 128 insertions(+), 8 deletions(-) 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/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.