diff --git a/README.md b/README.md index 236df49b96..86934532c1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ Task is a task runner / build tool that aims to be simpler and easier to use than, for example, GNU Make.
+
+ Taskfiles can be written in YAML or HCL. By default Task looks for Taskfile.yml, Taskfile.yaml, Taskfile.hcl, or a plain Taskfile in your project.
+
Installation | Documentation | Twitter | Bluesky | Mastodon | Discord
diff --git a/compiler.go b/compiler.go index 348a072898..6430459387 100644 --- a/compiler.go +++ b/compiler.go @@ -12,6 +12,7 @@ import ( "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/hclext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/internal/version" @@ -46,45 +47,50 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error) func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { result := env.GetEnviron() + evaluator := hclext.NewHCLEvaluator(result, result, nil) + hasHCL := false specialVars, err := c.getSpecialVars(t, call) if err != nil { return nil, err } for k, v := range specialVars { result.Set(k, ast.Var{Value: v}) + evaluator.SetVar(k, v) } getRangeFunc := func(dir string) func(k string, v ast.Var) error { return func(k string, v ast.Var) error { + if v.Expr != nil || v.ShExpr != nil { + result.Set(k, v) + hasHCL = true + return nil + } cache := &templater.Cache{Vars: result} - // Replace values newVar := templater.ReplaceVar(v, cache) - // If the variable should not be evaluated, but is nil, set it to an empty string - // This stops empty interface errors when using the templater to replace values later if !evaluateShVars && newVar.Value == nil { result.Set(k, ast.Var{Value: ""}) + evaluator.SetVar(k, "") return nil } - // If the variable should not be evaluated and it is set, we can set it and return if !evaluateShVars { result.Set(k, ast.Var{Value: newVar.Value}) + evaluator.SetVar(k, fmt.Sprint(newVar.Value)) return nil } - // Now we can check for errors since we've handled all the cases when we don't want to evaluate if err := cache.Err(); err != nil { return err } - // If the variable is already set, we can set it and return if newVar.Value != nil || newVar.Sh == nil { result.Set(k, ast.Var{Value: newVar.Value}) + evaluator.SetVar(k, fmt.Sprint(newVar.Value)) return nil } - // If the variable is dynamic, we need to resolve it first static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result)) if err != nil { return err } result.Set(k, ast.Var{Value: static}) + evaluator.SetVar(k, static) return nil } } @@ -126,18 +132,38 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* } } - if t == nil || call == nil { - return result, nil - } - - for k, v := range call.Vars.All() { - if err := rangeFunc(k, v); err != nil { + if hasHCL { + resVars, resEnv, err := hclext.NewResolver(result, result, nil).Resolve() + if err != nil { return nil, err } + result = ast.NewVars() + result.Merge(resEnv, nil) + result.Merge(resVars, nil) + evaluator = hclext.NewHCLEvaluator(result, result, nil) + hasHCL = false } - for k, v := range t.Vars.All() { - if err := taskRangeFunc(k, v); err != nil { - return nil, err + + if t != nil && call != nil { + for k, v := range call.Vars.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + for k, v := range t.Vars.All() { + if err := taskRangeFunc(k, v); err != nil { + return nil, err + } + } + + if hasHCL { + resVars, resEnv, err := hclext.NewResolver(result, result, nil).Resolve() + if err != nil { + return nil, err + } + result = ast.NewVars() + result.Merge(resEnv, nil) + result.Merge(resVars, nil) } } diff --git a/completion/fish/task.fish b/completion/fish/task.fish index e8640fe4a5..c54b548ff6 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -48,7 +48,7 @@ complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'executes tasks provided on c complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disables echoing' complete -c $GO_TASK_PROGNAME -l status -d 'exits with non-zero exit code if any of the given tasks is not up-to-date' complete -c $GO_TASK_PROGNAME -l summary -d 'show summary about a task' -complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose which Taskfile to run. Defaults to "Taskfile.yml"' +complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose which Taskfile to run. Defaults to "Taskfile.yml". Also searches for "Taskfile.yaml", "Taskfile.hcl", and "Taskfile"' complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'enables verbose mode' complete -c $GO_TASK_PROGNAME -l version -d 'show Task version' complete -c $GO_TASK_PROGNAME -s w -l watch -d 'enables watch of the given task' diff --git a/completion/zsh/_task b/completion/zsh/_task index ddb888d77b..817c5445f2 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -4,7 +4,7 @@ typeset -A opt_args _GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" -# Listing commands from Taskfile.yml +# Listing commands from Taskfile function __task_list() { local -a scripts cmd local -i enabled=0 @@ -19,7 +19,7 @@ function __task_list() { enabled=1 cmd+=(--taskfile "$taskfile") else - for taskfile in {T,t}askfile{,.dist}.{yaml,yml}; do + for taskfile in Taskfile.yml taskfile.yml Taskfile.yaml taskfile.yaml Taskfile.dist.yml taskfile.dist.yml Taskfile.dist.yaml taskfile.dist.yaml Taskfile.hcl taskfile.hcl Taskfile taskfile; do if [[ -f "$taskfile" ]]; then enabled=1 break diff --git a/executor_call_task.go b/executor_call_task.go new file mode 100644 index 0000000000..423cbd7cf9 --- /dev/null +++ b/executor_call_task.go @@ -0,0 +1,21 @@ +package task + +import ( + "bytes" + "context" + "io" + "strings" + + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile/ast" +) + +func (e *Executor) callTask(name string, vars *ast.Vars) (string, error) { + buf := &bytes.Buffer{} + origStdout, origStderr, origLogger := e.Stdout, e.Stderr, e.Logger + e.Stdout, e.Stderr = buf, buf + e.Logger = &logger.Logger{Stdout: io.Discard, Stderr: io.Discard} + err := e.RunTask(context.Background(), &Call{Task: name, Vars: vars, Silent: true, Indirect: true}) + e.Stdout, e.Stderr, e.Logger = origStdout, origStderr, origLogger + return strings.TrimSpace(buf.String()), err +} diff --git a/go.mod b/go.mod index 95cb8c2fbf..4aa2e1a7df 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 github.com/go-task/template v0.2.0 github.com/google/uuid v1.6.0 + github.com/hashicorp/hcl/v2 v2.24.0 github.com/joho/godotenv v1.5.1 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/otiai10/copy v1.14.1 @@ -36,17 +37,21 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -54,8 +59,12 @@ require ( github.com/skeema/knownhosts v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.37.0 // indirect + github.com/zclconf/go-cty v1.16.3 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/tools v0.31.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 2530252144..aeaf5b235e 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.19.0 h1:Im+SLRgT8maArxv81mULDWN8oKxkzboH07CHesxElq4= @@ -17,6 +19,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= @@ -60,12 +64,16 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 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/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -88,6 +96,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -129,15 +139,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= +github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= @@ -158,9 +174,11 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/hcl_e2e_test.go b/hcl_e2e_test.go new file mode 100644 index 0000000000..5d1959c9f0 --- /dev/null +++ b/hcl_e2e_test.go @@ -0,0 +1,47 @@ +package task + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestHCLE2E(t *testing.T) { + cmd := exec.Command("go", "run", "./cmd/task", "-t", filepath.Join("testdata", "HCLE2ETest", "Taskfile.hcl"), "all") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("task run failed: %v\n%s", err, out) + } + output := string(out) + if !strings.Contains(output, "BUILD:1.2.3") { + t.Fatalf("missing build output: %s", output) + } + one := strings.Index(output, "ONE-DONE") + two := strings.Index(output, "TWO-DONE") + if one == -1 || two == -1 || one > two { + t.Fatalf("dependency order wrong: %s", output) + } + if !strings.Contains(output, "LINT MODE fast") { + t.Fatalf("missing lint output: %s", output) + } + if !strings.Contains(output, "SCOPED 123 456 123456") { + t.Fatalf("missing scoped vars output: %s", output) + } + if !strings.Contains(output, "FINAL foo") { + t.Fatalf("missing final output: %s", output) + } + idx := strings.Index(output, "PATH=") + if idx == -1 || idx+5 >= len(output) || output[idx+5] == '\n' { + t.Fatalf("missing path output: %s", output) + } + if !strings.Contains(output, "GREET=HELLO, BOB!") { + t.Fatalf("missing greet output: %s", output) + } + if !strings.Contains(output, "EXT=base-ext") { + t.Fatalf("missing ext output: %s", output) + } + if !strings.Contains(output, "PLATFORM=linux") { + t.Fatalf("missing platform output: %s", output) + } +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index dab9fdf8f4..cb55ef02cf 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -26,6 +26,9 @@ was specified, or lists all tasks if an unknown task name was specified. Example: 'task hello' with the following 'Taskfile.yml' file will generate an 'output.txt' file with the content "hello". +Taskfiles can be written in YAML or HCL. By default Task looks for +'Taskfile.yml', 'Taskfile.yaml', 'Taskfile.hcl', or a bare 'Taskfile'. + ''' version: '3' tasks: @@ -128,7 +131,7 @@ func init() { pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.") pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.") pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.") - pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`) + pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml". Also searches for "Taskfile.yaml", "Taskfile.hcl", and "Taskfile".`) pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed].") pflag.StringVar(&Output.Group.Begin, "output-group-begin", "", "Message template to print before a task's grouped output.") pflag.StringVar(&Output.Group.End, "output-group-end", "", "Message template to print after a task's grouped output.") @@ -136,7 +139,7 @@ func init() { pflag.BoolVarP(&Color, "color", "c", true, "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.") pflag.IntVarP(&Concurrency, "concurrency", "C", 0, "Limit number of tasks to run concurrently.") pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.") - pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") + pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml,hcl} or $HOME/{T,t}askfile.") pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") // Gentle force experiment will override the force flag and add a new force-all flag diff --git a/internal/hclext/diagnostics_test.go b/internal/hclext/diagnostics_test.go new file mode 100644 index 0000000000..2480af048c --- /dev/null +++ b/internal/hclext/diagnostics_test.go @@ -0,0 +1,26 @@ +package hclext + +import ( + "fmt" + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestUnsupportedTupleErrorIncludesRange(t *testing.T) { + expr, diags := hclsyntax.ParseExpression([]byte(`tuple("a", "b")`), "Taskfile.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + vars := ast.NewVars() + vars.Set("INVALID", ast.Var{Expr: expr}) + r := NewResolver(vars, nil, nil) + _, _, err := r.Resolve() + require.Error(t, err) + rng := expr.Range() + expected := fmt.Sprintf("Taskfile.hcl:%d,%d-%d,%d", rng.Start.Line, rng.Start.Column, rng.End.Line, rng.End.Column) + require.Contains(t, err.Error(), "unsupported value type tuple") + require.Contains(t, err.Error(), expected) +} diff --git a/internal/hclext/evaluator.go b/internal/hclext/evaluator.go new file mode 100644 index 0000000000..ae80c1534d --- /dev/null +++ b/internal/hclext/evaluator.go @@ -0,0 +1,315 @@ +package hclext + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/taskfile/ast" +) + +type TaskRunner func(name string, vars *ast.Vars) (string, error) + +type HCLEvaluator struct { + EvalCtx *hcl.EvalContext + vars map[string]cty.Value + env map[string]cty.Value +} + +func NewHCLEvaluator(vars, env *ast.Vars, runner TaskRunner) *HCLEvaluator { + varVals := map[string]cty.Value{} + if vars != nil { + for k, v := range vars.All() { + if v.Value != nil { + varVals[k] = toCty(v.Value) + } + } + } + envVals := map[string]cty.Value{} + if env != nil { + for k, v := range env.All() { + if v.Value != nil { + envVals[k] = toCty(v.Value) + } + } + } + ctx := &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "vars": cty.ObjectVal(varVals), + "env": cty.ObjectVal(envVals), + }, + Functions: builtinFunctions(runner), + } + return &HCLEvaluator{EvalCtx: ctx, vars: varVals, env: envVals} +} + +func builtinFunctions(runner TaskRunner) map[string]function.Function { + funcs := map[string]function.Function{ + "upper": stringFunc(strings.ToUpper), + "lower": stringFunc(strings.ToLower), + "join": joinFunc(), + "split": splitFunc(), + "env": envFunc(), + "sh": shellFunc("/bin/sh"), + "bash": shellFunc("/bin/bash"), + "zsh": shellFunc("/bin/zsh"), + "tuple": tupleFunc(), + } + if runner != nil { + funcs["task"] = taskFunc(runner) + } + return funcs +} + +func stringFunc(fn func(string) string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{{Name: "s", Type: cty.String}}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(fn(args[0].AsString())), nil + }, + }) +} + +func joinFunc() function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + {Name: "list", Type: cty.List(cty.String)}, + {Name: "delim", Type: cty.String}, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + vals := args[0].AsValueSlice() + parts := make([]string, len(vals)) + for i, v := range vals { + parts[i] = v.AsString() + } + return cty.StringVal(strings.Join(parts, args[1].AsString())), nil + }, + }) +} + +func splitFunc() function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + {Name: "s", Type: cty.String}, + {Name: "delim", Type: cty.String}, + }, + Type: func(args []cty.Value) (cty.Type, error) { + return cty.List(cty.String), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + parts := strings.Split(args[0].AsString(), args[1].AsString()) + vals := make([]cty.Value, len(parts)) + for i, p := range parts { + vals[i] = cty.StringVal(p) + } + return cty.ListVal(vals), nil + }, + }) +} + +func envFunc() function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{{Name: "name", Type: cty.String}}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(os.Getenv(args[0].AsString())), nil + }, + }) +} + +func tupleFunc() function.Function { + return function.New(&function.Spec{ + VarParam: &function.Parameter{Name: "vals", Type: cty.DynamicPseudoType}, + Type: func(args []cty.Value) (cty.Type, error) { + types := make([]cty.Type, len(args)) + for i, v := range args { + types[i] = v.Type() + } + return cty.Tuple(types), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.TupleVal(args), nil + }, + }) +} + +func toCty(v any) cty.Value { + switch val := v.(type) { + case string: + return cty.StringVal(val) + case bool: + return cty.BoolVal(val) + case int: + return cty.NumberIntVal(int64(val)) + case int64: + return cty.NumberIntVal(val) + case float32: + return cty.NumberFloatVal(float64(val)) + case float64: + return cty.NumberFloatVal(val) + case []any: + vals := make([]cty.Value, len(val)) + for i, e := range val { + vals[i] = toCty(e) + } + return cty.TupleVal(vals) + case map[string]any: + attrs := make(map[string]cty.Value) + for k, e := range val { + attrs[k] = toCty(e) + } + return cty.ObjectVal(attrs) + default: + return cty.StringVal(fmt.Sprint(v)) + } +} + +func fromCty(val cty.Value, expr hcl.Expression) (any, error) { + switch { + case val.Type() == cty.String: + return val.AsString(), nil + case val.Type() == cty.Number: + bf := val.AsBigFloat() + if i, acc := bf.Int64(); acc == 1 { + return i, nil + } + f, _ := bf.Float64() + return f, nil + case val.Type() == cty.Bool: + return val.True(), nil + case val.Type().IsObjectType(): + attrs := make(map[string]any) + for k := range val.Type().AttributeTypes() { + v := val.GetAttr(k) + res, err := fromCty(v, expr) + if err != nil { + return nil, err + } + attrs[k] = res + } + return attrs, nil + case val.Type().IsTupleType(): + if _, ok := expr.(*hclsyntax.TupleConsExpr); ok { + vals := val.AsValueSlice() + res := make([]any, len(vals)) + for i, v := range vals { + r, err := fromCty(v, expr) + if err != nil { + return nil, err + } + res[i] = r + } + return res, nil + } + rng := expr.Range() + return nil, fmt.Errorf("unsupported value type tuple for %s:%d,%d-%d,%d", filepathext.TryAbsToRel(rng.Filename), rng.Start.Line, rng.Start.Column, rng.End.Line, rng.End.Column) + default: + rng := expr.Range() + return nil, fmt.Errorf("unsupported value type %s for %s:%d,%d-%d,%d", val.Type().FriendlyName(), filepathext.TryAbsToRel(rng.Filename), rng.Start.Line, rng.Start.Column, rng.End.Line, rng.End.Column) + } +} + +func shellFunc(shell string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{{Name: "cmd", Type: cty.String}}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + cmd := exec.Command(shell, "-c", args[0].AsString()) + out, err := cmd.CombinedOutput() + if err != nil { + return cty.NilVal, fmt.Errorf("%s: %w", shell, err) + } + return cty.StringVal(strings.TrimSpace(string(out))), nil + }, + }) +} + +func taskFunc(runner TaskRunner) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{{Name: "name", Type: cty.String}}, + VarParam: &function.Parameter{Name: "vars", Type: cty.DynamicPseudoType}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + name := args[0].AsString() + var depVars *ast.Vars + if len(args) > 1 && args[1].Type().IsObjectType() { + depVars = ast.NewVars() + for k := range args[1].Type().AttributeTypes() { + val := args[1].GetAttr(k) + var depVal string + switch { + case val.Type() == cty.String: + depVal = val.AsString() + case val.Type() == cty.Number: + bf := val.AsBigFloat() + depVal = bf.Text('f', -1) + case val.Type() == cty.Bool: + if val.True() { + depVal = "true" + } else { + depVal = "false" + } + default: + depVal = val.GoString() + } + depVars.Set(k, ast.Var{Value: depVal}) + } + } + out, err := runner(name, depVars) + if err != nil { + return cty.NilVal, err + } + return cty.StringVal(out), nil + }, + }) +} + +func (e *HCLEvaluator) SetVar(name, value string) { + if e.vars == nil { + e.vars = map[string]cty.Value{} + } + e.vars[name] = cty.StringVal(value) + e.EvalCtx.Variables["vars"] = cty.ObjectVal(e.vars) +} + +func (e *HCLEvaluator) EvalValue(expr hcl.Expression) (any, error) { + val, diags := expr.Value(e.EvalCtx) + if diags.HasErrors() { + return nil, diags + } + return fromCty(val, expr) +} + +func (e *HCLEvaluator) EvalString(expr hcl.Expression) (string, error) { + v, err := e.EvalValue(expr) + if err != nil { + return "", err + } + switch val := v.(type) { + case string: + return val, nil + case int: + return fmt.Sprintf("%d", val), nil + case int64: + return fmt.Sprintf("%d", val), nil + case float64: + return fmt.Sprintf("%v", val), nil + case bool: + if val { + return "true", nil + } + return "false", nil + default: + rng := expr.Range() + return "", fmt.Errorf("unsupported value type %T for %s:%d,%d-%d,%d", v, filepathext.TryAbsToRel(rng.Filename), rng.Start.Line, rng.Start.Column, rng.End.Line, rng.End.Column) + } +} diff --git a/internal/hclext/resolve.go b/internal/hclext/resolve.go new file mode 100644 index 0000000000..aeda81bcbc --- /dev/null +++ b/internal/hclext/resolve.go @@ -0,0 +1,209 @@ +package hclext + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + + "github.com/go-task/task/v3/taskfile/ast" +) + +// Resolver evaluates HCL expressions for vars and env allowing recursive references. +// It resolves variables on demand, detecting cycles and caching results. +type Resolver struct { + vars *ast.Vars + env *ast.Vars + runner TaskRunner + varCache map[string]any + envCache map[string]any + varStack map[string]bool + envStack map[string]bool +} + +// NewResolver creates a new Resolver. +func NewResolver(vars, env *ast.Vars, runner TaskRunner) *Resolver { + r := &Resolver{ + vars: vars, + env: env, + runner: runner, + varCache: map[string]any{}, + envCache: map[string]any{}, + varStack: map[string]bool{}, + envStack: map[string]bool{}, + } + if vars != nil { + for k, v := range vars.All() { + if v.Expr == nil { + if v.Value != nil { + r.varCache[k] = v.Value + } + } + } + } + if env != nil { + for k, v := range env.All() { + if v.Expr == nil { + if v.Value != nil { + r.envCache[k] = v.Value + } + } + } + } + return r +} + +// Resolve evaluates all expressions and returns new vars and env with values set. +func (r *Resolver) Resolve() (*ast.Vars, *ast.Vars, error) { + if r.vars != nil { + for k := range r.vars.All() { + if _, ok := r.varCache[k]; !ok { + if _, err := r.resolveVar(k); err != nil { + return nil, nil, err + } + } + } + } + if r.env != nil { + for k := range r.env.All() { + if _, ok := r.envCache[k]; !ok { + if _, err := r.resolveEnv(k); err != nil { + return nil, nil, err + } + } + } + } + vars := ast.NewVars() + for k, v := range r.varCache { + vars.Set(k, ast.Var{Value: v}) + } + env := ast.NewVars() + for k, v := range r.envCache { + env.Set(k, ast.Var{Value: v}) + } + return vars, env, nil +} + +func (r *Resolver) resolveVar(name string) (any, error) { + if v, ok := r.varCache[name]; ok { + return v, nil + } + if r.varStack[name] { + return nil, fmt.Errorf("cyclic variable reference for %s", name) + } + if r.vars == nil { + return nil, fmt.Errorf("undefined variable %s", name) + } + v, ok := r.vars.Get(name) + if !ok { + return nil, fmt.Errorf("undefined variable %s", name) + } + if v.Expr == nil { + r.varCache[name] = v.Value + return v.Value, nil + } + r.varStack[name] = true + defer delete(r.varStack, name) + depsVars, depsEnv := findDeps(v.Expr) + for dv := range depsVars { + if _, err := r.resolveVar(dv); err != nil { + return nil, err + } + } + for de := range depsEnv { + if _, err := r.resolveEnv(de); err != nil { + return nil, err + } + } + eval := NewHCLEvaluator(varsFromCache(r.varCache), envFromCache(r.envCache), r.runner) + val, err := eval.EvalValue(v.Expr) + if err != nil { + return nil, err + } + r.varCache[name] = val + return val, nil +} + +func (r *Resolver) resolveEnv(name string) (any, error) { + if v, ok := r.envCache[name]; ok { + return v, nil + } + if r.envStack[name] { + return nil, fmt.Errorf("cyclic env reference for %s", name) + } + if r.env != nil { + if v, ok := r.env.Get(name); ok { + if v.Expr == nil { + r.envCache[name] = v.Value + return v.Value, nil + } + r.envStack[name] = true + defer delete(r.envStack, name) + depsVars, depsEnv := findDeps(v.Expr) + for dv := range depsVars { + if _, err := r.resolveVar(dv); err != nil { + return nil, err + } + } + for de := range depsEnv { + if _, err := r.resolveEnv(de); err != nil { + return nil, err + } + } + eval := NewHCLEvaluator(varsFromCache(r.varCache), envFromCache(r.envCache), r.runner) + val, err := eval.EvalValue(v.Expr) + if err != nil { + return nil, err + } + r.envCache[name] = val + return val, nil + } + } + // Not defined; return empty string + r.envCache[name] = "" + return "", nil +} + +func varsFromCache(m map[string]any) *ast.Vars { + vs := ast.NewVars() + for k, v := range m { + vs.Set(k, ast.Var{Value: v}) + } + return vs +} + +func envFromCache(m map[string]any) *ast.Vars { + vs := ast.NewVars() + for k, v := range m { + vs.Set(k, ast.Var{Value: v}) + } + return vs +} + +func findDeps(expr hcl.Expression) (vars map[string]struct{}, env map[string]struct{}) { + vars = map[string]struct{}{} + env = map[string]struct{}{} + if expr == nil { + return + } + for _, tr := range expr.Variables() { + if len(tr) != 2 { + continue + } + root := tr.RootName() + attr, ok := tr[1].(hcl.TraverseAttr) + if !ok { + continue + } + switch root { + case "vars": + vars[attr.Name] = struct{}{} + case "env": + env[attr.Name] = struct{}{} + } + } + return +} + +// Helper to satisfy linter for unused imports +var _ = cty.String diff --git a/task.go b/task.go index fc3f17662d..81611bc8e6 100644 --- a/task.go +++ b/task.go @@ -15,6 +15,7 @@ import ( "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/fingerprint" + "github.com/go-task/task/v3/internal/hclext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/slicesext" @@ -259,23 +260,15 @@ func (e *Executor) mkdir(t *ast.Task) error { } func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { - g, ctx := errgroup.WithContext(ctx) - reacquire := e.releaseConcurrencyLimit() defer reacquire() for _, d := range t.Deps { - d := d - g.Go(func() error { - err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) - if err != nil { - return err - } - return nil - }) + if err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}); err != nil { + return err + } } - - return g.Wait() + return nil } func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, deferredExitCode *uint8) { @@ -290,15 +283,25 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, deferredExitCode cmd := t.Cmds[i] vars, _ := e.Compiler.GetVariables(origTask, call) cache := &templater.Cache{Vars: vars} + runtimeEnv := env.GetEnviron() + hclEval := hclext.NewHCLEvaluator(vars, runtimeEnv, e.callTask) extra := map[string]any{} if deferredExitCode != nil && *deferredExitCode > 0 { extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode) } - cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) - cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) - cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) + if origTask.IsHCL && cmd.Expr != nil { + val, err := hclEval.EvalString(cmd.Expr) + if err != nil { + return + } + cmd.Cmd = val + } else { + cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) + cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) + cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) + } if err := e.runCommand(ctx, t, call, i); err != nil { e.Logger.VerboseErrf(logger.Yellow, "task: ignored error in deferred cmd: %s\n", err.Error()) diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 3dab193c55..3675eecb55 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -1,6 +1,7 @@ package ast import ( + "github.com/hashicorp/hcl/v2" "gopkg.in/yaml.v3" "github.com/go-task/task/v3/errors" @@ -10,6 +11,7 @@ import ( // Cmd is a task command type Cmd struct { Cmd string + Expr hcl.Expression Task string For *For Silent bool @@ -27,6 +29,7 @@ func (c *Cmd) DeepCopy() *Cmd { } return &Cmd{ Cmd: c.Cmd, + Expr: c.Expr, Task: c.Task, For: c.For.DeepCopy(), Silent: c.Silent, diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 17fa976ae5..d5d01fe38f 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -41,6 +41,7 @@ type Task struct { Run string Platforms []*Platform Watch bool + IsHCL bool Location *Location // Populated during merging Namespace string @@ -214,6 +215,8 @@ func (t *Task) DeepCopy() *Task { Prefix: t.Prefix, IgnoreError: t.IgnoreError, Run: t.Run, + Watch: t.Watch, + IsHCL: t.IsHCL, IncludeVars: t.IncludeVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), Platforms: deepcopy.Slice(t.Platforms), diff --git a/taskfile/ast/var.go b/taskfile/ast/var.go index 0806e423f6..7988941fed 100644 --- a/taskfile/ast/var.go +++ b/taskfile/ast/var.go @@ -1,6 +1,7 @@ package ast import ( + "github.com/hashicorp/hcl/v2" "gopkg.in/yaml.v3" "github.com/go-task/task/v3/errors" @@ -8,11 +9,13 @@ import ( // Var represents either a static or dynamic variable. type Var struct { - Value any - Live any - Sh *string - Ref string - Dir string + Value any + Live any + Sh *string + Ref string + Dir string + Expr hcl.Expression + ShExpr hcl.Expression } func (v *Var) UnmarshalYAML(node *yaml.Node) error { diff --git a/taskfile/discovery_test.go b/taskfile/discovery_test.go new file mode 100644 index 0000000000..761fa4c5ee --- /dev/null +++ b/taskfile/discovery_test.go @@ -0,0 +1,61 @@ +package taskfile + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + return path +} + +func TestDiscoveryPrefersYAMLOverHCL(t *testing.T) { + dir := t.TempDir() + yamlPath := writeFile(t, dir, "Taskfile.yml", "version: '3'\n") + writeFile(t, dir, "Taskfile.hcl", "version = 3\n") + + node, err := NewFileNode("", dir) + if err != nil { + t.Fatalf("NewFileNode returned error: %v", err) + } + if node.Location() != yamlPath { + t.Fatalf("expected %s, got %s", yamlPath, node.Location()) + } +} + +func TestHCLLoaderInvoked(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "Taskfile.hcl", "version = \"3\"\n") + + node, err := NewFileNode("", dir) + if err != nil { + t.Fatalf("NewFileNode returned error: %v", err) + } + r := NewReader() + _, err = r.Read(context.Background(), node) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExtensionlessTaskfile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "Taskfile", "version = \"3\"\n") + + node, err := NewFileNode("", dir) + if err != nil { + t.Fatalf("NewFileNode returned error: %v", err) + } + r := NewReader() + _, err = r.Read(context.Background(), node) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/taskfile/hcl_evaluator_test.go b/taskfile/hcl_evaluator_test.go new file mode 100644 index 0000000000..05196b5ae4 --- /dev/null +++ b/taskfile/hcl_evaluator_test.go @@ -0,0 +1,38 @@ +package taskfile + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/env" + "github.com/go-task/task/v3/internal/hclext" + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestHCLEvaluatorExpressions(t *testing.T) { + t.Setenv("HOME", "/home/test") + vars := ast.NewVars() + vars.Set("FOO", ast.Var{Value: "bar"}) + eval := hclext.NewHCLEvaluator(vars, env.GetEnviron(), nil) + + expr1, diags := hclsyntax.ParseTemplate([]byte("${vars.FOO}"), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + v, err := eval.EvalString(expr1) + require.NoError(t, err) + require.Equal(t, "bar", v) + + expr2, diags := hclsyntax.ParseTemplate([]byte("${upper(vars.FOO)}"), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + v, err = eval.EvalString(expr2) + require.NoError(t, err) + require.Equal(t, "BAR", v) + + expr3, diags := hclsyntax.ParseTemplate([]byte("${env(\"HOME\")}"), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + v, err = eval.EvalString(expr3) + require.NoError(t, err) + require.Equal(t, "/home/test", v) +} diff --git a/taskfile/hcl_loader_test.go b/taskfile/hcl_loader_test.go new file mode 100644 index 0000000000..b8142c132c --- /dev/null +++ b/taskfile/hcl_loader_test.go @@ -0,0 +1,72 @@ +package taskfile + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestHCLLoader(t *testing.T) { + t.Parallel() + + data := []byte(`version = "3" + task "build" { + desc = "Build the project" + vars { USER = "world" } + env { GREETING = "hi" } + cmds = ["echo hello ${vars.USER}"] + } + `) + + loader := HCLLoader{} + tf, err := loader.Load(data, "Taskfile.hcl") + require.NoError(t, err) + + require.NotNil(t, tf.Version) + require.True(t, tf.Version.Equal(ast.V3)) + + build, ok := tf.Tasks.Get("build") + require.True(t, ok) + require.Equal(t, "Build the project", build.Desc) + require.Len(t, build.Cmds, 1) + require.NotNil(t, build.Cmds[0].Expr) + + v, ok := build.Vars.Get("USER") + require.True(t, ok) + require.NotNil(t, v.Expr) + + e, ok := build.Env.Get("GREETING") + require.True(t, ok) + require.NotNil(t, e.Expr) +} + +func TestHCLLoaderInvalid(t *testing.T) { + t.Parallel() + + data := []byte(`version = "3" + task "build" { + desc = "Missing brace" + `) + + loader := HCLLoader{} + _, err := loader.Load(data, "Taskfile.hcl") + require.Error(t, err) + require.Contains(t, err.Error(), "Taskfile.hcl") + require.Contains(t, err.Error(), ":2") +} + +func TestHCLLoaderRejectsGoTemplates(t *testing.T) { + t.Parallel() + + data := []byte(`version = "3" + task "demo" { + cmds = ["echo {{.FOO}}"] + } + `) + + loader := HCLLoader{} + _, err := loader.Load(data, "Taskfile.hcl") + require.Error(t, err) +} diff --git a/taskfile/hcl_recursive_vars_test.go b/taskfile/hcl_recursive_vars_test.go new file mode 100644 index 0000000000..858327a2a0 --- /dev/null +++ b/taskfile/hcl_recursive_vars_test.go @@ -0,0 +1,59 @@ +package taskfile + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/env" + "github.com/go-task/task/v3/internal/hclext" + "github.com/go-task/task/v3/taskfile/ast" +) + +func parseExpr(t *testing.T, s string) hcl.Expression { + t.Helper() + expr, diags := hclsyntax.ParseExpression([]byte(s), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + return expr +} + +func TestRecursiveVars(t *testing.T) { + vars := ast.NewVars() + vars.Set("GREETING", ast.Var{Expr: parseExpr(t, `"Hello, ${vars.NAME}!"`)}) + vars.Set("NAME", ast.Var{Expr: parseExpr(t, `"BOB"`)}) + vars.Set("UPPER_GREETING", ast.Var{Expr: parseExpr(t, `upper(vars.GREETING)`)}) + + resolver := hclext.NewResolver(vars, env.GetEnviron(), nil) + resolved, _, err := resolver.Resolve() + require.NoError(t, err) + + g, _ := resolved.Get("GREETING") + require.Equal(t, "Hello, BOB!", g.Value) + u, _ := resolved.Get("UPPER_GREETING") + require.Equal(t, "HELLO, BOB!", u.Value) +} + +func TestOrderIndependence(t *testing.T) { + vars := ast.NewVars() + vars.Set("FINAL", ast.Var{Expr: parseExpr(t, `upper(vars.INTERMEDIATE)`)}) + vars.Set("INTERMEDIATE", ast.Var{Expr: parseExpr(t, `"${vars.BASE} + ok"`)}) + vars.Set("BASE", ast.Var{Expr: parseExpr(t, `"yup"`)}) + + resolver := hclext.NewResolver(vars, env.GetEnviron(), nil) + resolved, _, err := resolver.Resolve() + require.NoError(t, err) + + v, _ := resolved.Get("FINAL") + require.Equal(t, "YUP + OK", v.Value) +} + +func TestCyclicReference(t *testing.T) { + vars := ast.NewVars() + vars.Set("LOOP", ast.Var{Expr: parseExpr(t, `"${vars.LOOP}"`)}) + + resolver := hclext.NewResolver(vars, env.GetEnviron(), nil) + _, _, err := resolver.Resolve() + require.Error(t, err) +} diff --git a/taskfile/hcl_runtime_test.go b/taskfile/hcl_runtime_test.go new file mode 100644 index 0000000000..ba7f97c13b --- /dev/null +++ b/taskfile/hcl_runtime_test.go @@ -0,0 +1,59 @@ +package taskfile + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/env" + "github.com/go-task/task/v3/internal/hclext" + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestBlockSyntaxEnforced(t *testing.T) { + data := []byte(`version = "3" + vars = { FOO = "bar" } + `) + loader := HCLLoader{} + _, err := loader.Load(data, "Taskfile.hcl") + require.Error(t, err) +} + +func TestShFunctionSuccess(t *testing.T) { + eval := hclext.NewHCLEvaluator(nil, env.GetEnviron(), nil) + expr, diags := hclsyntax.ParseExpression([]byte(`sh("echo hi")`), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + v, err := eval.EvalString(expr) + require.NoError(t, err) + require.Equal(t, "hi", v) +} + +func TestShFunctionFail(t *testing.T) { + eval := hclext.NewHCLEvaluator(nil, env.GetEnviron(), nil) + expr, diags := hclsyntax.ParseExpression([]byte(`sh("exit 1")`), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + _, err := eval.EvalString(expr) + require.Error(t, err) +} + +func TestTaskFunctionStdoutCapture(t *testing.T) { + runner := func(name string, vars *ast.Vars) (string, error) { + return "output", nil + } + eval := hclext.NewHCLEvaluator(nil, env.GetEnviron(), runner) + expr, diags := hclsyntax.ParseExpression([]byte(`task("build")`), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + v, err := eval.EvalString(expr) + require.NoError(t, err) + require.Equal(t, "output", v) +} + +func TestInvalidReference(t *testing.T) { + eval := hclext.NewHCLEvaluator(ast.NewVars(), env.GetEnviron(), nil) + expr, diags := hclsyntax.ParseExpression([]byte(`FOO`), "test.hcl", hcl.InitialPos) + require.False(t, diags.HasErrors()) + _, err := eval.EvalString(expr) + require.Error(t, err) +} diff --git a/taskfile/loader.go b/taskfile/loader.go new file mode 100644 index 0000000000..bfb945ae80 --- /dev/null +++ b/taskfile/loader.go @@ -0,0 +1,46 @@ +package taskfile + +import ( + stdErrors "errors" + + "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/taskfile/ast" +) + +// Loader defines the behavior required to load a Taskfile from raw data. +// +// Note: the returned [ast.Taskfile] is still backed by YAML-specific +// unmarshalling logic within the ast package. Loaders for alternative +// formats must populate the AST structures directly without relying on the +// YAML-only helpers. Future work will extract those bindings out of the ast +// package so it becomes truly format agnostic. +type Loader interface { + Load(data []byte, location string) (*ast.Taskfile, error) +} + +// YAMLLoader implements [Loader] using YAML as the configuration format. +type YAMLLoader struct{} + +// Load parses the given data as YAML into a Taskfile structure. +func (YAMLLoader) Load(data []byte, location string) (*ast.Taskfile, error) { + var tf ast.Taskfile + if err := yaml.Unmarshal(data, &tf); err != nil { + taskfileDecodeErr := &errors.TaskfileDecodeError{} + if stdErrors.As(err, &taskfileDecodeErr) { + snippet := NewSnippet(data, + WithLine(taskfileDecodeErr.Line), + WithColumn(taskfileDecodeErr.Column), + WithPadding(2), + ) + return nil, taskfileDecodeErr.WithFileInfo(location, snippet.String()) + } + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: err} + } + return &tf, nil +} + +// HCLLoader implements [Loader] using HCL as the configuration format. +type HCLLoader struct{} diff --git a/taskfile/loader_hcl.go b/taskfile/loader_hcl.go new file mode 100644 index 0000000000..d51357ac38 --- /dev/null +++ b/taskfile/loader_hcl.go @@ -0,0 +1,248 @@ +package taskfile + +import ( + "bytes" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/taskfile/ast" +) + +// Load parses the given data as HCL into a Taskfile structure. +func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) { + if bytes.Contains(data, []byte("{{")) { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: fmt.Errorf("go templates are not supported in HCL Taskfiles")} + } + parser := hclparse.NewParser() + file, diags := parser.ParseHCL(data, location) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + + schema := &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + {Name: "version", Required: true}, + }, + Blocks: []hcl.BlockHeaderSchema{ + {Type: "vars"}, + {Type: "env"}, + {Type: "task", LabelNames: []string{"name"}}, + }, + } + + content, diags := file.Body.Content(schema) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + + versionVal, diags := content.Attributes["version"].Expr.Value(nil) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + version, err := semver.NewVersion(versionVal.AsString()) + if err != nil { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: err} + } + + tf := &ast.Taskfile{Version: version, Tasks: ast.NewTasks()} + + if blocks := content.Blocks.OfType("vars"); len(blocks) > 0 { + vars, err := parseVarsBlock(blocks[0], location) + if err != nil { + return nil, err + } + tf.Vars = vars + } + if blocks := content.Blocks.OfType("env"); len(blocks) > 0 { + env, err := parseVarsBlock(blocks[0], location) + if err != nil { + return nil, err + } + tf.Env = env + } + + for _, block := range content.Blocks.OfType("task") { + task, err := parseTask(block, location) + if err != nil { + return nil, err + } + tf.Tasks.Set(task.Task, task) + } + + if tf.Vars == nil { + tf.Vars = ast.NewVars() + } + if tf.Env == nil { + tf.Env = ast.NewVars() + } + + return tf, nil +} + +func parseTask(block *hcl.Block, location string) (*ast.Task, error) { + t := &ast.Task{ + Task: block.Labels[0], + Cmds: []*ast.Cmd{}, + Vars: ast.NewVars(), + Env: ast.NewVars(), + IsHCL: true, + } + + schema := &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + {Name: "desc"}, + {Name: "cmds"}, + {Name: "deps"}, + }, + Blocks: []hcl.BlockHeaderSchema{ + {Type: "vars"}, + {Type: "env"}, + }, + } + content, diags := block.Body.Content(schema) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + + if attr, ok := content.Attributes["desc"]; ok { + var desc string + diags := gohcl.DecodeExpression(attr.Expr, nil, &desc) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + t.Desc = desc + } + + if attr, ok := content.Attributes["cmds"]; ok { + if tuple, ok := attr.Expr.(*hclsyntax.TupleConsExpr); ok { + for _, expr := range tuple.Exprs { + t.Cmds = append(t.Cmds, &ast.Cmd{Expr: expr}) + } + } else { + t.Cmds = append(t.Cmds, &ast.Cmd{Expr: attr.Expr}) + } + } + + if attr, ok := content.Attributes["deps"]; ok { + deps, err := parseDeps(attr.Expr, location) + if err != nil { + return nil, err + } + t.Deps = deps + } + + if blocks := content.Blocks.OfType("vars"); len(blocks) > 0 { + vars, err := parseVarsBlock(blocks[0], location) + if err != nil { + return nil, err + } + t.Vars = vars + } + if blocks := content.Blocks.OfType("env"); len(blocks) > 0 { + env, err := parseVarsBlock(blocks[0], location) + if err != nil { + return nil, err + } + t.Env = env + } + + return t, nil +} + +func parseVarsExpr(expr hcl.Expression, location string) (*ast.Vars, error) { + obj, ok := expr.(*hclsyntax.ObjectConsExpr) + if !ok { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: hcl.Diagnostics{}} + } + vars := ast.NewVars() + for _, item := range obj.Items { + key, diags := objectKey(item.KeyExpr) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + v := ast.Var{} + if inner, ok := item.ValueExpr.(*hclsyntax.ObjectConsExpr); ok { + for _, innerItem := range inner.Items { + attrKey, diags := objectKey(innerItem.KeyExpr) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + if attrKey == "sh" { + v.ShExpr = innerItem.ValueExpr + } + } + } else { + v.Expr = item.ValueExpr + } + vars.Set(key, v) + } + return vars, nil +} + +func parseVarsBlock(b *hcl.Block, location string) (*ast.Vars, error) { + body, ok := b.Body.(*hclsyntax.Body) + if !ok { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: hcl.Diagnostics{}} + } + vars := ast.NewVars() + for name, attr := range body.Attributes { + v := ast.Var{} + if obj, ok := attr.Expr.(*hclsyntax.ObjectConsExpr); ok { + for _, inner := range obj.Items { + attrKey, diags := objectKey(inner.KeyExpr) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + if attrKey == "sh" { + v.ShExpr = inner.ValueExpr + } + } + } else { + v.Expr = attr.Expr + } + vars.Set(name, v) + } + return vars, nil +} + +func parseDeps(expr hcl.Expression, location string) ([]*ast.Dep, error) { + tuple, ok := expr.(*hclsyntax.TupleConsExpr) + if !ok { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: hcl.Diagnostics{}} + } + deps := make([]*ast.Dep, 0, len(tuple.Exprs)) + for _, e := range tuple.Exprs { + call, ok := e.(*hclsyntax.FunctionCallExpr) + if !ok || call.Name != "task" || len(call.Args) == 0 { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: hcl.Diagnostics{}} + } + var name string + diags := gohcl.DecodeExpression(call.Args[0], nil, &name) + if diags.HasErrors() { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags} + } + dep := &ast.Dep{Task: name} + if len(call.Args) > 1 { + vars, err := parseVarsExpr(call.Args[1], location) + if err != nil { + return nil, err + } + dep.Vars = vars + } + deps = append(deps, dep) + } + return deps, nil +} + +func objectKey(expr hcl.Expression) (string, hcl.Diagnostics) { + var key string + diags := gohcl.DecodeExpression(expr, nil, &key) + return key, diags +} diff --git a/taskfile/loader_test.go b/taskfile/loader_test.go new file mode 100644 index 0000000000..9002e4bde1 --- /dev/null +++ b/taskfile/loader_test.go @@ -0,0 +1,23 @@ +package taskfile + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestYAMLLoader(t *testing.T) { + t.Parallel() + + data := []byte("version: '3'\n\ntasks:\n default:\n cmds:\n - echo hello\n") + loader := YAMLLoader{} + tf, err := loader.Load(data, "Taskfile.yml") + require.NoError(t, err) + + var expected ast.Taskfile + require.NoError(t, yaml.Unmarshal(data, &expected)) + require.Equal(t, &expected, tf) +} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index faa6616db4..8256f2507a 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -17,7 +17,8 @@ import ( // An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. type HTTPNode struct { *baseNode - url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml) + url *url.URL // original URL provided by the user + resolved *url.URL // resolved URL pointing to the actual remote file } func NewHTTPNode( @@ -41,6 +42,9 @@ func NewHTTPNode( } func (node *HTTPNode) Location() string { + if node.resolved != nil { + return node.resolved.Redacted() + } return node.url.Redacted() } @@ -53,6 +57,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { if err != nil { return nil, err } + node.resolved = url req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, errors.TaskfileFetchFailedError{URI: node.Location()} @@ -111,7 +116,7 @@ func (node *HTTPNode) ResolveDir(dir string) (string, error) { } func (node *HTTPNode) CacheKey() string { - checksum := strings.TrimRight(checksum([]byte(node.Location())), "=") + checksum := strings.TrimRight(checksum([]byte(node.url.Redacted())), "=") dir, filename := filepath.Split(node.url.Path) lastDir := filepath.Base(dir) prefix := filename diff --git a/taskfile/reader.go b/taskfile/reader.go index 3f36ad62b2..3600a662c9 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -4,16 +4,16 @@ import ( "context" "fmt" "os" + "path/filepath" + "strings" "sync" "time" "github.com/dominikbraun/graph" "golang.org/x/sync/errgroup" - "gopkg.in/yaml.v3" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/env" - "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile/ast" ) @@ -40,6 +40,7 @@ type ( // [ast.TaskfileGraph] from them. Reader struct { graph *ast.TaskfileGraph + loaders map[string]Loader insecure bool download bool offline bool @@ -55,7 +56,13 @@ type ( // options. func NewReader(opts ...ReaderOption) *Reader { r := &Reader{ - graph: ast.NewTaskfileGraph(), + graph: ast.NewTaskfileGraph(), + loaders: map[string]Loader{ + ".yml": YAMLLoader{}, + ".yaml": YAMLLoader{}, + ".hcl": HCLLoader{}, + "": HCLLoader{}, + }, insecure: false, download: false, offline: false, @@ -119,6 +126,24 @@ func (o *offlineOption) ApplyToReader(r *Reader) { r.offline = o.offline } +// WithLoader registers a new [Loader] for the given file extension. If a loader +// already exists for the extension, it will be replaced. +func WithLoader(ext string, loader Loader) ReaderOption { + return &loaderOption{ext: strings.ToLower(ext), loader: loader} +} + +type loaderOption struct { + ext string + loader Loader +} + +func (o *loaderOption) ApplyToReader(r *Reader) { + if r.loaders == nil { + r.loaders = map[string]Loader{} + } + r.loaders[o.ext] = o.loader +} + // WithTempDir sets the temporary directory that will be used by the [Reader]. // By default, the reader uses [os.TempDir]. func WithTempDir(tempDir string) ReaderOption { @@ -207,28 +232,27 @@ func (r *Reader) promptf(format string, a ...any) error { } func (r *Reader) include(ctx context.Context, node Node) error { - // Create a new vertex for the Taskfile + // Read and parse the Taskfile from the file + tf, err := r.readNode(ctx, node) + if err != nil { + return err + } + + // Create a new vertex for the Taskfile using the resolved location vertex := &ast.TaskfileVertex{ URI: node.Location(), - Taskfile: nil, + Taskfile: tf, } - // Add the included Taskfile to the DAG - // If the vertex already exists, we return early since its Taskfile has - // already been read and its children explored + // Add the included Taskfile to the DAG. If the vertex already exists, we + // return early since its Taskfile has already been read and its children + // explored if err := r.graph.AddVertex(vertex); err == graph.ErrVertexAlreadyExists { return nil } else if err != nil { return err } - // Read and parse the Taskfile from the file and add it to the vertex - var err error - vertex.Taskfile, err = r.readNode(ctx, node) - if err != nil { - return err - } - // Create an error group to wait for all included Taskfiles to be read var g errgroup.Group @@ -324,19 +348,24 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) return nil, err } - var tf ast.Taskfile - if err := yaml.Unmarshal(b, &tf); err != nil { - // Decode the taskfile and add the file info the any errors - taskfileDecodeErr := &errors.TaskfileDecodeError{} - if errors.As(err, &taskfileDecodeErr) { - snippet := NewSnippet(b, - WithLine(taskfileDecodeErr.Line), - WithColumn(taskfileDecodeErr.Column), - WithPadding(2), - ) - return nil, taskfileDecodeErr.WithFileInfo(node.Location(), snippet.String()) + ext := strings.ToLower(filepath.Ext(node.Location())) + loader, ok := r.loaders[ext] + if !ok { + // Fallback to YAML loader if no loader is registered for the extension + loader = YAMLLoader{} + } + + tf, err := loader.Load(b, node.Location()) + if err != nil && ext == "" { + if _, isHCL := loader.(HCLLoader); isHCL { + if tf2, err2 := (YAMLLoader{}).Load(b, node.Location()); err2 == nil { + tf = tf2 + err = nil + } } - return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} + } + if err != nil { + return nil, err } // Check that the Taskfile is set and has a schema version @@ -344,20 +373,33 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) return nil, &errors.TaskfileVersionCheckError{URI: node.Location()} } + if tf == nil { + return nil, &errors.TaskfileInvalidError{URI: node.Location(), Err: fmt.Errorf("empty taskfile")} + } // Set the taskfile/task's locations tf.Location = node.Location() - for task := range tf.Tasks.Values(nil) { - // If the task is not defined, create a new one - if task == nil { - task = &ast.Task{} - } - // Set the location of the taskfile for each task - if task.Location.Taskfile == "" { - task.Location.Taskfile = tf.Location + if tf.Tasks != nil { + for task := range tf.Tasks.Values(nil) { + // If the task is not defined, create a new one + if task == nil { + task = &ast.Task{} + } + + // TODO: i am not sure if this is the correct behavior, but it prevents an error for now + if task.Location == nil { + task.Location = &ast.Location{ + Taskfile: tf.Location, + } + } + + // Set the location of the taskfile for each task + if task.Location.Taskfile == "" { + task.Location.Taskfile = tf.Location + } } } - return &tf, nil + return tf, nil } func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) { diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index e209444acc..044744afdd 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -21,6 +21,10 @@ var ( "taskfile.dist.yml", "Taskfile.dist.yaml", "taskfile.dist.yaml", + "Taskfile.hcl", + "taskfile.hcl", + "Taskfile", + "taskfile", } allowedContentTypes = []string{ "text/plain", @@ -43,25 +47,28 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) { return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } - // Request the given URL - resp, err := http.DefaultClient.Do(req) - if err != nil { - if ctx.Err() != nil { - return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) + var resp *http.Response + if !strings.HasSuffix(u.Path, "/") { + // Request the given URL + resp, err = http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) + } + return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } - return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} - } - defer resp.Body.Close() + defer resp.Body.Close() - // If the request was successful and the content type is allowed, return the - // URL The content type check is to avoid downloading files that are not - // Taskfiles It means we can try other files instead of downloading - // something that is definitely not a Taskfile - contentType := resp.Header.Get("Content-Type") - if resp.StatusCode == http.StatusOK && slices.ContainsFunc(allowedContentTypes, func(s string) bool { - return strings.Contains(contentType, s) - }) { - return &u, nil + // If the request was successful and the content type is allowed, return the + // URL. The content type check is to avoid downloading files that are not + // Taskfiles. It means we can try other files instead of downloading + // something that is definitely not a Taskfile. + contentType := resp.Header.Get("Content-Type") + if resp.StatusCode == http.StatusOK && slices.ContainsFunc(allowedContentTypes, func(s string) bool { + return strings.Contains(contentType, s) + }) { + return &u, nil + } } // If the request was not successful, append the default Taskfile names to diff --git a/testdata/HCLE2ETest/Taskfile.hcl b/testdata/HCLE2ETest/Taskfile.hcl new file mode 100644 index 0000000000..6e62a983f6 --- /dev/null +++ b/testdata/HCLE2ETest/Taskfile.hcl @@ -0,0 +1,54 @@ +version = "3" + +vars { + ORIGINAL = "foo" + NAME = "BOB" + GREETING = "Hello, ${vars.NAME}!" + UPPER_GREETING = upper(vars.GREETING) + SUPPORTED_PLATFORMS = [ + "linux", "darwin", "windows", + ] + DOCKER_OPTIONS = { + cache = true + platform = vars.SUPPORTED_PLATFORMS[0] + } + ABC = 123 +} + +env { + EXTENDED = "${env.BASE}-ext" + BASE = "base" + PATH_COPY = env("PATH") +} + +task "build" { + vars { VERSION = sh("echo 1.2.3") } + cmds = ["echo BUILD:${vars.VERSION}", "echo ONE-DONE"] +} + +task "lint" { + cmds = ["echo LINT MODE ${vars.MODE}", "echo TWO-DONE"] +} + +task "scoped" { + vars { + DEF = 456 + GHI = "${vars.ABC}${vars.DEF}" + } + cmds = ["echo SCOPED ${vars.ABC} ${vars.DEF} ${vars.GHI}"] +} + +task "all" { + deps = [ + task("build"), + task("lint", {MODE = "fast"}), + task("scoped"), + ] + cmds = [ + "echo FINAL ${vars.ORIGINAL}", + "echo PATH=${env.PATH_COPY}", + "echo GREET=${vars.UPPER_GREETING}", + "echo EXT=${env.EXTENDED}", + "echo PLATFORM=${vars.SUPPORTED_PLATFORMS[0]}" + ] +} diff --git a/variables.go b/variables.go index 261de59b7e..d9ac1ae56b 100644 --- a/variables.go +++ b/variables.go @@ -14,6 +14,7 @@ import ( "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/fingerprint" + "github.com/go-task/task/v3/internal/hclext" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile/ast" ) @@ -108,9 +109,22 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } new.Env = ast.NewVars() - new.Env.Merge(templater.ReplaceVars(e.Taskfile.Env, cache), nil) - new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil) - new.Env.Merge(templater.ReplaceVars(origTask.Env, cache), nil) + if origTask.IsHCL { + envDefs := env.GetEnviron() + envDefs.Merge(e.Taskfile.Env, nil) + envDefs.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil) + envDefs.Merge(origTask.Env, nil) + resolver := hclext.NewResolver(vars, envDefs, e.callTask) + _, resolvedEnv, err := resolver.Resolve() + if err != nil { + return nil, err + } + new.Env = resolvedEnv + } else { + new.Env.Merge(templater.ReplaceVars(e.Taskfile.Env, cache), nil) + new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil) + new.Env.Merge(templater.ReplaceVars(origTask.Env, cache), nil) + } if evaluateShVars { for k, v := range new.Env.All() { // If the variable is not dynamic, we can set it and return @@ -126,6 +140,10 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } } + runtimeEnv := env.GetEnviron() + runtimeEnv.Merge(new.Env, nil) + hclEval := hclext.NewHCLEvaluator(vars, runtimeEnv, e.callTask) + if len(origTask.Sources) > 0 && origTask.Method != "none" { var checker fingerprint.SourcesCheckable @@ -173,9 +191,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err extra["KEY"] = keys[i] } newCmd := cmd.DeepCopy() - newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) - newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) - newCmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) + if origTask.IsHCL && cmd.Expr != nil { + val, err := hclEval.EvalString(cmd.Expr) + if err != nil { + return nil, err + } + newCmd.Cmd = val + } else { + newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) + newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) + newCmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) + } new.Cmds = append(new.Cmds, newCmd) } continue @@ -187,9 +213,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err continue } newCmd := cmd.DeepCopy() - newCmd.Cmd = templater.Replace(cmd.Cmd, cache) - newCmd.Task = templater.Replace(cmd.Task, cache) - newCmd.Vars = templater.ReplaceVars(cmd.Vars, cache) + if origTask.IsHCL && cmd.Expr != nil { + val, err := hclEval.EvalString(cmd.Expr) + if err != nil { + return nil, err + } + newCmd.Cmd = val + } else { + newCmd.Cmd = templater.Replace(cmd.Cmd, cache) + newCmd.Task = templater.Replace(cmd.Task, cache) + newCmd.Vars = templater.ReplaceVars(cmd.Vars, cache) + } new.Cmds = append(new.Cmds, newCmd) } } @@ -204,31 +238,50 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err if err != nil { return nil, err } - // Name the iterator variable var as string if dep.For.As != "" { as = dep.For.As } else { as = "ITEM" } - // Create a new command for each item in the list for i, loopValue := range list { - extra := map[string]any{ - as: loopValue, - } + extra := map[string]any{as: loopValue} if len(keys) > 0 { extra["KEY"] = keys[i] } newDep := dep.DeepCopy() - newDep.Task = templater.ReplaceWithExtra(dep.Task, cache, extra) - newDep.Vars = templater.ReplaceVarsWithExtra(dep.Vars, cache, extra) + if origTask.IsHCL { + newDep.Task = dep.Task + newDep.Vars = dep.Vars + } else { + newDep.Task = templater.ReplaceWithExtra(dep.Task, cache, extra) + newDep.Vars = templater.ReplaceVarsWithExtra(dep.Vars, cache, extra) + } new.Deps = append(new.Deps, newDep) } continue } newDep := dep.DeepCopy() - newDep.Task = templater.Replace(dep.Task, cache) - newDep.Vars = templater.ReplaceVars(dep.Vars, cache) + if origTask.IsHCL { + newDep.Task = dep.Task + if dep.Vars != nil { + newDep.Vars = ast.NewVars() + for k, v := range dep.Vars.All() { + if v.Expr != nil { + val, err := hclEval.EvalString(v.Expr) + if err != nil { + return nil, err + } + newDep.Vars.Set(k, ast.Var{Value: val}) + } else { + newDep.Vars.Set(k, v) + } + } + } + } else { + newDep.Task = templater.Replace(dep.Task, cache) + newDep.Vars = templater.ReplaceVars(dep.Vars, cache) + } new.Deps = append(new.Deps, newDep) } } diff --git a/website/docs/getting_started.mdx b/website/docs/getting_started.mdx index e79108d26d..605efc8614 100644 --- a/website/docs/getting_started.mdx +++ b/website/docs/getting_started.mdx @@ -48,11 +48,24 @@ tasks: silent: true ``` -As you can see, all Taskfiles are written in [YAML format][yaml]. The `version` -attribute specifies the minimum version of Task that can be used to run this -file. The `vars` attribute is used to define variables that can be used in -tasks. In this case, we are creating a string variable called `GREETING` with a -value of `Hello, World!`. +The example above is written in [YAML format][yaml], but Taskfiles can also be +written in HCL: + +```hcl +version = "3" + +vars = { GREETING = "Hello, World!" } + +task "default" { + cmds = ["echo ${GREETING}"] + silent = true +} +``` + +Both formats are fully supported. The `version` attribute specifies the minimum +version of Task that can be used to run this file. The `vars` attribute is used +to define variables that can be used in tasks. In this case, we are creating a +string variable called `GREETING` with a value of `Hello, World!`. Finally, the `tasks` attribute is used to define the tasks that can be run. In this case, we have a task called `default` that echoes the value of the @@ -71,9 +84,9 @@ task default Note that we don't have to specify the name of the Taskfile. Task will automatically look for a file called `Taskfile.yml` (or any of Task's [supported -file names][supported-file-names]) in the current directory. Additionally, tasks -with the name `default` are special. They can also be run without specifying the -task name. +file names][supported-file-names], such as `Taskfile.hcl` or a plain +`Taskfile`) in the current directory. Additionally, tasks with the name +`default` are special. They can also be run without specifying the task name. If you created a Taskfile in a different directory, you can run it by passing the absolute or relative path to the directory as an argument using the `--dir` diff --git a/website/docs/reference/cli.mdx b/website/docs/reference/cli.mdx index 55ab128f6e..0c1e368261 100644 --- a/website/docs/reference/cli.mdx +++ b/website/docs/reference/cli.mdx @@ -28,9 +28,11 @@ If `--` is given, all remaining arguments will be assigned to a special | `-n` | `--dry` | `bool` | `false` | Compiles and prints tasks in the order that they would be run, without executing them. | | `-x` | `--exit-code` | `bool` | `false` | Pass-through the exit code of the task command. | | `-f` | `--force` | `bool` | `false` | Forces execution even when the task is up-to-date. | -| `-g` | `--global` | `bool` | `false` | Runs global Taskfile, from `$HOME/Taskfile.{yml,yaml}`. | -| `-h` | `--help` | `bool` | `false` | Shows Task usage. | -| `-i` | `--init` | `bool` | `false` | Creates a new Taskfile.yml in the current folder. | +| `-g` | `--global` | `bool` | `false` | Runs global Taskfile, from `$HOME/{T,t}askfile.{yml,yaml,hcl}` or `$HOME/{T,t}askfile`. + | +| `-h` | `--help` | `bool` | `false` | Shows Task usage. +| `-i` | `--init` | `bool` | `false` | Creates a new `Taskfile.yml` in the current folder. + | | `-I` | `--interval` | `string` | `5s` | Sets a different watch interval when using `--watch`, the default being 5 seconds. This string should be a valid [Go Duration](https://pkg.go.dev/time#ParseDuration). | | `-l` | `--list` | `bool` | `false` | Lists tasks with description of current Taskfile. | | `-a` | `--list-all` | `bool` | `false` | Lists tasks with or without a description. | diff --git a/website/docs/reference/package.mdx b/website/docs/reference/package.mdx index 4777f5df68..fd193bc5bb 100644 --- a/website/docs/reference/package.mdx +++ b/website/docs/reference/package.mdx @@ -65,7 +65,7 @@ strings (via stdin). AST stands for ["Abstract Syntax Tree"][ast]. An AST allows us to easily represent the Taskfile syntax in Go. This package provides a way to parse -Taskfile YAML into an AST and store them in memory. +Taskfile configuration (YAML or HCL) into an AST and store them in memory. - [`ast.TaskfileGraph`] - Represents a set of Taskfiles and their dependencies between one another. diff --git a/website/docs/styleguide.mdx b/website/docs/styleguide.mdx index 1213e4a09f..aef2babf65 100644 --- a/website/docs/styleguide.mdx +++ b/website/docs/styleguide.mdx @@ -5,8 +5,10 @@ sidebar_position: 11 # Style Guide -This is the official style guide for `Taskfile.yml` files. It provides basic -instructions for keeping your Taskfiles clean and familiar to other users. +This is the official style guide for `Taskfile.yml` files. Task also supports +Taskfiles written in HCL, but this guide focuses on the YAML format. It +provides basic instructions for keeping your Taskfiles clean and familiar to +other users. This guide contains general guidelines, but they do not necessarily need to be followed strictly. Feel free to disagree and do things differently if you need diff --git a/website/docs/usage.mdx b/website/docs/usage.mdx index 9590cf4f25..b0aa326b6b 100644 --- a/website/docs/usage.mdx +++ b/website/docs/usage.mdx @@ -27,10 +27,16 @@ Task looks for files with the following names, in order of priority: - `taskfile.dist.yml` - `Taskfile.dist.yaml` - `taskfile.dist.yaml` +- `Taskfile.hcl` +- `taskfile.hcl` +- `Taskfile` +- `taskfile` The `.dist` variants allow projects to have one committed file (`.dist`) while still allowing individual users to override the Taskfile by adding an additional -`Taskfile.yml` (which would be in your `.gitignore`). +`Taskfile.yml` (which would be in your `.gitignore`). YAML files are checked +before HCL or extensionless files. If both YAML and HCL Taskfiles are present, +the YAML file will be used. ### Running a Taskfile from a subdirectory @@ -65,7 +71,8 @@ will be brought up. If you call Task with the `--global` (alias `-g`) flag, it will look for your home directory instead of your working directory. In short, Task will look for a -Taskfile that matches `$HOME/{T,t}askfile.{yml,yaml}` . +Taskfile that matches `$HOME/{T,t}askfile.{yml,yaml,hcl}` or +`$HOME/{T,t}askfile`. This is useful to have automation that you can run from anywhere in your system! @@ -97,14 +104,19 @@ tasks: ### Reading a Taskfile from stdin Taskfile also supports reading from stdin. This is useful if you are generating -Taskfiles dynamically and don't want write them to disk. To tell task to read +Taskfiles dynamically and don't want write them to disk. To tell Task to read from stdin, you must specify the `-t/--taskfile` flag with the special `-` -value. You may then pipe into Task as you would any other program: +value. You may then pipe into Task as you would any other program. Both YAML and +HCL content are supported and Task will attempt to detect the format +automatically: ```shell task -t - <(cat ./Taskfile.yml) # OR cat ./Taskfile.yml | task -t - + +# HCL content works too +cat ./Taskfile.hcl | task -t - ``` ## Environment variables diff --git a/website/versioned_docs/version-latest/getting_started.mdx b/website/versioned_docs/version-latest/getting_started.mdx index e79108d26d..605efc8614 100644 --- a/website/versioned_docs/version-latest/getting_started.mdx +++ b/website/versioned_docs/version-latest/getting_started.mdx @@ -48,11 +48,24 @@ tasks: silent: true ``` -As you can see, all Taskfiles are written in [YAML format][yaml]. The `version` -attribute specifies the minimum version of Task that can be used to run this -file. The `vars` attribute is used to define variables that can be used in -tasks. In this case, we are creating a string variable called `GREETING` with a -value of `Hello, World!`. +The example above is written in [YAML format][yaml], but Taskfiles can also be +written in HCL: + +```hcl +version = "3" + +vars = { GREETING = "Hello, World!" } + +task "default" { + cmds = ["echo ${GREETING}"] + silent = true +} +``` + +Both formats are fully supported. The `version` attribute specifies the minimum +version of Task that can be used to run this file. The `vars` attribute is used +to define variables that can be used in tasks. In this case, we are creating a +string variable called `GREETING` with a value of `Hello, World!`. Finally, the `tasks` attribute is used to define the tasks that can be run. In this case, we have a task called `default` that echoes the value of the @@ -71,9 +84,9 @@ task default Note that we don't have to specify the name of the Taskfile. Task will automatically look for a file called `Taskfile.yml` (or any of Task's [supported -file names][supported-file-names]) in the current directory. Additionally, tasks -with the name `default` are special. They can also be run without specifying the -task name. +file names][supported-file-names], such as `Taskfile.hcl` or a plain +`Taskfile`) in the current directory. Additionally, tasks with the name +`default` are special. They can also be run without specifying the task name. If you created a Taskfile in a different directory, you can run it by passing the absolute or relative path to the directory as an argument using the `--dir` diff --git a/website/versioned_docs/version-latest/reference/cli.mdx b/website/versioned_docs/version-latest/reference/cli.mdx index 55ab128f6e..b0b3dacbd6 100644 --- a/website/versioned_docs/version-latest/reference/cli.mdx +++ b/website/versioned_docs/version-latest/reference/cli.mdx @@ -28,9 +28,10 @@ If `--` is given, all remaining arguments will be assigned to a special | `-n` | `--dry` | `bool` | `false` | Compiles and prints tasks in the order that they would be run, without executing them. | | `-x` | `--exit-code` | `bool` | `false` | Pass-through the exit code of the task command. | | `-f` | `--force` | `bool` | `false` | Forces execution even when the task is up-to-date. | -| `-g` | `--global` | `bool` | `false` | Runs global Taskfile, from `$HOME/Taskfile.{yml,yaml}`. | -| `-h` | `--help` | `bool` | `false` | Shows Task usage. | -| `-i` | `--init` | `bool` | `false` | Creates a new Taskfile.yml in the current folder. | +| `-g` | `--global` | `bool` | `false` | Runs global Taskfile, from `$HOME/{T,t}askfile.{yml,yaml,hcl}` or `$HOME/{T,t}askfile`. +| +| `-i` | `--init` | `bool` | `false` | Creates a new `Taskfile.yml` in the current folder. + | | `-I` | `--interval` | `string` | `5s` | Sets a different watch interval when using `--watch`, the default being 5 seconds. This string should be a valid [Go Duration](https://pkg.go.dev/time#ParseDuration). | | `-l` | `--list` | `bool` | `false` | Lists tasks with description of current Taskfile. | | `-a` | `--list-all` | `bool` | `false` | Lists tasks with or without a description. | diff --git a/website/versioned_docs/version-latest/reference/package.mdx b/website/versioned_docs/version-latest/reference/package.mdx index 4777f5df68..fd193bc5bb 100644 --- a/website/versioned_docs/version-latest/reference/package.mdx +++ b/website/versioned_docs/version-latest/reference/package.mdx @@ -65,7 +65,7 @@ strings (via stdin). AST stands for ["Abstract Syntax Tree"][ast]. An AST allows us to easily represent the Taskfile syntax in Go. This package provides a way to parse -Taskfile YAML into an AST and store them in memory. +Taskfile configuration (YAML or HCL) into an AST and store them in memory. - [`ast.TaskfileGraph`] - Represents a set of Taskfiles and their dependencies between one another. diff --git a/website/versioned_docs/version-latest/styleguide.mdx b/website/versioned_docs/version-latest/styleguide.mdx index 1213e4a09f..aef2babf65 100644 --- a/website/versioned_docs/version-latest/styleguide.mdx +++ b/website/versioned_docs/version-latest/styleguide.mdx @@ -5,8 +5,10 @@ sidebar_position: 11 # Style Guide -This is the official style guide for `Taskfile.yml` files. It provides basic -instructions for keeping your Taskfiles clean and familiar to other users. +This is the official style guide for `Taskfile.yml` files. Task also supports +Taskfiles written in HCL, but this guide focuses on the YAML format. It +provides basic instructions for keeping your Taskfiles clean and familiar to +other users. This guide contains general guidelines, but they do not necessarily need to be followed strictly. Feel free to disagree and do things differently if you need diff --git a/website/versioned_docs/version-latest/usage.mdx b/website/versioned_docs/version-latest/usage.mdx index 9590cf4f25..b0aa326b6b 100644 --- a/website/versioned_docs/version-latest/usage.mdx +++ b/website/versioned_docs/version-latest/usage.mdx @@ -27,10 +27,16 @@ Task looks for files with the following names, in order of priority: - `taskfile.dist.yml` - `Taskfile.dist.yaml` - `taskfile.dist.yaml` +- `Taskfile.hcl` +- `taskfile.hcl` +- `Taskfile` +- `taskfile` The `.dist` variants allow projects to have one committed file (`.dist`) while still allowing individual users to override the Taskfile by adding an additional -`Taskfile.yml` (which would be in your `.gitignore`). +`Taskfile.yml` (which would be in your `.gitignore`). YAML files are checked +before HCL or extensionless files. If both YAML and HCL Taskfiles are present, +the YAML file will be used. ### Running a Taskfile from a subdirectory @@ -65,7 +71,8 @@ will be brought up. If you call Task with the `--global` (alias `-g`) flag, it will look for your home directory instead of your working directory. In short, Task will look for a -Taskfile that matches `$HOME/{T,t}askfile.{yml,yaml}` . +Taskfile that matches `$HOME/{T,t}askfile.{yml,yaml,hcl}` or +`$HOME/{T,t}askfile`. This is useful to have automation that you can run from anywhere in your system! @@ -97,14 +104,19 @@ tasks: ### Reading a Taskfile from stdin Taskfile also supports reading from stdin. This is useful if you are generating -Taskfiles dynamically and don't want write them to disk. To tell task to read +Taskfiles dynamically and don't want write them to disk. To tell Task to read from stdin, you must specify the `-t/--taskfile` flag with the special `-` -value. You may then pipe into Task as you would any other program: +value. You may then pipe into Task as you would any other program. Both YAML and +HCL content are supported and Task will attempt to detect the format +automatically: ```shell task -t - <(cat ./Taskfile.yml) # OR cat ./Taskfile.yml | task -t - + +# HCL content works too +cat ./Taskfile.hcl | task -t - ``` ## Environment variables